Skip to content

Android predictive-back gesture with a live screen peek#244

Open
ScottMorris wants to merge 10 commits into
mainfrom
feat/predictive-back-gesture
Open

Android predictive-back gesture with a live screen peek#244
ScottMorris wants to merge 10 commits into
mainfrom
feat/predictive-back-gesture

Conversation

@ScottMorris

Copy link
Copy Markdown
Contributor

Summary

Ports the Android predictive-back "peek" gesture from PR #13 (open since January, built on 563-commits-old code before the app rename and the event-driven Rust rewrite -- a literal rebase wasn't viable) as a fresh implementation against current main, fixing several real bugs found in that original attempt (wrong API-level gate, a Rust command macro that doesn't compile in tauri v2, a manifest flag that was never set, an unverified event-name guess, and a param-guessing underlay renderer its own comments admitted was broken).

  • plugins/predictive-back (new): registers OnBackAnimationCallback (API 33+) via a Channel to Rust, re-emitted as a predictive-back:event Tauri event. onResume() forces a clean re-registration, since the system's dispatcher registration doesn't reliably survive a pause/resume cycle (e.g. screen off then back on) even though the Kotlin callback object reference does.
  • RouteStage.tsx (new) replaces the plain <Outlet/> wrapper, layering the current route over a cached previous screen (ScreenStack -- the actual previously-rendered component with its real params, not a placeholder) and transforming both by live gesture progress.
  • plugins/os-prefs (renamed from time-prefs): it was about to gain a second, unrelated reading (Developer Options' animator duration scale) alongside its existing time-format read, so it's renamed to match -- the custom counterpart to the official @tauri-apps/plugin-os (static device info), but for OS preferences instead.
  • The settle animation (snap back on cancel, finish the slide on commit -- matching how native Android's own predictive-back completes the motion regardless of exact release point) is scaled by that OS animator duration scale.
  • android:enableOnBackInvokedCallback="true" is required on <application> for any of this to activate. It can't be injected by a plugin (update_android_manifest() only inserts child elements before a closing tag, with no path to an attribute on the tag itself) -- apps/threshold/src-tauri/build.rs patches it in directly and idempotently on every Android build instead.

Two device-only bugs found and fixed

Both were only reproducible on a real device (Pixel 8 Pro) -- neither showed up in review or in the emulator-free CI checks:

  • Committing a gesture calls router.history.back(), which is asynchronous -- popstate fires on a later tick (confirmed ~17ms later via direct device logging). Resetting the underlay/transform in the same tick visibly animated the outgoing screen snapping back into view, and the incoming screen then inherited that same still-running animation once the route swap landed mid-animation -- looking exactly like the incoming screen was "sliding in". Fixed by deferring the reset to a location.pathname effect that only fires once the navigation has actually landed.
  • @tanstack/router-core's defaultViewTransition option (configured as a function, per its own documented pattern) is only ever checked for truthiness by the installed version -- never actually invoked -- so the discrete View Transition system can't be suppressed the documented way for a raw history.back() call. Confirmed via direct source inspection and a temporary document.startViewTransition monkey-patch that logged every real call. Worked around by setting the same internal flag commitLocation() would have set. This also means ordinary (non-gesture) navigation's directional slide CSS types likely never actually apply anywhere in the app currently -- a genuine pre-existing issue, out of scope to fix broadly here.

Test plan

  • cargo test --workspace, pnpm -r run typecheck, pnpm --filter threshold test all clean
  • Real-device-verified on a Pixel 8 Pro via scripts/build-android-dev.sh: swipe-back on Edit Alarm/Settings shows the real previous screen peeking through; partial swipe + release snaps back smoothly; full commit finishes the slide (with a fade) and navigates once, cleanly, with no ghosting or double-animation; gesture survives a screen-off/on cycle; Home/Ringing correctly disable the gesture; desktop is unaffected

🤖 Generated with Claude Code

https://claude.ai/code/session_01MM1S4DNyxhwcbkMcx3hDp2

…esture

Registers OnBackAnimationCallback (API 33+) via a Channel to Rust, re-emitted as a predictive-back:event Tauri event for the webview to consume. Includes onResume() re-registration, since the system's dispatcher registration doesn't reliably survive a pause/resume cycle even though the Kotlin callback object reference does.
…-scale

time-prefs was about to gain a second, unrelated reading (Developer Options' animator duration scale) alongside its existing 12/24-hour time format read, so its name no longer matched its contents. Renamed to os-prefs, positioning it as this app's own counterpart to the official @tauri-apps/plugin-os (static device info) but for OS preferences instead -- a home for future reads of this kind rather than a new single-purpose plugin each time.
RouteStage replaces the plain <Outlet/> wrapper, layering the current route over a cached previous screen (ScreenStack -- a real, previously-rendered component with its actual params, not a placeholder or a guess) and transforming both by live gesture progress from PredictiveBackController. Settle animations (snap back on cancel, finish the slide on commit) are scaled by the OS's animator duration scale via AnimationScale.

Two real bugs surfaced only on a real device and are fixed here:
- Committing a gesture calls router.history.back(), which is asynchronous -- popstate fires on a later tick. Resetting the underlay/transform in the same tick visibly animated the outgoing screen snapping back into view, which the incoming screen then inherited when the route swap landed mid-animation. Fixed by deferring the reset to a location.pathname effect that only fires once the navigation has actually landed.
- @tanstack/router-core's defaultViewTransition option only checks truthiness rather than invoking it as documented, so ordinary navigation's browser View Transition can't be suppressed the documented way for a raw history.back() call. Worked around in skipNextViewTransition() by setting the same internal flag commitLocation() would have set.
Adds the architecture writeup for predictive-back (native -> Rust -> JS event flow, the RouteStage/ScreenStack design, and the two device-only bugs found and fixed while building it), a docs/plugins/ entry for each new/renamed plugin, and updates every stale time-prefs reference across the existing docs.
Came up repeatedly this session for PR bodies, plan files, and docs -- codifying it so it doesn't need repeating.
@ScottMorris ScottMorris added enhancement New feature or request android Android toolchain and mobile CI concerns ui User interface plugin Plugin work labels Jul 10, 2026
Comment thread apps/threshold/src-tauri/build.rs Outdated
/// attribute to an already-open tag. This flag is inherently app-shell-level config anyway
/// (same category as `usesCleartextTraffic`, set in this same generated project's
/// `app/build.gradle.kts`), so it belongs here rather than in `plugins/predictive-back`.
fn patch_predictive_back_manifest_flag() -> std::io::Result<()> {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IN the spirit of the Tauri plugins managing themselves this looks like it should be managed by the predictive-back plugin itself. If others were to use it they'd want this done as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is written with forced line wraps

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is written with forced line wraps!

ScottMorris and others added 5 commits July 10, 2026 17:46
…ctive-back plugin

test-kotlin-plugins still referenced the pre-rename plugins/time-prefs path and never picked up plugins/predictive-back, so both Gradle jobs were silently skipped in CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM1S4DNyxhwcbkMcx3hDp2
…k, not the app's build.rs

The app-shell build.rs was hand-patching android:enableOnBackInvokedCallback onto <application> on every Android build, which meant a plugin dependent on it wasn't self-contained -- any other app consuming tauri-plugin-predictive-back would need to reimplement that patch itself. Android's own Gradle library-manifest merge already reaches attributes on <application>, not just child elements, so the flag now ships in the plugin's own android/src/main/AndroidManifest.xml and merges in automatically, the same mechanism alarm-manager and wear-sync already use for their own receivers and services. Verified end-to-end on a real device build: the merged manifest in the installed APK carries the attribute with no app-side code at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM1S4DNyxhwcbkMcx3hDp2
Also unwraps plugins/predictive-back/README.md's prose, per house style -- flagged on PR #244 as still hard-wrapped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM1S4DNyxhwcbkMcx3hDp2
pnpm format:check was failing in CI on these files (italic markers, table alignment, lockfile block-scalar wrapping, line width) -- untouched by the substantive changes in the surrounding commits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM1S4DNyxhwcbkMcx3hDp2
…e module

Its Kotlin unit test (PredictiveBackPluginTest.kt) never actually compiled under CI before now -- the previous commit's workflow fix is what first pointed test-kotlin-plugins at this module, which surfaced the gap. Verified locally against the same tauri-ci-mobile container image and settings.gradle.kts shape CI uses: ./gradlew -p plugins/predictive-back/android testDebugUnitTest now passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM1S4DNyxhwcbkMcx3hDp2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

android Android toolchain and mobile CI concerns enhancement New feature or request plugin Plugin work ui User interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant