Compare commits

...
28 changed files with 514 additions and 38 deletions

No files matched your search

+8 -1
View File
@@ -450,7 +450,7 @@ jobs:
~/.cargo/git/db
~/.cargo/bin/cargo-ndk
app/src/main/rust/stores/target
key: stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-${{ hashFiles('app/src/main/rust/stores/src/**', 'app/src/main/rust/stores/Cargo.toml', 'app/src/main/rust/stores/build.rs', 'app/src/main/rust/stores/.cargo/**', 'app/build.gradle') }}
key: stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-${{ hashFiles('app/src/main/rust/stores/amazon/**', 'tools/stores/**', 'app/src/main/rust/stores/src/**', 'app/src/main/rust/stores/Cargo.toml', 'app/src/main/rust/stores/build.rs', 'app/src/main/rust/stores/.cargo/**', 'app/build.gradle') }}
restore-keys: |
stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-
stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-
@@ -523,6 +523,13 @@ jobs:
cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60
- name: Amazon engine host tests
timeout-minutes: 10
run: |
set -euo pipefail
export CARGO_TARGET_DIR="$PWD/app/src/main/rust/stores/target"
cargo test --manifest-path app/src/main/rust/stores/amazon/Cargo.toml --locked
- name: Upload the regenerated Cargo.lock
if: failure()
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty'
}
commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA")
for name in proot directaudio tracking xbox comet; do
for name in proot directaudio tracking xbox comet amazon; do
file="packs/$name.tar.zst"
[ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; }
sha=$(sha256sum "$file" | cut -d' ' -f1)
+1
View File
@@ -15,6 +15,7 @@ app/src/main/jniLibs/arm64-v8a/.proot-outputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
app/src/main/rust/stores/target/
app/src/main/rust/amazon/target/
app/src/main/assets/droiddeck-esync/
.kotlin/
+3 -2
View File
@@ -87,7 +87,7 @@ def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name ->
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet', 'amazon'].collectEntries { name ->
def pack = file("build/packs/${name}.tar.zst")
if (!pack.isFile()) {
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
@@ -296,6 +296,7 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
inputs.dir(rustCrateDir.dir('src'))
inputs.files(rustCrateDir.file('Cargo.toml'), rustCrateDir.file('Cargo.lock'))
inputs.property('ndkVersion', android.ndkVersion)
inputs.property('storeFeatures', 'gog,epic')
if (!skipRust) inputs.property('rustToolchain', providers.exec {
environment 'PATH', "${System.getProperty('user.home')}/.cargo/bin${File.pathSeparator}${System.getenv('PATH')}"
commandLine 'rustup', 'run', 'stable', 'rustc', '-Vv'
@@ -311,7 +312,7 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
}.standardOutput.asText.get().trim()
commandLine 'rustup', 'run', 'stable', 'cargo', 'ndk', '--target', 'arm64-v8a', '--platform', '26',
'--output-dir', rustJniLibsDir.get().asFile.absolutePath,
'build', '--release', '--locked'
'build', '--release', '--locked', '--no-default-features', '--features', 'gog,epic'
}
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
@@ -146,5 +146,7 @@ internal class AddonPack(
listOf("version", "xodus-cli", "xodus-service"))
val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE,
listOf("version", "comet"))
val AMAZON = AddonPack("amazon", BuildConfig.PACK_AMAZON_SHA256, BuildConfig.PACK_AMAZON_SIZE,
listOf("engine.json", "libdroiddeckamazon.so"), reuseDeviceCopy = false)
}
}
@@ -130,6 +130,7 @@ class StoreLoginActivity : ComponentActivity() {
private fun newFlow(state: String): Flow? {
val url = clientUrl
if (url != null) return com.droiddeck.launcher.stores.epic.EpicClient.loginFlow(url, chromeUa)
if (store == Store.AMAZON && !com.droiddeck.launcher.stores.amazon.AmazonNative.loadInstalled(applicationContext)) return null
return store?.let { LoginFlows.forStore(it, state) }
}
@@ -2,6 +2,8 @@ package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.content.Intent
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.LoginFlows
@@ -16,25 +18,53 @@ import org.json.JSONObject
object AmazonBackend : StoreBackend {
override val store: Store = Store.AMAZON
private val signingIn = java.util.concurrent.atomic.AtomicBoolean(false)
init {
LoginFlows.amazonFlow = { AmazonLogin.create() }
}
private fun engineMissing(): Boolean {
if (AmazonNative.ready()) return false
StoresState.logLine(AmazonNative.ENGINE_MISSING)
StoresState.post { StoresState.problems[store] = AmazonNative.ENGINE_MISSING }
return true
}
override fun signIn(context: Context) {
if (engineMissing()) return
context.startActivity(
Intent(context, StoreLoginActivity::class.java)
.putExtra(StoreLoginActivity.EXTRA_STORE, store.id)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
if (!signingIn.compareAndSet(false, true)) return
val app = context.applicationContext
try {
Thread({
try {
val available = runCatching {
AmazonNative.prepare(app) { state ->
if (state is com.droiddeck.launcher.core.AddonPack.State.Downloading) {
StoresState.post { StoresState.status[store] = app.getString(R.string.stores_engine_downloading, store.label, (state.progress * 100).toInt()) }
}
}
}.onFailure { Log.w("AmazonBackend", "Engine preparation failed", it) }.getOrDefault(false)
StoresState.post {
try {
StoresState.status.remove(store)
if (available) {
StoresState.problems.remove(store)
app.startActivity(Intent(app, StoreLoginActivity::class.java)
.putExtra(StoreLoginActivity.EXTRA_STORE, store.id)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} else {
StoresState.problems[store] = app.getString(R.string.stores_engine_failed, store.label)
}
} finally {
signingIn.set(false)
}
}
} catch (error: Exception) {
signingIn.set(false)
Log.w("AmazonBackend", "Could not finish engine preparation", error)
}
}, "amazon-engine").start()
} catch (error: Exception) {
signingIn.set(false)
StoresState.post {
StoresState.status.remove(store)
StoresState.problems[store] = app.getString(R.string.stores_engine_failed, store.label)
}
Log.w("AmazonBackend", "Could not start engine preparation", error)
}
}
override fun signOut(context: Context) {
@@ -75,6 +75,7 @@ object AmazonCredentialStore {
private fun refreshLocked(context: Context, a: Account): String? {
if (a.refreshToken.isEmpty()) return null
if (!AmazonNative.prepare(context)) return null
val answer = AmazonNative.call("auth.refresh", JSONObject().put("refreshToken", a.refreshToken))
val access = answer?.takeIf { !it.has("error") }?.optString(ACCESS_TOKEN, "").orEmpty()
if (access.isEmpty()) {
@@ -0,0 +1,30 @@
package com.droiddeck.launcher.stores.amazon
import java.io.File
import java.security.MessageDigest
import org.json.JSONObject
internal object AmazonEngine {
fun verify(directory: File): Boolean = runCatching {
val manifest = File(directory, "engine.json")
val library = File(directory, "libdroiddeckamazon.so")
val root = directory.canonicalFile
check(manifest.canonicalFile == File(root, manifest.name) && library.canonicalFile == File(root, library.name))
check(manifest.length() in 1..131_072 && library.isFile)
val data = JSONObject(manifest.readText())
check(data.getInt("schema") == 1 && data.getInt("abi") == 1)
check(data.getString("engine") == "amazon" && data.getString("target") == "aarch64-linux-android")
val hash = MessageDigest.getInstance("SHA-256")
library.inputStream().use { input ->
val block = ByteArray(64 * 1024)
while (true) {
val count = input.read(block)
if (count < 0) break
hash.update(block, 0, count)
}
}
val actual = hash.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
check(actual == data.getString("library_sha256"))
true
}.getOrDefault(false)
}
@@ -24,6 +24,11 @@ internal class AmazonInstaller(
private val cancelled = AtomicBoolean(false)
override fun run(handle: DownloadQueue.JobHandle): String? {
handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_engine_preparing, Store.AMAZON.label))
if (!AmazonNative.prepare(app, { !cancelled.get() })) {
if (cancelled.get()) return null
throw IllegalStateException(AmazonNative.ENGINE_MISSING)
}
handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in))
val token = AmazonCredentialStore.getValidAccessToken(app) ?: throw IllegalStateException("Not signed in to Amazon Games")
val entitlementId = item.extra["entitlementId"].orEmpty()
@@ -163,7 +163,6 @@ object AmazonLibrary {
}
try {
if (cache.isNotEmpty()) publish(app, cache)
if (!AmazonNative.ready()) return problem(AmazonNative.ENGINE_MISSING)
val syncedAt = prefs.getLong(KEY_SYNCED_AT, 0L)
if (!force && cache.isNotEmpty() && System.currentTimeMillis() - syncedAt in 0 until THROTTLE_MS) {
backfillPosters(app, cache)
@@ -173,6 +172,7 @@ object AmazonLibrary {
status(R.string.stores_sync_sign_in)
val account = AmazonCredentialStore.load(app) ?: return problem(StoresState.notSignedInLine(store))
if (!AmazonNative.prepare(app)) return problem(AmazonNative.ENGINE_MISSING)
val token = AmazonCredentialStore.getValidAccessToken(app) ?: return problem(StoresState.notSignedInLine(store))
status(R.string.stores_sync_game_list)
var answer = entitlements(token, account.deviceSerial)
@@ -1,8 +1,9 @@
package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.stores.StoresNative
import com.droiddeck.launcher.core.AddonPack
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@@ -16,16 +17,32 @@ object AmazonNative {
@Volatile private var configured = false
fun ready(): Boolean {
if (!StoresNative.ensureLoaded()) return false
if (!configured) synchronized(this) {
if (!configured) {
runCatching { nativeConfigure(BuildConfig.VERSION_NAME) }
.onFailure { Log.w(TAG, "configure: ${it.javaClass.simpleName}") }
configured = true
@Volatile private var loaded = false
fun ready(): Boolean = loaded && configured
internal fun prepare(context: Context, running: () -> Boolean = { true },
onState: (AddonPack.State) -> Unit = {}): Boolean {
if (ready()) return true
if (!AddonPack.AMAZON.await(context, running, onState)) return false
return loadInstalled(context)
}
@Synchronized
internal fun loadInstalled(context: Context): Boolean {
if (ready()) return true
if (!AddonPack.AMAZON.installed(context)) return false
return runCatching {
check(AmazonEngine.verify(AddonPack.AMAZON.dir(context))) { "Amazon engine verification failed" }
if (!loaded) {
System.load(AddonPack.AMAZON.file(context, "libdroiddeckamazon.so").absolutePath)
check(nativeEngineAbi() == 1) { "Incompatible Amazon engine" }
loaded = true
}
}
return true
nativeConfigure(BuildConfig.VERSION_NAME)
configured = true
true
}.onFailure { Log.w(TAG, "Amazon engine could not load", it) }.getOrDefault(false)
}
fun call(op: String, request: JSONObject = JSONObject()): JSONObject? {
@@ -109,6 +126,7 @@ object AmazonNative {
return holder[0] ?: InstallOutcome(false, false, "The Amazon install ended without a result", null)
}
@JvmStatic private external fun nativeEngineAbi(): Int
@JvmStatic private external fun nativeConfigure(appVersion: String)
@JvmStatic private external fun nativeCall(op: String, request: String): String?
@JvmStatic private external fun nativeFetch(op: String, request: String): ByteArray?
+3
View File
@@ -3064,4 +3064,7 @@
<string name="spatial_saved_views_hint">Saved views can be recalculated. Start a new calibration to capture in the current tracking frame.</string>
<string name="spatial_model_checksum_error">Hand model checksum mismatch</string>
<string name="spatial_opencv_load_error">OpenCV could not load</string>
<string name="stores_engine_downloading">Downloading %1$s support… %2$d%%</string>
<string name="stores_engine_preparing">Preparing %1$s support…</string>
<string name="stores_engine_failed">%1$s support could not be downloaded or loaded. Try again.</string>
</resources>
+16
View File
@@ -182,6 +182,22 @@ dependencies = [
"syn",
]
[[package]]
name = "droiddeckamazon"
version = "0.1.0"
dependencies = [
"flate2",
"futures-util",
"jni",
"libc",
"lzma-rs",
"reqwest",
"serde_json",
"sha1",
"sha2",
"tokio",
]
[[package]]
name = "droiddeckstores"
version = "0.1.0"
+12
View File
@@ -9,6 +9,13 @@ publish = false
name = "droiddeckstores"
crate-type = ["cdylib"]
[features]
default = ["gog", "epic", "amazon"]
gog = []
epic = []
amazon = []
amazon-pack = ["amazon"]
[dependencies]
jni = "0.21"
libc = "0.2"
@@ -25,3 +32,8 @@ sha2 = "0.10"
codegen-units = 1
lto = "thin"
strip = "symbols"
[workspace]
members = ["amazon"]
default-members = ["."]
resolver = "2"
@@ -0,0 +1,30 @@
[package]
name = "droiddeckamazon"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-or-later"
publish = false
[lib]
name = "droiddeckamazon"
crate-type = ["cdylib"]
path = "../src/lib.rs"
[features]
default = ["amazon-pack"]
gog = []
epic = []
amazon = []
amazon-pack = ["amazon"]
[dependencies]
jni = "0.21"
libc = "0.2"
lzma-rs = { version = "0.3", default-features = false }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] }
tokio = { version = "1", default-features = false, features = ["rt", "net", "time", "sync"] }
futures-util = { version = "0.3", default-features = false, features = ["std"] }
serde_json = { version = "1", features = ["preserve_order"] }
sha1 = "0.10"
sha2 = "0.10"
@@ -24,6 +24,14 @@ fn guarded<T>(fallback: T, f: impl FnOnce() -> T) -> T {
catch_unwind(AssertUnwindSafe(f)).unwrap_or(fallback)
}
#[no_mangle]
pub extern "system" fn Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeEngineAbi(
_env: JNIEnv,
_class: JClass,
) -> jni::sys::jint {
1
}
#[no_mangle]
pub extern "system" fn Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeConfigure(
mut env: JNIEnv,
+3
View File
@@ -2,10 +2,13 @@
#![allow(clippy::missing_safety_doc, clippy::result_large_err)]
#[cfg(feature = "amazon")]
pub mod amazon;
#[cfg(feature = "epic")]
pub mod epic;
pub mod fetch_core;
pub mod fetch_tuning;
#[cfg(feature = "gog")]
pub mod gog;
pub mod md5_small;
pub mod priority;
@@ -6,6 +6,7 @@ use jni::JNIEnv;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[no_mangle]
#[cfg(not(feature = "amazon-pack"))]
pub extern "system" fn Java_com_droiddeck_launcher_stores_StoresNative_nativeVersion(
env: JNIEnv,
_class: JClass,
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.stores.amazon
import java.io.File
import java.security.MessageDigest
import java.nio.file.Files
import org.json.JSONObject
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Assume.assumeFalse
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AmazonEngineTest {
@get:Rule val temp = TemporaryFolder()
private fun prepared(): File = temp.newFolder().apply {
val library = File(this, "libdroiddeckamazon.so").apply { writeBytes(byteArrayOf(1, 2, 3)) }
val hash = MessageDigest.getInstance("SHA-256").digest(library.readBytes())
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
File(this, "engine.json").writeText(JSONObject().put("schema", 1).put("abi", 1)
.put("engine", "amazon").put("target", "aarch64-linux-android")
.put("library_sha256", hash).toString())
}
@Test fun installedEngineWorksRepeatedlyWithoutNetwork() {
val directory = prepared()
assertTrue(AmazonEngine.verify(directory))
assertTrue(AmazonEngine.verify(directory))
}
@Test fun changedLibraryIsRejected() {
val directory = prepared()
File(directory, "libdroiddeckamazon.so").appendText("tampered")
assertFalse(AmazonEngine.verify(directory))
}
@Test fun incompatibleInterfaceIsRejected() {
val directory = prepared()
val manifest = File(directory, "engine.json")
manifest.writeText(JSONObject(manifest.readText()).put("abi", 2).toString())
assertFalse(AmazonEngine.verify(directory))
}
@Test fun incompleteAndMalformedPacksAreRejected() {
val directory = prepared()
File(directory, "libdroiddeckamazon.so").delete()
assertFalse(AmazonEngine.verify(directory))
val malformed = prepared()
File(malformed, "engine.json").writeText("not json")
assertFalse(AmazonEngine.verify(malformed))
}
@Test fun parentStorageAliasIsAllowedButLibrarySymlinkIsRejected() {
assumeFalse(System.getProperty("os.name").startsWith("Windows"))
val directory = prepared()
val alias = File(temp.root, "alias")
Files.createSymbolicLink(alias.toPath(), directory.toPath())
assertTrue(AmazonEngine.verify(alias))
val library = File(directory, "libdroiddeckamazon.so")
val outside = temp.newFile("outside.so").apply { writeBytes(library.readBytes()) }
library.delete()
Files.createSymbolicLink(library.toPath(), outside.toPath())
assertFalse(AmazonEngine.verify(directory))
}
}
+8
View File
@@ -83,3 +83,11 @@ pack tracking "$work/tracking" hand_landmarker.task hand_landmarker_lite.task po
libopencv_java4.so libmediapipe_tasks_jni.so
pack xbox "$linuxfs/droiddeck-xbox" version xodus-cli xodus-service
pack comet "$linuxfs/droiddeck-comet" version comet
if [[ "${DROIDDECK_SKIP_RUST:-0}" == 1 && "${CI:-false}" != true ]]; then
rm -f "$out/amazon.tar.zst"
else
bash "$repo/tools/stores/build-amazon.sh"
mkdir "$work/amazon"
cp "$repo/app/build/amazon-engine/engine.json" "$repo/app/build/amazon-engine/libdroiddeckamazon.so" "$work/amazon/"
pack amazon "$work/amazon" engine.json libdroiddeckamazon.so
fi
+48 -3
View File
@@ -1,9 +1,13 @@
"""Validate the JNI entry points and Android ELF dependency closure in the actual APK."""
import argparse
import hashlib
import io
import json
from pathlib import Path
import re
import subprocess
import tempfile
import tarfile
from zipfile import ZipFile
@@ -13,9 +17,43 @@ SYSTEM = set('libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan
EXPORTS = ['Java_com_droiddeck_launcher_stores_StoresNative_nativeVersion',
'Java_com_droiddeck_launcher_stores_gog_GogNative_nativeStart',
'Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeCall',
'Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeInstall',
'Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeCall',
'Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeInstall']
'Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeInstall']
AMAZON_EXPORTS = ['Java_com_droiddeck_launcher_stores_amazon_AmazonNative_native' + name
for name in ['EngineAbi', 'Configure', 'Call', 'Fetch', 'Inspect', 'Install', 'Cancel', 'Release']]
def check_amazon_pack(path, readelf):
errors = []
raw = subprocess.check_output(['zstd', '-dc', str(path)])
with tarfile.open(fileobj=io.BytesIO(raw)) as archive:
if sorted(archive.getnames()) != ['engine.json', 'libdroiddeckamazon.so']:
return ['Amazon pack must contain exactly its manifest and native engine']
for member in archive:
if not member.isfile():
return ['Amazon pack contains a non-regular file']
library = archive.extractfile('libdroiddeckamazon.so').read()
metadata = json.load(archive.extractfile('engine.json'))
if (metadata.get('schema'), metadata.get('engine'), metadata.get('abi'), metadata.get('target')) != (
1, 'amazon', 1, 'aarch64-linux-android'):
errors.append('Amazon engine metadata is incompatible')
if metadata.get('library_sha256') != hashlib.sha256(library).hexdigest():
errors.append('Amazon engine digest differs from its manifest')
with tempfile.TemporaryDirectory() as temp:
binary = Path(temp) / 'libdroiddeckamazon.so'
binary.write_bytes(library)
header = subprocess.check_output([readelf, '-h', str(binary)], text=True)
if 'AArch64' not in header or 'ELF64' not in header:
errors.append('Amazon engine is not Android ARM64')
symbols = subprocess.check_output([readelf, '--dyn-syms', '--wide', str(binary)], text=True)
exports = {line.split()[-1] for line in symbols.splitlines()
if ' GLOBAL ' in line and ' UND ' not in line and line.split()}
errors += ['Amazon library missing ' + symbol for symbol in AMAZON_EXPORTS if symbol not in exports]
if any(symbol in exports for symbol in EXPORTS):
errors.append('Amazon engine still exports other store entry points')
dynamic = subprocess.check_output([readelf, '-d', str(binary)], text=True)
errors += ['Amazon engine needs missing ' + name for name in re.findall(r'NEEDED.*\[(.*?)\]', dynamic)
if name not in SYSTEM]
return errors
def check(apk, readelf, stores=True):
@@ -41,8 +79,12 @@ def check(apk, readelf, stores=True):
exports = {line.split()[-1] for line in symbols.splitlines()
if ' GLOBAL ' in line and ' UND ' not in line and line.split()}
errors += ['Store library missing ' + symbol for symbol in EXPORTS if symbol not in exports]
if any(symbol in exports for symbol in AMAZON_EXPORTS):
errors.append('Bundled store library still contains Amazon entry points')
elif library is not None:
errors.append('Rust was skipped but the APK contains a cached store library')
if 'libdroiddeckamazon.so' in libraries:
errors.append('APK still bundles the optional Amazon engine')
return errors
@@ -51,8 +93,11 @@ if __name__ == '__main__':
parser.add_argument('apk')
parser.add_argument('--readelf', default='readelf')
parser.add_argument('--without-stores', action='store_true')
parser.add_argument('--amazon-pack', default='app/build/packs/amazon.tar.zst')
args = parser.parse_args()
errors = check(args.apk, args.readelf, not args.without_stores)
if not args.without_stores:
errors += check_amazon_pack(args.amazon_pack, args.readelf)
if errors:
parser.exit(1, '\n'.join(errors) + '\n')
print('APK native exports and dependencies verified')
+17 -4
View File
@@ -2,6 +2,7 @@
import hashlib
import gzip
import json
import os
from pathlib import Path
import subprocess
import sys
@@ -17,10 +18,14 @@ PACKS = Path('app/build/packs')
PACKED = {'assets/linuxfs/usr/local/lib/droiddeck-xbox/': 'xbox', 'assets/linuxfs/usr/local/lib/droiddeck-comet/': 'comet',
'assets/directaudio/': 'directaudio'}
PACKED_FILES = {'lib/arm64-v8a/libproot.so': ('proot', 'libproot.so'),
'lib/arm64-v8a/libproot-loader.so': ('proot', 'libproot-loader.so')}
'lib/arm64-v8a/libproot-loader.so': ('proot', 'libproot-loader.so'),
'lib/arm64-v8a/libdroiddeckamazon.so': ('amazon', 'libdroiddeckamazon.so'),
'assets/store-engines/amazon/engine.json': ('amazon', 'engine.json')}
PACKED_LIBRARIES = ['lib/arm64-v8a/libopencv_java4.so', 'lib/arm64-v8a/libmediapipe_tasks_jni.so']
PACK_NAMES = ['proot', 'directaudio', 'tracking', 'xbox', 'comet']
PACK_NAMES = ['proot', 'directaudio', 'tracking', 'xbox', 'comet', 'amazon']
SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/assets/pulseaudio.tzst',
'app/src/main/rust/stores/src', 'app/src/main/rust/stores/Cargo.toml',
'app/src/main/rust/stores/amazon/Cargo.toml', 'app/src/main/rust/stores/Cargo.lock', 'tools/stores',
'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio', 'tools/packs',
'tools/gamescope/release.env', 'tools/droiddeck-esync/release.env',
'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/components', 'tools/ci', 'tools/build_local.sh',
@@ -61,6 +66,9 @@ def outputs(root):
result['assets/' + tree + '/' + path.relative_to(base).as_posix()] = path.relative_to(root).as_posix()
for library in ['libproot.so', 'libproot-loader.so', 'libdirectaudiorelay.so']:
result['lib/arm64-v8a/' + library] = 'app/src/main/jniLibs/arm64-v8a/' + library
if amazon_enabled():
result['lib/arm64-v8a/libdroiddeckamazon.so'] = 'app/build/amazon-engine/libdroiddeckamazon.so'
result['assets/store-engines/amazon/engine.json'] = 'app/build/amazon-engine/engine.json'
result['assets/pulseaudio.tzst'] = 'app/build/prepared-assets/pulseaudio.tzst'
result['assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst'] = \
'app/build/prepared-assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst'
@@ -70,8 +78,13 @@ def outputs(root):
return result
def amazon_enabled():
return os.environ.get('CI') == 'true' or os.environ.get('DROIDDECK_SKIP_RUST') != '1'
def required():
paths = ['assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst']
paths = ['lib/arm64-v8a/libdroiddeckamazon.so', 'assets/store-engines/amazon/engine.json'] if amazon_enabled() else []
paths += ['assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst']
paths += [f'assets/graphics_driver/adrenotools-{name}.tzst'
for name in ['turnip26.3.0-a6xx', 'turnip26.3.0-quest2']]
paths += ['assets/linuxfs/' + name for name in ['libfakeinput.so', 'libblsession.so',
@@ -187,7 +200,7 @@ def pack_digests(root, name):
def check_apk(apk, root=ROOT):
data = verify(root)
packs = {name: pack_digests(root, name) for name in PACK_NAMES}
packs = {name: pack_digests(root, name) for name in PACK_NAMES if name != 'amazon' or amazon_enabled()}
with ZipFile(apk) as package:
names = set(package.namelist())
for library in PACKED_LIBRARIES:
+37
View File
@@ -0,0 +1,37 @@
# Amazon engine pilot
The APK keeps GOG and Epic in `libdroiddeckstores.so`. Amazon is compiled separately
as `libdroiddeckamazon.so`, using the same reviewed Rust sources and shared download
implementation. Cargo features determine the exported stores; the source is not copied.
Both crates share one Cargo workspace and the existing lockfile/target cache.
The separate library duplicates some linked Rust dependencies. Measure combined
download size before extending this model to other stores.
`tools/stores/build-amazon.sh` builds Android ARM64/API 26 with the selected NDK and
locked Cargo dependencies. `engine.json` records interface version 1, library digest,
source/recipe input digests, Rust compiler and NDK metadata. `tools/packs/build.sh`
includes exactly that manifest and library in the Amazon pack. APK validation checks
the engine's ARM64 ELF header, JNI exports, dependency closure and digest, and rejects
Amazon exports left in the bundled store library. Runtime input checks reject stale
pack bytes. CI runs the standalone engine's host tests as a required step.
The signed APK pins the complete pack SHA-256. Existing main-only publication produces
an immutable content-addressed release; PR publication attaches the exact same-run pack
to the PR release. This metadata documents the build; it is not an independent signed
attestation or a reproducibility claim. Cargo's existing incremental cache is used;
this pilot does not introduce independent prebuilt engine reuse or faster CI guarantees.
Amazon sign-in downloads support on a worker before opening the browser. Signed-in
library sync and installation prepare it as needed. Other stores and ordinary startup
do not request the pack. Existing library cache, credentials, game paths, sidecars and
installed games are retained. Once installed, the pinned pack is usable offline.
New APKs reuse an existing pack when its digest is unchanged; changed pins download
their exact version rather than loading an arbitrary older engine. Loading verifies
the manifest/library and JNI interface before configuring the engine. Unpinned local
builds intentionally cannot borrow an arbitrary prior Amazon pack.
The existing local `DROIDDECK_SKIP_RUST=1` mode omits Amazon too. CI always requires
the engine and pack even when that local flag is present.
Device validation still needs a fresh Amazon sign-in, existing-account upgrade, failed
download/retry, offline relaunch, and cancel/resume installation. Robolectric integrity
tests and ELF checks do not substitute for Android native loading and live account tests.
+33
View File
@@ -0,0 +1,33 @@
"""Record the Amazon engine's build inputs in its APK-pinned add-on pack."""
import hashlib
import json
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
def inputs(root=ROOT):
paths = ['app/src/main/rust/stores/amazon/Cargo.toml', 'app/src/main/rust/stores/Cargo.lock', 'app/src/main/rust/stores/Cargo.toml',
'app/src/main/rust/stores/src/lib.rs', 'app/src/main/rust/stores/src/stores_jni.rs',
'app/src/main/rust/stores/src/amazon', 'app/src/main/rust/stores/src/fetch_core.rs',
'app/src/main/rust/stores/src/fetch_tuning.rs', 'app/src/main/rust/stores/src/priority.rs',
'app/src/main/rust/stores/src/md5_small.rs',
'tools/stores/build-amazon.sh', 'tools/stores/amazon-manifest.py']
files = []
for name in paths:
path = root / name
files.extend(path.rglob('*') if path.is_dir() else [path])
return {p.relative_to(root).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(set(files)) if p.is_file() and '__pycache__' not in p.parts}
if __name__ == '__main__':
out, ndk = map(Path, sys.argv[1:])
data = {'schema': 1, 'engine': 'amazon', 'abi': 1, 'target': 'aarch64-linux-android',
'platform': 26, 'inputs': inputs(),
'rustc': subprocess.check_output(['rustup', 'run', 'stable', 'rustc', '-Vv'], text=True),
'ndk': (ndk / 'source.properties').read_text(),
'library_sha256': hashlib.sha256((out / 'libdroiddeckamazon.so').read_bytes()).hexdigest()}
(out / 'engine.json').write_text(json.dumps(data, sort_keys=True, indent=2) + '\n')
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
repo=$(cd "$(dirname "$0")/../.." && pwd)
out="$repo/app/build/amazon-engine"
export ANDROID_NDK_HOME=${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-${NDK:-${ANDROID_NDK_LATEST_HOME:-}}}}
[[ -d "$ANDROID_NDK_HOME" ]] || { echo 'Android NDK required for Amazon engine' >&2; exit 1; }
export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"$repo/app/src/main/rust/stores/target"}
export RUSTC=$(rustup which --toolchain stable rustc)
mkdir -p "$out"
cd "$repo/app/src/main/rust/stores/amazon"
rustup run stable cargo ndk --target arm64-v8a --platform 26 --output-dir "$repo/app/build/amazon-native" build --release --locked
cp "$repo/app/build/amazon-native/arm64-v8a/libdroiddeckamazon.so" "$out/libdroiddeckamazon.so"
python3 "$repo/tools/stores/amazon-manifest.py" "$out" "$ANDROID_NDK_HOME"
+77
View File
@@ -0,0 +1,77 @@
import hashlib
import io
import json
from pathlib import Path
import runpy
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'tools/release'))
import check_android_libraries as native
class AmazonEngineTest(unittest.TestCase):
def pack(self, folder, abi=1, library=b'engine', checksum=None, extra=False):
manifest = {'schema': 1, 'engine': 'amazon', 'abi': abi, 'target': 'aarch64-linux-android',
'library_sha256': checksum or hashlib.sha256(library).hexdigest()}
stream = io.BytesIO()
files = {'engine.json': json.dumps(manifest).encode(), 'libdroiddeckamazon.so': library}
if extra:
files['unexpected'] = b'x'
with tarfile.open(fileobj=stream, mode='w') as archive:
for name, content in files.items():
member = tarfile.TarInfo(name)
member.size = len(content)
archive.addfile(member, io.BytesIO(content))
path = Path(folder) / 'amazon.tar.zst'
path.write_bytes(stream.getvalue())
return path
def validate(self, path, missing=False, bundled=False):
def command(args, **kwargs):
if args[0] == 'zstd':
return path.read_bytes()
if '-h' in args:
return 'ELF64 AArch64'
if '-d' in args:
return 'NEEDED [libc.so]'
symbols = native.AMAZON_EXPORTS[:-1] if missing else native.AMAZON_EXPORTS
if bundled:
symbols = symbols + native.EXPORTS
return '\n'.join(' GLOBAL DEFAULT 1 ' + symbol for symbol in symbols)
with patch.object(native.subprocess, 'check_output', side_effect=command):
return native.check_amazon_pack(path, 'readelf')
def test_pack_checks_interface_digest_exports_and_isolation(self):
with tempfile.TemporaryDirectory() as directory:
self.assertEqual(self.validate(self.pack(directory)), [])
self.assertTrue(self.validate(self.pack(directory, abi=2)))
self.assertTrue(self.validate(self.pack(directory, checksum='0' * 64)))
self.assertTrue(self.validate(self.pack(directory), missing=True))
self.assertTrue(self.validate(self.pack(directory), bundled=True))
self.assertTrue(self.validate(self.pack(directory, extra=True)))
def test_manifest_tracks_amazon_and_shared_inputs(self):
inputs = runpy.run_path(str(ROOT / 'tools/stores/amazon-manifest.py'))['inputs']
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
amazon = root / 'app/src/main/rust/stores/src/amazon/example.rs'
amazon.parent.mkdir(parents=True)
amazon.write_text('amazon')
common = root / 'app/src/main/rust/stores/src/fetch_core.rs'
common.write_text('common')
before = inputs(root)
common.write_text('changed')
self.assertNotEqual(before, inputs(root))
self.assertIn(amazon.relative_to(root).as_posix(), before)
def test_apk_and_publication_use_optional_pack(self):
gradle = (ROOT / 'app/build.gradle').read_text(encoding='utf-8')
self.assertIn("'--no-default-features', '--features', 'gog,epic'", gradle)
self.assertIn('comet amazon; do', (ROOT / '.github/workflows/publish-packs.yml').read_text())
self.assertIn('pack amazon', (ROOT / 'tools/packs/build.sh').read_text())
+15 -3
View File
@@ -1,6 +1,7 @@
import importlib.util
import gzip
import io
import os
from pathlib import Path, PureWindowsPath
import subprocess
import tarfile
@@ -44,10 +45,13 @@ class RuntimeInputsTest(unittest.TestCase):
def packs(self):
self.pack('tracking', {library.rsplit('/', 1)[1]: b'library' for library in runtime.PACKED_LIBRARIES})
proot = {}
packed = {}
outputs = runtime.outputs(self.root)
for name, (pack, rel) in runtime.PACKED_FILES.items():
proot[rel] = (self.root / runtime.outputs(self.root)[name]).read_bytes()
self.pack('proot', proot)
if name in outputs:
packed.setdefault(pack, {})[rel] = (self.root / outputs[name]).read_bytes()
for name, files in packed.items():
self.pack(name, files)
for prefix, name in runtime.PACKED.items():
base = self.root / 'app/src/main' / prefix
self.pack(name, {path.relative_to(base).as_posix(): path.read_bytes() for path in base.rglob('*') if path.is_file()})
@@ -71,6 +75,14 @@ class RuntimeInputsTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'proot pack provides'):
runtime.check_apk(apk, self.root)
def test_local_rust_skip_cannot_disable_the_amazon_pack_in_ci(self):
with mock.patch.dict(os.environ, {'DROIDDECK_SKIP_RUST': '1', 'CI': 'false'}):
self.assertFalse(runtime.amazon_enabled())
self.assertNotIn('lib/arm64-v8a/libdroiddeckamazon.so', runtime.outputs(self.root))
with mock.patch.dict(os.environ, {'DROIDDECK_SKIP_RUST': '1', 'CI': 'true'}):
self.assertTrue(runtime.amazon_enabled())
self.assertIn('lib/arm64-v8a/libdroiddeckamazon.so', runtime.required())
def test_missing_proot_cannot_be_attested(self):
(self.root / 'app/src/main/jniLibs/arm64-v8a/libproot.so').unlink()
with self.assertRaisesRegex(ValueError, 'libproot.so'):