Release replan (maintainer, 2026-06-23): the Google Play production launch is deferred to v2.1.0. The v1.8.x line keeps shipping as GitHub-sideload builds, but the Play production launch is held until after v2.0.0 "Timebase" — the Android app is finalized across v2.0.1–v2.0.4 on the v2.0.0 core and launched jointly with iOS at v2.1.0. RustyNES is permanently open-source and income-free (ADR 0035): the Play-Store launch material in this doc describes a free-app launch, not a near-term step. See
to-dos/plans/v2.0.x-mobile-finalization-plan.md.Status: v1.8.0 "Android" — a working emulator, verified on hardware. The shared
rustynes-mobilebridge, therustynes-androidplatform crate, the Gradle/Compose app, and the Android CI gate are in place, and the app has been run end-to-end on a Samsung Galaxy Z Fold 7 (Android 16, arm64): nestest passes all 14 CPU suites (the core is cycle-accurate on ARM), audio plays throughAudioTrack, touch + hardware-gamepad input drive P1, save-states / SRAM / a recent-ROMs library persist, and the app runs full-screen on both the cover and the unfolded inner display with the system bars auto-hidden. Shipped so far: core + video (RGBABitmapblit) + audio + touch/gamepad + save-states/auto-resume + pause/fast-forward/mute + SAF ROM library. Next increment (documented, not yet built): the wgpuSurfaceViewrender path + the NTSC/CRT shader stack (Workstream B/F-shaders) — the currentBitmaprenderer is complete and performant, so wgpu is a fidelity/perf upgrade rather than a blocker. See the release train into-dos/plans/v1.8.0-android-plan.md. Per the locked MVP, netplay / RetroAchievements / Lua remain deferred to a follow-up mobile point release.
RustyNES ports to Android the same way it ports to the browser: the pure-Rust, byte-identical core is unchanged, and only a new host is written. Nothing here touches the chip stack or the emitted-frame/sample contract, so AccuracyCoin 139/141 (the two newest upstream PPU tests are known gaps) and the oracles stay green on host CI; the Android CI only proves the build links + (eventually) an instrumented smoke boot.
┌──────────────────────────── android/ (Gradle module) ────────────────────────────┐
│ Jetpack Compose shell (Kotlin) │
│ • SAF ROM picker, touch overlay, settings, save-state manager │
│ • drives the emulator through the UniFFI-generated NesController │
└───────────────┬───────────────────────────────────────────────┬───────────────────┘
│ generated Kotlin bindings (UniFFI) │ JNI (surface/audio)
┌───────▼─────────────────────┐ ┌────────▼───────────────────┐
│ rustynes-mobile (shared) │ │ rustynes-android │
│ typed control surface: │ │ hot glue UniFFI can't do: │
│ load_rom / run_frame / │ │ ANativeWindow → wgpu, │
│ set_button / save_state │ │ the AAudio sink, │
│ #[uniffi::export] │ │ android_main (spike) │
└───────────────┬─────────────┘ └────────────┬────────────────┘
└──────────────────┬─────────────────────────┘
┌─────────▼──────────┐
│ rustynes-core │ byte-identical cycle-accurate
│ (+ cpu/ppu/apu/...) │ core — never touched
└────────────────────┘
crates/rustynes-mobile— the platform-agnostic bridge. Owns the typed control surface overrustynes_core::Nesand lets UniFFI generate the Kotlin (Android) and Swift (iOS, v1.9.0) bindings from the#[uniffi::export]surface.std, host-testable, no new determinism surface. Shared verbatim with the future iOS host.crates/rustynes-android— the Android platform crate. Carries only the narrow JNI/surface/audio glue UniFFI can't express, all behind#[cfg(target_os = "android")]. Holds theandroid_mainentry for the beta.1 winit+wgpu+egui spike. This is the one mobile crate that carriesunsafe.android/— the Gradle module: a Material-3 Jetpack Compose shell that drives the core through the generatedNesController.cargo-ndkproduces the per-ABI.so; UniFFI generates the Kotlin bindings; both are wired as Gradle build steps.
See ADR 0024 for the binding and rendering decisions (UniFFI bridge plus the hybrid wgpu/Compose host).
- Input converges on the single late-latched
Buttonsmask per port, exactly as the desktop and wasm hosts do — touch and hardware gamepad are indistinguishable to the core, so TAS/netplay/rollback are unaffected. - Save-states use the platform-independent
.rnsformat, so a state saved on desktop loads on Android and a.rnmTAS replays bit-identically — desktop⇄ Android cross-play stays valid. - No commercial ROMs are ever bundled. ROM bytes come exclusively from the
Storage Access Framework document picker (no path, raw bytes →
Nes::from_rom). Thefossbuild declares no storage/network permissions and collects nothing; theplaybuild adds only the optional free Google Play services (Play Games, Cast, Play Integrity, in-app update, cloud save), which use the network and Google-account data — with no ads, no tracking, and no billing.
# Rust targets (arm64 ships; x86_64 is the emulator/CI ABI)
rustup target add aarch64-linux-android x86_64-linux-android
# cargo-ndk drives the NDK cross-compile
cargo install cargo-ndk --version "^3"
# Android SDK + NDK r27+ (16 KB-aligned .so, required for Android 15+ on Play).
# Point cargo-ndk at the NDK:
export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/<version> # e.g. 29.0.14206865# Cross-compile both crates for the shipped ABIs into a chosen output dir:
cargo ndk -t arm64-v8a -t x86_64 --platform 26 \
build --release -p rustynes-mobile -p rustynes-android
# Generate the Kotlin bindings from the built arm64 cdylib (API is
# target-independent, so any built library is a valid source of truth):
cargo run -p rustynes-mobile --bin uniffi-bindgen -- \
generate --library target/aarch64-linux-android/release/librustynes_mobile.so \
--language kotlin --out-dir target/uniffi-kotlinThe control surface is also exercised on host: cargo test -p rustynes-mobile
boots a synthetic NROM image, runs frames, round-trips a save-state, and checks
the input-mask + port-validation logic — all without any commercial ROM.
The Gradle module wires cargo ndk (→ app/src/main/jniLibs/<abi>/) and
uniffi-bindgen (→ generated Kotlin) as preBuild dependencies, so a normal
Gradle invocation rebuilds the native libraries and bindings as needed:
cd android
gradle wrapper --gradle-version 8.11.1 # first time, materialises ./gradlew
./gradlew :app:installDebug # alias -> installFossDebug (the default foss flavor)
./gradlew :app:assembleFossDebug # foss (F-Droid/sideload) debug APK — no Google SDKs
./gradlew :app:assemblePlayDebug # play (Google Play) debug APK
./gradlew :app:bundleFossRelease # foss release AAB (unsigned unless keystore.properties)
./gradlew :app:bundlePlayRelease # play release AABv2.0.1 (ADR 0025): the
distributionflavor dimension. The build now splits into afossflavor (default) and aplayflavor, so AGP names the variant tasks per-flavor (assembleFossDebug,bundlePlayRelease, …). The bareinstallDebugis kept as an alias toinstallFossDebugso the existing dev/CI command is undisturbed; the aggregateassembleDebug/bundleReleaseanchors still fan out to both flavors. See "No monetization (ADR 0035)" below.
minSdk 26 (AAudio floor), targetSdk 35 (Play mandate), compileSdk 35. Ship
ABI is arm64-v8a; x86_64 is included for the emulator. The release build runs
R8 (proguard-rules.pro keeps the JNA + generated-binding classes).
Release signing reads a gitignored android/keystore.properties
(storeFile, storePassword, keyAlias, keyPassword) — this is the Play App
Signing upload key only; Play manages the app signing key. When the file is
absent, the release build stays unsigned so CI bundleRelease still links.
Emulators are allowed on Google Play (RetroArch / Dolphin / PPSSPP ship there) given no bundled ROMs and no ROM downloader — RustyNES is compliant by design. A guaranteed sideload / F-Droid + GitHub-Releases channel is maintained so the project never depends solely on Play (also the home for the optional egui-debugger power-user build).
RustyNES is permanently open-source and income-free — see
ADR 0035. The Android app is a
free download with every feature unconditionally available: no ads, no tracking,
no demo/time gate, no in-app purchase, and no paid unlock. There is no LicenseManager
paywall, no full_unlock product, and no freemium ad layer — the app that was
previously planned as an ad-supported / one-time-unlock freemium model (an earlier
docs/monetization/ design set and the rustynes-monetization crate) was never
shipped to a store, and that plan and crate have since been removed entirely.
foss / play flavor split (ADR 0025, amended by ADR 0035). The build still
splits into a foss flavor (default — no Google SDKs; the F-Droid +
GitHub-sideload artifact) and a play flavor (optional free Google Play
services — Play Games achievements, Cast, Play Integrity, in-app update, cloud
save). Both flavors are free with identical gameplay features; the play flavor
carries no billing or ad SDKs. See
../to-dos/plans/v2.0.x-mobile-finalization-plan.md
for the historical planning record.
.github/workflows/android.yml runs on changes to the mobile crates / android/:
it installs the Android targets + cargo-ndk, cross-compiles both crates for
arm64 + x86_64, generates the Kotlin bindings (smoke), checks 16 KB ELF
alignment on the shipped arm64 .so, and best-effort-bundles the AAB. It is
not a required check — accuracy is gated only on host CI.
| Workstream | State |
|---|---|
| A Foundation / build (bridge + platform crate + Gradle/Compose + CI) | Done — on-device |
C Audio (AudioTrack sink, blocking-write pacing) |
Done — on-device |
| D Input (touch overlay + hardware gamepad → one late-latched mask) | Done — touch on-device |
| E Save-states / SRAM / auto-resume / persistable ROM library | Done — save/load on-device |
| F QoL (pause / fast-forward / mute) + responsive/foldable/immersive UI | Done — on-device |
| G Play packaging (release AAB, signing config, distribution) | Release AAB verified (R8, arm64-only); signing is maintainer-manual |
| M Freemium $2.99 unlock + 10-min demo gating | Removed entirely (ADR 0035) — RustyNES is permanently open-source and income-free; every feature ships unconditionally available |
B wgpu SurfaceView + surface-loss lifecycle |
Next increment (Bitmap blit ships now) |
| F shaders / palettes / per-game DB / TAS UI | Next increment (depends on B for shaders) |
Room-code / online netplay (v1.8.7). Mobile netplay landed past the locked
MVP: alongside direct-IP / LAN play, the app hosts and joins internet matches
by sharing a short 6-char room code, traversing carrier-grade NAT (CGNAT) via
STUN discovery + UDP hole punching. The bridge exposes
np_host_room(num_players, NpNetConfig) -> room code / np_join_room(room_code, NpNetConfig); NpNetConfig carries the signaling_url, STUN servers, and an
optional TURN trio, overridable in Settings. The same signaling + coturn stack in
deploy/ serves both this path and the browser path; the app ships with a
placeholder relay URL until the maintainer hosts it. The cone-NAT hole-punch
is end-to-end and loopback/mock-verified in CI; the symmetric-NAT TURN
relay-transport hand-off is now wired (UdpTransport::from_relay + NpStatus. relayed, mock-TURN-verified by relay_loopback). Only a live verify — a
hosted coturn + two real cellular/symmetric-NAT devices — remains. See
docs/netplay-webrtc.md §2.5 and the Mobile room-code checklist in
deploy/README.md.
Deferred to a follow-up mobile point release (per the locked MVP): RetroAchievements (Compose login UI over rcheevos) and Lua scripting. The egui debugger stays an optional sideload power-user overlay.