Add apk downloader project based on aurora's core
Docker all in one
This commit is contained in:
22
apk-downloader/.env
Normal file
22
apk-downloader/.env
Normal 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
26
apk-downloader/Dockerfile
Normal 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"]
|
||||
79
apk-downloader/build.gradle.kts
Normal file
79
apk-downloader/build.gradle.kts
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
48
apk-downloader/docker-compose.proton.yml
Normal file
48
apk-downloader/docker-compose.proton.yml
Normal 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
|
||||
25
apk-downloader/proton.env.example
Normal file
25
apk-downloader/proton.env.example
Normal 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=
|
||||
91
apk-downloader/src/main/java/android/os/Parcel.java
Normal file
91
apk-downloader/src/main/java/android/os/Parcel.java
Normal 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");
|
||||
}
|
||||
}
|
||||
18
apk-downloader/src/main/java/android/os/Parcelable.java
Normal file
18
apk-downloader/src/main/java/android/os/Parcelable.java
Normal 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);
|
||||
}
|
||||
}
|
||||
66
apk-downloader/src/main/java/android/util/Base64.java
Normal file
66
apk-downloader/src/main/java/android/util/Base64.java
Normal 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);
|
||||
}
|
||||
}
|
||||
43
apk-downloader/src/main/java/android/util/Log.java
Normal file
43
apk-downloader/src/main/java/android/util/Log.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
131
apk-downloader/src/main/kotlin/com/aurora/apkdownloader/Cli.kt
Normal file
131
apk-downloader/src/main/kotlin/com/aurora/apkdownloader/Cli.kt
Normal 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_]*)+")
|
||||
}
|
||||
@@ -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(",")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user