-
Notifications
You must be signed in to change notification settings - Fork 0
feat(mobile): add the App Store 4.7 self-audit, a release build path, and a UniFFI smoke test #332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1b6a69e
b05b714
ec27cbf
f05e357
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -255,10 +255,158 @@ jobs: | |
| done | ||
| exit "$fail" | ||
|
|
||
| # The RELEASE build path, and it is deliberately UNSIGNED. | ||
| # | ||
| # Signing material is the project owner's to provision and this workflow does not hold it, so | ||
| # a *signed* release build is out of reach here. An unsigned one is not: `assembleRelease` | ||
| # still runs R8, resource shrinking and the release manifest merge, which is where | ||
| # release-only breakage actually lives -- a missing keep rule strips a class the UniFFI | ||
| # bindings reach reflectively, and the debug build never notices because it does not minify. | ||
| # | ||
| # `isMinifyEnabled` is `false` in `app/build.gradle.kts` today, so this currently proves the | ||
| # release variant assembles at all. It is wired now rather than when minification is turned | ||
| # on, because the moment it is turned on this step is what catches the fallout. | ||
| - name: Assemble the release APK (unsigned) | ||
| working-directory: android | ||
| env: | ||
| RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384" | ||
| run: ./gradlew --no-daemon assembleRelease | ||
|
|
||
| # The same 16 KB gate as the debug APK's, on the release variant. Not redundant: the release | ||
| # variant has its own packaging and its own shrinking, so alignment has to be proven on the | ||
| # artifact that would actually ship, not inferred from the one that would not. | ||
| - name: Assert 16 KB page alignment inside the RELEASE APK | ||
| run: | | ||
| set -euo pipefail | ||
| apk=$(find android/app/build/outputs/apk/release -name '*.apk' | head -1) | ||
| test -n "$apk" || { echo "no release APK was produced"; exit 1; } | ||
| work=$(mktemp -d) | ||
| unzip -q "$apk" 'lib/*' -d "$work" | ||
| # The SAME three properties the debug gate above settled on, and the first version of | ||
| # this step got all three wrong by re-implementing instead of reusing: | ||
| # 1. DIVISIBILITY, not string equality. `0x8000` and `0x20000` are valid -- the | ||
| # requirement is "at least 16 KB". The debug gate's own comment records that an | ||
| # equality test wrongly flagged JNA's 64 KB-aligned libjnidispatch.so. | ||
| # 2. EVERY LOAD segment, not just the first (`awk ... exit` read one and stopped). | ||
| # 3. A `checked == 0` guard. Without it a missing ABI directory makes the glob match | ||
| # nothing and the gate PASSES on an APK with no libraries in it at all. | ||
| aligned16k() { | ||
| local a | ||
| for a in $(readelf -lW "$1" | awk '$1 == "LOAD" { print $NF }'); do | ||
| [ $(( a % 16384 )) -eq 0 ] || return 1 | ||
| done | ||
| return 0 | ||
| } | ||
| fail=0 | ||
| checked=0 | ||
| for abi in arm64-v8a x86_64; do | ||
| for so in "$work"/lib/"$abi"/*.so; do | ||
| [ -e "$so" ] || { echo "::error::the release APK carries no .so for $abi"; exit 1; } | ||
| checked=$((checked + 1)) | ||
| if aligned16k "$so"; then | ||
| echo "ok: $abi/$(basename "$so")" | ||
| else | ||
| echo "::error::$so has LOAD segment(s) not a multiple of 16 KB" | ||
| readelf -lW "$so" | awk '$1 == "LOAD"' | ||
| fail=1 | ||
| fi | ||
| done | ||
| done | ||
| if [ "$checked" -eq 0 ]; then | ||
| echo "::error::the release alignment gate checked nothing" | ||
| exit 1 | ||
| fi | ||
| exit "$fail" | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| - name: Upload the APK | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | ||
| with: | ||
| name: rustysnes-debug-apk | ||
| path: android/app/build/outputs/apk/debug/*.apk | ||
| if-no-files-found: error | ||
| retention-days: 14 | ||
|
|
||
| # The UniFFI RUNTIME smoke test, in its own job on purpose. | ||
| # | ||
| # `build` above proves the bindings COMPILE -- `MainActivity` calls `MobileCore` directly, so | ||
| # bindgen output that drifted from the Rust API fails the Kotlin compile there. What no build can | ||
| # prove is that `System.loadLibrary` finds the `.so` for the device's ABI, that JNA's mapping | ||
| # matches the symbols in it, and that a call marshals across and returns. This project has already | ||
| # shipped one native Android crash that a build could not have caught. | ||
| # | ||
| # A separate job, not another step in `build`: an emulator is the flakiest thing in this workflow, | ||
| # and a flaky step inside `build` would put the 16 KB alignment gates -- which are not flaky, and | ||
| # which gate a real Play requirement -- behind an AVD boot. | ||
| # | ||
| # The emulator runs x86_64, but that is NOT the set of Rust targets this job needs. Gradle's | ||
| # `cargoNdkBuild` builds every ABI in `app/build.gradle.kts`'s `cargoAbis` map -- arm64-v8a AND | ||
| # x86_64 -- before the instrumented test can install anything, so both targets must be installed | ||
| # or the task fails on `can't find crate for core`. Installing only x86_64 because "the emulator | ||
| # runs the host ABI" confuses what the emulator RUNS with what the build COMPILES; that is | ||
| # exactly how this job failed on its first run. | ||
| smoke: | ||
| runs-on: ubuntu-latest | ||
| env: | ||
| CARGO_NET_RETRY: "10" | ||
| CARGO_TERM_COLOR: always | ||
| steps: | ||
|
Comment on lines
+347
to
+352
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Check ci-success aggregation and permissions blocks for the android workflow.
set -euo pipefail
echo "=== android.yml: top-of-file permissions and job list ==="
sed -n '1,40p' .github/workflows/android.yml
echo
echo "=== every job defined in android.yml ==="
rg -nP '^ [a-zA-Z0-9_-]+:\s*$' .github/workflows/android.yml
echo
echo "=== any ci-success aggregator across all workflows ==="
fd -e yml -e yaml . .github/workflows --exec rg -n -A 20 'ci-success' {} \;
echo
echo "=== permissions blocks per workflow ==="
fd -e yml -e yaml . .github/workflows --exec sh -c 'echo "--- $1"; rg -n -A 3 "permissions:" "$1" || echo "(none)"' _ {} \;Repository: doublegate/RustySNES Length of output: 2084 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re
path = Path(".github/workflows/android.yml")
text = path.read_text()
top_permissions = re.search(
r"^permissions:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)",
text,
re.MULTILINE,
)
print("workflow_permissions:")
print(top_permissions.group("body").rstrip() if top_permissions else "(none)")
jobs = {}
job_matches = list(re.finditer(r"^ ([A-Za-z0-9_-]+):\s*$", text, re.MULTILINE))
for i, match in enumerate(job_matches):
name = match.group(1)
start = match.end()
end = job_matches[i + 1].start() if i + 1 < len(job_matches) else len(text)
block = text[start:end]
if name in {"smoke", "ci-success"}:
needs = re.search(r"^ needs:\s*(.+)$", block, re.MULTILINE)
job_name = re.search(r"^ name:\s*(.+)$", block, re.MULTILINE)
job_permissions = re.search(r"^ permissions:\s*$([\s\S]*?)(?=^ [A-Za-z_-]+:|\Z)", block, re.MULTILINE)
jobs[name] = {
"needs": needs.group(1).strip() if needs else "(none)",
"name": job_name.group(1).strip() if job_name else "(none)",
"permissions": job_permissions.group(0).strip() if job_permissions else "(none)",
}
print("selected_jobs:")
for name, data in jobs.items():
print(name, data)
ci = jobs.get("ci-success", {})
needs_text = ci.get("needs", "")
print("smoke_in_ci_success_needs:", bool(re.search(r"\bsmoke\b", needs_text)))
PYRepository: doublegate/RustySNES Length of output: 314 Add 🧰 Tools🪛 zizmor (1.28.0)[info] 318-318: workflow or action definition without a name (anonymous-definition): this job (anonymous-definition) 🤖 Prompt for AI AgentsSources: Path instructions, Linters/SAST tools |
||
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - uses: ./.github/actions/rust-setup | ||
|
|
||
| - name: Add the Android targets Gradle's cargoNdkBuild needs | ||
| run: rustup target add x86_64-linux-android aarch64-linux-android | ||
|
|
||
| # The same NDK and the same discovery as the `build` job, deliberately: `sdkmanager` is NOT | ||
| # on PATH on `ubuntu-latest` even though `$ANDROID_HOME` is set, which that job found the | ||
| # hard way on its first run. Two jobs building the same libraries with different NDKs would | ||
| # also make a divergence between them impossible to attribute. | ||
| - name: Install the NDK from the runner's Android SDK | ||
| run: | | ||
| set -euo pipefail | ||
| : "${ANDROID_HOME:?the runner image is expected to provide an Android SDK}" | ||
| sdk="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" | ||
| if [ ! -x "$sdk" ]; then | ||
| echo "::error::no sdkmanager at $sdk" | ||
| ls -la "$ANDROID_HOME/cmdline-tools" || true | ||
| exit 1 | ||
| fi | ||
| "$sdk" --install "ndk;27.2.12479018" | ||
| echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.2.12479018" >> "$GITHUB_ENV" | ||
|
|
||
| - name: Install cargo-ndk | ||
| run: cargo install cargo-ndk --locked --version ^3 | ||
|
Comment on lines
+362
to
+380
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Extract the NDK setup into a composite action to enforce the invariant the comment states. Lines 337-351 duplicate lines 70-86 verbatim, including the NDK version The repository already uses this pattern with Proposed structureNew file name: Android NDK setup
description: Installs the pinned NDK from the runner's Android SDK and cargo-ndk.
runs:
using: composite
steps:
- name: Install the NDK from the runner's Android SDK
shell: bash
run: |
set -euo pipefail
# `sdkmanager` is NOT on PATH on `ubuntu-latest` even though `$ANDROID_HOME` is set.
: "${ANDROID_HOME:?the runner image is expected to provide an Android SDK}"
sdk="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$sdk" ]; then
echo "::error::no sdkmanager at $sdk"
ls -la "$ANDROID_HOME/cmdline-tools" || true
exit 1
fi
"$sdk" --install "ndk;27.2.12479018"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.2.12479018" >> "$GITHUB_ENV"
- name: Install cargo-ndk
shell: bash
run: cargo install cargo-ndk --locked --version ^3Then in - # The same NDK and the same discovery as the `build` job, deliberately: `sdkmanager` is NOT
- # on PATH on `ubuntu-latest` even though `$ANDROID_HOME` is set, which that job found the
- # hard way on its first run. Two jobs building the same libraries with different NDKs would
- # also make a divergence between them impossible to attribute.
- - name: Install the NDK from the runner's Android SDK
- run: |
- set -euo pipefail
- : "${ANDROID_HOME:?the runner image is expected to provide an Android SDK}"
- sdk="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
- if [ ! -x "$sdk" ]; then
- echo "::error::no sdkmanager at $sdk"
- ls -la "$ANDROID_HOME/cmdline-tools" || true
- exit 1
- fi
- "$sdk" --install "ndk;27.2.12479018"
- echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.2.12479018" >> "$GITHUB_ENV"
-
- - name: Install cargo-ndk
- run: cargo install cargo-ndk --locked --version ^3
+ # Shared with the `build` job so both jobs cannot drift onto different NDKs.
+ - uses: ./.github/actions/android-ndk-setup🤖 Prompt for AI Agents |
||
|
|
||
| - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v5 | ||
| with: | ||
| distribution: temurin | ||
| java-version: "17" | ||
|
|
||
| # KVM has to be enabled explicitly on GitHub's Linux runners, or the AVD falls back to | ||
| # software rendering and the boot times out rather than failing with a clear reason. | ||
| - name: Enable KVM | ||
| run: | | ||
| echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules | ||
| sudo udevadm control --reload-rules | ||
| sudo udevadm trigger --name-match=kvm | ||
|
|
||
| # `RUSTFLAGS` for the same reason the `build` job sets it on its Gradle step: Gradle's | ||
| # `cargoNdkBuild` re-runs `cargo ndk` in its own process and inherits this environment, not | ||
| # the flags of any earlier step. | ||
| # Bounded, for the reason the job exists: the split contains the blast radius of a flaky | ||
| # emulator but does not bound its runtime, and an AVD that never reaches boot-complete would | ||
| # otherwise burn the whole job timeout. The observed run is ~6.5 minutes. | ||
| - name: Run the instrumented UniFFI smoke test | ||
| timeout-minutes: 25 | ||
| uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 | ||
| env: | ||
| RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384" | ||
| with: | ||
| api-level: 34 | ||
| arch: x86_64 | ||
| target: google_apis | ||
| disable-animations: true | ||
| working-directory: android | ||
| script: ./gradlew --no-daemon connectedDebugAndroidTest | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| package com.doublegate.rustysnes | ||
|
|
||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||
| import androidx.test.platform.app.InstrumentationRegistry | ||
| import org.junit.Assert.assertEquals | ||
| import org.junit.Assert.assertFalse | ||
| import org.junit.Assert.assertTrue | ||
| import org.junit.Test | ||
| import org.junit.runner.RunWith | ||
| import uniffi.rustysnes_mobile.MobileCore | ||
| import uniffi.rustysnes_mobile.MobileRegion | ||
|
|
||
| /** | ||
| * The UniFFI smoke test: proves the generated Kotlin bindings actually **load and call** the native | ||
| * library on a real Android runtime. | ||
| * | ||
| * `assembleDebug` already proves the bindings *compile* against the shell — `MainActivity` calls | ||
| * `MobileCore` directly, so a bindgen output that drifted from the Rust API fails the Kotlin | ||
| * compile. What a build cannot prove is that `System.loadLibrary` finds the `.so` for the device's | ||
| * ABI, that JNA's mapping matches the symbols in it, and that a call marshals across and returns. | ||
| * Those are runtime facts, and this project has already shipped one native Android crash that a | ||
| * build could not have caught. | ||
| * | ||
| * Deliberately ROM-free. The app takes ROMs only from the user's document picker | ||
| * (`docs/app-store-4-7-self-audit.md`), so there is no ROM to open here and no need for one: every | ||
| * assertion below is about the *bridge*, not about emulation, which the workspace's own test suite | ||
| * covers far better than an emulator can. | ||
| */ | ||
| @RunWith(AndroidJUnit4::class) | ||
| class MobileCoreSmokeTest { | ||
| /** Constructing the core loads the library and crosses the FFI boundary once. */ | ||
| @Test | ||
| fun the_native_library_loads_and_a_core_can_be_constructed() { | ||
| val core = MobileCore(MobileRegion.NTSC) | ||
| assertFalse("a freshly constructed core must report no ROM loaded", core.romLoaded()) | ||
| } | ||
|
|
||
| /** | ||
| * A frame with no ROM loaded still has to return a correctly sized framebuffer. This is the | ||
| * assertion that would catch a marshalling error: a wrong length, or a returned buffer that | ||
| * does not survive the crossing, shows up here and nowhere in a build. | ||
| */ | ||
| @Test | ||
| fun a_frame_runs_and_returns_a_framebuffer_of_the_declared_size() { | ||
| val core = MobileCore(MobileRegion.NTSC) | ||
| core.runFrame() | ||
|
|
||
| val size = core.frameSize() | ||
| assertTrue("frame width must be positive, got ${size.width}", size.width > 0u) | ||
| assertTrue("frame height must be positive, got ${size.height}", size.height > 0u) | ||
|
|
||
| val fb = core.framebuffer() | ||
| assertEquals( | ||
| "the framebuffer length must be width * height * 4 (RGBA8)", | ||
| (size.width * size.height * 4u).toInt(), | ||
| fb.size, | ||
| ) | ||
| } | ||
|
|
||
| /** AccuracySNES's HiROM image, packaged as a test asset by Gradle's `copyTestRom`. */ | ||
| private fun testRom(): ByteArray = | ||
| InstrumentationRegistry.getInstrumentation().context.assets | ||
| .open("accuracysnes-hirom.sfc").use { it.readBytes() } | ||
|
|
||
| /** | ||
| * A **real cart boots on the device**, and only then is the audio buffer asserted. | ||
| * | ||
| * This test took two wrong turns, both of the same kind. It first asserted that two successive | ||
| * `drainAudio()` calls return equal counts, claiming to pin the documented non-destructive | ||
| * contract; then that the buffer length is even, claiming to pin interleaved stereo. A no-ROM | ||
| * frame produces **no audio at all** (measured on the host: `first=0 second=0`), so the first | ||
| * passed on `0 == 0` and the second on `0 % 2 == 0`. Neither could fail for the reason it named. | ||
| * | ||
| * The fix is not a better assertion, it is a cart. `docs/mobile-readiness.md` records that no | ||
| * ROM had ever actually booted on a device or simulator; loading one here closes that and makes | ||
| * every assertion below capable of failing. | ||
| */ | ||
| @Test | ||
| fun a_real_cart_boots_and_produces_audio() { | ||
| val core = MobileCore(MobileRegion.NTSC) | ||
| core.loadRom(testRom()) | ||
| assertTrue("the cart did not load", core.romLoaded()) | ||
|
|
||
| // Eight frames for margin, not because eight are needed: measured on the host, a booted | ||
| // AccuracySNES cart emits 1066 interleaved samples from the FIRST frame. Stating that | ||
| // rather than a plausible "the APU needs the IPL handshake first" — which the measurement | ||
| // disproves — keeps the comment something a reader can rely on. | ||
| repeat(8) { core.runFrame() } | ||
|
|
||
| val audio = core.drainAudio() | ||
| assertTrue("a booted cart produced no audio at all", audio.isNotEmpty()) | ||
| assertEquals( | ||
| "drainAudio must return interleaved stereo, so its length is always even", | ||
| 0, | ||
| audio.size % 2, | ||
| ) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** Reset and power-cycle are the two lifecycle calls the shell makes; both must cross safely. */ | ||
| @Test | ||
| fun the_lifecycle_calls_cross_the_boundary() { | ||
| val core = MobileCore(MobileRegion.NTSC) | ||
| core.reset() | ||
| core.powerCycle() | ||
| assertFalse("no ROM was ever loaded", core.romLoaded()) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: doublegate/RustySNES
Length of output: 19756
🏁 Script executed:
Repository: doublegate/RustySNES
Length of output: 37135
🌐 Web query:
Android 16 KB page sizes APK ZIP alignment zipalign -P 16 official documentation💡 Result:
To ensure your application is compatible with 16 KB page size requirements, you must perform two distinct types of alignment: aligning the ELF segments of your native libraries (.so files) and ZIP-aligning your APK [1][2]. Starting November 1, 2025, all new apps and updates submitted to Google Play that target Android 15 and higher must support 16 KB page sizes [1]. 1. ELF Alignment Native libraries must be built with 16 KB segment alignment. This is achieved at compile-time by passing linker flags to the NDK [3]. When building your libraries, use the following linker flags: -Wl,-z,max-page-size=16384 You can verify the alignment of your.so files using tools like readelf or objdump to ensure the LOAD segments are aligned to 16 KB (0x4000) [3][4]. 2. APK ZIP Alignment Once your libraries are correctly built, you must use the zipalign tool to align the uncompressed native libraries within the APK to 16 KB boundaries [1][5]. This ensures the files can be memory-mapped (mmap) efficiently by the Android system [5]. Use the following zipalign command: zipalign -v -P 16 -f 4 APK_NAME.apk OUTPUT_NAME.apk In this command: -P 16: Specifies that uncompressed.so files should be aligned to a 16 KiB page boundary [5]. -f: Overwrites the existing output file if necessary [5]. -v: Provides verbose output to confirm the alignment process [5]. -c: (Optional) Can be used to check the alignment of an existing APK without modifying it [1][5]. The official Android documentation confirms that using -P 16 is the recommended approach to ensure compatibility with both 16 KB and 4 KB page size devices [5]. If you do not perform these steps, your app may fail compatibility checks or trigger warnings in the Google Play Console [6][2].
Citations:
🏁 Script executed:
Repository: doublegate/RustySNES
Length of output: 358
🌐 Web query:
site:developer.android.com/guide/practices/page-sizes AGP 8.5.1 16 KB zip alignment💡 Result:
Android Gradle Plugin (AGP) version 8.5.1 and higher is required to properly support 16 KB page size alignment for apps that use uncompressed shared libraries [1]. Key technical details regarding this requirement include: 1. Requirement for 16 KB Alignment: Devices using 16 KB page sizes require that uncompressed shared libraries (.so files) shipped within an app are aligned on a 16 KB zip-aligned boundary to function correctly [1]. 2. Why AGP 8.5.1 is Necessary: While AGP versions 8.3 through 8.5 align apps to 16 KB by default, they do not always ensure that bundletool zip-aligns the resulting APKs correctly when built from an Android App Bundle (AAB) [1]. Upgrading to AGP 8.5.1 or higher ensures this alignment is handled correctly during the build process, preventing potential installation failures on 16 KB devices [1]. 3. Alternative for Older AGP Versions: If upgrading to AGP 8.5.1+ is not possible, developers must configure their Gradle build to use compressed shared libraries [1]. However, this is discouraged because compressed libraries must be extracted to disk during installation, which increases the app's installation footprint and can lead to more frequent installation failures due to insufficient disk space [1]. 4. Compatibility Recommendation: To ensure 16 KB compatibility, the official recommendation is to update tools to the latest versions, specifically AGP 8.5.1 or higher and NDK version r28 or higher, and ensure all prebuilt dependencies are also 16 KB compatible [1].
Citations:
Add a 16 KB ZIP-alignment check for the release APK.
This step checks ELF
LOADalignment only. An APK with aligned ELF segments but a 4 KB-aligned uncompressed.soZIP entry passes this step and fails 16 KB compatibility checks. Runzipalign -c -P 16 -v 4 "$apk"before extraction.🤖 Prompt for AI Agents