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
16 changes: 11 additions & 5 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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).
### Removed

- **Dead `mc-r1-dmc-abort-probe` diagnostic feature (v2.0.1 housekeeping).** The
Expand Down
81 changes: 68 additions & 13 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -340,19 +371,19 @@ dependencies {
// PlayGames.kt). DISTINCT from RetroAchievements (rustynes-ra). The PGS sign-in
// also reads the manifest <meta-data app_id>, 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.
Expand All @@ -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)
}
}
}
}
50 changes: 50 additions & 0 deletions android/app/src/foss/java/com/doublegate/rustynes/Billing.kt
Original file line number Diff line number Diff line change
@@ -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) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 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
import android.view.View

/** 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) {}

/**
* FOSS stand-in for the CAF `MediaRouteButton`: a blank, inert [View]. The FOSS
* flavor links no `androidx.mediarouter` / `com.google.android.gms.cast`, and the
* only caller in `src/main` is gated behind the default-off
* `BuildConfig.CHROMECAST_ENABLED`, so this view is never actually shown here.
* Returning a real (empty) `View` keeps the shared `AndroidView` factory's
* non-null contract without leaking any proprietary Cast type into `src/main`.
*/
fun mediaRouteButton(context: Context): View = View(context)
}
84 changes: 84 additions & 0 deletions android/app/src/foss/java/com/doublegate/rustynes/CloudSave.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// 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,
) {
// A `data class` with `ByteArray` members needs a hand-written equals/hashCode:
// the auto-generated ones compare arrays by reference, which is both surprising and
// divergent. We implement genuine structural equality (contentEquals/contentHashCode
// on the byte fields) so two SaveConflicts carrying equal values compare equal — the
// sane contract the shared conflict-picker UI can rely on regardless of flavor.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SaveConflict) return false
return conflictId == other.conflictId &&
sha == other.sha &&
slot == other.slot &&
localBytes.contentEquals(other.localBytes) &&
cloudBytes.contentEquals(other.cloudBytes)
}

override fun hashCode(): Int {
var result = conflictId.hashCode()
result = 31 * result + sha.hashCode()
result = 31 * result + slot.hashCode()
result = 31 * result + localBytes.contentHashCode()
result = 31 * result + cloudBytes.contentHashCode()
return result
}
}

/** 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)
}
}
Loading