Skip to content

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

Merged
ScottMorris merged 13 commits into
mainfrom
feat/predictive-back-gesture
Jul 11, 2026
Merged

Android predictive-back gesture with a live screen peek#244
ScottMorris merged 13 commits into
mainfrom
feat/predictive-back-gesture

Conversation

@ScottMorris

@ScottMorris ScottMorris commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Closes #12

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. update_android_manifest() only inserts child elements before a closing tag, with no path to an attribute on the tag itself, so instead plugins/predictive-back/android/src/main/AndroidManifest.xml declares its own <application> with that attribute, and Android's standard Gradle library-manifest merge folds it into the consuming app's final manifest automatically -- the same mechanism alarm-manager and wear-sync already rely on for their own receivers and services. No app-shell-level wiring needed.

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.

Review fixes

A few issues surfaced in review, all fixed:

  • ScreenStack only collapsed back to the entry directly below the top, so a couple of existing navigation flows that reach a second leaf without passing back through Home first (e.g. Settings' test-ringing button, whose dismissal navigates forward with replace: true rather than history.back()) grew the stack by a few entries on every repeat, unboundedly over a long-running session. Fixed by collapsing to any earlier matching entry, bounding growth by the number of distinct paths actually visited.
  • RouteStage's gesture-lifecycle effect cleanup cancelled its settle timers but not the commit's 1-second finalize-fallback timer, so a brand-new gesture starting while a previous commit's navigation was still stalled could later have that orphaned timer fire mid-gesture, forcibly hiding the underlay. Fixed by also clearing it (and resetting the pending-commit flag) in the cleanup.
  • The os-prefs rename left its frontend time-format wrapper (utils/timePrefs.ts, export TimePrefs) still named after the retired time-prefs plugin. Renamed to utils/timeFormatPrefs.ts / TimeFormatPrefs, matching AnimationScale.ts's feature-scoped naming for the plugin's other reading.

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 8 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
… adjacent one

Only checking the slot directly below the top handled simple push/pop navigation, but this app's screens form a star around Home, and a couple of existing flows reach a second leaf without passing back through Home first (e.g. Settings' test-ringing button, whose dismissal navigates forward with replace: true rather than history.back()). Those cycles were treated as "genuinely new" every time and grew the stack by a few entries on every repeat, unboundedly over a long-running session. Searching the whole stack for a match bounds growth by the number of distinct paths actually visited instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM1S4DNyxhwcbkMcx3hDp2
…ag on effect cleanup

The gesture-lifecycle effect's cleanup cancelled its settle timers (hideTimeoutRef, rafRef) but not the 1-second finalize-fallback timer scheduled on commit. If a brand-new gesture started while a previous commit's navigation was still stalled (a blocked/slow navigation, within that fallback window), the orphaned timer stayed live and could later fire mid-new-gesture, forcibly hiding the underlay regardless of what the new gesture was actually doing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM1S4DNyxhwcbkMcx3hDp2
utils/timePrefs.ts (export TimePrefs) was left named after the retired time-prefs plugin after its rename to os-prefs. It only ever wrapped the time-format reading, not the whole plugin, so it's renamed to utils/timeFormatPrefs.ts / TimeFormatPrefs -- a feature-scoped name matching AnimationScale.ts's convention for the plugin's other reading, rather than one that reads as tied to the plugin's old name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM1S4DNyxhwcbkMcx3hDp2
@ScottMorris ScottMorris merged commit be7e9ab into main Jul 11, 2026
7 checks passed
@ScottMorris ScottMorris deleted the feat/predictive-back-gesture branch July 11, 2026 13:39
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.

Android predictive back “peek” for Threshold

1 participant