Compare commits
2 Commits
3ab3943f1e
...
43214ecc8a
| Author | SHA1 | Date | |
|---|---|---|---|
| 43214ecc8a | |||
|
|
334fd2fffd |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.gradle
|
||||||
|
**/build
|
||||||
|
**/downloads
|
||||||
|
downloads
|
||||||
|
local.properties
|
||||||
98
README.md
98
README.md
@@ -53,6 +53,104 @@ Please only download the latest stable releases from one of these sources:
|
|||||||
|
|
||||||
You can also get latest debug builds signed with AOSP test keys for testing latest changes from our [GitLab Package Registry](https://gitlab.com/AuroraOSS/AuroraStore/-/packages/24103616).
|
You can also get latest debug builds signed with AOSP test keys for testing latest changes from our [GitLab Package Registry](https://gitlab.com/AuroraOSS/AuroraStore/-/packages/24103616).
|
||||||
|
|
||||||
|
## Headless APK Downloader
|
||||||
|
|
||||||
|
This repository includes a JVM CLI module for downloading the latest available APK files for a Google Play package name. Build the compiled distribution once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :apk-downloader:installDist
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the generated launcher directly, without Gradle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./apk-downloader/build/install/apk-downloader/bin/apk-downloader org.mozilla.firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, files are saved under `./downloads/<packageName>/<versionCode>/`. To choose a different output directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./apk-downloader/build/install/apk-downloader/bin/apk-downloader org.mozilla.firefox --out ./downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
When Google Play returns multiple APK files, the downloader keeps the verified raw files and also writes a split APK archive next to them:
|
||||||
|
|
||||||
|
```text
|
||||||
|
downloads/<packageName>/<versionCode>/
|
||||||
|
base.apk
|
||||||
|
config_*.apk
|
||||||
|
<packageName>_<versionCode>.apks
|
||||||
|
```
|
||||||
|
|
||||||
|
This `.apks` file is a zip archive of the downloaded base/split APKs for split-aware installers. It is not a true universal APK. Bundletool can create a universal APK from an Android App Bundle (`.aab`) with `build-apks --mode=universal`, but Google Play download delivery provides APK files here, not the original `.aab`, so bundletool cannot merge these split APKs back into one universal APK.
|
||||||
|
|
||||||
|
To create a portable archive:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :apk-downloader:distZip
|
||||||
|
```
|
||||||
|
|
||||||
|
The archive is written to `apk-downloader/build/distributions/apk-downloader.zip`. Unzip it anywhere with Java 17+ installed and run `bin/apk-downloader`.
|
||||||
|
|
||||||
|
For development, you can still run through Gradle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :apk-downloader:run --args="org.mozilla.firefox"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also build a Docker image for the downloader:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f apk-downloader/Dockerfile -t aurora-apk-downloader .
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD/downloads:/downloads" \
|
||||||
|
-v "$HOME/.config/aurora-apk-downloader:/root/.config/aurora-apk-downloader" \
|
||||||
|
aurora-apk-downloader \
|
||||||
|
com.nestle.tr.nescafe.nescafe3in1loyaltyapp --country TR --out /downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
If your Proton VPN runs in a separate VPN container, run the downloader with that container's network namespace, for example `--network container:<vpn-container-name>`.
|
||||||
|
|
||||||
|
The image also includes an exec-friendly wrapper that takes the country first, then the package name. Start a reusable container and run downloads with `docker exec`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name aurora-apk-downloader \
|
||||||
|
-v "$PWD/downloads:/downloads" \
|
||||||
|
-v "$HOME/.config/aurora-apk-downloader:/root/.config/aurora-apk-downloader" \
|
||||||
|
--entrypoint sleep \
|
||||||
|
aurora-apk-downloader infinity
|
||||||
|
|
||||||
|
docker exec aurora-apk-downloader \
|
||||||
|
download-apk TR com.nestle.tr.nescafe.nescafe3in1loyaltyapp
|
||||||
|
```
|
||||||
|
|
||||||
|
To run Proton VPN inside Docker instead of on the host, use the bundled Compose file. Copy the example environment file, fill in your Proton WireGuard values, then start the reusable downloader stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp apk-downloader/proton.env.example apk-downloader/proton.env
|
||||||
|
# edit apk-downloader/proton.env
|
||||||
|
docker compose \
|
||||||
|
--env-file apk-downloader/proton.env \
|
||||||
|
-f apk-downloader/docker-compose.proton.yml \
|
||||||
|
up -d --build
|
||||||
|
|
||||||
|
docker compose \
|
||||||
|
--env-file apk-downloader/proton.env \
|
||||||
|
-f apk-downloader/docker-compose.proton.yml \
|
||||||
|
exec downloader download-apk TR com.nestle.tr.nescafe.nescafe3in1loyaltyapp
|
||||||
|
```
|
||||||
|
|
||||||
|
If `APK_COUNTRY` and `APK_PACKAGE` are set in the env file, you can use those defaults instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose \
|
||||||
|
--env-file apk-downloader/proton.env \
|
||||||
|
-f apk-downloader/docker-compose.proton.yml \
|
||||||
|
exec downloader sh -lc 'download-apk "$APK_COUNTRY" "$APK_PACKAGE"'
|
||||||
|
```
|
||||||
|
|
||||||
|
For Proton WireGuard, set `WIREGUARD_PRIVATE_KEY` and `WIREGUARD_ADDRESSES` from a Proton VPN WireGuard config. For OpenVPN, set `VPN_TYPE=openvpn` and use Proton's OpenVPN/IKEv2 username and password in `OPENVPN_USER` and `OPENVPN_PASSWORD`.
|
||||||
|
|
||||||
## Certificate Fingerprints
|
## Certificate Fingerprints
|
||||||
|
|
||||||
- SHA1: 94:42:75:D7:59:8B:C0:3E:48:85:06:06:42:25:A7:19:90:A2:22:02
|
- SHA1: 94:42:75:D7:59:8B:C0:3E:48:85:06:06:42:25:A7:19:90:A2:22:02
|
||||||
|
|||||||
22
apk-downloader/.env
Normal file
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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