Files
Ferrous-Solitaire/docs/android/PLAYABILITY_TODO.md
T
funman300 2a206b994c fix(android): wrap sync HTTP tasks in per-call Tokio runtime
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 all three spawn
sites in sync_plugin.rs (start_pull, handle_manual_sync_request,
push_replay_on_win) and the push_on_exit fallback by wrapping each HTTP
future in tokio::runtime::Builder::new_current_thread().enable_all().

Also fixes a clippy type_complexity warning in hud_plugin.rs by
extracting HudScoreFont / HudMovesFont / HudTimeFont type aliases for
the update_hud_typography query parameters.

Closes P4 AVD JNI bridge test: keystore JNI verified working on
Android 14 x86_64 AVD (load_access_token returned NotFound correctly);
clipboard JNI compiled and linked, runtime test deferred to a real-device
session with a won game and active sync server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:55:20 -07:00

13 KiB
Raw Blame History

Android Playability TODO

Started: 2026-05-10 — first hardware screenshot of v0.22.3 APK running on a real device showed the desktop HUD projected onto a 360 dp portrait viewport with no mobile adaptation. This list tracks the work needed to make the APK genuinely playable, not just "boots without crashing."

Context: v0.22.3 (signed release APK) builds and launches. JNI bridges (clipboard, keystore) compile but are untested on hardware. The work below is UI/UX port work — no architectural rewrites required.


Reading from the v0.22.3 screenshot

Region Observation
Top ~5 % System bar (clock, signal, battery) overlapped by game HUD — no safe-area inset
HUD text row Score:0 Pause Esc Help A Modes [] New_Game N Moves:0 0:08 all overlapping — desktop layout crammed into 360 dp
Keyboard hints Esc, A, [], N shown next to buttons — meaningless on touch
Foundations row Leftmost foundation (♥) clipped left; rightmost tableau column (♠ 4) clipped right
Card backs Face-down cards render as solid red squares, not back-art texture
Vertical use Cards occupy top ~30 % only; bottom 70 % empty black — no portrait-aware layout
Bottom edge No accommodation for Android gesture / home-indicator area

P0 — Blocking playability

  • Safe-area insets (top + bottom). Closed 2026-05-10 by b9aa262. SafeAreaInsets resource + SafeAreaInsetsPlugin query WindowInsets.getInsets(systemBars()) via JNI on Android; HUD anchors carry SafeAreaAnchoredTop { base_top } and the change-detection fix-up system re-applies base_top + insets.top whenever the resource updates. Bottom inset is captured but not yet consumed (waits for bottom-anchored UI).
  • Mobile HUD layout. Closed 2026-05-10. Both the left HUD column and the right action button row are now capped at max_width: 50 % and the button row + tier-row child Nodes carry flex_wrap: Wrap. On a 360 dp viewport the 6-button row breaks to multiple lines (right-justified) and the tier rows wrap individually instead of overflowing into the action column. On desktop (≥ 1280 px) the 50 % cap is wider than any natural row width so the existing single-line layout is unchanged.
  • Card-back asset not rendering. Closed 2026-05-10 by fcc7337. AssetPlugin::file_path = "../assets" was set unconditionally to fix the desktop cargo run -p solitaire_app CWD relativity, but on Android cargo-apk packages the same directory into the APK at assets/ and Bevy's AndroidAssetReader is already rooted there — prepending ../ walked the reader out of the APK assets root and every load failed silently. The face-down branch then fell through to the card_back_colour(0) solid-red brick fallback. Gated the override behind #[cfg(not(target_os = "android"))].
  • Viewport overflow. Closed 2026-05-10. compute_layout was clamping the input window up to MIN_WINDOW = 800 × 600, so a 360 dp phone got laid out as if it were 800-wide and the outer piles fell outside the actual viewport. Lowered the floor to 320 × 400 (below the smallest reasonable phone) so real Android resolutions flow through without clamping, while keeping a sentinel to guard against degenerate / startup-zero windows. New regression test phone_portrait_layout_fits_horizontally asserts all 13 piles fit a 360 × 800 viewport.

P1 — Touch UX

  • Suppress keyboard-hint labels on Android. Closed 2026-05-10. spawn_action_button now nulls the hotkey argument on Android via a #[cfg(target_os = "android")] rebind, so the U / Esc / F1 / N chips next to the action row labels disappear on touch builds. Other hint sites (onboarding panel, pause-modal Esc hint, mode-card hotkey chips on the home screen, replay overlay footer, modal toggle hints in profile/stats/leaderboard/settings, help screen) survive — they live behind navigation and a touch user reaches them less often. Track as a P3 sweep when more screens are audited on hardware.
  • Thumb-sized hit targets. Closed 2026-05-10. Action button Node carries min_width: Val::Px(48.0), min_height: Val::Px(48.0) — meets Material's 48 dp baseline on touch and is a no-op for buttons whose content already exceeds 48 px in either axis. Applied universally rather than cfg-gated since Material's guideline applies to all input modes. Cards, pile markers, modal close buttons not yet audited — track as P3 if they fall below threshold on hardware.
  • Portrait-first card spacing. Closed 2026-05-11. compute_layout now derives an adaptive tableau_fan_frac from the available vertical space below the tableau row. On height-limited (desktop) windows the formula returns ≈ 0.25 and the clamp keeps the existing behaviour. On width-limited (portrait phone) windows — where card size is constrained by the 9-column horizontal packing — the fan fraction expands to fill the viewport (≈ 0.84 at 360 × 800 dp). tableau_facedown_fan_frac scales proportionally. Both values live in the Layout struct; card_plugin::card_positions and input_plugin::card_position / pile_drop_rect read from the struct so rendering and hit-testing stay in sync across viewport sizes.
  • Double-tap auto-move visible feedback. Closed 2026-05-11. On a recognised double-tap (priority 1 single-card or priority 2 stack move), the moved card(s) receive a 0.35 s lime flash (STATE_SUCCESS tint + HintHighlight { remaining: 0.35 }) before the move request is written. The flash persists through the card animation and is cleaned up by the existing tick_hint_highlight system. Hardware trigger-verification remains a manual step — connect AVD or device and confirm two rapid TouchPhase::Ended events within 0.5 s produce the lime flash.

P2 — Polish

  • Drag responsiveness on touch. Closed 2026-05-11. Two code-side improvements shipped; final feel confirmation still needs hardware:
    1. start_drag (mouse path) now bails out when a touch is just-pressed (Touches::iter_just_pressed()), ensuring touch_start_drag always owns the drag state on touch-screen devices — including Bevy/Winit versions that simulate MouseButton::Left from the primary touch.
    2. Mobile drag commit threshold lowered 10 px → 8 px, matching Android's ViewConfiguration.getScaledTouchSlop() spec. Smaller threshold → smaller snap-on-commit and faster perceived response. Remaining: connect AVD or device and verify drag feels responsive with no stutter; tune threshold further if needed.
  • Long-press menu. Closed 2026-05-11. New system radial_open_on_long_press in radial_menu.rs counts up while a touch is held (drag.active_touch_id.is_some() && !drag.committed) and opens RightClickRadialState::Active after 0.5 s — the same state the right-click path uses. Existing radial infrastructure then handles everything:
    • radial_track_cursor extended to fall back to the first active touch when no cursor position is available, so sliding the held finger moves the hover ring.
    • radial_handle_release_or_cancel extended to confirm/cancel on Touches::iter_just_released() in addition to right-mouse release.
    • handle_double_tap skips when the radial is active (guards a narrow edge case where the finger lifts at exactly the same frame the 0.5 s threshold fires). Hardware verification needed: confirm the 0.5 s hold feel, verify sliding to a destination and lifting confirms the move.
  • HUD typography. Closed 2026-05-11. New system update_hud_typography fires on WindowResized and adjusts Tier-1 font sizes based on viewport width. Below 480 logical px: Score TYPE_HEADLINE (26) → TYPE_BODY_LG (18), Moves/Timer TYPE_BODY_LG (18) → TYPE_CAPTION (11), so all three items fit in the 180 dp HUD column on a 360 dp phone. At ≥ 480 px the original sizes are restored — desktop/tablet layout unchanged. add_message::<WindowResized>() added defensively to HudPlugin so the system works under MinimalPlugins in tests.
  • Orientation lock. Closed 2026-05-11. Added [package.metadata.android.application.activity] section to solitaire_app/Cargo.toml with orientation = "portrait". cargo-apk/ndk-build maps this to android:screenOrientation="portrait" in the generated AndroidManifest.xml. Remove (or add a landscape layout) before enabling auto-rotate.

P3 — Asset density

  • Density-aware card scaling. Closed 2026-05-11 — no code change required. WindowResized fires with logical pixels; sprites are sized in world units (1 world unit = 1 logical pixel); Bevy's renderer maps logical → physical via scale_factor internally. On a 360 dp 3×-DPI phone, cards are 40 logical dp = 120 physical px. The 256 × 384 px card textures are downscaled to fit (256 → 120 px) — quality is fine. Upscaling only occurs if card_width × scale_factor > 256, i.e. a tablet with a logical width > 765 dp at 3× DPI — no current target device falls in that range. Revisit if the game ships on large-screen high-DPI tablets.
  • App-icon density buckets. Closed 2026-05-11. Created solitaire_app/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher.png from the existing assets/icon/ PNGs (48→mdpi, 64→hdpi, 128→xhdpi, 256→xxhdpi+xxxhdpi). Added resources = "res" to [package.metadata.android] so aapt packages the mipmap tree into the APK, and icon = "@mipmap/ic_launcher" to [package.metadata.android.application] so the launcher references it.

P4 — Stability / runtime

  • B0004 ECS hierarchy warnings. Investigated 2026-05-11 — no fix required. B0004 fires via Bevy's validate_parent_has_component<C> hook when a child entity has UI component C (e.g. Node, InheritedVisibility) but its parent doesn't yet. In Bevy 0.18, .despawn() is recursive (docs: "When a parent is despawned, all children will also be despawned"), so all .despawn() calls in the engine are safe. The warnings seen on the Pixel 7 AVD during startup are a component-propagation timing artifact — UI children reach the hook before the parent's inherited components finish initialising — not a gameplay defect. despawn_related::<Children>() in 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. 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.


Notes / decisions

  • This list is screenshot-driven; expect more items to surface once P0 unblocks actually moving cards on hardware.
  • The pattern across all the bugs is "no one ran the relevant code path on Android yet." The hard work — Bevy 0.18 on Android, JNI bridges, signed CI builds — is done. What's left is a coordinated pass of #[cfg(target_os = "android")] gates plus making LayoutResource query the real surface size.
  • Where possible, prefer responsive layout (query window size) over branching #[cfg] blocks. Branches are fine for input methods (touch vs. mouse) but not for screen geometry — a foldable or desktop window of equivalent size should look the same.