From ca1a16a7aeb4becb96bf762ae22f0ed9192c13c2 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 8 Jul 2026 02:39:37 -0400 Subject: [PATCH 1/3] feat(android)!: v2.0.1 core re-port onto Timebase + foss/play flavor-split scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut the first release of the Android mobile-finalization re-port train (v2.0.1 -> v2.0.4) onto the v2.0.0 "Timebase" core, and land the STRUCTURAL start of the foss/play product- flavor split (ADR 0025). Every change here is host/Android-side only: the deterministic chip stack (rustynes-core/-cpu/-ppu/-apu/-mappers) is byte-identical, so host CI accuracy is unchanged by construction (AccuracyCoin 139/141). Version bump ------------ android/app: versionCode 20000 -> 20001, versionName "2.0.0" -> "2.0.1". Movie-epoch warning on the mobile bridge (ADR 0028) --------------------------------------------------- rustynes-mobile's NesController::movie_play now peeks rustynes_core::recorded_before_v2_timebase(&bytes) and, for a pre-v2.0.0 "Timebase" .rnm, queues a drainable host warning via the new NesController::drain_warnings() -- mirroring the desktop + wasm frontends' identical notice. A pre-v2.0.0 movie still replays its recorded INPUT faithfully, but the host can now tell the user that exact framebuffer/audio reproduction is not guaranteed across the engine-timebase boundary (the one-clock / every-cycle-bus-access rewrite changed the sub-frame timing the old movie was captured against). A malformed/short header is treated as "not pre-v2" (deserialize already succeeded, so it is a current-epoch movie). A pre-v2.0.0 .rns restore already maps to a clean MobileError::SaveState (the core's restore rejects the bumped FORMAT_VERSION) -- no crash, no code change needed. Pure host-shell wiring: a new `warnings: Vec` field on Inner, initialised in `new`, cleared on `load_rom`, drained by `drain_warnings`; the deterministic core never reads it. Extended the fm2 replay test to assert a current-epoch .rnm raises no warning. foss/play product-flavor split -- STRUCTURAL start (ADR 0025) ------------------------------------------------------------ The Android build gains a `distribution` flavor dimension so RustyNES can eventually ship a genuinely Google-free, ad-free, tracking-free artifact to F-Droid (a BuildConfig flag gates code EXECUTION, not manifest merging / packaging -- only a product flavor produces a truly clean build). This commit does the structural work; the monetization ad glue (AppLovin MAX + RevenueCat) stays dormant and is the v2.1.0 step. - Flavor dimension: `foss` (isDefault = true) + `play`, with PLAY_BUILD set per-flavor (false/true) instead of in defaultConfig. The per-feature runtime gates (PGS_ENABLED, CHROMECAST_ENABLED, PLAY_INTEGRITY_ENABLED, ...) stay in defaultConfig as the in-`play` toggles the maintainer flips at launch. - Every proprietary Google-Play SDK moved from unconditional implementation(...) to playImplementation(...): Play Billing, the Cast Application Framework, Play Games v2, Play Integrity, and in-app update/review. The `foss` variant links NONE of them. - Façade: the six Google-touching glue classes moved to src/play/ (LicenseManager, PlayGamesManager, CloudSaveManager, IntegrityManager, PlayUpdatesManager, ChromecastSender + RustyNesCastOptionsProvider), each with a byte-compatible no-op twin in src/foss/ that carries the identical public surface and imports no com.google.* / com.android.billingclient.*. Shared pure types (PgsIds, IntegrityVerdict, DEMO_SESSION_SECONDS) moved to a new src/main/PlayFacadeShared.kt so MainActivity resolves them in both flavors. MainActivity now reads a flavor-neutral LicenseManager.priceLabel (a String) instead of the Google ProductDetails type, so src/main links no Play SDK -- the only Google-typed leak into the shared shell. The AOSP Presentation-API CastManager (Cast.kt) is NOT proprietary and stays in src/main. - Manifest: the Cast-framework OPTIONS_PROVIDER and Play-Games APP_ID meta-data moved to a new src/play/AndroidManifest.xml overlay so they never merge into the `foss` manifest (the future AD_ID permission + AdMob/AppLovin entries land there too, never in main). - installDebug -> installFossDebug alias (afterEvaluate, guarded against the AGP anchor) so the existing dev/on-device command is undisturbed; the aggregate assembleDebug / bundleRelease anchors AGP still creates are left as-is. - baselineprofile (com.android.test module targeting the now-flavored :app) gains missingDimensionStrategy("distribution", "foss") so variant-matching / Gradle sync resolve unambiguously. - android.yml now bundles BOTH flavors (bundleFossRelease + bundlePlayRelease) and its artifact glob widened to outputs/bundle/*/*.aab. Docs ---- - Created to-dos/v1.8.x-on-device-verification.md (the manual on-device checklist the mobile-finalization plan repeatedly cites but which did not exist): ROM import, save-state/rewind, MFi/hardware controllers, audio + interruptions, SMB/Zelda determinism smoke, the pre-v2.0.0 .rns/.rnm handling, the accuracy figure to match (139/141), and the both-flavor verification gate. - docs/android.md: flavored build commands + the "structural start landed in v2.0.1" note. docs/STATUS.md: an in-progress v2.0.1 banner. CHANGELOG [Unreleased]: the full entry. The `!` marks the save-state/movie epoch boundary this train rides on (ADR 0028): a pre-v2.0.0 .rnm now surfaces a warning and a pre-v2.0.0 .rns fails cleanly on mobile, matching the desktop/wasm behaviour. Verified locally: cargo fmt --all --check, cargo clippy -p rustynes-mobile --all-targets -D warnings, cargo check -p rustynes-mobile -p rustynes-monetization, cargo check --workspace, cargo test -p rustynes-mobile (the movie/drain_warnings test), and markdownlint on every touched .md -- all green. The Android NDK cross-build + both-flavor Gradle packaging is deferred to android.yml CI (no local NDK/emulator), per the plan. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/android.yml | 16 ++- CHANGELOG.md | 34 +++++- android/app/build.gradle.kts | 81 +++++++++++--- .../java/com/doublegate/rustynes/Billing.kt | 50 +++++++++ .../doublegate/rustynes/ChromecastSender.kt | 24 +++++ .../java/com/doublegate/rustynes/CloudSave.kt | 67 ++++++++++++ .../java/com/doublegate/rustynes/Integrity.kt | 20 ++++ .../java/com/doublegate/rustynes/PlayGames.kt | 33 ++++++ .../com/doublegate/rustynes/PlayUpdates.kt | 31 ++++++ android/app/src/main/AndroidManifest.xml | 24 +---- .../com/doublegate/rustynes/MainActivity.kt | 9 +- .../doublegate/rustynes/PlayFacadeShared.kt | 83 ++++++++++++++ .../java/com/doublegate/rustynes/Billing.kt | 19 +++- .../doublegate/rustynes/ChromecastSender.kt | 5 + .../java/com/doublegate/rustynes/CloudSave.kt | 5 + .../java/com/doublegate/rustynes/Integrity.kt | 18 ++-- .../java/com/doublegate/rustynes/PlayGames.kt | 43 ++------ .../com/doublegate/rustynes/PlayUpdates.kt | 4 + android/baselineprofile/build.gradle.kts | 8 ++ crates/rustynes-mobile/src/lib.rs | 47 ++++++++ docs/STATUS.md | 18 ++++ docs/android.md | 33 ++++-- to-dos/v1.8.x-on-device-verification.md | 101 ++++++++++++++++++ 23 files changed, 671 insertions(+), 102 deletions(-) create mode 100644 android/app/src/foss/java/com/doublegate/rustynes/Billing.kt create mode 100644 android/app/src/foss/java/com/doublegate/rustynes/ChromecastSender.kt create mode 100644 android/app/src/foss/java/com/doublegate/rustynes/CloudSave.kt create mode 100644 android/app/src/foss/java/com/doublegate/rustynes/Integrity.kt create mode 100644 android/app/src/foss/java/com/doublegate/rustynes/PlayGames.kt create mode 100644 android/app/src/foss/java/com/doublegate/rustynes/PlayUpdates.kt create mode 100644 android/app/src/main/java/com/doublegate/rustynes/PlayFacadeShared.kt rename android/app/src/{main => play}/java/com/doublegate/rustynes/Billing.kt (84%) rename android/app/src/{main => play}/java/com/doublegate/rustynes/ChromecastSender.kt (96%) rename android/app/src/{main => play}/java/com/doublegate/rustynes/CloudSave.kt (96%) rename android/app/src/{main => play}/java/com/doublegate/rustynes/Integrity.kt (91%) rename android/app/src/{main => play}/java/com/doublegate/rustynes/PlayGames.kt (75%) rename android/app/src/{main => play}/java/com/doublegate/rustynes/PlayUpdates.kt (94%) create mode 100644 to-dos/v1.8.x-on-device-verification.md diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f5ba658e5..c602cb07b 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -96,7 +96,7 @@ jobs: target/x86_64-linux-android/release/librustynes_*.so gradle-bundle: - name: Gradle bundleRelease (best-effort packaging) + name: Gradle bundle foss+play release (best-effort packaging) runs-on: ubuntu-latest # Packaging is informational: the cross-build job above is the real link # gate. R8/Compose packaging pulls the full Android SDK, so a transient @@ -131,12 +131,18 @@ jobs: build-scan-publish: true build-scan-terms-of-use-url: "https://gradle.com/terms-of-service" build-scan-terms-of-use-agree: "yes" - - name: Bundle release AAB (unsigned) + # v2.0.1 (ADR 0025): the `distribution` flavor split builds BOTH channel AABs. + # `bundleFossRelease` is the F-Droid / sideload artifact (no Google SDKs); + # `bundlePlayRelease` is the Google Play artifact. Building both here proves the + # split compiles + packages for each flavor. The aggregate `bundleRelease` anchor + # would also fan out to both, but the explicit tasks make the intent legible. + - name: Bundle release AABs (foss + play, unsigned) working-directory: android - run: ./gradlew :app:bundleRelease --no-daemon - - name: Upload AAB + run: ./gradlew :app:bundleFossRelease :app:bundlePlayRelease --no-daemon + - name: Upload AABs uses: actions/upload-artifact@v7 with: name: rustynes-aab - path: android/app/build/outputs/bundle/release/*.aab + # Flavored output dirs: outputs/bundle/fossRelease/*.aab + .../playRelease/*.aab + path: android/app/build/outputs/bundle/*/*.aab if-no-files-found: warn diff --git a/CHANGELOG.md b/CHANGELOG.md index 12584a971..0f0b23264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -*Nothing yet — the next planned milestone is **v2.0.1** (the first release of the -Android mobile-finalization re-port train onto the v2.0.0 core; see -`to-dos/ROADMAP.md`).* +### Android — v2.0.1 core re-port onto Timebase + the start of the `foss`/`play` split + +- **Android re-ported onto the v2.0.0 "Timebase" core.** The `versionCode`/`versionName` + bump to `20001` / `2.0.1` cuts the first release of the Android mobile-finalization + re-port train (see `to-dos/ROADMAP.md`). The deterministic chip stack is byte-identical, + so host CI accuracy is unchanged (**AccuracyCoin 139/141**). +- **Movie-epoch warning on the mobile bridge (ADR 0028).** `rustynes-mobile`'s + `NesController::movie_play` now peeks `rustynes_core::recorded_before_v2_timebase` and, for + a pre-v2.0.0 `.rnm`, queues a drainable host warning (new `NesController::drain_warnings`) + — mirroring the desktop + wasm frontends. Input replay still proceeds, but the host can now + tell the user that exact framebuffer/audio reproduction is not guaranteed across the + engine-timebase boundary. A pre-v2.0.0 `.rns` restore already maps to a clean + `MobileError::SaveState` (no crash). Host-shell wiring only; the core is untouched. +- **Structural start of the `foss` / `play` Android product-flavor split (ADR 0025).** Added a + `distribution` flavor dimension — `foss` (default, `isDefault = true`) + `play` — with + `PLAY_BUILD` set per-flavor. Every proprietary Google-Play SDK (Play Billing, the Cast + Application Framework, Play Games v2, Play Integrity, in-app update/review) moved from + unconditional `implementation(…)` to **`playImplementation(…)`**, so the `foss` variant links + **none** of them. The glue that touches those SDKs (`LicenseManager`, `PlayGamesManager`, + `CloudSaveManager`, `IntegrityManager`, `PlayUpdatesManager`, `ChromecastSender`) moved to + `src/play/` with byte-compatible **no-op façades in `src/foss/`**; shared pure types + (`PgsIds`, `IntegrityVerdict`, `DEMO_SESSION_SECONDS`) moved to `src/main`; `MainActivity` + now reads a flavor-neutral `LicenseManager.priceLabel` instead of the Google `ProductDetails` + type, so `src/main` links no Play SDK. The Cast-framework OPTIONS_PROVIDER + Play-Games APP_ID + manifest meta-data moved to `src/play/AndroidManifest.xml` so they never merge into the + `foss` manifest. An `installDebug` alias forwards to `installFossDebug` so the dev/CI command + is unchanged; `android.yml` now bundles both flavors. The monetization / ad glue stays dormant + (the AppLovin/RevenueCat wiring is the v2.1.0 step). The AOSP Presentation-API `CastManager` + is not a proprietary SDK and stays shared in `src/main`. +- **Docs:** added the previously-dangling `to-dos/v1.8.x-on-device-verification.md` (the manual + on-device checklist the mobile plan cites). ## [2.0.0] - 2026-07-03 - "Timebase" (one-clock master-clock rewrite + Vs. DualSystem) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 204905d19..f62c61d0d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -39,15 +39,14 @@ android { applicationId = "com.doublegate.rustynes" minSdk = 26 // AAudio floor. targetSdk = 36 // Play mandate (Android 16) from 2026-08-31. - versionCode = 20000 // 2.0.0 - versionName = "2.0.0" + versionCode = 20001 // 2.0.1 + versionName = "2.0.1" // No abiFilters here — set per buildType so release ships arm64 only // while debug keeps x86_64 for the emulator. - // PLAY_BUILD gates the freemium (demo timer + persistence locks + Billing). - // Default false → sideload/GitHub/dev builds are full-featured; the Google - // Play AAB sets it true (v1.8.8 "Atlas" launch — postponed from v1.8.6). See - // the v1.8.0 plan's monetization timing. - buildConfigField("boolean", "PLAY_BUILD", "false") + // PLAY_BUILD moved to the `distribution` product flavors below (v2.0.1, ADR + // 0025): it is now `false` for `foss` and `true` for `play`, so the flag is + // a compile-time property of the channel rather than a defaultConfig default. + // It still gates the freemium (demo timer + persistence locks + Billing). // CHROMECAST_ENABLED gates the experimental Cast Application Framework // (CAF) sender path (v1.8.7, #38) — a ~20-30fps SPECTATOR mirror to a // custom Web Receiver, distinct from the primary low-latency Presentation @@ -78,6 +77,34 @@ android { buildConfigField("long", "INTEGRITY_CLOUD_PROJECT_NUMBER", "0L") } + // v2.0.1 (ADR 0025): the `distribution` product-flavor split. Two channels off + // the one byte-identical Timebase core: + // - `foss` (default) — the pure-Rust emulator ONLY: no Google Play SDKs, no + // ads, no tracking. This is the F-Droid + GitHub-Releases sideload artifact. + // Its `src/foss/` source set supplies no-op façades for every proprietary + // subsystem so `MainActivity` links without any `com.google.*` / + // `com.android.billingclient.*` dependency (moved to `playImplementation`). + // - `play` — everything proprietary (Billing, Cast framework, Play Games v2, + // Play Integrity, in-app update/review; AppLovin/RevenueCat ads land here at + // v2.1.0). Google Play only. + // PLAY_BUILD is set per-flavor; the per-feature runtime gates (PGS_ENABLED, …) + // stay in defaultConfig as the in-`play` toggles the maintainer flips at launch. + // NOTE: this is the STRUCTURAL start of the split (v2.0.1). The monetization ad + // glue is still dormant (ADR 0025); only the existing proprietary SDK groups are + // flavor-scoped here. + flavorDimensions += "distribution" + productFlavors { + create("foss") { + dimension = "distribution" + isDefault = true + buildConfigField("boolean", "PLAY_BUILD", "false") + } + create("play") { + dimension = "distribution" + buildConfigField("boolean", "PLAY_BUILD", "true") + } + } + // Release signing reads `keystore.properties` (gitignored) or env vars; when // neither is present the release build stays unsigned so CI `bundleRelease` // still links and verifies. Play App Signing manages the app key; this is the @@ -327,11 +354,15 @@ dependencies { // Pinned at 8.0.0 here: Billing 9.x is an API-breaking major (the v1.8.8 Play // launch / Workstream P revisits the entitlement code), and this Atlas-foundation // pass is presentation/Gradle only — bumping it would touch Billing.kt/LicenseManager. - implementation("com.android.billingclient:billing-ktx:8.0.0") + // v2.0.1 (ADR 0025): PLAY-FLAVOR ONLY. `playImplementation` keeps these + // proprietary Google-Play SDKs out of the `foss` (F-Droid/sideload) artifact + // entirely — the `foss` variant links none of them (its `src/foss/` no-op + // façades stand in), so the clean channel has no Play Services / Billing / ads. + playImplementation("com.android.billingclient:billing-ktx:8.0.0") // Cast Application Framework sender (v1.8.7, #38). Linked but DORMANT: it does // nothing until CastContext is initialized, which only happens behind the // default-off BuildConfig.CHROMECAST_ENABLED flag (see ChromecastSender.kt). - implementation("com.google.android.gms:play-services-cast-framework:22.1.0") + playImplementation("com.google.android.gms:play-services-cast-framework:22.1.0") // v1.8.8 "Atlas" (Workstreams D+E): Play Games Services v2 — the cloud-save // Snapshots client (D), the PGS achievements + leaderboards clients (E), and the // PGS v2 auto-sign-in (GamesSignInClient). 21.0.0 is the current v2 SDK (the v1 @@ -340,19 +371,19 @@ dependencies { // PlayGames.kt). DISTINCT from RetroAchievements (rustynes-ra). The PGS sign-in // also reads the manifest , which is a maintainer-supplied // placeholder (@string/game_services_project_id) until the Play Games project lands. - implementation("com.google.android.gms:play-services-games-v2:21.0.0") + playImplementation("com.google.android.gms:play-services-games-v2:21.0.0") // v1.8.8 "Atlas" (Workstream L): Play Integrity API — the anti-tamper layer over // Billing. 1.6.0 (SafetyNet Attestation was turned down Jan 2025; this is the modern // replacement). Linked but DORMANT: no token is requested until behind the default- // off BuildConfig.PLAY_INTEGRITY_ENABLED flag (see Integrity.kt). Verdict decryption // needs the maintainer's linked Cloud project + server endpoint. - implementation("com.google.android.play:integrity:1.6.0") + playImplementation("com.google.android.play:integrity:1.6.0") // v1.8.8 "Atlas" (Workstream L): in-app updates (flexible) + in-app review. 2.1.0 // is the in-app-update floor for targeting Android 14+; review 2.0.2 is current. // These need NO Cloud project — they no-op gracefully on a sideloaded (non-Play) // install, so they are safe to call unconditionally (still flavor-gated for clarity). - implementation("com.google.android.play:app-update-ktx:2.1.0") - implementation("com.google.android.play:review-ktx:2.0.2") + playImplementation("com.google.android.play:app-update-ktx:2.1.0") + playImplementation("com.google.android.play:review-ktx:2.0.2") // v1.8.8 "Atlas" (Workstream J): pull the generated Baseline + Startup Profiles // from the Macrobenchmark module. The baselineprofile plugin wires the produced // `baseline-prof.txt` / `startup-prof.txt` into this variant's merged assets. @@ -370,3 +401,27 @@ baselineProfile { automaticGenerationDuringBuild = false dexLayoutOptimization = true } + +// v2.0.1 (ADR 0025): an unflavored `installDebug` convenience alias so the pre-split +// dev/on-device command keeps working after the `distribution` flavor dimension was +// added. Adding a flavor removes the bare `installDebug` task (AGP now names install +// tasks per-variant: `installFossDebug`, `installPlayDebug`, …) — and, unlike +// `assembleDebug` / `bundleRelease`, AGP does NOT create an aggregate `install*` +// anchor. `foss` is the default (`isDefault = true`), so this alias forwards the old +// `installDebug` name to the `foss` variant, leaving the on-device test workflow (and +// any script invoking `installDebug`) undisturbed. The aggregate `assembleDebug` / +// `bundleRelease` anchors AGP still creates (each fans out to every flavor) are left +// as-is — registering our own would collide with them. `afterEvaluate` is required +// because the flavored tasks are created during AGP's variant configuration, which +// runs after this build script's body. +afterEvaluate { + if (tasks.findByName("installDebug") == null) { + tasks.findByName("installFossDebug")?.let { fossInstall -> + tasks.register("installDebug") { + group = "install" + description = "Alias for installFossDebug (the default `foss` flavor)." + dependsOn(fossInstall) + } + } + } +} diff --git a/android/app/src/foss/java/com/doublegate/rustynes/Billing.kt b/android/app/src/foss/java/com/doublegate/rustynes/Billing.kt new file mode 100644 index 000000000..675e29812 --- /dev/null +++ b/android/app/src/foss/java/com/doublegate/rustynes/Billing.kt @@ -0,0 +1,50 @@ +// FOSS-FLAVOR SOURCE SET (v2.0.1, ADR 0025). No-op stand-in for the Play-Billing +// `LicenseManager` (the real one is in `src/play/.../Billing.kt`). This file links NO +// `com.android.billingclient.*` — that is the whole point of the FOSS / F-Droid split. +// +// The FOSS build has NO freemium / demo gate: there is nothing to purchase, so the app +// is simply full-featured. [isUnlocked] is therefore a constant `true`, every entitlement +// call is a no-op, and [priceLabel] is a never-shown placeholder (the unlock affordance +// is only drawn when `!unlocked`, which never happens here, and behind `PLAY_BUILD` +// elsewhere). The public surface is byte-for-byte what `MainActivity` calls on the `play` +// twin, so the shared `MainActivity` (src/main) compiles against either flavor unchanged. +package com.doublegate.rustynes + +import android.app.Activity +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** The one-time "Full Unlock" product id — unused in FOSS (no Billing), kept for parity. */ +const val FULL_UNLOCK_PRODUCT = "full_unlock" + +/** + * No-op FOSS entitlement manager. Always "unlocked" (the FOSS build ships every feature + * for free), so the shell's demo gate is inert. + */ +@Suppress("UNUSED_PARAMETER") +class LicenseManager(private val appContext: Context) { + + /** FOSS is always fully unlocked (no freemium). State-backed to mirror the `play` + * twin's Compose-observable surface, though it never changes here. */ + var isUnlocked by mutableStateOf(true) + private set + + /** Placeholder price label; never shown in FOSS (the unlock button is gated on + * `!isUnlocked`, which is never true). Mirrors the `play` twin's `String` surface. */ + val priceLabel: String + get() = "$2.99" + + /** No Play connection in FOSS. */ + fun connect() {} + + /** No entitlement to refresh in FOSS. */ + fun refreshEntitlement() {} + + /** No purchase flow in FOSS. */ + fun purchase(activity: Activity) {} + + /** Debug unlock toggle is meaningless when already permanently unlocked; no-op. */ + fun debugForceUnlocked(value: Boolean) {} +} diff --git a/android/app/src/foss/java/com/doublegate/rustynes/ChromecastSender.kt b/android/app/src/foss/java/com/doublegate/rustynes/ChromecastSender.kt new file mode 100644 index 000000000..07f529e0f --- /dev/null +++ b/android/app/src/foss/java/com/doublegate/rustynes/ChromecastSender.kt @@ -0,0 +1,24 @@ +// FOSS-FLAVOR SOURCE SET (v2.0.1, ADR 0025). No-op stand-in for the Cast-Application- +// Framework `ChromecastSender` (the real one is in `src/play/.../ChromecastSender.kt`). +// Links NO `com.google.android.gms.cast.*`, and there is NO `RustyNesCastOptionsProvider` +// here (the FOSS manifest omits the Cast OPTIONS_PROVIDER meta-data). The CAF spectator- +// mirror is a Play-Services feature, so it is simply absent in FOSS. NOTE: the AOSP +// Presentation-API `CastManager` (Cast.kt) is separate and stays in `src/main`, so +// same-device external-display mirroring still works in FOSS. +package com.doublegate.rustynes + +import android.content.Context + +/** No-op FOSS Chromecast sender: never casts; every frame push is dropped. */ +@Suppress("UNUSED_PARAMETER") +class ChromecastSender(context: Context) { + + /** Never casting in FOSS. */ + val isCasting: Boolean = false + + fun register() {} + + fun unregister() {} + + fun sendFrame(indexBytes: ByteArray) {} +} diff --git a/android/app/src/foss/java/com/doublegate/rustynes/CloudSave.kt b/android/app/src/foss/java/com/doublegate/rustynes/CloudSave.kt new file mode 100644 index 000000000..41105c5ab --- /dev/null +++ b/android/app/src/foss/java/com/doublegate/rustynes/CloudSave.kt @@ -0,0 +1,67 @@ +// FOSS-FLAVOR SOURCE SET (v2.0.1, ADR 0025). No-op stand-in for the Play-Games-Snapshots +// `CloudSaveManager` (the real one is in `src/play/.../CloudSave.kt`). Links NO +// `com.google.android.gms.games.*`. Cloud save-sync is a Play-Games feature, so it is +// simply absent in FOSS; local save-states are unaffected (they go through the shared +// `SaveStateStore`). Public surface — including a Snapshot-free `SaveConflict` — matches +// the `play` twin so the shared conflict-picker UI in `MainActivity` compiles unchanged +// (it treats `SaveConflict` opaquely: it holds one and hands it back to `resolveConflict`). +package com.doublegate.rustynes + +import android.content.Context + +/** + * FOSS `SaveConflict`: the same public shape the `play` twin exposes, minus the internal + * Google `Snapshot` resolution handle (there is no Snapshots backend here). `MainActivity` + * only ever holds one of these and passes it back to [CloudSaveManager.resolveConflict], + * so its fields being inert is invisible. A conflict is never actually surfaced in FOSS. + */ +data class SaveConflict( + val conflictId: String, + val sha: String, + val slot: String, + /** The locally-modified copy's bytes (this device). */ + val localBytes: ByteArray, + /** The server copy's bytes — always empty in FOSS (no cloud). */ + val cloudBytes: ByteArray, +) { + // ByteArray members need structural equals/hashCode; generated component equality is + // reference-based otherwise. FOSS never compares SaveConflicts, but define them so the + // data class matches the play twin's contract and the compiler stays quiet. + override fun equals(other: Any?): Boolean = this === other + + override fun hashCode(): Int = System.identityHashCode(this) +} + +/** No-op FOSS cloud-save manager: never active; every push/pull/resolve reports failure. */ +@Suppress("UNUSED_PARAMETER") +class CloudSaveManager( + context: Context, + private val pgs: PlayGamesManager, +) { + /** Cloud sync is never active in FOSS. */ + fun isActive(settings: AppSettings): Boolean = false + + fun pushSlot( + sha: String, + slot: String, + settings: AppSettings, + onConflict: (SaveConflict) -> Unit = {}, + onDone: (Boolean) -> Unit = {}, + ) { + onDone(false) + } + + fun pullSlot( + sha: String, + slot: String, + settings: AppSettings, + onConflict: (SaveConflict) -> Unit = {}, + onDone: (Boolean) -> Unit = {}, + ) { + onDone(false) + } + + fun resolveConflict(conflict: SaveConflict, keepLocal: Boolean, onDone: (Boolean) -> Unit = {}) { + onDone(false) + } +} diff --git a/android/app/src/foss/java/com/doublegate/rustynes/Integrity.kt b/android/app/src/foss/java/com/doublegate/rustynes/Integrity.kt new file mode 100644 index 000000000..33cbb37a4 --- /dev/null +++ b/android/app/src/foss/java/com/doublegate/rustynes/Integrity.kt @@ -0,0 +1,20 @@ +// FOSS-FLAVOR SOURCE SET (v2.0.1, ADR 0025). No-op stand-in for the Play-Integrity +// `IntegrityManager` (the real one is in `src/play/.../Integrity.kt`). Links NO +// `com.google.android.play.core.integrity.*`. Anti-tamper attestation is a Play-only +// concern (and pointless on an open, freely-redistributable FOSS build), so this never +// requests a token and always reports [IntegrityVerdict.UNKNOWN] — which the app treats +// as "no signal". The shared `IntegrityVerdict` enum lives in `PlayFacadeShared.kt`. +package com.doublegate.rustynes + +import android.content.Context + +/** No-op FOSS integrity manager: never contacts Play Integrity; verdict is always UNKNOWN. */ +@Suppress("UNUSED_PARAMETER") +class IntegrityManager(context: Context) { + + fun prepareToken() {} + + fun request(requestHash: String, onToken: (String) -> Unit) {} + + fun evaluateStub(token: String): IntegrityVerdict = IntegrityVerdict.UNKNOWN +} diff --git a/android/app/src/foss/java/com/doublegate/rustynes/PlayGames.kt b/android/app/src/foss/java/com/doublegate/rustynes/PlayGames.kt new file mode 100644 index 000000000..a3740274a --- /dev/null +++ b/android/app/src/foss/java/com/doublegate/rustynes/PlayGames.kt @@ -0,0 +1,33 @@ +// FOSS-FLAVOR SOURCE SET (v2.0.1, ADR 0025). No-op stand-in for the Play-Games-Services +// `PlayGamesManager` (the real one is in `src/play/.../PlayGames.kt`). Links NO +// `com.google.*`. The shared `PgsIds` object lives in `src/main/.../PlayFacadeShared.kt`, +// so `MainActivity`'s `unlock(PgsIds.ACH_…)` calls still resolve — they are simply +// swallowed here. Public surface is identical to the `play` twin. +package com.doublegate.rustynes + +import android.app.Activity +import android.content.Context + +/** No-op FOSS Play-Games manager: never signed in, every achievement/leaderboard call + * dropped. There is no Google account surface in the FOSS build. */ +@Suppress("UNUSED_PARAMETER") +class PlayGamesManager(context: Context) { + + /** Always false: no PGS sign-in in FOSS. Read by the cloud-save layer + Settings. */ + var isSignedIn: Boolean = false + private set + + fun attachActivity(activity: Activity?) {} + + fun initialize() {} + + fun ensureSignedIn(onResult: (Boolean) -> Unit = {}) { + onResult(false) + } + + fun unlock(achievementId: String) {} + + fun increment(achievementId: String, delta: Int) {} + + fun submitScore(leaderboardId: String, score: Long) {} +} diff --git a/android/app/src/foss/java/com/doublegate/rustynes/PlayUpdates.kt b/android/app/src/foss/java/com/doublegate/rustynes/PlayUpdates.kt new file mode 100644 index 000000000..f2c2bcef8 --- /dev/null +++ b/android/app/src/foss/java/com/doublegate/rustynes/PlayUpdates.kt @@ -0,0 +1,31 @@ +// FOSS-FLAVOR SOURCE SET (v2.0.1, ADR 0025). No-op stand-in for the in-app-update / +// in-app-review `PlayUpdatesManager` (the real one is in `src/play/.../PlayUpdates.kt`). +// Links NO `com.google.android.play.core.*`. A sideload / F-Droid build updates through +// its own store (F-Droid client, GitHub release), so there is nothing to do here; the +// review prompt is likewise a Play-only affordance. `ActivityResultLauncher` / +// `IntentSenderRequest` are AndroidX (not Google Play), so the parameter type is shared. +package com.doublegate.rustynes + +import android.app.Activity +import android.content.Context +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest + +/** No-op FOSS updates/review manager: no in-app update, no review prompt. */ +@Suppress("UNUSED_PARAMETER") +class PlayUpdatesManager(context: Context) { + + /** Callback the shell sets to be told a flexible update finished downloading — never + * invoked in FOSS (no in-app updates). Kept for API parity with the `play` twin. */ + var onUpdateDownloaded: (() -> Unit)? = null + + fun checkForFlexibleUpdate(launcher: ActivityResultLauncher) {} + + fun resumeStalledUpdate() {} + + fun completeFlexibleUpdate() {} + + fun release() {} + + fun maybeRequestReview(activity: Activity) {} +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6afe2eb06..35ec4fa1d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -50,26 +50,10 @@ no longer called under targetSdk 36 on Android 16). --> - - - - - +