Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 264 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
name: Android

on:
push:
branches: [main]
paths:
- "crates/rustysnes-android/**"
- "crates/rustysnes-mobile/**"
- "crates/rustysnes-monetization/**"
- "crates/rustysnes-gfx-shaders/**"
- "android/**"
- "Cargo.lock"
- ".github/workflows/android.yml"
pull_request:
paths:
- "crates/rustysnes-android/**"
- "crates/rustysnes-mobile/**"
- "crates/rustysnes-monetization/**"
- "crates/rustysnes-gfx-shaders/**"
- "android/**"
- "Cargo.lock"
- ".github/workflows/android.yml"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +14 to +22
schedule:
# A ~60-day refresh, matching `ios.yml`'s cadence and for the same reason: the NDK and the
# Android Gradle Plugin move under us, and this workflow is the project's only exercise of
# either. Offset from `ios.yml` (1st) to the 15th so the two mobile jobs do not collide.
- cron: "0 8 15 */2 *"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
Comment on lines +38 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: name the job.

zizmor flags build as an anonymous job definition. Adding a name: key improves readability in the Actions UI but has no functional effect.

🧰 Tools
🪛 zizmor (1.28.0)

[info] 38-38: workflow or action definition without a name (anonymous-definition): this job

(anonymous-definition)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/android.yml around lines 38 - 39, Add a descriptive name:
field to the build job in the Android workflow, alongside runs-on, so the job is
clearly labeled in the GitHub Actions UI.

Source: Linters/SAST tools

env:
CARGO_NET_RETRY: "10"
CARGO_TERM_COLOR: always
steps:
Comment on lines +38 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a job-level timeout-minutes.

The build job has no timeout-minutes. It installs an NDK via sdkmanager, and it runs a Rust and Gradle build. If any step hangs — for example, sdkmanager blocking on an unexpected license prompt — the job runs up to the default GitHub Actions limit before failing. Set an explicit bound, for example timeout-minutes: 30, to fail fast and free the runner sooner.

🧰 Tools
🪛 zizmor (1.28.0)

[info] 38-38: workflow or action definition without a name (anonymous-definition): this job

(anonymous-definition)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/android.yml around lines 38 - 43, Add a job-level
timeout-minutes setting to the Android workflow’s build job, using a 30-minute
limit. Keep the existing runs-on, environment, and steps unchanged.

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- uses: ./.github/actions/rust-setup

# The four ABIs Play accepts for a 64-bit-required listing plus the two 32-bit ones the
# `minSdk = 21` floor still reaches. `cargo ndk` maps each to its NDK triple.
- name: Add the Android targets
run: |
rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
x86_64-linux-android \
i686-linux-android

- name: Confirm the Android targets actually installed
run: rustup target list --installed | grep -E 'android' | sort

# The NDK comes from the runner image's own Android SDK rather than a third-party action.
# `ubuntu-latest` ships `$ANDROID_HOME` with `sdkmanager`, so this adds no new supply-chain
# surface — the v1.26.0 hardening pass is the reason that matters here.
#
# r27 is the floor. Note that pinning the NDK is NOT by itself what produces 16 KB-aligned
# libraries — see the build step below, which passes the alignment explicitly because the
# first CI run proved the NDK version does not decide it.
- name: Install the NDK from the runner's Android SDK
run: |
set -euo pipefail
# `sdkmanager` is NOT on PATH on `ubuntu-latest` even though `$ANDROID_HOME` is set —
# it lives under the SDK's own cmdline-tools. Found by this workflow's first CI run.
: "${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 +85 to +86

# `--build-std` is deliberately NOT used: the shipped build must be reproducible from a
# stable toolchain, and `rust-toolchain.toml` pins 1.96 stable.
#
# `max-page-size=16384` is passed EXPLICITLY rather than relied upon as an NDK default. This
# workflow's first CI run failed the gate below with 4 KB-aligned LOAD segments, and the
# cause was measured, not guessed: with the same NDK r27c, `cargo-ndk` 3.5.4 emits 0x1000 and
# 4.1.2 emits 0x4000 — the linker invocation decides it, not the NDK. Passing the flag makes
# the result independent of which `cargo-ndk` resolves, which a version range cannot promise.
# Applied to the 64-bit ABIs only, matching what the gate checks and what Play requires; the
# 32-bit libraries stay 4 KB by design.
- name: Build the JNI libraries (64-bit ABIs, 16 KB-aligned)
env:
RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384"
run: |
cargo ndk \
-t arm64-v8a -t x86_64 \
-o android/app/src/main/jniLibs \
build --release -p rustysnes-android -p rustysnes-mobile -p rustysnes-monetization

# Only the two 32-bit ABIs here. Re-listing the 64-bit ones would relink them without the
# flag above and overwrite the aligned output with 4 KB libraries — the gate would catch it,
# but silently undoing the previous step is worth not writing in the first place.
- name: Build the JNI libraries (32-bit ABIs)
run: |
cargo ndk \
-t armeabi-v7a -t x86 \
-o android/app/src/main/jniLibs \
build --release -p rustysnes-android -p rustysnes-mobile -p rustysnes-monetization

- name: Show what was produced
run: find android/app/src/main/jniLibs -name '*.so' -printf '%p %s bytes\n' | sort

# ---------------------------------------------------------------------------------------
# The 16 KB page-alignment gate.
#
# Play requires every shipped `.so` to have 16 KB-aligned LOAD segments for devices with a
# 16 KB page size; a 4 KB-aligned library simply fails to load there.
#
# This gate earned its place on its first run: it failed, and the 4 KB libraries were real.
# It was written expecting to be a formality that confirmed an NDK default — the build step
# above records what the measurement actually showed. Treat a failure here as a genuine
# finding, not as a broken check.
#
# Checked on the 64-bit ABIs only: the requirement is a property of 64-bit page sizes, and
# the 32-bit libraries are 4 KB-aligned by design.
# ---------------------------------------------------------------------------------------
- name: Assert 16 KB ELF page alignment on the 64-bit ABIs
run: |
set -euo pipefail
# The requirement is "aligned to AT LEAST 16 KB", so the test is divisibility, not
# equality. An exact `== 0x4000` match rejects a library that is MORE strictly aligned --
# JNA's `libjnidispatch.so` ships at `0x10000` (64 KB), which is perfectly valid, and the
# equality test flagged it as a violation on this workflow's first APK-level run. Bash
# parses the `0x` prefix inside `$(( ))`.
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 android/app/src/main/jniLibs/"$abi"/*.so; do
[ -e "$so" ] || { echo "::error::no .so produced for $abi"; exit 1; }
checked=$((checked + 1))
if aligned16k "$so"; then
echo "ok: $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 alignment gate checked nothing — the .so glob matched no files"
exit 1
fi
exit "$fail"

# The JDK is preinstalled on `ubuntu-latest`; Gradle comes from the committed wrapper rather
# than the runner's copy, so CI and a developer's machine build with the SAME Gradle. The
# runner image's preinstalled version moves under us, which is the drift a wrapper exists to
# remove — and until now this project had no wrapper at all, so every build depended on
# whatever Gradle happened to be installed.
- name: Show the Java, and the Gradle the wrapper pins
working-directory: android
run: |
java -version
./gradlew --version

# `assembleDebug`, not `assembleRelease`: a release build needs signing material this
# workflow deliberately does not hold (see `docs/mobile-readiness.md`). What this proves is
# that the Kotlin sources, the manifest, and the freshly built `.so`s package together.
# `RUSTFLAGS` again, because Gradle's own `cargoNdkBuild` task re-runs `cargo ndk` for all
# three crates and does NOT inherit the flag from the steps above -- it inherits this process
# environment instead. Without it the APK ships 4 KB-aligned libraries even though the
# standalone build above produced 16 KB ones, which is exactly the false pass the APK-level
# gate below now catches.
- name: Assemble the debug APK
working-directory: android
env:
RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384"
run: ./gradlew --no-daemon assembleDebug

- name: Confirm the APK carries every ABI
run: |
set -euo pipefail
apk=$(find android -name '*-debug.apk' | head -1)
[ -n "$apk" ] || { echo "::error::no debug APK was produced"; exit 1; }
echo "APK: $apk"
for abi in arm64-v8a armeabi-v7a x86_64 x86; do
if unzip -l "$apk" | grep -q "lib/$abi/"; then
echo "ok: $abi present"
else
echo "::error::the APK is missing lib/$abi/ — jniLibs packaging regressed"
exit 1
fi
done

# The authoritative alignment gate: the APK is what ships, and it carries THREE libraries per
# ABI (`librustysnes_android.so`, `librustysnes_mobile.so`, `librustysnes_monetization.so`),
# built by Gradle's own `cargoNdkBuild`. The earlier jniLibs gate checks a pre-Gradle build and
# would pass while the packaged libraries were misaligned -- a false pass CodeRabbit caught on
# this PR. Both are kept: the early one fails fast, this one is the truth.
- name: Assert 16 KB page alignment inside the APK
run: |
set -euo pipefail
apk=$(find android -name '*-debug.apk' | head -1)
[ -n "$apk" ] || { echo "::error::no debug APK was produced"; exit 1; }
# Same divisibility test as the jniLibs gate above -- see its comment for why an exact
# `0x4000` match is wrong.
aligned16k() {
local a
for a in $(readelf -lW "$1" | awk '$1 == "LOAD" { print $NF }'); do
[ $(( a % 16384 )) -eq 0 ] || return 1
done
return 0
}
work=$(mktemp -d)
unzip -q "$apk" 'lib/*' -d "$work"
fail=0
# EVERY library in the APK is checked, not only ours: Play's requirement is a property of
# the shipped package, so a misaligned third-party dependency fails the listing just as
# surely as a misaligned one of ours.
for abi in arm64-v8a x86_64; do
for so in "$work/lib/$abi"/*.so; do
[ -e "$so" ] || { echo "::error::the APK has no .so for $abi"; exit 1; }
if aligned16k "$so"; then
echo "ok: $abi/$(basename "$so")"
else
echo "::error::$(basename "$so") ($abi) has LOAD segment(s) not a multiple of 16 KB"
readelf -lW "$so" | awk '$1 == "LOAD"'
fail=1
fi
done
# Presence, by name, for the three this project builds. A count would have to be
# revised every time a dependency adds or drops a native library -- and a wrong count
# is what this gate got wrong first time round, failing on a correct APK.
for want in librustysnes_android.so librustysnes_mobile.so librustysnes_monetization.so; do
[ -e "$work/lib/$abi/$want" ] || {
echo "::error::the APK is missing lib/$abi/$want -- jniLibs packaging regressed"
fail=1
}
done
done
Comment thread
coderabbitai[bot] marked this conversation as resolved.
exit "$fail"

- 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
73 changes: 73 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
generate. The on-screen controls that would drive it are still outstanding;
`docs/mobile-readiness.md` now says which half is done.

- **Android CI, with the 16 KB page-alignment gate (`v1.30.0`).** `android/` had Gradle sources and
a `cargo ndk` layout but **no workflow at all** — nothing built it, so nothing could regress
visibly. `.github/workflows/android.yml` cross-builds `rustysnes-android` for all four ABIs,
asserts every 64-bit `.so`'s `PT_LOAD` segments are 16 KB aligned, assembles a debug APK, and
checks the APK actually carries each ABI.

The alignment check is the store-facing one: Play requires 16 KB-aligned segments for 16 KB-page
devices and a 4 KB-aligned library simply fails to load there. It fails closed if the `.so` glob
matches nothing, so a build that produces no libraries cannot pass it silently.

**It was written as a formality and immediately found a real defect.** The first CI run produced
4 KB-aligned `arm64-v8a` and `x86_64` libraries — exactly what Play rejects. The gate's own
comment had asserted that 16 KB was the NDK default from r27 onward, so the obvious reading was
that the check was broken. It was not. Reproduced locally and bisected: with the **same** NDK
r27c, `cargo-ndk` 3.5.4 emits `0x1000` and 4.1.2 emits `0x4000`. The alignment is decided by the
linker invocation, not by the NDK version — and the workflow pinned `cargo-ndk` to `^3`, which
resolved to 3.5.4. Fixed by passing `-C link-arg=-Wl,-z,max-page-size=16384` explicitly for the
64-bit ABIs (verified to produce `0x4000` under the failing 3.5.4), so the result no longer
depends on a tool default that a version range cannot promise. The 32-bit ABIs build in a
separate step and stay 4 KB by design.

**The workflow also found that the Android app could not be built from a clean checkout at all.**
`android/gradle.properties` had never been committed, so `:app:checkDebugAarMetadata` fails with
"contains AndroidX dependencies, but the `android.useAndroidX` property is not enabled" — every
Android build this project has ever done ran on a machine that already had those settings in a
user-level `~/.gradle/gradle.properties`. Now committed, with `enableJetifier` explicitly off
(there are no legacy support-library artifacts to rewrite) and a raised Kotlin daemon heap.

**The alignment gate was itself checking the wrong artifacts, and CodeRabbit caught it.** The APK
carries **three** libraries per ABI (`librustysnes_android.so`, `librustysnes_mobile.so`,
`librustysnes_monetization.so`), all built by Gradle's own `cargoNdkBuild` — while the workflow's
standalone pre-build produced only `rustysnes-android`, and Gradle's task did not inherit the
alignment flag. So the gate could pass on libraries that never ship while the ones that do ship
were 4 KB aligned. Fixed on both sides: the pre-build now covers all three crates, the Gradle step
carries `RUSTFLAGS`, and a second gate runs over the **assembled APK's** `lib/` — pinned at exactly
six 64-bit libraries so a packaging change that drops one fails rather than passing more quietly.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
`Cargo.lock` and `crates/rustysnes-monetization/**` are now in the path filters too.

**The gate's own alignment test was wrong too, and so was a shipped `libjnidispatch.so`.** The
test compared each `PT_LOAD` Align to `0x4000` for *equality*, but the requirement is "aligned to
at least 16 KB" — a divisibility property, and 64 KB satisfies it. Now `align % 16384 == 0`,
verified against real files to accept 64 KB and 16 KB and reject 4 KB.

Fixing that exposed a genuine one underneath, and finding it required measuring **per ABI** — the
first pass checked only `arm64-v8a` and generalised, which produced a confident wrong conclusion
that no dependency bump was needed. Reading the published JNA AARs:

| version | arm64-v8a | x86_64 | armeabi-v7a | x86 |
|---|---|---|---|---|
| 5.15.0 | `0x10000` | `0x1000` | `0x1000` | `0x1000` |
| 5.16.0+ | `0x4000` | `0x4000` | `0x4000` | `0x4000` |

So the pinned 5.15.0 satisfied the requirement on arm64 and **violated it on every other ABI**.
Bumped to 5.17.0 — 5.16.0 is the first release that fixes it, 5.17.0 the nearest settled patch
line after that change.

The hardcoded "expect exactly 6 libraries" assertion was wrong too (the APK carries 10, including
two third-party ones) and is replaced by a by-name presence check for the three this project
builds — a count would need revising whenever a dependency adds a native library. Every library
in the APK is alignment-checked, not just ours: Play's requirement is a property of the package,
so a misaligned dependency fails the listing just as surely.

Deliberately two actions only, both already pinned in this repo: the NDK comes from the runner
image's own `sdkmanager` and the JDK is preinstalled, rather than adding three third-party actions
to the supply-chain surface the `v1.26.0` pass tightened. `assembleDebug`, not release — signing
material is maintainer-only and stays out of CI.

The **Gradle wrapper is now committed** (`android/gradlew`, `gradlew.bat`, and
`gradle/wrapper/`, pinning Gradle 8.10). The project had none, so every build — CI or local —
used whatever Gradle happened to be installed, and this environment only ever had a cached
distribution. CI now runs `./gradlew`, so it and a developer's machine build with the same
Gradle.

- **AccuracySNES: the Mesen2 oracle diagnosed, and a stale frame budget aligned.** Three `v1.28.0`
items ended at "no oracle can arbitrate", so the headless runner was investigated rather than
accepted as environmental. Established: the hang is genuinely pre-existing (the `v1.25.0`-era image
Expand Down
13 changes: 12 additions & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -141,5 +141,16 @@ dependencies {
// The AAR classifier is required on Android -- the plain `net.java.dev.jna:jna` jar (what
// UniFFI's Kotlin bindings assume on a desktop JVM) does not bundle Android's native
// `libjnidispatch.so`.
implementation("net.java.dev.jna:jna:5.15.0@aar")
//
// 5.17.0, not 5.15.0, because of the 16 KB page-alignment requirement -- and the version was
// chosen by measuring the published AARs, per ABI, rather than from a changelog:
//
// 5.15.0 arm64-v8a 0x10000 x86_64 0x1000 armeabi-v7a 0x1000 x86 0x1000
// 5.16.0+ arm64-v8a 0x4000 x86_64 0x4000 armeabi-v7a 0x4000 x86 0x4000
//
// So 5.15.0 satisfies the requirement on arm64 (64 KB is a multiple of 16 KB) and violates it
// on every other ABI -- which is why checking one ABI and generalising gave the wrong answer
// the first time. 5.16.0 is the first release that fixes it; 5.17.0 is taken as the nearest
// settled patch line after that change.
implementation("net.java.dev.jna:jna:5.17.0@aar")
}
Loading
Loading