Add optional HyperOS system navigation watchdog - #5
Open
Dragonk wants to merge 21 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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=1andhide_gesture_line=1inSettings.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 settingforce_fsg_nav_bar=1again 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):
com.android.intentresolver/.ResolverActivityto appear.force_fsg_nav_bar=1hid it again.The project owner also confirmed the first PR #5 test build (pre-hardening) worked on his real device.
UX
ADB setup
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_SETTINGSadded to the manifest (withtools:ignore="ProtectedPermissions").SystemNavigationEnforcer(Android-Context-free, unit-tested).SystemNavigationControlleris a service-lifetime singleton (one enforcer, one mutex, one baseline state across all bind/unbind cycles) with its ownrestoreScopefor fail-safe restore that survivesscope.cancel().EdgeGestureAccessibilityService:onServiceConnectedtoggles bound + re-enforce;onUnbinddeactivates + restores (fail-safe);onDestroyfully shuts down viarestoreScope(not cancellable byscope.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
shouldEnforcerequiresisGestureNavigationAvailable(all 3 zones attached AND not in pass-through mode) in addition to master/bound/permission/device.deactivateAndRestoreIfNeeded()restores the system nav whenevershouldEnforce=false, even ifenforcing=false(process restart scenario).Mutex(all enforcer calls, no exceptions).scope.cancel()(independentrestoreScope).shutdown()usesrestoreScopesoonDestroy()can't cancel the final restore.rebuild()is transactional — every exit path emits exactly one readiness callback.rebuild()success means physical 3/3 attachment only; passThrough affects the watchdog notification but not the physical cleanup.Privacy
WRITE_SECURE_SETTINGSis optional, granted manually via ADB, used only while the feature is enabled, and only to maintain the two documented navigation settings. NoINTERNETpermission; 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
79 unit tests pass (0 failures, 0 errors): 42
SystemNavigationEnforcerTestcases + 36GestureZoneLayoutTestcases (includingareRequiredGestureZonesAttached0/1/2/3 andisGestureNavigationAvailablewith 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 withadb install -r.Notes / risks
main.