Files
Ferrous-Solitaire/docs/android_investigation.md
T
funman300 e6c67d03c2
Build and Deploy / build-and-push (push) Successful in 4m55s
chore: rename app from Solitaire Quest to Ferrous Solitaire
Replace all display-name occurrences across web pages, Rust source,
docs, and Cargo metadata. Update localStorage token key from sq_token
to fs_token. Tagline "Klondike Solitaire" retained as genre descriptor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:04:45 -07:00

248 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Android Port Investigation
> **Date:** 2026-04-28
> **Author:** Claude Code
> **Scope:** Feasibility analysis for porting Ferrous Solitaire to Android using cargo-mobile2
---
## Summary
A working Android port is feasible but not trivial. The core game logic (`solitaire_core`, `solitaire_sync`) compiles to Android without changes. Every other crate requires at least minor surgery. The biggest blockers are the `keyring` crate (no Android backend), the `kira`/`AudioManager` audio stack (`DefaultBackend` uses CPAL which targets desktop), and the `dirs` crate returning `None` on Android in its current usage. Touch input already has a solid foundation in `input_plugin.rs`. Estimated effort from a clean Android toolchain is **1218 developer-days** to reach a playable-but-rough state.
---
## 1. Bevy on Android — Current Status
Bevy's Android support is community-maintained via the `winit` backend and is usable but carries known rough edges as of the 0.15/0.16 generation.
**What works:**
- Basic rendering via Vulkan (through `wgpu`). OpenGL ES fallback is available for older devices.
- Touch input events: Bevy's `TouchInput` events and the `Touches` resource are populated from Android `MotionEvent`s via `winit`. The existing `touch_start_drag`, `touch_follow_drag`, `touch_end_drag`, and `handle_touch_stock_tap` systems in `input_plugin.rs` will function correctly — this was already written with multi-touch in mind and uses `TouchPhase::Started/Moved/Ended/Canceled` cleanly.
- Bevy UI (the `bevy::ui` module used for all overlays).
- `WindowResized` events fire correctly, so the layout system will recompute for any screen size.
**What does not work / needs attention:**
- **`bevy/dynamic_linking`**: The dynamic linking feature must be stripped from any Android build profile. Dynamic linking is a desktop-only development shortcut; Android requires static linking.
- **Fixed window size**: `main.rs` sets `resolution: (1280u32, 800u32)`. On Android the window is always the full display. This value is harmlessly overridden by the OS, but `min_width`/`min_height` constraints should be removed or set to 0 for Android to avoid Winit warnings.
- **`F11` fullscreen toggle** (`handle_fullscreen` in `input_plugin.rs`): `WindowMode::BorderlessFullscreen` is desktop-only. On Android it should be a no-op.
- **Keyboard shortcuts**: The entire `handle_keyboard_core`, `handle_keyboard_hint`, `handle_keyboard_forfeit` systems are desktop-only workflows. They will not crash, but they are dead code on Android. No touchscreen replacement for Undo (U), New Game (N), Draw (D/Space), Hint (H), Forfeit (G) exists yet — these need an on-screen UI.
- **`CursorPlugin`**: The custom cursor sprite plugin is irrelevant on Android (no cursor). Harmless to leave registered, but it uses `PrimaryWindow` cursor APIs that may panic or warn on Android.
**cargo-mobile2 integration for Bevy:**
The standard path is:
1. Install `cargo-mobile2`: `cargo install --locked cargo-mobile2`
2. Run `cargo mobile init` in the workspace root. This generates an `android/` directory with the Gradle project, `AndroidManifest.xml`, and JNI glue.
3. cargo-mobile2 targets the `solitaire_app` binary crate (the thin entry point). The generated `lib.rs` shim calls `android_main` via `bevy::winit`'s Android entry point.
4. The `solitaire_app` crate needs a `[lib]` target added alongside the existing `[[bin]]`, with `crate-type = ["cdylib"]`, used only when building for Android.
**Required `Cargo.toml` changes (workspace level):**
```toml
[target.'cfg(target_os = "android")'.dependencies]
# android_logger and ndk-glue wiring are handled by cargo-mobile2's generated shim.
# No direct ndk-glue dependency is needed in app code when using Bevy + cargo-mobile2.
```
**NDK version:** Android NDK r25c or r26 LTS is the tested range for `wgpu`/Vulkan on Android. NDK r27+ may work but has had compatibility reports with CPAL. Set `ANDROID_NDK_ROOT` to the NDK root; the minimum API level should be 26 (Android 8.0) for Vulkan stability.
---
## 2. Audio — `kira` + `DefaultBackend`
**The problem:**
`solitaire_engine/src/audio_plugin.rs` creates an `AudioManager<DefaultBackend>`. `kira`'s `DefaultBackend` is an alias for `CpalBackend`, which wraps CPAL. CPAL's Android backend uses OpenSL ES and is functional but historically fragile. As of kira 0.9+, `kira` no longer bundles its own CPAL backend by default in the same way — the `DefaultBackend` feature must be enabled explicitly and requires `cpal` with the Android feature.
**Current code behavior:**
The `AudioPlugin::build` already handles the "no audio device" case gracefully:
```rust
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
if manager.is_none() {
warn!("audio device unavailable; SFX disabled");
}
```
This means if the audio manager fails to initialise on Android, the game continues silently. This is acceptable as a first-pass fallback.
**What is needed for working audio on Android:**
- Add `kira` dependency with `cpal` backend enabled for Android: The `kira` workspace dependency currently specifies `version = "0.12"`. Verify that `kira/Cargo.toml` exposes a `cpal` feature (or that `DefaultBackend` compiles on Android targets with NDK). If not, a `CpalBackend` with `cpal = { features = ["oboe"] }` may be needed.
- The `NonSend` resource `AudioState` should compile fine — `NonSend` is legal in Bevy Android builds.
- `include_bytes!` for the WAV assets is compile-time and unaffected by platform.
**Recommendation:** Defer full audio verification to a device test. The graceful fallback means a silent-but-working first build is achievable without resolving this.
---
## 3. `keyring` Crate — No Android Backend
**The problem:**
`keyring = "2"` is used in `solitaire_data/src/auth_tokens.rs` to store JWT access and refresh tokens in the OS keychain. The `keyring` crate's Android backend does not exist — as of v2.x, supported backends are: macOS Keychain, Windows Credential Manager, Linux Secret Service (D-Bus), and iOS Keychain. There is no Android KeyStore backend.
On Android, `Entry::new(...)` will return `keyring::Error::NoStorageAccess`, which the existing code already maps to `TokenError::KeychainUnavailable`. So the code will not crash — it will simply fail every token store/load operation.
**Current failure mode:**
Every call to `store_tokens`, `load_access_token`, `load_refresh_token`, or `delete_tokens` will return `Err(TokenError::KeychainUnavailable(...))`. The sync client in `sync_client.rs` needs to be verified to handle this gracefully rather than propagating an error that disables sync entirely.
**Options for Android credential storage:**
| Option | Security | Effort | Notes |
|---|---|---|---|
| **In-memory only (prompt re-login each session)** | N/A | 1 day | Simplest. On `TokenError::KeychainUnavailable`, the `SyncProvider` returns `SyncError::Auth`, user is prompted to log in. Already architecturally supported. |
| **Encrypted `SharedPreferences` equivalent via JNI** | Good | 46 days | Call Android's `EncryptedSharedPreferences` (Jetpack Security) via JNI. Significant JNI boilerplate. |
| **AES-256 file encryption using Android Keystore via JNI** | Excellent | 58 days | Proper Android keychain equivalent. Complex JNI. |
| **Store in app-private file, unencrypted** | Poor | 0.5 days | Only acceptable during development. Never ship. |
**Recommended approach (first pass):** Use the in-memory / re-login-each-session path. The existing `TokenError::KeychainUnavailable` variant already exists for exactly this reason (Linux without a running secret service). The `SyncPlugin` should detect this on startup and present a "Sync unavailable — please log in" message rather than a hard error. This requires:
1. Conditional compilation: when `cfg(target_os = "android")`, replace the `keyring` calls with a no-op in-memory store (a simple `Mutex<HashMap<String, String>>`).
2. A `#[cfg(not(target_os = "android"))]` guard on the `keyring` import/dependency in `solitaire_data/Cargo.toml`.
**Required `solitaire_data/Cargo.toml` change:**
```toml
[target.'cfg(not(target_os = "android"))'.dependencies]
keyring = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
# keyring is replaced by in-memory storage; no dependency needed
```
---
## 4. `dirs` Crate — Data Directory on Android
**The problem:**
`storage.rs` and other persistence modules use `dirs::data_dir()` to locate `~/.local/share/solitaire_quest/` (or platform equivalent). On Android, `dirs::data_dir()` returns `None` because there is no `XDG_DATA_HOME` and the `dirs` crate does not implement an Android-specific path.
**Current code behavior:**
All persistence functions already handle `None` gracefully (returning default values or `Err`), consistent with the CLAUDE.md lesson about `dirs::data_dir()`. Stats and progress will silently not persist across sessions if `data_dir()` returns `None`.
**Fix required:**
Android apps should store private data in the app's internal storage directory, obtained via JNI: `context.getFilesDir()`. This requires either:
- A thin JNI helper (via `jni` crate) called once on startup to obtain the path and store it as a global.
- Or passing the path in via the `android_main` entry point using `cargo-mobile2`'s `AndroidApp` handle, which exposes `internal_data_path()`.
The `cargo-mobile2` + Bevy path exposes an `AndroidApp` via `bevy::winit`'s Android entry point. Bevy 0.13+ passes `AndroidApp` through `WinitPlugin`, and it is accessible via a Bevy resource. A startup system can extract `app.internal_data_path()` and insert a `PlatformDataDirResource` that the storage functions read instead of calling `dirs::data_dir()`.
**Effort:** 12 days to implement the override and thread it through all `storage.rs` / `progress.rs` / `settings.rs` / `achievements.rs` call sites.
---
## 5. Touch Input — Current State and Gaps
**What already exists (strong foundation):**
The `InputPlugin` in `input_plugin.rs` has a complete parallel touch pipeline:
| System | Purpose | Status |
|---|---|---|
| `handle_touch_stock_tap` | Tap the stock pile to draw | Complete |
| `touch_start_drag` | Begin a touch drag on a face-up card | Complete |
| `touch_follow_drag` | Move card(s) with the active finger | Complete |
| `touch_end_drag` | Resolve the drag (move or reject) | Complete |
The touch systems use `TouchInput` events and the `Touches` resource, map touch IDs to `DragState.active_touch_id` to prevent multi-finger conflicts, and share the same `DragState`, `MoveRequestEvent`, `MoveRejectedEvent`, and `StateChangedEvent` infrastructure as the mouse pipeline. The drag threshold (`tuning.drag_threshold_px`) applies identically.
**Gaps for a production Android experience:**
1. **No double-tap equivalent for auto-move**: `handle_double_click` is mouse-only. Android users need a double-tap to trigger the same "move to best destination" logic. The `handle_double_click` system checks `buttons.just_pressed(MouseButton::Left)` and will be inert on Android. Estimated: 1 day.
2. **No touch equivalent for keyboard actions**: Undo, New Game, Draw (when stock is visible but tapping it is awkward), Hint, and Forfeit have no on-screen buttons. These need an Android-specific UI bar or gesture (e.g. two-finger tap for undo). Estimated: 23 days for a minimal floating action button strip.
3. **Drag threshold tuning**: The threshold is in `AnimationTuning` (`tuning.drag_threshold_px`). Touch screens typically need a larger threshold than mouse (physical screens have more accidental movement during a tap). The current value should be evaluated on a real device and likely increased for touch.
4. **No long-press for right-click equivalent**: The right-click highlight/hint glow (`HintHighlightTimer`) is triggered via right mouse button. Long-press detection is not yet implemented. This is a missing feature but not a blocker for basic play.
5. **`handle_double_click` uses `LocalDateTime`-based timing via `Time`**: This will work on Android, but `DOUBLE_CLICK_WINDOW = 0.35s` may feel too tight on touch. Should be configurable.
---
## 6. Additional Issues Not in Scope of the Four Research Areas
**`CursorPlugin`:** Uses Bevy's cursor APIs which are desktop-only. Should be conditionally compiled out on Android with `#[cfg(not(target_os = "android"))]`.
**`reqwest` with `rustls-native-certs`:** The `reqwest` dependency uses `rustls` with native root certificates. On Android, `rustls-native-certs` reads system certificates differently (via the `android_system_properties` crate internally). This generally works but should be tested; Android's certificate store is in a non-standard location vs Linux.
**App lifecycle (suspend/resume):** Android can suspend the process at any time. Bevy handles `WindowEvent::Suspended` and `WindowEvent::Resumed` via `winit`, pausing the render loop. The `SyncPlugin`'s "push on exit" path (`AppExit` event) should also trigger on `WindowEvent::Suspended` to avoid data loss when the user backgrounds the app. This is a separate feature (1 day).
**No `sqlx` on Android:** `solitaire_server` is a server binary and is never built for Android. The `sqlx` dependency only exists in `solitaire_server/Cargo.toml` and will not affect Android builds of the client crates.
**`solitaire_assetgen`:** The asset generation tool is desktop-only and not part of the client build. Unaffected.
---
## 7. Required Changes Per Crate
### `solitaire_core` and `solitaire_sync`
No changes required. Both are pure Rust with no platform dependencies.
### `solitaire_data`
| Change | Effort |
|---|---|
| Gate `keyring` dependency on `#[cfg(not(target_os = "android"))]` | 0.5 days |
| Implement `auth_tokens.rs` in-memory fallback for Android | 1 day |
| Add `internal_data_path()` override for `dirs::data_dir()` on Android | 1.5 days |
| Audit all `dirs::data_dir()` / `settings_file_path()` call sites to accept injected path | 0.5 days |
### `solitaire_engine`
| Change | Effort |
|---|---|
| Conditionally disable `CursorPlugin` on Android | 0.5 days |
| Disable `handle_fullscreen` on Android (or make it a no-op) | 0.25 days |
| Implement double-tap for auto-move (touch equivalent of `handle_double_click`) | 1 day |
| On-screen action bar for Undo, New Game, Hint (minimal floating buttons) | 2.5 days |
| Tune drag threshold for touch; expose as a platform-specific tuning constant | 0.5 days |
| Trigger sync push on `WindowEvent::Suspended` in `SyncPlugin` | 1 day |
| Verify `kira` audio on Android (test `DefaultBackend` / CPAL; implement fallback if needed) | 12 days |
### `solitaire_app`
| Change | Effort |
|---|---|
| Add `[lib]` target with `crate-type = ["cdylib"]` for Android builds | 0.25 days |
| Create `src/lib.rs` (or `src/android.rs`) Android entry point calling `android_main` | 0.5 days |
| Remove or guard fixed `resolution` / `resize_constraints` for Android | 0.25 days |
| Pass `AndroidApp::internal_data_path()` to a startup resource | 0.5 days |
### Build / Toolchain
| Change | Effort |
|---|---|
| Install cargo-mobile2, Android NDK r25c/r26, `aarch64-linux-android` target | 1 day |
| Run `cargo mobile init`, configure `android/` Gradle project | 0.5 days |
| Get a first build compiling (resolve linker / NDK issues) | 12 days |
---
## 8. Estimated Effort
| Phase | Description | Days |
|---|---|---|
| Toolchain setup | NDK, cargo-mobile2, first compile | 23 |
| `solitaire_data` Android adaptations | keyring fallback, data dir | 3 |
| `solitaire_app` Android entry point | cdylib, AndroidApp wiring | 1 |
| `solitaire_engine` guards and fixes | cursor, fullscreen, audio verify | 23 |
| Touch UX improvements | double-tap, action bar, threshold tuning | 45 |
| Testing on real device / emulator | iteration, lifecycle edge cases | 23 |
| **Total** | | **1417 days** |
This produces a playable, functionally complete Android build. It does not include Play Store preparation (signing keys, metadata, icon set, permissions manifest tuning) which would add 12 more days.
---
## 9. Recommended First Step
**Get the workspace to compile for `aarch64-linux-android` without running.**
This surfaces all the real linker and dependency errors before writing any gameplay code:
```bash
# Install toolchain
rustup target add aarch64-linux-android
cargo install --locked cargo-mobile2
# In the workspace root:
cargo mobile init # generates android/ directory
# Attempt a library build targeting Android
cargo build -p solitaire_app --target aarch64-linux-android 2>&1 | head -60
```
The first build will fail on `keyring` (no Android backend) and likely on `dirs`. Fixing those two in `solitaire_data` — gate `keyring` behind `cfg(not(target_os = "android"))` and stub the data directory — will probably get the workspace to a clean compile. From there, the path to a running APK is incremental.
Do not attempt to resolve audio or touch UX until the build compiles cleanly. Compile errors are the only true blockers; the rest are feature gaps.