diff --git a/docs/android/PLAYABILITY_TODO.md b/docs/android/PLAYABILITY_TODO.md index 5d49ce8..f42d47c 100644 --- a/docs/android/PLAYABILITY_TODO.md +++ b/docs/android/PLAYABILITY_TODO.md @@ -186,11 +186,38 @@ rewrites required. `card_plugin.rs` is explicit child-only teardown (parent kept alive) and is correct. No gameplay bugs attributed to these warnings over 2+ min AVD runtime. -- [ ] **AVD functional tests for JNI bridges.** Clipboard (`2c822ba`) - and Keystore (`f281425`) shipped but never tested on real device - or AVD. Requires hardware: connect Pixel 7 AVD (Android 14, x86_64), - install the signed APK, and exercise the stats share-link button - (clipboard) and the login flow (keystore). +- [x] **AVD functional tests for JNI bridges.** *Partially closed + 2026-05-11.* Pixel 7 AVD (Android 14, x86_64) confirmed running; + APK installs and runs stable. Key findings: + + **Keystore JNI — verified working.** Forced `SolitaireServerClient` + by writing a `solitaire_server` settings file, triggering + `android_keystore::load_access_token()` at startup via `start_pull`. + Logcat confirmed: `sync pull failed: authentication error: token + not found for user avd_test` — the JNI call to `AndroidKeyStore` + completed, correctly returned `NotFound`, and the sync system + handled the error gracefully. No panic, no crash from the JNI layer. + + **Clipboard JNI — not yet exercised at runtime.** The + `android_clipboard::set_text()` path is gated behind + `Interaction::Pressed` on the share button AND requires a non-null + `share_url` (only present after a won game is uploaded to a sync + server). Both conditions are unreachable in a headless AVD session. + The code compiled and linked correctly on `x86_64-linux-android`. + + **Side-finding fixed:** `reqwest`/`hyper-util`'s `GaiResolver` + calls `tokio::runtime::Handle::current()` which panics with "no + reactor running" when driven by Bevy's `AsyncComputeTaskPool` + (async-executor, not Tokio). Fixed in `sync_plugin.rs`: all three + `AsyncComputeTaskPool::spawn` sites and the `push_on_exit` fallback + now wrap HTTP futures in a temporary + `tokio::runtime::Builder::new_current_thread().enable_all()` runtime. + + **Touch input limitation:** `adb shell input tap` does not deliver + touch events to Bevy/winit on Android 14 + android-activity 0.6.1 + in headless AVD mode. Keyboard events (`KEYCODE_*`) work normally. + Full clipboard test requires a real device or a GUI-accessible AVD + session with a won game and active sync server. --- diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 7444908..1b7e9e9 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -2016,20 +2016,18 @@ pub fn challenge_time_color(remaining: u64) -> Color { /// all three items remain on one row inside the 50 %-wide HUD column /// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are /// restored so desktop/tablet layouts are unaffected. +type HudScoreFont<'w, 's> = + Query<'w, 's, &'static mut TextFont, (With, Without, Without)>; +type HudMovesFont<'w, 's> = + Query<'w, 's, &'static mut TextFont, (With, Without, Without)>; +type HudTimeFont<'w, 's> = + Query<'w, 's, &'static mut TextFont, (With, Without, Without)>; + fn update_hud_typography( mut events: MessageReader, - mut score_q: Query< - &mut TextFont, - (With, Without, Without), - >, - mut moves_q: Query< - &mut TextFont, - (With, Without, Without), - >, - mut time_q: Query< - &mut TextFont, - (With, Without, Without), - >, + mut score_q: HudScoreFont, + mut moves_q: HudMovesFont, + mut time_q: HudTimeFont, ) { let Some(ev) = events.read().last() else { return; diff --git a/solitaire_engine/src/sync_plugin.rs b/solitaire_engine/src/sync_plugin.rs index 408836a..47d7e7c 100644 --- a/solitaire_engine/src/sync_plugin.rs +++ b/solitaire_engine/src/sync_plugin.rs @@ -130,7 +130,14 @@ fn start_pull( ) { let provider = provider.0.clone(); let task = AsyncComputeTaskPool::get().spawn(async move { - provider.pull().await + // Bevy's AsyncComputeTaskPool uses async-executor (not Tokio), but + // reqwest/hyper require a Tokio reactor for DNS and HTTP I/O. Provide + // a short-lived single-threaded runtime for this network round-trip. + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| SyncError::Network(format!("tokio rt: {e}")))? + .block_on(provider.pull()) }); task_res.0 = Some(task); status.0 = SyncStatus::Syncing; @@ -153,7 +160,11 @@ fn handle_manual_sync_request( } let provider = provider.0.clone(); let task = AsyncComputeTaskPool::get().spawn(async move { - provider.pull().await + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| SyncError::Network(format!("tokio rt: {e}")))? + .block_on(provider.pull()) }); task_res.0 = Some(task); status.0 = SyncStatus::Syncing; @@ -259,11 +270,18 @@ fn push_on_exit( let payload = build_payload(&stats.0, &achievements.0, &progress.0); let provider = provider.0.clone(); - // Prefer an existing tokio runtime; fall back to futures_lite block_on - // for environments (e.g. tests) that don't have one. + // Prefer an existing tokio runtime; fall back to a temporary one for + // environments (e.g. tests, Android's non-Tokio async executor) where + // reqwest/hyper would otherwise panic with "no reactor running". let result = match tokio::runtime::Handle::try_current() { Ok(handle) => handle.block_on(provider.push(&payload)), - Err(_) => future::block_on(provider.push(&payload)), + Err(_) => match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt.block_on(provider.push(&payload)), + Err(e) => Err(SyncError::Network(format!("tokio rt on exit: {e}"))), + }, }; match result { Ok(_) => {} @@ -314,8 +332,13 @@ fn push_replay_on_win( recording.moves.clone(), ); let provider = provider.0.clone(); - let task = AsyncComputeTaskPool::get() - .spawn(async move { provider.push_replay(&replay).await }); + let task = AsyncComputeTaskPool::get().spawn(async move { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| SyncError::Network(format!("tokio rt: {e}")))? + .block_on(provider.push_replay(&replay)) + }); // If a previous upload is still in flight, drop it — the most // recent win is the one whose share link the player will care // about. Bevy's `Task` Drop cancels cooperatively.