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>
17 KiB
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 12–18 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
TouchInputevents and theTouchesresource are populated from AndroidMotionEvents viawinit. The existingtouch_start_drag,touch_follow_drag,touch_end_drag, andhandle_touch_stock_tapsystems ininput_plugin.rswill function correctly — this was already written with multi-touch in mind and usesTouchPhase::Started/Moved/Ended/Canceledcleanly. - Bevy UI (the
bevy::uimodule used for all overlays). WindowResizedevents 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.rssetsresolution: (1280u32, 800u32). On Android the window is always the full display. This value is harmlessly overridden by the OS, butmin_width/min_heightconstraints should be removed or set to 0 for Android to avoid Winit warnings. F11fullscreen toggle (handle_fullscreenininput_plugin.rs):WindowMode::BorderlessFullscreenis desktop-only. On Android it should be a no-op.- Keyboard shortcuts: The entire
handle_keyboard_core,handle_keyboard_hint,handle_keyboard_forfeitsystems 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 usesPrimaryWindowcursor APIs that may panic or warn on Android.
cargo-mobile2 integration for Bevy: The standard path is:
- Install
cargo-mobile2:cargo install --locked cargo-mobile2 - Run
cargo mobile initin the workspace root. This generates anandroid/directory with the Gradle project,AndroidManifest.xml, and JNI glue. - cargo-mobile2 targets the
solitaire_appbinary crate (the thin entry point). The generatedlib.rsshim callsandroid_mainviabevy::winit's Android entry point. - The
solitaire_appcrate needs a[lib]target added alongside the existing[[bin]], withcrate-type = ["cdylib"], used only when building for Android.
Required Cargo.toml changes (workspace level):
[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:
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
kiradependency withcpalbackend enabled for Android: Thekiraworkspace dependency currently specifiesversion = "0.12". Verify thatkira/Cargo.tomlexposes acpalfeature (or thatDefaultBackendcompiles on Android targets with NDK). If not, aCpalBackendwithcpal = { features = ["oboe"] }may be needed. - The
NonSendresourceAudioStateshould compile fine —NonSendis 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 | 4–6 days | Call Android's EncryptedSharedPreferences (Jetpack Security) via JNI. Significant JNI boilerplate. |
| AES-256 file encryption using Android Keystore via JNI | Excellent | 5–8 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:
- Conditional compilation: when
cfg(target_os = "android"), replace thekeyringcalls with a no-op in-memory store (a simpleMutex<HashMap<String, String>>). - A
#[cfg(not(target_os = "android"))]guard on thekeyringimport/dependency insolitaire_data/Cargo.toml.
Required solitaire_data/Cargo.toml change:
[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
jnicrate) called once on startup to obtain the path and store it as a global. - Or passing the path in via the
android_mainentry point usingcargo-mobile2'sAndroidApphandle, which exposesinternal_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: 1–2 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:
-
No double-tap equivalent for auto-move:
handle_double_clickis mouse-only. Android users need a double-tap to trigger the same "move to best destination" logic. Thehandle_double_clicksystem checksbuttons.just_pressed(MouseButton::Left)and will be inert on Android. Estimated: 1 day. -
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: 2–3 days for a minimal floating action button strip.
-
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. -
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. -
handle_double_clickusesLocalDateTime-based timing viaTime: This will work on Android, butDOUBLE_CLICK_WINDOW = 0.35smay 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) |
1–2 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) | 1–2 days |
8. Estimated Effort
| Phase | Description | Days |
|---|---|---|
| Toolchain setup | NDK, cargo-mobile2, first compile | 2–3 |
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 | 2–3 |
| Touch UX improvements | double-tap, action bar, threshold tuning | 4–5 |
| Testing on real device / emulator | iteration, lifecycle edge cases | 2–3 |
| Total | 14–17 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 1–2 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:
# 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.