├── assets └── banner.png ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ ├── pr-verification.yml │ └── daily-tests.yml ├── .gitignore ├── fdroid ├── api │ ├── src │ │ ├── commonMain │ │ │ └── kotlin │ │ │ │ └── io │ │ │ │ └── github │ │ │ │ └── kdroidfilter │ │ │ │ └── storekit │ │ │ │ └── fdroid │ │ │ │ └── api │ │ │ │ ├── constants │ │ │ │ └── ApiConstants.kt │ │ │ │ └── services │ │ │ │ └── FDroidService.kt │ │ └── commonTest │ │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kdroidfilter │ │ │ └── storekit │ │ │ └── fdroid │ │ │ └── api │ │ │ └── services │ │ │ └── FDroidServiceTest.kt │ └── build.gradle.kts └── core │ ├── src │ ├── commonMain │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kdroidfilter │ │ │ └── storekit │ │ │ └── fdroid │ │ │ └── core │ │ │ └── model │ │ │ └── FDroidPackageInfo.kt │ └── commonTest │ │ └── kotlin │ │ └── io │ │ └── github │ │ └── kdroidfilter │ │ └── storekit │ │ └── fdroid │ │ └── core │ │ └── model │ │ └── FDroidPackageInfoTest.kt │ └── build.gradle.kts ├── aptoide ├── api │ ├── src │ │ ├── commonMain │ │ │ └── kotlin │ │ │ │ └── io │ │ │ │ └── github │ │ │ │ └── kdroidfilter │ │ │ │ └── storekit │ │ │ │ └── aptoide │ │ │ │ └── api │ │ │ │ ├── constants │ │ │ │ └── ApiConstants.kt │ │ │ │ ├── extensions │ │ │ │ └── SignatureExtensions.kt │ │ │ │ └── services │ │ │ │ └── AptoideService.kt │ │ └── commonTest │ │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kdroidfilter │ │ │ └── storekit │ │ │ └── aptoide │ │ │ └── api │ │ │ ├── extensions │ │ │ └── SignatureExtensionsTest.kt │ │ │ └── services │ │ │ └── AptoideServiceTest.kt │ └── build.gradle.kts └── core │ └── build.gradle.kts ├── gplay ├── scrapper │ ├── src │ │ └── commonMain │ │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kdroidfilter │ │ │ └── storekit │ │ │ └── gplay │ │ │ └── scrapper │ │ │ ├── constants │ │ │ ├── ApiConstants.kt │ │ │ └── RegexConstants.kt │ │ │ ├── utils │ │ │ ├── HtmlDecoder.kt │ │ │ ├── DataSetParser.kt │ │ │ ├── JsonExtensions.kt │ │ │ └── NetworkUtils.kt │ │ │ └── services │ │ │ └── PlayStoreScraperService.kt │ └── build.gradle.kts └── core │ ├── build.gradle.kts │ └── src │ └── commonMain │ └── kotlin │ └── io │ └── github │ └── kdroidfilter │ └── storekit │ └── gplay │ └── core │ └── model │ └── GooglePlayApplicationInfo.kt ├── gradle.properties ├── apkcombo ├── scraper │ ├── src │ │ ├── commonMain │ │ │ └── kotlin │ │ │ │ └── io │ │ │ │ └── github │ │ │ │ └── kdroidfilter │ │ │ │ └── storekit │ │ │ │ └── apkcombo │ │ │ │ └── scraper │ │ │ │ ├── constants │ │ │ │ └── ApiConstants.kt │ │ │ │ ├── utils │ │ │ │ └── NetworkUtils.kt │ │ │ │ └── services │ │ │ │ └── ApkComboScraperService.kt │ │ └── commonTest │ │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kdroidfilter │ │ │ └── storekit │ │ │ └── apkcombo │ │ │ └── scraper │ │ │ ├── utils │ │ │ └── NetworkUtilsTest.kt │ │ │ └── services │ │ │ └── ApkComboScraperServiceTest.kt │ └── build.gradle.kts └── core │ ├── src │ └── commonMain │ │ └── kotlin │ │ └── io │ │ └── github │ │ └── kdroidfilter │ │ └── storekit │ │ └── apkcombo │ │ └── core │ │ └── model │ │ └── ApkComboApplicationInfo.kt │ └── build.gradle.kts ├── apkpure ├── core │ ├── src │ │ └── commonMain │ │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kdroidfilter │ │ │ └── storekit │ │ │ └── apkpure │ │ │ └── core │ │ │ └── model │ │ │ └── ApkPureApplicationInfo.kt │ └── build.gradle.kts └── scraper │ ├── src │ ├── commonTest │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kdroidfilter │ │ │ └── storekit │ │ │ └── apkpure │ │ │ └── scraper │ │ │ └── services │ │ │ └── ApkPureSignatureAndVersionCodeTest.kt │ └── commonMain │ │ └── kotlin │ │ └── io │ │ └── github │ │ └── kdroidfilter │ │ └── storekit │ │ └── apkpure │ │ └── scraper │ │ └── services │ │ └── ApkPureScraperService.kt │ └── build.gradle.kts ├── authenticity ├── src │ ├── androidMain │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kdroidfilter │ │ │ └── storekit │ │ │ └── authenticity │ │ │ ├── SignatureExtractor.kt │ │ │ └── InstallationSourceDetector.kt │ └── androidTest │ │ └── kotlin │ │ └── io │ │ └── github │ │ └── kdroidfilter │ │ └── storekit │ │ └── authenticity │ │ ├── InstallationSourceDetectorTest.kt │ │ └── SignatureExtractorTest.kt └── build.gradle.kts ├── apklinkresolver └── core │ ├── src │ ├── commonMain │ │ └── kotlin │ │ │ └── io │ │ │ └── github │ │ │ └── kdroidfilter │ │ │ └── storekit │ │ │ └── apklinkresolver │ │ │ └── core │ │ │ ├── model │ │ │ └── ApkLinkInfo.kt │ │ │ ├── service │ │ │ ├── ApkSourcePriority.kt │ │ │ └── ApkLinkResolverService.kt │ │ │ └── utils │ │ │ └── FileUtils.kt │ └── commonTest │ │ └── kotlin │ │ └── io │ │ └── github │ │ └── kdroidfilter │ │ └── storekit │ │ └── apklinkresolver │ │ └── core │ │ └── utils │ │ └── FileUtilsTest.kt │ └── build.gradle.kts ├── LICENSE ├── sample ├── build.gradle.kts └── src │ └── jvmMain │ └── kotlin │ └── io │ └── github │ └── kdroidfilter │ └── storekit │ └── sample │ └── Main.kt ├── settings.gradle.kts ├── gradlew.bat └── gradlew /assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kdroidFilter/StoreKit/HEAD/assets/banner.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kdroidFilter/StoreKit/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gradle" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.iml 3 | .gradle 4 | .idea 5 | .kotlin 6 | .DS_Store 7 | build 8 | */build 9 | captures 10 | .externalNativeBuild 11 | .cxx 12 | local.properties 13 | xcuserdata/ 14 | Pods/ 15 | *.jks 16 | *yarn.lock 17 | -------------------------------------------------------------------------------- /fdroid/api/src/commonMain/kotlin/io/github/kdroidfilter/storekit/fdroid/api/constants/ApiConstants.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.fdroid.api.constants 2 | 3 | internal const val BASE_FDROID_API_URL = "https://f-droid.org/api/v1" 4 | internal const val PACKAGES_PATH = "/packages" -------------------------------------------------------------------------------- /aptoide/api/src/commonMain/kotlin/io/github/kdroidfilter/storekit/aptoide/api/constants/ApiConstants.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.aptoide.api.constants 2 | 3 | internal const val BASE_APTOIDE_API_URL = "https://ws2.aptoide.com/api/7" 4 | internal const val APP_GET_META_PATH = "/app/getMeta" -------------------------------------------------------------------------------- /gplay/scrapper/src/commonMain/kotlin/io/github/kdroidfilter/storekit/gplay/scrapper/constants/ApiConstants.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.gplay.scrapper.constants 2 | 3 | internal const val BASE_PLAY_STORE_URL = "https://play.google.com" 4 | internal const val DETAIL_PATH = "/store/apps/details" -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gplay/scrapper/src/commonMain/kotlin/io/github/kdroidfilter/storekit/gplay/scrapper/constants/RegexConstants.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.gplay.scrapper.constants 2 | 3 | // Regex for datasets 4 | internal val keyRegex = Regex("(ds:\\d+)") 5 | internal val valueRegex = Regex("data:([\\s\\S]*?),\\s*sideChannel:") -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Gradle 2 | org.gradle.jvmargs=-Xmx4G 3 | org.gradle.caching=true 4 | org.gradle.configuration-cache=true 5 | org.gradle.daemon=true 6 | org.gradle.parallel=true 7 | 8 | #Kotlin 9 | kotlin.code.style=official 10 | kotlin.daemon.jvmargs=-Xmx4G 11 | 12 | #Android 13 | android.useAndroidX=true 14 | android.nonTransitiveRClass=true 15 | -------------------------------------------------------------------------------- /apkcombo/scraper/src/commonMain/kotlin/io/github/kdroidfilter/storekit/apkcombo/scraper/constants/ApiConstants.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apkcombo.scraper.constants 2 | 3 | /** 4 | * Base URL for the APKCombo website 5 | */ 6 | const val BASE_APKCOMBO_URL = "https://apkcombo.com" 7 | 8 | /** 9 | * Path for app details on APKCombo 10 | */ 11 | const val APP_PATH = "/app" 12 | 13 | /** 14 | * Path for app download on APKCombo 15 | */ 16 | const val DOWNLOAD_PATH = "/download/apk" -------------------------------------------------------------------------------- /apkpure/core/src/commonMain/kotlin/io/github/kdroidfilter/storekit/apkpure/core/model/ApkPureApplicationInfo.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apkpure.core.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | /** 6 | * Represents basic information about an application on the APKPure website. 7 | */ 8 | @Serializable 9 | data class ApkPureApplicationInfo( 10 | val title: String = "", 11 | val version: String = "", 12 | val versionCode: String = "", 13 | val signature: String = "", 14 | val downloadLink: String = "", 15 | val appId: String = "", 16 | val url: String = "" 17 | ) 18 | -------------------------------------------------------------------------------- /authenticity/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /aptoide/api/src/commonMain/kotlin/io/github/kdroidfilter/storekit/aptoide/api/extensions/SignatureExtensions.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.aptoide.api.extensions 2 | 3 | import io.github.kdroidfilter.storekit.aptoide.core.model.AptoideSignature 4 | 5 | /** 6 | * Extension functions for [AptoideSignature] class. 7 | */ 8 | 9 | /** 10 | * Converts the SHA1 signature format from "XX:XX:XX..." to a continuous lowercase hex string. 11 | * 12 | * Example: "35:B4:38:FE:1B:C6:9D:97:5D:C8:70:2D:C1:6A:B6:9E:BF:65:F2:6F" becomes "35b438fe1bc69d975dc8702dc16ab69ebf65f26f" 13 | * 14 | * @return The SHA1 signature as a continuous lowercase hex string. 15 | */ 16 | fun AptoideSignature.toFormattedSha1(): String { 17 | return sha1.replace(":", "").lowercase() 18 | } -------------------------------------------------------------------------------- /apkcombo/core/src/commonMain/kotlin/io/github/kdroidfilter/storekit/apkcombo/core/model/ApkComboApplicationInfo.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apkcombo.core.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | /** 6 | * Represents detailed information about an application on the APKCombo website. 7 | * 8 | * @property title The title/name of the application 9 | * @property version Current version of the application 10 | * @property versionCode Version code of the application 11 | * @property downloadLink Direct download link for the APK 12 | * @property appId Unique identifier of the application 13 | * @property url URL to the application's page on APKCombo 14 | */ 15 | @Serializable 16 | data class ApkComboApplicationInfo( 17 | val title: String = "", 18 | val version: String = "", 19 | val versionCode: String = "", 20 | val downloadLink: String = "", 21 | val appId: String = "", 22 | val url: String = "" 23 | ) -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Maven Central 2 | 3 | on: 4 | push: 5 | tags: 6 | - '**' 7 | 8 | jobs: 9 | publish: 10 | runs-on: macos-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up JDK 17 | uses: actions/setup-java@v3 18 | with: 19 | java-version: '17' 20 | distribution: 'temurin' 21 | 22 | - name: Set up Publish to Maven Central 23 | run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache 24 | env: 25 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVENCENTRALUSERNAME }} 26 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVENCENTRALPASSWORD }} 27 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNINGINMEMORYKEY }} 28 | ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNINGKEYID }} 29 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNINGPASSWORD }} 30 | 31 | -------------------------------------------------------------------------------- /apklinkresolver/core/src/commonMain/kotlin/io/github/kdroidfilter/storekit/apklinkresolver/core/model/ApkLinkInfo.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apklinkresolver.core.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | /** 6 | * Represents information about an APK download. 7 | * 8 | * @property packageName The package name of the application 9 | * @property downloadLink The direct download link for the APK 10 | * @property source The source of the download link (e.g., "apkcombo", "aptoide") 11 | * @property version The version of the application (if available) 12 | * @property versionCode The version code of the application (if available) 13 | * @property title The title/name of the application (if available) 14 | * @property fileSize The size of the APK file in bytes (if available) 15 | */ 16 | @Serializable 17 | data class ApkLinkInfo( 18 | val packageName: String, 19 | val downloadLink: String, 20 | val source: String, 21 | val version: String = "", 22 | val versionCode: String = "", 23 | val title: String = "", 24 | val fileSize: Long = -1 25 | ) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Elie G. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /gplay/scrapper/src/commonMain/kotlin/io/github/kdroidfilter/storekit/gplay/scrapper/utils/HtmlDecoder.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.gplay.scrapper.utils 2 | 3 | /** 4 | * Provides utility functions for decoding HTML entities commonly encountered in HTML content. 5 | * This object focuses on converting encoded HTML entities back to their literal character equivalents 6 | * in a given string, allowing for easier text processing and display. 7 | * 8 | * The primary focus is on handling a fixed set of HTML entities, including: 9 | * - & for & 10 | * - > for > 11 | * - < for < 12 | * - " for " 13 | * - ' for ' 14 | */ 15 | internal object HtmlDecoder { 16 | 17 | fun decodeHtml(s: String): String { 18 | return s 19 | .replace("&", "&") 20 | .replace(">", ">") 21 | .replace("<", "<") 22 | .replace(""", "\"") 23 | .replace("'", "'") 24 | } 25 | 26 | // Simple unescape HTML function 27 | fun unescapeHtml(s: String?): String { 28 | return s?.replace("
", "\n")?.let { decodeHtml(it) } ?: "" 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /.github/workflows/pr-verification.yml: -------------------------------------------------------------------------------- 1 | name: PR Verification 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build-and-test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | 15 | - name: Set up JDK 16 | uses: actions/setup-java@v3 17 | with: 18 | java-version: '17' 19 | distribution: 'temurin' 20 | 21 | - name: Build project 22 | run: ./gradlew build --no-daemon 23 | 24 | - name: Run tests 25 | run: | 26 | ./gradlew apkcombo:core:allTests \ 27 | aptoide:api:allTests \ 28 | aptoide:core:allTests \ 29 | authenticity:allTests \ 30 | fdroid:api:allTests \ 31 | fdroid:core:allTests \ 32 | gplay:core:allTests \ 33 | gplay:scrapper:allTests 34 | 35 | - name: Upload test reports 36 | if: always() 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: test-reports 40 | path: | 41 | **/build/reports/tests/ 42 | **/build/reports/allTests/ -------------------------------------------------------------------------------- /.github/workflows/daily-tests.yml: -------------------------------------------------------------------------------- 1 | name: Daily Tests 2 | 3 | on: 4 | schedule: 5 | # Run at 00:00 UTC every day 6 | - cron: '0 0 * * *' 7 | # Allow manual triggering of the workflow 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up JDK 19 | uses: actions/setup-java@v3 20 | with: 21 | java-version: '17' 22 | distribution: 'temurin' 23 | 24 | - name: Run tests 25 | run: | 26 | ./gradlew apkcombo:core:allTests \ 27 | aptoide:api:allTests \ 28 | aptoide:core:allTests \ 29 | authenticity:allTests \ 30 | fdroid:api:allTests \ 31 | fdroid:core:allTests \ 32 | gplay:core:allTests \ 33 | gplay:scrapper:allTests \ 34 | 35 | - name: Upload test reports 36 | if: always() 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: test-reports 40 | path: | 41 | **/build/reports/tests/ 42 | **/build/reports/allTests/ 43 | 44 | -------------------------------------------------------------------------------- /sample/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.multiplatform) 3 | alias(libs.plugins.kotlinx.serialization) 4 | } 5 | 6 | group = "io.github.kdroidfilter.storekit.sample" 7 | version = "1.0-SNAPSHOT" 8 | 9 | kotlin { 10 | jvmToolchain(17) 11 | jvm { 12 | // Configure JVM target 13 | mainRun { 14 | mainClass.set("io.github.kdroidfilter.storekit.sample.MainKt") 15 | } 16 | } 17 | 18 | sourceSets { 19 | jvmMain.dependencies { 20 | implementation(project(":gplay:scrapper")) 21 | implementation(project(":aptoide:api")) 22 | implementation(project(":fdroid:api")) 23 | implementation(project(":apkcombo:scraper")) 24 | implementation(project(":apkpure:scraper")) 25 | implementation(project(":apklinkresolver:core")) 26 | implementation(libs.kotlinx.serialization.json) 27 | implementation(libs.kotlinx.coroutines.swing) 28 | implementation(libs.ktor.client.core) 29 | implementation(libs.ktor.client.content.negotiation) 30 | implementation(libs.ktor.client.serialization) 31 | implementation(libs.ktor.client.logging) 32 | implementation(libs.ktor.client.cio) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.gradle.internal.impldep.org.junit.experimental.categories.Categories.CategoryFilter.include 2 | 3 | rootProject.name = "StoreKit" 4 | 5 | pluginManagement { 6 | repositories { 7 | google { 8 | content { 9 | includeGroupByRegex("com\\.android.*") 10 | includeGroupByRegex("com\\.google.*") 11 | includeGroupByRegex("androidx.*") 12 | includeGroupByRegex("android.*") 13 | } 14 | } 15 | gradlePluginPortal() 16 | mavenCentral() 17 | } 18 | } 19 | 20 | dependencyResolutionManagement { 21 | repositories { 22 | google { 23 | content { 24 | includeGroupByRegex("com\\.android.*") 25 | includeGroupByRegex("com\\.google.*") 26 | includeGroupByRegex("androidx.*") 27 | includeGroupByRegex("android.*") 28 | } 29 | } 30 | mavenCentral() 31 | } 32 | } 33 | include(":gplay:scrapper") 34 | include(":gplay:core") 35 | include(":aptoide:core") 36 | include(":aptoide:api") 37 | include(":fdroid:core") 38 | include(":fdroid:api") 39 | include(":apkpure:core") 40 | include(":apkpure:scraper") 41 | include(":apkcombo:core") 42 | include(":apkcombo:scraper") 43 | include(":apklinkresolver:core") 44 | include(":authenticity") 45 | include(":sample") 46 | -------------------------------------------------------------------------------- /gplay/scrapper/src/commonMain/kotlin/io/github/kdroidfilter/storekit/gplay/scrapper/utils/DataSetParser.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.gplay.scrapper.utils 2 | 3 | import io.github.kdroidfilter.storekit.gplay.scrapper.constants.keyRegex 4 | import io.github.kdroidfilter.storekit.gplay.scrapper.constants.valueRegex 5 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.JsonExtensions.jsonParser 6 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.NetworkUtils.logger 7 | import kotlinx.serialization.json.JsonElement 8 | 9 | /** 10 | * Parses datasets from a list of script strings by extracting keys and corresponding JSON elements. 11 | * 12 | * @param scripts A list of strings, each representing a script containing potential JSON data. 13 | * @return A map where the keys are extracted keys from the scripts, and the values are the parsed JSON elements. 14 | */ 15 | // Parse datasets from extracted scripts 16 | internal fun parseDataSetsFromScripts(scripts: List): Map { 17 | return scripts.mapNotNull { script -> 18 | val keyMatch = keyRegex.find(script) 19 | val valueMatch = valueRegex.find(script) 20 | 21 | if (keyMatch != null && valueMatch != null) { 22 | val key = keyMatch.groupValues[1] 23 | val jsonStr = valueMatch.groupValues[1] 24 | try { 25 | val jsonElement = jsonParser.parseToJsonElement(jsonStr) 26 | key to jsonElement 27 | } catch (e: Exception) { 28 | logger.error(e) { "Failed to parse JSON" } 29 | null 30 | } 31 | } else null 32 | }.toMap() 33 | } -------------------------------------------------------------------------------- /apkpure/scraper/src/commonTest/kotlin/io/github/kdroidfilter/storekit/apkpure/scraper/services/ApkPureSignatureAndVersionCodeTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apkpure.scraper.services 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | 6 | class ApkPureSignatureAndVersionCodeTest { 7 | 8 | @Test 9 | fun `extractSignature from More App Info section`() { 10 | val html = SAMPLE_SIGNATURE_HTML 11 | val sig = extractSignature(html) 12 | assertEquals("35b438fe1bc69d975dc8702dc16ab69ebf65f26f", sig) 13 | } 14 | 15 | @Test 16 | fun `extractVersionCode fallback from variant block`() { 17 | val html = SAMPLE_VARIANT_HTML 18 | val code = extractVersionCodeFallback(html) 19 | assertEquals("1030640", code) 20 | } 21 | } 22 | 23 | private const val SAMPLE_SIGNATURE_HTML = """ 24 |
25 |
    26 |
  • 27 | 28 |
    29 |
    Signature
    30 |
    35b438fe1bc69d975dc8702dc16ab69ebf65f26f
    31 |
    32 |
  • 33 |
34 |
35 | """ 36 | 37 | private const val SAMPLE_VARIANT_HTML = """ 38 |
39 |
40 | 5.11.0.0 41 | (1030640) 42 | XAPK 43 |
44 | 45 | Download 46 | 47 |
48 | """ 49 | -------------------------------------------------------------------------------- /aptoide/api/src/commonTest/kotlin/io/github/kdroidfilter/storekit/aptoide/api/extensions/SignatureExtensionsTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.aptoide.api.extensions 2 | 3 | import io.github.kdroidfilter.storekit.aptoide.core.model.AptoideSignature 4 | import kotlin.test.Test 5 | import kotlin.test.assertEquals 6 | 7 | class SignatureExtensionsTest { 8 | 9 | @Test 10 | fun testToFormattedSha1() { 11 | // Given 12 | val signature = AptoideSignature( 13 | sha1 = "35:B4:38:FE:1B:C6:9D:97:5D:C8:70:2D:C1:6A:B6:9E:BF:65:F2:6F", 14 | owner = "Test Owner" 15 | ) 16 | 17 | // When 18 | val formattedSha1 = signature.toFormattedSha1() 19 | 20 | // Then 21 | assertEquals("35b438fe1bc69d975dc8702dc16ab69ebf65f26f", formattedSha1) 22 | } 23 | 24 | @Test 25 | fun testToFormattedSha1WithEmptySha1() { 26 | // Given 27 | val signature = AptoideSignature( 28 | sha1 = "", 29 | owner = "Test Owner" 30 | ) 31 | 32 | // When 33 | val formattedSha1 = signature.toFormattedSha1() 34 | 35 | // Then 36 | assertEquals("", formattedSha1) 37 | } 38 | 39 | @Test 40 | fun testToFormattedSha1WithoutColons() { 41 | // Given 42 | val signature = AptoideSignature( 43 | sha1 = "35B438FE1BC69D975DC8702DC16AB69EBF65F26F", 44 | owner = "Test Owner" 45 | ) 46 | 47 | // When 48 | val formattedSha1 = signature.toFormattedSha1() 49 | 50 | // Then 51 | assertEquals("35b438fe1bc69d975dc8702dc16ab69ebf65f26f", formattedSha1) 52 | } 53 | 54 | @Test 55 | fun testSpecificSha1Value() { 56 | // Given 57 | val signature = AptoideSignature( 58 | sha1 = "38:91:8A:45:3D:07:19:93:54:F8:B1:9A:F0:5E:C6:56:2C:ED:57:88", 59 | owner = "Aptoide" 60 | ) 61 | 62 | // When 63 | val formattedSha1 = signature.toFormattedSha1() 64 | 65 | // Then 66 | assertEquals("38918a453d07199354f8b19af05ec6562ced5788", formattedSha1) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /aptoide/api/src/commonTest/kotlin/io/github/kdroidfilter/storekit/aptoide/api/services/AptoideServiceTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.aptoide.api.services 2 | 3 | import io.github.kdroidfilter.storekit.aptoide.api.extensions.toFormattedSha1 4 | import io.github.kdroidfilter.storekit.aptoide.core.model.AptoideApplicationInfo 5 | import kotlinx.coroutines.runBlocking 6 | import kotlin.test.Test 7 | import kotlin.test.assertEquals 8 | import kotlin.test.assertNotNull 9 | 10 | class AptoideServiceTest { 11 | 12 | @Test 13 | fun testRetrieveAndVerifySignature() = runBlocking { 14 | // Given 15 | val aptoideService = AptoideService() 16 | val packageName = "com.android.chrome" // Using Chrome as an example 17 | val expectedSignature = "38918a453d07199354f8b19af05ec6562ced5788" 18 | 19 | try { 20 | // When 21 | val appInfo: AptoideApplicationInfo = aptoideService.getAppMetaByPackageName(packageName) 22 | 23 | // Then 24 | assertNotNull(appInfo, "App info should not be null") 25 | assertNotNull(appInfo.file, "File info should not be null") 26 | assertNotNull(appInfo.file.signature, "Signature should not be null") 27 | 28 | val signature = appInfo.file.signature 29 | assertNotNull(signature.sha1, "SHA1 should not be null") 30 | 31 | // Format the signature using the extension function 32 | val formattedSha1 = signature.toFormattedSha1() 33 | 34 | // Verify the signature matches the expected value 35 | assertEquals(expectedSignature, formattedSha1, "Signature should match the expected value") 36 | 37 | println("[DEBUG_LOG] Retrieved signature: ${signature.sha1}") 38 | println("[DEBUG_LOG] Formatted signature: $formattedSha1") 39 | println("[DEBUG_LOG] Expected signature: $expectedSignature") 40 | 41 | } catch (e: Exception) { 42 | println("[DEBUG_LOG] Error retrieving app info: ${e.message}") 43 | throw e 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /apklinkresolver/core/src/commonTest/kotlin/io/github/kdroidfilter/storekit/apklinkresolver/core/utils/FileUtilsTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apklinkresolver.core.utils 2 | 3 | import kotlinx.coroutines.runBlocking 4 | import kotlin.test.Test 5 | import kotlin.test.assertTrue 6 | 7 | class FileUtilsTest { 8 | 9 | @Test 10 | fun testGetFileSizeFromUrl_ValidUrl() = runBlocking { 11 | // Given 12 | // Using a reliable URL that should have a Content-Length header 13 | val url = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" 14 | 15 | // When 16 | val fileSize = FileUtils.getFileSizeFromUrl(url) 17 | 18 | // Then 19 | println("[DEBUG_LOG] File size for $url: $fileSize bytes") 20 | assertTrue(fileSize > 0, "File size should be greater than 0 for a valid URL") 21 | } 22 | 23 | @Test 24 | fun testGetFileSizeFromUrl_InvalidUrl() = runBlocking { 25 | // Given 26 | val invalidUrl = "https://invalid.url.that.does.not.exist.example.com/file.txt" 27 | 28 | // When 29 | val fileSize = FileUtils.getFileSizeFromUrl(invalidUrl) 30 | 31 | // Then 32 | println("[DEBUG_LOG] File size for invalid URL: $fileSize bytes") 33 | assertTrue(fileSize == -1L, "File size should be -1 for an invalid URL") 34 | } 35 | 36 | @Test 37 | fun testGetFileSizeFromUrl_NoContentLengthHeader() = runBlocking { 38 | // Given 39 | // Some servers might not provide a Content-Length header 40 | // Using a URL that might use chunked transfer encoding 41 | val url = "https://httpbin.org/stream/10" 42 | 43 | // When 44 | val fileSize = FileUtils.getFileSizeFromUrl(url) 45 | 46 | // Then 47 | println("[DEBUG_LOG] File size for URL without Content-Length: $fileSize bytes") 48 | // The result could be -1 (if no Content-Length) or > 0 (if Content-Length is provided) 49 | assertTrue(fileSize == -1L || fileSize > 0L, 50 | "File size should be either -1 (no Content-Length) or greater than 0") 51 | } 52 | } -------------------------------------------------------------------------------- /apklinkresolver/core/src/commonMain/kotlin/io/github/kdroidfilter/storekit/apklinkresolver/core/service/ApkSourcePriority.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apklinkresolver.core.service 2 | 3 | /** 4 | * Enum representing the available APK sources. 5 | */ 6 | enum class ApkSource { 7 | APKPURE, 8 | APTOIDE, 9 | APKCOMBO, 10 | FDROID, 11 | } 12 | 13 | /** 14 | * Singleton class for configuring the priority of APK sources. 15 | * This allows users to specify which sources should be tried first when retrieving APK download links. 16 | */ 17 | object ApkSourcePriority { 18 | /** 19 | * The default priority order for APK sources. 20 | */ 21 | private val defaultOrder: List = listOf(ApkSource.APKPURE, ApkSource.APKCOMBO, ApkSource.FDROID, ApkSource.APTOIDE) 22 | private var priorityOrder: List = defaultOrder 23 | 24 | /** 25 | * Sets the priority order for APK sources. 26 | * The sources will be tried in the order specified. 27 | * 28 | * @param sources The ordered list of APK sources to try 29 | */ 30 | fun setPriorityOrder(sources: List) { 31 | require(sources.isNotEmpty()) { "Priority list cannot be empty" } 32 | require(sources.toSet().size == sources.size) { "Priority list cannot contain duplicates" } 33 | // Allow partial ordering; unspecified sources will be tried after in their default order 34 | // Previously required all sources; relaxing to support dynamic additions like APKPURE 35 | // No strict check here beyond non-empty and no duplicates. 36 | 37 | // Merge with default order to append unspecified sources at the end in their default order 38 | val remaining = defaultOrder.filterNot { sources.contains(it) } 39 | priorityOrder = sources.toList() + remaining 40 | } 41 | 42 | /** 43 | * Gets the current priority order for APK sources. 44 | * 45 | * @return The ordered list of APK sources 46 | */ 47 | fun getPriorityOrder(): List = priorityOrder.toList() 48 | 49 | /** 50 | * Resets the priority order to the default. 51 | */ 52 | fun resetToDefault() { 53 | priorityOrder = defaultOrder 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | 3 | junit-junit = "4.13.2" 4 | kotlin = "2.2.0" 5 | agp = "8.9.3" 6 | kotlin-logging = "7.0.7" 7 | kotlinx-coroutines = "1.10.2" 8 | kotlinx-serialization = "1.8.1" 9 | ksoup-html = "0.6.0" 10 | ksoup-entities = "0.6.0" 11 | ktor = "3.2.0" 12 | slf4j-simple = "2.0.17" 13 | vanniktech = "0.32.0" 14 | 15 | [libraries] 16 | junit-junit = { module = "junit:junit", version.ref = "junit-junit" } 17 | kotlin-logging = { module = "io.github.oshai:kotlin-logging", version.ref = "kotlin-logging" } 18 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } 19 | kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } 20 | kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } 21 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } 22 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } 23 | ksoup-html = { module = "com.mohamedrejeb.ksoup:ksoup-html", version.ref = "ksoup-html" } 24 | ksoup-entities = { module = "com.mohamedrejeb.ksoup:ksoup-entities", version.ref = "ksoup-entities" } 25 | ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } 26 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 27 | ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } 28 | ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" } 29 | ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } 30 | slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j-simple" } 31 | 32 | 33 | [plugins] 34 | 35 | multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 36 | android-library = { id = "com.android.library", version.ref = "agp" } 37 | kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 38 | vannitktech-maven-publish = {id = "com.vanniktech.maven.publish", version.ref = "vanniktech"} 39 | -------------------------------------------------------------------------------- /fdroid/api/src/commonMain/kotlin/io/github/kdroidfilter/storekit/fdroid/api/services/FDroidService.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.fdroid.api.services 2 | 3 | import io.github.kdroidfilter.storekit.fdroid.api.constants.BASE_FDROID_API_URL 4 | import io.github.kdroidfilter.storekit.fdroid.api.constants.PACKAGES_PATH 5 | import io.github.kdroidfilter.storekit.fdroid.core.model.FDroidPackageInfo 6 | import io.github.oshai.kotlinlogging.KotlinLogging 7 | import io.ktor.client.* 8 | import io.ktor.client.engine.cio.* 9 | import io.ktor.client.plugins.logging.* 10 | import io.ktor.client.request.* 11 | import io.ktor.client.statement.* 12 | import io.ktor.http.* 13 | import kotlinx.serialization.json.Json 14 | 15 | /** 16 | * A service class for interacting with the F-Droid API. 17 | * This class provides methods to fetch package information from the F-Droid API. 18 | */ 19 | class FDroidService { 20 | private val logger = KotlinLogging.logger {} 21 | private val client = HttpClient(CIO) { 22 | install(Logging) { 23 | level = LogLevel.INFO 24 | } 25 | } 26 | 27 | private val json = Json { 28 | ignoreUnknownKeys = true 29 | coerceInputValues = true 30 | } 31 | 32 | /** 33 | * Fetches package information from the F-Droid API using the package name. 34 | * 35 | * @param packageName The package name of the application. 36 | * @return An instance of [FDroidPackageInfo] containing the package information. 37 | * @throws IllegalArgumentException if the package with the given package name does not exist or is not accessible. 38 | */ 39 | suspend fun getPackageInfo(packageName: String): FDroidPackageInfo { 40 | logger.info { "Fetching package information for package name: $packageName" } 41 | val url = "$BASE_FDROID_API_URL$PACKAGES_PATH/$packageName" 42 | 43 | val response = client.get(url) 44 | 45 | if (!response.status.isSuccess()) { 46 | throw IllegalArgumentException("Package with package name: $packageName does not exist or is not accessible. HTTP status: ${response.status}") 47 | } 48 | 49 | val responseText = response.bodyAsText() 50 | val fdroidPackageInfo = json.decodeFromString(responseText) 51 | logger.info { "Successfully fetched package information for package name: $packageName" } 52 | 53 | return fdroidPackageInfo 54 | } 55 | } -------------------------------------------------------------------------------- /fdroid/api/src/commonTest/kotlin/io/github/kdroidfilter/storekit/fdroid/api/services/FDroidServiceTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.fdroid.api.services 2 | 3 | import io.github.kdroidfilter.storekit.fdroid.core.model.FDroidPackageInfo 4 | import kotlinx.coroutines.runBlocking 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | import kotlin.test.assertNotNull 8 | import kotlin.test.assertTrue 9 | 10 | class FDroidServiceTest { 11 | 12 | @Test 13 | fun testRetrievePackageInfo() = runBlocking { 14 | // Given 15 | val fdroidService = FDroidService() 16 | val packageName = "org.fdroid.fdroid" // Using F-Droid app itself as an example 17 | 18 | try { 19 | // When 20 | val packageInfo: FDroidPackageInfo = fdroidService.getPackageInfo(packageName) 21 | 22 | // Then 23 | assertNotNull(packageInfo, "Package info should not be null") 24 | assertEquals(packageName, packageInfo.packageName, "Package name should match the requested one") 25 | assertTrue(packageInfo.suggestedVersionCode > 0, "Suggested version code should be positive") 26 | 27 | // Verify packages list 28 | assertNotNull(packageInfo.packages, "Packages list should not be null") 29 | assertTrue(packageInfo.packages.isNotEmpty(), "Packages list should not be empty") 30 | 31 | // Verify at least one package has valid version info 32 | val firstPackage = packageInfo.packages.firstOrNull() 33 | assertNotNull(firstPackage, "First package should not be null") 34 | assertTrue(firstPackage.versionName.isNotEmpty(), "Version name should not be empty") 35 | assertTrue(firstPackage.versionCode > 0, "Version code should be positive") 36 | 37 | // Debug logging 38 | println("[DEBUG_LOG] Retrieved package info for: ${packageInfo.packageName}") 39 | println("[DEBUG_LOG] Suggested version code: ${packageInfo.suggestedVersionCode}") 40 | println("[DEBUG_LOG] Number of packages: ${packageInfo.packages.size}") 41 | println("[DEBUG_LOG] First package version name: ${firstPackage.versionName}") 42 | println("[DEBUG_LOG] First package version code: ${firstPackage.versionCode}") 43 | 44 | } catch (e: Exception) { 45 | println("[DEBUG_LOG] Error retrieving package info: ${e.message}") 46 | throw e 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /apkcombo/scraper/src/commonTest/kotlin/io/github/kdroidfilter/storekit/apkcombo/scraper/utils/NetworkUtilsTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apkcombo.scraper.utils 2 | 3 | import io.github.kdroidfilter.storekit.apkcombo.scraper.constants.BASE_APKCOMBO_URL 4 | import kotlin.test.Test 5 | import kotlin.test.assertEquals 6 | 7 | class NetworkUtilsTest { 8 | 9 | @Test 10 | fun testCleanDownloadLink_WithR2Prefix() { 11 | // Given 12 | val encodedUrl = "https%3A%2F%2Fexample.com%2Fapp.apk" 13 | val link = "/r2?u=$encodedUrl" 14 | 15 | // When 16 | val cleanedLink = cleanDownloadLink(link) 17 | 18 | // Then 19 | assertEquals("https://example.com/app.apk", cleanedLink, 20 | "Should decode URL-encoded part after 'u='") 21 | println("[DEBUG_LOG] Original link: $link") 22 | println("[DEBUG_LOG] Cleaned link: $cleanedLink") 23 | } 24 | 25 | @Test 26 | fun testCleanDownloadLink_WithSlashPrefix() { 27 | // Given 28 | val link = "/download/app.apk" 29 | 30 | // When 31 | val cleanedLink = cleanDownloadLink(link) 32 | 33 | // Then 34 | assertEquals("$BASE_APKCOMBO_URL$link", cleanedLink, 35 | "Should prepend the base APKCombo URL") 36 | println("[DEBUG_LOG] Original link: $link") 37 | println("[DEBUG_LOG] Cleaned link: $cleanedLink") 38 | } 39 | 40 | @Test 41 | fun testCleanDownloadLink_WithFullUrl() { 42 | // Given 43 | val link = "https://example.com/app.apk" 44 | 45 | // When 46 | val cleanedLink = cleanDownloadLink(link) 47 | 48 | // Then 49 | assertEquals(link, cleanedLink, 50 | "Should return the link unchanged") 51 | println("[DEBUG_LOG] Original link: $link") 52 | println("[DEBUG_LOG] Cleaned link: $cleanedLink") 53 | } 54 | 55 | @Test 56 | fun testCleanDownloadLink_WithInvalidEncoding() { 57 | // Given 58 | val invalidEncodedUrl = "https%3A%2F%2Fexample.com%2Fapp.apk%" // Invalid % at the end 59 | val link = "/r2?u=$invalidEncodedUrl" 60 | 61 | // When 62 | val cleanedLink = cleanDownloadLink(link) 63 | 64 | // Then 65 | assertEquals(link, cleanedLink, 66 | "Should return the original link when decoding fails") 67 | println("[DEBUG_LOG] Original link with invalid encoding: $link") 68 | println("[DEBUG_LOG] Cleaned link: $cleanedLink") 69 | } 70 | } -------------------------------------------------------------------------------- /gplay/scrapper/src/commonMain/kotlin/io/github/kdroidfilter/storekit/gplay/scrapper/utils/JsonExtensions.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.gplay.scrapper.utils 2 | 3 | import kotlinx.serialization.json.* 4 | 5 | /** 6 | * Provides utility functions and extensions for working with JSON data using Kotlin serialization. 7 | * This object contains parsing capabilities and helper functions to safely interact with JSON elements. 8 | * 9 | * Properties: 10 | * - jsonParser: A Json instance to handle JSON serialization and deserialization with options 11 | * to ignore unknown keys. 12 | * 13 | * Functions: 14 | * - JsonElement?.asStringOrNull: Extension function to safely retrieve a JSON element as a nullable String. 15 | * - JsonElement?.asLongOrNull: Extension function to safely retrieve a JSON element as a nullable Long. 16 | * - JsonElement?.asDoubleOrNull: Extension function to safely retrieve a JSON element as a nullable Double. 17 | * - jsonElementToBool: Converts a JSON element to a Boolean based on its numeric value, treating 0 as false and others as true. 18 | * - microsToPrice: Converts a micro-unit value stored in a JSON element to a double representing a price. 19 | * - nestedLookup: Navigates through a JSON structure using a sequence of indices, resolving keys as strings and array positions. 20 | */ 21 | internal object JsonExtensions { 22 | 23 | // JSON parser 24 | val jsonParser = Json { ignoreUnknownKeys = true } 25 | 26 | // Helper Extensions 27 | fun JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.contentOrNull 28 | fun JsonElement?.asLongOrNull(): Long? = (this as? JsonPrimitive)?.longOrNull 29 | fun JsonElement?.asDoubleOrNull(): Double? = (this as? JsonPrimitive)?.doubleOrNull 30 | 31 | fun jsonElementToBool(e: JsonElement?): Boolean { 32 | // In Python code, bool is determined by direct casting. Often s == 0 => false else true 33 | val v = e?.asLongOrNull() ?: return false 34 | return v != 0L 35 | } 36 | 37 | fun microsToPrice(e: JsonElement?): Double { 38 | val v = e?.asLongOrNull() ?: return 0.0 39 | return v / 1000000.0 40 | } 41 | 42 | /** 43 | * Safely navigates into a Json structure based on a list of indexes. 44 | * Each index tries to access either a JsonArray index or a JsonObject key (converted to string). 45 | */ 46 | fun nestedLookup(source: JsonElement?, indexes: List): JsonElement? { 47 | var current: JsonElement? = source 48 | for (p in indexes) { 49 | current = when (current) { 50 | is JsonArray -> if (p < current.size) current[p] else null 51 | is JsonObject -> current[p.toString()] 52 | else -> null 53 | } 54 | if (current == null) return null 55 | } 56 | return current 57 | } 58 | } -------------------------------------------------------------------------------- /fdroid/core/src/commonMain/kotlin/io/github/kdroidfilter/storekit/fdroid/core/model/FDroidPackageInfo.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.fdroid.core.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * Data class representing the F-Droid package information. 8 | * This class corresponds to the response from the F-Droid API. 9 | * 10 | * Example response: 11 | * ```json 12 | * { 13 | * "packageName": "org.fdroid.fdroid", 14 | * "suggestedVersionCode": 1009000, 15 | * "packages": [ 16 | * { 17 | * "versionName": "1.10-alpha0", 18 | * "versionCode": 1010000 19 | * }, 20 | * { 21 | * "versionName": "1.9", 22 | * "versionCode": 1009000 23 | * } 24 | * ] 25 | * } 26 | * ``` 27 | */ 28 | @Serializable 29 | data class FDroidPackageInfo( 30 | /** 31 | * The package name of the application. 32 | */ 33 | val packageName: String = "", 34 | 35 | /** 36 | * The suggested version code of the application. 37 | */ 38 | val suggestedVersionCode: Long = 0, 39 | 40 | /** 41 | * List of available package versions. 42 | */ 43 | val packages: List = emptyList() 44 | ) { 45 | /** 46 | * Base URL for F-Droid repository downloads. 47 | */ 48 | private val baseRepoUrl = "https://f-droid.org/repo/" 49 | 50 | /** 51 | * Gets the download link for the package with the specified version code. 52 | * 53 | * @param versionCode The version code of the package. 54 | * @return The download link in the format "https://f-droid.org/repo/packagename_versioncode.apk", 55 | * or null if the version code doesn't exist in the packages list. 56 | */ 57 | fun getDownloadLink(versionCode: Long): String? { 58 | // Check if the version exists in the packages list 59 | val versionExists = packages.any { it.versionCode == versionCode } 60 | if (!versionExists) { 61 | return null 62 | } 63 | 64 | return "$baseRepoUrl${packageName}_$versionCode.apk" 65 | } 66 | 67 | /** 68 | * Gets the download link for the suggested version of the package. 69 | * 70 | * @return The download link for the suggested version, 71 | * or null if the suggested version doesn't exist in the packages list. 72 | */ 73 | fun getSuggestedVersionDownloadLink(): String? { 74 | return getDownloadLink(suggestedVersionCode) 75 | } 76 | } 77 | 78 | /** 79 | * Data class representing a version of a package in F-Droid. 80 | */ 81 | @Serializable 82 | data class FDroidPackageVersion( 83 | /** 84 | * The version name of the package (e.g., "1.9"). 85 | */ 86 | val versionName: String = "", 87 | 88 | /** 89 | * The version code of the package (e.g., 1009000). 90 | */ 91 | val versionCode: Long = 0 92 | ) 93 | -------------------------------------------------------------------------------- /authenticity/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.SonatypeHost 2 | 3 | plugins { 4 | alias(libs.plugins.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.vannitktech.maven.publish) 8 | } 9 | 10 | group = "io.github.kdroidfilter.storekit.authenticity" 11 | val ref = System.getenv("GITHUB_REF") ?: "" 12 | val version = if (ref.startsWith("refs/tags/")) { 13 | val tag = ref.removePrefix("refs/tags/") 14 | if (tag.startsWith("v")) tag.substring(1) else tag 15 | } else "dev" 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | androidTarget { 20 | publishLibraryVariants("release") 21 | } 22 | 23 | jvm() 24 | 25 | sourceSets { 26 | commonMain.dependencies { 27 | implementation(libs.kotlinx.serialization.json) 28 | } 29 | 30 | androidMain.dependencies { 31 | // Android-specific dependencies for signature extraction 32 | } 33 | 34 | commonTest.dependencies { 35 | implementation(kotlin("test")) 36 | } 37 | } 38 | } 39 | 40 | android { 41 | namespace = "io.github.kdroidfilter.storekit.authenticity" 42 | compileSdk = 35 43 | 44 | defaultConfig { 45 | minSdk = 21 46 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 47 | } 48 | 49 | dependencies { 50 | androidTestImplementation("androidx.test:core:1.6.1") 51 | androidTestImplementation("androidx.test:runner:1.6.2") 52 | androidTestImplementation("androidx.test:rules:1.6.1") 53 | androidTestImplementation("androidx.test.ext:junit:1.2.1") 54 | androidTestImplementation(libs.junit.junit) 55 | } 56 | } 57 | 58 | mavenPublishing { 59 | coordinates( 60 | groupId = "io.github.kdroidfilter", 61 | artifactId = "storekit-authenticity", 62 | version = version.toString() 63 | ) 64 | 65 | pom { 66 | name.set("App Authenticity Library") 67 | description.set("Module for extracting app signatures in SHA1 format from installed Android applications") 68 | inceptionYear.set("2024") 69 | url.set("https://github.com/kdroidFilter/StoreKit/") 70 | 71 | licenses { 72 | license { 73 | name.set("MIT License") 74 | url.set("https://opensource.org/licenses/MIT") 75 | } 76 | } 77 | 78 | developers { 79 | developer { 80 | id.set("kdroidFilter") 81 | name.set("Elie Gambache") 82 | email.set("elyahou.hadass@gmail.com") 83 | } 84 | } 85 | 86 | scm { 87 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 88 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 89 | url.set("https://github.com/kdroidFilter/StoreKit/") 90 | } 91 | } 92 | 93 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 94 | 95 | signAllPublications() 96 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /apkpure/core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.SonatypeHost 2 | 3 | plugins { 4 | alias(libs.plugins.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.vannitktech.maven.publish) 8 | } 9 | 10 | group = "io.github.kdroidfilter.storekit.apkpure.core" 11 | val ref = System.getenv("GITHUB_REF") ?: "" 12 | val version = if (ref.startsWith("refs/tags/")) { 13 | val tag = ref.removePrefix("refs/tags/") 14 | if (tag.startsWith("v")) tag.substring(1) else tag 15 | } else "dev" 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | androidTarget { 20 | publishLibraryVariants("release") 21 | } 22 | wasmJs { browser() } 23 | 24 | jvm() 25 | 26 | linuxX64 { 27 | binaries.staticLib { 28 | baseName = "shared" 29 | } 30 | } 31 | 32 | mingwX64 { 33 | binaries.staticLib { 34 | baseName = "shared" 35 | } 36 | } 37 | 38 | listOf( 39 | iosX64(), 40 | iosArm64(), 41 | iosSimulatorArm64() 42 | ).forEach { 43 | it.binaries.framework { 44 | baseName = "core" 45 | isStatic = true 46 | } 47 | } 48 | 49 | listOf( 50 | macosX64(), 51 | macosArm64() 52 | ).forEach { 53 | it.binaries.framework { 54 | baseName = "core" 55 | isStatic = true 56 | } 57 | } 58 | 59 | sourceSets { 60 | commonMain.dependencies { 61 | implementation(libs.kotlinx.serialization.json) 62 | } 63 | 64 | commonTest.dependencies { 65 | implementation(kotlin("test")) 66 | } 67 | } 68 | 69 | targets.withType { 70 | compilations["main"].compilerOptions.options.freeCompilerArgs.add("-Xexport-kdoc") 71 | } 72 | } 73 | 74 | android { 75 | namespace = "io.github.kdroidfilter.storekit.apkpure.core" 76 | compileSdk = 35 77 | 78 | defaultConfig { 79 | minSdk = 21 80 | } 81 | } 82 | 83 | mavenPublishing { 84 | coordinates( 85 | groupId = "io.github.kdroidfilter", 86 | artifactId = "storekit-apkpure-core", 87 | version = version.toString() 88 | ) 89 | 90 | pom { 91 | name.set("APKPure Core Library") 92 | description.set("Core module for APKPure Library containing model classes") 93 | inceptionYear.set("2024") 94 | url.set("https://github.com/kdroidFilter/StoreKit/") 95 | 96 | licenses { 97 | license { 98 | name.set("MIT License") 99 | url.set("https://opensource.org/licenses/MIT") 100 | } 101 | } 102 | 103 | developers { 104 | developer { 105 | id.set("kdroidFilter") 106 | name.set("Elie Gambache") 107 | email.set("elyahou.hadass@gmail.com") 108 | } 109 | } 110 | 111 | scm { 112 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 113 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 114 | url.set("https://github.com/kdroidFilter/StoreKit/") 115 | } 116 | } 117 | 118 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 119 | signAllPublications() 120 | } 121 | 122 | -------------------------------------------------------------------------------- /fdroid/core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.SonatypeHost 2 | 3 | plugins { 4 | alias(libs.plugins.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.vannitktech.maven.publish) 8 | } 9 | 10 | group = "io.github.kdroidfilter.storekit.fdroid.core" 11 | val ref = System.getenv("GITHUB_REF") ?: "" 12 | val version = if (ref.startsWith("refs/tags/")) { 13 | val tag = ref.removePrefix("refs/tags/") 14 | if (tag.startsWith("v")) tag.substring(1) else tag 15 | } else "dev" 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | androidTarget { 20 | publishLibraryVariants("release") 21 | } 22 | wasmJs { browser() } 23 | 24 | jvm() 25 | 26 | linuxX64 { 27 | binaries.staticLib { 28 | baseName = "shared" 29 | } 30 | } 31 | 32 | mingwX64 { 33 | binaries.staticLib { 34 | baseName = "shared" 35 | } 36 | } 37 | 38 | listOf( 39 | iosX64(), 40 | iosArm64(), 41 | iosSimulatorArm64() 42 | ).forEach { 43 | it.binaries.framework { 44 | baseName = "core" 45 | isStatic = true 46 | } 47 | } 48 | 49 | listOf( 50 | macosX64(), 51 | macosArm64() 52 | ).forEach { 53 | it.binaries.framework { 54 | baseName = "core" 55 | isStatic = true 56 | } 57 | } 58 | 59 | sourceSets { 60 | commonMain.dependencies { 61 | implementation(libs.kotlinx.serialization.json) 62 | } 63 | 64 | commonTest.dependencies { 65 | implementation(kotlin("test")) 66 | } 67 | } 68 | 69 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers 70 | targets.withType { 71 | compilations["main"].compilerOptions.options.freeCompilerArgs.add("-Xexport-kdoc") 72 | } 73 | } 74 | 75 | android { 76 | namespace = "io.github.kdroidfilter.storekit.fdroid.core" 77 | compileSdk = 35 78 | 79 | defaultConfig { 80 | minSdk = 21 81 | } 82 | } 83 | 84 | mavenPublishing { 85 | coordinates( 86 | groupId = "io.github.kdroidfilter", 87 | artifactId = "storekit-fdroid-core", 88 | version = version.toString() 89 | ) 90 | 91 | pom { 92 | name.set("F-Droid Core Library") 93 | description.set("Core module for F-Droid Library containing model classes") 94 | inceptionYear.set("2024") 95 | url.set("https://github.com/kdroidFilter/StoreKit/") 96 | 97 | licenses { 98 | license { 99 | name.set("MIT License") 100 | url.set("https://opensource.org/licenses/MIT") 101 | } 102 | } 103 | 104 | developers { 105 | developer { 106 | id.set("kdroidFilter") 107 | name.set("Elie Gambache") 108 | email.set("elyahou.hadass@gmail.com") 109 | } 110 | } 111 | 112 | scm { 113 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 114 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 115 | url.set("https://github.com/kdroidFilter/StoreKit/") 116 | } 117 | } 118 | 119 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 120 | 121 | signAllPublications() 122 | } -------------------------------------------------------------------------------- /gplay/core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.SonatypeHost 2 | 3 | plugins { 4 | alias(libs.plugins.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.vannitktech.maven.publish) 8 | } 9 | 10 | group = "io.github.kdroidfilter.storekit.gplay.core" 11 | val ref = System.getenv("GITHUB_REF") ?: "" 12 | val version = if (ref.startsWith("refs/tags/")) { 13 | val tag = ref.removePrefix("refs/tags/") 14 | if (tag.startsWith("v")) tag.substring(1) else tag 15 | } else "dev" 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | androidTarget { 20 | publishLibraryVariants("release") 21 | } 22 | wasmJs { browser() } 23 | 24 | jvm() 25 | 26 | linuxX64 { 27 | binaries.staticLib { 28 | baseName = "shared" 29 | } 30 | } 31 | 32 | mingwX64 { 33 | binaries.staticLib { 34 | baseName = "shared" 35 | } 36 | } 37 | 38 | listOf( 39 | iosX64(), 40 | iosArm64(), 41 | iosSimulatorArm64() 42 | ).forEach { 43 | it.binaries.framework { 44 | baseName = "core" 45 | isStatic = true 46 | } 47 | } 48 | 49 | listOf( 50 | macosX64(), 51 | macosArm64() 52 | ).forEach { 53 | it.binaries.framework { 54 | baseName = "core" 55 | isStatic = true 56 | } 57 | } 58 | 59 | sourceSets { 60 | commonMain.dependencies { 61 | implementation(libs.kotlinx.serialization.json) 62 | } 63 | 64 | commonTest.dependencies { 65 | implementation(kotlin("test")) 66 | } 67 | } 68 | 69 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers 70 | targets.withType { 71 | compilations["main"].compilerOptions.options.freeCompilerArgs.add("-Xexport-kdoc") 72 | } 73 | } 74 | 75 | android { 76 | namespace = "io.github.kdroidfilter.storekit.gplay.core" 77 | compileSdk = 35 78 | 79 | defaultConfig { 80 | minSdk = 21 81 | } 82 | } 83 | 84 | mavenPublishing { 85 | coordinates( 86 | groupId = "io.github.kdroidfilter", 87 | artifactId = "storekit-gplay-core", 88 | version = version.toString() 89 | ) 90 | 91 | pom { 92 | name.set("GPlay Core Library") 93 | description.set("Core module for GPlay Library containing model classes") 94 | inceptionYear.set("2024") 95 | url.set("https://github.com/kdroidFilter/StoreKit/") 96 | 97 | licenses { 98 | license { 99 | name.set("MIT License") 100 | url.set("https://opensource.org/licenses/MIT") 101 | } 102 | } 103 | 104 | developers { 105 | developer { 106 | id.set("kdroidFilter") 107 | name.set("Elie Gambache") 108 | email.set("elyahou.hadass@gmail.com") 109 | } 110 | } 111 | 112 | scm { 113 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 114 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 115 | url.set("https://github.com/kdroidFilter/StoreKit/") 116 | } 117 | } 118 | 119 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 120 | 121 | signAllPublications() 122 | } 123 | -------------------------------------------------------------------------------- /apkcombo/core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.SonatypeHost 2 | 3 | plugins { 4 | alias(libs.plugins.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.vannitktech.maven.publish) 8 | } 9 | 10 | group = "io.github.kdroidfilter.storekit.apkcombo.core" 11 | val ref = System.getenv("GITHUB_REF") ?: "" 12 | val version = if (ref.startsWith("refs/tags/")) { 13 | val tag = ref.removePrefix("refs/tags/") 14 | if (tag.startsWith("v")) tag.substring(1) else tag 15 | } else "dev" 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | androidTarget { 20 | publishLibraryVariants("release") 21 | } 22 | wasmJs { browser() } 23 | 24 | jvm() 25 | 26 | linuxX64 { 27 | binaries.staticLib { 28 | baseName = "shared" 29 | } 30 | } 31 | 32 | mingwX64 { 33 | binaries.staticLib { 34 | baseName = "shared" 35 | } 36 | } 37 | 38 | listOf( 39 | iosX64(), 40 | iosArm64(), 41 | iosSimulatorArm64() 42 | ).forEach { 43 | it.binaries.framework { 44 | baseName = "core" 45 | isStatic = true 46 | } 47 | } 48 | 49 | listOf( 50 | macosX64(), 51 | macosArm64() 52 | ).forEach { 53 | it.binaries.framework { 54 | baseName = "core" 55 | isStatic = true 56 | } 57 | } 58 | 59 | sourceSets { 60 | commonMain.dependencies { 61 | implementation(libs.kotlinx.serialization.json) 62 | } 63 | 64 | commonTest.dependencies { 65 | implementation(kotlin("test")) 66 | } 67 | } 68 | 69 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers 70 | targets.withType { 71 | compilations["main"].compilerOptions.options.freeCompilerArgs.add("-Xexport-kdoc") 72 | } 73 | } 74 | 75 | android { 76 | namespace = "io.github.kdroidfilter.storekit.apkcombo.core" 77 | compileSdk = 35 78 | 79 | defaultConfig { 80 | minSdk = 21 81 | } 82 | } 83 | 84 | mavenPublishing { 85 | coordinates( 86 | groupId = "io.github.kdroidfilter", 87 | artifactId = "storekit-apkcombo-core", 88 | version = version.toString() 89 | ) 90 | 91 | pom { 92 | name.set("APKCombo Core Library") 93 | description.set("Core module for APKCombo Library containing model classes") 94 | inceptionYear.set("2024") 95 | url.set("https://github.com/kdroidFilter/StoreKit/") 96 | 97 | licenses { 98 | license { 99 | name.set("MIT License") 100 | url.set("https://opensource.org/licenses/MIT") 101 | } 102 | } 103 | 104 | developers { 105 | developer { 106 | id.set("kdroidFilter") 107 | name.set("Elie Gambache") 108 | email.set("elyahou.hadass@gmail.com") 109 | } 110 | } 111 | 112 | scm { 113 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 114 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 115 | url.set("https://github.com/kdroidFilter/StoreKit/") 116 | } 117 | } 118 | 119 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 120 | 121 | signAllPublications() 122 | } -------------------------------------------------------------------------------- /aptoide/core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.SonatypeHost 2 | 3 | plugins { 4 | alias(libs.plugins.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.vannitktech.maven.publish) 8 | } 9 | 10 | group = "io.github.kdroidfilter.storekit.aptoide.core" 11 | val ref = System.getenv("GITHUB_REF") ?: "" 12 | val version = if (ref.startsWith("refs/tags/")) { 13 | val tag = ref.removePrefix("refs/tags/") 14 | if (tag.startsWith("v")) tag.substring(1) else tag 15 | } else "dev" 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | androidTarget { 20 | publishLibraryVariants("release") 21 | } 22 | wasmJs { browser() } 23 | 24 | jvm() 25 | 26 | linuxX64 { 27 | binaries.staticLib { 28 | baseName = "shared" 29 | } 30 | } 31 | 32 | mingwX64 { 33 | binaries.staticLib { 34 | baseName = "shared" 35 | } 36 | } 37 | 38 | listOf( 39 | iosX64(), 40 | iosArm64(), 41 | iosSimulatorArm64() 42 | ).forEach { 43 | it.binaries.framework { 44 | baseName = "core" 45 | isStatic = true 46 | } 47 | } 48 | 49 | listOf( 50 | macosX64(), 51 | macosArm64() 52 | ).forEach { 53 | it.binaries.framework { 54 | baseName = "core" 55 | isStatic = true 56 | } 57 | } 58 | 59 | sourceSets { 60 | commonMain.dependencies { 61 | implementation(libs.kotlinx.serialization.json) 62 | } 63 | 64 | commonTest.dependencies { 65 | implementation(kotlin("test")) 66 | } 67 | } 68 | 69 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers 70 | targets.withType { 71 | compilations["main"].compilerOptions.options.freeCompilerArgs.add("-Xexport-kdoc") 72 | } 73 | } 74 | 75 | android { 76 | namespace = "io.github.kdroidfilter.storekit.aptoide.core" 77 | compileSdk = 35 78 | 79 | defaultConfig { 80 | minSdk = 21 81 | } 82 | } 83 | 84 | mavenPublishing { 85 | coordinates( 86 | groupId = "io.github.kdroidfilter", 87 | artifactId = "storekit-aptoide-core", 88 | version = version.toString() 89 | ) 90 | 91 | pom { 92 | name.set("Aptoide Core Library") 93 | description.set("Core module for Aptoide Library containing model classes") 94 | inceptionYear.set("2024") 95 | url.set("https://github.com/kdroidFilter/StoreKit/") 96 | 97 | licenses { 98 | license { 99 | name.set("MIT License") 100 | url.set("https://opensource.org/licenses/MIT") 101 | } 102 | } 103 | 104 | developers { 105 | developer { 106 | id.set("kdroidFilter") 107 | name.set("Elie Gambache") 108 | email.set("elyahou.hadass@gmail.com") 109 | } 110 | } 111 | 112 | scm { 113 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 114 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 115 | url.set("https://github.com/kdroidFilter/StoreKit/") 116 | } 117 | } 118 | 119 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 120 | 121 | signAllPublications() 122 | } 123 | -------------------------------------------------------------------------------- /fdroid/core/src/commonTest/kotlin/io/github/kdroidfilter/storekit/fdroid/core/model/FDroidPackageInfoTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.fdroid.core.model 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertNull 6 | 7 | class FDroidPackageInfoTest { 8 | 9 | @Test 10 | fun testGetDownloadLink() { 11 | // Given 12 | val packageName = "net.thunderbird.android" 13 | val suggestedVersionCode = 10L 14 | val packages = listOf( 15 | FDroidPackageVersion(versionName = "10.0", versionCode = 10), 16 | FDroidPackageVersion(versionName = "9.0", versionCode = 9), 17 | FDroidPackageVersion(versionName = "8.2", versionCode = 8) 18 | ) 19 | val packageInfo = FDroidPackageInfo( 20 | packageName = packageName, 21 | suggestedVersionCode = suggestedVersionCode, 22 | packages = packages 23 | ) 24 | 25 | // When - Test with valid version code 26 | val downloadLink = packageInfo.getDownloadLink(10) 27 | 28 | // Then 29 | assertEquals( 30 | "https://f-droid.org/repo/net.thunderbird.android_10.apk", 31 | downloadLink, 32 | "Download link should be correctly formatted" 33 | ) 34 | 35 | // When - Test with another valid version code 36 | val downloadLink2 = packageInfo.getDownloadLink(9) 37 | 38 | // Then 39 | assertEquals( 40 | "https://f-droid.org/repo/net.thunderbird.android_9.apk", 41 | downloadLink2, 42 | "Download link should be correctly formatted for version 9" 43 | ) 44 | 45 | // When - Test with invalid version code 46 | val invalidDownloadLink = packageInfo.getDownloadLink(999) 47 | 48 | // Then 49 | assertNull( 50 | invalidDownloadLink, 51 | "Download link should be null for non-existent version code" 52 | ) 53 | } 54 | 55 | @Test 56 | fun testGetSuggestedVersionDownloadLink() { 57 | // Given 58 | val packageName = "net.thunderbird.android" 59 | val suggestedVersionCode = 10L 60 | val packages = listOf( 61 | FDroidPackageVersion(versionName = "10.0", versionCode = 10), 62 | FDroidPackageVersion(versionName = "9.0", versionCode = 9), 63 | FDroidPackageVersion(versionName = "8.2", versionCode = 8) 64 | ) 65 | val packageInfo = FDroidPackageInfo( 66 | packageName = packageName, 67 | suggestedVersionCode = suggestedVersionCode, 68 | packages = packages 69 | ) 70 | 71 | // When 72 | val suggestedDownloadLink = packageInfo.getSuggestedVersionDownloadLink() 73 | 74 | // Then 75 | assertEquals( 76 | "https://f-droid.org/repo/net.thunderbird.android_10.apk", 77 | suggestedDownloadLink, 78 | "Suggested version download link should be correctly formatted" 79 | ) 80 | 81 | // Given - Test with non-existent suggested version 82 | val packageInfoWithInvalidSuggested = FDroidPackageInfo( 83 | packageName = packageName, 84 | suggestedVersionCode = 999, 85 | packages = packages 86 | ) 87 | 88 | // When 89 | val invalidSuggestedDownloadLink = packageInfoWithInvalidSuggested.getSuggestedVersionDownloadLink() 90 | 91 | // Then 92 | assertNull( 93 | invalidSuggestedDownloadLink, 94 | "Download link should be null for non-existent suggested version code" 95 | ) 96 | } 97 | } -------------------------------------------------------------------------------- /gplay/scrapper/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.SonatypeHost 2 | 3 | plugins { 4 | alias(libs.plugins.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.vannitktech.maven.publish) 8 | 9 | } 10 | 11 | group = "io.github.kdroidfilter.storekit.gplay.scrapper" 12 | val ref = System.getenv("GITHUB_REF") ?: "" 13 | val version = if (ref.startsWith("refs/tags/")) { 14 | val tag = ref.removePrefix("refs/tags/") 15 | if (tag.startsWith("v")) tag.substring(1) else tag 16 | } else "dev" 17 | 18 | kotlin { 19 | jvmToolchain(17) 20 | androidTarget { 21 | publishLibraryVariants("release") 22 | } 23 | 24 | jvm() 25 | 26 | 27 | sourceSets { 28 | commonMain.dependencies { 29 | api(project(":gplay:core")) 30 | implementation(libs.kotlinx.coroutines.core) 31 | implementation(libs.kotlinx.serialization.json) 32 | compileOnly(libs.ktor.client.core) 33 | compileOnly(libs.ktor.client.content.negotiation) 34 | compileOnly(libs.ktor.client.serialization) 35 | compileOnly(libs.ktor.client.logging) 36 | compileOnly(libs.ktor.client.cio) 37 | implementation(libs.ksoup.html) 38 | implementation(libs.ksoup.entities) 39 | implementation(libs.kotlin.logging) 40 | } 41 | 42 | commonTest.dependencies { 43 | implementation(kotlin("test")) 44 | } 45 | 46 | androidMain.dependencies { 47 | implementation(libs.kotlinx.coroutines.android) 48 | } 49 | 50 | jvmMain.dependencies { 51 | implementation(libs.kotlinx.coroutines.swing) 52 | implementation(libs.slf4j.simple) 53 | } 54 | 55 | } 56 | 57 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers 58 | targets.withType { 59 | compilations["main"].compilerOptions.options.freeCompilerArgs.add("-Xexport-kdoc") 60 | } 61 | 62 | } 63 | 64 | android { 65 | namespace = "io.github.kdroidfilter.storekit.gplay.scrapper" 66 | compileSdk = 35 67 | 68 | defaultConfig { 69 | minSdk = 21 70 | } 71 | } 72 | 73 | mavenPublishing { 74 | coordinates( 75 | groupId = "io.github.kdroidfilter", 76 | artifactId = "storekit-gplay-scrapper", 77 | version = version.toString() 78 | ) 79 | 80 | pom { 81 | name.set("GPlay Scrapper Library") 82 | description.set("GPlay Scrapper Library is a Kotlin library for extracting comprehensive app data from the Google Play Store.") 83 | inceptionYear.set("2024") 84 | url.set("https://github.com/kdroidFilter/StoreKit/") 85 | 86 | licenses { 87 | license { 88 | name.set("MIT License") 89 | url.set("https://opensource.org/licenses/MIT") 90 | } 91 | } 92 | 93 | developers { 94 | developer { 95 | id.set("kdroidFilter") 96 | name.set("Elie Gambache") 97 | email.set("elyahou.hadass@gmail.com") 98 | } 99 | } 100 | 101 | scm { 102 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 103 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 104 | url.set("https://github.com/kdroidFilter/StoreKit/") 105 | } 106 | } 107 | 108 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 109 | 110 | signAllPublications() 111 | } 112 | -------------------------------------------------------------------------------- /gplay/scrapper/src/commonMain/kotlin/io/github/kdroidfilter/storekit/gplay/scrapper/utils/NetworkUtils.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.gplay.scrapper.utils 2 | 3 | import io.github.kdroidfilter.storekit.gplay.scrapper.constants.BASE_PLAY_STORE_URL 4 | import io.github.kdroidfilter.storekit.gplay.scrapper.constants.DETAIL_PATH 5 | import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlHandler 6 | import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlParser 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import io.ktor.client.* 9 | import io.ktor.client.engine.cio.* 10 | import io.ktor.client.request.* 11 | import io.ktor.client.statement.* 12 | 13 | // Ktor HttpClient for making network requests 14 | 15 | /** 16 | * A utility object providing network-related functionalities specific to app data fetching and 17 | * HTML content processing. This object contains functions to perform HTTP requests to fetch web 18 | * pages and extract JSON data from HTML content. 19 | */ 20 | internal object NetworkUtils { 21 | internal val logger = KotlinLogging.logger {} 22 | internal val client = HttpClient(CIO) 23 | 24 | /** 25 | * Fetches the application page from the Play Store using the provided application ID. 26 | * 27 | * @param appId The unique identifier of the application whose page is to be fetched. 28 | * @param lang The language code for the content localization. Defaults to "en". 29 | * @param country The country code for the content localization. Defaults to "us". 30 | * @return HttpResponse representing the server's response to the request. 31 | */ 32 | // Networking 33 | internal suspend fun fetchAppPage(appId: String, lang: String = "en", country: String = "us"): HttpResponse { 34 | val url = "$BASE_PLAY_STORE_URL$DETAIL_PATH?id=$appId&hl=$lang&gl=$country" 35 | logger.info { "Fetching URL: $url" } 36 | return client.get(url) 37 | } 38 | 39 | /** 40 | * Extracts JSON blobs contained within script tags from the provided HTML content. 41 | * 42 | * The method identifies script tags in the HTML and collects their content if it contains 43 | * a specific pattern ("AF_initDataCallback"), indicating the presence of JSON blobs. 44 | * 45 | * @param html The string representation of the HTML content to be parsed. 46 | * @return A list of strings, each representing a JSON blob extracted from the script tags in the HTML. 47 | */ 48 | // Extract JSON from HTML 49 | internal fun extractJsonBlobsFromHtml(html: String): List { 50 | val jsonScripts = mutableListOf() 51 | var currentTagIsScript = false 52 | val currentScriptContent = StringBuilder() 53 | 54 | val handler = KsoupHtmlHandler.Builder() 55 | .onOpenTag { name, _, _ -> 56 | if (name.equals("script", ignoreCase = true)) { 57 | currentTagIsScript = true 58 | currentScriptContent.clear() 59 | } 60 | } 61 | .onCloseTag { name, _ -> 62 | if (name.equals("script", ignoreCase = true)) { 63 | currentTagIsScript = false 64 | val scriptText = currentScriptContent.toString() 65 | if (scriptText.contains("AF_initDataCallback")) { 66 | jsonScripts.add(scriptText) 67 | } 68 | } 69 | } 70 | .onText { text -> 71 | if (currentTagIsScript) { 72 | currentScriptContent.append(text) 73 | } 74 | } 75 | .build() 76 | 77 | val parser = KsoupHtmlParser(handler = handler) 78 | parser.write(html) 79 | parser.end() 80 | 81 | return jsonScripts 82 | } 83 | 84 | } -------------------------------------------------------------------------------- /fdroid/api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.SonatypeHost 2 | 3 | plugins { 4 | alias(libs.plugins.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.vannitktech.maven.publish) 8 | } 9 | 10 | group = "io.github.kdroidfilter.storekit.fdroid.api" 11 | val ref = System.getenv("GITHUB_REF") ?: "" 12 | val version = if (ref.startsWith("refs/tags/")) { 13 | val tag = ref.removePrefix("refs/tags/") 14 | if (tag.startsWith("v")) tag.substring(1) else tag 15 | } else "dev" 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | androidTarget { 20 | publishLibraryVariants("release") 21 | } 22 | 23 | jvm() 24 | 25 | sourceSets { 26 | commonMain.dependencies { 27 | api(project(":fdroid:core")) 28 | implementation(libs.kotlinx.coroutines.core) 29 | implementation(libs.kotlinx.coroutines.test) 30 | implementation(libs.kotlinx.serialization.json) 31 | compileOnly(libs.ktor.client.core) 32 | compileOnly(libs.ktor.client.content.negotiation) 33 | compileOnly(libs.ktor.client.serialization) 34 | compileOnly(libs.ktor.client.logging) 35 | compileOnly(libs.ktor.client.cio) 36 | implementation(libs.kotlin.logging) 37 | } 38 | 39 | commonTest.dependencies { 40 | implementation(kotlin("test")) 41 | implementation(libs.ktor.client.core) 42 | implementation(libs.ktor.client.content.negotiation) 43 | implementation(libs.ktor.client.serialization) 44 | implementation(libs.ktor.client.logging) 45 | implementation(libs.ktor.client.cio) 46 | } 47 | 48 | androidMain.dependencies { 49 | implementation(libs.kotlinx.coroutines.android) 50 | } 51 | 52 | jvmMain.dependencies { 53 | implementation(libs.kotlinx.coroutines.swing) 54 | implementation(libs.slf4j.simple) 55 | } 56 | } 57 | 58 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers 59 | targets.withType { 60 | compilations["main"].compilerOptions.options.freeCompilerArgs.add("-Xexport-kdoc") 61 | } 62 | } 63 | 64 | android { 65 | namespace = "io.github.kdroidfilter.storekit.fdroid.api" 66 | compileSdk = 35 67 | 68 | defaultConfig { 69 | minSdk = 21 70 | } 71 | } 72 | 73 | mavenPublishing { 74 | coordinates( 75 | groupId = "io.github.kdroidfilter", 76 | artifactId = "storekit-fdroid-api", 77 | version = version.toString() 78 | ) 79 | 80 | pom { 81 | name.set("F-Droid API Library") 82 | description.set("F-Droid Library is a Kotlin library for extracting comprehensive app data from the F-Droid API.") 83 | inceptionYear.set("2024") 84 | url.set("https://github.com/kdroidFilter/StoreKit/") 85 | 86 | licenses { 87 | license { 88 | name.set("MIT License") 89 | url.set("https://opensource.org/licenses/MIT") 90 | } 91 | } 92 | 93 | developers { 94 | developer { 95 | id.set("kdroidFilter") 96 | name.set("Elie Gambache") 97 | email.set("elyahou.hadass@gmail.com") 98 | } 99 | } 100 | 101 | scm { 102 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 103 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 104 | url.set("https://github.com/kdroidFilter/StoreKit/") 105 | } 106 | } 107 | 108 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 109 | 110 | signAllPublications() 111 | } -------------------------------------------------------------------------------- /aptoide/api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.SonatypeHost 2 | 3 | plugins { 4 | alias(libs.plugins.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.vannitktech.maven.publish) 8 | } 9 | 10 | group = "io.github.kdroidfilter.storekit.aptoide.api" 11 | val ref = System.getenv("GITHUB_REF") ?: "" 12 | val version = if (ref.startsWith("refs/tags/")) { 13 | val tag = ref.removePrefix("refs/tags/") 14 | if (tag.startsWith("v")) tag.substring(1) else tag 15 | } else "dev" 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | androidTarget { 20 | publishLibraryVariants("release") 21 | } 22 | 23 | jvm() 24 | 25 | 26 | sourceSets { 27 | commonMain.dependencies { 28 | api(project(":aptoide:core")) 29 | implementation(libs.kotlinx.coroutines.core) 30 | implementation(libs.kotlinx.coroutines.test) 31 | implementation(libs.kotlinx.serialization.json) 32 | compileOnly(libs.ktor.client.core) 33 | compileOnly(libs.ktor.client.content.negotiation) 34 | compileOnly(libs.ktor.client.serialization) 35 | compileOnly(libs.ktor.client.logging) 36 | compileOnly(libs.ktor.client.cio) 37 | implementation(libs.kotlin.logging) 38 | } 39 | 40 | commonTest.dependencies { 41 | implementation(kotlin("test")) 42 | implementation(libs.ktor.client.core) 43 | implementation(libs.ktor.client.content.negotiation) 44 | implementation(libs.ktor.client.serialization) 45 | implementation(libs.ktor.client.logging) 46 | implementation(libs.ktor.client.cio) 47 | } 48 | 49 | 50 | androidMain.dependencies { 51 | implementation(libs.kotlinx.coroutines.android) 52 | } 53 | 54 | jvmMain.dependencies { 55 | implementation(libs.kotlinx.coroutines.swing) 56 | implementation(libs.slf4j.simple) 57 | } 58 | } 59 | 60 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers 61 | targets.withType { 62 | compilations["main"].compilerOptions.options.freeCompilerArgs.add("-Xexport-kdoc") 63 | } 64 | } 65 | 66 | android { 67 | namespace = "io.github.kdroidfilter.storekit.aptoide.api" 68 | compileSdk = 35 69 | 70 | defaultConfig { 71 | minSdk = 21 72 | } 73 | } 74 | 75 | mavenPublishing { 76 | coordinates( 77 | groupId = "io.github.kdroidfilter", 78 | artifactId = "storekit-aptoide-api", 79 | version = version.toString() 80 | ) 81 | 82 | pom { 83 | name.set("Aptoide API Library") 84 | description.set("Aptoide Library is a Kotlin library for extracting comprehensive app data from the Aptoide API.") 85 | inceptionYear.set("2024") 86 | url.set("https://github.com/kdroidFilter/StoreKit/") 87 | 88 | licenses { 89 | license { 90 | name.set("MIT License") 91 | url.set("https://opensource.org/licenses/MIT") 92 | } 93 | } 94 | 95 | developers { 96 | developer { 97 | id.set("kdroidFilter") 98 | name.set("Elie Gambache") 99 | email.set("elyahou.hadass@gmail.com") 100 | } 101 | } 102 | 103 | scm { 104 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 105 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 106 | url.set("https://github.com/kdroidFilter/StoreKit/") 107 | } 108 | } 109 | 110 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 111 | 112 | signAllPublications() 113 | } 114 | -------------------------------------------------------------------------------- /apkpure/scraper/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // apkpure/api/build.gradle.kts 2 | import com.vanniktech.maven.publish.SonatypeHost 3 | import kotlin.toString 4 | 5 | plugins { 6 | alias(libs.plugins.multiplatform) 7 | alias(libs.plugins.android.library) 8 | alias(libs.plugins.kotlinx.serialization) 9 | alias(libs.plugins.vannitktech.maven.publish) 10 | } 11 | 12 | group = "io.github.kdroidfilter.storekit.apkpure.api" 13 | val ref = System.getenv("GITHUB_REF") ?: "" 14 | val version = if (ref.startsWith("refs/tags/")) { 15 | val tag = ref.removePrefix("refs/tags/") 16 | if (tag.startsWith("v")) tag.substring(1) else tag 17 | } else "dev" 18 | 19 | kotlin { 20 | jvmToolchain(17) 21 | androidTarget { 22 | publishLibraryVariants("release") 23 | } 24 | 25 | jvm() 26 | 27 | sourceSets { 28 | commonMain.dependencies { 29 | api(project(":apkpure:core")) 30 | implementation(libs.kotlinx.coroutines.core) 31 | implementation(libs.kotlinx.coroutines.test) 32 | implementation(libs.kotlinx.serialization.json) 33 | compileOnly(libs.ktor.client.core) 34 | compileOnly(libs.ktor.client.content.negotiation) 35 | compileOnly(libs.ktor.client.serialization) 36 | compileOnly(libs.ktor.client.logging) 37 | compileOnly(libs.ktor.client.cio) 38 | implementation(libs.kotlin.logging) 39 | implementation(libs.ksoup.html) 40 | implementation(libs.ksoup.entities) 41 | } 42 | 43 | commonTest.dependencies { 44 | implementation(kotlin("test")) 45 | implementation(libs.ktor.client.core) 46 | implementation(libs.ktor.client.content.negotiation) 47 | implementation(libs.ktor.client.serialization) 48 | implementation(libs.ktor.client.logging) 49 | implementation(libs.ktor.client.cio) 50 | } 51 | 52 | androidMain.dependencies { 53 | implementation(libs.kotlinx.coroutines.android) 54 | } 55 | 56 | jvmMain.dependencies { 57 | implementation(libs.kotlinx.coroutines.swing) 58 | implementation(libs.slf4j.simple) 59 | } 60 | } 61 | 62 | targets.withType { 63 | compilations["main"].compilerOptions.options.freeCompilerArgs.add("-Xexport-kdoc") 64 | } 65 | } 66 | 67 | android { 68 | namespace = "io.github.kdroidfilter.storekit.apkpure.api" 69 | compileSdk = 35 70 | 71 | defaultConfig { 72 | minSdk = 21 73 | } 74 | } 75 | 76 | mavenPublishing { 77 | coordinates( 78 | groupId = "io.github.kdroidfilter", 79 | artifactId = "storekit-apkpure-api", 80 | version = version.toString() 81 | ) 82 | 83 | pom { 84 | name.set("APKPure API Library") 85 | description.set("APKPure Library is a Kotlin library for extracting comprehensive app data from APKPure.") 86 | inceptionYear.set("2024") 87 | url.set("https://github.com/kdroidFilter/StoreKit/") 88 | 89 | licenses { 90 | license { 91 | name.set("MIT License") 92 | url.set("https://opensource.org/licenses/MIT") 93 | } 94 | } 95 | 96 | developers { 97 | developer { 98 | id.set("kdroidFilter") 99 | name.set("Elie Gambache") 100 | email.set("elyahou.hadass@gmail.com") 101 | } 102 | } 103 | 104 | scm { 105 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 106 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 107 | url.set("https://github.com/kdroidFilter/StoreKit/") 108 | } 109 | } 110 | 111 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 112 | signAllPublications() 113 | } -------------------------------------------------------------------------------- /apkcombo/scraper/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.SonatypeHost 2 | 3 | plugins { 4 | alias(libs.plugins.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.vannitktech.maven.publish) 8 | } 9 | 10 | group = "io.github.kdroidfilter.storekit.apkcombo.scraper" 11 | val ref = System.getenv("GITHUB_REF") ?: "" 12 | val version = if (ref.startsWith("refs/tags/")) { 13 | val tag = ref.removePrefix("refs/tags/") 14 | if (tag.startsWith("v")) tag.substring(1) else tag 15 | } else "dev" 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | androidTarget { 20 | publishLibraryVariants("release") 21 | } 22 | 23 | jvm() 24 | 25 | sourceSets { 26 | commonMain.dependencies { 27 | api(project(":apkcombo:core")) 28 | implementation(libs.kotlinx.coroutines.core) 29 | implementation(libs.kotlinx.serialization.json) 30 | compileOnly(libs.ktor.client.core) 31 | compileOnly(libs.ktor.client.content.negotiation) 32 | compileOnly(libs.ktor.client.serialization) 33 | compileOnly(libs.ktor.client.logging) 34 | compileOnly(libs.ktor.client.cio) 35 | implementation(libs.ksoup.html) 36 | implementation(libs.ksoup.entities) 37 | implementation(libs.kotlin.logging) 38 | } 39 | 40 | commonTest.dependencies { 41 | implementation(kotlin("test")) 42 | implementation(libs.ktor.client.core) 43 | implementation(libs.ktor.client.content.negotiation) 44 | implementation(libs.ktor.client.serialization) 45 | implementation(libs.ktor.client.logging) 46 | implementation(libs.ktor.client.cio) 47 | } 48 | 49 | androidMain.dependencies { 50 | implementation(libs.kotlinx.coroutines.android) 51 | } 52 | 53 | jvmMain.dependencies { 54 | implementation(libs.kotlinx.coroutines.swing) 55 | implementation(libs.slf4j.simple) 56 | } 57 | } 58 | 59 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers 60 | targets.withType { 61 | compilations["main"].compilerOptions.options.freeCompilerArgs.add("-Xexport-kdoc") 62 | } 63 | } 64 | 65 | android { 66 | namespace = "io.github.kdroidfilter.storekit.apkcombo.scraper" 67 | compileSdk = 35 68 | 69 | defaultConfig { 70 | minSdk = 21 71 | } 72 | } 73 | 74 | mavenPublishing { 75 | coordinates( 76 | groupId = "io.github.kdroidfilter", 77 | artifactId = "storekit-apkcombo-scraper", 78 | version = version.toString() 79 | ) 80 | 81 | pom { 82 | name.set("APKCombo Scraper Library") 83 | description.set("APKCombo Scraper Library is a Kotlin library for extracting app data from the APKCombo website.") 84 | inceptionYear.set("2024") 85 | url.set("https://github.com/kdroidFilter/StoreKit/") 86 | 87 | licenses { 88 | license { 89 | name.set("MIT License") 90 | url.set("https://opensource.org/licenses/MIT") 91 | } 92 | } 93 | 94 | developers { 95 | developer { 96 | id.set("kdroidFilter") 97 | name.set("Elie Gambache") 98 | email.set("elyahou.hadass@gmail.com") 99 | } 100 | } 101 | 102 | scm { 103 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 104 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 105 | url.set("https://github.com/kdroidFilter/StoreKit/") 106 | } 107 | } 108 | 109 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 110 | 111 | signAllPublications() 112 | } -------------------------------------------------------------------------------- /sample/src/jvmMain/kotlin/io/github/kdroidfilter/storekit/sample/Main.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.sample 2 | 3 | import io.github.kdroidfilter.storekit.gplay.scrapper.services.getGooglePlayApplicationInfo 4 | import io.github.kdroidfilter.storekit.apklinkresolver.core.service.ApkSourcePriority 5 | import io.github.kdroidfilter.storekit.apklinkresolver.core.service.ApkLinkResolverService 6 | import io.github.kdroidfilter.storekit.apklinkresolver.core.service.ApkSource 7 | 8 | import io.github.kdroidfilter.storekit.aptoide.api.services.AptoideService 9 | import io.github.kdroidfilter.storekit.fdroid.api.services.FDroidService 10 | import io.github.kdroidfilter.storekit.apkpure.scraper.services.getApkPureApplicationInfo 11 | import kotlinx.coroutines.runBlocking 12 | import kotlinx.serialization.json.Json 13 | 14 | /** 15 | * Sample application that creates example instances of Aptoide, F-Droid, Google Play, APKPure, and APK Downloader models 16 | * and prints them as JSON. 17 | */ 18 | fun main() { 19 | 20 | ApkSourcePriority.setPriorityOrder( 21 | listOf( 22 | ApkSource.APKPURE, 23 | ApkSource.APKCOMBO, 24 | ApkSource.FDROID, 25 | ApkSource.APTOIDE, 26 | ) 27 | ) 28 | 29 | // Create a pretty-printed JSON formatter 30 | val json = Json { 31 | prettyPrint = true 32 | encodeDefaults = true 33 | } 34 | runBlocking { 35 | 36 | // Create an example Google Play application info 37 | val gplayApp = getGooglePlayApplicationInfo("com.waze") 38 | 39 | val aptoideService = AptoideService() 40 | 41 | // Create an example Aptoide application info 42 | val aptoideApp = aptoideService.getAppMetaByPackageName("com.waze") 43 | 44 | // Print the Google Play example as JSON 45 | println("=== Google Play Example ===") 46 | println(json.encodeToString(gplayApp)) 47 | println() 48 | 49 | // Print the Aptoide example as JSON 50 | println("=== Aptoide Example ===") 51 | println(json.encodeToString(aptoideApp)) 52 | println() 53 | 54 | val fdroidService = FDroidService() 55 | 56 | // Create an example F-Droid package info 57 | val fdroidPackage = fdroidService.getPackageInfo("net.thunderbird.android") 58 | 59 | // Print the F-Droid example as JSON 60 | println("=== F-Droid Example ===") 61 | println(json.encodeToString(fdroidPackage)) 62 | println() 63 | 64 | // APKPure example 65 | try { 66 | val apkpureApp = getApkPureApplicationInfo("com.citycar.flutter") 67 | println("=== APKPure Example ===") 68 | println(json.encodeToString(apkpureApp)) 69 | println() 70 | } catch (e: Exception) { 71 | println("Error retrieving APKPure info: ${e.message}") 72 | } 73 | 74 | // APK Downloader example 75 | println("=== APK Downloader Example ===") 76 | 77 | // Create an instance of the APK Downloader service 78 | val apkLinkResolverService = ApkLinkResolverService() 79 | 80 | try { 81 | // Get download link for a package using the custom priority 82 | val downloadInfo = apkLinkResolverService.getApkDownloadLink("com.unicell.pangoandroid") 83 | 84 | println("Download info for com.apple.bnd:") 85 | println(json.encodeToString(downloadInfo)) 86 | println() 87 | println("Source: ${downloadInfo.source}") 88 | println("Title: ${downloadInfo.title}") 89 | println("Version: ${downloadInfo.version}") 90 | println("Version Code: ${downloadInfo.versionCode}") 91 | println("Download Link: ${downloadInfo.downloadLink}") 92 | println("File Size: ${downloadInfo.fileSize} bytes") 93 | } catch (e: Exception) { 94 | println("Error retrieving download link: ${e.message}") 95 | } finally { 96 | // Reset to default priority 97 | ApkSourcePriority.resetToDefault() 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /apklinkresolver/core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.vanniktech.maven.publish.SonatypeHost 2 | 3 | plugins { 4 | alias(libs.plugins.multiplatform) 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.vannitktech.maven.publish) 8 | } 9 | 10 | group = "io.github.kdroidfilter.storekit.apklinkresolver.core" 11 | val ref = System.getenv("GITHUB_REF") ?: "" 12 | val version = if (ref.startsWith("refs/tags/")) { 13 | val tag = ref.removePrefix("refs/tags/") 14 | if (tag.startsWith("v")) tag.substring(1) else tag 15 | } else "dev" 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | androidTarget { 20 | publishLibraryVariants("release") 21 | } 22 | jvm() 23 | 24 | sourceSets { 25 | commonMain.dependencies { 26 | implementation(libs.kotlinx.serialization.json) 27 | implementation(project(":apkcombo:core")) 28 | implementation(project(":aptoide:core")) 29 | implementation(project(":aptoide:api")) 30 | implementation(project(":apkcombo:scraper")) 31 | implementation(project(":fdroid:core")) 32 | implementation(project(":fdroid:api")) 33 | implementation(project(":apkpure:core")) 34 | implementation(project(":apkpure:scraper")) 35 | implementation(libs.kotlin.logging) 36 | compileOnly(libs.ktor.client.core) 37 | compileOnly(libs.ktor.client.content.negotiation) 38 | compileOnly(libs.ktor.client.serialization) 39 | compileOnly(libs.ktor.client.cio) 40 | compileOnly(libs.ktor.client.logging) 41 | 42 | } 43 | 44 | commonTest.dependencies { 45 | implementation(kotlin("test")) 46 | implementation(libs.kotlinx.coroutines.core) 47 | implementation(libs.ktor.client.core) 48 | implementation(libs.ktor.client.content.negotiation) 49 | implementation(libs.ktor.client.serialization) 50 | implementation(libs.ktor.client.logging) 51 | implementation(libs.ktor.client.cio) 52 | } 53 | 54 | androidUnitTest.dependencies { 55 | implementation(libs.kotlinx.coroutines.android) 56 | } 57 | 58 | jvmTest.dependencies { 59 | implementation(libs.kotlinx.coroutines.swing) 60 | } 61 | } 62 | 63 | //https://kotlinlang.org/docs/native-objc-interop.html#export-of-kdoc-comments-to-generated-objective-c-headers 64 | targets.withType { 65 | compilations["main"].compilerOptions.options.freeCompilerArgs.add("-Xexport-kdoc") 66 | } 67 | } 68 | 69 | android { 70 | namespace = "io.github.kdroidfilter.storekit.apklinkresolver.core" 71 | compileSdk = 35 72 | 73 | defaultConfig { 74 | minSdk = 21 75 | } 76 | } 77 | 78 | mavenPublishing { 79 | coordinates( 80 | groupId = "io.github.kdroidfilter", 81 | artifactId = "storekit-apklinkresolver-core", 82 | version = version.toString() 83 | ) 84 | 85 | pom { 86 | name.set("APK Link Resolver Library") 87 | description.set("Core module for APK Link Resolver Library containing model classes and services") 88 | inceptionYear.set("2024") 89 | url.set("https://github.com/kdroidFilter/StoreKit/") 90 | 91 | licenses { 92 | license { 93 | name.set("MIT License") 94 | url.set("https://opensource.org/licenses/MIT") 95 | } 96 | } 97 | 98 | developers { 99 | developer { 100 | id.set("kdroidFilter") 101 | name.set("Elie Gambache") 102 | email.set("elyahou.hadass@gmail.com") 103 | } 104 | } 105 | 106 | scm { 107 | connection.set("scm:git:git://github.com/kdroidFilter/StoreKit.git") 108 | developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/StoreKit.git") 109 | url.set("https://github.com/kdroidFilter/StoreKit/") 110 | } 111 | } 112 | 113 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 114 | 115 | signAllPublications() 116 | } 117 | -------------------------------------------------------------------------------- /apklinkresolver/core/src/commonMain/kotlin/io/github/kdroidfilter/storekit/apklinkresolver/core/utils/FileUtils.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apklinkresolver.core.utils 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.engine.cio.CIO 6 | import io.ktor.client.plugins.UserAgent 7 | import io.ktor.client.request.get 8 | import io.ktor.client.request.head 9 | import io.ktor.client.request.header 10 | import io.ktor.http.HttpHeaders 11 | import io.ktor.http.HttpStatusCode 12 | 13 | /** 14 | * Utility functions for file operations. 15 | */ 16 | object FileUtils { 17 | private val logger = KotlinLogging.logger {} 18 | 19 | /** 20 | * Retrieves the file size from a URL. 21 | * First tries a HEAD request, then falls back to a GET request with Range header, 22 | * and finally tries a regular GET request if both previous methods fail. 23 | * 24 | * @param url The URL to check 25 | * @return The file size in bytes, or -1 if the size could not be determined 26 | */ 27 | suspend fun getFileSizeFromUrl(url: String): Long { 28 | logger.info { "Retrieving file size for URL: $url" } 29 | 30 | val client = HttpClient(CIO) { 31 | install(UserAgent) { 32 | agent = "Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.7151.116 Mobile" 33 | } 34 | } 35 | 36 | return try { 37 | // First try: HEAD request (most efficient) 38 | try { 39 | val headResponse = client.head(url) 40 | 41 | if (headResponse.status == HttpStatusCode.OK) { 42 | // Extract Content-Length header 43 | val contentLength = headResponse.headers[HttpHeaders.ContentLength]?.toLongOrNull() 44 | 45 | if (contentLength != null && contentLength > 0) { 46 | logger.info { "File size from HEAD request: $contentLength bytes" } 47 | return contentLength 48 | } 49 | } 50 | logger.info { "HEAD request didn't provide file size, trying GET with Range header" } 51 | } catch (e: Exception) { 52 | logger.info { "HEAD request failed: ${e.message}, trying GET with Range header" } 53 | } 54 | 55 | // Second try: GET request with Range header (requests only first byte) 56 | try { 57 | val rangeResponse = client.get(url) { 58 | header(HttpHeaders.Range, "bytes=0-0") 59 | } 60 | 61 | // Check for Content-Range header which contains total size 62 | val contentRange = rangeResponse.headers[HttpHeaders.ContentRange] 63 | if (contentRange != null) { 64 | // Content-Range format: "bytes 0-0/12345" where 12345 is the total size 65 | val totalSize = contentRange.substringAfter("/").toLongOrNull() 66 | if (totalSize != null && totalSize > 0) { 67 | logger.info { "File size from Content-Range: $totalSize bytes" } 68 | return totalSize 69 | } 70 | } 71 | 72 | // Check if we got Content-Length header as fallback 73 | val contentLength = rangeResponse.headers[HttpHeaders.ContentLength]?.toLongOrNull() 74 | if (contentLength != null && contentLength > 0) { 75 | logger.info { "File size from GET with Range: $contentLength bytes" } 76 | return contentLength 77 | } 78 | 79 | logger.info { "GET with Range didn't provide file size, trying regular GET" } 80 | } catch (e: Exception) { 81 | logger.info { "GET with Range failed: ${e.message}, trying regular GET" } 82 | } 83 | 84 | // Third try: Regular GET request (least efficient, but most compatible) 85 | val getResponse = client.get(url) 86 | 87 | if (getResponse.status == HttpStatusCode.OK) { 88 | val contentLength = getResponse.headers[HttpHeaders.ContentLength]?.toLongOrNull() 89 | 90 | if (contentLength != null && contentLength > 0) { 91 | logger.info { "File size from GET request: $contentLength bytes" } 92 | return contentLength 93 | } 94 | } 95 | 96 | logger.warn { "Could not determine file size after all attempts" } 97 | -1 98 | } catch (e: Exception) { 99 | logger.error(e) { "Error retrieving file size: ${e.message}" } 100 | -1 101 | } finally { 102 | client.close() 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /gplay/core/src/commonMain/kotlin/io/github/kdroidfilter/storekit/gplay/core/model/GooglePlayApplicationInfo.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.gplay.core.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | /** 6 | * Represents a category on the Google Play Store. 7 | * 8 | * @property name Name of the category 9 | * @property id Unique identifier of the category 10 | */ 11 | @Serializable 12 | data class GooglePlayCategory(val name: String, val id: String) 13 | 14 | /** 15 | * Comprehensive data model that represents detailed information about an application on the Google Play Store. 16 | * 17 | * @property title The title/name of the application 18 | * @property description Plain text description of the application 19 | * @property descriptionHTML HTML-formatted description of the application 20 | * @property summary A short summary of the application 21 | * @property installs Number of installs as a formatted string (e.g., "10,000,000+") 22 | * @property minInstalls Minimum number of installs as a numeric value 23 | * @property realInstalls Estimated real number of installs 24 | * @property score Overall rating score (0.0 to 5.0) 25 | * @property ratings Total number of ratings 26 | * @property reviews Total number of reviews 27 | * @property histogram Distribution of ratings (1-5 stars) 28 | * @property price Price of the application in the specified currency 29 | * @property free Whether the application is free 30 | * @property currency Currency code for the price (e.g., "USD") 31 | * @property sale Whether the application is on sale 32 | * @property saleTime Duration of the sale in milliseconds 33 | * @property originalPrice Original price before the sale 34 | * @property saleText Text describing the sale 35 | * @property offersIAP Whether the application offers in-app purchases 36 | * @property inAppProductPrice Price range for in-app purchases 37 | * @property developer Name of the developer 38 | * @property developerId Unique identifier of the developer 39 | * @property developerEmail Contact email of the developer 40 | * @property developerWebsite Website of the developer 41 | * @property developerAddress Physical address of the developer 42 | * @property privacyPolicy URL to the privacy policy 43 | * @property genre Primary genre of the application 44 | * @property genreId Identifier of the primary genre 45 | * @property categories List of categories the application belongs to 46 | * @property icon URL to the application icon 47 | * @property headerImage URL to the header image 48 | * @property screenshots URLs to the application screenshots 49 | * @property video URL to the promotional video 50 | * @property videoImage URL to the video thumbnail 51 | * @property contentRating Content rating (e.g., "Everyone", "Teen") 52 | * @property contentRatingDescription Description of the content rating 53 | * @property adSupported Whether the application is supported by ads 54 | * @property containsAds Whether the application contains ads 55 | * @property released Release date of the application 56 | * @property updated Last update timestamp 57 | * @property version Current version of the application 58 | * @property comments List of user comments 59 | * @property appId Unique identifier of the application 60 | * @property url URL to the application's page on Google Play 61 | */ 62 | @Serializable 63 | data class GooglePlayApplicationInfo( 64 | val title: String = "", 65 | val description: String = "", 66 | val descriptionHTML: String = "", 67 | val summary: String = "", 68 | val installs: String = "", 69 | val minInstalls: Long = 0, 70 | val realInstalls: Long = 0, 71 | val score: Double = 0.0, 72 | val ratings: Long = 0, 73 | val reviews: Long = 0, 74 | val histogram: List = emptyList(), 75 | val price: Double = 0.0, 76 | val free: Boolean = false, 77 | val currency: String = "", 78 | val sale: Boolean = false, 79 | val saleTime: Long? = null, 80 | val originalPrice: Double? = null, 81 | val saleText: String? = null, 82 | val offersIAP: Boolean = false, 83 | val inAppProductPrice: String = "", 84 | val developer: String = "", 85 | val developerId: String = "", 86 | val developerEmail: String = "", 87 | val developerWebsite: String = "", 88 | val developerAddress: String = "", 89 | val privacyPolicy: String = "", 90 | val genre: String = "", 91 | val genreId: String = "", 92 | val categories: List = emptyList(), 93 | val icon: String = "", 94 | val headerImage: String = "", 95 | val screenshots: List = emptyList(), 96 | val video: String = "", 97 | val videoImage: String = "", 98 | val contentRating: String = "", 99 | val contentRatingDescription: String = "", 100 | val adSupported: Boolean = false, 101 | val containsAds: Boolean = false, 102 | val released: String = "", 103 | val updated: Long = 0, 104 | val version: String = "Varies with device", 105 | val comments: List = emptyList(), 106 | val appId: String = "", 107 | val url: String = "" 108 | ) 109 | -------------------------------------------------------------------------------- /aptoide/api/src/commonMain/kotlin/io/github/kdroidfilter/storekit/aptoide/api/services/AptoideService.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.aptoide.api.services 2 | 3 | import io.github.kdroidfilter.storekit.aptoide.api.constants.APP_GET_META_PATH 4 | import io.github.kdroidfilter.storekit.aptoide.api.constants.BASE_APTOIDE_API_URL 5 | import io.github.kdroidfilter.storekit.aptoide.core.model.AptoideApplicationInfo 6 | import io.github.kdroidfilter.storekit.aptoide.core.model.AptoideResponse 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import io.ktor.client.* 9 | import io.ktor.client.engine.cio.* 10 | import io.ktor.client.plugins.logging.* 11 | import io.ktor.client.request.* 12 | import io.ktor.client.statement.* 13 | import io.ktor.http.* 14 | import kotlinx.serialization.json.Json 15 | 16 | /** 17 | * A service class for interacting with the Aptoide API. 18 | * This class provides methods to fetch app metadata from the Aptoide API. 19 | */ 20 | class AptoideService { 21 | private val logger = KotlinLogging.logger {} 22 | private val client = HttpClient(CIO) { 23 | install(Logging) { 24 | level = LogLevel.INFO 25 | } 26 | } 27 | 28 | private val json = Json { 29 | ignoreUnknownKeys = true 30 | coerceInputValues = true 31 | } 32 | 33 | /** 34 | * Fetches application metadata from the Aptoide API using the package name. 35 | * 36 | * @param packageName The package name of the application. 37 | * @param language The language code for the content localization. Defaults to "en". 38 | * @return An instance of [AptoideApplicationInfo] containing the application's metadata. 39 | * @throws IllegalArgumentException if the application with the given package name does not exist or is not accessible. 40 | */ 41 | suspend fun getAppMetaByPackageName(packageName: String, language: String = "en"): AptoideApplicationInfo { 42 | logger.info { "Fetching app metadata for package name: $packageName" } 43 | val url = "$BASE_APTOIDE_API_URL$APP_GET_META_PATH" 44 | 45 | val response = client.get(url) { 46 | parameter("package_name", packageName) 47 | parameter("language", language) 48 | } 49 | 50 | if (!response.status.isSuccess()) { 51 | throw IllegalArgumentException("Application with package name: $packageName does not exist or is not accessible. HTTP status: ${response.status}") 52 | } 53 | 54 | val responseText = response.bodyAsText() 55 | val aptoideResponse = json.decodeFromString(responseText) 56 | logger.info { "Successfully fetched app metadata for package name: $packageName" } 57 | 58 | return aptoideResponse.data 59 | } 60 | 61 | /** 62 | * Fetches application metadata from the Aptoide API using the app ID. 63 | * 64 | * @param appId The ID of the application. 65 | * @param language The language code for the content localization. Defaults to "en". 66 | * @return An instance of [AptoideApplicationInfo] containing the application's metadata. 67 | * @throws IllegalArgumentException if the application with the given ID does not exist or is not accessible. 68 | */ 69 | suspend fun getAppMetaById(appId: Long, language: String = "en"): AptoideApplicationInfo { 70 | logger.info { "Fetching app metadata for app ID: $appId" } 71 | val url = "$BASE_APTOIDE_API_URL$APP_GET_META_PATH" 72 | 73 | val response = client.get(url) { 74 | parameter("app_id", appId) 75 | parameter("language", language) 76 | } 77 | 78 | if (!response.status.isSuccess()) { 79 | throw IllegalArgumentException("Application with app ID: $appId does not exist or is not accessible. HTTP status: ${response.status}") 80 | } 81 | 82 | val responseText = response.bodyAsText() 83 | val aptoideResponse = json.decodeFromString(responseText) 84 | logger.info { "Successfully fetched app metadata for app ID: $appId" } 85 | 86 | return aptoideResponse.data 87 | } 88 | 89 | /** 90 | * Fetches application metadata from the Aptoide API using the APK MD5 sum. 91 | * 92 | * @param md5sum The MD5 sum of the APK file. 93 | * @param language The language code for the content localization. Defaults to "en". 94 | * @return An instance of [AptoideApplicationInfo] containing the application's metadata. 95 | * @throws IllegalArgumentException if the application with the given MD5 sum does not exist or is not accessible. 96 | */ 97 | suspend fun getAppMetaByMd5sum(md5sum: String, language: String = "en"): AptoideApplicationInfo { 98 | logger.info { "Fetching app metadata for APK MD5 sum: $md5sum" } 99 | val url = "$BASE_APTOIDE_API_URL$APP_GET_META_PATH" 100 | 101 | val response = client.get(url) { 102 | parameter("apk_md5sum", md5sum) 103 | parameter("language", language) 104 | } 105 | 106 | if (!response.status.isSuccess()) { 107 | throw IllegalArgumentException("Application with APK MD5 sum: $md5sum does not exist or is not accessible. HTTP status: ${response.status}") 108 | } 109 | 110 | val responseText = response.bodyAsText() 111 | val aptoideResponse = json.decodeFromString(responseText) 112 | logger.info { "Successfully fetched app metadata for APK MD5 sum: $md5sum" } 113 | 114 | return aptoideResponse.data 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /apkpure/scraper/src/commonMain/kotlin/io/github/kdroidfilter/storekit/apkpure/scraper/services/ApkPureScraperService.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apkpure.scraper.services 2 | 3 | import io.github.kdroidfilter.storekit.apkpure.core.model.ApkPureApplicationInfo 4 | import io.ktor.client.* 5 | import io.ktor.client.call.* 6 | import io.ktor.client.request.* 7 | import io.ktor.client.statement.* 8 | import io.ktor.http.* 9 | 10 | private const val BASE_APKPURE_URL = "https://apkpure.com" 11 | private const val APP_PATH = "/app" 12 | private const val DOWNLOAD_SUFFIX = "/download" 13 | private const val DIRECT_DL_BASE = "https://d.apkpure.com/b/XAPK" 14 | 15 | // Build direct download URL: https://d.apkpure.com/b/XAPK/?version= 16 | fun buildApkPureDownloadUrl(packageName: String, version: String = "latest"): String { 17 | val versionParam = if (version.isBlank()) "latest" else version 18 | return "$DIRECT_DL_BASE/$packageName?version=$versionParam" 19 | } 20 | 21 | // Build info page URL: https://apkpure.com/app//download 22 | fun buildApkPureInfoUrl(packageName: String): String = "$BASE_APKPURE_URL$APP_PATH/$packageName$DOWNLOAD_SUFFIX" 23 | 24 | suspend fun getApkPureApplicationInfo( 25 | packageName: String, 26 | client: HttpClient = defaultClient() 27 | ): ApkPureApplicationInfo { 28 | val url = buildApkPureInfoUrl(packageName) 29 | val response = client.get(url) 30 | if (!response.status.isSuccess()) { 31 | throw IllegalArgumentException("Application with packageName: $packageName does not exist or is not accessible. HTTP status: ${response.status}") 32 | } 33 | val html = response.bodyAsText() 34 | 35 | val title = extractTitle(html) 36 | val version = extractVersion(html) 37 | val versionCode = extractVersionCode(html).ifBlank { extractVersionCodeFallback(html) } 38 | val signature = extractSignature(html) 39 | 40 | val downloadLink = buildApkPureDownloadUrl(packageName, "latest") 41 | 42 | return ApkPureApplicationInfo( 43 | title = if (title.isNotBlank()) title else "Unknown App", 44 | version = version, 45 | versionCode = versionCode, 46 | signature = signature, 47 | downloadLink = downloadLink, 48 | appId = packageName, 49 | url = url 50 | ) 51 | } 52 | 53 | internal fun extractTitle(html: String): String { 54 | val patterns = listOf( 55 | Regex("""]*class=\"[^\"]*name[^\"]*\"[^>]*>(.*?)""", RegexOption.IGNORE_CASE), 56 | Regex("""([^<]+)""", RegexOption.IGNORE_CASE) 57 | ) 58 | for (p in patterns) { 59 | val m = p.find(html) 60 | if (m != null) { 61 | return m.groupValues[1] 62 | .replace("&", "&") 63 | .replace(" - APK Download", "") 64 | .trim() 65 | } 66 | } 67 | return "" 68 | } 69 | 70 | internal fun extractVersion(html: String): String { 71 | val patterns = listOf( 72 | Regex("""]*class=\"[^\"]*version( name)?[^\"]*\"[^>]*>\s*]*>([^<]+)""", RegexOption.IGNORE_CASE), 73 | Regex("""]*class=\"[^\"]*vername[^\"]*\"[^>]*>([^<]+)""", RegexOption.IGNORE_CASE) 74 | ) 75 | for (p in patterns) { 76 | val m = p.find(html) 77 | if (m != null) { 78 | val idx = if (m.groupValues.size >= 3) 2 else 1 79 | return m.groupValues[idx].trim() 80 | } 81 | } 82 | return "" 83 | } 84 | 85 | internal fun extractVersionCode(html: String): String { 86 | val p = Regex("""]*class=\"[^\"]*vercode[^\"]*\"[^>]*>\(([^)]+)\)""", RegexOption.IGNORE_CASE) 87 | return p.find(html)?.groupValues?.get(1)?.trim().orEmpty() 88 | } 89 | 90 | // Fallbacks: extract versionCode from variant blocks or URLs 91 | internal fun extractVersionCodeFallback(html: String): String { 92 | val patterns = listOf( 93 | // Variant info-top line like: (1030640) 94 | Regex("""]*class=\"[^\"]*code[^\"]*\"[^>]*>\\(([^)]+)\\)""", RegexOption.IGNORE_CASE), 95 | // Download button href: https://d.apkpure.com/b/XAPK/com.package?versionCode=1030640 96 | Regex("href=\"[^\"]*versionCode=([0-9]+)[^\"]*\""), 97 | // Data attribute variant code, sometimes plain number inside span 98 | Regex("""\(\s*([0-9]{3,})\s*\)""") 99 | ) 100 | for (p in patterns) { 101 | val m = p.find(html) 102 | if (m != null) return m.groupValues[1].trim() 103 | } 104 | return "" 105 | } 106 | 107 | // Extract signature from More App Info section or variant dialog 108 | internal fun extractSignature(html: String): String { 109 | val patterns = listOf( 110 | // More App Info list item:
Signature
35b4...
111 | Regex("""Signature\s*]*class=\"value[^\"]*\"[^>]*>([a-fA-F0-9]{8,})""", RegexOption.IGNORE_CASE), 112 | // Variant dialog line: Signature35b4... 113 | Regex("""]*class=\"label\"[^>]*>\s*Signature\s*\s*]*class=\"value\"[^>]*>\s*([a-fA-F0-9]{8,})\s*""", RegexOption.IGNORE_CASE) 114 | ) 115 | for (p in patterns) { 116 | val m = p.find(html) 117 | if (m != null) return m.groupValues[1].trim() 118 | } 119 | return "" 120 | } 121 | 122 | private fun defaultClient(): HttpClient { 123 | // Keep minimal: rely on platform default if the consumer injects a configured client elsewhere. 124 | return HttpClient() 125 | } 126 | -------------------------------------------------------------------------------- /authenticity/src/androidMain/kotlin/io/github/kdroidfilter/storekit/authenticity/SignatureExtractor.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.authenticity 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | import android.content.pm.Signature 6 | import android.content.pm.SigningInfo 7 | import android.os.Build 8 | import java.security.MessageDigest 9 | import java.security.NoSuchAlgorithmException 10 | 11 | /** 12 | * Utility class for extracting app signatures in SHA1 format from installed Android applications. 13 | */ 14 | class SignatureExtractor { 15 | companion object { 16 | /** 17 | * Extracts the SHA1 signature of an installed Android application. 18 | * 19 | * @param context The Android context. 20 | * @param packageName The package name of the application. 21 | * @return The SHA1 signature of the application, or null if the application is not installed or the signature cannot be extracted. 22 | */ 23 | fun extractSha1Signature(context: Context, packageName: String): String? { 24 | return try { 25 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 26 | // For Android P (API 28) and above, use GET_SIGNING_CERTIFICATES 27 | val packageInfo = context.packageManager.getPackageInfo( 28 | packageName, 29 | PackageManager.GET_SIGNING_CERTIFICATES 30 | ) 31 | val signingInfo = packageInfo.signingInfo 32 | val signatures = signingInfo?.apkContentsSigners 33 | if (signatures != null && signatures.isNotEmpty()) { 34 | convertSignatureToSha1(signatures[0]) 35 | } else { 36 | null 37 | } 38 | } else { 39 | // For older versions, use GET_SIGNATURES with safe calls 40 | @Suppress("DEPRECATION") 41 | val packageInfo = context.packageManager.getPackageInfo( 42 | packageName, 43 | PackageManager.GET_SIGNATURES 44 | ) 45 | val signatures = packageInfo.signatures 46 | if (signatures != null && signatures.isNotEmpty()) { 47 | convertSignatureToSha1(signatures[0]) 48 | } else { 49 | null 50 | } 51 | } 52 | } catch (e: PackageManager.NameNotFoundException) { 53 | null 54 | } catch (e: NoSuchAlgorithmException) { 55 | null 56 | } catch (e: Exception) { 57 | null 58 | } 59 | } 60 | 61 | /** 62 | * Converts a signature to SHA1 format. 63 | * 64 | * @param signature The signature to convert. 65 | * @return The SHA1 representation of the signature. 66 | * @throws NoSuchAlgorithmException If the SHA1 algorithm is not available. 67 | */ 68 | @Throws(NoSuchAlgorithmException::class) 69 | private fun convertSignatureToSha1(signature: Signature): String { 70 | val md = MessageDigest.getInstance("SHA1") 71 | md.update(signature.toByteArray()) 72 | return bytesToHexString(md.digest()) 73 | } 74 | 75 | /** 76 | * Converts a byte array to a hexadecimal string. 77 | * 78 | * @param bytes The byte array to convert. 79 | * @return The hexadecimal string representation of the byte array. 80 | */ 81 | private fun bytesToHexString(bytes: ByteArray): String { 82 | val sb = StringBuilder() 83 | for (b in bytes) { 84 | val hex = Integer.toHexString(0xFF and b.toInt()) 85 | if (hex.length == 1) { 86 | sb.append('0') 87 | } 88 | sb.append(hex) 89 | } 90 | return sb.toString() 91 | } 92 | 93 | /** 94 | * Verifies if a given string is a valid SHA1 signature. 95 | * 96 | * @param sha1String The SHA1 string to verify. 97 | * @return True if the string is a valid SHA1 signature, false otherwise. 98 | */ 99 | fun isValidSha1Signature(sha1String: String): Boolean { 100 | // A valid SHA1 hash is 40 characters long (20 bytes * 2 hex chars per byte) 101 | if (sha1String.length != 40) { 102 | return false 103 | } 104 | 105 | // Check if all characters are valid hexadecimal digits 106 | return sha1String.all { char -> 107 | char in '0'..'9' || char in 'a'..'f' || char in 'A'..'F' 108 | } 109 | } 110 | 111 | /** 112 | * Verifies if the signature of an installed package matches a provided SHA1 signature. 113 | * 114 | * @param context The Android context. 115 | * @param packageName The package name of the application to verify. 116 | * @param expectedSha1 The expected SHA1 signature to compare against. 117 | * @return True if the signatures match, false otherwise (including if the package is not installed 118 | * or the signature cannot be extracted, or if the provided SHA1 is invalid). 119 | */ 120 | fun verifyPackageSignature(context: Context, packageName: String, expectedSha1: String): Boolean { 121 | // First, validate the expected SHA1 signature 122 | if (!isValidSha1Signature(expectedSha1)) { 123 | return false 124 | } 125 | 126 | // Extract the actual signature from the installed package 127 | val actualSha1 = extractSha1Signature(context, packageName) ?: return false 128 | 129 | // Compare the signatures (case-insensitive comparison) 130 | return actualSha1.equals(expectedSha1, ignoreCase = true) 131 | } 132 | } 133 | } -------------------------------------------------------------------------------- /authenticity/src/androidMain/kotlin/io/github/kdroidfilter/storekit/authenticity/InstallationSourceDetector.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.authenticity 2 | 3 | import android.content.Context 4 | import android.content.pm.ApplicationInfo 5 | import android.content.pm.PackageManager 6 | import android.os.Build 7 | 8 | /** 9 | * Enum representing different installation sources for Android applications. 10 | */ 11 | enum class InstallationSource { 12 | GOOGLE_PLAY, 13 | AMAZON, 14 | SAMSUNG, 15 | HUAWEI, 16 | XIAOMI, 17 | OPPO, 18 | VIVO, 19 | ONEPLUS, 20 | SIDELOADED, 21 | SYSTEM, 22 | OTHER 23 | } 24 | 25 | /** 26 | * Data class containing information about the installation source of an application. 27 | * 28 | * @property installerPackage The package name of the installer, or null if unknown. 29 | * @property installerName A human-readable name of the installer. 30 | * @property source The categorized installation source (including SYSTEM when applicable). 31 | */ 32 | data class InstallationInfo( 33 | val installerPackage: String?, 34 | val installerName: String, 35 | val source: InstallationSource, 36 | val isSystemApp: Boolean = source == InstallationSource.SYSTEM 37 | ) 38 | 39 | /** 40 | * Utility class for detecting the installation source of Android applications. 41 | */ 42 | class InstallationSourceDetector { 43 | 44 | companion object { 45 | /** 46 | * Detects the installation source of an Android application. 47 | * 48 | * @param context The Android context. 49 | * @param packageName The package name of the application. 50 | * @return Information about the installation source. 51 | */ 52 | fun detectInstallationSource(context: Context, packageName: String): InstallationInfo { 53 | val installerPackage = getInstallerPackageName(context, packageName) 54 | 55 | return if (installerPackage.isNullOrEmpty()) { 56 | // Si aucun installateur n'est identifié, on considère l'app comme sideloaded 57 | InstallationInfo(null, "Sideloaded/Unknown", InstallationSource.SIDELOADED) 58 | } else { 59 | classifyInstaller(installerPackage) 60 | } 61 | } 62 | 63 | /** 64 | * Checks if an application is a system app. 65 | * 66 | * @param context The Android context. 67 | * @param packageName The package name of the application. 68 | * @return True if the application is a system app, false otherwise. 69 | */ 70 | fun isSystemApp(context: Context, packageName: String): Boolean { 71 | return try { 72 | val packageManager = context.packageManager 73 | val packageInfo = packageManager.getPackageInfo(packageName, 0) 74 | 75 | // Check if the app is installed in the system partition 76 | (packageInfo.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM != 0 77 | } catch (e: Exception) { 78 | false 79 | } 80 | } 81 | 82 | /** 83 | * Gets the package name of the installer for an application. 84 | * 85 | * @param context The Android context. 86 | * @param packageName The package name of the application. 87 | * @return The package name of the installer, or null if it cannot be determined. 88 | */ 89 | private fun getInstallerPackageName(context: Context, packageName: String): String? { 90 | return try { 91 | val packageManager = context.packageManager 92 | 93 | // Si c'est une app système, on retourne un identifiant spécial 94 | if (isSystemApp(context, packageName)) { 95 | return "android.system" 96 | } 97 | 98 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 99 | packageManager.getInstallSourceInfo(packageName).installingPackageName 100 | } else { 101 | @Suppress("DEPRECATION") 102 | packageManager.getInstallerPackageName(packageName) 103 | } 104 | } catch (e: Exception) { 105 | null 106 | } 107 | } 108 | 109 | /** 110 | * Classifies the installer package name into a more specific installation source. 111 | * 112 | * @param installerPackage The package name of the installer. 113 | * @return Information about the classified installation source. 114 | */ 115 | private fun classifyInstaller(installerPackage: String): InstallationInfo { 116 | return when { 117 | installerPackage == "android.system" -> 118 | InstallationInfo(installerPackage, "Système Android", InstallationSource.SYSTEM) 119 | 120 | installerPackage.contains("google", ignoreCase = true) || 121 | installerPackage == "com.android.vending" -> 122 | InstallationInfo(installerPackage, "Google Play Store", InstallationSource.GOOGLE_PLAY) 123 | 124 | installerPackage.contains("amazon", ignoreCase = true) || 125 | installerPackage == "com.amazon.venezia" -> 126 | InstallationInfo(installerPackage, "Amazon Appstore", InstallationSource.AMAZON) 127 | 128 | installerPackage.contains("samsung", ignoreCase = true) || 129 | installerPackage == "com.sec.android.app.samsungapps" -> 130 | InstallationInfo(installerPackage, "Samsung Galaxy Store", InstallationSource.SAMSUNG) 131 | 132 | installerPackage.contains("huawei", ignoreCase = true) || 133 | installerPackage == "com.huawei.appmarket" -> 134 | InstallationInfo(installerPackage, "Huawei AppGallery", InstallationSource.HUAWEI) 135 | 136 | installerPackage.contains("xiaomi", ignoreCase = true) || 137 | installerPackage == "com.xiaomi.market" -> 138 | InstallationInfo(installerPackage, "Xiaomi GetApps", InstallationSource.XIAOMI) 139 | 140 | installerPackage.contains("oppo", ignoreCase = true) || 141 | installerPackage == "com.oppo.market" -> 142 | InstallationInfo(installerPackage, "OPPO App Market", InstallationSource.OPPO) 143 | 144 | installerPackage.contains("vivo", ignoreCase = true) || 145 | installerPackage == "com.vivo.appstore" -> 146 | InstallationInfo(installerPackage, "Vivo App Store", InstallationSource.VIVO) 147 | 148 | installerPackage.contains("oneplus", ignoreCase = true) || 149 | installerPackage == "com.oneplus.market" -> 150 | InstallationInfo(installerPackage, "OnePlus App Market", InstallationSource.ONEPLUS) 151 | 152 | else -> InstallationInfo(installerPackage, "Other Store", InstallationSource.OTHER) 153 | } 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /apklinkresolver/core/src/commonMain/kotlin/io/github/kdroidfilter/storekit/apklinkresolver/core/service/ApkLinkResolverService.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apklinkresolver.core.service 2 | 3 | import io.github.kdroidfilter.storekit.apkcombo.core.model.ApkComboApplicationInfo 4 | import io.github.kdroidfilter.storekit.apkcombo.scraper.services.getApkComboApplicationInfo 5 | import io.github.kdroidfilter.storekit.apklinkresolver.core.model.ApkLinkInfo 6 | import io.github.kdroidfilter.storekit.apklinkresolver.core.utils.FileUtils 7 | import io.github.kdroidfilter.storekit.aptoide.api.services.AptoideService 8 | import io.github.kdroidfilter.storekit.aptoide.core.model.AptoideApplicationInfo 9 | import io.github.kdroidfilter.storekit.fdroid.api.services.FDroidService 10 | import io.github.kdroidfilter.storekit.fdroid.core.model.FDroidPackageInfo 11 | import io.github.kdroidfilter.storekit.apkpure.core.model.ApkPureApplicationInfo 12 | import io.github.kdroidfilter.storekit.apkpure.scraper.services.getApkPureApplicationInfo 13 | import io.github.oshai.kotlinlogging.KotlinLogging 14 | 15 | /** 16 | * Service for retrieving APK download links from various sources based on configured priority. 17 | */ 18 | class ApkLinkResolverService { 19 | private val logger = KotlinLogging.logger {} 20 | private val aptoideService = AptoideService() 21 | private val fdroidService = FDroidService() 22 | 23 | /** 24 | * Retrieves an APK download link for the specified package name. 25 | * The sources are tried in the order specified by the [ApkSourcePriority] configuration. 26 | * 27 | * @param packageName The package name of the application 28 | * @return An [ApkLinkInfo] containing the download link and related information 29 | * @throws IllegalArgumentException if the application cannot be found in any of the configured sources 30 | */ 31 | suspend fun getApkDownloadLink(packageName: String): ApkLinkInfo { 32 | logger.info { "Retrieving APK download link for package: $packageName" } 33 | 34 | val priorityOrder = ApkSourcePriority.getPriorityOrder() 35 | logger.info { "Using source priority order: $priorityOrder" } 36 | 37 | val exceptions = mutableListOf() 38 | 39 | for (source in priorityOrder) { 40 | try { 41 | when (source) { 42 | ApkSource.APKCOMBO -> { 43 | logger.info { "Trying to get download link from APKCombo" } 44 | val appInfo = getApkComboApplicationInfo(packageName) 45 | return createApkLinkInfoFromApkCombo(appInfo) 46 | } 47 | ApkSource.APTOIDE -> { 48 | logger.info { "Trying to get download link from Aptoide" } 49 | val appInfo = aptoideService.getAppMetaByPackageName(packageName) 50 | return createApkLinkInfoFromAptoide(appInfo) 51 | } 52 | ApkSource.FDROID -> { 53 | logger.info { "Trying to get download link from F-Droid" } 54 | val packageInfo = fdroidService.getPackageInfo(packageName) 55 | return createApkLinkInfoFromFDroid(packageInfo) 56 | } 57 | ApkSource.APKPURE -> { 58 | logger.info { "Trying to get download link from APKPure" } 59 | val appInfo = getApkPureApplicationInfo(packageName) 60 | return createApkLinkInfoFromApkPure(appInfo) 61 | } 62 | } 63 | } catch (e: Exception) { 64 | logger.warn { "Failed to get download link from $source: ${e.message}" } 65 | exceptions.add(e) 66 | } 67 | } 68 | 69 | // If we get here, all sources failed 70 | val errorMessage = buildString { 71 | append("Failed to retrieve APK download link for package: $packageName. ") 72 | append("Tried the following sources: ") 73 | priorityOrder.forEachIndexed { index, source -> 74 | if (index > 0) append(", ") 75 | append(source.name) 76 | } 77 | append(". Errors: ") 78 | exceptions.forEachIndexed { index, exception -> 79 | if (index > 0) append("; ") 80 | append("${priorityOrder[index].name}: ${exception.message}") 81 | } 82 | } 83 | 84 | logger.error { errorMessage } 85 | throw IllegalArgumentException(errorMessage) 86 | } 87 | 88 | /** 89 | * Creates an [ApkLinkInfo] from an [ApkComboApplicationInfo]. 90 | */ 91 | private suspend fun createApkLinkInfoFromApkCombo(appInfo: ApkComboApplicationInfo): ApkLinkInfo { 92 | // Get file size from download link 93 | val fileSize = FileUtils.getFileSizeFromUrl(appInfo.downloadLink) 94 | 95 | return ApkLinkInfo( 96 | packageName = appInfo.appId, 97 | downloadLink = appInfo.downloadLink, 98 | source = ApkSource.APKCOMBO.name, 99 | version = appInfo.version, 100 | versionCode = appInfo.versionCode, 101 | title = appInfo.title, 102 | fileSize = fileSize 103 | ) 104 | } 105 | 106 | /** 107 | * Creates an [ApkLinkInfo] from an [AptoideApplicationInfo]. 108 | */ 109 | private suspend fun createApkLinkInfoFromAptoide(appInfo: AptoideApplicationInfo): ApkLinkInfo { 110 | // Aptoide provides the download link in the file.path property 111 | val downloadLink = if (appInfo.file.path.isNotEmpty()) { 112 | appInfo.file.path 113 | } else { 114 | appInfo.file.path_alt 115 | } 116 | 117 | // Get file size from download link 118 | val fileSize = FileUtils.getFileSizeFromUrl(downloadLink) 119 | 120 | return ApkLinkInfo( 121 | packageName = appInfo.packageName.ifEmpty { appInfo.package_ }, 122 | downloadLink = downloadLink, 123 | source = ApkSource.APTOIDE.name, 124 | version = appInfo.file.vername, 125 | versionCode = appInfo.file.vercode.toString(), 126 | title = appInfo.name, 127 | fileSize = fileSize 128 | ) 129 | } 130 | 131 | /** 132 | * Creates an [ApkLinkInfo] from an [ApkPureApplicationInfo]. 133 | */ 134 | private suspend fun createApkLinkInfoFromApkPure(appInfo: ApkPureApplicationInfo): ApkLinkInfo { 135 | val fileSize = FileUtils.getFileSizeFromUrl(appInfo.downloadLink) 136 | return ApkLinkInfo( 137 | packageName = appInfo.appId, 138 | downloadLink = appInfo.downloadLink, 139 | source = ApkSource.APKPURE.name, 140 | version = appInfo.version, 141 | versionCode = appInfo.versionCode, 142 | title = appInfo.title, 143 | fileSize = fileSize 144 | ) 145 | } 146 | 147 | /** 148 | * Creates an [ApkLinkInfo] from a [FDroidPackageInfo]. 149 | */ 150 | private suspend fun createApkLinkInfoFromFDroid(packageInfo: FDroidPackageInfo): ApkLinkInfo { 151 | // Get the download link for the suggested version 152 | val downloadLink = packageInfo.getSuggestedVersionDownloadLink() 153 | ?: throw IllegalArgumentException("No download link available for package: ${packageInfo.packageName}") 154 | 155 | // Get file size from download link 156 | val fileSize = FileUtils.getFileSizeFromUrl(downloadLink) 157 | 158 | // Find the suggested version details 159 | val suggestedVersion = packageInfo.packages.find { it.versionCode == packageInfo.suggestedVersionCode } 160 | 161 | return ApkLinkInfo( 162 | packageName = packageInfo.packageName, 163 | downloadLink = downloadLink, 164 | source = ApkSource.FDROID.name, 165 | version = suggestedVersion?.versionName ?: "", 166 | versionCode = packageInfo.suggestedVersionCode.toString(), 167 | title = packageInfo.packageName, // F-Droid API doesn't provide app title in the package info 168 | fileSize = fileSize 169 | ) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /authenticity/src/androidTest/kotlin/io/github/kdroidfilter/storekit/authenticity/InstallationSourceDetectorTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.authenticity 2 | 3 | import android.content.Context 4 | import android.content.pm.ApplicationInfo 5 | import android.content.pm.PackageInfo 6 | import android.content.pm.PackageManager 7 | import androidx.test.core.app.ApplicationProvider 8 | import androidx.test.ext.junit.runners.AndroidJUnit4 9 | import org.junit.Assert.* 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | 13 | /** 14 | * Instrumented test for the InstallationSourceDetector class. 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class InstallationSourceDetectorTest { 18 | 19 | /** 20 | * Test detecting the installation source of an installed app. 21 | * This test assumes that the Android system package is installed on the device. 22 | */ 23 | @Test 24 | fun testDetectInstallationSourceForSystemApp() { 25 | val context = ApplicationProvider.getApplicationContext() 26 | val packageName = "android" // Android system package should always be installed 27 | 28 | val installationInfo = InstallationSourceDetector.detectInstallationSource(context, packageName) 29 | 30 | // The Android system package should be detected as a system app 31 | assertTrue("Android system package should be a system app", installationInfo.isSystemApp) 32 | println("[DEBUG_LOG] Android system package installation info: $installationInfo") 33 | } 34 | 35 | /** 36 | * Test detecting the installation source of a non-existent app. 37 | */ 38 | @Test 39 | fun testDetectInstallationSourceForNonExistentApp() { 40 | val context = ApplicationProvider.getApplicationContext() 41 | val packageName = "com.nonexistent.app.that.does.not.exist" 42 | 43 | val installationInfo = InstallationSourceDetector.detectInstallationSource(context, packageName) 44 | 45 | // A non-existent package should be detected as sideloaded 46 | assertEquals("Non-existent package should be detected as sideloaded", InstallationSource.SIDELOADED, installationInfo.source) 47 | assertFalse("Non-existent package should not be a system app", installationInfo.isSystemApp) 48 | println("[DEBUG_LOG] Non-existent package installation info: $installationInfo") 49 | } 50 | 51 | /** 52 | * Test the isSystemApp method with a system app. 53 | */ 54 | @Test 55 | fun testIsSystemAppWithSystemApp() { 56 | val context = ApplicationProvider.getApplicationContext() 57 | val packageName = "android" // Android system package should always be installed 58 | 59 | val isSystemApp = InstallationSourceDetector.isSystemApp(context, packageName) 60 | 61 | // The Android system package should be a system app 62 | assertTrue("Android system package should be a system app", isSystemApp) 63 | println("[DEBUG_LOG] Android system package is system app: $isSystemApp") 64 | } 65 | 66 | /** 67 | * Test the isSystemApp method with a non-system app. 68 | * This test tries to find a non-system app on the device. 69 | */ 70 | @Test 71 | fun testIsSystemAppWithNonSystemApp() { 72 | val context = ApplicationProvider.getApplicationContext() 73 | val packageManager = context.packageManager 74 | 75 | // Get all installed packages 76 | val installedPackages = packageManager.getInstalledPackages(0) 77 | 78 | // Find a non-system app 79 | val nonSystemPackage = installedPackages.find { 80 | (it.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM == 0 81 | }?.packageName 82 | 83 | if (nonSystemPackage != null) { 84 | val isSystemApp = InstallationSourceDetector.isSystemApp(context, nonSystemPackage) 85 | 86 | // The non-system package should not be a system app 87 | assertFalse("Non-system package should not be a system app", isSystemApp) 88 | println("[DEBUG_LOG] Non-system package ($nonSystemPackage) is system app: $isSystemApp") 89 | } else { 90 | println("[DEBUG_LOG] No non-system package found on the device, test skipped") 91 | } 92 | } 93 | 94 | /** 95 | * Test that prints the installation info of all installed packages. 96 | */ 97 | @Test 98 | fun testPrintAllInstalledPackagesInstallationInfo() { 99 | val context = ApplicationProvider.getApplicationContext() 100 | val packageManager = context.packageManager 101 | 102 | // Get all installed packages 103 | val installedPackages = packageManager.getInstalledPackages(PackageManager.GET_META_DATA) 104 | 105 | println("[DEBUG_LOG] Found ${installedPackages.size} installed packages") 106 | println("[DEBUG_LOG] ===== INSTALLED APPLICATIONS WITH THEIR SOURCES =====") 107 | 108 | // Iterate through each package and print its installation info 109 | for (packageInfo in installedPackages) { 110 | val packageName = packageInfo.packageName 111 | val appName = try { 112 | packageInfo.applicationInfo?.loadLabel(packageManager)?.toString() ?: "Unknown" 113 | } catch (e: Exception) { 114 | "Unknown" 115 | } 116 | val installationInfo = InstallationSourceDetector.detectInstallationSource(context, packageName) 117 | val isSystemApp = (packageInfo.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM != 0 118 | val installerName = installationInfo.installerName 119 | val source = installationInfo.source 120 | 121 | println("[DEBUG_LOG] App: $appName") 122 | println("[DEBUG_LOG] - Package: $packageName") 123 | println("[DEBUG_LOG] - Source: $source") 124 | println("[DEBUG_LOG] - Installer: $installerName") 125 | println("[DEBUG_LOG] - System App: $isSystemApp") 126 | println("[DEBUG_LOG] ------------------------------") 127 | } 128 | println("[DEBUG_LOG] ===== END OF INSTALLED APPLICATIONS =====") 129 | } 130 | 131 | /** 132 | * Test the classification of different installation sources. 133 | */ 134 | @Test 135 | fun testClassifyInstaller() { 136 | val context = ApplicationProvider.getApplicationContext() 137 | 138 | // Test Google Play Store 139 | val googlePlayInfo = createInstallationInfoWithMockInstaller(context, "com.android.vending") 140 | assertEquals("Google Play Store should be classified correctly", InstallationSource.GOOGLE_PLAY, googlePlayInfo.source) 141 | 142 | // Test Amazon Appstore 143 | val amazonInfo = createInstallationInfoWithMockInstaller(context, "com.amazon.venezia") 144 | assertEquals("Amazon Appstore should be classified correctly", InstallationSource.AMAZON, amazonInfo.source) 145 | 146 | // Test Samsung Galaxy Store 147 | val samsungInfo = createInstallationInfoWithMockInstaller(context, "com.sec.android.app.samsungapps") 148 | assertEquals("Samsung Galaxy Store should be classified correctly", InstallationSource.SAMSUNG, samsungInfo.source) 149 | 150 | // Test Huawei AppGallery 151 | val huaweiInfo = createInstallationInfoWithMockInstaller(context, "com.huawei.appmarket") 152 | assertEquals("Huawei AppGallery should be classified correctly", InstallationSource.HUAWEI, huaweiInfo.source) 153 | 154 | // Test unknown installer 155 | val unknownInfo = createInstallationInfoWithMockInstaller(context, "com.unknown.installer") 156 | assertEquals("Unknown installer should be classified as OTHER", InstallationSource.OTHER, unknownInfo.source) 157 | 158 | println("[DEBUG_LOG] Installation source classification tests completed successfully") 159 | } 160 | 161 | /** 162 | * Helper method to create an InstallationInfo object with a mock installer package. 163 | */ 164 | private fun createInstallationInfoWithMockInstaller(context: Context, installerPackage: String): InstallationInfo { 165 | // This is a simplified way to test the classification logic without actually installing apps 166 | // We're directly calling the private method using reflection 167 | val method = InstallationSourceDetector.Companion::class.java.getDeclaredMethod("classifyInstaller", String::class.java) 168 | method.isAccessible = true 169 | return method.invoke(InstallationSourceDetector.Companion, installerPackage) as InstallationInfo 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /authenticity/src/androidTest/kotlin/io/github/kdroidfilter/storekit/authenticity/SignatureExtractorTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.authenticity 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | import androidx.test.core.app.ApplicationProvider 6 | import androidx.test.ext.junit.runners.AndroidJUnit4 7 | import org.junit.Assert.assertNotNull 8 | import org.junit.Assert.assertNull 9 | import org.junit.Assert.assertEquals 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | 13 | /** 14 | * Instrumented test for the SignatureExtractor class. 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class SignatureExtractorTest { 18 | 19 | /** 20 | * Test extracting the signature of an installed app. 21 | * This test assumes that the Android system package is installed on the device. 22 | */ 23 | @Test 24 | fun testExtractSignatureFromInstalledApp() { 25 | val context = ApplicationProvider.getApplicationContext() 26 | val packageName = "android" // Android system package should always be installed 27 | 28 | val signature = SignatureExtractor.extractSha1Signature(context, packageName) 29 | 30 | // The Android system package should have a signature 31 | assertNotNull("Signature should not be null for installed package", signature) 32 | println("Android system package signature: $signature") 33 | } 34 | 35 | /** 36 | * Test extracting the signature of a non-existent app. 37 | */ 38 | @Test 39 | fun testExtractSignatureFromNonExistentApp() { 40 | val context = ApplicationProvider.getApplicationContext() 41 | val packageName = "com.nonexistent.app.that.does.not.exist" 42 | 43 | val signature = SignatureExtractor.extractSha1Signature(context, packageName) 44 | 45 | // A non-existent package should return null 46 | assertNull("Signature should be null for non-existent package", signature) 47 | } 48 | 49 | /** 50 | * Test that prints the signatures of all installed packages. 51 | */ 52 | @Test 53 | fun testPrintAllInstalledPackagesSignatures() { 54 | val context = ApplicationProvider.getApplicationContext() 55 | val packageManager = context.packageManager 56 | 57 | // Get all installed packages 58 | val installedPackages = packageManager.getInstalledPackages(0) 59 | 60 | println("[DEBUG_LOG] Found ${installedPackages.size} installed packages") 61 | 62 | // Iterate through each package and print its signature 63 | for (packageInfo in installedPackages) { 64 | val packageName = packageInfo.packageName 65 | val signature = SignatureExtractor.extractSha1Signature(context, packageName) 66 | 67 | println("[DEBUG_LOG] Package: $packageName, Signature: $signature") 68 | } 69 | } 70 | 71 | /** 72 | * Test that verifies Chrome's signature if it's installed. 73 | * Chrome's signature should match the expected value. 74 | */ 75 | @Test 76 | fun testChromeSignature() { 77 | val context = ApplicationProvider.getApplicationContext() 78 | val packageManager = context.packageManager 79 | val chromePackageName = "com.android.chrome" 80 | val expectedSignature = "38918a453d07199354f8b19af05ec6562ced5788" 81 | 82 | try { 83 | // Check if Chrome is installed 84 | packageManager.getPackageInfo(chromePackageName, 0) 85 | 86 | // Chrome is installed, extract its signature 87 | val signature = SignatureExtractor.extractSha1Signature(context, chromePackageName) 88 | 89 | // Verify that the signature is not null 90 | assertNotNull("Chrome signature should not be null", signature) 91 | 92 | // Verify that the signature matches the expected value 93 | assertEquals("Chrome signature should match the expected value", expectedSignature, signature) 94 | 95 | println("[DEBUG_LOG] Chrome signature verified: $signature") 96 | } catch (e: PackageManager.NameNotFoundException) { 97 | // Chrome is not installed, skip the test 98 | println("[DEBUG_LOG] Chrome is not installed on this device, test skipped") 99 | } 100 | } 101 | /** 102 | * Test that verifies Chrome's signature using the verifyPackageSignature function. 103 | * This test checks if the verifyPackageSignature function correctly validates Chrome's signature. 104 | */ 105 | @Test 106 | fun testVerifyPackageSignatureWithChrome() { 107 | val context = ApplicationProvider.getApplicationContext() 108 | val packageManager = context.packageManager 109 | val chromePackageName = "com.android.chrome" 110 | val expectedSignature = "38918a453d07199354f8b19af05ec6562ced5788" 111 | 112 | try { 113 | // Check if Chrome is installed 114 | packageManager.getPackageInfo(chromePackageName, 0) 115 | 116 | // Verify Chrome's signature using the verifyPackageSignature function 117 | val isSignatureValid = SignatureExtractor.verifyPackageSignature( 118 | context, 119 | chromePackageName, 120 | expectedSignature 121 | ) 122 | 123 | // The signature should be valid 124 | assertEquals("Chrome signature verification should succeed", true, isSignatureValid) 125 | println("[DEBUG_LOG] Chrome signature verification succeeded") 126 | 127 | // Test with an incorrect signature to ensure the function can detect invalid signatures 128 | val incorrectSignature = "0000000000000000000000000000000000000000" 129 | val isIncorrectSignatureValid = SignatureExtractor.verifyPackageSignature( 130 | context, 131 | chromePackageName, 132 | incorrectSignature 133 | ) 134 | 135 | // The incorrect signature should be invalid 136 | assertEquals("Incorrect signature verification should fail", false, isIncorrectSignatureValid) 137 | println("[DEBUG_LOG] Incorrect signature verification failed as expected") 138 | 139 | } catch (e: PackageManager.NameNotFoundException) { 140 | // Chrome is not installed, skip the test 141 | println("[DEBUG_LOG] Chrome is not installed on this device, test skipped") 142 | } 143 | } 144 | 145 | /** 146 | * Test the isValidSha1Signature function with various inputs. 147 | * This test verifies that the function correctly identifies valid and invalid SHA1 signatures. 148 | */ 149 | @Test 150 | fun testIsValidSha1Signature() { 151 | // Valid SHA1 signatures (40 characters, hex only) 152 | val validSignature1 = "38918a453d07199354f8b19af05ec6562ced5788" // Chrome's signature 153 | val validSignature2 = "0123456789abcdef0123456789abcdef01234567" 154 | val validSignature3 = "ABCDEF0123456789ABCDEF0123456789ABCDEF01" // Uppercase is also valid 155 | 156 | // Invalid SHA1 signatures 157 | val invalidSignature1 = "too_short" // Too short 158 | val invalidSignature2 = "38918a453d07199354f8b19af05ec6562ced5788extra" // Too long 159 | val invalidSignature3 = "38918a453d07199354f8b19af05ec6562ced578g" // Contains invalid character 'g' 160 | val invalidSignature4 = "38918a453d07199354f8b19af05ec6562ced578!" // Contains invalid character '!' 161 | val invalidSignature5 = "" // Empty string 162 | 163 | // Test valid signatures 164 | assertEquals("Valid signature 1 should be recognized as valid", true, SignatureExtractor.isValidSha1Signature(validSignature1)) 165 | assertEquals("Valid signature 2 should be recognized as valid", true, SignatureExtractor.isValidSha1Signature(validSignature2)) 166 | assertEquals("Valid signature 3 should be recognized as valid", true, SignatureExtractor.isValidSha1Signature(validSignature3)) 167 | 168 | // Test invalid signatures 169 | assertEquals("Invalid signature 1 should be recognized as invalid", false, SignatureExtractor.isValidSha1Signature(invalidSignature1)) 170 | assertEquals("Invalid signature 2 should be recognized as invalid", false, SignatureExtractor.isValidSha1Signature(invalidSignature2)) 171 | assertEquals("Invalid signature 3 should be recognized as invalid", false, SignatureExtractor.isValidSha1Signature(invalidSignature3)) 172 | assertEquals("Invalid signature 4 should be recognized as invalid", false, SignatureExtractor.isValidSha1Signature(invalidSignature4)) 173 | assertEquals("Invalid signature 5 should be recognized as invalid", false, SignatureExtractor.isValidSha1Signature(invalidSignature5)) 174 | 175 | println("[DEBUG_LOG] isValidSha1Signature tests completed successfully") 176 | } 177 | } -------------------------------------------------------------------------------- /apkcombo/scraper/src/commonTest/kotlin/io/github/kdroidfilter/storekit/apkcombo/scraper/services/ApkComboScraperServiceTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apkcombo.scraper.services 2 | 3 | import io.github.kdroidfilter.storekit.apkcombo.core.model.ApkComboApplicationInfo 4 | import kotlinx.coroutines.runBlocking 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | import kotlin.test.assertNotNull 8 | import kotlin.test.assertNotEquals 9 | import kotlin.test.assertTrue 10 | import kotlin.test.assertFailsWith 11 | import kotlin.test.fail 12 | 13 | class ApkComboScraperServiceTest { 14 | 15 | @Test 16 | fun testGetApkComboApplicationInfo_ValidPackage() = runBlocking { 17 | // Given 18 | val packageName = "com.google.android.gm" 19 | 20 | try { 21 | // When 22 | val appInfo: ApkComboApplicationInfo = getApkComboApplicationInfo(packageName) 23 | 24 | // Then 25 | assertNotNull(appInfo, "App info should not be null") 26 | assertEquals(packageName, appInfo.appId, "App ID should match the package name") 27 | assertNotEquals("", appInfo.title, "Title should not be empty") 28 | assertNotEquals("", appInfo.version, "Version should not be empty") 29 | assertNotEquals("", appInfo.downloadLink, "Download link should not be empty") 30 | assertTrue(appInfo.url.contains(packageName), "URL should contain the package name") 31 | 32 | println("[DEBUG_LOG] Retrieved app info for $packageName:") 33 | println("[DEBUG_LOG] Title: ${appInfo.title}") 34 | println("[DEBUG_LOG] Version: ${appInfo.version}") 35 | println("[DEBUG_LOG] Version Code: ${appInfo.versionCode}") 36 | println("[DEBUG_LOG] Download Link: ${appInfo.downloadLink}") 37 | println("[DEBUG_LOG] URL: ${appInfo.url}") 38 | } catch (e: Exception) { 39 | println("[DEBUG_LOG] Error retrieving app info: ${e.message}") 40 | throw e 41 | } 42 | } 43 | 44 | @Test 45 | fun testGetApkComboApplicationInfo_InvalidPackage() = runBlocking { 46 | // Given 47 | val invalidPackageName = "com.invalid.package.that.does.not.exist" 48 | 49 | // When/Then 50 | val exception = assertFailsWith { 51 | getApkComboApplicationInfo(invalidPackageName) 52 | } 53 | 54 | println("[DEBUG_LOG] Expected exception message: ${exception.message}") 55 | assertTrue(exception.message?.contains(invalidPackageName) ?: false, 56 | "Exception message should contain the invalid package name") 57 | } 58 | 59 | @Test 60 | fun testGetApkComboApplicationInfo_PopularApp() = runBlocking { 61 | // Given 62 | val packageName = "com.facebook.katana" // Facebook is a popular app that should be available 63 | 64 | try { 65 | // When 66 | val appInfo: ApkComboApplicationInfo = getApkComboApplicationInfo(packageName) 67 | 68 | // Then 69 | assertNotNull(appInfo, "App info should not be null") 70 | assertEquals(packageName, appInfo.appId, "App ID should match the package name") 71 | assertTrue(appInfo.title.contains("Facebook"), "Title should contain 'Facebook'") 72 | assertNotEquals("", appInfo.version, "Version should not be empty") 73 | assertNotEquals("", appInfo.downloadLink, "Download link should not be empty") 74 | 75 | println("[DEBUG_LOG] Retrieved app info for Facebook:") 76 | println("[DEBUG_LOG] Title: ${appInfo.title}") 77 | println("[DEBUG_LOG] Version: ${appInfo.version}") 78 | println("[DEBUG_LOG] Version Code: ${appInfo.versionCode}") 79 | println("[DEBUG_LOG] Download Link: ${appInfo.downloadLink}") 80 | println("[DEBUG_LOG] URL: ${appInfo.url}") 81 | } catch (e: Exception) { 82 | println("[DEBUG_LOG] Error retrieving app info: ${e.message}") 83 | throw e 84 | } 85 | } 86 | 87 | @Test 88 | fun testGetApkComboApplicationInfo_MultiplePackages() = runBlocking { 89 | // Given 90 | val validPackages = listOf( 91 | "com.google.android.gm", // Gmail 92 | "com.facebook.katana", // Facebook 93 | "com.spotify.music", // Spotify 94 | "fm.jewishmusic.application" 95 | ) 96 | 97 | // These packages might not be available or might return errors 98 | val potentiallyUnavailablePackages = listOf( 99 | "com.whatsapp" // WhatsApp - might be unavailable (410 Gone) 100 | ) 101 | 102 | val invalidPackages = listOf( 103 | "com.invalid.package.that.does.not.exist", 104 | "com.another.invalid.package.123456" 105 | ) 106 | 107 | // Test valid packages 108 | println("[DEBUG_LOG] Testing ${validPackages.size} valid packages") 109 | for (packageName in validPackages) { 110 | try { 111 | // When 112 | val appInfo: ApkComboApplicationInfo = getApkComboApplicationInfo(packageName) 113 | 114 | // Then 115 | assertNotNull(appInfo, "App info should not be null for $packageName") 116 | assertEquals(packageName, appInfo.appId, "App ID should match the package name for $packageName") 117 | assertNotEquals("", appInfo.title, "Title should not be empty for $packageName") 118 | assertNotEquals("", appInfo.version, "Version should not be empty for $packageName") 119 | assertNotEquals("", appInfo.downloadLink, "Download link should not be empty for $packageName") 120 | 121 | println("[DEBUG_LOG] Successfully retrieved app info for $packageName:") 122 | println("[DEBUG_LOG] Title: ${appInfo.title}") 123 | println("[DEBUG_LOG] Version: ${appInfo.version}") 124 | println("[DEBUG_LOG] Version Code: ${appInfo.versionCode}") 125 | } catch (e: Exception) { 126 | println("[DEBUG_LOG] Error retrieving app info for $packageName: ${e.message}") 127 | fail("Failed to retrieve app info for valid package $packageName: ${e.message}") 128 | } 129 | } 130 | 131 | // Test potentially unavailable packages 132 | if (potentiallyUnavailablePackages.isNotEmpty()) { 133 | println("[DEBUG_LOG] Testing ${potentiallyUnavailablePackages.size} potentially unavailable packages") 134 | for (packageName in potentiallyUnavailablePackages) { 135 | try { 136 | // When 137 | val appInfo: ApkComboApplicationInfo = getApkComboApplicationInfo(packageName) 138 | 139 | // Then - if we get here, the package was available 140 | assertNotNull(appInfo, "App info should not be null for $packageName") 141 | assertEquals(packageName, appInfo.appId, "App ID should match the package name for $packageName") 142 | assertNotEquals("", appInfo.title, "Title should not be empty for $packageName") 143 | 144 | println("[DEBUG_LOG] Successfully retrieved app info for potentially unavailable package $packageName:") 145 | println("[DEBUG_LOG] Title: ${appInfo.title}") 146 | println("[DEBUG_LOG] Version: ${appInfo.version}") 147 | println("[DEBUG_LOG] Version Code: ${appInfo.versionCode}") 148 | } catch (e: Exception) { 149 | // Log the error but don't fail the test 150 | println("[DEBUG_LOG] Expected error for potentially unavailable package $packageName: ${e.message}") 151 | } 152 | } 153 | } 154 | 155 | // Test invalid packages 156 | println("[DEBUG_LOG] Testing ${invalidPackages.size} invalid packages") 157 | for (invalidPackageName in invalidPackages) { 158 | try { 159 | getApkComboApplicationInfo(invalidPackageName) 160 | fail("Expected exception for invalid package $invalidPackageName, but none was thrown") 161 | } catch (e: IllegalArgumentException) { 162 | // Expected exception 163 | println("[DEBUG_LOG] Expected exception for $invalidPackageName: ${e.message}") 164 | assertTrue(e.message?.contains(invalidPackageName) ?: false, 165 | "Exception message should contain the invalid package name") 166 | } catch (e: Exception) { 167 | println("[DEBUG_LOG] Unexpected exception type for $invalidPackageName: ${e::class.simpleName} - ${e.message}") 168 | fail("Expected IllegalArgumentException for invalid package $invalidPackageName, but got ${e::class.simpleName}") 169 | } 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /apkcombo/scraper/src/commonMain/kotlin/io/github/kdroidfilter/storekit/apkcombo/scraper/utils/NetworkUtils.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apkcombo.scraper.utils 2 | 3 | import io.github.kdroidfilter.storekit.apkcombo.scraper.constants.APP_PATH 4 | import io.github.kdroidfilter.storekit.apkcombo.scraper.constants.BASE_APKCOMBO_URL 5 | import io.github.kdroidfilter.storekit.apkcombo.scraper.constants.DOWNLOAD_PATH 6 | import io.github.oshai.kotlinlogging.KotlinLogging 7 | import io.ktor.client.HttpClient 8 | import io.ktor.client.plugins.HttpTimeout 9 | import io.ktor.client.plugins.UserAgent 10 | import io.ktor.client.request.get 11 | import io.ktor.client.request.post 12 | import io.ktor.client.request.setBody 13 | import io.ktor.client.request.forms.FormDataContent 14 | import io.ktor.client.statement.HttpResponse 15 | import io.ktor.client.statement.bodyAsText 16 | import io.ktor.http.Parameters 17 | import io.ktor.http.isSuccess 18 | 19 | /** 20 | * Logger for network operations 21 | */ 22 | internal val logger = KotlinLogging.logger {} 23 | 24 | /** 25 | * Fetches the HTML content of an app's download page from APKCombo. 26 | * Uses /app path which redirects automatically to the correct URL. 27 | * 28 | * @param packageName The package name of the application 29 | * @return HttpResponse containing the HTML content with download information 30 | */ 31 | suspend fun fetchAppDownloadPage(packageName: String): HttpResponse { 32 | logger.info { "Fetching app download page for packageName: $packageName" } 33 | 34 | val client = HttpClient { 35 | install(UserAgent) { 36 | agent = "Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.7151.116 Mobile" 37 | } 38 | install(HttpTimeout) { 39 | requestTimeoutMillis = 30000 40 | connectTimeoutMillis = 10000 41 | } 42 | } 43 | 44 | return try { 45 | // Use /app path which redirects automatically to correct URL 46 | val downloadUrl = "$BASE_APKCOMBO_URL$APP_PATH/$packageName$DOWNLOAD_PATH" 47 | logger.info { "Fetching download page: $downloadUrl" } 48 | 49 | // Get the initial download page 50 | val initialResponse = client.get(downloadUrl) 51 | val initialHtml = initialResponse.bodyAsText() 52 | 53 | // Check if we need to make an AJAX call to get download details 54 | // Only do this if the page doesn't already contain download links 55 | val hasDirectDownloadLinks = initialHtml.contains("class=\"variant\"") && 56 | (initialHtml.contains("/r2?u=") || initialHtml.contains(".apk") || initialHtml.contains(".xapk")) 57 | 58 | if (!hasDirectDownloadLinks && (initialHtml.contains("fetchData") || initialHtml.contains("app-details"))) { 59 | logger.info { "Detected dynamic content without direct download links, attempting to extract download endpoint..." } 60 | 61 | // Extract the download endpoint from the JavaScript 62 | val downloadEndpoint = extractDownloadEndpoint(initialHtml, packageName) 63 | 64 | if (downloadEndpoint.isNotEmpty()) { 65 | logger.info { "Making POST request to: $downloadEndpoint" } 66 | 67 | // Make POST request to get actual download data 68 | val postResponse = client.post(downloadEndpoint) { 69 | setBody(FormDataContent(Parameters.build { 70 | append("package_name", packageName) 71 | append("version", "") 72 | })) 73 | } 74 | 75 | // Check if POST request was successful 76 | if (postResponse.status.isSuccess()) { 77 | logger.info { "POST request successful, status: ${postResponse.status}" } 78 | client.close() 79 | return postResponse 80 | } else { 81 | logger.warn { "POST request failed with status: ${postResponse.status}" } 82 | // Fall back to initial response 83 | } 84 | } else { 85 | logger.warn { "Could not extract download endpoint from dynamic content" } 86 | } 87 | } else if (hasDirectDownloadLinks) { 88 | logger.info { "Found direct download links in initial response, using static content" } 89 | } else { 90 | logger.info { "No dynamic content detected, using initial response" } 91 | } 92 | 93 | client.close() 94 | initialResponse 95 | } catch (e: Exception) { 96 | client.close() 97 | throw Exception("Error fetching app download page: ${e.message}", e) 98 | } 99 | } 100 | 101 | /** 102 | * Extracts the download endpoint from JavaScript code 103 | */ 104 | private fun extractDownloadEndpoint(html: String, packageName: String): String { 105 | logger.info { "Extracting download endpoint for package: $packageName" } 106 | 107 | // First, look for the xid variable 108 | val xidPattern = Regex("""var xid = "([^"]+)"""") 109 | val xidMatch = xidPattern.find(html) 110 | 111 | if (xidMatch != null) { 112 | val xid = xidMatch.groupValues[1] 113 | logger.info { "Found xid: $xid" } 114 | 115 | // Now look for the app path construction 116 | // Pattern: fetchData("/app-name/package.name/" + xid + "/dl") 117 | val appPathPattern = Regex("""fetchData\("([^"]+/$packageName/)" \+ xid \+ "/dl"\)""") 118 | val appPathMatch = appPathPattern.find(html) 119 | 120 | if (appPathMatch != null) { 121 | val appPath = appPathMatch.groupValues[1] 122 | val fullEndpoint = "$appPath$xid/dl" 123 | logger.info { "Constructed endpoint from xid: $fullEndpoint" } 124 | return "$BASE_APKCOMBO_URL$fullEndpoint" 125 | } 126 | 127 | // Fallback: try to find any path that contains the package name 128 | val pathPattern = Regex(""""/([^"/]+/$packageName/)"""") 129 | val pathMatch = pathPattern.find(html) 130 | 131 | if (pathMatch != null) { 132 | val basePath = pathMatch.groupValues[1] 133 | val fullEndpoint = "/$basePath$xid/dl" 134 | logger.info { "Constructed endpoint from base path: $fullEndpoint" } 135 | return "$BASE_APKCOMBO_URL$fullEndpoint" 136 | } 137 | } 138 | 139 | // Look for direct fetchData patterns 140 | val fetchDataPatterns = listOf( 141 | // Pattern: fetchData("/full/path/to/endpoint") 142 | Regex("""fetchData\("([^"]+/dl)""""), 143 | // Pattern: fetchData with variable construction 144 | Regex("""fetchData\("([^"]+)" \+ [^"]+ \+ "([^"]+)""""), 145 | ) 146 | 147 | for (pattern in fetchDataPatterns) { 148 | val match = pattern.find(html) 149 | if (match != null) { 150 | val endpoint = match.groupValues[1] 151 | logger.info { "Found direct fetchData endpoint: $endpoint" } 152 | 153 | if (endpoint.contains(packageName) || endpoint.endsWith("/dl")) { 154 | return if (endpoint.startsWith("/")) { 155 | "$BASE_APKCOMBO_URL$endpoint" 156 | } else { 157 | endpoint 158 | } 159 | } 160 | } 161 | } 162 | 163 | // Look in specific script sections for the pattern 164 | val scriptPattern = Regex("""]*>(.*?)""", RegexOption.DOT_MATCHES_ALL) 165 | val scriptMatches = scriptPattern.findAll(html) 166 | 167 | for (scriptMatch in scriptMatches) { 168 | val scriptContent = scriptMatch.groupValues[1] 169 | 170 | // Look for the specific pattern in this app 171 | if (scriptContent.contains("fetchData") && scriptContent.contains(packageName)) { 172 | logger.info { "Found relevant script section" } 173 | 174 | // Try to extract the complete pattern 175 | val completePattern = Regex("""var xid = "([^"]+)".*?fetchData\("([^"]+/$packageName/)" \+ xid \+ "/dl"\)""", RegexOption.DOT_MATCHES_ALL) 176 | val completeMatch = completePattern.find(scriptContent) 177 | 178 | if (completeMatch != null) { 179 | val xid = completeMatch.groupValues[1] 180 | val basePath = completeMatch.groupValues[2] 181 | val fullEndpoint = "$basePath$xid/dl" 182 | logger.info { "Extracted complete endpoint: $fullEndpoint" } 183 | return "$BASE_APKCOMBO_URL$fullEndpoint" 184 | } 185 | 186 | // Alternative: look for any dl endpoint 187 | val dlPattern = Regex("""["']([^"']*$packageName[^"']*dl)["']""") 188 | val dlMatch = dlPattern.find(scriptContent) 189 | if (dlMatch != null) { 190 | val endpoint = dlMatch.groupValues[1] 191 | logger.info { "Found dl endpoint: $endpoint" } 192 | return if (endpoint.startsWith("/")) { 193 | "$BASE_APKCOMBO_URL$endpoint" 194 | } else { 195 | "$BASE_APKCOMBO_URL/$endpoint" 196 | } 197 | } 198 | } 199 | } 200 | 201 | logger.warn { "Could not extract download endpoint" } 202 | return "" 203 | } 204 | 205 | /** 206 | * Cleans a download link from APKCombo. 207 | */ 208 | fun cleanDownloadLink(link: String): String { 209 | return when { 210 | link.startsWith("/r2?u=") -> { 211 | // Decode the URL encoded after "u=" 212 | val encodedUrl = link.substringAfter("u=") 213 | try { 214 | java.net.URLDecoder.decode(encodedUrl, "UTF-8") 215 | } catch (e: Exception) { 216 | link 217 | } 218 | } 219 | link.startsWith("/") -> "$BASE_APKCOMBO_URL$link" 220 | else -> link 221 | } 222 | } -------------------------------------------------------------------------------- /apkcombo/scraper/src/commonMain/kotlin/io/github/kdroidfilter/storekit/apkcombo/scraper/services/ApkComboScraperService.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.apkcombo.scraper.services 2 | 3 | import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlHandler 4 | import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlParser 5 | import io.github.kdroidfilter.storekit.apkcombo.core.model.ApkComboApplicationInfo 6 | import io.github.kdroidfilter.storekit.apkcombo.scraper.constants.APP_PATH 7 | import io.github.kdroidfilter.storekit.apkcombo.scraper.constants.BASE_APKCOMBO_URL 8 | import io.github.kdroidfilter.storekit.apkcombo.scraper.constants.DOWNLOAD_PATH 9 | import io.github.kdroidfilter.storekit.apkcombo.scraper.utils.cleanDownloadLink 10 | import io.github.kdroidfilter.storekit.apkcombo.scraper.utils.fetchAppDownloadPage 11 | import io.github.kdroidfilter.storekit.apkcombo.scraper.utils.logger 12 | import io.ktor.client.statement.bodyAsText 13 | import io.ktor.http.isSuccess 14 | 15 | /** 16 | * Enhanced class responsible for extracting app information from APKCombo HTML content. 17 | */ 18 | class AppInfoExtractor { 19 | private var appInfo = AppInfo() 20 | private var isInVariantLink = false 21 | private var isInDownloadLink = false 22 | private var currentLinkHref = "" 23 | private var isInVersionSpan = false 24 | private var isInVersionCodeSpan = false 25 | private var isInTitleElement = false 26 | private var currentTitle = "" 27 | 28 | data class AppInfo( 29 | val title: String = "", 30 | val version: String = "", 31 | val versionCode: String = "", 32 | val downloadLink: String = "" 33 | ) 34 | 35 | private val handler = KsoupHtmlHandler 36 | .Builder() 37 | .onOpenTag { name, attributes, _ -> 38 | when (name.lowercase()) { 39 | "a" -> { 40 | val href = attributes["href"] ?: "" 41 | val className = attributes["class"] ?: "" 42 | 43 | // Multiple patterns for download links 44 | when { 45 | // Primary pattern: variant class with download link 46 | className.contains("variant") && href.isNotEmpty() && 47 | (href.contains("/r2?u=") || href.contains(".apk") || href.contains(".xapk")) -> { 48 | isInVariantLink = true 49 | currentLinkHref = href 50 | } 51 | // Secondary pattern: any download-related link 52 | href.contains("/r2?u=") || 53 | (href.contains("download") && (href.contains(".apk") || href.contains(".xapk"))) -> { 54 | isInDownloadLink = true 55 | currentLinkHref = href 56 | } 57 | // Tertiary pattern: variant class (even without obvious download URL) 58 | className.contains("variant") && href.isNotEmpty() -> { 59 | if (appInfo.downloadLink.isEmpty()) { 60 | isInVariantLink = true 61 | currentLinkHref = href 62 | } 63 | } 64 | } 65 | } 66 | "span" -> { 67 | val className = attributes["class"] ?: "" 68 | when { 69 | className.contains("vername") -> isInVersionSpan = true 70 | className.contains("vercode") -> isInVersionCodeSpan = true 71 | } 72 | } 73 | "h1" -> { 74 | isInTitleElement = true 75 | } 76 | "title" -> { 77 | isInTitleElement = true 78 | } 79 | } 80 | } 81 | .onCloseTag { name, _ -> 82 | when (name.lowercase()) { 83 | "a" -> { 84 | when { 85 | isInVariantLink -> { 86 | isInVariantLink = false 87 | if (appInfo.downloadLink.isEmpty()) { 88 | appInfo = appInfo.copy(downloadLink = currentLinkHref) 89 | } 90 | } 91 | isInDownloadLink -> { 92 | isInDownloadLink = false 93 | if (appInfo.downloadLink.isEmpty()) { 94 | appInfo = appInfo.copy(downloadLink = currentLinkHref) 95 | } 96 | } 97 | } 98 | } 99 | "span" -> { 100 | when { 101 | isInVersionSpan -> isInVersionSpan = false 102 | isInVersionCodeSpan -> isInVersionCodeSpan = false 103 | } 104 | } 105 | "h1", "title" -> { 106 | isInTitleElement = false 107 | if (currentTitle.isNotEmpty() && appInfo.title.isEmpty()) { 108 | appInfo = appInfo.copy(title = currentTitle.trim()) 109 | } 110 | currentTitle = "" 111 | } 112 | } 113 | } 114 | .onText { text -> 115 | when { 116 | isInVersionSpan && appInfo.version.isEmpty() -> { 117 | val trimmedText = text.trim() 118 | if (trimmedText.isNotEmpty()) { 119 | appInfo = appInfo.copy(version = trimmedText) 120 | } 121 | } 122 | isInVersionCodeSpan && appInfo.versionCode.isEmpty() -> { 123 | val trimmedText = text.trim() 124 | if (trimmedText.isNotEmpty()) { 125 | val cleanVersionCode = trimmedText.removePrefix("(").removeSuffix(")") 126 | appInfo = appInfo.copy(versionCode = cleanVersionCode) 127 | } 128 | } 129 | isInTitleElement -> { 130 | currentTitle += text 131 | } 132 | } 133 | } 134 | .build() 135 | 136 | fun extractAppInfo(html: String): AppInfo { 137 | // Reset data 138 | appInfo = AppInfo() 139 | isInVariantLink = false 140 | isInDownloadLink = false 141 | currentLinkHref = "" 142 | isInVersionSpan = false 143 | isInVersionCodeSpan = false 144 | isInTitleElement = false 145 | currentTitle = "" 146 | 147 | val parser = KsoupHtmlParser(handler = handler) 148 | parser.write(html) 149 | parser.end() 150 | 151 | logger.info { "Extracted app info - Title: '${appInfo.title}', Version: '${appInfo.version}', VersionCode: '${appInfo.versionCode}', DownloadLink: '${appInfo.downloadLink}'" } 152 | 153 | // If we couldn't extract version info, try alternative methods 154 | if (appInfo.version.isEmpty() || appInfo.downloadLink.isEmpty()) { 155 | logger.info { "Some info missing, trying regex extraction..." } 156 | extractWithRegex(html) 157 | } 158 | 159 | return appInfo 160 | } 161 | 162 | private fun extractWithRegex(html: String) { 163 | // Extract version with regex as fallback 164 | if (appInfo.version.isEmpty()) { 165 | val versionPattern = Regex("""]*class="[^"]*vername[^"]*"[^>]*>([^<]+)""") 166 | val versionMatch = versionPattern.find(html) 167 | if (versionMatch != null) { 168 | appInfo = appInfo.copy(version = versionMatch.groupValues[1].trim()) 169 | } 170 | } 171 | 172 | // Extract version code with regex as fallback 173 | if (appInfo.versionCode.isEmpty()) { 174 | val versionCodePattern = Regex("""]*class="[^"]*vercode[^"]*"[^>]*>\(([^)]+)\)""") 175 | val versionCodeMatch = versionCodePattern.find(html) 176 | if (versionCodeMatch != null) { 177 | appInfo = appInfo.copy(versionCode = versionCodeMatch.groupValues[1].trim()) 178 | } 179 | } 180 | 181 | // Extract download link with regex as fallback 182 | if (appInfo.downloadLink.isEmpty()) { 183 | val downloadPatterns = listOf( 184 | Regex("""href="(/r2\?u=[^"]+)""""), 185 | Regex("""href="([^"]*download[^"]*\.apk[^"]*)""""), 186 | Regex("""href="([^"]*\.xapk[^"]*)"""") 187 | ) 188 | 189 | for (pattern in downloadPatterns) { 190 | val match = pattern.find(html) 191 | if (match != null) { 192 | appInfo = appInfo.copy(downloadLink = match.groupValues[1]) 193 | break 194 | } 195 | } 196 | } 197 | 198 | // Extract title as fallback 199 | if (appInfo.title.isEmpty()) { 200 | val titlePatterns = listOf( 201 | Regex("""([^<]*APK[^<]*)""", RegexOption.IGNORE_CASE), 202 | Regex("""]*class="[^"]*title[^"]*"[^>]*>([^<]+)""", RegexOption.IGNORE_CASE), 203 | Regex("""]*>([^<]+)"""), 204 | // Extract from Download ... APK pattern 205 | Regex("""Download ([^-]+) APK""", RegexOption.IGNORE_CASE), 206 | // Extract from meta description 207 | Regex(""" 1) { 220 | appInfo = appInfo.copy(title = title) 221 | break 222 | } 223 | } 224 | } 225 | } 226 | } 227 | } 228 | 229 | /** 230 | * Enhanced function to fetch and return detailed information about an application on APKCombo. 231 | */ 232 | suspend fun getApkComboApplicationInfo(packageName: String): ApkComboApplicationInfo { 233 | logger.info { "Fetching app details for packageName: $packageName" } 234 | 235 | val response = fetchAppDownloadPage(packageName) 236 | 237 | if (!response.status.isSuccess()) { 238 | throw IllegalArgumentException("Application with packageName: $packageName does not exist or is not accessible. HTTP status: ${response.status}") 239 | } 240 | 241 | val html = response.bodyAsText() 242 | logger.info { "Fetched HTML content of size: ${html.length}" } 243 | 244 | val extractor = AppInfoExtractor() 245 | val appInfo = extractor.extractAppInfo(html) 246 | 247 | val downloadLink = cleanDownloadLink(appInfo.downloadLink) 248 | 249 | // Use the /app path as it redirects automatically 250 | val url = "$BASE_APKCOMBO_URL$APP_PATH/$packageName$DOWNLOAD_PATH" 251 | 252 | return ApkComboApplicationInfo( 253 | title = appInfo.title.ifEmpty { "Unknown App" }, 254 | version = appInfo.version, 255 | versionCode = appInfo.versionCode, 256 | downloadLink = downloadLink, 257 | appId = packageName, 258 | url = url 259 | ) 260 | } -------------------------------------------------------------------------------- /gplay/scrapper/src/commonMain/kotlin/io/github/kdroidfilter/storekit/gplay/scrapper/services/PlayStoreScraperService.kt: -------------------------------------------------------------------------------- 1 | package io.github.kdroidfilter.storekit.gplay.scrapper.services 2 | 3 | import io.github.kdroidfilter.storekit.gplay.scrapper.constants.BASE_PLAY_STORE_URL 4 | import io.github.kdroidfilter.storekit.gplay.scrapper.constants.DETAIL_PATH 5 | import io.github.kdroidfilter.storekit.gplay.core.model.GooglePlayApplicationInfo 6 | import io.github.kdroidfilter.storekit.gplay.core.model.GooglePlayCategory 7 | 8 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.HtmlDecoder.unescapeHtml 9 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.JsonExtensions.asDoubleOrNull 10 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.JsonExtensions.asLongOrNull 11 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.JsonExtensions.asStringOrNull 12 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.JsonExtensions.jsonElementToBool 13 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.JsonExtensions.microsToPrice 14 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.JsonExtensions.nestedLookup 15 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.NetworkUtils.extractJsonBlobsFromHtml 16 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.NetworkUtils.fetchAppPage 17 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.NetworkUtils.logger 18 | import io.github.kdroidfilter.storekit.gplay.scrapper.utils.parseDataSetsFromScripts 19 | import io.ktor.client.statement.* 20 | import io.ktor.http.* 21 | import kotlinx.serialization.json.JsonArray 22 | import kotlinx.serialization.json.JsonElement 23 | import kotlinx.serialization.json.JsonPrimitive 24 | import kotlinx.serialization.json.jsonArray 25 | 26 | private fun extractCategories(datasets: Map): List { 27 | val detailJson = datasets["ds:5"] ?: return emptyList() 28 | val catElement = nestedLookup(detailJson, listOf(1,2,118)) 29 | val categories = mutableListOf() 30 | if (catElement == null) { 31 | // fallback 32 | return fallbackCategories(datasets) 33 | } 34 | extractCategoriesRecursive(catElement, categories) 35 | if (categories.isEmpty()) { 36 | return fallbackCategories(datasets) 37 | } 38 | return categories 39 | } 40 | 41 | private fun fallbackCategories(datasets: Map): List { 42 | val detailJson = datasets["ds:5"] ?: return emptyList() 43 | val name = nestedLookup(detailJson, listOf(1,2,79,0,0,0)).asStringOrNull() 44 | val id = nestedLookup(detailJson, listOf(1,2,79,0,0,2)).asStringOrNull() 45 | return if (name != null && id != null) listOf(GooglePlayCategory(name, id)) else emptyList() 46 | } 47 | 48 | private fun extractCategoriesRecursive(e: JsonElement, categories: MutableList) { 49 | when (e) { 50 | is JsonArray -> { 51 | // According to python logic: 52 | // "if len(s) >=4 and type(s[0]) is str: categories.append({name: s[0], id: s[2]})" 53 | if (e.size >= 4 && e[0] is JsonPrimitive) { 54 | val name = e[0].asStringOrNull() ?: return 55 | val id = e.getOrNull(2)?.asStringOrNull() ?: return 56 | categories.add(GooglePlayCategory(name, id)) 57 | } else { 58 | for (sub in e) { 59 | extractCategoriesRecursive(sub, categories) 60 | } 61 | } 62 | } 63 | else -> {} 64 | } 65 | } 66 | 67 | internal fun extractComments(datasets: Map): List { //TODO Not working 68 | // Lists of potential datasets 69 | val possiblePaths = listOf("ds:8", "ds:9", "ds:13", "ds:15") 70 | var commentsArray: JsonElement? = null 71 | 72 | for (path in possiblePaths) { 73 | val authorPath = listOf(path, "0", "0", "1", "0") 74 | val versionPath = listOf(path, "0", "0", "10") 75 | val datePath = listOf(path, "0", "0", "5", "0") 76 | 77 | val authorExists = nestedLookup(datasets[path], authorPath.drop(1).map { it.toInt() }) != null 78 | val versionExists = nestedLookup(datasets[path], versionPath.drop(1).map { it.toInt() }) != null 79 | val dateExists = nestedLookup(datasets[path], datePath.drop(1).map { it.toInt() }) != null 80 | 81 | if (authorExists && versionExists && dateExists) { 82 | // If we find all the fields, we consider that this dataset is the right one 83 | commentsArray = nestedLookup(datasets[path], listOf(0)) 84 | if (commentsArray != null) break 85 | } 86 | } 87 | 88 | if (commentsArray == null || commentsArray !is JsonArray) { 89 | return emptyList() 90 | } 91 | 92 | // limit ad 5 comments 93 | return commentsArray.take(5).mapNotNull { entry -> 94 | entry.jsonArray.getOrNull(4)?.asStringOrNull() 95 | } 96 | } 97 | 98 | internal fun extractHistogram(detailJson: JsonElement): List { 99 | // histogram is at [1,2,51,1] 100 | val histBase = nestedLookup(detailJson, listOf(1,2,51,1)) as? JsonArray ?: return listOf(0,0,0,0,0) 101 | // According to python: [1][1], [2][1], [3][1], [4][1], [5][1] 102 | // histBase[0] might be something else 103 | val result = mutableListOf() 104 | for (i in 1..5) { 105 | val value = histBase.getOrNull(i)?.jsonArray?.getOrNull(1)?.asLongOrNull() ?: 0 106 | result.add(value) 107 | } 108 | return result 109 | } 110 | 111 | /** 112 | * Fetches and returns detailed information about a Google Play application based on the provided application ID. 113 | * 114 | * @param appId The unique identifier for the application on the Google Play Store. 115 | * @param lang The language code to fetch application details in. Defaults to English ("en"). 116 | * @param country The country code to fetch application details from. Defaults to United States ("us"). 117 | * @return An instance of [GooglePlayApplicationInfo] containing the application's information such as title, description, developer details, and more. 118 | */ 119 | suspend fun getGooglePlayApplicationInfo(appId: String, lang: String = "en", country: String = "us"): GooglePlayApplicationInfo { 120 | logger.info { "Fetching app details for appId: $appId" } 121 | val response = fetchAppPage(appId, lang, country) 122 | val html = response.bodyAsText() 123 | logger.info { "Fetched HTML content of size: ${html.length}" } 124 | 125 | if (!response.status.isSuccess()) { 126 | throw IllegalArgumentException("Application with appId: $appId does not exist or is not accessible. HTTP status: ${response.status}") 127 | } 128 | 129 | val scripts = extractJsonBlobsFromHtml(html) 130 | val datasets = parseDataSetsFromScripts(scripts) 131 | 132 | val comments = extractComments(datasets) 133 | 134 | val detailJson = datasets["ds:5"] 135 | ?: return GooglePlayApplicationInfo( 136 | appId = appId, 137 | url = "$BASE_PLAY_STORE_URL$DETAIL_PATH?id=$appId&hl=$lang&gl=$country", 138 | comments = comments // even if empty, we set it 139 | ) 140 | 141 | // descriptionHTML = nested_lookup(... [12,0,0,1]) or [72,0,1] 142 | val rawDescriptionHTML = nestedLookup(detailJson, listOf(1,2,12,0,0,1)).asStringOrNull() 143 | ?: nestedLookup(detailJson, listOf(1,2,72,0,1)).asStringOrNull() 144 | ?: "" 145 | val description = unescapeHtml(rawDescriptionHTML) 146 | 147 | val summary = nestedLookup(detailJson, listOf(1,2,73,0,1)).asStringOrNull()?.let { unescapeHtml(it) } ?: "" 148 | 149 | val title = nestedLookup(detailJson, listOf(1,2,0,0)).asStringOrNull() ?: "" 150 | val installs = nestedLookup(detailJson, listOf(1,2,13,0)).asStringOrNull() ?: "" 151 | val minInstalls = nestedLookup(detailJson, listOf(1,2,13,1)).asLongOrNull() ?: 0 152 | val realInstalls = nestedLookup(detailJson, listOf(1,2,13,2)).asLongOrNull() ?: 0 153 | val score = nestedLookup(detailJson, listOf(1,2,51,0,1)).asDoubleOrNull() ?: 0.0 154 | val ratings = nestedLookup(detailJson, listOf(1,2,51,2,1)).asLongOrNull() ?: 0 155 | val reviews = nestedLookup(detailJson, listOf(1,2,51,3,1)).asLongOrNull() ?: 0 156 | val histogram = extractHistogram(detailJson) 157 | 158 | val price = microsToPrice(nestedLookup(detailJson, listOf(1,2,57,0,0,0,0,1,0,0))) 159 | val free = (nestedLookup(detailJson, listOf(1,2,57,0,0,0,0,1,0,0)).asLongOrNull() == 0L) 160 | val currency = nestedLookup(detailJson, listOf(1,2,57,0,0,0,0,1,0,1)).asStringOrNull() ?: "" 161 | 162 | val sale = datasets["ds:4"]?.let { jsonElementToBool(nestedLookup(it, listOf(0,2,0,0,0,14,0,0))) } ?: false 163 | val saleTime = datasets["ds:4"]?.let { nestedLookup(it, listOf(0,2,0,0,0,14,0,0)).asLongOrNull() } 164 | val originalPriceVal = datasets["ds:3"]?.let { microsToPrice(nestedLookup(it, listOf(0,2,0,0,0,1,1,0))) } 165 | val originalPrice = if (originalPriceVal == 0.0) null else originalPriceVal 166 | val saleText = datasets["ds:4"]?.let { nestedLookup(it, listOf(0,2,0,0,0,14,1)).asStringOrNull() } 167 | 168 | val offersIAP = jsonElementToBool(nestedLookup(detailJson, listOf(1,2,19,0))) 169 | val inAppProductPrice = nestedLookup(detailJson, listOf(1,2,19,0)).asStringOrNull() ?: "" 170 | 171 | val developer = nestedLookup(detailJson, listOf(1,2,68,0)).asStringOrNull() ?: "" 172 | val developerId = nestedLookup(detailJson, listOf(1,2,68,1,4,2)).asStringOrNull()?.substringAfter("id=") ?: "" 173 | val developerEmail = nestedLookup(detailJson, listOf(1,2,69,1,0)).asStringOrNull() ?: "" 174 | val developerWebsite = nestedLookup(detailJson, listOf(1,2,69,0,5,2)).asStringOrNull() ?: "" 175 | val developerAddress = nestedLookup(detailJson, listOf(1,2,69,2,0)).asStringOrNull() ?: "" 176 | val privacyPolicy = nestedLookup(detailJson, listOf(1,2,99,0,5,2)).asStringOrNull() ?: "" 177 | 178 | val genre = nestedLookup(detailJson, listOf(1,2,79,0,0,0)).asStringOrNull() ?: "" 179 | val genreId = nestedLookup(detailJson, listOf(1,2,79,0,0,2)).asStringOrNull() ?: "" 180 | val categories = extractCategories(datasets) 181 | 182 | val icon = nestedLookup(detailJson, listOf(1,2,95,0,3,2)).asStringOrNull() ?: "" 183 | val headerImage = nestedLookup(detailJson, listOf(1,2,96,0,3,2)).asStringOrNull() ?: "" 184 | 185 | val screenshotsJsonArray = nestedLookup(detailJson, listOf(1,2,78,0)) as? JsonArray 186 | val screenshots = screenshotsJsonArray?.mapNotNull { element -> 187 | element.jsonArray.getOrNull(3)?.jsonArray?.getOrNull(2)?.asStringOrNull() 188 | } ?: emptyList() 189 | 190 | val video = nestedLookup(detailJson, listOf(1,2,100,0,0,3,2)).asStringOrNull() ?: "" 191 | val videoImage = nestedLookup(detailJson, listOf(1,2,100,1,0,3,2)).asStringOrNull() ?: "" 192 | 193 | val contentRating = nestedLookup(detailJson, listOf(1,2,9,0)).asStringOrNull() ?: "" 194 | val contentRatingDescription = nestedLookup(detailJson, listOf(1,2,9,2,1)).asStringOrNull() ?: "" 195 | 196 | val adSupported = jsonElementToBool(nestedLookup(detailJson, listOf(1,2,48))) 197 | val containsAds = (nestedLookup(detailJson, listOf(1,2,48)).asLongOrNull() == 1L) 198 | 199 | val released = nestedLookup(detailJson, listOf(1,2,10,0)).asStringOrNull() ?: "" 200 | val updated = nestedLookup(detailJson, listOf(1,2,145,0,1,0)).asLongOrNull() ?: 0 201 | val version = nestedLookup(detailJson, listOf(1,2,140,0,0,0)).asStringOrNull() ?: "Varies with device" 202 | 203 | val url = "$BASE_PLAY_STORE_URL$DETAIL_PATH?id=$appId&hl=$lang&gl=$country" 204 | 205 | return GooglePlayApplicationInfo( 206 | title = title, 207 | description = description, 208 | descriptionHTML = rawDescriptionHTML, 209 | summary = summary, 210 | installs = installs, 211 | minInstalls = minInstalls, 212 | realInstalls = realInstalls, 213 | score = score, 214 | ratings = ratings, 215 | reviews = reviews, 216 | histogram = histogram, 217 | price = price, 218 | free = free, 219 | currency = currency, 220 | sale = sale, 221 | saleTime = saleTime, 222 | originalPrice = originalPrice, 223 | saleText = saleText, 224 | offersIAP = offersIAP, 225 | inAppProductPrice = inAppProductPrice, 226 | developer = developer, 227 | developerId = developerId, 228 | developerEmail = developerEmail, 229 | developerWebsite = developerWebsite, 230 | developerAddress = developerAddress, 231 | privacyPolicy = privacyPolicy, 232 | genre = genre, 233 | genreId = genreId, 234 | categories = categories, 235 | icon = icon, 236 | headerImage = headerImage, 237 | screenshots = screenshots, 238 | video = video, 239 | videoImage = videoImage, 240 | contentRating = contentRating, 241 | contentRatingDescription = contentRatingDescription, 242 | adSupported = adSupported, 243 | containsAds = containsAds, 244 | released = released, 245 | updated = updated, 246 | version = version, 247 | comments = comments, 248 | appId = appId, 249 | url = url 250 | ) 251 | } 252 | --------------------------------------------------------------------------------