Repository navigation
feat(android): v1.8.0 beta.1 — rustynes-mobile UniFFI bridge + Android host foundation - #157
Conversation
…d host foundation First *platform* release (Workstream A, foundation & build). Additive only; the byte-identical core is untouched, so AccuracyCoin 100% (139/139) and every host gate stay green. New crates: - rustynes-mobile: platform-agnostic UniFFI bridge over rustynes-core::Nes (NesController control surface: load ROM from bytes, per-port Buttons mask, run/step frame, drain audio, .rns save/restore, cartridge info). UniFFI generates the Kotlin/Swift bindings; shared verbatim with the v1.9.0 iOS host. 6 host unit tests (synthetic NROM, no commercial ROM). No new determinism surface. - rustynes-android: JNI/surface/audio hot glue, all #[cfg(target_os="android")] so it is a host-safe lint-only shell; holds android_main for the winit+wgpu spike. The one mobile crate carrying unsafe (// SAFETY: noted). android/ Gradle module: a Material-3 Jetpack Compose first-boot shell that opens a ROM via the Storage Access Framework, drives the core through the generated NesController, blits each RGBA frame to a Bitmap, and routes an on-screen overlay + hardware gamepad keys into the single late-latched controller mask. cargo-ndk + uniffi-bindgen wired as Gradle build steps; AAB, minSdk 26 / targetSdk 35, arm64 ship ABI + x86_64. CI: .github/workflows/android.yml cross-compiles both crates for arm64+x86_64 against the NDK, generates the Kotlin bindings (smoke), checks 16 KB ELF alignment, best-effort-bundles the AAB. Not a required check. Docs: docs/android.md + ADR 0024 (UniFFI bridge + hybrid wgpu/Compose host). Verified end-to-end: host build + tests, clippy -D warnings, rustdoc, fmt, markdownlint; UniFFI Kotlin generation; NDK r29 cross-compile to arm64 producing both .so libraries warning-free. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request establishes the foundation for the Android platform release of RustyNES, introducing a shared mobile bridge crate (rustynes-mobile) with UniFFI bindings, an Android platform crate (rustynes-android) for JNI and native surface glue, and a Jetpack Compose-based application shell. The review feedback highlights three critical improvements: first, in rustynes-mobile, active button masks should be re-applied immediately after restoring a save state to prevent input desynchronization; second, in MainActivity.kt, LocalContext.current must be used instead of LocalActivity to avoid a compilation error with the specified Compose version; and third, the pixel-packing routine packRgbaToArgb should be moved to a background thread to prevent UI thread jank.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
Pull request overview
This PR introduces the first Android foundation for RustyNES v1.8.0 beta.1 by adding a shared UniFFI-based mobile bridge crate, an Android host/platform crate, and an initial Gradle/Compose Android app module, plus documentation and CI to validate cross-compilation and binding generation.
Changes:
- Added
rustynes-mobile, a host-testable UniFFI-exported control surface overrustynes-core::Nes(ROM from bytes, input mask/buttons, frame stepping, audio drain, save-state, cart info). - Added
rustynes-android, a platform crate intended to hold Android-only JNI/surface/audio glue (currently a gated shell with logging +android_mainplaceholder). - Added an
android/Gradle module and an Android GitHub Actions workflow to cross-build/link the.soartifacts and smoke-generate Kotlin bindings; updated docs/ADR/CHANGELOG and workspace membership.
Reviewed changes
Copilot reviewed 25 out of 26 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/android.md | Adds Android architecture/build/signing/CI documentation and beta roadmap. |
| docs/adr/0024-mobile-bridge-and-hybrid-android-host.md | ADR describing the UniFFI bridge + hybrid host rendering decision. |
| crates/rustynes-mobile/src/lib.rs | Implements the UniFFI-exported mobile control surface + unit tests. |
| crates/rustynes-mobile/src/bin/uniffi-bindgen.rs | Adds a standalone UniFFI bindgen entrypoint for Kotlin/Swift generation. |
| crates/rustynes-mobile/Cargo.toml | Declares the new bridge crate outputs (lib/cdylib/staticlib) and deps. |
| crates/rustynes-android/src/lib.rs | Introduces the Android host crate skeleton and Android-only module gating. |
| crates/rustynes-android/Cargo.toml | Declares the new Android platform crate and Android-target-only deps. |
| CHANGELOG.md | Documents the new Android workstream A foundation deliverables. |
| Cargo.toml | Adds the new crates to the workspace + workspace dependency entry. |
| Cargo.lock | Locks new dependencies (UniFFI, Android tooling crates, etc.). |
| android/settings.gradle.kts | Adds the Android Gradle settings and repository configuration. |
| android/gradle/wrapper/gradle-wrapper.properties | Pins the Gradle wrapper distribution. |
| android/gradle.properties | Enables Gradle properties for the Android module build. |
| android/build.gradle.kts | Defines root Android plugin versions. |
| android/app/src/main/res/values/themes.xml | Adds the Android launch theme resources. |
| android/app/src/main/res/values/strings.xml | Adds the app name string resource. |
| android/app/src/main/res/values/ic_launcher_background.xml | Adds launcher background color resource. |
| android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | Adds adaptive launcher icon definition. |
| android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml | Adds round adaptive launcher icon definition. |
| android/app/src/main/res/drawable/ic_launcher_foreground.xml | Adds vector foreground icon asset. |
| android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt | Implements the first-boot Compose shell driving the UniFFI controller. |
| android/app/src/main/AndroidManifest.xml | Adds manifest with activity, features, and no-permissions stance. |
| android/app/proguard-rules.pro | Adds R8/Proguard keep rules for UniFFI/JNA bindings. |
| android/app/build.gradle.kts | Wires cargo-ndk + UniFFI bindgen into Gradle and sets app config/deps. |
| android/.gitignore | Adds Android-module ignore rules and re-includes required XML resources. |
| .github/workflows/android.yml | Adds Android cross-build + UniFFI generation workflow and packaging job. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The beta.1 module now builds a real installable APK end-to-end
(cargo-ndk -> UniFFI -> Compose). Two build-blockers fixed:
- rustynes-mobile: rename the MobileError variant field `message` -> `reason`.
UniFFI maps error enums to Kotlin Exception subclasses, so a `message` field
collided with `Throwable.message` and broke the generated bindings' compile.
- Compose shell: use `LocalContext` (activity-compose 1.9.3) instead of
`LocalActivity` (only in 1.10+) to obtain the ContentResolver for SAF.
Also: pin the Gradle wrapper to 8.13, drop an invalid AGP property
(`enableNewResourceShrinker`) for `nonTransitiveRClass`, commit the Gradle
wrapper (jar + gradlew[.bat]) so the module builds without a pre-installed
Gradle, and simplify the CI bundle step to use ./gradlew directly.
Verified: `cargo test -p rustynes-mobile` (6 pass) + clippy clean; the Gradle
`:app:assembleDebug` produces a 22.5 MB app-debug.apk packaging both
librustynes_{mobile,android}.so for arm64-v8a + x86_64.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-device validation on a Galaxy Z Fold 7 (Android 16, arm64): nestest boots, renders with the correct NES palette, and reports all 14 suites OK via the on-screen Start button — the core, UniFFI bridge, render path, and touch input all work end-to-end on real hardware. - Layout: the NES image now takes the remaining vertical space (`weight(1f)`, letterboxed) instead of driving its height from width, which overflowed on a wide/foldable display and pushed the Open-ROM button + touch overlay off-screen. - Foldable: the activity is `resizeableActivity` and absorbs the fold/unfold + posture config changes (screenSize/smallestScreenSize/screenLayout/orientation /density) so the emulation thread + surface survive the cover<->inner screen swap with no restart; orientation is unlocked so the large inner screen can present in any posture. The Compose layout is responsive. - Debug-only autoload: a ROM pushed to the app's external files dir (`autoload.nes`) is loaded on boot (BuildConfig.DEBUG-gated) so the render path is testable on-device without driving the SAF picker by hand; release boots to the picker. Enabled the `buildConfig` build feature for the gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… controls Verified on the Z Fold 7 inner (1968x2184) + cover displays: the status bar, navigation bar, and large-screen taskbar are hidden in sticky immersive mode and only reappear transiently on an edge swipe, then auto-hide — so they never sit on top of the on-screen D-pad / face buttons. Re-asserted on every window-focus gain (via onWindowFocusChanged) so the bars stay hidden across the SAF picker, dialogs, and the cover<->inner fold transition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t cut) Verified on the Z Fold 7: the apu_mixer square-wave ROM is audible, no underruns in logcat. The core emits deterministic mono f32 samples (NesController.drainAudio); a low-latency mono PCM_FLOAT AudioTrack (USAGE_GAME, MODE_STREAM, PERFORMANCE_MODE_LOW_LATENCY, ~4-frame buffer) plays them. The blocking write paces the emulation loop to real time when sound is present, with a wall-clock NTSC-frame floor so silent ROMs still run at ~60 Hz. The sink is a pure consumer of already-deterministic samples, so the determinism contract is untouched. Known follow-up: UniFFI maps Vec<f32> to a boxed List<Float>; a later increment moves the audio pull into the rustynes-android JNI hot path (primitive float[]/native ring) to drop the per-frame boxing, plus AAudio focus handling + DRC, per the plan's Workstream C. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (beta.4 E) Workstream E persistence, built on the working SAF load: - SaveStateStore: `.rns` blobs at filesDir/states/<rom-sha256>/<slot>.rns — the same per-ROM-SHA layout the desktop host uses, so states stay byte-identical and portable across devices. Save/Load slot-1 buttons in a new control bar. - Save-on-background + auto-resume: onPause writes the `auto` slot for the current ROM (the bridge lock serialises it against the emu thread); opening that ROM again auto-loads it, so the player resumes where they left off. Battery/SRAM is captured in the snapshot, so in-cart saves persist too. - RomLibrary: recent ROMs persisted as the SAF content URIs with takePersistableUriPermission, so they survive reboot; an idle-screen recents list re-opens them in a tap. Display names resolved via OpenableColumns. - Loads now funnel through one `loadRom` helper (picker, recents, debug autoload): build controller -> key by SHA-256 -> auto-resume -> grant + remember. Verified on-device: builds, installs, boots; the Open/Save/Load/Reset bar renders and the autoloaded ROM runs. The `.rns` format is unchanged, so determinism + desktop<->Android state portability hold. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pack, ABIs) Adopted the three actionable bot findings from the #157 review: - HIGH (rustynes-mobile): load_state now re-applies the host-held controller masks after nes.restore overwrites the core's input latch, so a button held across a state load no longer sticks/desyncs (mirrors the desktop host). Guarded by a new load_state_preserves_held_input test (7 tests pass). - MEDIUM (Compose): the 61k-pixel RGBA->ARGB pack now runs inside the Dispatchers.Default block with runFrame/audio; only setPixels + asImageBitmap stay on the UI thread, removing per-frame main-thread jank. - LOW (Gradle): split builtAbis (arm64+x86_64, cross-built for CI/emulator) from the release variant's shipAbi (arm64-only AAB); debug keeps x86_64 for the emulator. Comment now matches the actual packaging intent. The other flagged threads were already fixed on the branch (LocalContext, the gradle.properties cleanup) or not applicable (ANDROID_NDK_LATEST_HOME is exported by the GitHub runners). Verified: cargo test/clippy green; the debug APK builds + boots clean on the Z Fold 7. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts from the v1.7.1 release: - Cargo.toml: take main's 1.7.1 workspace + dep-pin bumps; keep the new rustynes-mobile/rustynes-android members + the rustynes-mobile dep (1.7.1). - CHANGELOG.md: keep the v1.8.0 "Android" Added block under [Unreleased] and main's new [1.7.1] release section.
cargo-deny flagged bincode 1.3.3 as unmaintained (RUSTSEC-2025-0141). bincode is NOT a workspace dependency — no crate declares or uses it; it was pulled purely as a build-time transitive dep of uniffi_macros 0.28. Rather than ignore the advisory, upgrading uniffi to the current 0.31.2 removes bincode from the tree entirely (along with other stale transitive deps: askama_escape, mime/mime_guess, unicase). `cargo deny check advisories` now passes with no new ignore. The proc-macro surface (setup_scaffolding!, #[uniffi::export], the uniffi-bindgen CLI) is unchanged across 0.28->0.31, so the generated Kotlin NesController API is identical. Verified: cargo test/clippy green (7 mobile tests); the debug APK builds with the 0.31 bindgen and boots clean on the Z Fold 7. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Emulator quality-of-life on the working render path: - Pause/Resume: the emulation loop idles (no frames advance) while paused. - Fast-forward (>>): drops the frame-pace delay and the audio write so the core runs ahead of real time; releasing returns to 60 Hz with audio. - Mute: discards the drained audio samples (the core still produces them, so the timeline is unchanged). All three are @volatile flags on EmulatorHandle read by the loop — frontend-only, determinism untouched. The control bar is now horizontally scrollable so all controls reach on a narrow cover screen. Verified building + running on the Z Fold 7. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- docs/android.md: status banner + workstream table now reflect the on-device-verified reality (nestest all-OK, audio, input, save-states, pause/FF/mute, foldable+immersive on a Z Fold 7); wgpu/shaders marked the documented next increment. - Verified Play packaging end-to-end: `gradle :app:bundleRelease` produces a 3.2 MB arm64-only AAB (R8-minified; the ABI split keeps x86_64 out of the release), and a debug-key-signed `assembleRelease` APK installs + runs on the Z Fold 7 with no R8/JNA/UniFFI reflection breakage (proguard keep-rules hold). - Added android/keystore.properties.example documenting the upload-key format (the real keystore.properties stays gitignored / maintainer-manual). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… demo Maintainer-locked (2026-06-19): ship on Google Play as a free download with a one-time non-consumable "Full Unlock" IAP at $2.99 (Play Billing, single-listing freemium — not a paid app, not a second demo listing). The free tier is a time-limited demo: a 10-minute per-launch session with save-states, save-on-background/resume, and on-cart battery-backed SRAM persistence disabled; everything else (accuracy, video, audio, input, pause/FF, ROM library) is identical to paid. Added Workstream M (Entitlement/BillingManager, the three persistence locks + session timer, unlock/restore UI, debug override) and folded it into rc.1 of the release train. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rkstream M) Single free Play listing + a one-time non-consumable "full_unlock" IAP at $2.99 via Play Billing 8 (LicenseManager: connect, query product, queryPurchases, launchBillingFlow, acknowledge, restore; entitlement cached in SharedPreferences but re-verified against Play on each foreground). isUnlocked is Compose-observable. Demo gating (all host policy, core/determinism untouched): - 10-minute per-launch session timer (60 s in debug); on expiry the emulator pauses and a blocking DemoExpiredOverlay offers Unlock + Restore. - Save-state Save/Load buttons hidden in demo. - Save-on-background + auto-resume disabled in demo (onPause writes nothing; a ROM cold-boots), so battery-backed SRAM never persists across a close. - An "Unlock $price" button + "Demo · M:SS" countdown in the control bar; both vanish the instant the entitlement flips (purchase / restore). Verified on the Z Fold 7 via a BuildConfig.DEBUG-only unlock toggle (the real Play purchase flow needs a Play Console product + license-tested account): in demo Save/Load are hidden and the Unlock button + live countdown show; toggling unlock restores Save/Load and removes the demo UI. The product id, $2.99 pricing, and the closed-track purchase test remain maintainer-manual. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Monetization section (the $2.99 Full Unlock IAP + the 10-min demo's three persistence locks + session timer) and the maintainer-manual Play Console steps (create the `full_unlock` managed product at $2.99, closed-track + license-tested purchase verification). Mark Workstreams G + M done in the status table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s/QoL/freemium) Expand the [Unreleased] Android entry beyond the beta.1 foundation to record the on-device-verified emulator (audio, input, save-states/SRAM/recents, pause/FF/mute, foldable+immersive UI, release AAB) and the freemium $2.99 unlock + 10-min demo gating; note wgpu/shaders as the documented next increment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deliver the GPU shader experience on the Compose render path with an AGSL
RuntimeShader (API 33+) applied via Modifier.graphicsLayer { renderEffect = … }:
None / Scanlines / CRT (soft 240-line scanlines + a 3px RGB aperture mask +
vignette), an "FX:" cycle button with the choice persisted, feature-detected
(videoFiltersSupported) with the plain Bitmap blit as the always-on fallback.
Post-processing only — the emulated framebuffer/audio is untouched, determinism
holds.
This is the pragmatic, Compose-native, low-risk path to the shader goal; the full
wgpu-on-SurfaceView native renderer (reusing the desktop WGSL NTSC/CRT/Bisqwit
stack) is documented as the deeper-architecture follow-up in the plan. Verified on
the Z Fold 7: FX cycles None->Scanlines->CRT and the CRT scanline/mask/vignette
applies to the NES image.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ery) Register a PowerManager.OnThermalStatusChangedListener (API 29+): when the device reports THERMAL_STATUS_SEVERE+, cancel fast-forward (emulator.turbo = false). The NES is light, but uncapped fast-forward can heat a phone; this only adjusts host pacing, never emulation speed, so determinism is unaffected. Unregistered in onDestroy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cut the v1.8.0 "Android" platform release: bump the workspace + all crate version pins 1.7.1 -> 1.8.0, and finalize the CHANGELOG [Unreleased] -> [1.8.0] "Android" section (the full Android cut: foundation, audio, input, save-states/SRAM/recents, QoL pause/FF/mute, foldable+immersive UI, freemium $2.99 unlock + 10-min demo, AGSL CRT/scanline filters, thermal backoff, verified release AAB). The pure-Rust core is byte-identical on ARM (additive host only), so AccuracyCoin 100% (139/139) and every desktop gate are unchanged. The full wgpu-SurfaceView native renderer + desktop WGSL shader stack remain the documented next increment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1.8.0 "Android" — beta.1 (Workstream A: foundation & build)
The first platform (not accuracy) release begins. RustyNES ports to Android the same way it ports to the browser: the pure-Rust, byte-identical core is untouched, and only a new host is written. AccuracyCoin 100% (139/139) and every host gate stay green; the new Android CI only proves the build links.
This PR is the foundation the remaining betas build on. It is fully verifiable on host CI and the NDK cross-build is verified end-to-end.
What's in it
New crate
rustynes-mobile— the shared, platform-agnostic UniFFI bridge overrustynes_core::Nes.NesControllerexposes the typed control surface (load ROM from bytes, per-portButtonsmask + individual buttons, run/step frame, drain audio,.rnssave/restore, cartridgeinfo). UniFFI generates the Kotlin (Android) and Swift (iOS, v1.9.0) bindings from#[uniffi::export].std, host-testable (6 unit tests boot a synthetic NROM and round-trip input + save-state — no commercial ROM), and adds no new determinism surface. Reused verbatim by the future iOS host.New crate
rustynes-android— the Android platform crate, carrying only the narrow JNI/surface/audio hot glue UniFFI can't express (theANativeWindow→ wgpu handoff + audio sink, filled in across beta.2/beta.3) plus theandroid_mainentry for the beta.1 winit+wgpu+egui spike. Everything Android-specific is behind#[cfg(target_os = "android")], so on host it's a near-empty lint-only shell. It is the one mobile crate that carriesunsafe(each site// SAFETY:-noted).android/Gradle module — a Material-3 Jetpack Compose first-boot shell: opens a ROM via the Storage Access Framework (no path, raw bytes → the core), runs the emulator through the generatedNesController, blits each RGBA frame to aBitmap, and routes an on-screen D-pad/A/B/Select/Start overlay + hardware gamepad keys into the single late-latched controller mask.cargo-ndk+uniffi-bindgenare wired as Gradle build steps; release targets an AAB (minSdk 26,targetSdk 35, arm64 ship ABI + x86_64, R8 + proguard keeping JNA + the generated bindings).CI —
.github/workflows/android.yml: installs the Android Rust targets + cargo-ndk, cross-compiles both crates for arm64 + x86_64 against the NDK, generates the Kotlin bindings (smoke), checks 16 KB ELF alignment on the shipped arm64.so, best-effort-bundles the AAB. Not a required check — accuracy is gated only on host CI.Docs —
docs/android.md(architecture, build, signing, CI, the remaining-beta map) + ADR 0024 (the UniFFI-bridge + hybrid wgpu/Compose-host decision). CHANGELOG[Unreleased].Verified this session
cargo build --workspace,cargo test -p rustynes-mobile(6 pass),cargo clippy -D warnings(whole workspace, via pre-commit),cargo fmt --check,RUSTDOCFLAGS=-D warnings cargo doc, markdownlint.NesController(runFrame(): ByteArray,setButton(port, NesButton, Boolean),saveState()/loadRom(),info(): RomInfo); the Compose shell's enum/method/package usage matches the generated bindings exactly.aarch64-linux-androidbuildslibrustynes_mobile.so+librustynes_android.sowarning-free.Hard contract held
Determinism untouched; input converges on the one late-latched mask; the
.rnsformat is unchanged, so desktop⇄Android save-state/.rnmcross-play stays valid; no commercial ROMs bundled (SAF only).Remaining betas (on-device work, tracked in
docs/android.md)SurfaceView+ surface-loss lifecycle + Choreographer pacing.rns/SRAM infilesDir; save-on-backgroundNetplay, RetroAchievements, and Lua are deferred to a follow-up mobile point release per the locked MVP.
🤖 Generated with Claude Code