Add apk downloader project based on aurora's core #2

Merged
athaliar merged 1 commits from nathan/apkDownloader into master 2026-07-07 14:26:29 +00:00
33 changed files with 1812 additions and 0 deletions
Showing only changes of commit 334fd2fffd - Show all commits

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.gradle
**/build
**/downloads
downloads
local.properties

View File

@@ -53,6 +53,104 @@ 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). 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/<packageName>/<versionCode>/`. 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/<packageName>/<versionCode>/
base.apk
config_*.apk
<packageName>_<versionCode>.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:/downloads" \
-v "$HOME/.config/aurora-apk-downloader:/root/.config/aurora-apk-downloader" \
aurora-apk-downloader \
com.nestle.tr.nescafe.nescafe3in1loyaltyapp --country TR --out /downloads
```
If your Proton VPN runs in a separate VPN container, run the downloader with that container's network namespace, for example `--network container:<vpn-container-name>`.
The image also includes an exec-friendly wrapper that takes the country first, then the package name. Start a reusable container and run downloads with `docker exec`:
```bash
docker run -d --name aurora-apk-downloader \
-v "$PWD/downloads:/downloads" \
-v "$HOME/.config/aurora-apk-downloader:/root/.config/aurora-apk-downloader" \
--entrypoint sleep \
aurora-apk-downloader infinity
docker exec aurora-apk-downloader \
download-apk TR com.nestle.tr.nescafe.nescafe3in1loyaltyapp
```
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 start the reusable downloader stack:
```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 -d --build
docker compose \
--env-file apk-downloader/proton.env \
-f apk-downloader/docker-compose.proton.yml \
exec downloader download-apk TR com.nestle.tr.nescafe.nescafe3in1loyaltyapp
```
If `APK_COUNTRY` and `APK_PACKAGE` are set in the env file, you can use those defaults instead:
```bash
docker compose \
--env-file apk-downloader/proton.env \
-f apk-downloader/docker-compose.proton.yml \
exec downloader sh -lc 'download-apk "$APK_COUNTRY" "$APK_PACKAGE"'
```
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 ## Certificate Fingerprints
- SHA1: 94:42:75:D7:59:8B:C0:3E:48:85:06:06:42:25:A7:19:90:A2:22:02 - SHA1: 94:42:75:D7:59:8B:C0:3E:48:85:06:06:42:25:A7:19:90:A2:22:02

22
apk-downloader/.env Normal file
View File

@@ -0,0 +1,22 @@
# SPDX-FileCopyrightText: 2026 Aurora OSS
# SPDX-License-Identifier: GPL-3.0-or-later
SERVER_COUNTRIES=Turkey
TZ=Europe/Paris
# 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=

26
apk-downloader/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# 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
COPY apk-downloader/docker/download-apk /usr/local/bin/download-apk
ENV PATH="/opt/apk-downloader/bin:${PATH}" \
APK_DOWNLOADS_DIR="/downloads"
RUN mkdir -p /downloads \
&& chmod +x /usr/local/bin/download-apk
ENTRYPOINT ["/opt/apk-downloader/bin/apk-downloader"]

View File

@@ -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<KotlinCompile>().configureEach {
dependsOn(extractGplayApiJar)
}
tasks.withType<JavaExec>().configureEach {
workingDir = rootProject.projectDir
}
tasks.named<JavaExec>("run") {
isIgnoreExitValue = true
doLast {
val exitValue = executionResult.get().exitValue
if (exitValue != 0) {
logger.lifecycle("apk-downloader exited with code $exitValue")
}
}
}

View File

@@ -0,0 +1,48 @@
# 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}
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:
container_name: aurora-apk-downloader
build:
context: ..
dockerfile: apk-downloader/Dockerfile
image: aurora-apk-downloader:latest
entrypoint:
- sleep
- infinity
network_mode: "service:protonvpn"
depends_on:
protonvpn:
condition: service_healthy
environment:
APK_DOWNLOADS_DIR: /downloads
APK_COUNTRY: ${APK_COUNTRY:-TR}
APK_PACKAGE: ${APK_PACKAGE:-com.nestle.tr.nescafe.nescafe3in1loyaltyapp}
volumes:
- ${DOWNLOADS_DIR:-../downloads}:/downloads
- ${AUTH_CACHE_DIR:-../.apk-downloader-auth}:/root/.config/aurora-apk-downloader
restart: unless-stopped

View File

@@ -0,0 +1,25 @@
# SPDX-FileCopyrightText: 2026 Aurora OSS
# SPDX-License-Identifier: GPL-3.0-or-later
# Optional defaults for `docker compose exec downloader sh -lc 'download-apk "$APK_COUNTRY" "$APK_PACKAGE"'`.
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=

View File

@@ -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 extends Parcelable> 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<String> createStringArrayList() {
throw unsupported();
}
public void writeStringList(List<String> value) {
throw unsupported();
}
public <T extends Parcelable> ArrayList<T> createTypedArrayList(Parcelable.Creator<T> creator) {
throw unsupported();
}
public <T extends Parcelable> void writeTypedList(List<T> value) {
throw unsupported();
}
private static UnsupportedOperationException unsupported() {
return new UnsupportedOperationException("Parcel is not supported in apk-downloader");
}
}

View File

@@ -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> {
T createFromParcel(Parcel source);
T[] newArray(int size);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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<java.nio.file.Path>()
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()
}
}

View File

@@ -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<AuthData>(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<DispenserAuth>(
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")
}
}
}

View File

@@ -0,0 +1,131 @@
/*
* 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"
)
),
TR(
GeoProfile(
country = "TR",
localeTag = "tr-TR",
timeZone = "Europe/Istanbul",
operator = "28601"
)
)
}
sealed interface CliParseResult {
data class Valid(val options: CliOptions) : CliParseResult
data class Invalid(val message: String) : CliParseResult
}
object CliParser {
fun parse(args: Array<String>): 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_]*)+")
}

View File

@@ -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(",")
}
}

View File

@@ -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<Int>
get() = _responseCode.asStateFlow()
fun call(url: String, headers: Map<String, String> = 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<String, String>,
params: Map<String, String>
): 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<String, String>, 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<String, String>): PlayResponse =
get(url, headers, emptyMap())
@Throws(IOException::class)
override fun get(
url: String,
headers: Map<String, String>,
params: Map<String, String>
): 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<String, String>, 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<String, String>): HttpUrl {
val builder = url.toHttpUrl().newBuilder()
params.forEach { (key, value) -> builder.addQueryParameter(key, value) }
return builder.build()
}
}

View File

@@ -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<DownloadItem>
)

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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<String>) {
when (val result = CliParser.parse(args)) {
is CliParseResult.Invalid -> {
System.err.println(result.message)
System.err.println(
"Usage: apk-downloader <package.name> [--out <path>] [--country US|TR] " +
"[--locale <tag>] [--timezone <zone>] [--operator <mccmnc>]"
)
exitProcess(ExitCodes.INVALID_ARGS_OR_UNSUPPORTED)
}
is CliParseResult.Valid -> {
exitProcess(ApkDownloaderRunner().run(result.options))
}
}
}

View File

@@ -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<DownloadItem> =
fileList.usableFiles(sharedLibraryPackageName)
private fun List<PlayFile>.usableFiles(
sharedLibraryPackageName: String? = null
): List<DownloadItem> = 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
}
}
}

View File

@@ -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>): Path?
}
class SplitApkArchiveWriter : ApkArchiveWriter {
override fun write(
packageName: String,
versionCode: Long,
appDir: Path,
files: List<Path>
): 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
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,83 @@
/*
* 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 `valid package with country profile`() {
val result = CliParser.parse(arrayOf("com.example.app", "--country", "TR"))
assertThat(result).isEqualTo(
CliParseResult.Valid(
CliOptions(
packageName = "com.example.app",
outDir = Path("downloads"),
geoProfile = GeoProfile(
country = "TR",
localeTag = "tr-TR",
timeZone = "Europe/Istanbul",
operator = "28601"
)
)
)
)
}
@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"))
}
}

View File

@@ -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"
)
)
}
}

View File

@@ -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) }
}
}

View File

@@ -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()
}
}

View File

@@ -14,6 +14,7 @@ buildscript {
plugins { plugins {
alias(libs.plugins.android.application) apply false 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.compose) apply false
alias(libs.plugins.jetbrains.kotlin.parcelize) apply false alias(libs.plugins.jetbrains.kotlin.parcelize) apply false
alias(libs.plugins.jetbrains.kotlin.serialization) apply false alias(libs.plugins.jetbrains.kotlin.serialization) apply false

View File

@@ -22,6 +22,7 @@ gplayapi = "3.6.3"
hiddenapibypass = "6.1" hiddenapibypass = "6.1"
hilt = "2.59.2" hilt = "2.59.2"
junit = "4.13.2" junit = "4.13.2"
coroutines = "1.10.2"
kotlin = "2.3.21" kotlin = "2.3.21"
ksp = "2.3.7" ksp = "2.3.7"
ktlint = "14.2.0" 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" } hilt-androidx-work = { module = "androidx.hilt:hilt-work", version.ref = "androidx-hilt" }
huawei-hms-coreservice = { module = "com.huawei.hms:ag-coreservice", version.ref = "agCoreservice" } huawei-hms-coreservice = { module = "com.huawei.hms:ag-coreservice", version.ref = "agCoreservice" }
junit = { module = "junit:junit", version.ref = "junit" } 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" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
lsposed-hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenapibypass" } lsposed-hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenapibypass" }
process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "processPhoenix" } 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" } google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt-android-plugin = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } 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-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-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }

View File

@@ -32,4 +32,5 @@ dependencyResolutionManagement {
} }
} }
include(":app") include(":app")
include(":apk-downloader")
rootProject.name = "AuroraStore4" rootProject.name = "AuroraStore4"