Skip to content

Add optional HyperOS system navigation watchdog - #5

Open
Dragonk wants to merge 21 commits into
tanujnotes:mainfrom
Dragonk:feat/system-navigation-watchdog
Open

Add optional HyperOS system navigation watchdog#5
Dragonk wants to merge 21 commits into
tanujnotes:mainfrom
Dragonk:feat/system-navigation-watchdog

Conversation

@Dragonk

@Dragonk Dragonk commented Aug 26, 2026

Copy link
Copy Markdown

Depends on #4. This PR builds on the configurable gesture-zone work in #4, which itself currently depends on the accessibility-overlay migration in #3. Until #3 and #4 merge, GitHub shows their prerequisite commits in this PR's diff. After they land, the diff reduces to the system-navigation-watchdog changes only.

Summary

Adds an optional, off-by-default system-navigation watchdog for Xiaomi/Redmi/POCO devices running MIUI/HyperOS. When enabled, it keeps the OEM three-button navigation bar hidden so Ogesture can fully replace it with gesture navigation — maintaining force_fsg_nav_bar=1 and hide_gesture_line=1 in Settings.Global.

Why

On some Xiaomi/HyperOS devices the system three-button navigation bar comes back even after switching to gesture navigation. The project owner reproduced a specific case: with a third-party launcher and Ogesture handling Back/Home/Recents, opening a ZIP in a browser triggered the system Open with / Resolver activity (com.android.intentresolver/.ResolverActivity), at which point HyperOS restored the three-button bar. Manually setting force_fsg_nav_bar=1 again hid it.

Rather than predict every situation that resets the nav bar, the watchdog observes the actual navigation-related settings and maintains the user-selected desired state. This makes it independent of the trigger.

Real-device reproduction

This reproduction was supplied by the project owner (not the agent):

  • Xiaomi/HyperOS device, third-party launcher, Ogesture navigation.
  • Opening a ZIP caused com.android.intentresolver/.ResolverActivity to appear.
  • HyperOS restored the 3-button navigation bar.
  • Manually restoring force_fsg_nav_bar=1 hid it again.

The project owner also confirmed the first PR #5 test build (pre-hardening) worked on his real device.

UX

  • A dedicated System navigation screen (parallel to App compatibility / Gesture areas) reached from a compact entry card on the main dashboard.
  • Feature switch: "Hide system navigation buttons" — OFF by default. A feature that is already ON can always be turned OFF, even if prerequisites (permission/master) are no longer met.
  • Permission status ("ADB permission granted/required") with re-check on resume.
  • Device-support guard: on non-Xiaomi/Redmi/POCO devices the switch is disabled.
  • A "needs gestures on" note: enforcement only runs while Ogesture is actively providing navigation.

ADB setup

.\adb.exe shell pm grant com.ogesture android.permission.WRITE_SECURE_SETTINGS

Root is not required. Ogesture uses the permission only for this optional feature; when disabled, it stops enforcing and restores the previous navigation state.

Implementation

  • WRITE_SECURE_SETTINGS added to the manifest (with tools:ignore="ProtectedPermissions").
  • Pure enforcement logic in SystemNavigationEnforcer (Android-Context-free, unit-tested).
  • SystemNavigationController is a service-lifetime singleton (one enforcer, one mutex, one baseline state across all bind/unbind cycles) with its own restoreScope for fail-safe restore that survives scope.cancel().
  • Enforced only when all preconditions hold: user opt-in + supported device + permission granted + master gestures on + service bound + isGestureNavigationAvailable (all 3 gesture zones attached AND not in pass-through mode).
  • Wired into EdgeGestureAccessibilityService: onServiceConnected toggles bound + re-enforce; onUnbind deactivates + restores (fail-safe); onDestroy fully shuts down via restoreScope (not cancellable by scope.cancel()).

Reliability & performance hardening

This PR includes a full reliability/performance hardening pass plus eight follow-up rounds fixing issues found in code review:

First pass: controller lifecycle, observer idempotency, crash-safe persisted baseline, tri-state SettingReadResult, zero per-MOVE allocation, cached Back-indicator position, replay generation token, haptic caching, DisposableEffect, removal of 1-second UI polling, off-main app enumeration, icon-cache lifecycle, single-map DataStore, watchdog 30s.

Follow-up 1–7: collector leaks, serialized state machine, crash-safe baseline, service-lifetime controller, shutdown fail-safe, gesture runtime readiness (all-or-nothing 3/3), transactional rebuild, pass-through apps get system navbar back.

Follow-up 8 (1 P1 fix): Separate runtime attachment from navigation availability in rebuild. rebuild() success = areRequiredGestureZonesAttached (3/3 physical only, not affected by passThrough). Navigation availability reported to the watchdog = isGestureNavigationAvailable (3/3 && !passThrough). Previously, a rebuild during pass-through mode would remove all 3 perfectly good windows, leaving no gesture zones when leaving the excluded app.

Safety / failure handling

  • No crash if the permission is missing/revoked.
  • No writes on unsupported devices.
  • No enforcement while Ogesture can't provide navigation — shouldEnforce requires isGestureNavigationAvailable (all 3 zones attached AND not in pass-through mode) in addition to master/bound/permission/device.
  • deactivateAndRestoreIfNeeded() restores the system nav whenever shouldEnforce=false, even if enforcing=false (process restart scenario).
  • State transitions serialized through a single Mutex (all enforcer calls, no exceptions).
  • Restore survives service scope.cancel() (independent restoreScope).
  • Pending restore retries automatically via the 30s watchdog when permission returns.
  • shutdown() uses restoreScope so onDestroy() can't cancel the final restore.
  • rebuild() is transactional — every exit path emits exactly one readiness callback.
  • Pass-through apps get the system navbar back; windows are preserved during rebuild so gestures recover immediately when leaving the excluded app.
  • rebuild() success means physical 3/3 attachment only; passThrough affects the watchdog notification but not the physical cleanup.

Privacy

WRITE_SECURE_SETTINGS is optional, granted manually via ADB, used only while the feature is enabled, and only to maintain the two documented navigation settings. No INTERNET permission; no data leaves the device. The crash-recovery baseline is a tiny snapshot stored in the app's private DataStore, removed after successful restore.

Validation

./gradlew clean
./gradlew testDebugUnitTest  -> BUILD SUCCESSFUL
./gradlew lintDebug          -> BUILD SUCCESSFUL
./gradlew assembleDebug      -> BUILD SUCCESSFUL
./gradlew assembleRelease    -> BUILD SUCCESSFUL

79 unit tests pass (0 failures, 0 errors): 42 SystemNavigationEnforcerTest cases + 36 GestureZoneLayoutTest cases (including areRequiredGestureZonesAttached 0/1/2/3 and isGestureNavigationAvailable with passThrough) + 1 example unchanged.

Test APK

The project owner real-device validated the previous PR #5 test build (pre-hardening). The post-hardening build (including all eight follow-up fix rounds) still requires a fresh device validation.

https://github.com/Dragonk/Ogesture/releases/tag/system-nav-watchdog-test-2026-08-26

APK asset: https://github.com/Dragonk/Ogesture/releases/download/system-nav-watchdog-test-2026-08-26/ogesture-system-navigation-watchdog-release.apk

This is a test build from my fork. Users with the official Ogesture build need to uninstall first (INSTALL_FAILED_UPDATE_INCOMPATIBLE). Users with a previous Dragonk test build can update with adb install -r.

Notes / risks

Dragonk added 21 commits August 25, 2026 17:09
Built test APKs are written to dist/ (gitignored, untracked). No APKs,
signing keys, or build artifacts are committed.
The gesture touch zones and visual indicators were TYPE_APPLICATION_OVERLAY
windows owned by a standalone foreground LifecycleService (EdgeOverlayService).
Those application overlays are hidden by HIDE_NON_SYSTEM_OVERLAY_WINDOWS on
secure system screens (Settings, SubSettings), so gestures stopped working
there, and Android showed the persistent "displaying over other apps"
notification because of SYSTEM_ALERT_WINDOW.

Extract the window logic from EdgeOverlayService into a reusable
EdgeOverlayController that is created, owned, and destroyed by the already-
enabled EdgeGestureAccessibilityService. Every window the controller and the
indicators add now uses WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
bound to the active AccessibilityService lifecycle: trusted windows that are
not hidden on secure screens and do not require SYSTEM_ALERT_WINDOW.

The controller is decoupled from the concrete service through a
GestureDispatcher interface (trigger + replay), keeping it testable. The
existing replay/interactivity workarounds (held-zone untouchable+alpha 0,
indicator-hide-before-inject grace) are preserved verbatim; behaviour change
is limited to the window type and ownership.

Remove the now-obsolete foreground service and boot receiver:
- delete EdgeOverlayService (foreground service, notification channel,
  canDrawOverlays watchdog, start/stop helpers)
- delete BootReceiver (only revived EdgeOverlayService; the AccessibilityService
  is re-bound by Android on process death/reboot without it)
- drop SYSTEM_ALERT_WINDOW, FOREGROUND_SERVICE, FOREGROUND_SERVICE_SPECIAL_USE,
  POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED from the manifest
- drop the specialUse service metadata and the EdgeOverlayService declaration
- drop the androidx.lifecycle.service / lifecycle-service catalog dependency
  (no LifecycleService remains)

The accessibility service watchdog now only re-checks unrestricted battery and
re-asserts the controller; the canDrawOverlays failure path is gone. Accessibility
privacy is unchanged (canRetrieveWindowContent stays false; only the foreground
package name is read for per-app pass-through). Lifecycle on service
bind/unbind/rebind, configuration and display-geometry changes, and rotation are
handled by the controller via the owner service.
With gesture windows owned by the AccessibilityService as TYPE_ACCESSIBILITY_OVERLAY,
SYSTEM_ALERT_WINDOW / canDrawOverlays / ACTION_MANAGE_OVERLAY_PERMISSION are no
longer needed. Remove that requirement from the setup card and the master-switch
gating, the periodic overlay-permission re-check, and the gestures-off overlay
toast path. Enabling gestures now depends only on the AccessibilityService being
bound and unrestricted battery usage.

- MainActivity: remove overlayGranted state, its ON_RESUME/1s poll, the
  ACTION_MANAGE_OVERLAY_PERMISSION intent, and the offReason overlay branch;
  canEnable no longer requires overlay. Replace the "restricted screens"
  remember bullet with an on-device/local-only one (the Settings limitation
  this change fixes is no longer true).
- PermissionsCard: drop the overlay RequirementRow and the onRequestOverlay
  callback; the card now lists accessibility + battery only.
- MainViewModel.setMasterEnabled: just write the datastore flag — the
  AccessibilityService observes the flow and attaches/detaches zones itself,
  so there is no foreground service to start/stop.
- CompatibilityScreen: drop the "System screens" note claiming gestures won't
  work on Settings (no longer accurate).
- strings: remove notification channel/running, permission_overlay,
  permission_grant, toast_gestures_off_overlay, compat_settings_*; reword the
  remember bullet to remember_on_device.
- ExampleInstrumentedTest: accept the .debug applicationId suffix produced by
  the debug build type alongside com.ogesture.
- README "How it works": gesture overlays are accessibility-overlay windows
  owned by Ogesture's accessibility service, so they work on secure system
  screens and do not require "Display over other apps". Permissions list
  reduced from three to two (accessibility + unrestricted battery). Remove the
  known limitation that gestures cannot work on Settings pages — this change
  addresses it.
- PRIVACY: permission table no longer lists SYSTEM_ALERT_WINDOW,
  FOREGROUND_SERVICE/FOREGROUND_SERVICE_SPECIAL_USE, POST_NOTIFICATIONS, or
  RECEIVE_BOOT_COMPLETED; accessibility now owns the edge overlays and performs
  the navigation actions.
Pure-data regression guards for the zone set (bottom/left/right), the
swipe-direction mapping per zone, the Back/Home/Recents action wiring (incl.
the bottom zone's swipe-and-hold Recents long action), and that every zone
has positive thickness/length. The accessibility-overlay migration must
preserve all of this; a silent change to the layout or action wiring now
fails here.
Four user-tunable geometry settings, persisted via DataStore Preferences and
clamped to valid ranges so a corrupted preference can never produce a zero-sized
or absurd overlay:
- back_activation_height_percent  (10..100%, step 10, default 80)
- bottom_activation_width_percent  (10..100%, step 10, default 80)
- back_edge_sensitivity           (0.5x..4.0x, step 0.25, default 1.0x)
- bottom_edge_sensitivity         (0.5x..4.0x, step 0.25, default 1.0x)

Sensitivity multiplies the BASE gesture-zone THICKNESS only (the starting
hit-region depth), never the swipe-distance threshold. Central constants live
on GestureZoneSettings so the UI, repository and controller share one source of
truth; clampPercent/clampSensitivity normalise raw persisted values.

Models also gains buildGestureZones(settings) -> List<ZoneConfig> (geometry from
settings, action/direction mapping static) and homeHandleWidthDp(percent) for the
Home handle width mapping. GESTURE_ZONES is kept as buildGestureZones(DEFAULT) so
the default layout and existing action-mapping tests are unchanged.
…e width

EdgeOverlayController observes a single combined runtime configuration
(masterEnabled + the four geometry settings) and rebuilds the zones exactly once
per committed change (distinctUntilChanged on the whole settings record), so a
geometry change updates the active overlay windows immediately — no app restart,
accessibility toggle, or reboot required. Pass-through is observed separately
since it changes interactivity, not geometry.

Side Back zones are now anchored toward the BOTTOM of the screen (measured
upward from just above the reserved bottom gesture band) instead of vertically
centered, matching the new activation-height semantics. Corner precedence is
deterministic: the bottom Home/Recents band has priority, and the side zones
are offset upward by the bottom band's height so the two never ambiguously
overlap in the corners — a vertical gesture in the bottom band reliably belongs
to Home/Recents, and Back works immediately above the band.

Sensitivity multiplies only the base zone thickness; the nav-bar inset is added
afterward and is never multiplied, preserving the slippery-nav-bar handoff. The
last applied settings are cached so a rotation (display-geometry change) re-lays
out with the same settings rather than racing the combined flow; no duplicate
windows are created (rebuild detaches all first).

HomeIndicator takes a barWidthDp parameter; its visual width follows the
configured bottom activation width (homeHandleWidthDp), staying a compact handle
rather than scaling with the invisible touch depth (sensitivity does not widen
it). Default 80% remains 108dp. The Back indicator is unchanged.
A new "Gesture areas" card (separate from the gesture-action rows) exposes the
four geometry settings with Material 3 sliders:
- Back: Activation height (10..100%), Edge sensitivity (0.5x..4.0x, shows dp)
- Home & Recents: Activation width (10..100%), Edge sensitivity (0.5x..4.0x, shows dp)
Home and Recents are noted as sharing the same bottom region.

Sliders use local Compose state while dragging and persist on
onValueChangeFinished (snapped to the allowed step), so dragging a thumb doesn't
spam DataStore writes or trigger dozens of overlay rebuilds per second; the
committed value updates the active gesture windows immediately. No Save button.

MainViewModel exposes gestureZoneSettings as a StateFlow and four write-through
setters; the repository remains the source of truth for clamped values. All
user-facing strings are resources.
Expand the gesture-zone regression tests for the configurable geometry:
- defaults (80/80/1x/1x -> 16dp/12dp) and defaultZones_matchLegacyFixedLayout
- percentage geometry (Back height 10/50/100, bottom width 10/50/100; one setting
  drives both sides)
- sensitivity (Back 0.5x->8dp/1x->16dp/4x->64dp; bottom 0.5x->6dp/1x->12dp/4x->48dp)
- sensitivity does not change swipe-distance threshold (model exposes no such field)
- clampPercent / clampSensitivity normalise out-of-range values; corrupted settings
  never produce zero/negative thickness
- geometry changes never alter action/direction mapping (Back/Home/Recents, directions)
- side vs bottom corner-overlap precedence: modelled purely, side zone ends at/above
  the bottom band top for every combination of height/width/sensitivity
- Home handle width mapping: 80%->108dp, 100%->135dp, 10%->24dp (min), 50%->~67dp,
  never below 24 or above 135
The visible Home/Recents bottom bar now spans the actual configured bottom
activation region 1:1 instead of a compact 108dp×pct/80 handle capped at 135dp.
At 100% the bar spans the full bottom activation width; at 10% it spans the
central 10%; 50% → central half; etc. — exactly matching the touch zone.

The width is no longer a separate dp-based formula: HomeIndicator takes a
resolved pixel width (barWidthPx) and the controller passes the SAME
resolvedBottomZone.widthPx from computeGestureZoneLayout (the single production
geometry helper that also sizes the bottom touch window), so the bar can never
drift from the actual activation region. The old homeHandleWidthDp() helper and
its DEFAULT/MIN/MAX_HANDLE_WIDTH_DP constants are removed.

Only horizontal width follows the activation percentage. Bottom edge
sensitivity still affects only the invisible vertical touch depth — it does
not change the bar's width, height, opacity, shape, or animation. Rotation /
display-geometry changes rebuild via the existing controller path, so the bar
re-sizes correctly across portrait/landscape with no stale cached width.

Builds on the shared computeGestureZoneLayout geometry (ScreenGeometry /
ZoneLayout) introduced for the corner-precedence fix, extending that single
source of truth to the indicator rather than adding a parallel formula.

Tests: the old compact-handle dp tests are replaced by regression tests using
the production helper — homeIndicatorWidth_equalsBottomTouchZoneWidth for
every 10..100% across representative widths/densities/sensitivities/nav insets,
plus explicit 1200px and 1080px percentage checks (10/50/80/100%).
The four Gesture Areas sliders no longer expand on the main dashboard; the main
screen now shows a compact entry card (parallel to the App compatibility entry)
that opens a dedicated Gesture areas screen.

Main screen:
- SectionHeader("Gesture areas") is kept; under it a GestureAreasEntryCard with
  the same Surface/extraLarge/surfaceContainerHigh visual language as
  CompatEntryCard — full-width click target, bodyMedium description, trailing ›.

Dedicated GestureAreasScreen (new ui/GestureAreasScreen.kt):
- Mirrors CompatibilityScreen: Scaffold + TopAppBar (back arrow, "Gesture areas"
  title, surface-colored), scrollable column, short info card, then the detailed
  settings card with the existing Back / Home & Recents grouping, labels, hints,
  and immediate-persistence slider behavior unchanged.
- GestureAreasCard, AreaSectionHeader, PercentSlider, SensitivitySlider are moved
  here (private) from MainActivity.kt, so MainActivity returns to being the
  dashboard + lightweight navigation.

Navigation: a mutually-exclusive AppScreen enum (MAIN / GESTURE_AREAS /
COMPATIBILITY) drives the existing local Compose navigation — no Navigation
Compose dependency. BackHandler returns to MAIN; system Back does the same;
CompatibilityScreen's internal App Picker navigation is unchanged.

No runtime gesture geometry, settings, DataStore keys, defaults, clamping,
computeGestureZoneLayout, EdgeOverlayController, indicator sizing, or overlay
architecture is modified — UI/navigation restructuring only. New strings:
gesture_areas_screen_title, gesture_areas_entry_desc.
Optional, off-by-default feature for Xiaomi/Redmi/POCO (MIUI/HyperOS): keep the
OEM three-button navigation bar hidden so Ogesture can fully replace it with
gesture navigation. HyperOS may reset the nav bar (e.g. when the system
resolver/chooser appears); instead of predicting each trigger, the watchdog
observes the actual Settings.Global keys force_fsg_nav_bar and hide_gesture_line
via ContentObservers and re-enforces force_fsg_nav_bar=1 / hide_gesture_line=1
when either changes — independent of what caused the reset.

Permission: adds WRITE_SECURE_SETTINGS (a privileged/development permission,
never requested at runtime). The user grants it once via ADB:
  adb shell pm grant com.ogesture android.permission.WRITE_SECURE_SETTINGS
The in-app System navigation screen shows the exact command (Windows PowerShell
+ macOS/Linux variants) with a copy button, the permission status, and re-checks
on resume so the user sees the granted state after running adb. All writes are
guarded by a permission check and wrapped in try/catch — no crash if the
permission is missing/revoked. Unsupported devices show a disabled switch.

Enforcement is gated on: user opt-in + supported device + WRITE_SECURE_SETTINGS
granted + master gestures on + AccessibilityService bound. The prior values of
both keys are captured once per enable and restored when the feature is disabled
(or Ogesture can no longer navigate), handling a previously-absent key by
deleting it. HyperOS race protection: immediate enforcement + 250ms/1000ms
delayed re-checks. Service rebind re-enforces automatically.

Architecture (preserves PR tanujnotes#3/tanujnotes#4): the watchdog follows the AccessibilityService
lifecycle (attach on connect, detach on unbind/destroy) — no foreground service,
no persistent notification, no SYSTEM_ALERT_WINDOW, no BootReceiver. Pure
enforcement logic is extracted into SystemNavigationEnforcer (Android-free, fully
unit-tested via a fake SecureSettingsGateway); SystemNavigationController owns
the lifecycle/DataStore-flow/ContentObserver/retry wiring. No INTERNET permission,
no root/Shizuku dependency, canRetrieveWindowContent remains false.

UI: a dedicated System navigation screen (parallel to App compatibility / Gesture
areas) reached from a compact entry card on the main dashboard; feature switch,
permission status, ADB setup instructions, device-support guard, and a
'needs gestures on' note. New strings only — no hardcoded user-facing text.

Tests: 16 new SystemNavigationEnforcerTest cases (unsupported/permission-missing
-> no writes; already-1 -> no redundant writes; restore-0 -> 1; both -> 1;
baseline-once; restore-on-disable; null-handling; idempotent; permission-revoked
safety; device-support detection). Existing 26 geometry tests unchanged.
43 unit tests, 0 failures.
…nav restore

P0 — lifecycle/coroutine leaks:
- Per-binding connectionScope in EdgeGestureAccessibilityService: a fresh SupervisorJob on
  every onServiceConnected, cancelled on unbind/destroy, so old controllers + their Flow
  collectors can't survive a reconnect and stack (no ghost rebuilds, no duplicate observers).
- EdgeOverlayController owns a controllerJob cancelled in stop(); collectors launched under it,
  so after teardown no DataStore emission can call back into a stale controller. start/stop
  idempotent.
- SystemNavigationController observer registration is idempotent (observerRegistered flag);
  stop cancels pending retry work.

P0 — crash-safe persisted navigation baseline:
- Original force_fsg_nav_bar/hide_gesture_line values are persisted in DataStore BEFORE the
  first enforcement write, so a process death mid-enforcement can still restore the real
  original state (not the enforced 1/1). Loaded on restart via loadPersistedBaseline without
  recapturing. Cleared only after a successful restore.
- Tri-state SettingReadResult (Present/Absent/Failure): a temporary SettingsProvider exception
  is never conflated with absence, never triggers a delete during restore. Read failure blocks
  enforcement (fail safe).
- State transitions serialized through a Mutex (single-writer); ContentObserver + retry +
  bind/unbind + settings changes all go through the same serialized path. I/O off-main.

P1 — gesture hot path:
- SwipeDetector replaces ArrayList<TouchSample> with preallocated FloatArray/LongArray
  buffers — zero per-MOVE allocation. Recording stops the moment a swipe crosses the
  activation threshold (a definite navigation gesture no longer needs replay history); normal
  successful gestures record only the few samples before the threshold. dispose() cancels the
  pending hold callback and releases the View reference.
- BackIndicator caches window Y at gesture start; pillY() reuses it — no getLocationOnScreen
  per MOVE.
- Replay uses a generation token: a completion/timeout callback from generation N can't mutate a
  rebuilt controller's state. Named runnables cancelled in stop/detachAll.
- Haptic effect + vibrator cached at controller init; gesture-trigger path is a direct call.
- Debug logs guarded by BuildConfig.DEBUG (no string construction in release hot paths).

P1 — UI:
- MainActivity: LaunchedEffect lifecycle observer replaced with DisposableEffect (removed on
  dispose); permanent 1-second polling loop removed. Accessibility bound state comes from a
  service StateFlow (event-driven); battery/enabled-in-settings re-checked on resume. A
  bounded post-update rebind grace replaces the infinite loop.
- SystemNavigationScreen: switch always allows turning an already-ON feature OFF, even if
  permission/master are gone (prerequisites only gate turning ON).

P1/P2 — background/overhead:
- AccessibilityService watchdog interval 10s→30s; refreshImePackages moved out of the watchdog
  loop (runs on connect only).
- CompatibilityScreen: app enumeration + label loading moved off main via produceState
  (Dispatchers.IO); icon cache cleared on picker dispose (lifecycle matches privacy doc).

P2 — efficiency:
- SettingsRepository.gestureZoneSettings derives all four values from a single store.data.map
  instead of four combined flows.

Tests: 48 unit tests (0 failures) — 22 SystemNavigationEnforcer cases (lifecycle, tri-state
read, baseline persistence, process-death recovery, pending-restore, permission transitions)
plus the existing 26 geometry tests. Clean build: testDebugUnitTest + lintDebug (0 errors) +
assembleDebug + assembleRelease all succeed.
1. P0: masterEnabled collector leaked across reconnects — moved from service-wide scope to
   connectionScope so it's cancelled on unbind.
2. P0: restore during onUnbind could be cancelled by connectionJob.cancel() — SystemNavigationController
   now has an independent restoreScope (SupervisorJob) for fail-safe restore that survives connection
   cancellation. The system-nav buttons come back even as the service is being torn down.
3. P0: pending restore after permission loss/restart wasn't completed — added attemptPendingRestore()
   to the enforcer, called from controller.start() when the preference is OFF but a baseline exists.
   Restores automatically when permission returns; stays pending if it can't.
4. P0/P1: rebuild during replay left zones permanently FLAG_NOT_TOUCHABLE — detachAll() now calls
   cancelReplayCallbacks() + resets replaying/hideIndicatorsForReplay/zonesHeld before rebuilding.
5. P1: SwipeDetector ACTION_UP no longer recorded — long-press (DOWN→UP, no MOVE) replayed as
   ~50ms tap instead of mirroring the hold; wrong-direction drag lost its endpoint. ACTION_UP now
   adds its sample before building SampleView.
6. P1: SettingsProvider I/O on main thread — enforcer reads/writes now wrapped in
   withContext(Dispatchers.IO); reassert is suspend.

UI: bound true→true now refreshes accessibilityStatus immediately (not only on next ON_RESUME).

Tests: 53 unit tests (0 failures) — 5 new follow-up regression cases:
attemptPendingRestore_restoresWhenPermissionReturns, attemptPendingRestore_noBaseline_isNoOp,
attemptPendingRestore_noPermission_staysPending, pendingRestoreAfterRestart_loadsAndRestoresOriginalBaseline,
restartWithEnforcementOn_doesNotRecaptureEnforcedValues.
…sive teardown

1. All enforcer calls now go through one mutex — restoreScope paths (stop, attemptPendingRestore,
   retryPendingRestoreIfNeeded, observer, retry) all acquire mutex.withLock; no parallel
   restore/stopEnforcing race.
2. Double-restore from onUnbind+stop removed: onServiceUnbound no longer calls recomputeEnforcement;
   stop() is the single serialized restore path.
3. attemptPendingRestore only when preference is OFF: start() checks repo.hideSystemNavigation.first()
   before calling it, so a restart with the feature ON doesn't briefly restore 0/0 then re-enforce 1/1.
4. Baseline kept in RAM after a failed restore: stopEnforcing/attemptPendingRestore only clear the
   in-memory baseline on success; a failed restore sets pendingRestore=true for a later retry.
5. Actual restore-after-permission-return trigger: 30s watchdog calls retryPendingRestoreIfNeeded
   (no-op when not pending, no SettingsProvider polling); enforcer retries the restore when permission
   has returned. No longer only-on-restart.
6. Defensive teardown in onServiceConnected: extracted tearDownConnection() that stops old controllers,
   cancels connectionJob, removes watchdog — used by onServiceConnected, onUnbind, onDestroy so a
   reused service can't stack controllers/observers/windows.

Tests: 6 new regression cases (attemptPendingRestore-isNoOp-when-enforcing, stopEnforcing-sets-pending,
retryPendingRestore-restores-when-permission-returns, retryPendingRestore-no-op-when-not-pending,
retryPendingRestore-stays-pending-without-permission, startEnforcing-after-pending-captures-fresh-baseline).
…riter mutex

Three P0 fixes from the third code review round:

1. P0: Fail-safe restore when Ogesture can't navigate — added deactivateAndRestoreIfNeeded()
   to the enforcer. recomputeEnforcement's else branch now ALWAYS calls it: if enforcing, it
   stops + restores; if not enforcing but a baseline exists (e.g. process restart with the
   preference ON but master off / permission missing), it still restores the baseline so the
   system nav buttons come back. The previous stopEnforcing() guard (if (!enforcing) return)
   meant a fresh enforcer with enforcing=false would skip the restore entirely.

2. P0: attemptPendingRestore sets pendingRestore when permission missing — the previous code
   returned without setting the flag, so after a restart with the preference OFF + no permission,
   retryPendingRestoreIfNeeded would never fire (pendingRestore=false). Now attemptPendingRestore
   sets pendingRestore=true when it can't restore, so the 30s watchdog retries when permission returns.

3. P0: SystemNavigationController is now a SERVICE-LIFETIME singleton, not per-binding. One enforcer,
   one mutex, one baseline state across all bind/unbind cycles. Previously each binding created a
   new controller with its own mutex, so old↔new controller races were possible (old restore racing
   new enforce, each under a different mutex). Now onServiceConnected just toggles bound + recompute;
   the controller's state survives unbind. shutdown() is called only in onDestroy.

Also: loadPersistedBaseline is now under the mutex; all enforcer calls go through one mutex with no
exceptions (single-writer). tearDownOverlayConnection replaces tearDownConnection (overlay-only;
sysNavController is service-lifetime). Added shutdown() for full teardown in onDestroy.

Tests: 7 new regression cases (deactivateAndRestoreIfNeeded-restores-when-enforcing,
deactivateAndRestoreIfNeeded-restores-when-not-enforcing-but-baseline-exists,
deactivateAndRestoreIfNeeded-no-baseline-isNoOp, deactivateAndRestoreIfNeeded-no-permission-sets-pending,
restartWithEnforcementOn-masterOff-restores-baseline, attemptPendingRestore-no-permission-sets-pending,
loadPersistedBaseline-then-restart-with-enforcement-on-enforces). 66 unit tests total, 0 failures.
… barrier

Four fixes from the fourth code review round:

1. P0: Shutdown restore not cancellable — SystemNavigationController now has its own
   restoreScope (SupervisorJob, independent of the service scope). shutdown() launches the
   restore on restoreScope, so scope.cancel() in onDestroy() cannot cancel it. The system
   nav buttons come back even as the service is being destroyed.

2. P0: No double restore in onDestroy — onDestroy() no longer calls tearDownOverlayConnection
   (which would call onServiceUnbound, launching a restore that scope.cancel() would cancel).
   Instead it stops the overlay controller directly and calls sysNavController.shutdown()
   which does the single fail-safe restore on restoreScope.

3. P0: gestureRuntimeReady gate — EdgeOverlayController now reports runtime readiness via
   onRuntimeReadyChange callback. SystemNavigationController gates shouldEnforce on
   gestureRuntimeReady: if any gesture zone fails to attach (addView throws), the system nav
   buttons are NOT hidden — Ogesture can't replace them without working gesture zones.
   onGestureRuntimeReady(false) triggers deactivateAndRestoreIfNeeded.

4. P1: Initialization barrier — start() reads the first DataStore snapshot (hideSystemNavigation
   + masterEnabled) synchronously before allowing onServiceBound to recompute, so a process
   restart with the feature ON doesn't briefly restore 0/0 then re-enforce 1/1 (navbar flash).
   onServiceBound is a no-op until initialized=true.

Tests: 3 new regression cases (deactivateAndRestoreIfNeeded-onShutdown-restoresEvenIfNotEnforcing,
deactivateAndRestoreIfNeeded-repeatedCallsAreSafe, startEnforcing-failsIfNoGestureRuntimeReady).
69 unit tests total, 0 failures.
…tification

1. P0: gestureRuntimeReady now requires ALL 3 required zones (BOTTOM + LEFT_EDGE +
   RIGHT_EDGE), not isNotEmpty(). If any zone fails to addView, the remaining partial set
   is immediately removed (all-or-nothing: 3/3 = ready, 0/3 = not, never 1/3 or 2/3).
   Extracted as areRequiredGestureZonesAttached() pure function in Models.kt, unit-tested
   for 0/3, 1/3, 2/3, 3/3.

2. P1: rebuild() now calls detachAll(notifyRuntime=false) so the system-nav controller
   doesn't see a transient false→true flash on every rotation/geometry change. One final
   readiness notification is emitted after the rebuild is complete, based on the all-or-nothing
   check. Real teardown (stop, unbind, master off) still calls detachAll(notifyRuntime=true).

Tests: 4 new areRequiredGestureZonesAttached cases (3/3 true, 2/3 false, 1/3 false, 0/3 false).
73 unit tests total, 0 failures.
1. P0: rebuild() now wrapped in try/catch/finally — every exit path (including
   unexpected exceptions from currentGeometry, computeGestureZoneLayout, View creation,
   BackIndicator/HomeIndicator/SwipeDetector construction, layoutParamsFor) guarantees
   exactly one final onRuntimeReadyChange callback. An exception after detachAll(false)
   can no longer leave gestureRuntimeReady=true with 0 gesture windows, which would have
   left the system navbar hidden with no working Ogesture navigation.

2. Cleanup at !allAttached now works regardless of activeViews.isNotEmpty() — if all 3
   addView calls fail, detectors/indicators are still cleaned (previously skipped because
   the guard was !allAttached && activeViews.isNotEmpty()).

Release notes and PR description updated to reflect 73 tests, current SHA, and follow-up 5+6.
P0: When an app is in the compatibility exclusion list (pass-through mode),
Ogesture deliberately ignores all touches — the gesture zones become
FLAG_NOT_TOUCHABLE. But gestureRuntimeReady was still true (all 3 windows
physically exist), so the watchdog kept the system navbar hidden. The user was
left with NO navigation at all in that app.

Fix: navigation availability now checks both zones attached AND !passThrough:
  isGestureNavigationAvailable(activeZones, passThrough) = 3/3 && !passThrough

The pass-through collector now calls reportNavigationAvailability() after
toggling passThrough, so entering an excluded app restores the system navbar
and leaving it re-hides it. zonesHeld/replaying are NOT included — those are
transient during a gesture and must not trigger a restore.

Added isGestureNavigationAvailable pure function in Models.kt, unit-tested for:
3/3+noPT=true, 3/3+PT=false, 2/3+false=false, 1/3+false=false, 0/3+false=false,
0/3+PT=false. 79 unit tests total, 0 failures.
… in rebuild

P1 regression: rebuild() used isGestureNavigationAvailable (which includes
!passThrough) as the success criterion for physical zone attachment. When
passThrough=true (excluded app), a rotation/settings change triggered rebuild,
all 3 zones were physically created, but success=false (because passThrough),
so finally{} removed all 3 perfectly good windows. Gestures wouldn't recover
when leaving the excluded app until another rebuild/rebind.

Fix: rebuild success = areRequiredGestureZonesAttached (3/3 physical only).
Navigation availability (reported to the watchdog via reportNavigationAvailability)
= isGestureNavigationAvailable (3/3 && !passThrough). Two separate concepts:
  - success/cleanup: did the windows physically attach?
  - watchdog notification: can Ogesture currently replace the system navbar?

79 unit tests, 0 failures.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant