Add apk downloader project based on aurora's core
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.gradle
|
||||||
|
**/build
|
||||||
|
**/downloads
|
||||||
|
downloads
|
||||||
|
local.properties
|
||||||
71
README.md
71
README.md
@@ -53,6 +53,77 @@ Please only download the latest stable releases from one of these sources:
|
|||||||
|
|
||||||
You can also get latest debug builds signed with AOSP test keys for testing latest changes from our [GitLab Package Registry](https://gitlab.com/AuroraOSS/AuroraStore/-/packages/24103616).
|
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:/work/downloads" \
|
||||||
|
-v "$HOME/.config/aurora-apk-downloader:/root/.config/aurora-apk-downloader" \
|
||||||
|
aurora-apk-downloader \
|
||||||
|
com.nestle.tr.nescafe.nescafe3in1loyaltyapp --country TR --out /work/downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
If your Proton VPN runs in a separate VPN container, run the downloader with that container's network namespace, for example `--network container:<vpn-container-name>`.
|
||||||
|
|
||||||
|
To run Proton VPN inside Docker instead of on the host, use the bundled Compose file. Copy the example environment file, fill in your Proton WireGuard values, then run the one-shot downloader service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp apk-downloader/proton.env.example apk-downloader/proton.env
|
||||||
|
# edit apk-downloader/proton.env
|
||||||
|
docker compose \
|
||||||
|
--env-file apk-downloader/proton.env \
|
||||||
|
-f apk-downloader/docker-compose.proton.yml \
|
||||||
|
up --build --abort-on-container-exit --exit-code-from downloader downloader
|
||||||
|
```
|
||||||
|
|
||||||
|
For Proton WireGuard, set `WIREGUARD_PRIVATE_KEY` and `WIREGUARD_ADDRESSES` from a Proton VPN WireGuard config. For OpenVPN, set `VPN_TYPE=openvpn` and use Proton's OpenVPN/IKEv2 username and password in `OPENVPN_USER` and `OPENVPN_PASSWORD`.
|
||||||
|
|
||||||
## Certificate Fingerprints
|
## 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
|
||||||
|
|||||||
25
apk-downloader/.env
Normal file
25
apk-downloader/.env
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
# Package and Play metadata profile.
|
||||||
|
APK_PACKAGE=com.nestle.tr.nescafe.nescafe3in1loyaltyapp
|
||||||
|
APK_COUNTRY=TR
|
||||||
|
SERVER_COUNTRIES=Turkey
|
||||||
|
TZ=Europe/Istanbul
|
||||||
|
|
||||||
|
# Local output/cache paths. DOWNLOADS_DIR is relative to apk-downloader/ when using
|
||||||
|
# `docker compose -f apk-downloader/docker-compose.proton.yml`.
|
||||||
|
DOWNLOADS_DIR=../downloads
|
||||||
|
AUTH_CACHE_DIR=../.apk-downloader-auth
|
||||||
|
|
||||||
|
# Recommended: Proton WireGuard.
|
||||||
|
# Get these values from a Proton VPN WireGuard config.
|
||||||
|
VPN_TYPE=wireguard
|
||||||
|
WIREGUARD_PRIVATE_KEY=
|
||||||
|
WIREGUARD_ADDRESSES=
|
||||||
|
|
||||||
|
# Alternative: Proton OpenVPN.
|
||||||
|
# Use Proton's OpenVPN/IKEv2 credentials, not your normal Proton account password.
|
||||||
|
# VPN_TYPE=openvpn
|
||||||
|
# OPENVPN_USER=
|
||||||
|
# OPENVPN_PASSWORD=
|
||||||
19
apk-downloader/Dockerfile
Normal file
19
apk-downloader/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
FROM eclipse-temurin:17-jdk AS build
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN ./gradlew --no-daemon :apk-downloader:installDist
|
||||||
|
|
||||||
|
FROM eclipse-temurin:17-jre
|
||||||
|
WORKDIR /work
|
||||||
|
|
||||||
|
COPY --from=build /workspace/apk-downloader/build/install/apk-downloader /opt/apk-downloader
|
||||||
|
|
||||||
|
ENTRYPOINT ["/opt/apk-downloader/bin/apk-downloader"]
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apk-downloader/docker-compose.proton.yml
Normal file
44
apk-downloader/docker-compose.proton.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
services:
|
||||||
|
protonvpn:
|
||||||
|
image: qmcgaw/gluetun:latest
|
||||||
|
container_name: aurora-apk-downloader-protonvpn
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
devices:
|
||||||
|
- /dev/net/tun:/dev/net/tun
|
||||||
|
environment:
|
||||||
|
VPN_SERVICE_PROVIDER: protonvpn
|
||||||
|
VPN_TYPE: ${VPN_TYPE:-wireguard}
|
||||||
|
SERVER_COUNTRIES: ${SERVER_COUNTRIES:-Turkey}
|
||||||
|
WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
|
||||||
|
WIREGUARD_ADDRESSES: ${WIREGUARD_ADDRESSES:-}
|
||||||
|
OPENVPN_USER: ${OPENVPN_USER:-}
|
||||||
|
OPENVPN_PASSWORD: ${OPENVPN_PASSWORD:-}
|
||||||
|
TZ: ${TZ:-Europe/Istanbul}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/gluetun-entrypoint", "healthcheck"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
downloader:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apk-downloader/Dockerfile
|
||||||
|
image: aurora-apk-downloader:latest
|
||||||
|
network_mode: "service:protonvpn"
|
||||||
|
depends_on:
|
||||||
|
protonvpn:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ${DOWNLOADS_DIR:-../downloads}:/downloads
|
||||||
|
- ${AUTH_CACHE_DIR:-../.apk-downloader-auth}:/root/.config/aurora-apk-downloader
|
||||||
|
command:
|
||||||
|
- ${APK_PACKAGE:-com.nestle.tr.nescafe.nescafe3in1loyaltyapp}
|
||||||
|
- --out
|
||||||
|
- /downloads
|
||||||
|
restart: "no"
|
||||||
Binary file not shown.
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
|
||||||
|
|
||||||
|
# Package and Play metadata profile.
|
||||||
|
APK_PACKAGE=com.nestle.tr.nescafe.nescafe3in1loyaltyapp
|
||||||
|
APK_COUNTRY=TR
|
||||||
|
SERVER_COUNTRIES=Turkey
|
||||||
|
TZ=Europe/Istanbul
|
||||||
|
|
||||||
|
# Local output/cache paths. DOWNLOADS_DIR is relative to apk-downloader/ when using
|
||||||
|
# `docker compose -f apk-downloader/docker-compose.proton.yml`.
|
||||||
|
DOWNLOADS_DIR=../downloads
|
||||||
|
AUTH_CACHE_DIR=../.apk-downloader-auth
|
||||||
|
|
||||||
|
# Recommended: Proton WireGuard.
|
||||||
|
# Get these values from a Proton VPN WireGuard config.
|
||||||
|
VPN_TYPE=wireguard
|
||||||
|
WIREGUARD_PRIVATE_KEY=
|
||||||
|
WIREGUARD_ADDRESSES=
|
||||||
|
|
||||||
|
# Alternative: Proton OpenVPN.
|
||||||
|
# Use Proton's OpenVPN/IKEv2 credentials, not your normal Proton account password.
|
||||||
|
# VPN_TYPE=openvpn
|
||||||
|
# OPENVPN_USER=
|
||||||
|
# OPENVPN_PASSWORD=
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
apk-downloader/src/main/kotlin/com/aurora/apkdownloader/Cli.kt
Normal file
123
apk-downloader/src/main/kotlin/com/aurora/apkdownloader/Cli.kt
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.apkdownloader
|
||||||
|
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.io.path.Path
|
||||||
|
|
||||||
|
data class CliOptions(
|
||||||
|
val packageName: String,
|
||||||
|
val outDir: Path = Path("downloads"),
|
||||||
|
val geoProfile: GeoProfile = GeoProfile.default()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GeoProfile(
|
||||||
|
val country: String,
|
||||||
|
val localeTag: String,
|
||||||
|
val timeZone: String,
|
||||||
|
val operator: String
|
||||||
|
) {
|
||||||
|
val cacheKey: String
|
||||||
|
get() = listOf(country, localeTag, timeZone, operator)
|
||||||
|
.joinToString("-")
|
||||||
|
.lowercase()
|
||||||
|
.replace(Regex("[^a-z0-9._-]"), "_")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun default(): GeoProfile = RegionProfile.US.geoProfile
|
||||||
|
|
||||||
|
fun forCountry(country: String): GeoProfile? = RegionProfile.entries
|
||||||
|
.firstOrNull { it.name.equals(country, ignoreCase = true) }
|
||||||
|
?.geoProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class RegionProfile(val geoProfile: GeoProfile) {
|
||||||
|
US(
|
||||||
|
GeoProfile(
|
||||||
|
country = "US",
|
||||||
|
localeTag = "en-US",
|
||||||
|
timeZone = "America/New_York",
|
||||||
|
operator = "310260"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface CliParseResult {
|
||||||
|
data class Valid(val options: CliOptions) : CliParseResult
|
||||||
|
data class Invalid(val message: String) : CliParseResult
|
||||||
|
}
|
||||||
|
|
||||||
|
object CliParser {
|
||||||
|
fun parse(args: Array<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,63 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.apkdownloader
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlin.io.path.Path
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CliParserTest {
|
||||||
|
@Test
|
||||||
|
fun `valid package only uses default output directory`() {
|
||||||
|
val result = CliParser.parse(arrayOf("org.mozilla.firefox"))
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(
|
||||||
|
CliParseResult.Valid(
|
||||||
|
CliOptions(
|
||||||
|
packageName = "org.mozilla.firefox",
|
||||||
|
outDir = Path("downloads"),
|
||||||
|
geoProfile = GeoProfile.default()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid package with output directory`() {
|
||||||
|
val result = CliParser.parse(arrayOf("org.mozilla.firefox", "--out", "./downloads"))
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(
|
||||||
|
CliParseResult.Valid(
|
||||||
|
CliOptions(
|
||||||
|
packageName = "org.mozilla.firefox",
|
||||||
|
outDir = Path("./downloads"),
|
||||||
|
geoProfile = GeoProfile.default()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing package is invalid`() {
|
||||||
|
val result = CliParser.parse(emptyArray())
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(CliParseResult.Invalid("Missing package name"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown option is invalid`() {
|
||||||
|
val result = CliParser.parse(arrayOf("org.mozilla.firefox", "--version-code", "1"))
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(CliParseResult.Invalid("Unknown option: --version-code"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unsupported country is invalid`() {
|
||||||
|
val result = CliParser.parse(arrayOf("com.example.app", "--country", "ZZ"))
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(CliParseResult.Invalid("Unsupported country: ZZ"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -32,4 +32,5 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
include(":app")
|
include(":app")
|
||||||
|
include(":apk-downloader")
|
||||||
rootProject.name = "AuroraStore4"
|
rootProject.name = "AuroraStore4"
|
||||||
|
|||||||
Reference in New Issue
Block a user