diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..c791b21ba --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.gradle +**/build +**/downloads +downloads +local.properties diff --git a/README.md b/README.md index 188b71f9e..4a4e4001a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,77 @@ Please only download the latest stable releases from one of these sources: You can also get latest debug builds signed with AOSP test keys for testing latest changes from our [GitLab Package Registry](https://gitlab.com/AuroraOSS/AuroraStore/-/packages/24103616). +## Headless APK Downloader + +This repository includes a JVM CLI module for downloading the latest available APK files for a Google Play package name. Build the compiled distribution once: + +```bash +./gradlew :apk-downloader:installDist +``` + +Then run the generated launcher directly, without Gradle: + +```bash +./apk-downloader/build/install/apk-downloader/bin/apk-downloader org.mozilla.firefox +``` + +By default, files are saved under `./downloads///`. To choose a different output directory: + +```bash +./apk-downloader/build/install/apk-downloader/bin/apk-downloader org.mozilla.firefox --out ./downloads +``` + +When Google Play returns multiple APK files, the downloader keeps the verified raw files and also writes a split APK archive next to them: + +```text +downloads/// + base.apk + config_*.apk + _.apks +``` + +This `.apks` file is a zip archive of the downloaded base/split APKs for split-aware installers. It is not a true universal APK. Bundletool can create a universal APK from an Android App Bundle (`.aab`) with `build-apks --mode=universal`, but Google Play download delivery provides APK files here, not the original `.aab`, so bundletool cannot merge these split APKs back into one universal APK. + +To create a portable archive: + +```bash +./gradlew :apk-downloader:distZip +``` + +The archive is written to `apk-downloader/build/distributions/apk-downloader.zip`. Unzip it anywhere with Java 17+ installed and run `bin/apk-downloader`. + +For development, you can still run through Gradle: + +```bash +./gradlew :apk-downloader:run --args="org.mozilla.firefox" +``` + +You can also build a Docker image for the downloader: + +```bash +docker build -f apk-downloader/Dockerfile -t aurora-apk-downloader . +docker run --rm \ + -v "$PWD/downloads:/work/downloads" \ + -v "$HOME/.config/aurora-apk-downloader:/root/.config/aurora-apk-downloader" \ + aurora-apk-downloader \ + com.nestle.tr.nescafe.nescafe3in1loyaltyapp --country TR --out /work/downloads +``` + +If your Proton VPN runs in a separate VPN container, run the downloader with that container's network namespace, for example `--network container:`. + +To run Proton VPN inside Docker instead of on the host, use the bundled Compose file. Copy the example environment file, fill in your Proton WireGuard values, then run the one-shot downloader service: + +```bash +cp apk-downloader/proton.env.example apk-downloader/proton.env +# edit apk-downloader/proton.env +docker compose \ + --env-file apk-downloader/proton.env \ + -f apk-downloader/docker-compose.proton.yml \ + up --build --abort-on-container-exit --exit-code-from downloader downloader +``` + +For Proton WireGuard, set `WIREGUARD_PRIVATE_KEY` and `WIREGUARD_ADDRESSES` from a Proton VPN WireGuard config. For OpenVPN, set `VPN_TYPE=openvpn` and use Proton's OpenVPN/IKEv2 username and password in `OPENVPN_USER` and `OPENVPN_PASSWORD`. + ## Certificate Fingerprints - SHA1: 94:42:75:D7:59:8B:C0:3E:48:85:06:06:42:25:A7:19:90:A2:22:02 diff --git a/apk-downloader/.env b/apk-downloader/.env new file mode 100644 index 000000000..e13dc8aae --- /dev/null +++ b/apk-downloader/.env @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 Aurora OSS +# SPDX-License-Identifier: GPL-3.0-or-later + +# Package and Play metadata profile. +APK_PACKAGE=com.nestle.tr.nescafe.nescafe3in1loyaltyapp +APK_COUNTRY=TR +SERVER_COUNTRIES=Turkey +TZ=Europe/Istanbul + +# Local output/cache paths. DOWNLOADS_DIR is relative to apk-downloader/ when using +# `docker compose -f apk-downloader/docker-compose.proton.yml`. +DOWNLOADS_DIR=../downloads +AUTH_CACHE_DIR=../.apk-downloader-auth + +# Recommended: Proton WireGuard. +# Get these values from a Proton VPN WireGuard config. +VPN_TYPE=wireguard +WIREGUARD_PRIVATE_KEY= +WIREGUARD_ADDRESSES= + +# Alternative: Proton OpenVPN. +# Use Proton's OpenVPN/IKEv2 credentials, not your normal Proton account password. +# VPN_TYPE=openvpn +# OPENVPN_USER= +# OPENVPN_PASSWORD= diff --git a/apk-downloader/Dockerfile b/apk-downloader/Dockerfile new file mode 100644 index 000000000..1f3d8c33f --- /dev/null +++ b/apk-downloader/Dockerfile @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 Aurora OSS +# SPDX-License-Identifier: GPL-3.0-or-later + +FROM eclipse-temurin:17-jdk AS build +WORKDIR /workspace + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + +COPY . . +RUN ./gradlew --no-daemon :apk-downloader:installDist + +FROM eclipse-temurin:17-jre +WORKDIR /work + +COPY --from=build /workspace/apk-downloader/build/install/apk-downloader /opt/apk-downloader + +ENTRYPOINT ["/opt/apk-downloader/bin/apk-downloader"] diff --git a/apk-downloader/build.gradle.kts b/apk-downloader/build.gradle.kts new file mode 100644 index 000000000..cccd48f3d --- /dev/null +++ b/apk-downloader/build.gradle.kts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.attributes.Category +import org.gradle.api.attributes.Usage +import org.gradle.api.attributes.java.TargetJvmEnvironment +import org.gradle.api.tasks.JavaExec +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.jetbrains.kotlin.jvm) + alias(libs.plugins.jetbrains.kotlin.serialization) + application +} + +kotlin { + jvmToolchain(17) +} + +application { + mainClass.set("com.aurora.apkdownloader.MainKt") +} + +val gplayapiAar by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true + attributes { + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY)) + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, objects.named("android")) + attribute(KotlinPlatformType.attribute, KotlinPlatformType.androidJvm) + } +} + +val extractedGplayApiJar = layout.buildDirectory.file("gplayapi/gplayapi.jar") +val extractGplayApiJar by tasks.registering(Copy::class) { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from({ gplayapiAar.files.map { zipTree(it) } }) { + include("classes.jar") + rename { "gplayapi.jar" } + } + into(layout.buildDirectory.dir("gplayapi")) +} + +dependencies { + gplayapiAar(libs.auroraoss.gplayapi) + implementation(files(extractedGplayApiJar)) + implementation("com.google.code.gson:gson:2.14.0") + implementation(libs.google.protobuf.javalite) + implementation("org.jetbrains.kotlin:kotlin-parcelize-runtime:${libs.versions.kotlin.get()}") + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.squareup.okhttp) + + testImplementation(libs.junit) + testImplementation(libs.google.truth) +} + +tasks.withType().configureEach { + dependsOn(extractGplayApiJar) +} + +tasks.withType().configureEach { + workingDir = rootProject.projectDir +} + +tasks.named("run") { + isIgnoreExitValue = true + doLast { + val exitValue = executionResult.get().exitValue + if (exitValue != 0) { + logger.lifecycle("apk-downloader exited with code $exitValue") + } + } +} diff --git a/apk-downloader/docker-compose.proton.yml b/apk-downloader/docker-compose.proton.yml new file mode 100644 index 000000000..dea3e6c0d --- /dev/null +++ b/apk-downloader/docker-compose.proton.yml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: 2026 Aurora OSS +# SPDX-License-Identifier: GPL-3.0-or-later + +services: + protonvpn: + image: qmcgaw/gluetun:latest + container_name: aurora-apk-downloader-protonvpn + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + environment: + VPN_SERVICE_PROVIDER: protonvpn + VPN_TYPE: ${VPN_TYPE:-wireguard} + SERVER_COUNTRIES: ${SERVER_COUNTRIES:-Turkey} + WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-} + WIREGUARD_ADDRESSES: ${WIREGUARD_ADDRESSES:-} + OPENVPN_USER: ${OPENVPN_USER:-} + OPENVPN_PASSWORD: ${OPENVPN_PASSWORD:-} + TZ: ${TZ:-Europe/Istanbul} + healthcheck: + test: ["CMD", "/gluetun-entrypoint", "healthcheck"] + interval: 10s + timeout: 5s + retries: 12 + restart: unless-stopped + + downloader: + build: + context: .. + dockerfile: apk-downloader/Dockerfile + image: aurora-apk-downloader:latest + network_mode: "service:protonvpn" + depends_on: + protonvpn: + condition: service_healthy + volumes: + - ${DOWNLOADS_DIR:-../downloads}:/downloads + - ${AUTH_CACHE_DIR:-../.apk-downloader-auth}:/root/.config/aurora-apk-downloader + command: + - ${APK_PACKAGE:-com.nestle.tr.nescafe.nescafe3in1loyaltyapp} + - --out + - /downloads + restart: "no" diff --git a/apk-downloader/downloads/org.mozilla.firefox/2016168303/org.mozilla.firefox_2016168303.apks b/apk-downloader/downloads/org.mozilla.firefox/2016168303/org.mozilla.firefox_2016168303.apks new file mode 100644 index 000000000..8a7372aeb Binary files /dev/null and b/apk-downloader/downloads/org.mozilla.firefox/2016168303/org.mozilla.firefox_2016168303.apks differ diff --git a/apk-downloader/proton.env.example b/apk-downloader/proton.env.example new file mode 100644 index 000000000..e13dc8aae --- /dev/null +++ b/apk-downloader/proton.env.example @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 Aurora OSS +# SPDX-License-Identifier: GPL-3.0-or-later + +# Package and Play metadata profile. +APK_PACKAGE=com.nestle.tr.nescafe.nescafe3in1loyaltyapp +APK_COUNTRY=TR +SERVER_COUNTRIES=Turkey +TZ=Europe/Istanbul + +# Local output/cache paths. DOWNLOADS_DIR is relative to apk-downloader/ when using +# `docker compose -f apk-downloader/docker-compose.proton.yml`. +DOWNLOADS_DIR=../downloads +AUTH_CACHE_DIR=../.apk-downloader-auth + +# Recommended: Proton WireGuard. +# Get these values from a Proton VPN WireGuard config. +VPN_TYPE=wireguard +WIREGUARD_PRIVATE_KEY= +WIREGUARD_ADDRESSES= + +# Alternative: Proton OpenVPN. +# Use Proton's OpenVPN/IKEv2 credentials, not your normal Proton account password. +# VPN_TYPE=openvpn +# OPENVPN_USER= +# OPENVPN_PASSWORD= diff --git a/apk-downloader/src/main/java/android/os/Parcel.java b/apk-downloader/src/main/java/android/os/Parcel.java new file mode 100644 index 000000000..525fceee4 --- /dev/null +++ b/apk-downloader/src/main/java/android/os/Parcel.java @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package android.os; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +public class Parcel { + private Parcel() { + } + + public int readInt() { + throw unsupported(); + } + + public void writeInt(int value) { + throw unsupported(); + } + + public long readLong() { + throw unsupported(); + } + + public void writeLong(long value) { + throw unsupported(); + } + + public float readFloat() { + throw unsupported(); + } + + public void writeFloat(float value) { + throw unsupported(); + } + + public byte readByte() { + throw unsupported(); + } + + public void writeByte(byte value) { + throw unsupported(); + } + + public String readString() { + throw unsupported(); + } + + public void writeString(String value) { + throw unsupported(); + } + + public T readParcelable(ClassLoader loader) { + throw unsupported(); + } + + public void writeParcelable(Parcelable value, int flags) { + throw unsupported(); + } + + public Serializable readSerializable() { + throw unsupported(); + } + + public void writeSerializable(Serializable value) { + throw unsupported(); + } + + public ArrayList createStringArrayList() { + throw unsupported(); + } + + public void writeStringList(List value) { + throw unsupported(); + } + + public ArrayList createTypedArrayList(Parcelable.Creator creator) { + throw unsupported(); + } + + public void writeTypedList(List value) { + throw unsupported(); + } + + private static UnsupportedOperationException unsupported() { + return new UnsupportedOperationException("Parcel is not supported in apk-downloader"); + } +} diff --git a/apk-downloader/src/main/java/android/os/Parcelable.java b/apk-downloader/src/main/java/android/os/Parcelable.java new file mode 100644 index 000000000..a767504d7 --- /dev/null +++ b/apk-downloader/src/main/java/android/os/Parcelable.java @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package android.os; + +public interface Parcelable { + int describeContents(); + + void writeToParcel(Parcel dest, int flags); + + interface Creator { + T createFromParcel(Parcel source); + + T[] newArray(int size); + } +} diff --git a/apk-downloader/src/main/java/android/util/Base64.java b/apk-downloader/src/main/java/android/util/Base64.java new file mode 100644 index 000000000..ff2014520 --- /dev/null +++ b/apk-downloader/src/main/java/android/util/Base64.java @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package android.util; + +import java.nio.charset.StandardCharsets; + +public final class Base64 { + public static final int DEFAULT = 0; + public static final int NO_PADDING = 1; + public static final int NO_WRAP = 2; + public static final int CRLF = 4; + public static final int URL_SAFE = 8; + public static final int NO_CLOSE = 16; + + private Base64() { + } + + public static byte[] decode(String str, int flags) { + java.util.Base64.Decoder decoder = decoder(flags); + return decoder.decode(padIfNeeded(str)); + } + + public static byte[] decode(byte[] input, int flags) { + return decode(new String(input, StandardCharsets.UTF_8), flags); + } + + public static String encodeToString(byte[] input, int flags) { + java.util.Base64.Encoder encoder = encoder(flags); + return encoder.encodeToString(input); + } + + public static byte[] encode(byte[] input, int flags) { + return encodeToString(input, flags).getBytes(StandardCharsets.UTF_8); + } + + private static java.util.Base64.Decoder decoder(int flags) { + if ((flags & URL_SAFE) != 0) { + return java.util.Base64.getUrlDecoder(); + } + if ((flags & NO_WRAP) != 0) { + return java.util.Base64.getDecoder(); + } + return java.util.Base64.getMimeDecoder(); + } + + private static java.util.Base64.Encoder encoder(int flags) { + java.util.Base64.Encoder encoder = (flags & URL_SAFE) != 0 + ? java.util.Base64.getUrlEncoder() + : java.util.Base64.getEncoder(); + if ((flags & NO_PADDING) != 0) { + encoder = encoder.withoutPadding(); + } + return encoder; + } + + private static String padIfNeeded(String value) { + int remainder = value.length() % 4; + if (remainder == 0) { + return value; + } + return value + "====".substring(remainder); + } +} diff --git a/apk-downloader/src/main/java/android/util/Log.java b/apk-downloader/src/main/java/android/util/Log.java new file mode 100644 index 000000000..c2d72a957 --- /dev/null +++ b/apk-downloader/src/main/java/android/util/Log.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package android.util; + +public final class Log { + private Log() { + } + + public static int d(String tag, String msg) { + return 0; + } + + public static int d(String tag, String msg, Throwable tr) { + return 0; + } + + public static int i(String tag, String msg) { + return 0; + } + + public static int i(String tag, String msg, Throwable tr) { + return 0; + } + + public static int w(String tag, String msg) { + return 0; + } + + public static int w(String tag, String msg, Throwable tr) { + return 0; + } + + public static int e(String tag, String msg) { + return 0; + } + + public static int e(String tag, String msg, Throwable tr) { + return 0; + } +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/ApkDownloaderRunner.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/ApkDownloaderRunner.kt new file mode 100644 index 000000000..9a070f60c --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/ApkDownloaderRunner.kt @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import com.aurora.gplayapi.exceptions.GooglePlayException +import java.util.Locale +import okhttp3.OkHttpClient + +class ApkDownloaderRunner( + private val httpClient: DesktopHttpClient = DesktopHttpClient(OkHttpClient()), + private val authSource: AuthSource? = null, + private val appResolver: AppResolver = PlayAppResolver(httpClient), + private val downloader: Downloader = FileDownloader(httpClient), + private val archiveWriter: ApkArchiveWriter = SplitApkArchiveWriter() +) { + fun run(options: CliOptions): Int { + return try { + val authData = (authSource ?: buildAuthSource(options)).getAuthData() + val app = appResolver.resolve(options.packageName, authData) + val paths = DownloadPaths(options.outDir) + val appDir = paths.appVersionDirectory(app.packageName, app.versionCode) + val downloadedFiles = mutableListOf() + + println("Downloading ${app.displayName} (${app.packageName}) version ${app.versionCode}") + app.files.forEachIndexed { index, item -> + val target = paths.filePath(app.packageName, app.versionCode, item) + println("[${index + 1}/${app.files.size}] ${item.name}") + downloader.download(item, target) + downloadedFiles.add(target) + } + + archiveWriter.write(app.packageName, app.versionCode, appDir, downloadedFiles)?.let { + println("Saved split APK archive to $it") + } + println("Saved APK files to $appDir") + ExitCodes.SUCCESS + } catch (exception: AuthFailureException) { + System.err.println(exception.cleanMessage()) + ExitCodes.AUTH_FAILURE + } catch (exception: UnsupportedPackageException) { + System.err.println(exception.cleanMessage()) + ExitCodes.INVALID_ARGS_OR_UNSUPPORTED + } catch (exception: GooglePlayException) { + System.err.println(exception.cleanMessage()) + ExitCodes.INVALID_ARGS_OR_UNSUPPORTED + } catch (exception: DownloadException) { + System.err.println(exception.cleanMessage()) + ExitCodes.DOWNLOAD_FAILURE + } catch (exception: Exception) { + System.err.println(exception.cleanMessage()) + ExitCodes.DOWNLOAD_FAILURE + } + } + + private fun buildAuthSource(options: CliOptions): AuthSource { + val locale = Locale.forLanguageTag(options.geoProfile.localeTag) + return AuthSessionManager( + httpClient = httpClient, + json = JsonFactory.create(), + properties = DesktopDeviceProfile.properties(options.geoProfile), + locale = locale, + cacheFile = AuthSessionManager.defaultCacheFile(options.geoProfile.cacheKey) + ) + } + + private fun Exception.cleanMessage(): String { + val message = message ?: this::class.simpleName.orEmpty() + return message.filter { it == '\n' || it == '\t' || !it.isISOControl() }.trim() + } +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/AuthSessionManager.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/AuthSessionManager.kt new file mode 100644 index 000000000..48b1fd308 --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/AuthSessionManager.kt @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.PlayResponse +import com.aurora.gplayapi.helpers.AuthHelper +import java.nio.file.Files +import java.nio.file.Path +import java.util.Locale +import java.util.Properties +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class AuthFailureException(message: String, cause: Throwable? = null) : Exception(message, cause) + +fun interface AuthSource { + fun getAuthData(): AuthData +} + +class AuthSessionManager( + private val httpClient: DesktopHttpClient, + private val json: Json, + private val properties: Properties, + private val locale: Locale = Locale.US, + private val dispenserUrl: String = DEFAULT_DISPENSER_URL, + private val cacheFile: Path = defaultCacheFile() +) : AuthSource { + override fun getAuthData(): AuthData { + val cached = readCached() + if (cached != null && AuthHelper.isValid(cached)) { + return cached + } + + return buildAnonymousAuthData().also { authData -> + Files.createDirectories(cacheFile.parent) + cacheFile.writeText(json.encodeToString(authData)) + } + } + + private fun readCached(): AuthData? { + if (!cacheFile.exists()) return null + return runCatching { + json.decodeFromString(cacheFile.readText()) + }.getOrNull() + } + + private fun buildAnonymousAuthData(): AuthData { + return try { + val requestBody = json.encodeToString(properties).toByteArray() + val response = retryTransientDispenserFailures { + httpClient.postAuth(dispenserUrl, requestBody) + } + if (!response.isSuccessful) { + throw AuthFailureException( + "Dispenser returned HTTP ${response.code}: ${response.errorString}" + ) + } + + val dispenserAuth = json.decodeFromString( + response.responseBytes.decodeToString() + ) + AuthHelper.build( + email = dispenserAuth.email, + token = dispenserAuth.auth, + tokenType = AuthHelper.Token.AUTH, + isAnonymous = true, + properties = properties, + locale = locale + ) + } catch (exception: AuthFailureException) { + throw exception + } catch (exception: Exception) { + val location = exception.stackTrace.firstOrNull()?.let { " at $it" }.orEmpty() + throw AuthFailureException( + "Failed to build anonymous auth session: ${exception::class.qualifiedName}: ${exception.message}$location", + exception + ) + } + } + + private fun retryTransientDispenserFailures( + maxAttempts: Int = 3, + request: () -> PlayResponse + ): PlayResponse { + var lastResponse: PlayResponse? = null + repeat(maxAttempts) { attempt -> + val response = request() + if (response.code !in 500..599 || attempt == maxAttempts - 1) { + return response + } + lastResponse = response + Thread.sleep(500L * (attempt + 1)) + } + return lastResponse ?: request() + } + + @Serializable + private data class DispenserAuth( + val email: String, + @SerialName("authToken") val auth: String + ) + + companion object { + const val DEFAULT_DISPENSER_URL = "https://auroraoss.com/api/auth" + + fun defaultCacheFile(cacheKey: String = "default"): Path { + val home = System.getProperty("user.home") + ?: error("user.home system property is not set") + return Path(home, ".config", "aurora-apk-downloader", "auth-$cacheKey.json") + } + } +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/Cli.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/Cli.kt new file mode 100644 index 000000000..874e08748 --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/Cli.kt @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import java.nio.file.Path +import kotlin.io.path.Path + +data class CliOptions( + val packageName: String, + val outDir: Path = Path("downloads"), + val geoProfile: GeoProfile = GeoProfile.default() +) + +data class GeoProfile( + val country: String, + val localeTag: String, + val timeZone: String, + val operator: String +) { + val cacheKey: String + get() = listOf(country, localeTag, timeZone, operator) + .joinToString("-") + .lowercase() + .replace(Regex("[^a-z0-9._-]"), "_") + + companion object { + fun default(): GeoProfile = RegionProfile.US.geoProfile + + fun forCountry(country: String): GeoProfile? = RegionProfile.entries + .firstOrNull { it.name.equals(country, ignoreCase = true) } + ?.geoProfile + } +} + +private enum class RegionProfile(val geoProfile: GeoProfile) { + US( + GeoProfile( + country = "US", + localeTag = "en-US", + timeZone = "America/New_York", + operator = "310260" + ) + ) +} + +sealed interface CliParseResult { + data class Valid(val options: CliOptions) : CliParseResult + data class Invalid(val message: String) : CliParseResult +} + +object CliParser { + fun parse(args: Array): CliParseResult { + if (args.isEmpty()) return CliParseResult.Invalid("Missing package name") + + var packageName: String? = null + var outDir = Path("downloads") + var geoProfile = GeoProfile.default() + var index = 0 + + while (index < args.size) { + when (val arg = args[index]) { + "--out" -> { + val value = args.getOrNull(index + 1) + ?: return CliParseResult.Invalid("Missing value for --out") + outDir = Path(value) + index += 2 + } + "--country" -> { + val value = args.getOrNull(index + 1) + ?: return CliParseResult.Invalid("Missing value for --country") + geoProfile = GeoProfile.forCountry(value) + ?: return CliParseResult.Invalid("Unsupported country: $value") + index += 2 + } + "--locale" -> { + val value = args.getOrNull(index + 1) + ?: return CliParseResult.Invalid("Missing value for --locale") + geoProfile = geoProfile.copy(localeTag = value) + index += 2 + } + "--timezone" -> { + val value = args.getOrNull(index + 1) + ?: return CliParseResult.Invalid("Missing value for --timezone") + geoProfile = geoProfile.copy(timeZone = value) + index += 2 + } + "--operator" -> { + val value = args.getOrNull(index + 1) + ?: return CliParseResult.Invalid("Missing value for --operator") + if (!value.matches(Regex("\\d{5,6}"))) { + return CliParseResult.Invalid("Invalid operator: $value") + } + geoProfile = geoProfile.copy(operator = value) + index += 2 + } + else -> { + if (arg.startsWith("--")) { + return CliParseResult.Invalid("Unknown option: $arg") + } + if (packageName != null) { + return CliParseResult.Invalid("Unexpected argument: $arg") + } + packageName = arg + index++ + } + } + } + + val parsedPackageName = packageName + ?: return CliParseResult.Invalid("Missing package name") + if (!PACKAGE_NAME_REGEX.matches(parsedPackageName)) { + return CliParseResult.Invalid("Invalid package name: $parsedPackageName") + } + + return CliParseResult.Valid(CliOptions(parsedPackageName, outDir, geoProfile)) + } + + private val PACKAGE_NAME_REGEX = + Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+") +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DesktopDeviceProfile.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DesktopDeviceProfile.kt new file mode 100644 index 000000000..bf760af1a --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DesktopDeviceProfile.kt @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import java.util.Properties + +object DesktopDeviceProfile { + fun properties(geoProfile: GeoProfile = GeoProfile.default()): Properties = Properties().apply { + setProperty("UserReadableName", "Google Pixel 7a") + setProperty("Build.BOARD", "lynx") + setProperty("Build.BOOTLOADER", "lynx-1.0-11017859") + setProperty("Build.BRAND", "google") + setProperty("Build.DEVICE", "lynx") + setProperty("Build.FINGERPRINT", "google/lynx/lynx:14/AP2A.240805.005/12025142:user/release-keys") + setProperty("Build.HARDWARE", "lynx") + setProperty("Build.ID", "AP2A.240805.005") + setProperty("Build.MANUFACTURER", "Google") + setProperty("Build.MODEL", "Pixel 7a") + setProperty("Build.PRODUCT", "lynx") + setProperty("Build.RADIO", "g5300g-230927-231026-B-11040898") + setProperty("Build.TAGS", "release-keys") + setProperty("Build.TIME", "1722459600000") + setProperty("Build.TYPE", "user") + setProperty("Build.USER", "android-build") + setProperty("Build.VERSION.CODENAME", "REL") + setProperty("Build.VERSION.INCREMENTAL", "12025142") + setProperty("Build.VERSION.RELEASE", "15") + setProperty("Build.VERSION.SDK_INT", "35") + setProperty("CellOperator", geoProfile.operator.take(3)) + setProperty("Client", "android-google") + setProperty("Features", "android.hardware.audio.output,android.hardware.bluetooth,android.hardware.bluetooth_le,android.hardware.camera,android.hardware.camera.autofocus,android.hardware.camera.flash,android.hardware.camera.front,android.hardware.fingerprint,android.hardware.location,android.hardware.location.gps,android.hardware.location.network,android.hardware.microphone,android.hardware.nfc,android.hardware.opengles.aep,android.hardware.ram.normal,android.hardware.screen.landscape,android.hardware.screen.portrait,android.hardware.sensor.accelerometer,android.hardware.sensor.compass,android.hardware.sensor.gyroscope,android.hardware.sensor.light,android.hardware.sensor.proximity,android.hardware.telephony,android.hardware.telephony.gsm,android.hardware.touchscreen,android.hardware.touchscreen.multitouch,android.hardware.usb.accessory,android.hardware.usb.host,android.hardware.wifi,android.hardware.wifi.direct,android.software.app_widgets,android.software.backup,android.software.cts,android.software.device_admin,android.software.file_based_encryption,android.software.home_screen,android.software.input_methods,android.software.live_wallpaper,android.software.managed_users,android.software.print,android.software.secure_lock_screen,android.software.verified_boot,android.software.webview,com.google.android.feature.GOOGLE_BUILD,com.google.android.feature.GOOGLE_EXPERIENCE,com.google.android.feature.PIXEL_EXPERIENCE") + setProperty("GL.Extensions", "GL_ANDROID_extension_pack_es31a,GL_EXT_color_buffer_float,GL_EXT_debug_marker,GL_EXT_texture_filter_anisotropic,GL_KHR_debug,GL_OES_EGL_image,GL_OES_EGL_image_external,GL_OES_depth24,GL_OES_depth_texture,GL_OES_element_index_uint,GL_OES_rgb8_rgba8,GL_OES_standard_derivatives,GL_OES_texture_float,GL_OES_texture_npot,GL_OES_vertex_array_object") + setProperty("GL.Version", "196610") + setProperty("GSF.version", "251333035") + setProperty("HasFiveWayNavigation", "false") + setProperty("HasHardKeyboard", "false") + setProperty("Keyboard", "1") + setProperty("Locales", localeList(geoProfile)) + setProperty("Navigation", "1") + setProperty("Platforms", "arm64-v8a") + setProperty("Roaming", "mobile-notroaming") + setProperty("Screen.Density", "420") + setProperty("Screen.Height", "2424") + setProperty("Screen.Width", "1080") + setProperty("ScreenLayout", "2") + setProperty("SharedLibraries", "android.ext.shared,android.hidl.base-V1.0-java,android.hidl.manager-V1.0-java,android.net.ipsec.ike,android.test.base,android.test.mock,android.test.runner,com.android.future.usb.accessory,com.android.location.provider,com.android.media.remotedisplay,com.android.mediadrm.signer,com.android.nfc_extras,com.android.cts.ctsshim.shared_library,com.google.android.gms,javax.obex,org.apache.http.legacy") + setProperty("SimOperator", geoProfile.operator) + setProperty("TimeZone", geoProfile.timeZone) + setProperty("TouchScreen", "3") + setProperty("Vending.version", "84651510") + setProperty("Vending.versionString", "46.5.15-31 [0] [PR] 777931631") + } + + private fun localeList(geoProfile: GeoProfile): String { + val normalized = geoProfile.localeTag.replace("-", "_") + val language = normalized.substringBefore("_") + return listOf( + language, + normalized, + "en", + "en_US", + "fr", + "fr_FR", + "de", + "de_DE", + "es", + "es_ES" + ).distinct().joinToString(",") + } +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DesktopHttpClient.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DesktopHttpClient.kt new file mode 100644 index 000000000..0cf708e6c --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DesktopHttpClient.kt @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import com.aurora.gplayapi.data.models.PlayResponse +import com.aurora.gplayapi.network.IHttpClient +import java.io.IOException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import okhttp3.Headers.Companion.toHeaders +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response + +class DesktopHttpClient(private val okHttpClient: OkHttpClient) : IHttpClient { + companion object { + private const val USER_AGENT = "com.aurora.store-4.8.3-75" + } + + private val _responseCode = MutableStateFlow(0) + override val responseCode: StateFlow + get() = _responseCode.asStateFlow() + + fun call(url: String, headers: Map = emptyMap()): Response { + val request = Request.Builder() + .url(url) + .headers(headers.toHeaders()) + .get() + .build() + return okHttpClient.newCall(request).execute() + } + + @Throws(IOException::class) + override fun post( + url: String, + headers: Map, + params: Map + ): PlayResponse { + val request = Request.Builder() + .url(buildUrl(url, params)) + .headers(headers.toHeaders()) + .post(ByteArray(0).toRequestBody()) + .build() + return process(request) + } + + override fun postAuth(url: String, body: ByteArray): PlayResponse { + val request = Request.Builder() + .url(url) + .header("User-Agent", USER_AGENT) + .post(body.toRequestBody("application/json".toMediaType())) + .build() + return process(request) + } + + @Throws(IOException::class) + override fun post(url: String, headers: Map, body: ByteArray): PlayResponse { + val request = Request.Builder() + .url(url) + .headers(headers.toHeaders()) + .post(body.toRequestBody()) + .build() + return process(request) + } + + @Throws(IOException::class) + override fun get(url: String, headers: Map): PlayResponse = + get(url, headers, emptyMap()) + + @Throws(IOException::class) + override fun get( + url: String, + headers: Map, + params: Map + ): PlayResponse { + val request = Request.Builder() + .url(buildUrl(url, params)) + .headers(headers.toHeaders()) + .get() + .build() + return process(request) + } + + override fun getAuth(url: String): PlayResponse { + val request = Request.Builder() + .url(url) + .header("User-Agent", USER_AGENT) + .get() + .build() + return process(request) + } + + @Throws(IOException::class) + override fun get(url: String, headers: Map, paramString: String): PlayResponse { + val request = Request.Builder() + .url("$url$paramString") + .headers(headers.toHeaders()) + .get() + .build() + return process(request) + } + + private fun process(request: Request): PlayResponse { + _responseCode.value = 0 + okHttpClient.newCall(request).execute().use { response -> + val responseBytes = response.body.bytes() + _responseCode.value = response.code + return PlayResponse( + isSuccessful = response.isSuccessful, + code = response.code, + responseBytes = responseBytes, + errorString = if (response.isSuccessful) { + "" + } else { + responseBytes.decodeToString().ifBlank { response.message } + } + ) + } + } + + private fun buildUrl(url: String, params: Map): HttpUrl { + val builder = url.toHttpUrl().newBuilder() + params.forEach { (key, value) -> builder.addQueryParameter(key, value) } + return builder.build() + } +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DownloadModels.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DownloadModels.kt new file mode 100644 index 000000000..6598ed20b --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DownloadModels.kt @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +data class DownloadItem( + val url: String, + val name: String, + val size: Long, + val sha1: String = "", + val sha256: String = "", + val sharedLibraryPackageName: String? = null +) + +data class ResolvedApp( + val packageName: String, + val displayName: String, + val versionCode: Long, + val files: List +) diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DownloadPaths.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DownloadPaths.kt new file mode 100644 index 000000000..aaf2d2ad9 --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/DownloadPaths.kt @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import java.nio.file.Path + +class DownloadPaths(private val outDir: Path) { + fun appVersionDirectory(packageName: String, versionCode: Long): Path = + outDir.resolve(packageName).resolve(versionCode.toString()) + + fun filePath(packageName: String, versionCode: Long, item: DownloadItem): Path { + val appDir = appVersionDirectory(packageName, versionCode) + val parent = item.sharedLibraryPackageName?.let { + appDir.resolve("libraries").resolve(it) + } ?: appDir + return parent.resolve(item.name) + } +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/ExitCodes.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/ExitCodes.kt new file mode 100644 index 000000000..80c0d415c --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/ExitCodes.kt @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +object ExitCodes { + const val SUCCESS = 0 + const val INVALID_ARGS_OR_UNSUPPORTED = 1 + const val AUTH_FAILURE = 2 + const val DOWNLOAD_FAILURE = 3 +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/FileDownloader.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/FileDownloader.kt new file mode 100644 index 000000000..bd9d86de5 --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/FileDownloader.kt @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import java.net.HttpURLConnection.HTTP_PARTIAL +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.fileSize +import kotlin.io.path.notExists +import kotlin.io.path.outputStream +import kotlin.io.path.pathString + +class DownloadException(message: String, cause: Throwable? = null) : Exception(message, cause) + +fun interface Downloader { + fun download(item: DownloadItem, target: Path) +} + +class FileDownloader(private val httpClient: DesktopHttpClient) : Downloader { + override fun download(item: DownloadItem, target: Path) { + if (target.exists() && verifyCompletedFile(target, item)) { + return + } + + Files.createDirectories(target.parent) + val tmp = target.resolveSibling("${target.fileName}.tmp") + val existingBytes = if (tmp.exists()) tmp.fileSize() else 0L + val headers = if (existingBytes > 0) { + mapOf("Range" to "bytes=$existingBytes-") + } else { + emptyMap() + } + + try { + httpClient.call(item.url, headers).use { response -> + if (!response.isSuccessful) { + throw DownloadException("Download failed for ${item.name}: HTTP ${response.code}") + } + + val resuming = existingBytes > 0 && response.code == HTTP_PARTIAL + if (existingBytes > 0 && !resuming) { + tmp.deleteIfExists() + } + + response.body.byteStream().use { input -> + val options = if (resuming) { + arrayOf(StandardOpenOption.CREATE, StandardOpenOption.APPEND) + } else { + arrayOf(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + } + Files.newOutputStream(tmp, *options).use { output -> + input.copyTo(output) + } + } + } + + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING) + if (!verifyCompletedFile(target, item)) { + target.deleteIfExists() + throw DownloadException("Verification failed for ${target.pathString}") + } + } catch (exception: DownloadException) { + throw exception + } catch (exception: Exception) { + throw DownloadException("Download failed for ${item.name}: ${exception.message}", exception) + } + } + + private fun verifyCompletedFile(target: Path, item: DownloadItem): Boolean { + if (target.notExists()) return false + if (item.size > 0 && target.fileSize() != item.size) return false + return FileVerifier.verify(target, item) + } +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/FileVerifier.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/FileVerifier.kt new file mode 100644 index 000000000..6ec8544ff --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/FileVerifier.kt @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import java.nio.file.Path +import java.security.DigestInputStream +import java.security.MessageDigest +import kotlin.io.path.inputStream +import kotlin.io.path.notExists +import kotlin.io.path.pathString + +object FileVerifier { + fun verify(file: Path, item: DownloadItem): Boolean { + if (file.notExists()) return false + val algorithm = if (item.sha256.isBlank()) "SHA-1" else "SHA-256" + val expected = if (algorithm == "SHA-1") item.sha1 else item.sha256 + if (expected.isBlank()) return false + + return runCatching { + val digest = MessageDigest.getInstance(algorithm) + file.inputStream().use { input -> + DigestInputStream(input, digest).use { digestInput -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (digestInput.read(buffer) != -1) { + /* DigestInputStream updates the digest as bytes are read. */ + } + } + } + digest.digest().toHex() == expected.lowercase() + }.getOrElse { + System.err.println("Failed to verify ${file.pathString}: ${it.message}") + false + } + } + + private fun ByteArray.toHex(): String = joinToString(separator = "") { byte -> + "%02x".format(byte) + } +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/JsonFactory.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/JsonFactory.kt new file mode 100644 index 000000000..61d87c4fd --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/JsonFactory.kt @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import com.aurora.gplayapi.data.serializers.LocaleSerializer +import com.aurora.gplayapi.data.serializers.PropertiesSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual + +object JsonFactory { + fun create(): Json { + val module = SerializersModule { + contextual(LocaleSerializer) + contextual(PropertiesSerializer) + } + + return Json { + prettyPrint = true + ignoreUnknownKeys = true + coerceInputValues = true + serializersModule = module + explicitNulls = false + } + } +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/Main.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/Main.kt new file mode 100644 index 000000000..569a76c51 --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/Main.kt @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import kotlin.system.exitProcess + +fun main(args: Array) { + when (val result = CliParser.parse(args)) { + is CliParseResult.Invalid -> { + System.err.println(result.message) + System.err.println( + "Usage: apk-downloader [--out ] [--country US|TR] " + + "[--locale ] [--timezone ] [--operator ]" + ) + exitProcess(ExitCodes.INVALID_ARGS_OR_UNSUPPORTED) + } + is CliParseResult.Valid -> { + exitProcess(ApkDownloaderRunner().run(result.options)) + } + } +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/PlayAppResolver.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/PlayAppResolver.kt new file mode 100644 index 000000000..b9ed67b99 --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/PlayAppResolver.kt @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.PlayFile +import com.aurora.gplayapi.helpers.AppDetailsHelper +import com.aurora.gplayapi.helpers.PurchaseHelper + +class UnsupportedPackageException(message: String) : Exception(message) + +fun interface AppResolver { + fun resolve(packageName: String, authData: AuthData): ResolvedApp +} + +class PlayAppResolver( + private val httpClient: DesktopHttpClient +) : AppResolver { + override fun resolve(packageName: String, authData: AuthData): ResolvedApp { + val app = AppDetailsHelper(authData) + .using(httpClient) + .getAppByPackageName(packageName) + + val purchaseHelper = PurchaseHelper(authData).using(httpClient) + val appFiles = app.usableFiles().ifEmpty { + purchaseHelper.purchase(app.packageName, app.versionCode, app.offerType) + .usableFiles() + } + + if (appFiles.isEmpty()) { + throw UnsupportedPackageException("No APK files available for ${app.packageName}") + } + + val sharedLibraryFiles = app.dependencies.dependentLibraries.flatMap { sharedLibrary -> + val files = sharedLibrary.usableFiles(sharedLibrary.packageName).ifEmpty { + purchaseHelper.purchase(sharedLibrary.packageName, sharedLibrary.versionCode, 0) + .usableFiles(sharedLibrary.packageName) + } + files + } + + return ResolvedApp( + packageName = app.packageName, + displayName = app.displayName, + versionCode = app.versionCode, + files = sharedLibraryFiles + appFiles + ) + } + + private fun App.usableFiles(sharedLibraryPackageName: String? = null): List = + fileList.usableFiles(sharedLibraryPackageName) + + private fun List.usableFiles( + sharedLibraryPackageName: String? = null + ): List = mapNotNull { playFile -> + playFile.toDownloadItem(sharedLibraryPackageName) + } + + private fun PlayFile.toDownloadItem(sharedLibraryPackageName: String?): DownloadItem? { + if (url.isBlank()) return null + return when (type) { + PlayFile.Type.BASE, + PlayFile.Type.SPLIT -> DownloadItem( + url = url, + name = name, + size = size, + sha1 = sha1, + sha256 = sha256, + sharedLibraryPackageName = sharedLibraryPackageName + ) + PlayFile.Type.OBB, + PlayFile.Type.PATCH -> null + } + } +} diff --git a/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/SplitApkArchiveWriter.kt b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/SplitApkArchiveWriter.kt new file mode 100644 index 000000000..929b53f89 --- /dev/null +++ b/apk-downloader/src/main/kotlin/com/aurora/apkdownloader/SplitApkArchiveWriter.kt @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import java.nio.file.Files +import java.nio.file.Path +import java.util.zip.Deflater +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.io.path.inputStream +import kotlin.io.path.name +import kotlin.io.path.outputStream + +fun interface ApkArchiveWriter { + fun write(packageName: String, versionCode: Long, appDir: Path, files: List): Path? +} + +class SplitApkArchiveWriter : ApkArchiveWriter { + override fun write( + packageName: String, + versionCode: Long, + appDir: Path, + files: List + ): Path? { + if (files.size <= 1) return null + + Files.createDirectories(appDir) + val archive = appDir.resolve("${packageName}_$versionCode.apks") + val apkFiles = files.filter { it.name.endsWith(".apk") } + if (apkFiles.size <= 1) return null + + ZipOutputStream(archive.outputStream()).use { zip -> + zip.setLevel(Deflater.NO_COMPRESSION) + apkFiles.forEach { file -> + val entryName = appDir.relativize(file).toString().replace('\\', '/') + zip.putNextEntry(ZipEntry(entryName)) + file.inputStream().use { input -> input.copyTo(zip) } + zip.closeEntry() + } + } + + return archive + } +} diff --git a/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/ApkDownloaderRunnerTest.kt b/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/ApkDownloaderRunnerTest.kt new file mode 100644 index 000000000..ef8bd4112 --- /dev/null +++ b/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/ApkDownloaderRunnerTest.kt @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import com.aurora.gplayapi.data.models.AuthData +import com.google.common.truth.Truth.assertThat +import java.nio.file.Path +import kotlin.io.path.Path +import org.junit.Test + +class ApkDownloaderRunnerTest { + @Test + fun `download failure exits with code 3`() { + val runner = ApkDownloaderRunner( + authSource = AuthSource { AuthData("BOGUS") }, + appResolver = AppResolver { packageName, _ -> + ResolvedApp( + packageName = packageName, + displayName = "Example", + versionCode = 1, + files = listOf( + DownloadItem( + url = "https://example.test/base.apk", + name = "base.apk", + size = 1, + sha256 = "00" + ) + ) + ) + }, + downloader = Downloader { _, _: Path -> + throw DownloadException("Verification failed") + }, + archiveWriter = ApkArchiveWriter { _, _, _, _ -> null } + ) + + val exitCode = runner.run(CliOptions("com.example.app", Path("downloads"))) + + assertThat(exitCode).isEqualTo(ExitCodes.DOWNLOAD_FAILURE) + } +} diff --git a/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/CliParserTest.kt b/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/CliParserTest.kt new file mode 100644 index 000000000..e6a961f66 --- /dev/null +++ b/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/CliParserTest.kt @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import com.google.common.truth.Truth.assertThat +import kotlin.io.path.Path +import org.junit.Test + +class CliParserTest { + @Test + fun `valid package only uses default output directory`() { + val result = CliParser.parse(arrayOf("org.mozilla.firefox")) + + assertThat(result).isEqualTo( + CliParseResult.Valid( + CliOptions( + packageName = "org.mozilla.firefox", + outDir = Path("downloads"), + geoProfile = GeoProfile.default() + ) + ) + ) + } + + @Test + fun `valid package with output directory`() { + val result = CliParser.parse(arrayOf("org.mozilla.firefox", "--out", "./downloads")) + + assertThat(result).isEqualTo( + CliParseResult.Valid( + CliOptions( + packageName = "org.mozilla.firefox", + outDir = Path("./downloads"), + geoProfile = GeoProfile.default() + ) + ) + ) + } + + @Test + fun `missing package is invalid`() { + val result = CliParser.parse(emptyArray()) + + assertThat(result).isEqualTo(CliParseResult.Invalid("Missing package name")) + } + + @Test + fun `unknown option is invalid`() { + val result = CliParser.parse(arrayOf("org.mozilla.firefox", "--version-code", "1")) + + assertThat(result).isEqualTo(CliParseResult.Invalid("Unknown option: --version-code")) + } + + @Test + fun `unsupported country is invalid`() { + val result = CliParser.parse(arrayOf("com.example.app", "--country", "ZZ")) + + assertThat(result).isEqualTo(CliParseResult.Invalid("Unsupported country: ZZ")) + } +} diff --git a/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/DownloadPathsTest.kt b/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/DownloadPathsTest.kt new file mode 100644 index 000000000..bcc125952 --- /dev/null +++ b/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/DownloadPathsTest.kt @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import com.google.common.truth.Truth.assertThat +import kotlin.io.path.Path +import org.junit.Test + +class DownloadPathsTest { + @Test + fun `base and split APK files go under package version directory`() { + val paths = DownloadPaths(Path("downloads")) + + assertThat( + paths.filePath( + "org.mozilla.firefox", + 123, + DownloadItem("https://example.test/base.apk", "base.apk", 4, sha256 = "abcd") + ) + ).isEqualTo(Path("downloads", "org.mozilla.firefox", "123", "base.apk")) + + assertThat( + paths.filePath( + "org.mozilla.firefox", + 123, + DownloadItem("https://example.test/split.apk", "split_config.en.apk", 4, sha256 = "abcd") + ) + ).isEqualTo(Path("downloads", "org.mozilla.firefox", "123", "split_config.en.apk")) + } + + @Test + fun `shared library APK files go under libraries package directory`() { + val paths = DownloadPaths(Path("downloads")) + + assertThat( + paths.filePath( + "org.mozilla.firefox", + 123, + DownloadItem( + url = "https://example.test/lib.apk", + name = "base.apk", + size = 4, + sha256 = "abcd", + sharedLibraryPackageName = "com.google.android.trichromelibrary" + ) + ) + ).isEqualTo( + Path( + "downloads", + "org.mozilla.firefox", + "123", + "libraries", + "com.google.android.trichromelibrary", + "base.apk" + ) + ) + } +} diff --git a/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/FileDownloaderTest.kt b/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/FileDownloaderTest.kt new file mode 100644 index 000000000..b19081cfa --- /dev/null +++ b/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/FileDownloaderTest.kt @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import com.google.common.truth.Truth.assertThat +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import java.net.InetSocketAddress +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import kotlin.io.path.exists +import kotlin.io.path.readBytes +import kotlin.io.path.writeBytes +import okhttp3.OkHttpClient +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class FileDownloaderTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `fresh download writes tmp then final file`() { + val bytes = "hello world".toByteArray() + TestHttpServer { exchange -> + exchange.respond(200, bytes) + }.use { server -> + val target = targetPath("base.apk") + val item = DownloadItem(server.url, "base.apk", bytes.size.toLong(), sha256 = bytes.sha256()) + + downloader().download(item, target) + + assertThat(target.readBytes()).isEqualTo(bytes) + assertThat(tmpPath(target).exists()).isFalse() + } + } + + @Test + fun `partial tmp sends range and appends when server honors it`() { + val fullBytes = "hello world".toByteArray() + var rangeHeader: String? = null + TestHttpServer { exchange -> + rangeHeader = exchange.requestHeaders.getFirst("Range") + exchange.respond(206, "world".toByteArray()) + }.use { server -> + val target = targetPath("base.apk") + tmpPath(target).writeBytes("hello ".toByteArray()) + val item = DownloadItem(server.url, "base.apk", fullBytes.size.toLong(), sha256 = fullBytes.sha256()) + + downloader().download(item, target) + + assertThat(rangeHeader).isEqualTo("bytes=6-") + assertThat(target.readBytes()).isEqualTo(fullBytes) + } + } + + @Test + fun `server ignoring range restarts download cleanly`() { + val fullBytes = "hello world".toByteArray() + var rangeHeader: String? = null + TestHttpServer { exchange -> + rangeHeader = exchange.requestHeaders.getFirst("Range") + exchange.respond(200, fullBytes) + }.use { server -> + val target = targetPath("base.apk") + tmpPath(target).writeBytes("partial ".toByteArray()) + val item = DownloadItem(server.url, "base.apk", fullBytes.size.toLong(), sha256 = fullBytes.sha256()) + + downloader().download(item, target) + + assertThat(rangeHeader).isEqualTo("bytes=8-") + assertThat(target.readBytes()).isEqualTo(fullBytes) + } + } + + @Test + fun `corrupt file is rejected`() { + val bytes = "hello world".toByteArray() + TestHttpServer { exchange -> + exchange.respond(200, bytes) + }.use { server -> + val target = targetPath("base.apk") + val item = DownloadItem(server.url, "base.apk", bytes.size.toLong(), sha256 = "00") + + val result = runCatching { downloader().download(item, target) } + + assertThat(result.exceptionOrNull()).isInstanceOf(DownloadException::class.java) + assertThat(target.exists()).isFalse() + } + } + + private fun downloader(): FileDownloader = FileDownloader(DesktopHttpClient(OkHttpClient())) + + private fun targetPath(name: String): Path = temporaryFolder.newFolder().toPath().resolve(name) + + private fun tmpPath(target: Path): Path = target.resolveSibling("${target.fileName}.tmp") + + private class TestHttpServer( + private val handler: (HttpExchange) -> Unit + ) : AutoCloseable { + private val server = HttpServer.create(InetSocketAddress(0), 0) + + val url: String + get() = "http://127.0.0.1:${server.address.port}/file" + + init { + server.createContext("/file") { exchange -> handler(exchange) } + server.start() + } + + override fun close() { + server.stop(0) + } + } + + private fun HttpExchange.respond(code: Int, bytes: ByteArray) { + sendResponseHeaders(code, bytes.size.toLong()) + responseBody.use { it.write(bytes) } + } + + private fun ByteArray.sha256(): String { + val digest = MessageDigest.getInstance("SHA-256").digest(this) + return digest.joinToString(separator = "") { "%02x".format(it) } + } +} diff --git a/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/SplitApkArchiveWriterTest.kt b/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/SplitApkArchiveWriterTest.kt new file mode 100644 index 000000000..f96e92286 --- /dev/null +++ b/apk-downloader/src/test/kotlin/com/aurora/apkdownloader/SplitApkArchiveWriterTest.kt @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.apkdownloader + +import com.google.common.truth.Truth.assertThat +import java.util.zip.ZipFile +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.writeText +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class SplitApkArchiveWriterTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `writes apks archive for multiple apk files`() { + val appDir = temporaryFolder.newFolder().toPath() + val base = appDir.resolve("base.apk").also { it.writeText("base") } + val split = appDir.resolve("config.arm64_v8a.apk").also { it.writeText("split") } + + val archive = SplitApkArchiveWriter().write( + packageName = "org.mozilla.firefox", + versionCode = 123, + appDir = appDir, + files = listOf(base, split) + ) + + assertThat(archive).isNotNull() + assertThat(archive!!.exists()).isTrue() + ZipFile(archive.toFile()).use { zip -> + assertThat(zip.getEntry("base.apk")).isNotNull() + assertThat(zip.getEntry("config.arm64_v8a.apk")).isNotNull() + } + } + + @Test + fun `preserves shared library relative paths`() { + val appDir = temporaryFolder.newFolder().toPath() + val base = appDir.resolve("base.apk").also { it.writeText("base") } + val libDir = appDir.resolve("libraries/com.example.lib").also { it.createDirectories() } + val lib = libDir.resolve("base.apk").also { it.writeText("lib") } + + val archive = SplitApkArchiveWriter().write( + packageName = "com.example.app", + versionCode = 123, + appDir = appDir, + files = listOf(base, lib) + ) + + ZipFile(archive!!.toFile()).use { zip -> + assertThat(zip.getEntry("base.apk")).isNotNull() + assertThat(zip.getEntry("libraries/com.example.lib/base.apk")).isNotNull() + } + } + + @Test + fun `does not write archive for a single apk file`() { + val appDir = temporaryFolder.newFolder().toPath() + val base = appDir.resolve("base.apk").also { it.writeText("base") } + + val archive = SplitApkArchiveWriter().write( + packageName = "org.mozilla.firefox", + versionCode = 123, + appDir = appDir, + files = listOf(base) + ) + + assertThat(archive).isNull() + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 1c9d267b7..db9655bc3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,7 @@ buildscript { plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.jetbrains.kotlin.jvm) apply false alias(libs.plugins.jetbrains.kotlin.compose) apply false alias(libs.plugins.jetbrains.kotlin.parcelize) apply false alias(libs.plugins.jetbrains.kotlin.serialization) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae7bb403d..f9b0ca680 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ gplayapi = "3.6.3" hiddenapibypass = "6.1" hilt = "2.59.2" junit = "4.13.2" +coroutines = "1.10.2" kotlin = "2.3.21" ksp = "2.3.7" ktlint = "14.2.0" @@ -91,6 +92,7 @@ hilt-androidx-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = hilt-androidx-work = { module = "androidx.hilt:hilt-work", version.ref = "androidx-hilt" } huawei-hms-coreservice = { module = "com.huawei.hms:ag-coreservice", version.ref = "agCoreservice" } junit = { module = "junit:junit", version.ref = "junit" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } lsposed-hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenapibypass" } process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "processPhoenix" } @@ -107,6 +109,7 @@ androidx-navigation = { id = "androidx.navigation.safeargs.kotlin", version.ref google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt-android-plugin = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } jetbrains-kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jetbrains-kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9b265199c..1cc1443ef 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,4 +32,5 @@ dependencyResolutionManagement { } } include(":app") +include(":apk-downloader") rootProject.name = "AuroraStore4"