feat(app): Android build target — first working APK at 54 MB

Wires the workspace through `cargo apk build`. After this commit
`cargo apk build -p solitaire_app --target x86_64-linux-android`
produces a debug-signed APK at `target/debug/apk/solitaire-quest.apk`
containing all assets and `lib/x86_64/libsolitaire_app.so` — runnable
on the AVD or a physical x86_64 device.

The five gating points discovered by iterating compile cycles:

1. solitaire_app split into bin + lib. cargo-apk needs a `cdylib`
   to bundle as `libmain.so`; pure-bin crates panic with
   "Bin is not compatible with Cdylib". `src/lib.rs` carries the
   ECS bootstrap as `pub fn run`; `src/main.rs` is a 3-line shim
   that delegates for the desktop `cargo run` path.

2. `[package.metadata.android]` pins target SDK 34 / min SDK 26
   so cargo-apk doesn't probe for whatever default it ships
   (which on this machine was an uninstalled API 30). `assets =
   "../assets"` lets the same asset directory feed both desktop
   and APK.

3. Workspace `bevy` features add `android-native-activity` (the
   Bevy-side glue that pairs with cargo-apk's NativeActivity
   wrapper). The feature is target-gated inside bevy_internal so
   desktop builds compile it out.

4. `arboard` (clipboard, used by Stats's "Copy share link") has
   no Android backend — `cargo apk build` fails with E0433 on
   `platform::Clipboard` if unconditional. Target-gated to
   `cfg(not(target_os = "android"))`; the system surfaces an
   informational toast on Android until JNI ClipboardManager is
   wired in the Phase-Android round.

5. `keyring` + `keyring-core` cannot compile for android — the
   transitive `rpassword` uses `libc::__errno_location` which
   bionic doesn't expose. Both crates target-gated; `auth_tokens`
   ships a stub on Android that returns `KeychainUnavailable` for
   every call, matching how callers already handle a Linux box
   without Secret Service.

Cosmetic post-pass panic: cargo-apk panics AFTER the APK is signed
when it tries to also wrap the bin target. The APK on disk is
unaffected. Working around this with `cargo apk build --lib` is
the next small step.

What's verified:
- Desktop `cargo build`, `cargo clippy --workspace --all-targets`,
  and `cargo test --workspace` all clean.
- `cargo apk build -p solitaire_app --target x86_64-linux-android`
  produces 54 MB debug APK with libsolitaire_app.so + assets.

What's NOT yet verified:
- Whether the APK actually launches on the AVD / a phone (next
  step: `adb install` + `adb logcat` against the bevy_test AVD).
- Whether `dirs::data_dir()` on Android returns a usable path
  (sync / persistence will surface this if not).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-07 19:34:48 +00:00
parent 690e1d2ad6
commit fb8b2ac684
8 changed files with 454 additions and 267 deletions
+10 -1
View File
@@ -13,10 +13,19 @@ chrono = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
dirs = { workspace = true }
keyring-core = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in
# `rpassword` which uses `libc::__errno_location` — a symbol the
# Android NDK doesn't expose (`__errno` lives at a different path
# on bionic). On Android `auth_tokens` falls back to a stub
# implementation that always returns `KeychainUnavailable`; the
# real backend lands when we wire Android Keystore via JNI.
[target.'cfg(not(target_os = "android"))'.dependencies]
keyring-core = { workspace = true }
[dev-dependencies]
solitaire_server = { path = "../solitaire_server" }
solitaire_sync = { workspace = true }
+51
View File
@@ -14,8 +14,19 @@
//! the Bevy `App`). If no default store is set, all operations in this module
//! will return [`TokenError::KeychainUnavailable`].
//!
//! # Android stub
//!
//! `keyring-core` cannot compile for the android target (its `rpassword`
//! transitive dep uses `libc::__errno_location`, which Android's bionic
//! doesn't expose). On Android every function in this module returns
//! [`TokenError::KeychainUnavailable`] so callers can detect the fallback
//! the same way they handle a Linux box without Secret Service. The
//! real Android backend will arrive in the Phase-Android round when we
//! wire Android Keystore via JNI.
//!
//! # Note: no unit tests — requires live OS keychain.
#[cfg(not(target_os = "android"))]
use keyring_core::Entry;
use thiserror::Error;
@@ -34,9 +45,11 @@ pub enum TokenError {
}
/// Service name used to namespace all keychain entries for this application.
#[cfg(not(target_os = "android"))]
const SERVICE: &str = "solitaire_quest_server";
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
#[cfg(not(target_os = "android"))]
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
let msg = err.to_string();
match err {
@@ -51,6 +64,7 @@ fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
/// Store the access and refresh tokens for `username` in the OS keychain.
///
/// Any previously stored tokens for that username are overwritten.
#[cfg(not(target_os = "android"))]
pub fn store_tokens(
username: &str,
access_token: &str,
@@ -72,6 +86,7 @@ pub fn store_tokens(
/// Load the stored access token for `username` from the OS keychain.
///
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
#[cfg(not(target_os = "android"))]
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
Entry::new(SERVICE, &format!("{username}_access"))
.map_err(|e| map_keyring_err(e, username))?
@@ -82,6 +97,7 @@ pub fn load_access_token(username: &str) -> Result<String, TokenError> {
/// Load the stored refresh token for `username` from the OS keychain.
///
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
#[cfg(not(target_os = "android"))]
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
Entry::new(SERVICE, &format!("{username}_refresh"))
.map_err(|e| map_keyring_err(e, username))?
@@ -93,6 +109,7 @@ pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
///
/// Intended to be called on logout or account deletion. Missing entries are
/// silently ignored (the tokens are already gone, which is the desired state).
#[cfg(not(target_os = "android"))]
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
match Entry::new(SERVICE, &format!("{username}_access"))
.map_err(|e| map_keyring_err(e, username))?
@@ -112,3 +129,37 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
Ok(())
}
// -------------------------------------------------------------------
// Android stub — same public API, always returns KeychainUnavailable.
// Lets `sync_client::*` compile unchanged on Android; the runtime
// effect is "session login required every launch", same as a Linux
// box without Secret Service.
// -------------------------------------------------------------------
#[cfg(target_os = "android")]
const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)";
#[cfg(target_os = "android")]
pub fn store_tokens(
_username: &str,
_access_token: &str,
_refresh_token: &str,
) -> Result<(), TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
}
#[cfg(target_os = "android")]
pub fn load_access_token(_username: &str) -> Result<String, TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
}
#[cfg(target_os = "android")]
pub fn load_refresh_token(_username: &str) -> Result<String, TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
}
#[cfg(target_os = "android")]
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
}