Skip to content

feat(android): device location sync, GMS mock support, and mobile transport hardening - #479

Merged
Ryanmello07 merged 2 commits into
urnetwork:mainfrom
full-bars:fix/location-sync-and-transport-hardening
Sep 6, 2026
Merged

feat(android): device location sync, GMS mock support, and mobile transport hardening#479
Ryanmello07 merged 2 commits into
urnetwork:mainfrom
full-bars:fix/location-sync-and-transport-hardening

Conversation

@full-bars

@full-bars full-bars commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR addresses critical mobile transport reliability issues and completes the location synchronization architecture on URNetwork Connect for Android:

  1. Mobile Handover & Transport Recovery: Prevents socket abort hangs during physical radio changes (Wi-Fi ↔ Cellular transitions), mitigates control-plane stalls against unrouteable IPv6 addresses under IPv4-only VPN capture kill switch policies, flushes stalled DoH connection pools on network loss, and debounces contract error flapping in the UI.
  2. Device Location Sync & Feeder Hardening: Decouples the mock location SDK feeder into an application-scoped singleton (MockLocationFeeder), strictly isolates simulation to active client connections, adds an aligned URSwitch toggle to SettingsScreen.kt, debounces provider reconnects with generation-tracked grace periods, and provides zero-ambiguity recovery for orphaned mock provider states.
  3. Google Play Services (GMS) FLP Mocking & Leak Prevention: Resolves physical GPS leaks in Google Chrome on stock Android by declaring approximate location permission (ACCESS_COARSE_LOCATION) and establishing an interactive runtime grant flow so Google Play Services' FusedLocationProviderClient accepts mock mode.

Important

Mobile VPN Capture & IPv6 Blackholing

When the VPN tunnel runs in capture mode with the Kill Switch enabled, Android intentionally blackholes IPv6 traffic to prevent leaks. The transport recovery policy and DNS-over-HTTPS (DoH) connection lifetimes now cleanly reconcile across physical radio changes rather than stalling on dead sockets or unrouteable IPv6 addresses.


Important

The Android Location Subsystem Split: Why Some Devices Require Location Permissions

A core subtlety of Android is that two completely different location architectures exist depending on whether Google Play Services (GMS) is present:

  1. De-Googled / AOSP Android (GrapheneOS, CalyxOS, LineageOS, F-Droid builds):

    • Applications query the platform AOSP LocationManager (gps, network, and fused).
    • Setting mock locations on AOSP (addTestProvider, setTestProviderLocation) requires zero runtime permissions. Security is governed exclusively by the system AppOp (android:mock_location) granted via Developer options > Select mock location app.
    • On these devices, URnetwork requires zero location permissions requested or granted.
  2. Stock Android with Google Play Services (Pixel, Samsung, Motorola, etc.):

    • Modern browsers (Google Chrome) and apps bypass AOSP and directly query Google Play Services' proprietary FusedLocationProviderClient (FLP).
    • At the Binder IPC interface inside com.google.android.gms, Google enforces that any process invoking setMockMode(true) or setMockLocation() must hold ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION.
    • In legacy builds where these permissions were stripped (tools:node="remove"), Google Play Services threw SecurityException: uid does not have any of [android.permission.ACCESS_COARSE_LOCATION, android.permission.ACCESS_FINE_LOCATION], rejected mock mode, and served Chrome the phone's hardware GPS.

Dynamic Adaptation in URnetwork:
The engine dynamically inspects the host environment via supportsFusedMockLocation(context):

  • On GMS devices: It guides the user to grant approximate location access. Because URnetwork requests exclusively ACCESS_COARSE_LOCATION, Android 12+ presents a simplified "Approximate location only" prompt without ever offering or exposing precise hardware GPS (ACCESS_FINE_LOCATION). This fully satisfies Google Play Services FLP Binder IPC checks while guaranteeing zero access to high-accuracy device GPS.
  • On de-Googled devices: The permission requirement is completely bypassed, preserving a zero-permission footprint.

Tip

Privacy Invariant: One-Way Outbound Write Only

URnetwork never queries or reads the physical device location. There are zero calls to requestLocationUpdates(), getLastKnownLocation(), or getCurrentLocation(). Location data flows strictly outbound: coordinates from the authenticated exit provider are injected into the mock location engine.


Tip

Toggling the switch ON when setup is incomplete (e.g., Developer options not enabled, mock location app not selected, or GMS permission missing) automatically routes the user directly to Route.MockLocationGuide. Once prerequisites are satisfied, the toggle controls active location simulation in-place.


Logical Impact & Changes

Stability & Transport Hardening

  • Mobile Handover & Transport Recovery (MainApplication.kt):
    • Hardened transport recovery handling during radio changes so physical link loss purges stale connection handles.
    • Aligned control-plane family handling with mobile VPN capture policies to prevent Happy Eyeballs stalls against blackholed IPv6 routes.
  • Contract Status UI Debouncing (MainApplication.kt & DeviceLocal.kt):
    • Deduplicated contract status event logging and smoothed UI notifications so transient multi-client candidate route negotiation errors do not flicker error banners.
    • Reset cached contract status logging state across device teardown and initialization to prevent stale state carryover.
  • Background Feeder Decoupling & Client Mode Isolation (MockLocationFeeder.kt):
    • Decoupled SDK feeder into a dedicated @Singleton MockLocationFeeder started in MainApplication.onCreate().
    • Subscribes to both DeviceLocal.addTunnelChangeListener and DeviceLocal.addConnectChangeListener.
    • Enforces client-only execution: evaluates isClientConnected = device.connectEnabled && device.tunnelStarted. If Provide Mode is running (e.g. Provide mode = Always), location mocking remains disarmed.
    • Added @Synchronized lifecycle methods and listener callbacks with currentDevice identity validation to prevent race conditions during device swaps.

Bug Fixes & Location Hardening

  • Declared Approximate Location Permission Only (AndroidManifest.xml):
    • Declared exclusively ACCESS_COARSE_LOCATION and declared hardware.location.gps as required="false", omitting ACCESS_FINE_LOCATION. This satisfies GMS FLP Binder IPC checks without triggering precise location prompts or Play Store privacy scrutiny.
  • GMS FLP Gating (MockLocationState.kt & MockLocationEligibility.kt):
    • Added hasLocationPermission(context) helper and NEEDS_LOCATION_PERMISSION status, ensuring GMS FLP mock mode only attempts arming when authorized.
    • Defaulted locationPermissionGranted to false in resolveMockLocationStatus while maintaining existing behavior when requiresLocationPermission = false.
  • Provider Debounce Grace Period (MockLocationFeeder.kt):
    • Added a 10-second debounce window with atomic generation tracking (graceGeneration) so brief network reconnects or provider grid re-dials do not prematurely disarm mock GPS and reveal the physical location.
  • Active Device Scoping (MockLocationFeeder.kt):
    • Bound retained target coordinates strictly to the active Device instance (currentDevice !== device), ensuring clean state resets on device replacement.

Features & UI Alignment

  • Settings Screen Alignment (SettingsScreen.kt):
    • Added aligned URSwitch to the "Device location sync" row, matching vertical alignment, sizing, and spacing of "Kill switch" and "Cellular network".
    • Preserved tap navigation on the label and chevron to Route.MockLocationGuide.
    • Added contextual status subtitle below the row when enabled or orphaned (Syncing with <City>, Setup required, Waiting for provider location, or GPS frozen — action needed).
  • Mock Location Guide (MockLocationGuideScreen.kt):
    • Added an interactive "Grant permission" step using ActivityResultContracts.RequestMultiplePermissions() on devices where GMS FLP mocking requires it.
    • Added an inline URSwitch in the "Ready" state banner once all system prerequisites are met.
    • Dynamically updates banner text to reflect active syncing status (Syncing with <City>) or waiting state.
    • Gated the Ready/Active banner on state.status != MockLocationStatus.ORPHANED so contradictory green waiting text is suppressed during orphaned state.

Zero-Ambiguity Orphan Recovery

  • Prominent Recovery Card (MockLocationGuideScreen.kt & strings.xml):
    • Placed the recovery card at the top of the guide when MockLocationStatus.ORPHANED is active:
      • Settings status: GPS frozen — action needed.
      • Guide title: Simulated location is stuck.
      • Guide detail: Explains that URnetwork was deselected while active so Android locked simulated GPS, and informs the user how to recover.
      • Recovery action: Dedicated [Open developer options] button launching Android system developer settings.
    • Suppressed duplicate [Open developer options] button in Step 2 while in ORPHANED status to eliminate redundant UI actions.

Visual Preview & UI Context

Before (Navigation row only) After (Aligned URSwitch + Live Sync Status)
Settings Screen Before Settings Screen After
  • Left (Before): Plain navigation row with no inline toggle or sync status visibility.
  • Right (After): Features the newly aligned URSwitch toggle and live status subtitle (highlighted by arrows: active toggle and Syncing with Berlin, State of Berlin), matching the vertical cadence and spacing of "Kill switch" and "Allow providing on cellular network". Dynamic feedback updates seamlessly (Syncing with <City>, Waiting for provider location, Setup required, or GPS frozen — action needed).

What's Changed

Full Changelog

main...full-bars:android:fix/location-sync-and-transport-hardening

…nsport hardening

- Decouple mock location feeder into an application-scoped singleton (MockLocationFeeder)
  isolated strictly to active client tunnels, with 10s provider debounce and generation tracking.
- Declare ACCESS_COARSE_LOCATION (without fine location or hardware GPS requirement) and gate
  GMS FusedLocationProviderClient mock mode on runtime grant, resolving Google Chrome leaks
  on stock Android without exposing precise GPS.
- Add aligned URSwitch toggle and dynamic status feedback to Settings screen, matching design
  system vertical alignment and spacing.
- Introduce interactive permission step and zero-ambiguity orphaned mock provider recovery flow
  with direct developer options shortcut in MockLocationGuideScreen.
- Deduplicate contract status logging and reset cached state across device teardown/initialization.
@full-bars
full-bars force-pushed the fix/location-sync-and-transport-hardening branch from fc4ff9c to b9bbb2c Compare September 6, 2026 04:59
Review fixes on top of urnetwork#479. The feature and its architecture are the
contributor's; these are the corrections that came out of reviewing it.

B1 The permission gate sat above the whole engine. resolveMockLocationStatus
   returned NEEDS_LOCATION_PERMISSION before it could return ACTIVE, and
   reconcile() gates all arming on ACTIVE, so a missing grant stopped arm()
   entirely -- including the AOSP test providers, which need no runtime
   permission at all (MOCKLOCATION.md 8). Every GMS-flavor user with the
   toggle already on would have lost the feature silently on upgrade, since
   the permission was tools:node="remove"-blocked before this PR and nobody
   has it granted. The grant now gates only the optional FLP mirror, which is
   what 3.2 always said it should: an optional, gracefully-degrading
   enhancement. NEEDS_LOCATION_PERMISSION stays as an advisory the guide
   reads; the resolver no longer returns it.

B2 src/main merges into all four flavors, so the F-Droid build shipped a
   location permission src/ungoogle can never use. src/github now removes it.
   Restored the ACCESS_FINE_LOCATION and hardware.location.gps guards the PR
   deleted without needing to (dac1d14 put them there deliberately), and
   declared hardware.location not-required, since COARSE otherwise implies it
   as required and Play would filter out the TVs the block above supports.

B4 arm() is the only thing that reclaims orphaned test providers, and
   reconcile() could only reach disarm() when already posting. With B1 that
   was reachable: providers registered, nothing able to reclaim them, device
   location frozen for every app. Startup now reclaims whenever the resolved
   status is not ACTIVE, and disarm() also runs when a claimed provider set
   survives. disarm() only logs when it really stopped a posting run, so the
   orphaned retry path does not spam.

B5 MOCKLOCATION.md said the opposite of what ships in six places and is cited
   by section number from nine source files. Reconciled, with 6.7 added for
   the grace window; nothing renumbered. Whether GMS enforces the permission
   at runtime is still open -- the vendor prose names only ACCESS_MOCK_LOCATION
   while the annotation is CLASS-retention -- and is recorded as open. Getting
   it wrong is now harmless, which is the point of B1.

Also: consume the permission result and route to App info once the system
dialog will not appear again; give the section's ORPHANED row its weight so
the chevron measures; add an ERROR_TRANSIENT arm to the settings subtitle;
throttle the 1 Hz setMockLocation failure log; drop the PR's @PARAM KDoc,
which four files in ui/ have and all four are this PR's; extract the grace
window into a pure MockLocationGracePolicy so it is testable at all, with the
10s figure derived from 6.1 rather than asserted.

Known, and blocking a release rather than this branch: the new strings are
hand-written into generated files. build.sh regenerates res/values*/strings.xml
from ../localizations before every pipeline build, so the seven mock_location_*
keys must land in the store first or a regeneration drops them. CI cannot catch
this -- the workflow says so at build-and-test.yml:31. The locale edits an
earlier pass made were reverted for that reason; Android falls back to values/
for a missing key, so English-only is the safe state until the store syncs.

Verified locally against a from-scratch toolchain (JDK 21, SDK 36, NDK
29.0.14206865, gradle 9.5.1, locally built URnetworkSdk.aar):
  testGithubDebugUnitTest        313 tests, 0 failures (was 296)
  compile{Github,Play,Solana_dapp,Ethos_dapp}ReleaseKotlin  ok
  assembleGithubDebug            ok
  merged manifest, per flavor    github COARSE absent; play/solana/ethos
                                 present; FINE absent everywhere
That last one is the check CI never runs: it builds only assembleGithubDebug
plus compile*ReleaseKotlin, and compileXReleaseKotlin never runs
processXReleaseManifest, so the flavor permission set is unverified upstream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VYp99vhW9soGoTmNGVYJk
@Ryanmello07
Ryanmello07 merged commit efb2797 into urnetwork:main Sep 6, 2026
2 checks passed
Ryanmello07 added a commit that referenced this pull request Sep 6, 2026
Generated output of urnetwork/localizations#<store PR>; no hand edits.
`npm run gen:android` wrote all 19 res/values*/strings.xml.

#479 added seven mock_location_* strings directly to res/values/strings.xml.
These files are generated, and build.sh regenerates from ../localizations
before every pipeline build, so the release build dropped all seven and
failed on nine unresolved R.string.mock_location_* references across
MockLocationGuideScreen.kt, MockLocationSection.kt and SettingsScreen.kt.
CI stayed green throughout: it runs gradle directly and never runs the
generator, which build-and-test.yml:31 already documents as a known gap.

Reproduced on main before the store keys landed -- `npm run gen:android`
took the mock_location_* set from 24 keys to 17 and
:app:compileGithubReleaseKotlin failed. With the keys in the store the same
regeneration is clean.

The seven keys now arrive translated in all 18 locales rather than English
only, and mock_location_error_cleanup_required drops out: the ORPHANED copy
moved to the _stuck_ strings and it has no kotlin references left, so it is
retired in the store.

Verified after regeneration: 345 unit tests, 0 failures; github, play,
solana_dapp and ethos_dapp release variants all compile; assembleGithubDebug
packages; merged manifests unchanged (github still has no
ACCESS_COARSE_LOCATION, the three GMS flavors still do, no ACCESS_FINE_LOCATION
anywhere).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VYp99vhW9soGoTmNGVYJk
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.

2 participants