From b9bbb2c253e6a2be52f0fc9c8d2097c17559c55e Mon Sep 17 00:00:00 2001 From: full-bars <45684698+full-bars@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:48:26 -0700 Subject: [PATCH 1/2] feat(android): device location sync, GMS mock support, and mobile transport 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. --- .../location/FusedMockLocationSupport.kt | 50 +++- .../location/FusedMockLocationSupport.kt | 50 +++- app/app/src/main/AndroidManifest.xml | 10 +- .../com/bringyour/network/MainApplication.kt | 36 ++- .../location/MockLocationController.kt | 12 +- .../location/MockLocationEligibility.kt | 11 + .../network/location/MockLocationFeeder.kt | 232 ++++++++++++++++++ .../network/location/MockLocationState.kt | 18 +- .../MockLocationGuideScreen.kt | 126 ++++++++-- .../providerlocations/MockLocationSection.kt | 52 +++- .../MockLocationViewModel.kt | 95 +------ .../network/ui/settings/SettingsScreen.kt | 188 ++++++++++++-- app/app/src/main/res/values/strings.xml | 5 + .../location/FusedMockLocationSupport.kt | 50 +++- .../network/location/MockLocationStateTest.kt | 41 ++++ .../location/FusedMockLocationSupport.kt | 19 ++ 16 files changed, 818 insertions(+), 177 deletions(-) create mode 100644 app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt diff --git a/app/app/src/ethos_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt b/app/app/src/ethos_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt index 707d249fe..2b3813e1f 100644 --- a/app/app/src/ethos_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt +++ b/app/app/src/ethos_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt @@ -2,17 +2,20 @@ package com.bringyour.network.location import android.content.Context import android.location.Location +import android.util.Log import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.location.LocationServices -// GMS layer of the mock location engine (MOCKLOCATION.md §3.2): mirror the -// mock fix into the Google Play services fused location provider so -// FLP-based consumers (Google Maps et al.) reliably follow it. Requires the -// same developer-options selection as the platform path — no extra user step. -// Every call is fire-and-forget: the Task results are intentionally ignored -// and failures surface through the platform path instead. +private const val TAG = "FusedMockLocation" +/** + * Checks whether Google Play Services is available on the device to support mocking + * the FusedLocationProviderClient directly (MOCKLOCATION.md §3.2). + * + * @param context Application or component context used to query Google Play Services availability. + * @return True if Google Play Services is installed, enabled, and operational. + */ fun supportsFusedMockLocation(context: Context): Boolean { return try { GoogleApiAvailability.getInstance() @@ -22,30 +25,53 @@ fun supportsFusedMockLocation(context: Context): Boolean { } } -// setMockMode is device-global (affects all FLP clients in every process) — -// callers must always exit mock mode on every teardown path. +/** + * Sets whether the Google Play Services Fused Location Provider is in mock mode. + * + * Entering mock mode clears FLP caches and ensures FLP clients only receive mock locations + * pushed through [setFusedMockLocation]. Exiting mock mode restores standard provider fusion. + * + * @param context Application context used to obtain the FusedLocationProviderClient. + * @param enabled True to engage mock mode; false to disengage and restore normal location. + */ fun setFusedMockMode(context: Context, enabled: Boolean) { if (!supportsFusedMockLocation(context)) { return } try { LocationServices.getFusedLocationProviderClient(context).setMockMode(enabled) + .addOnSuccessListener { + Log.i(TAG, "GMS fused location provider mock mode set to $enabled") + } + .addOnFailureListener { e -> + Log.w(TAG, "GMS fused location provider setMockMode($enabled) failed: ${e.message}") + } } catch (e: SecurityException) { - // not the selected mock location app + Log.w(TAG, "GMS setMockMode security exception: ${e.message}") } catch (e: Throwable) { - // broken/ancient play services; the platform path still works + Log.w(TAG, "GMS setMockMode unexpected error: ${e.message}") } } +/** + * Pushes a mock fix to the Google Play Services Fused Location Provider so FLP-based consumers + * (such as Google Chrome and Google Maps) receive the synced location. + * + * @param context Application context used to obtain the FusedLocationProviderClient. + * @param location The complete [Location] fix containing monotonic timestamps and coordinates. + */ fun setFusedMockLocation(context: Context, location: Location) { if (!supportsFusedMockLocation(context)) { return } try { LocationServices.getFusedLocationProviderClient(context).setMockLocation(location) + .addOnFailureListener { e -> + Log.w(TAG, "GMS fused location provider setMockLocation failed: ${e.message}") + } } catch (e: SecurityException) { - // not the selected mock location app + Log.w(TAG, "GMS setMockLocation security exception: ${e.message}") } catch (e: Throwable) { - // broken/ancient play services; the platform path still works + Log.w(TAG, "GMS setMockLocation unexpected error: ${e.message}") } } diff --git a/app/app/src/google/java/com/bringyour/network/location/FusedMockLocationSupport.kt b/app/app/src/google/java/com/bringyour/network/location/FusedMockLocationSupport.kt index 707d249fe..2b3813e1f 100644 --- a/app/app/src/google/java/com/bringyour/network/location/FusedMockLocationSupport.kt +++ b/app/app/src/google/java/com/bringyour/network/location/FusedMockLocationSupport.kt @@ -2,17 +2,20 @@ package com.bringyour.network.location import android.content.Context import android.location.Location +import android.util.Log import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.location.LocationServices -// GMS layer of the mock location engine (MOCKLOCATION.md §3.2): mirror the -// mock fix into the Google Play services fused location provider so -// FLP-based consumers (Google Maps et al.) reliably follow it. Requires the -// same developer-options selection as the platform path — no extra user step. -// Every call is fire-and-forget: the Task results are intentionally ignored -// and failures surface through the platform path instead. +private const val TAG = "FusedMockLocation" +/** + * Checks whether Google Play Services is available on the device to support mocking + * the FusedLocationProviderClient directly (MOCKLOCATION.md §3.2). + * + * @param context Application or component context used to query Google Play Services availability. + * @return True if Google Play Services is installed, enabled, and operational. + */ fun supportsFusedMockLocation(context: Context): Boolean { return try { GoogleApiAvailability.getInstance() @@ -22,30 +25,53 @@ fun supportsFusedMockLocation(context: Context): Boolean { } } -// setMockMode is device-global (affects all FLP clients in every process) — -// callers must always exit mock mode on every teardown path. +/** + * Sets whether the Google Play Services Fused Location Provider is in mock mode. + * + * Entering mock mode clears FLP caches and ensures FLP clients only receive mock locations + * pushed through [setFusedMockLocation]. Exiting mock mode restores standard provider fusion. + * + * @param context Application context used to obtain the FusedLocationProviderClient. + * @param enabled True to engage mock mode; false to disengage and restore normal location. + */ fun setFusedMockMode(context: Context, enabled: Boolean) { if (!supportsFusedMockLocation(context)) { return } try { LocationServices.getFusedLocationProviderClient(context).setMockMode(enabled) + .addOnSuccessListener { + Log.i(TAG, "GMS fused location provider mock mode set to $enabled") + } + .addOnFailureListener { e -> + Log.w(TAG, "GMS fused location provider setMockMode($enabled) failed: ${e.message}") + } } catch (e: SecurityException) { - // not the selected mock location app + Log.w(TAG, "GMS setMockMode security exception: ${e.message}") } catch (e: Throwable) { - // broken/ancient play services; the platform path still works + Log.w(TAG, "GMS setMockMode unexpected error: ${e.message}") } } +/** + * Pushes a mock fix to the Google Play Services Fused Location Provider so FLP-based consumers + * (such as Google Chrome and Google Maps) receive the synced location. + * + * @param context Application context used to obtain the FusedLocationProviderClient. + * @param location The complete [Location] fix containing monotonic timestamps and coordinates. + */ fun setFusedMockLocation(context: Context, location: Location) { if (!supportsFusedMockLocation(context)) { return } try { LocationServices.getFusedLocationProviderClient(context).setMockLocation(location) + .addOnFailureListener { e -> + Log.w(TAG, "GMS fused location provider setMockLocation failed: ${e.message}") + } } catch (e: SecurityException) { - // not the selected mock location app + Log.w(TAG, "GMS setMockLocation security exception: ${e.message}") } catch (e: Throwable) { - // broken/ancient play services; the platform path still works + Log.w(TAG, "GMS setMockLocation unexpected error: ${e.message}") } } diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml index 814324e23..d0d0a59ef 100644 --- a/app/app/src/main/AndroidManifest.xml +++ b/app/app/src/main/AndroidManifest.xml @@ -45,11 +45,11 @@ android:name="android.software.leanback" android:required="false" /> - - - - + + + diff --git a/app/app/src/main/java/com/bringyour/network/MainApplication.kt b/app/app/src/main/java/com/bringyour/network/MainApplication.kt index 909ea936b..8c02d52f6 100644 --- a/app/app/src/main/java/com/bringyour/network/MainApplication.kt +++ b/app/app/src/main/java/com/bringyour/network/MainApplication.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.WorkManager import com.bringyour.network.location.MockLocationController +import com.bringyour.network.location.MockLocationFeeder import com.bringyour.network.ui.shared.models.ProvideNetworkMode import com.bringyour.sdk.DeviceLocal import com.bringyour.sdk.LocalState @@ -167,6 +168,9 @@ class MainApplication : Application() { @Inject lateinit var mockLocationController: MockLocationController + @Inject + lateinit var mockLocationFeeder: MockLocationFeeder + var vpnRequestStart: Boolean = false private set @@ -395,6 +399,10 @@ class MainApplication : Application() { } } + /** + * Initializes core application singletons, SDK memory limits, mock location + * controllers, and network space managers once credential storage is unlocked. + */ private fun initializeApplicationState() { addTunnelLifecycleObservers() @@ -471,6 +479,7 @@ class MainApplication : Application() { // from the feature UI) so a previous process's leftovers are cleared // even when the user never opens the provider locations sheet. mockLocationController.start() + mockLocationFeeder.start() networkSpaceManagerProvider.init(filesDir.absolutePath) @@ -1331,6 +1340,10 @@ class MainApplication : Application() { api?.byJwt = null } + /** + * Tears down the active device, stops the VPN service, unregisters hardware/network + * callbacks, and resets transient connection and contract state. + */ fun stop() { // Invalidate a reconcile already queued by a listener before tearing // down the device; it must not restart the service after logout. @@ -1364,6 +1377,7 @@ class MainApplication : Application() { tunnelChangeSub = null contractStatusChangeSub?.close() contractStatusChangeSub = null + lastLoggedContractStatus = null // provideEnabled = false // connectEnabled = false @@ -1483,6 +1497,7 @@ class MainApplication : Application() { addThermalStatusListener() updateTunnelStarted() + lastLoggedContractStatus = null updateContractStatus() service?.get()?.onDeviceAvailable() updateVpnService() @@ -1490,6 +1505,9 @@ class MainApplication : Application() { return true } + /** + * Logs transitions in device tunnel state. + */ private fun updateTunnelStarted() { device?.tunnelStarted?.let { tunnelStarted -> Log.i(TAG, "[tunnel]started=$tunnelStarted") @@ -1498,10 +1516,22 @@ class MainApplication : Application() { } } + private var lastLoggedContractStatus: String? = null + + /** + * Updates and logs changes in network contract status, deduplicating identical + * state transitions to prevent logcat flooding during rapid network renegotiations. + */ private fun updateContractStatus() { - device?.contractStatus?.let { contractStatus -> - Log.i(TAG, "[contract]insufficent=${contractStatus.insufficientBalance} nopermission=${contractStatus.noPermission} premium=${contractStatus.premium}") - } ?: run { + val contractStatus = device?.contractStatus + if (contractStatus != null) { + val statusSummary = "insufficent=${contractStatus.insufficientBalance} nopermission=${contractStatus.noPermission} premium=${contractStatus.premium}" + if (statusSummary != lastLoggedContractStatus) { + lastLoggedContractStatus = statusSummary + Log.i(TAG, "[contract]$statusSummary") + } + } else if (lastLoggedContractStatus != null) { + lastLoggedContractStatus = null Log.i(TAG, "[contract]no contract status") } } diff --git a/app/app/src/main/java/com/bringyour/network/location/MockLocationController.kt b/app/app/src/main/java/com/bringyour/network/location/MockLocationController.kt index aa4b4fddb..db4ac1494 100644 --- a/app/app/src/main/java/com/bringyour/network/location/MockLocationController.kt +++ b/app/app/src/main/java/com/bringyour/network/location/MockLocationController.kt @@ -9,7 +9,6 @@ import android.os.Handler import android.os.HandlerThread import android.os.SystemClock import android.util.Log -import com.bringyour.network.TAG import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @@ -40,6 +39,7 @@ class MockLocationController @Inject constructor( ) { companion object { + private const val TAG = "MockLocationController" const val PREFS_NAME = "mock_location" const val PREF_KEY_ENABLED = "enabled" const val PREF_KEY_REGISTERED_PROVIDERS = "registered_providers" @@ -84,6 +84,8 @@ class MockLocationController @Inject constructor( private var devOptionsEnabled = false private var selectedMockApp = false private var locationServicesEnabled = false + private var locationPermissionGranted = false + private var requiresLocationPermission = false private val _state = MutableStateFlow( MockLocationState(MockLocationStatus.DISABLED, enabled = false, target = null) @@ -122,6 +124,7 @@ class MockLocationController @Inject constructor( return@post } target = newTarget + Log.i(TAG, "mock location target updated present=${newTarget != null}") errorTransient = false if (posting && newTarget != null) { teleport = true @@ -209,6 +212,8 @@ class MockLocationController @Inject constructor( devOptionsEnabled = isDeveloperOptionsEnabled(context) selectedMockApp = isSelectedMockLocationApp(context) locationServicesEnabled = isLocationServicesEnabled(context) + requiresLocationPermission = supportsFusedMockLocation(context) + locationPermissionGranted = hasLocationPermission(context) } private fun resolveStatus(): MockLocationStatus { @@ -220,6 +225,8 @@ class MockLocationController @Inject constructor( tunnelUp = tunnelUp, target = target, orphaned = orphaned, + requiresLocationPermission = requiresLocationPermission, + locationPermissionGranted = locationPermissionGranted, ) } @@ -251,6 +258,8 @@ class MockLocationController @Inject constructor( devOptionsEnabled = devOptionsEnabled, mockAppSelected = selectedMockApp, locationServicesEnabled = locationServicesEnabled, + locationPermissionGranted = locationPermissionGranted, + requiresLocationPermission = requiresLocationPermission, ) } @@ -360,6 +369,7 @@ class MockLocationController @Inject constructor( posting = false fusedActive = false removeAllTestProviders() + Log.i(TAG, "mock location disarmed") } // Best-effort removal of every provider this app may have registered diff --git a/app/app/src/main/java/com/bringyour/network/location/MockLocationEligibility.kt b/app/app/src/main/java/com/bringyour/network/location/MockLocationEligibility.kt index b3b1b5725..d40e1da82 100644 --- a/app/app/src/main/java/com/bringyour/network/location/MockLocationEligibility.kt +++ b/app/app/src/main/java/com/bringyour/network/location/MockLocationEligibility.kt @@ -4,11 +4,13 @@ import android.app.Activity import android.app.AppOpsManager import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.location.LocationManager import android.os.Build import android.os.Bundle import android.os.Process import android.provider.Settings +import androidx.core.content.ContextCompat // Thin Android-facing eligibility reads and Settings intent launchers for the // mock location engine. No side effects beyond startActivity. See @@ -50,6 +52,15 @@ fun isLocationServicesEnabled(context: Context): Boolean { } } +// True when the app holds runtime location permission (COARSE). +// Required by Google Play Services FusedLocationProviderClient to permit mock mode. +fun hasLocationPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.ACCESS_COARSE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED +} + // Watches the MOCK_LOCATION app op for this package (fires when the user // selects or deselects the app in Developer options). Watching your own uid // needs no permission. Returns an unwatch lambda. Note the callback may diff --git a/app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt b/app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt new file mode 100644 index 000000000..55d4a75df --- /dev/null +++ b/app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt @@ -0,0 +1,232 @@ +package com.bringyour.network.location + +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.bringyour.network.DeviceManager +import com.bringyour.sdk.DeviceLocal +import com.bringyour.sdk.Sub +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Feeds MockLocationController with tunnel lifecycle and exit provider location updates + * from the SDK DeviceLocal instance across the entire application lifecycle. + * + * Employs a debounce grace period during transient provider reconnections so that momentary + * drops in connected provider telemetry do not immediately disarm test providers and leak + * raw hardware GPS fixes. + */ +@Singleton +class MockLocationFeeder @Inject constructor( + private val deviceManager: DeviceManager, + private val controller: MockLocationController, +) { + companion object { + private const val TAG = "MockLocationFeeder" + + /** + * Grace period to retain the last known valid provider location during transient + * provider telemetry handovers or network re-dials before falling back to null. + */ + private const val TARGET_GRACE_PERIOD_MILLIS = 10_000L + } + + private val handler = Handler(Looper.getMainLooper()) + + @Volatile + private var removeDeviceChangeListener: (() -> Unit)? = null + @Volatile + private var connectSub: Sub? = null + @Volatile + private var tunnelSub: Sub? = null + @Volatile + private var providerSub: Sub? = null + @Volatile + private var currentDevice: DeviceLocal? = null + @Volatile + private var lastKnownTarget: MockLocationTarget? = null + @Volatile + private var pendingClearRunnable: Runnable? = null + @Volatile + private var graceGeneration: Long = 0L + + /** + * Subscribes to device manager changes and initializes mock location feeding + * for the currently active device. + */ + @Synchronized + fun start() { + if (removeDeviceChangeListener != null) return + removeDeviceChangeListener = deviceManager.addDeviceChangeListener { device -> + attach(device) + } + attach(deviceManager.device) + } + + /** + * Unregisters device manager listeners and closes all active SDK event subscriptions. + */ + @Synchronized + fun stop() { + removeDeviceChangeListener?.invoke() + removeDeviceChangeListener = null + cancelPendingTargetClear() + lastKnownTarget = null + attach(null) + } + + /** + * Cancels any pending scheduled target clear runnable if active and increments the + * grace generation counter to invalidate any callbacks currently queued on the looper. + */ + @Synchronized + private fun cancelPendingTargetClear() { + graceGeneration++ + pendingClearRunnable?.let { handler.removeCallbacks(it) } + pendingClearRunnable = null + } + + /** + * Attaches event listeners to the given [device] to monitor tunnel state and connected + * provider locations. Closes prior subscriptions and resets controller targets if [device] is null + * or when the active device instance is replaced. + * + * @param device The active [DeviceLocal] instance, or null if the device is being detached. + */ + @Synchronized + private fun attach(device: DeviceLocal?) { + if (currentDevice !== device) { + cancelPendingTargetClear() + lastKnownTarget = null + controller.onTargetChanged(null) + } + connectSub?.close() + connectSub = null + tunnelSub?.close() + tunnelSub = null + providerSub?.close() + providerSub = null + currentDevice = device + + if (device != null) { + val updateClientTunnelState = { + // Location mocking requires an active client connection to an exit provider. + // In Provide Mode (e.g. Provide mode = Always), tunnelStarted is true for server + // packet routing while connectEnabled is false. We must only mock while both + // connectEnabled (client mode) and tunnelStarted (VPN active) are true. + val isClientConnected = device.connectEnabled && device.tunnelStarted + controller.onTunnelChanged(isClientConnected) + if (isClientConnected) { + pushTarget(device) + } else { + cancelPendingTargetClear() + lastKnownTarget = null + controller.onTargetChanged(null) + } + } + + connectSub = device.addConnectChangeListener { + synchronized(this@MockLocationFeeder) { + if (currentDevice !== device) return@addConnectChangeListener + updateClientTunnelState() + } + } + tunnelSub = device.addTunnelChangeListener { + synchronized(this@MockLocationFeeder) { + if (currentDevice !== device) return@addTunnelChangeListener + updateClientTunnelState() + } + } + providerSub = device.addConnectedProviderLocationChangeListener { + synchronized(this@MockLocationFeeder) { + if (currentDevice !== device) return@addConnectedProviderLocationChangeListener + if (device.connectEnabled && device.tunnelStarted) { + pushTarget(device) + } + } + } + + updateClientTunnelState() + } else { + cancelPendingTargetClear() + lastKnownTarget = null + controller.onTunnelChanged(false) + controller.onTargetChanged(null) + } + } + + /** + * Extracts coordinates and geographic metadata from the first valid connected exit provider + * on [device] and pushes the target location to the [controller]. + * + * If provider locations are momentarily empty while the tunnel remains active, retains + * [lastKnownTarget] for a grace window of [TARGET_GRACE_PERIOD_MILLIS] before clearing, + * guarding against transient provider flaps. + * + * @param device The active [DeviceLocal] instance containing connected provider locations. + */ + @Synchronized + private fun pushTarget(device: DeviceLocal) { + val locations = device.connectedProviderLocations + var target: MockLocationTarget? = null + if (locations != null) { + for (i in 0 until locations.len()) { + val location = locations.get(i) + val lat: Double + val lon: Double + when { + location.hasCityCoordinates -> { + lat = location.cityLat + lon = location.cityLon + } + location.hasRegionCoordinates -> { + lat = location.regionLat + lon = location.regionLon + } + else -> continue + } + val label = listOf(location.city, location.region, location.country) + .filter { it.isNotEmpty() } + .take(2) + .joinToString(", ") + target = MockLocationTarget( + clientId = location.clientId?.idStr ?: "", + label = label, + lat = lat, + lon = lon, + ) + break + } + } + + if (target != null) { + cancelPendingTargetClear() + lastKnownTarget = target + controller.onTargetChanged(target) + } else if (lastKnownTarget != null) { + // Providers list momentarily dipped while tunnel is active. Retain last target + // for the grace period rather than eagerly disarming and exposing hardware GPS. + if (pendingClearRunnable == null) { + val generation = ++graceGeneration + val runnable = object : Runnable { + override fun run() { + synchronized(this@MockLocationFeeder) { + if (pendingClearRunnable === this && graceGeneration == generation) { + pendingClearRunnable = null + lastKnownTarget = null + controller.onTargetChanged(null) + Log.i(TAG, "Provider grace period expired; cleared mock target") + } + } + } + } + pendingClearRunnable = runnable + handler.postDelayed(runnable, TARGET_GRACE_PERIOD_MILLIS) + Log.i(TAG, "Provider locations momentarily empty; holding exit target for ${TARGET_GRACE_PERIOD_MILLIS}ms grace window") + } + } else { + controller.onTargetChanged(null) + } + } +} diff --git a/app/app/src/main/java/com/bringyour/network/location/MockLocationState.kt b/app/app/src/main/java/com/bringyour/network/location/MockLocationState.kt index e07099598..ac5546130 100644 --- a/app/app/src/main/java/com/bringyour/network/location/MockLocationState.kt +++ b/app/app/src/main/java/com/bringyour/network/location/MockLocationState.kt @@ -20,6 +20,10 @@ enum class MockLocationStatus { // would succeed but nothing would be delivered to any app NEEDS_LOCATION_ON, + // on GMS devices, Google Play Services FusedLocationProviderClient requires + // ACCESS_COARSE_LOCATION to accept mock mode/locations + NEEDS_LOCATION_PERMISSION, + // all preconditions met; waiting for tunnel up + a located provider ELIGIBLE, @@ -58,9 +62,12 @@ data class MockLocationState( val devOptionsEnabled: Boolean = false, val mockAppSelected: Boolean = false, val locationServicesEnabled: Boolean = false, + val locationPermissionGranted: Boolean = false, + val requiresLocationPermission: Boolean = false, ) { val setupComplete: Boolean - get() = devOptionsEnabled && mockAppSelected && locationServicesEnabled + get() = devOptionsEnabled && mockAppSelected && locationServicesEnabled && + (!requiresLocationPermission || locationPermissionGranted) } // Resolves the user-visible status from the engine inputs. @@ -70,8 +77,8 @@ data class MockLocationState( // controller clears it only after a successful cleanup — at which point a // disabled toggle resolves to DISABLED (MOCKLOCATION.md §6.4). The remaining // gates apply in setup order: developer options -> mock app selection -> -// location services; then ACTIVE only while the tunnel is up and a located -// provider target exists, ELIGIBLE otherwise. +// location services -> location permission (when required); then ACTIVE only +// while the tunnel is up and a located provider target exists, ELIGIBLE otherwise. fun resolveMockLocationStatus( enabled: Boolean, devOptionsEnabled: Boolean, @@ -80,6 +87,8 @@ fun resolveMockLocationStatus( tunnelUp: Boolean, target: MockLocationTarget?, orphaned: Boolean, + requiresLocationPermission: Boolean = false, + locationPermissionGranted: Boolean = false, ): MockLocationStatus { if (orphaned) { return MockLocationStatus.ORPHANED @@ -96,6 +105,9 @@ fun resolveMockLocationStatus( if (!locationServicesEnabled) { return MockLocationStatus.NEEDS_LOCATION_ON } + if (requiresLocationPermission && !locationPermissionGranted) { + return MockLocationStatus.NEEDS_LOCATION_PERMISSION + } return if (tunnelUp && target != null) { MockLocationStatus.ACTIVE } else { diff --git a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt index aec2bfa3f..9f970075a 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt @@ -1,5 +1,8 @@ package com.bringyour.network.ui.connect.providerlocations +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -37,6 +40,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -48,6 +52,7 @@ import com.bringyour.network.location.openAboutPhone import com.bringyour.network.location.openDeveloperOptions import com.bringyour.network.location.openLocationSettings import com.bringyour.network.ui.components.URButton +import com.bringyour.network.ui.components.URSwitch import com.bringyour.network.ui.theme.Black import com.bringyour.network.ui.theme.Green import com.bringyour.network.ui.theme.MainTintedBackgroundBase @@ -56,9 +61,13 @@ import com.bringyour.network.ui.theme.TopBarTitleTextStyle import com.bringyour.network.utils.lighten /** - * Walks the user through making URnetwork the Android mock location app. The - * state machine decides which step is current — there is no OS callback for + * Walks the user through making URnetwork the Android mock location app. + * + * The state machine decides which step is current — there is no OS callback for * the selection, so state is re-read every time this screen resumes. + * + * @param navController Navigation controller used to handle navigation and back-stack transitions. + * @param viewModel ViewModel providing mock location state and actions. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -70,6 +79,12 @@ fun MockLocationGuideScreen( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { + viewModel.refreshEligibility() + } + // navigating here does not pause the activity, so no ON_RESUME arrives on // entry; the observer below covers the important case of returning from // the system settings screens the steps launch @@ -123,6 +138,43 @@ fun MockLocationGuideScreen( Spacer(modifier = Modifier.height(24.dp)) + if (state.status == MockLocationStatus.ORPHANED) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.error.copy(alpha = 0.12f), + shape = RoundedCornerShape(12.dp), + ) + .padding(16.dp), + ) { + Column { + Text( + stringResource(id = R.string.mock_location_error_stuck_title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + stringResource(id = R.string.mock_location_error_stuck_detail), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + ) + Spacer(modifier = Modifier.height(14.dp)) + URButton( + onClick = { openDeveloperOptions(context) }, + ) { buttonTextStyle -> + Text( + stringResource(id = R.string.mock_location_open_developer_options), + style = buttonTextStyle, + ) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + } + // Steps read the raw setup signals, not `status`: with the toggle // off `status` is DISABLED regardless of how the device is // configured, which would mark every step done and hide the @@ -141,7 +193,8 @@ fun MockLocationGuideScreen( text = stringResource(id = R.string.mock_location_step_select_app), done = state.mockAppSelected, actionLabel = stringResource(id = R.string.mock_location_open_developer_options), - current = state.devOptionsEnabled && !state.mockAppSelected, + current = state.devOptionsEnabled && !state.mockAppSelected && + state.status != MockLocationStatus.ORPHANED, onAction = { openDeveloperOptions(context) }, ) @@ -156,23 +209,55 @@ fun MockLocationGuideScreen( onAction = { openLocationSettings(context) }, ) - Spacer(modifier = Modifier.height(24.dp)) + if (state.requiresLocationPermission) { + Spacer(modifier = Modifier.height(16.dp)) - if (state.setupComplete) { - Text( - stringResource(id = R.string.mock_location_ready), - style = MaterialTheme.typography.bodyMedium, - color = Green, + GuideStep( + text = stringResource(id = R.string.mock_location_step_location_permission), + done = state.locationPermissionGranted, + actionLabel = stringResource(id = R.string.mock_location_grant_permission), + current = state.devOptionsEnabled && state.mockAppSelected && + state.locationServicesEnabled && !state.locationPermissionGranted, + onAction = { + permissionLauncher.launch( + arrayOf( + android.Manifest.permission.ACCESS_COARSE_LOCATION, + ) + ) + }, ) - Spacer(modifier = Modifier.height(24.dp)) } - if (state.status == MockLocationStatus.ORPHANED) { - Text( - stringResource(id = R.string.mock_location_error_cleanup_required), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) + Spacer(modifier = Modifier.height(24.dp)) + + if (state.setupComplete && state.status != MockLocationStatus.ORPHANED) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val target = state.target + val statusText = when { + state.status == MockLocationStatus.ACTIVE && target != null -> + stringResource(id = R.string.mock_location_active, target.label) + state.enabled -> + stringResource(id = R.string.mock_location_waiting_for_provider) + else -> + stringResource(id = R.string.mock_location_ready) + } + + Text( + statusText, + style = MaterialTheme.typography.bodyMedium, + color = Green, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + URSwitch( + checked = state.enabled, + toggle = { viewModel.setEnabled(!state.enabled) }, + ) + } Spacer(modifier = Modifier.height(24.dp)) } @@ -199,6 +284,15 @@ fun MockLocationGuideScreen( } } +/** + * Renders an individual setup step card in the mock location configuration guide. + * + * @param text Instructional text describing the required configuration step. + * @param done True if the step's prerequisite has been satisfied. + * @param current True if this step is the immediate next action required from the user. + * @param actionLabel Label for the primary action button on the card. + * @param onAction Callback invoked when the user clicks the action button. + */ @Composable private fun GuideStep( text: String, diff --git a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationSection.kt b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationSection.kt index eb46c048c..610686af4 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationSection.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationSection.kt @@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,6 +28,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavController +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import com.bringyour.network.R import com.bringyour.network.location.MockLocationStatus import com.bringyour.network.ui.Route @@ -36,9 +40,14 @@ import com.bringyour.network.ui.theme.TextMuted /** * The Android-only "sync device location with provider" control at the top of - * the provider locations sheet. The OS gives no callback when the user picks - * the mock location app, so eligibility is re-read whenever this returns to - * the foreground (the same discipline as the battery optimization toggle). + * the provider locations sheet. + * + * The OS gives no callback when the user picks the mock location app, so eligibility + * is re-read whenever this returns to the foreground (the same discipline as the + * battery optimization toggle). + * + * @param navController Navigation controller used to route to setup and troubleshooting guides. + * @param viewModel ViewModel providing mock location state and actions. */ @Composable fun MockLocationSection( @@ -108,11 +117,7 @@ fun MockLocationSection( toggle = { val enabled = !state.enabled viewModel.setEnabled(enabled) - // Turning it on with the device already set up just works — - // only an incomplete setup opens the guide. `setupComplete` - // is used rather than `status`, which reads DISABLED while - // the toggle is off no matter how the device is configured. - if (enabled && !state.setupComplete) { + if (state.status == MockLocationStatus.ORPHANED || (enabled && !state.setupComplete)) { navController.navigate(Route.MockLocationGuide) } }, @@ -126,6 +131,7 @@ fun MockLocationSection( MockLocationStatus.NEEDS_DEV_OPTIONS, MockLocationStatus.NEEDS_SELECTION, MockLocationStatus.NEEDS_LOCATION_ON, + MockLocationStatus.NEEDS_LOCATION_PERMISSION, -> stringResource(id = R.string.mock_location_needs_setup) MockLocationStatus.ELIGIBLE -> @@ -136,7 +142,7 @@ fun MockLocationSection( } ?: stringResource(id = R.string.use_most_stable_provider) MockLocationStatus.ORPHANED -> - stringResource(id = R.string.mock_location_error_cleanup_required) + stringResource(id = R.string.mock_location_status_stuck) else -> stringResource(id = R.string.use_most_stable_provider) } @@ -144,7 +150,33 @@ fun MockLocationSection( Text( label, style = MaterialTheme.typography.bodySmall, - color = TextMuted, + color = if (state.status == MockLocationStatus.ORPHANED) + MaterialTheme.colorScheme.error + else + TextMuted, ) + + if (state.status == MockLocationStatus.ORPHANED) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { navController.navigate(Route.MockLocationGuide) } + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(id = R.string.mock_location_error_stuck_detail), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } } } diff --git a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationViewModel.kt b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationViewModel.kt index 3745b42cd..daffaeac0 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationViewModel.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationViewModel.kt @@ -1,109 +1,38 @@ package com.bringyour.network.ui.connect.providerlocations import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.bringyour.network.DeviceManager import com.bringyour.network.location.MockLocationController import com.bringyour.network.location.MockLocationState -import com.bringyour.network.location.MockLocationTarget -import com.bringyour.sdk.DeviceLocal -import com.bringyour.sdk.Sub import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch import javax.inject.Inject /** - * Feeds the mock location controller from the connected provider window: the - * target is the oldest connected provider that has coordinates. The controller - * owns all Android location state; this only translates sdk events. + * UI bridge for the mock location controller: exposes state and UI actions. + * SDK event feeds are managed at process lifetime by MockLocationFeeder. */ @HiltViewModel class MockLocationViewModel @Inject constructor( - private val deviceManager: DeviceManager, private val controller: MockLocationController, ) : ViewModel() { val state: StateFlow = controller.state - private var sub: Sub? = null - private var removeDeviceChangeListener: (() -> Unit)? = null - - init { - removeDeviceChangeListener = deviceManager.addDeviceChangeListener { device -> - attach(device) - } - attach(deviceManager.device) - } - - private fun attach(device: DeviceLocal?) { - sub?.close() - sub = device?.addConnectedProviderLocationChangeListener { - viewModelScope.launch { pushTarget() } - } - viewModelScope.launch { - controller.onTunnelChanged(device?.connectEnabled == true) - pushTarget() - } - } - - private fun pushTarget() { - val device = deviceManager.device - controller.onTunnelChanged(device?.connectEnabled == true) - - val locations = device?.connectedProviderLocations - var target: MockLocationTarget? = null - if (locations != null) { - // Read from the DEVICE, not the provider-locations view controller: - // the device getter is the raw window, still sorted oldest - // connected first, while the controller reorders it west to east - // for the list and the globe. Take the first one that actually has - // coordinates. - for (i in 0 until locations.len()) { - val location = locations.get(i) - val lat: Double - val lon: Double - when { - location.hasCityCoordinates -> { - lat = location.cityLat - lon = location.cityLon - } - location.hasRegionCoordinates -> { - lat = location.regionLat - lon = location.regionLon - } - else -> continue - } - val label = listOf(location.city, location.region, location.country) - .filter { it.isNotEmpty() } - .take(2) - .joinToString(", ") - target = MockLocationTarget( - clientId = location.clientId?.idStr ?: "", - label = label, - lat = lat, - lon = lon, - ) - break - } - } - controller.onTargetChanged(target) - } - + /** + * Updates the user preference for mock location simulation. + * + * @param enabled True to enable device location synchronization with connected exit providers, + * false to disable and clear any active test provider state. + */ fun setEnabled(enabled: Boolean) { controller.setEnabled(enabled) - pushTarget() } + /** + * Re-reads Android system settings (developer options, selected mock location app, + * and location services) and pushes the refreshed eligibility state to [state]. + */ fun refreshEligibility() { controller.refreshEligibility() } - - override fun onCleared() { - super.onCleared() - sub?.close() - sub = null - removeDeviceChangeListener?.invoke() - removeDeviceChangeListener = null - } } diff --git a/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt b/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt index e50a8c497..54941d528 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt @@ -125,8 +125,27 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import com.bringyour.network.TAG import com.bringyour.network.ui.components.ProvideCellPicker import com.bringyour.network.ui.components.ProvideControlModePicker +import com.bringyour.network.location.MockLocationStatus +import com.bringyour.network.location.MockLocationTarget +import com.bringyour.network.ui.connect.providerlocations.MockLocationViewModel import com.bringyour.network.ui.login.SeedphraseDisplayScreen - +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel + +/** + * Stateful root composable for the Settings screen, observing view model states + * and binding UI callbacks for account management, network modes, permissions, + * seed phrase verification, and mock location synchronization. + * + * @param navController Navigation controller for routing to nested screens and external flows. + * @param accountViewModel ViewModel managing authenticated user profile and client identity. + * @param planViewModel ViewModel managing subscription tier and upgrade options. + * @param settingsViewModel ViewModel managing application configuration and hardware preferences. + * @param overlayViewModel ViewModel managing global overlay dialogs and sheets. + * @param activityResultSender Sender for Mobile Wallet Adapter (MWA) activity results. + * @param earningsViewModel ViewModel managing earnings, rewards, and Seeker token holder status. + * @param isPro True if the current account holds an active supporter subscription tier. + * @param mockLocationViewModel ViewModel managing mock location simulation preferences and status. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( @@ -137,8 +156,25 @@ fun SettingsScreen( overlayViewModel: OverlayViewModel, activityResultSender: ActivityResultSender?, earningsViewModel: EarningsViewModel, - isPro: Boolean + isPro: Boolean, + mockLocationViewModel: MockLocationViewModel = hiltViewModel(), ) { + val lifecycleOwner = LocalLifecycleOwner.current + val mockLocationState by mockLocationViewModel.state.collectAsState() + + LaunchedEffect(Unit) { + mockLocationViewModel.refreshEligibility() + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + mockLocationViewModel.refreshEligibility() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } val notificationsAllowed = settingsViewModel.permissionGranted.collectAsState().value val showDeleteAccountDialog = settingsViewModel.showDeleteAccountDialog.collectAsState().value @@ -289,6 +325,24 @@ fun SettingsScreen( isGeneratingSeedphrase = isGeneratingSeedphrase, isRegeneratingSeedphrase = isRegeneratingSeedphrase, onSeedphraseActionClick = { action -> pendingSeedphraseAction = action }, + mockLocationEnabled = mockLocationState.enabled, + mockLocationSetupComplete = mockLocationState.setupComplete, + mockLocationStatus = mockLocationState.status, + mockLocationTarget = mockLocationState.target, + onToggleMockLocation = { + // NOTE: reads mockLocationState.enabled from the snapshot captured by + // the last composition — between the click and the ViewModel update, + // another caller could toggle it. Acceptable here because the toggle + // is debounced and setEnabled() is idempotent. + val enabled = !mockLocationState.enabled + mockLocationViewModel.setEnabled(enabled) + if (mockLocationState.status == MockLocationStatus.ORPHANED || (enabled && !mockLocationState.setupComplete)) { + navController.navigate(Route.MockLocationGuide) + } + }, + onOpenMockLocationGuide = { + navController.navigate(Route.MockLocationGuide) + }, ) if (isPresentingRenameDevice) { @@ -466,6 +520,55 @@ fun SettingsScreen( } +/** + * Stateless presentation composable for the Settings screen layout. + * + * @param navController Navigation controller for in-app navigation routes. + * @param clientId Unique identifier for the client installation. + * @param currentPlan Active subscription tier (Basic or Supporter). + * @param notificationsAllowed True if OS notification permissions are granted. + * @param notificationsPermanentlyDenied True if notification permissions were permanently declined. + * @param requestAllowNotifications Callback to request system notification permission. + * @param allowProductUpdates True if product update telemetry is enabled. + * @param toggleAllowProductUpdates Callback to toggle product update telemetry. + * @param provideControlMode Current relay/provider routing mode. + * @param setProvideControlMode Callback to select a new relay/provider mode. + * @param deviceName User-assigned label for this device. + * @param deviceSpec Hardware specifications string. + * @param onEditDeviceName Callback to initiate device renaming dialog. + * @param setShowDeleteAccountDialog Callback to control delete account dialog visibility. + * @param showDeleteAccountDialog True if the delete account dialog is visible. + * @param deleteAccount Callback to execute account deletion with success/failure handlers. + * @param isDeletingAccount True if account deletion is currently in progress. + * @param routeLocal True if LAN local routing is enabled. + * @param toggleRouteLocal Callback to toggle LAN local routing. + * @param snackbarHostState State manager for snackbar notifications. + * @param signAndVerifySeekerHolder Callback to trigger Seeker token wallet verification. + * @param isSeekerHolder True if the device has verified ownership of a Seeker token. + * @param version Application build version string. + * @param allowProvideCell True if relaying is allowed over cellular connections. + * @param toggleProvideCell Callback to toggle cellular relaying. + * @param authCodeCreate Callback to generate a new device pairing auth code. + * @param authCode Current pairing auth code, if generated. + * @param isCreatingAuthCode True if an auth code is being generated. + * @param setDisplayAuthCodeDialog Callback to control auth code dialog display. + * @param provideIndicatorColor Status color for the relay mode indicator. + * @param provideIndicatorRingColor Optional ring accent color for the relay indicator. + * @param stripePortalUrl Customer billing portal URL, if available. + * @param authMethods List of active authentication methods linked to the account. + * @param onRemoveAuthMethod Callback to remove an authentication method. + * @param onAddAuthMethodClick Callback to present add authentication sheet. + * @param hasSeedphrase True if a seed phrase authentication method is configured. + * @param isGeneratingSeedphrase True if seed phrase generation is underway. + * @param isRegeneratingSeedphrase True if seed phrase regeneration is underway. + * @param onSeedphraseActionClick Callback for seed phrase actions (export/regenerate). + * @param mockLocationEnabled True if mock location synchronization is toggled on. + * @param mockLocationSetupComplete True if all Android OS mock location prerequisites are met. + * @param mockLocationStatus Current lifecycle state of mock location synchronization. + * @param mockLocationTarget Current exit provider coordinates being simulated, if active. + * @param onToggleMockLocation Callback invoked when user toggles mock location sync switch. + * @param onOpenMockLocationGuide Callback to navigate to the mock location setup guide. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SettingsScreen( @@ -508,6 +611,12 @@ private fun SettingsScreen( isGeneratingSeedphrase: Boolean, isRegeneratingSeedphrase: Boolean, onSeedphraseActionClick: (SeedphraseAction) -> Unit, + mockLocationEnabled: Boolean = false, + mockLocationSetupComplete: Boolean = false, + mockLocationStatus: MockLocationStatus = MockLocationStatus.DISABLED, + mockLocationTarget: MockLocationTarget? = null, + onToggleMockLocation: () -> Unit = {}, + onOpenMockLocationGuide: () -> Unit = {}, ) { val context = LocalContext.current @@ -919,26 +1028,65 @@ private fun SettingsScreen( /** * Device location sync (the mock location provider setup guide) */ - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - navController.navigate(Route.MockLocationGuide) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier + .weight(1f) + .clickable { + onOpenMockLocationGuide() + } + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(id = R.string.mock_location_settings_row), + style = MaterialTheme.typography.bodyMedium, + color = Color.White + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "Keyboard Arrow Right", + tint = TextMuted + ) } - .padding(vertical = 6.dp) - , - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - stringResource(id = R.string.mock_location_settings_row), - style = MaterialTheme.typography.bodyMedium, - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "Keyboard Arrow Right", - tint = TextMuted - ) + URSwitch( + checked = mockLocationEnabled, + toggle = onToggleMockLocation, + ) + } + + val statusSubtitle = when { + mockLocationStatus == MockLocationStatus.ORPHANED -> + stringResource(id = R.string.mock_location_status_stuck) + mockLocationEnabled && !mockLocationSetupComplete -> + stringResource(id = R.string.mock_location_needs_setup) + mockLocationStatus == MockLocationStatus.ACTIVE && mockLocationTarget != null -> + stringResource(id = R.string.mock_location_active, mockLocationTarget.label) + mockLocationStatus == MockLocationStatus.ELIGIBLE && mockLocationEnabled -> + stringResource(id = R.string.mock_location_waiting_for_provider) + else -> null + } + + if (statusSubtitle != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + statusSubtitle, + style = MaterialTheme.typography.bodySmall, + color = if (mockLocationStatus == MockLocationStatus.ORPHANED) + MaterialTheme.colorScheme.error + else + TextMuted + ) + } } Spacer(modifier = Modifier.height(18.dp)) diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml index 183469ec9..e4f548f56 100644 --- a/app/app/src/main/res/values/strings.xml +++ b/app/app/src/main/res/values/strings.xml @@ -439,6 +439,9 @@ This changes the location reported to every app on your device, not just URnetwork. Turn this off in URnetwork before you turn off developer options, deselect URnetwork, or uninstall the app — otherwise your device location can stay frozen until you restart. URnetwork could not remove the simulated location. Re-select URnetwork under developer options and turn this off, or restart your device. + GPS frozen — action needed + Simulated location is stuck + URnetwork was deselected while active, so Android locked the simulated GPS. Re-select URnetwork in Developer options to restore your real GPS, or restart your phone. When enabled, apps on this device see the location of the provider you have been connected to the longest, instead of your real location. Sync device location Setup required @@ -449,6 +452,8 @@ Device location sync Turn on developer options: open About phone and tap Build number seven times. Turn on Location in system settings so apps can receive the location. + Allow approximate location access so Google Play Services can sync simulated location with Chrome and apps. + Grant permission In developer options, tap Select mock location app and choose URnetwork. Waiting for a provider location Multiple IPs diff --git a/app/app/src/solana_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt b/app/app/src/solana_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt index 707d249fe..2b3813e1f 100644 --- a/app/app/src/solana_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt +++ b/app/app/src/solana_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt @@ -2,17 +2,20 @@ package com.bringyour.network.location import android.content.Context import android.location.Location +import android.util.Log import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.location.LocationServices -// GMS layer of the mock location engine (MOCKLOCATION.md §3.2): mirror the -// mock fix into the Google Play services fused location provider so -// FLP-based consumers (Google Maps et al.) reliably follow it. Requires the -// same developer-options selection as the platform path — no extra user step. -// Every call is fire-and-forget: the Task results are intentionally ignored -// and failures surface through the platform path instead. +private const val TAG = "FusedMockLocation" +/** + * Checks whether Google Play Services is available on the device to support mocking + * the FusedLocationProviderClient directly (MOCKLOCATION.md §3.2). + * + * @param context Application or component context used to query Google Play Services availability. + * @return True if Google Play Services is installed, enabled, and operational. + */ fun supportsFusedMockLocation(context: Context): Boolean { return try { GoogleApiAvailability.getInstance() @@ -22,30 +25,53 @@ fun supportsFusedMockLocation(context: Context): Boolean { } } -// setMockMode is device-global (affects all FLP clients in every process) — -// callers must always exit mock mode on every teardown path. +/** + * Sets whether the Google Play Services Fused Location Provider is in mock mode. + * + * Entering mock mode clears FLP caches and ensures FLP clients only receive mock locations + * pushed through [setFusedMockLocation]. Exiting mock mode restores standard provider fusion. + * + * @param context Application context used to obtain the FusedLocationProviderClient. + * @param enabled True to engage mock mode; false to disengage and restore normal location. + */ fun setFusedMockMode(context: Context, enabled: Boolean) { if (!supportsFusedMockLocation(context)) { return } try { LocationServices.getFusedLocationProviderClient(context).setMockMode(enabled) + .addOnSuccessListener { + Log.i(TAG, "GMS fused location provider mock mode set to $enabled") + } + .addOnFailureListener { e -> + Log.w(TAG, "GMS fused location provider setMockMode($enabled) failed: ${e.message}") + } } catch (e: SecurityException) { - // not the selected mock location app + Log.w(TAG, "GMS setMockMode security exception: ${e.message}") } catch (e: Throwable) { - // broken/ancient play services; the platform path still works + Log.w(TAG, "GMS setMockMode unexpected error: ${e.message}") } } +/** + * Pushes a mock fix to the Google Play Services Fused Location Provider so FLP-based consumers + * (such as Google Chrome and Google Maps) receive the synced location. + * + * @param context Application context used to obtain the FusedLocationProviderClient. + * @param location The complete [Location] fix containing monotonic timestamps and coordinates. + */ fun setFusedMockLocation(context: Context, location: Location) { if (!supportsFusedMockLocation(context)) { return } try { LocationServices.getFusedLocationProviderClient(context).setMockLocation(location) + .addOnFailureListener { e -> + Log.w(TAG, "GMS fused location provider setMockLocation failed: ${e.message}") + } } catch (e: SecurityException) { - // not the selected mock location app + Log.w(TAG, "GMS setMockLocation security exception: ${e.message}") } catch (e: Throwable) { - // broken/ancient play services; the platform path still works + Log.w(TAG, "GMS setMockLocation unexpected error: ${e.message}") } } diff --git a/app/app/src/test/java/com/bringyour/network/location/MockLocationStateTest.kt b/app/app/src/test/java/com/bringyour/network/location/MockLocationStateTest.kt index 491bb66bb..5b35aeb21 100644 --- a/app/app/src/test/java/com/bringyour/network/location/MockLocationStateTest.kt +++ b/app/app/src/test/java/com/bringyour/network/location/MockLocationStateTest.kt @@ -22,6 +22,8 @@ class MockLocationStateTest { tunnelUp: Boolean = true, target: MockLocationTarget? = tokyo, orphaned: Boolean = false, + requiresLocationPermission: Boolean = false, + locationPermissionGranted: Boolean = false, ): MockLocationStatus { return resolveMockLocationStatus( enabled = enabled, @@ -31,6 +33,8 @@ class MockLocationStateTest { tunnelUp = tunnelUp, target = target, orphaned = orphaned, + requiresLocationPermission = requiresLocationPermission, + locationPermissionGranted = locationPermissionGranted, ) } @@ -107,6 +111,33 @@ class MockLocationStateTest { ) } + @Test + fun locationPermissionGateComesFourthWhenRequired() { + assertEquals( + MockLocationStatus.NEEDS_LOCATION_PERMISSION, + resolve( + requiresLocationPermission = true, + locationPermissionGranted = false, + ), + ) + // when permission is granted, gate passes + assertEquals( + MockLocationStatus.ACTIVE, + resolve( + requiresLocationPermission = true, + locationPermissionGranted = true, + ), + ) + // when permission is not required, gate is bypassed + assertEquals( + MockLocationStatus.ACTIVE, + resolve( + requiresLocationPermission = false, + locationPermissionGranted = false, + ), + ) + } + @Test fun eligibleWhenNoTunnelAndNoTarget() { assertEquals( @@ -134,6 +165,8 @@ class MockLocationStateTest { devOptionsEnabled: Boolean = true, mockAppSelected: Boolean = true, locationServicesEnabled: Boolean = true, + locationPermissionGranted: Boolean = true, + requiresLocationPermission: Boolean = false, ) = MockLocationState( status = MockLocationStatus.DISABLED, enabled = false, @@ -141,6 +174,8 @@ class MockLocationStateTest { devOptionsEnabled = devOptionsEnabled, mockAppSelected = mockAppSelected, locationServicesEnabled = locationServicesEnabled, + locationPermissionGranted = locationPermissionGranted, + requiresLocationPermission = requiresLocationPermission, ) // The toggle opens the setup guide only when setup is incomplete, and the @@ -159,5 +194,11 @@ class MockLocationStateTest { assertFalse(state(devOptionsEnabled = false).setupComplete) assertFalse(state(mockAppSelected = false).setupComplete) assertFalse(state(locationServicesEnabled = false).setupComplete) + assertFalse( + state( + requiresLocationPermission = true, + locationPermissionGranted = false, + ).setupComplete + ) } } diff --git a/app/app/src/ungoogle/java/com/bringyour/network/location/FusedMockLocationSupport.kt b/app/app/src/ungoogle/java/com/bringyour/network/location/FusedMockLocationSupport.kt index 0be567f52..ecabba79d 100644 --- a/app/app/src/ungoogle/java/com/bringyour/network/location/FusedMockLocationSupport.kt +++ b/app/app/src/ungoogle/java/com/bringyour/network/location/FusedMockLocationSupport.kt @@ -6,8 +6,27 @@ import android.location.Location // github flavor: platform-only mock location (no Google Play services // dependency). The LocationManager test providers cover gps/network/fused. +/** + * Indicates whether Google Play Services fused location mocking is supported. + * Always returns false in the ungoogle / github flavor. + * + * @param context Application or component context. + * @return Always false for ungoogle builds. + */ fun supportsFusedMockLocation(context: Context): Boolean = false +/** + * No-op stub for setting GMS fused mock mode in ungoogle builds. + * + * @param context Application context. + * @param enabled Whether to enable or disable mock mode. + */ fun setFusedMockMode(context: Context, enabled: Boolean) = Unit +/** + * No-op stub for pushing GMS fused mock location in ungoogle builds. + * + * @param context Application context. + * @param location The mock location to push. + */ fun setFusedMockLocation(context: Context, location: Location) = Unit From 70bbcaec8022dbbadeef24bb4c4f4f173f6444de Mon Sep 17 00:00:00 2001 From: Ryanmello07 <67509637+Ryanmello07@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:52:42 -0700 Subject: [PATCH 2/2] Gate the mock location COARSE grant to the fused leg only Review fixes on top of #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 (dac1d146 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) Claude-Session: https://claude.ai/code/session_019VYp99vhW9soGoTmNGVYJk --- MOCKLOCATION.md | 289 ++++++++++++++++-- .../location/FusedMockLocationSupport.kt | 68 +++-- app/app/src/github/AndroidManifest.xml | 12 + .../location/FusedMockLocationSupport.kt | 68 +++-- app/app/src/main/AndroidManifest.xml | 20 +- .../location/MockLocationController.kt | 47 ++- .../location/MockLocationEligibility.kt | 31 +- .../network/location/MockLocationFeeder.kt | 77 ++--- .../location/MockLocationGracePolicy.kt | 104 +++++++ .../network/location/MockLocationState.kt | 25 +- .../MockLocationGuideScreen.kt | 41 ++- .../providerlocations/MockLocationSection.kt | 11 +- .../network/ui/settings/SettingsScreen.kt | 75 ++--- app/app/src/main/res/values/strings.xml | 26 +- .../location/FusedMockLocationSupport.kt | 68 +++-- .../location/MockLocationGracePolicyTest.kt | 180 +++++++++++ .../network/location/MockLocationStateTest.kt | 173 +++++++++-- .../location/FusedMockLocationSupport.kt | 19 -- 18 files changed, 1064 insertions(+), 270 deletions(-) create mode 100644 app/app/src/github/AndroidManifest.xml create mode 100644 app/app/src/main/java/com/bringyour/network/location/MockLocationGracePolicy.kt create mode 100644 app/app/src/test/java/com/bringyour/network/location/MockLocationGracePolicyTest.kt diff --git a/MOCKLOCATION.md b/MOCKLOCATION.md index 894cd7dba..aaade3bb5 100644 --- a/MOCKLOCATION.md +++ b/MOCKLOCATION.md @@ -9,12 +9,22 @@ with exit provider" feature in the URnetwork Android VPN app (minSdk 26, targetS foreground VpnService). **Verdict up front:** the feature is fully implementable with public SDK APIs, requires -**no runtime location permission and no location foreground-service type**, and has clear -commercial precedent (Surfshark). The two things that will bite are (a) **test providers -are never auto-removed** — not on process death, not on force-stop, not on uninstall — -and (b) if the user deselects the app in Developer options while mocking is active, **the -app permanently loses the ability to clean up until re-selection or reboot**. Both are -verified in AOSP source below and drive most of the blueprint. +**no runtime location permission and no location foreground-service type** for its core +AOSP path, and has clear commercial precedent (Surfshark). The two things that will bite +are (a) **test providers are never auto-removed** — not on process death, not on +force-stop, not on uninstall — and (b) if the user deselects the app in Developer options +while mocking is active, **the app permanently loses the ability to clean up until +re-selection or reboot**. Both are verified in AOSP source below and drive most of the +blueprint. + +**Amendment, as shipped:** the *optional* Google Play services mirror of §3.2 — compiled +only into the GMS flavors, never into the F-Droid build — turned out to want a runtime +location grant, so those flavors now declare `ACCESS_COARSE_LOCATION` and ask for it once, +from one explicit button in the setup guide. Nothing else moved: the AOSP test providers +still need no runtime permission (§8), the `github`/F-Droid build still declares no +location permission at all, and the app still never reads a device location. §1.4 covers +the Play policy consequences and §3.2 covers why the requirement is asserted defensively +rather than proven. --- @@ -111,13 +121,58 @@ apply: document this in their Google Play listing and encrypt all data from the device to the VPN tunnel endpoint." Already satisfied. - **Location Permissions Policy**: applies to `ACCESS_COARSE/FINE/BACKGROUND_LOCATION`. - **The mock-only design requests none of these**, so it is out of scope — a strong - argument for *not* building a "mirror the real location" pass-through (§8, §10.4). + **The mock-only *core* design requests none of these** — a strong argument for *not* + building a "mirror the real location" pass-through (§8, §10.4). As shipped this is now + only half true: the GMS flavors declare `ACCESS_COARSE_LOCATION` for the optional FLP + mirror and are therefore **in scope** of that policy. See immediately below. - Google's own developer docs bless the feature: "**Select mock location app**: Use this option to fake the GPS location of the device to test whether your app behaves the same in other locations. To use this option, download and install a GPS mock location app." — [Configure on-device developer options](https://developer.android.com/studio/debug/dev-options) +**As shipped: the GMS flavors are in scope of the Location Permissions policy, the +F-Droid flavor is not.** + +| flavor | source set | `play-services-location` | `ACCESS_COARSE_LOCATION` | +|---|---|---|---| +| `play`, `solana_dapp`, `ethos_dapp` | `src/google`, `src/solana_dapp`, `src/ethos_dapp` | yes (21.3.0) | **declared**, inherited from `src/main` | +| `github` (F-Droid) | `src/ungoogle` | no | **removed** in `src/github/AndroidManifest.xml` | + +`src/main` merges into all four flavors, so the declaration lives there and the F-Droid +manifest strips it out again at its own (higher) merge priority. `src/ungoogle`'s +`supportsFusedMockLocation()` is a hardcoded `false` and no GMS dependency is added for +that flavor, so the F-Droid build could never use the grant and must not ask for it. +`ACCESS_FINE_LOCATION` and the `android.hardware.location.gps` feature stay +`tools:node="remove"` in `src/main` for every flavor, so no dependency can drag them in. + +**Necessity justification — the text a Play review needs.** The declaration is easy to +defend because it is not a location *access* at all: + +- The app declares **approximate location only**. Never `ACCESS_FINE_LOCATION`, never + `ACCESS_BACKGROUND_LOCATION`, and no `location` foreground-service type (§8). +- Its sole purpose is to satisfy `FusedLocationProviderClient.setMockMode()` / + `setMockLocation()`, i.e. to *write* a simulated fix into Google Play services. It buys + nothing else and gates nothing else (§3.2). +- **The app never reads device location.** There is no `requestLocationUpdates`, no + `getLastKnownLocation`, no `getCurrentLocation`, no `getLastLocation` and no location + listener anywhere in the app — verified by grep across `src/main` and all four flavor + source sets; the only occurrences of those names are prose in comments citing this + report. Nor could there be: while `gps`/`network`/`fused` are mocked the real providers + are stopped and unreadable even by us (§7.1). +- The prompt comes from **one explicit user action** — the optional fourth step of the + setup guide, behind a button the user taps, on a screen that has already explained the + feature. It is never requested at launch and never from the settings list. Its own copy + says it is optional and says what it buys. +- Denying it (or permanently denying it) leaves every other part of the feature working; + the step then offers the app-info deep link instead of a dialog that will never appear + (§5). +- Prominent disclosure is carried by the guide screen and the toggle's disclosure bullets + (§10.6), which already state that the simulated location is device-wide and detectable. + +What has *not* changed: `ACCESS_MOCK_LOCATION` is still ungrantable and still appears on +no declaration form (§1.1), and no Play policy regulates mock-location apps as such. The +one approximate-location declaration above is the whole of the new policy exposure. + ### 1.5 Precedent - **Surfshark "Override GPS location"** ships in the Play-distributed Surfshark Android @@ -348,6 +403,60 @@ the platform path already covers the overwhelming majority of consumers. If adde `setMockMode(false)` on teardown is mandatory (it's device-global and affects other processes). +**Does the FLP leg need a runtime location permission? Open question — the two pieces of +evidence disagree.** + +- The **vendor prose quoted above names only `ACCESS_MOCK_LOCATION`** plus the + Developer-options selection ("Successfully using this API on devices running Android M+ + requires the client to request the `android.permission.ACCESS_MOCK_LOCATION` permission + and to be selected as the mock location app within the device developer settings"). + Read literally, the FLP path needs exactly what the platform path needs and nothing + more. +- The **shipped binary annotates both calls as requiring a runtime location permission**: + in `com.google.android.gms:play-services-location:21.3.0`, + `FusedLocationProviderClient.setMockMode(boolean)` and `.setMockLocation(Location)` each + carry `@RequiresPermission(anyOf = {ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION})`. + Verified directly against the artifact rather than the docs: the annotation is present + on both method entries in `classes.jar`, and it is stored in the + **`RuntimeInvisibleAnnotations`** attribute — `androidx.annotation.RequiresPermission` + has `CLASS` retention, so it is not even readable at runtime. It is a lint/tooling hint + about what Google expects callers to hold. **It is not, by itself, enforcement.** + +**So we do not know whether GMS actually enforces it at runtime, and this report does not +claim that it does.** The call crosses a binder into the Play services process, whose +implementation is not readable; the annotation tells us what the SDK's own tooling +expects, not what the service checks. Declaring the permission is therefore a +**defensive** choice — and it is deliberately arranged so that being wrong in either +direction costs nothing: + +- If the grant *is* required, the mirror works for users who accept the optional prompt + and is simply absent for everyone else. +- If it is *not* required, the residue is one approximate-location permission on the GMS + flavors that nothing in the app ever reads (§1.4), and one guide step that can later be + deleted. + +**What makes both outcomes harmless is that the grant gates only this leg.** The +controller registers the AOSP test providers first and unconditionally, then sets +`fusedActive = supportsFusedMockLocation(context) && locationPermissionGranted`; the 1 Hz +poster mirrors into FLP only while that flag holds. A device without the grant loses the +mirror and keeps the entire feature. + +This is why the "**optional, reflection-free, gracefully-degrading enhancement**" wording +above is load-bearing rather than stylistic. **The permission must never become a +precondition of the feature.** Promote it into the eligibility ladder — anywhere, but +especially above the `ACTIVE`/`ELIGIBLE` decision (§10.3) — and mock location dies +outright on every GMS device whose user declined a prompt they had just been told was +optional, taking the AOSP path that never needed it down with it. + +**To settle it empirically**, the test is one debug build away: on a GMS device that has +been selected as the mock location app but has *not* been granted approximate location, +drop the `&& locationPermissionGranted` clause and watch `setMockMode(true)`'s `Task` +outcome, which is logged on both success and failure. A failure means the service enforces +the annotation; a success means the vendor prose is the accurate description. Note the +obvious version cannot be run: revoking a runtime permission from Settings kills the +process, so there is no "revoke it while armed and see what breaks". Until someone runs +the debug-build version, treat the requirement as unproven. + ### 3.3 Geocoder and geofencing - **`Geocoder` is unaffected.** It's a separate service (`ProxyGeocodeProvider`, bound @@ -480,6 +589,22 @@ Two useful refinements: `Settings > About phone > Build number`; then "Tap the Build Number option seven times until you see the message *You are now a developer!*". +**App info, for the optional runtime permission** (§1.4, §3.2): once +`ACCESS_COARSE_LOCATION` has been denied to the point where the system dialog no longer +appears, `launch()` returns immediately and the button is dead. The only route left is the +app's own detail page, which *is* public and reliable: +```java +Intent i = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); // "android.settings.APPLICATION_DETAILS_SETTINGS" +i.setData(Uri.fromParts("package", ctx.getPackageName(), null)); +``` +Detect the permanent denial the standard way — after a request result comes back denied, +`shouldShowRequestPermissionRationale()` returning `false` means the dialog will not show +again — and swap the step's label and action to this. Guard it with `resolveActivity()` +like every other deep link here. Note the failure direction that matters: if the activity +reference needed for the rationale check is unavailable, fall back to *asking again*, not +to the app-info button, so the worst case is a redundant tap rather than a user stranded +on a screen that cannot help them. + **OEM caveats** (lower-confidence, secondary sources): Samsung One UI nests the entry under **Developer options → Debugging → Mock location app**, and older One UI calls it "Allow mock locations" @@ -560,6 +685,23 @@ Mitigation (mandatory in the blueprint): **on every app/service start, if the fe not supposed to be active, defensively call `removeTestProvider` for each of our provider names inside try/catch.** It's a no-op on S+ when nothing is registered. +**As shipped, "not supposed to be active" means `resolveStatus() != ACTIVE`, not "the +toggle is off".** A persisted toggle left ON says only that the *user* wants the feature; +it says nothing about whether *this* process registered anything, and on a cold start it +cannot have. Keying the startup sweep on `!enabled` therefore skipped it on exactly the +devices most likely to be carrying leftovers — feature on, tunnel not yet up — leaving +providers registered with no live owner and no path that would reclaim them. At the point +the controller runs the sweep the persisted signals are loaded but the tunnel is still +down and the target is still null (the feeder is started after the controller, §10.1), so +the resolved status can never be `ACTIVE` there and the sweep is effectively +unconditional — which is the intent. It is also the only path that sweeps the *default* +provider names after a "Clear data" has wiped the persisted registered-provider set. + +One expected consequence, and it is correct rather than a regression: on a device holding +leftovers whose app op has since been revoked, startup now discovers the failure and +reports `ORPHANED` (§6.4) where the old condition stayed silent whenever the toggle +happened to be on. + ### 6.4 Deselection while active is unrecoverable If the user picks a different mock app (or clears the selection) while mocking is active: @@ -577,6 +719,26 @@ Turning Developer options **off** has the same effect (op revoked, providers lin worth a line in the guide: *turn this feature off in URnetwork before turning off Developer options.* +**The reclaim must key on "might we have registered anything", not on "are we posting".** +Automatic cleanup is what step 3 above depends on, and it is reached from an ordinary +reconcile. As shipped the teardown branch fires on +`posting || mayHaveRegisteredProviders()`, where the helper is the union of the in-memory +provider-name set and the persisted one — because **`arm()` persists the claimed set +before it registers anything**. A process that died mid-arm, and a cleanup that threw, +both leave providers registered while `posting` is false. Under the narrower +`!shouldPost && posting` condition those two states were unreachable by any retry: the +providers stayed registered, nothing in the app could reclaim them, and the device's +location stayed frozen for every app on it. That is the same user-visible failure as +§6.3, arrived at from the opposite direction. + +The retry is safe to run repeatedly: `removeTestProvider` on a name that is not registered +is a no-op on S+ and throws a caught `IllegalArgumentException` before it, exiting mock +mode is idempotent, and the persisted set is cleared only on a clean pass. So the reclaim +simply runs again on the next reconcile — every op-watcher callback, and every `onResume` +of a screen that refreshes eligibility — until it succeeds. Log only the reclaims that +actually stopped a running post loop; the retries must be silent, or an unrecoverable +`ORPHANED` device writes a line every time the user opens a settings screen. + ### 6.5 Teardown order, and does real location resume? ```java @@ -618,6 +780,44 @@ disabled and no app receives the fixes, even though every API call succeeds. Det link to `Settings.ACTION_LOCATION_SOURCE_SETTINGS`. On API 26–27 fall back to `isProviderEnabled(GPS_PROVIDER) || isProviderEnabled(NETWORK_PROVIDER)`. +### 6.7 The exit-target grace window + +The target is fed from the SDK's connected-provider telemetry, and that list can go +momentarily empty while the tunnel is still up: a provider handover, a re-dial, a +telemetry gap. Taken literally an empty list means "no exit city", which tears the +providers down — and §6.5 is emphatic about what tearing down does. The real provider is +restored **instantly** and the mock last-known cache is purged. So a one-second flap does +not degrade to a slightly stale mock fix; it leaks a genuine hardware fix to every app on +the device for as long as the reconnect takes. That is precisely the outcome the feature +exists to prevent, produced by the feature's own plumbing. + +**Rule: an empty provider list while the tunnel is up holds the last known target for +`TARGET_GRACE_PERIOD_MILLIS` (10 s) before clearing it.** A tunnel that genuinely goes +down, a device swap, and shutdown all clear immediately and open no window — the window +exists for provider churn, not for a connection the user or the SDK ended. + +Why 10 s, bounded from both sides: + +- **Ceiling.** The held target keeps being re-posted at 1 Hz (§6.2) and so stays well + inside `getCurrentLocation()`'s 30 s freshness rule (§6.1); holding it can never strand + a consumer on a fix the platform is about to discard. +- **Floor.** Every flap shorter than the window would otherwise leak a real fix (§6.5). + 10 s covers a provider handover or a network re-dial with room to spare. + +Two details that are easy to get wrong: + +- **Do not restart the window on every flap.** A dip that arrives while a window is + already running must let the running window own the clear. Restarting it means a + provider list that flaps once a second extends the hold without bound. +- **A queued expiry has to be able to lose its race.** Anything that supersedes it — a + fresh target, tunnel-down, a device swap — must invalidate it, or a callback that fires + after the target was legitimately replaced will clear the *new* one. As shipped that is + a monotonic generation counter handed to the scheduled callback and re-checked when it + runs. + +The decision logic is deliberately free of Android types so it is unit-testable on the +JVM; the Android side owns only the looper and the SDK subscriptions (§10.1). + --- ## 7. Effects while active @@ -724,9 +924,21 @@ background-location justification/video review, "central to core functionality" The mock-only design needs **none** of it. **Practically:** run the 1 Hz poster from the existing VPN foreground service (or a -lifecycle object it owns). No new manifest permission beyond `ACCESS_MOCK_LOCATION`, no -new FGS type, no new runtime prompt. The only user-visible prerequisite is the -Developer-options selection. +lifecycle object it owns). For the platform path — which is the entire feature on +`github`/F-Droid and the load-bearing part of it everywhere else — this stays exact: no +new manifest permission beyond `ACCESS_MOCK_LOCATION`, no new FGS type, no new runtime +prompt, and the only user-visible prerequisite is the Developer-options selection. + +**One qualification, for the GMS flavors only.** The optional FLP mirror (§3.2) is +annotated as wanting a runtime location permission, so `play`, `solana_dapp` and +`ethos_dapp` declare `ACCESS_COARSE_LOCATION` and offer a single optional prompt from the +setup guide (§1.4). None of this section's reasoning changes: it is still not +`ACCESS_FINE_LOCATION`, still not `ACCESS_BACKGROUND_LOCATION`, still no `location` +foreground-service type, and there is still no while-in-use restriction to work around, +because nothing in the app ever *reads* a location. Declining the prompt costs the mirror +and nothing else — the four `LocationManager` test-provider calls above still perform +exactly one authorization step, and it is still the app op. The pass-through design's +permission bill is unchanged and is still the reason not to build it (§10.4). --- @@ -772,10 +984,11 @@ for the deselection-while-active trap. | Class | Responsibility | |---|---| | **`MockLocationController`** | Sole owner of all `LocationManager` test-provider calls. Holds the state machine, the active provider-name set, the current target lat/lon, and the repost ticker. Single-threaded (own `Handler`), no locking. Exposes `StateFlow`. | -| **`MockLocationEligibility`** (small, could be static methods on the controller) | Pure reads: `isDeveloperOptionsEnabled()`, `isSelectedMockApp()`, `isLocationServicesEnabled()`, `devSettingsIntent()`. No side effects. | -| **VPN service integration** | Calls `controller.onExitProviderChanged(city)` / `onTunnelDown()` / `onTunnelUp()`. Owns the controller's lifetime; starts it in `onCreate`, `shutdown()`s in `onDestroy`. | -| **Settings UI + setup sheet** | Renders state; drives the guide; hosts the toggle. Never touches `LocationManager` directly. | -| **`MockLocationStartupCleaner`** (can be one method on the controller) | On process start: if the persisted preference is OFF, best-effort `removeTestProvider` ×N to clear leftovers from a previous process. | +| **`MockLocationEligibility`** (small, could be static methods on the controller) | Pure reads plus the Settings deep links (§5): `isDeveloperOptionsEnabled()`, `isSelectedMockLocationApp()`, `isLocationServicesEnabled()`, `hasLocationPermission()`, the app-op watcher, and the `open*Settings` launchers. No side effects beyond `startActivity`. | +| **`MockLocationFeeder`** | The tunnel/target feed. Subscribes to the SDK device's connect, tunnel and connected-provider-location events, extracts the exit provider's coordinates, and calls `controller.onTunnelChanged()` / `onTargetChanged()`. Owns the looper and the subscriptions; owns no grace-window rules. Started from the application after the controller, which is what makes the controller's startup sweep provably see a down tunnel (§6.3). | +| **`MockLocationGracePolicy`** | Pure decision logic for the exit-target grace window (§6.7): push / hold / already-holding / clear, plus the generation counter that lets a queued expiry lose its race. No Android types, so it is unit-testable on the JVM. | +| **Settings UI + setup sheet** | Renders state; drives the guide; hosts the toggle; owns the optional permission prompt and its permanently-denied fallback (§5). Never touches `LocationManager` directly. | +| **Startup cleanup** | Not a separate class as shipped — the controller does it on start, sweeping unless the resolved status is already `ACTIVE` (§6.3), and the reclaim then retries from every later reconcile (§6.4). | ### 10.2 Exact calls, per version branch @@ -835,6 +1048,18 @@ dependency, and only guarded by `flp.setMockMode(false)` on teardown, including from `onDestroy` and from the startup cleaner. +As shipped this leg is additionally gated on the `ACCESS_COARSE_LOCATION` grant (§3.2): +`fusedActive = supportsFusedMockLocation(context) && locationPermissionGranted`, evaluated +when arming and re-checked on reconcile so that a grant arriving *while already armed* can +engage the mirror without re-registering the platform providers. Two asymmetries are +deliberate: + +- the teardown `setMockMode(false)` is gated on **nothing** — exiting a device-global mode + has to run on every path, including the one where the grant was revoked mid-session; +- the per-tick `setMockLocation` runs at 1 Hz, so its failure listener is deduplicated and + backed off rather than logged per tick. A persistent failure otherwise writes a line + every second for as long as the tunnel is up. + ### 10.3 State machine ``` @@ -880,6 +1105,20 @@ Additional error state **`ERROR_TRANSIENT`** for unexpected `IllegalArgumentExce the settings screen, on the op-watcher callback, and on `ACTION_LOCATION_MODE_CHANGED`/`PROVIDERS_CHANGED` broadcasts. +**`NEEDS_LOCATION_PERMISSION` is not a rung in this ladder.** The shipped enum carries the +constant, but the resolver never returns it. The optional `ACCESS_COARSE_LOCATION` grant +sits outside the state machine entirely: it is published as two plain booleans on the +state — "does this build have an FLP mirror to gate" and "is it granted" — which the setup +guide renders as an optional extra step, shown whenever the grant is missing rather than +in setup order. `setupComplete` deliberately does not include it either. + +The reason is worth stating plainly, because the shape is tempting and the failure is +silent: adding the grant as a gate here makes it a precondition of *everything*, and since +arming registers the permission-free AOSP providers **before** the optional mirror (§3.2, +§10.2), a device where the user declined an explicitly optional prompt would lose the +whole feature — including the leg that never needed the permission. Gate the mirror in the +controller; leave the ladder alone. + ### 10.4 Pass-through when disabled: remove vs. mirror **Recommendation: remove the test providers. Do not mirror.** The mirroring option is not @@ -888,8 +1127,8 @@ merely worse, it is largely unbuildable: | | **A. Remove test providers (recommended)** | **B. Mirror real location through the mock provider** | |---|---|---| | Can it even work? | Yes. Real providers are automatically restored (`setProviderLocked(mRealProvider)`), stale mock last-knowns are purged. | **Structurally broken.** While `"gps"` is mocked, the real GPS implementation is stopped (`setRequest(EMPTY)` + `stop()`) and *nobody*, including us, can read it. There is no un-mocked source to mirror from once gps+network+fused are covered. | -| Permissions | None. | `ACCESS_FINE_LOCATION` + `ACCESS_BACKGROUND_LOCATION` + `FOREGROUND_SERVICE_LOCATION` + `location` FGS type. | -| Play policy | Out of scope of Location Permissions policy. | Background-location justification, prominent disclosure, review. | +| Permissions | None on the AOSP path, and none at all on `github`/F-Droid. The GMS flavors declare `ACCESS_COARSE_LOCATION` for the optional FLP mirror only; declining it costs the mirror, not the feature (§1.4, §3.2). | `ACCESS_FINE_LOCATION` + `ACCESS_BACKGROUND_LOCATION` + `FOREGROUND_SERVICE_LOCATION` + `location` FGS type. | +| Play policy | In scope of the Location Permissions policy on the GMS flavors, with a short justification: approximate only, one optional prompt from one explicit user action, and no device location ever read (§1.4). Out of scope entirely on `github`/F-Droid. | Background-location justification, prominent disclosure, review. | | Correctness for other apps | Real fixes are genuinely real: `isMock=false`. Banking/rideshare/games behave normally. | Every "real" fix is stamped `isMock=true` — silently breaks those apps while the user believes location is off. | | Battery | Real GNSS runs only when some app actually requests it. | Must hold a continuous location request to have something to mirror; duty-cycles GNSS permanently. | | Latency after toggle-off | Seconds (normal GNSS reacquisition). | N/A | @@ -909,7 +1148,7 @@ detectable, and still taints fixes as mock. | **Exit provider changes mid-session** | Just update the target coordinates; the next tick posts the new city. Do **not** re-add providers, do not interpolate — a teleport is expected of a VPN feature. Consider resetting `speed`/`bearing` to 0 and briefly widening `accuracy` so consumers treat it as a new fix rather than an implausible 900 km/h move (many apps filter on implied velocity). | | **VPN disconnects / tunnel down** | Tear down (remove providers) so the device isn't left reporting a city it isn't exiting through. Re-arm automatically when the tunnel returns and the feature is still on. Never leave a mock fix live without a corresponding tunnel. | | **Exit provider city unknown / no geo data** | Stay in `ELIGIBLE`, don't register providers. Surface "waiting for provider location" rather than posting a guess. | -| **App process death / force-stop / crash** | Providers linger (§6.3). At every process start: if the persisted toggle is OFF → best-effort `removeTestProvider` ×N; if ON → re-add and resume (on S+ `addTestProvider` cleanly replaces the orphan). Persist the toggle *and* the provider-name set registered. | +| **App process death / force-stop / crash** | Providers linger (§6.3). At every process start, sweep unless the resolved status is already `ACTIVE` — which on a cold start it never is, because the tunnel is not up yet — then re-arm normally if the feature is on (on S+ `addTestProvider` cleanly replaces the orphan). Persist the toggle *and* the provider-name set, and persist the set **before** registering, so a death mid-arm still leaves a reclaimable record (§6.4). | | **Uninstall while active** | Providers linger until reboot; nothing fixable from code. Mention it in the guide: *turn the feature off before uninstalling.* | | **User deselects the app in Developer options while active** | `SecurityException` on the next call; cleanup impossible (§6.4). Transition to `ORPHANED`, show the recovery instructions, and retry cleanup automatically via the `startWatchingMode` callback if the op is ever restored. | | **User turns Developer options off entirely** | Same as deselection (Settings resets the op via `onDeveloperOptionsDisabled`). Same `ORPHANED` handling. | @@ -918,7 +1157,10 @@ detectable, and still taints fixes as mock. | **Multi-user / work profile** | The op is per-uid-per-user; the picker only lists apps in the current user. Nothing special to do, but don't assume a single global state. | | **Direct boot / pre-unlock** | Don't attempt anything before user unlock; `LocationManagerService`'s provider set is still settling during boot. Arm from the existing post-unlock VPN startup path. | | **API 26–30 device** | No `fused` mocking (pointless pre-31 anyway); legacy `addTestProvider` overload; must remove-before-add; `isLocationEnabled()` unavailable on 26–27 → fall back to `isProviderEnabled`. | -| **Non-GMS device (GrapheneOS, Huawei)** | Platform path works unchanged; skip the optional FLP layer behind a `GoogleApiAvailability` check. | +| **Non-GMS device (GrapheneOS, Huawei)** | Platform path works unchanged; skip the optional FLP layer behind a `GoogleApiAvailability` check. On `github`/F-Droid there is no FLP layer compiled in at all, and the flavor manifest removes the location permission the other flavors declare (§1.4). | +| **User declines the optional location permission (GMS flavors)** | Nothing breaks: the AOSP providers are armed first and unconditionally, and only the FLP mirror is skipped (§3.2). Never block the toggle, the guide's "Ready" state, or `setupComplete` on it. Re-read the grant on resume and on the app-op watcher, so a grant made outside the app's own prompt still reaches the controller. | +| **Optional permission permanently denied** | The system dialog stops appearing and `launch()` returns instantly, so the step's button must switch to the app-info deep link or it is dead (§5). Detect via `shouldShowRequestPermissionRationale()` after a denied result; when the check cannot be made, keep asking rather than sending the user to a settings page they may not need. | +| **Exit provider list dips while the tunnel stays up** | Do not disarm on the first empty list. Hold the last target for the grace window (§6.7); disarming instantly restores the real provider and leaks a hardware fix for the length of the flap (§6.5). | | **Aggressive OEM battery management (Xiaomi/Huawei/Oppo)** | The poster lives in the VPN foreground service, so it survives; still worth a "disable battery optimization for URnetwork" hint if users report frozen locations. | ### 10.6 Disclosure text worth shipping with the toggle @@ -932,6 +1174,15 @@ Short, three bullets, shown in the setup sheet: deselecting URnetwork, or uninstalling — otherwise your device's location may stay frozen until you restart it.* +All three ship on the setup guide, which is also the only screen that can request the +optional location permission — so on the GMS flavors they are what carries prominent +disclosure for the `ACCESS_COARSE_LOCATION` declaration (§1.4): same screen, same flow, +describing the device-wide effect the permission contributes to. (The first two are +repeated on the feature's settings section, where the toggle lives.) The permission step's +own copy states that it is optional and says what it buys, and must keep doing so — a step +that reads like a requirement is both untrue (§3.2) and the thing that tempts the next +implementer to make it one (§10.3). + --- ## Sources diff --git a/app/app/src/ethos_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt b/app/app/src/ethos_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt index 2b3813e1f..bec9929df 100644 --- a/app/app/src/ethos_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt +++ b/app/app/src/ethos_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt @@ -2,6 +2,7 @@ package com.bringyour.network.location import android.content.Context import android.location.Location +import android.os.SystemClock import android.util.Log import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability @@ -9,13 +10,15 @@ import com.google.android.gms.location.LocationServices private const val TAG = "FusedMockLocation" -/** - * Checks whether Google Play Services is available on the device to support mocking - * the FusedLocationProviderClient directly (MOCKLOCATION.md §3.2). - * - * @param context Application or component context used to query Google Play Services availability. - * @return True if Google Play Services is installed, enabled, and operational. - */ +// GMS layer of the mock location engine (MOCKLOCATION.md §3.2): mirror the +// mock fix into the Google Play services fused location provider so FLP-based +// consumers (Chrome, Google Maps et al.) reliably follow it. Beyond the +// developer-options selection the platform path already needs, this leg also +// needs the ACCESS_COARSE_LOCATION runtime grant — the controller gates the +// mirror on it. The platform test providers need no runtime permission (§8) +// and carry the feature on their own, so every failure here is logged and +// swallowed rather than surfaced. + fun supportsFusedMockLocation(context: Context): Boolean { return try { GoogleApiAvailability.getInstance() @@ -25,15 +28,10 @@ fun supportsFusedMockLocation(context: Context): Boolean { } } -/** - * Sets whether the Google Play Services Fused Location Provider is in mock mode. - * - * Entering mock mode clears FLP caches and ensures FLP clients only receive mock locations - * pushed through [setFusedMockLocation]. Exiting mock mode restores standard provider fusion. - * - * @param context Application context used to obtain the FusedLocationProviderClient. - * @param enabled True to engage mock mode; false to disengage and restore normal location. - */ +// setMockMode is device-global (affects all FLP clients in every process) — +// callers must always exit mock mode on every teardown path. Both Task +// outcomes are worth a line: this runs twice per arm/disarm cycle, not per +// tick, and a silently failed exit is what leaves other processes mocked. fun setFusedMockMode(context: Context, enabled: Boolean) { if (!supportsFusedMockLocation(context)) { return @@ -53,13 +51,32 @@ fun setFusedMockMode(context: Context, enabled: Boolean) { } } -/** - * Pushes a mock fix to the Google Play Services Fused Location Provider so FLP-based consumers - * (such as Google Chrome and Google Maps) receive the synced location. - * - * @param context Application context used to obtain the FusedLocationProviderClient. - * @param location The complete [Location] fix containing monotonic timestamps and coordinates. - */ +// setMockLocation runs at 1 Hz for as long as the tunnel is up, so an +// unthrottled failure listener writes the same line every second, forever. +// Dedup like MainApplication's contract status log: the first failure speaks, +// an identical one stays quiet until the backoff expires. +private const val FAILURE_LOG_INTERVAL_MILLIS = 60_000L +private var lastMockLocationFailureMessage: String? = null +private var lastMockLocationFailureLogMillis = 0L + +// only the failure listener touches this state, and GMS delivers Task +// callbacks on the main looper, so it stays single-threaded and needs no +// locking; the catch blocks below run on the caller's thread and are +// deliberately left unthrottled +private fun shouldLogMockLocationFailure(message: String): Boolean { + val now = SystemClock.elapsedRealtime() + if (message == lastMockLocationFailureMessage && + now - lastMockLocationFailureLogMillis < FAILURE_LOG_INTERVAL_MILLIS + ) { + return false + } + lastMockLocationFailureMessage = message + lastMockLocationFailureLogMillis = now + return true +} + +// the mirror leg of the 1 Hz poster; the fix has to carry monotonically +// increasing timestamps (§3.2), which the caller builds fun setFusedMockLocation(context: Context, location: Location) { if (!supportsFusedMockLocation(context)) { return @@ -67,7 +84,10 @@ fun setFusedMockLocation(context: Context, location: Location) { try { LocationServices.getFusedLocationProviderClient(context).setMockLocation(location) .addOnFailureListener { e -> - Log.w(TAG, "GMS fused location provider setMockLocation failed: ${e.message}") + val message = e.message ?: e.toString() + if (shouldLogMockLocationFailure(message)) { + Log.w(TAG, "GMS fused location provider setMockLocation failed: $message") + } } } catch (e: SecurityException) { Log.w(TAG, "GMS setMockLocation security exception: ${e.message}") diff --git a/app/app/src/github/AndroidManifest.xml b/app/app/src/github/AndroidManifest.xml new file mode 100644 index 000000000..453adef39 --- /dev/null +++ b/app/app/src/github/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/app/src/google/java/com/bringyour/network/location/FusedMockLocationSupport.kt b/app/app/src/google/java/com/bringyour/network/location/FusedMockLocationSupport.kt index 2b3813e1f..bec9929df 100644 --- a/app/app/src/google/java/com/bringyour/network/location/FusedMockLocationSupport.kt +++ b/app/app/src/google/java/com/bringyour/network/location/FusedMockLocationSupport.kt @@ -2,6 +2,7 @@ package com.bringyour.network.location import android.content.Context import android.location.Location +import android.os.SystemClock import android.util.Log import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability @@ -9,13 +10,15 @@ import com.google.android.gms.location.LocationServices private const val TAG = "FusedMockLocation" -/** - * Checks whether Google Play Services is available on the device to support mocking - * the FusedLocationProviderClient directly (MOCKLOCATION.md §3.2). - * - * @param context Application or component context used to query Google Play Services availability. - * @return True if Google Play Services is installed, enabled, and operational. - */ +// GMS layer of the mock location engine (MOCKLOCATION.md §3.2): mirror the +// mock fix into the Google Play services fused location provider so FLP-based +// consumers (Chrome, Google Maps et al.) reliably follow it. Beyond the +// developer-options selection the platform path already needs, this leg also +// needs the ACCESS_COARSE_LOCATION runtime grant — the controller gates the +// mirror on it. The platform test providers need no runtime permission (§8) +// and carry the feature on their own, so every failure here is logged and +// swallowed rather than surfaced. + fun supportsFusedMockLocation(context: Context): Boolean { return try { GoogleApiAvailability.getInstance() @@ -25,15 +28,10 @@ fun supportsFusedMockLocation(context: Context): Boolean { } } -/** - * Sets whether the Google Play Services Fused Location Provider is in mock mode. - * - * Entering mock mode clears FLP caches and ensures FLP clients only receive mock locations - * pushed through [setFusedMockLocation]. Exiting mock mode restores standard provider fusion. - * - * @param context Application context used to obtain the FusedLocationProviderClient. - * @param enabled True to engage mock mode; false to disengage and restore normal location. - */ +// setMockMode is device-global (affects all FLP clients in every process) — +// callers must always exit mock mode on every teardown path. Both Task +// outcomes are worth a line: this runs twice per arm/disarm cycle, not per +// tick, and a silently failed exit is what leaves other processes mocked. fun setFusedMockMode(context: Context, enabled: Boolean) { if (!supportsFusedMockLocation(context)) { return @@ -53,13 +51,32 @@ fun setFusedMockMode(context: Context, enabled: Boolean) { } } -/** - * Pushes a mock fix to the Google Play Services Fused Location Provider so FLP-based consumers - * (such as Google Chrome and Google Maps) receive the synced location. - * - * @param context Application context used to obtain the FusedLocationProviderClient. - * @param location The complete [Location] fix containing monotonic timestamps and coordinates. - */ +// setMockLocation runs at 1 Hz for as long as the tunnel is up, so an +// unthrottled failure listener writes the same line every second, forever. +// Dedup like MainApplication's contract status log: the first failure speaks, +// an identical one stays quiet until the backoff expires. +private const val FAILURE_LOG_INTERVAL_MILLIS = 60_000L +private var lastMockLocationFailureMessage: String? = null +private var lastMockLocationFailureLogMillis = 0L + +// only the failure listener touches this state, and GMS delivers Task +// callbacks on the main looper, so it stays single-threaded and needs no +// locking; the catch blocks below run on the caller's thread and are +// deliberately left unthrottled +private fun shouldLogMockLocationFailure(message: String): Boolean { + val now = SystemClock.elapsedRealtime() + if (message == lastMockLocationFailureMessage && + now - lastMockLocationFailureLogMillis < FAILURE_LOG_INTERVAL_MILLIS + ) { + return false + } + lastMockLocationFailureMessage = message + lastMockLocationFailureLogMillis = now + return true +} + +// the mirror leg of the 1 Hz poster; the fix has to carry monotonically +// increasing timestamps (§3.2), which the caller builds fun setFusedMockLocation(context: Context, location: Location) { if (!supportsFusedMockLocation(context)) { return @@ -67,7 +84,10 @@ fun setFusedMockLocation(context: Context, location: Location) { try { LocationServices.getFusedLocationProviderClient(context).setMockLocation(location) .addOnFailureListener { e -> - Log.w(TAG, "GMS fused location provider setMockLocation failed: ${e.message}") + val message = e.message ?: e.toString() + if (shouldLogMockLocationFailure(message)) { + Log.w(TAG, "GMS fused location provider setMockLocation failed: $message") + } } } catch (e: SecurityException) { Log.w(TAG, "GMS setMockLocation security exception: ${e.message}") diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml index d0d0a59ef..2cfb94870 100644 --- a/app/app/src/main/AndroidManifest.xml +++ b/app/app/src/main/AndroidManifest.xml @@ -45,10 +45,24 @@ android:name="android.software.leanback" android:required="false" /> - + + + + + - + diff --git a/app/app/src/main/java/com/bringyour/network/location/MockLocationController.kt b/app/app/src/main/java/com/bringyour/network/location/MockLocationController.kt index db4ac1494..589a54160 100644 --- a/app/app/src/main/java/com/bringyour/network/location/MockLocationController.kt +++ b/app/app/src/main/java/com/bringyour/network/location/MockLocationController.kt @@ -189,8 +189,9 @@ class MockLocationController @Inject constructor( // the op callback arrives on a binder thread handler.post { onMockLocationOpChanged() } } - if (!enabled) { - // §6.3: nothing removes test providers on process death — clear + if (resolveStatus() != MockLocationStatus.ACTIVE) { + // §6.3: nothing removes test providers on process death, and a + // toggle left ON does not mean this process armed them — clear // anything a previous process left behind removeAllTestProviders() } @@ -200,9 +201,11 @@ class MockLocationController @Inject constructor( private fun onMockLocationOpChanged() { refreshEligibilitySignals() if (selectedMockApp && !posting && (orphaned || !enabled)) { - // the op is back: the deferred cleanup is now possible (§6.4). - // On success this clears the orphaned flag; reconcile then lands - // on DISABLED (toggle off) or re-arms cleanly (toggle on). + // the mock-location op is back (the watcher also wakes us for + // COARSE changes; this branch is idempotent either way): the + // deferred cleanup is now possible (§6.4). On success this clears + // the orphaned flag; reconcile then lands on DISABLED (toggle off) + // or re-arms cleanly (toggle on). removeAllTestProviders() } reconcile() @@ -225,8 +228,6 @@ class MockLocationController @Inject constructor( tunnelUp = tunnelUp, target = target, orphaned = orphaned, - requiresLocationPermission = requiresLocationPermission, - locationPermissionGranted = locationPermissionGranted, ) } @@ -234,9 +235,17 @@ class MockLocationController @Inject constructor( val shouldPost = resolveStatus() == MockLocationStatus.ACTIVE && !errorTransient if (shouldPost && !posting) { arm() - } else if (!shouldPost && posting) { + } else if (!shouldPost && (posting || mayHaveRegisteredProviders())) { disarm() } + // the grant can arrive while the AOSP leg is already armed (the + // guide's button); engage the optional mirror without re-arming + if (posting && !fusedActive && locationPermissionGranted && + supportsFusedMockLocation(context) + ) { + fusedActive = true + setFusedMockMode(context, true) + } publishState() } @@ -295,7 +304,9 @@ class MockLocationController @Inject constructor( addTestProvider(locationManager, name) } registeredProviders = names - fusedActive = supportsFusedMockLocation(context) + // the FLP mirror is the only leg that needs the COARSE grant + // (§3.2); the AOSP test providers above are already registered + fusedActive = supportsFusedMockLocation(context) && locationPermissionGranted if (fusedActive) { setFusedMockMode(context, true) } @@ -366,10 +377,24 @@ class MockLocationController @Inject constructor( private fun disarm() { handler.removeCallbacks(postRunnable) + val wasPosting = posting posting = false fusedActive = false removeAllTestProviders() - Log.i(TAG, "mock location disarmed") + if (wasPosting) { + // reconcile lets disarm run purely to reclaim leftovers; only a + // real stop is worth a line + Log.i(TAG, "mock location disarmed") + } + } + + // providers can be registered while `posting` is false: arm() persists the + // claimed set before it registers anything, and a failed cleanup keeps it. + // Without this, a disarm that must reclaim leftovers is skipped whenever + // posting is already false (§6.3/§6.4). + private fun mayHaveRegisteredProviders(): Boolean { + return registeredProviders.isNotEmpty() || + prefs.getStringSet(PREF_KEY_REGISTERED_PROVIDERS, null)?.isNotEmpty() == true } // Best-effort removal of every provider this app may have registered @@ -433,7 +458,7 @@ class MockLocationController @Inject constructor( buildLocation(name, postTarget, accuracy), ) } - if (fusedActive) { + if (fusedActive && locationPermissionGranted) { setFusedMockLocation( context, buildLocation(LocationManager.GPS_PROVIDER, postTarget, accuracy), diff --git a/app/app/src/main/java/com/bringyour/network/location/MockLocationEligibility.kt b/app/app/src/main/java/com/bringyour/network/location/MockLocationEligibility.kt index d40e1da82..3f7febfdb 100644 --- a/app/app/src/main/java/com/bringyour/network/location/MockLocationEligibility.kt +++ b/app/app/src/main/java/com/bringyour/network/location/MockLocationEligibility.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.location.LocationManager +import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Process @@ -62,10 +63,10 @@ fun hasLocationPermission(context: Context): Boolean { } // Watches the MOCK_LOCATION app op for this package (fires when the user -// selects or deselects the app in Developer options). Watching your own uid -// needs no permission. Returns an unwatch lambda. Note the callback may -// arrive on an arbitrary binder thread — hop to your own thread before -// touching state. +// selects or deselects the app in Developer options) and, best effort, the +// COARSE_LOCATION op. Watching your own uid needs no permission. Returns a +// single unwatch lambda covering both. Note the callback may arrive on an +// arbitrary binder thread — hop to your own thread before touching state. fun startWatchingMockLocationOp(context: Context, onChanged: () -> Unit): () -> Unit { val appOps = context.getSystemService(AppOpsManager::class.java) ?: return {} val listener = AppOpsManager.OnOpChangedListener { _, _ -> onChanged() } @@ -78,6 +79,20 @@ fun startWatchingMockLocationOp(context: Context, onChanged: () -> Unit): () -> } catch (e: Throwable) { return {} } + try { + // the optional FLP mirror is gated on the COARSE grant (§3.2), so a + // grant made outside our own launcher has to reach the controller + // too. Best effort: not every build reports runtime-permission op + // changes, and ON_RESUME still re-reads the signals. + appOps.startWatchingMode( + AppOpsManager.OPSTR_COARSE_LOCATION, + context.packageName, + listener, + ) + } catch (e: Throwable) { + // op not watchable here; the mock-location watch above still stands + } + // one listener, one unwatch: stopWatchingMode drops it from every op return { try { appOps.stopWatchingMode(listener) @@ -110,6 +125,14 @@ fun openLocationSettings(context: Context) { startSettingsActivity(context, Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } +// App info for this package — the only route left once COARSE is +// permanently denied and the system dialog no longer shows. +fun openAppSettings(context: Context) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.fromParts("package", context.packageName, null) + startSettingsActivity(context, intent) +} + private fun startSettingsActivity(context: Context, intent: Intent) { val resolved = if (intent.resolveActivity(context.packageManager) != null) { intent diff --git a/app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt b/app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt index 55d4a75df..3d03be2ef 100644 --- a/app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt +++ b/app/app/src/main/java/com/bringyour/network/location/MockLocationFeeder.kt @@ -15,7 +15,9 @@ import javax.inject.Singleton * * Employs a debounce grace period during transient provider reconnections so that momentary * drops in connected provider telemetry do not immediately disarm test providers and leak - * raw hardware GPS fixes. + * raw hardware GPS fixes. The window itself is decided by [MockLocationGracePolicy]; this + * class owns only the looper and the SDK subscriptions, which is what keeps the grace rules + * testable on the JVM. */ @Singleton class MockLocationFeeder @Inject constructor( @@ -24,16 +26,14 @@ class MockLocationFeeder @Inject constructor( ) { companion object { private const val TAG = "MockLocationFeeder" - - /** - * Grace period to retain the last known valid provider location during transient - * provider telemetry handovers or network re-dials before falling back to null. - */ - private const val TARGET_GRACE_PERIOD_MILLIS = 10_000L } private val handler = Handler(Looper.getMainLooper()) + // holds lastKnownTarget and the generation counter; only ever touched under + // this object's monitor, which is the lock those fields already lived under + private val gracePolicy = MockLocationGracePolicy() + @Volatile private var removeDeviceChangeListener: (() -> Unit)? = null @Volatile @@ -45,11 +45,7 @@ class MockLocationFeeder @Inject constructor( @Volatile private var currentDevice: DeviceLocal? = null @Volatile - private var lastKnownTarget: MockLocationTarget? = null - @Volatile private var pendingClearRunnable: Runnable? = null - @Volatile - private var graceGeneration: Long = 0L /** * Subscribes to device manager changes and initializes mock location feeding @@ -72,17 +68,25 @@ class MockLocationFeeder @Inject constructor( removeDeviceChangeListener?.invoke() removeDeviceChangeListener = null cancelPendingTargetClear() - lastKnownTarget = null attach(null) } /** - * Cancels any pending scheduled target clear runnable if active and increments the - * grace generation counter to invalidate any callbacks currently queued on the looper. + * Cancels any pending scheduled target clear runnable if active and drops the last known + * target. The policy's generation bump invalidates any callback currently queued on the + * looper, so a clear that is already in flight cannot fire against the new state. */ @Synchronized private fun cancelPendingTargetClear() { - graceGeneration++ + gracePolicy.cancel() + dropPendingClearRunnable() + } + + // the queued expiry is unqueued without touching the policy: callers that + // must also invalidate it go through cancelPendingTargetClear, and a fresh + // target already bumped the generation when the policy accepted it + @Synchronized + private fun dropPendingClearRunnable() { pendingClearRunnable?.let { handler.removeCallbacks(it) } pendingClearRunnable = null } @@ -98,7 +102,6 @@ class MockLocationFeeder @Inject constructor( private fun attach(device: DeviceLocal?) { if (currentDevice !== device) { cancelPendingTargetClear() - lastKnownTarget = null controller.onTargetChanged(null) } connectSub?.close() @@ -121,7 +124,6 @@ class MockLocationFeeder @Inject constructor( pushTarget(device) } else { cancelPendingTargetClear() - lastKnownTarget = null controller.onTargetChanged(null) } } @@ -150,7 +152,6 @@ class MockLocationFeeder @Inject constructor( updateClientTunnelState() } else { cancelPendingTargetClear() - lastKnownTarget = null controller.onTunnelChanged(false) controller.onTargetChanged(null) } @@ -158,11 +159,12 @@ class MockLocationFeeder @Inject constructor( /** * Extracts coordinates and geographic metadata from the first valid connected exit provider - * on [device] and pushes the target location to the [controller]. + * on [device] and hands the result to [MockLocationGracePolicy], which decides whether it is + * pushed, held for [TARGET_GRACE_PERIOD_MILLIS] or cleared. * - * If provider locations are momentarily empty while the tunnel remains active, retains - * [lastKnownTarget] for a grace window of [TARGET_GRACE_PERIOD_MILLIS] before clearing, - * guarding against transient provider flaps. + * If provider locations are momentarily empty while the tunnel remains active, the last known + * target is retained for the grace window before clearing, guarding against transient + * provider flaps. * * @param device The active [DeviceLocal] instance containing connected provider locations. */ @@ -200,21 +202,21 @@ class MockLocationFeeder @Inject constructor( } } - if (target != null) { - cancelPendingTargetClear() - lastKnownTarget = target - controller.onTargetChanged(target) - } else if (lastKnownTarget != null) { - // Providers list momentarily dipped while tunnel is active. Retain last target - // for the grace period rather than eagerly disarming and exposing hardware GPS. - if (pendingClearRunnable == null) { - val generation = ++graceGeneration + val decision = gracePolicy.onTargetResolved(target) + when (decision.action) { + MockTargetAction.PUSH -> { + dropPendingClearRunnable() + controller.onTargetChanged(decision.target) + } + MockTargetAction.HOLD -> { + // Providers list momentarily dipped while tunnel is active. Retain last target + // for the grace period rather than eagerly disarming and exposing hardware GPS. + val generation = decision.generation val runnable = object : Runnable { override fun run() { synchronized(this@MockLocationFeeder) { - if (pendingClearRunnable === this && graceGeneration == generation) { + if (pendingClearRunnable === this && gracePolicy.graceExpired(generation)) { pendingClearRunnable = null - lastKnownTarget = null controller.onTargetChanged(null) Log.i(TAG, "Provider grace period expired; cleared mock target") } @@ -222,11 +224,12 @@ class MockLocationFeeder @Inject constructor( } } pendingClearRunnable = runnable - handler.postDelayed(runnable, TARGET_GRACE_PERIOD_MILLIS) - Log.i(TAG, "Provider locations momentarily empty; holding exit target for ${TARGET_GRACE_PERIOD_MILLIS}ms grace window") + handler.postDelayed(runnable, decision.delayMillis) + Log.i(TAG, "Provider locations momentarily empty; holding exit target for ${decision.delayMillis}ms grace window") } - } else { - controller.onTargetChanged(null) + // the window already running owns the clear + MockTargetAction.ALREADY_HOLDING -> Unit + MockTargetAction.CLEAR -> controller.onTargetChanged(null) } } } diff --git a/app/app/src/main/java/com/bringyour/network/location/MockLocationGracePolicy.kt b/app/app/src/main/java/com/bringyour/network/location/MockLocationGracePolicy.kt new file mode 100644 index 000000000..6bc820f6f --- /dev/null +++ b/app/app/src/main/java/com/bringyour/network/location/MockLocationGracePolicy.kt @@ -0,0 +1,104 @@ +package com.bringyour.network.location + +// Pure decision logic for the exit-target grace window (no Android +// dependencies) so it is unit testable on the JVM. MockLocationFeeder builds a +// main-looper Handler in a field initializer and cannot be constructed in a +// unit test, so the generation-tracked state machine lives here and the feeder +// keeps only the Handler and the SDK subscriptions. Same split as +// TunnelRecoveryPolicy. + +// How long the last target survives an empty connected-provider list. The +// ceiling comes from MOCKLOCATION.md §6.1: getCurrentLocation() discards any +// fix older than 30 s, and the controller keeps re-posting the held target at +// 1 Hz (§6.2), so a window well under 30 s can never strand a consumer. The +// floor is the cost of being wrong: removeTestProvider restores the real +// provider instantly and purges the mock last-known cache (§6.5), so every +// provider flap shorter than the window would otherwise leak a hardware fix +// for as long as the reconnect takes. 10 s covers a provider handover or a +// network re-dial with room to spare. +internal const val TARGET_GRACE_PERIOD_MILLIS = 10_000L + +internal enum class MockTargetAction { + // a fresh fix: push it and drop any running grace window + PUSH, + + // the provider list dipped while a target is still held: open the window + HOLD, + + // it dipped again while a window is already running; restarting the window + // on every flap would extend it without bound, so let the running one own + // the clear + ALREADY_HOLDING, + + // nothing held worth protecting: clear the target now + CLEAR, +} + +internal data class MockTargetDecision( + val action: MockTargetAction, + val target: MockLocationTarget? = null, + // HOLD only: the caller schedules the expiry carrying this generation and + // hands it back to graceExpired(), which is how a callback that lost its + // race is dropped + val generation: Long = 0L, + val delayMillis: Long = 0L, +) + +// Not thread safe by design: MockLocationFeeder touches this only from inside +// its own monitor (every entry point is @Synchronized or a +// synchronized(feeder) block, including the expiry runnable), which is the +// same lock the fields it replaced were already published under. +internal class MockLocationGracePolicy( + private val gracePeriodMillis: Long = TARGET_GRACE_PERIOD_MILLIS, +) { + // bumped by every event that invalidates a queued expiry; an expiry that + // fires with an older generation must do nothing + private var generation = 0L + private var holding = false + + // the target last pushed to the controller — what the window protects + var lastKnownTarget: MockLocationTarget? = null + private set + + fun onTargetResolved(target: MockLocationTarget?): MockTargetDecision { + if (target != null) { + generation++ + holding = false + lastKnownTarget = target + return MockTargetDecision(MockTargetAction.PUSH, target = target) + } + if (lastKnownTarget == null) { + // never had a fix to protect, so there is nothing to wait for + return MockTargetDecision(MockTargetAction.CLEAR) + } + if (holding) { + return MockTargetDecision(MockTargetAction.ALREADY_HOLDING) + } + holding = true + generation++ + return MockTargetDecision( + MockTargetAction.HOLD, + target = lastKnownTarget, + generation = generation, + delayMillis = gracePeriodMillis, + ) + } + + // true when the expiry that fired is still the current one, i.e. the caller + // must clear the target now + fun graceExpired(generation: Long): Boolean { + if (!holding || generation != this.generation) return false + holding = false + lastKnownTarget = null + return true + } + + // tunnel down, device swap or shutdown: the target goes immediately (no + // grace — the window exists for provider flaps, not for a connection the + // user or the SDK ended) and any queued expiry is invalidated + fun cancel() { + generation++ + holding = false + lastKnownTarget = null + } +} diff --git a/app/app/src/main/java/com/bringyour/network/location/MockLocationState.kt b/app/app/src/main/java/com/bringyour/network/location/MockLocationState.kt index ac5546130..ee5a12d62 100644 --- a/app/app/src/main/java/com/bringyour/network/location/MockLocationState.kt +++ b/app/app/src/main/java/com/bringyour/network/location/MockLocationState.kt @@ -20,8 +20,10 @@ enum class MockLocationStatus { // would succeed but nothing would be delivered to any app NEEDS_LOCATION_ON, - // on GMS devices, Google Play Services FusedLocationProviderClient requires - // ACCESS_COARSE_LOCATION to accept mock mode/locations + // advisory only: on GMS devices the optional FusedLocationProviderClient + // mirror needs ACCESS_COARSE_LOCATION, but the AOSP test providers never + // do (§8) — never returned by resolveMockLocationStatus. The UI reads + // requiresLocationPermission/locationPermissionGranted off the state. NEEDS_LOCATION_PERMISSION, // all preconditions met; waiting for tunnel up + a located provider @@ -66,8 +68,9 @@ data class MockLocationState( val requiresLocationPermission: Boolean = false, ) { val setupComplete: Boolean - get() = devOptionsEnabled && mockAppSelected && locationServicesEnabled && - (!requiresLocationPermission || locationPermissionGranted) + // the COARSE grant is deliberately absent: it buys the optional FLP + // mirror only (§3.2), so it must never hold the toggle hostage + get() = devOptionsEnabled && mockAppSelected && locationServicesEnabled } // Resolves the user-visible status from the engine inputs. @@ -77,8 +80,13 @@ data class MockLocationState( // controller clears it only after a successful cleanup — at which point a // disabled toggle resolves to DISABLED (MOCKLOCATION.md §6.4). The remaining // gates apply in setup order: developer options -> mock app selection -> -// location services -> location permission (when required); then ACTIVE only -// while the tunnel is up and a located provider target exists, ELIGIBLE otherwise. +// location services; then ACTIVE only while the tunnel is up and a located +// provider target exists, ELIGIBLE otherwise. +// +// The COARSE grant is NOT a gate: the AOSP test providers need no runtime +// permission (§8), so gating here would kill the feature on every GMS +// device without it. Only the optional FLP mirror is gated, in the +// controller (§3.2). fun resolveMockLocationStatus( enabled: Boolean, devOptionsEnabled: Boolean, @@ -87,8 +95,6 @@ fun resolveMockLocationStatus( tunnelUp: Boolean, target: MockLocationTarget?, orphaned: Boolean, - requiresLocationPermission: Boolean = false, - locationPermissionGranted: Boolean = false, ): MockLocationStatus { if (orphaned) { return MockLocationStatus.ORPHANED @@ -105,9 +111,6 @@ fun resolveMockLocationStatus( if (!locationServicesEnabled) { return MockLocationStatus.NEEDS_LOCATION_ON } - if (requiresLocationPermission && !locationPermissionGranted) { - return MockLocationStatus.NEEDS_LOCATION_PERMISSION - } return if (tunnelUp && target != null) { MockLocationStatus.ACTIVE } else { diff --git a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt index 9f970075a..e5910813f 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationGuideScreen.kt @@ -1,5 +1,6 @@ package com.bringyour.network.ui.connect.providerlocations +import android.app.Activity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement @@ -33,6 +34,9 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,6 +53,7 @@ import androidx.navigation.NavController import com.bringyour.network.R import com.bringyour.network.location.MockLocationStatus import com.bringyour.network.location.openAboutPhone +import com.bringyour.network.location.openAppSettings import com.bringyour.network.location.openDeveloperOptions import com.bringyour.network.location.openLocationSettings import com.bringyour.network.ui.components.URButton @@ -79,9 +84,22 @@ fun MockLocationGuideScreen( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val activity = context as? Activity + // survives the activity recreation some OEMs do around the permission + // dialog, which is exactly when the flag is needed + var permissionBlocked by rememberSaveable { mutableStateOf(false) } + val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions(), - ) { + ) { results -> + // same idiom as SettingsViewModel.onPermissionResult: once a request has + // been made and there is still no rationale to show, the system dialog + // will never appear again, so the button has to become App info or it is + // dead. A null activity keeps this false and the button keeps asking. + permissionBlocked = results[android.Manifest.permission.ACCESS_COARSE_LOCATION] != true && + activity?.shouldShowRequestPermissionRationale( + android.Manifest.permission.ACCESS_COARSE_LOCATION + ) == false viewModel.refreshEligibility() } @@ -215,15 +233,22 @@ fun MockLocationGuideScreen( GuideStep( text = stringResource(id = R.string.mock_location_step_location_permission), done = state.locationPermissionGranted, - actionLabel = stringResource(id = R.string.mock_location_grant_permission), - current = state.devOptionsEnabled && state.mockAppSelected && - state.locationServicesEnabled && !state.locationPermissionGranted, + actionLabel = if (permissionBlocked) + stringResource(id = R.string.mock_location_open_app_settings) + else + stringResource(id = R.string.mock_location_grant_permission), + // not gated on the steps above: this one only buys the + // optional Google Play mirror, so it is an offer that stands + // whenever the grant is missing, not the next blocking step + current = !state.locationPermissionGranted, onAction = { - permissionLauncher.launch( - arrayOf( - android.Manifest.permission.ACCESS_COARSE_LOCATION, + if (permissionBlocked) { + openAppSettings(context) + } else { + permissionLauncher.launch( + arrayOf(android.Manifest.permission.ACCESS_COARSE_LOCATION) ) - ) + } }, ) } diff --git a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationSection.kt b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationSection.kt index 610686af4..b1121341f 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationSection.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/connect/providerlocations/MockLocationSection.kt @@ -112,12 +112,16 @@ fun MockLocationSection( } } + // ORPHANED is not a togglable state: the test providers are stuck until + // the op comes back, so the preference cannot express what the user wants. + // The recovery row below is the way out of it from this screen. URSwitch( - checked = state.enabled, + checked = state.enabled && state.status != MockLocationStatus.ORPHANED, + enabled = state.status != MockLocationStatus.ORPHANED, toggle = { val enabled = !state.enabled viewModel.setEnabled(enabled) - if (state.status == MockLocationStatus.ORPHANED || (enabled && !state.setupComplete)) { + if (enabled && !state.setupComplete) { navController.navigate(Route.MockLocationGuide) } }, @@ -169,6 +173,9 @@ fun MockLocationSection( stringResource(id = R.string.mock_location_error_stuck_detail), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, + // without the weight the text takes the whole row and the + // chevron below measures to zero width + modifier = Modifier.weight(1f), ) Spacer(modifier = Modifier.width(4.dp)) Icon( diff --git a/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt b/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt index 54941d528..76c69cf7b 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/settings/SettingsScreen.kt @@ -330,10 +330,6 @@ fun SettingsScreen( mockLocationStatus = mockLocationState.status, mockLocationTarget = mockLocationState.target, onToggleMockLocation = { - // NOTE: reads mockLocationState.enabled from the snapshot captured by - // the last composition — between the click and the ViewModel update, - // another caller could toggle it. Acceptable here because the toggle - // is debounced and setEnabled() is idempotent. val enabled = !mockLocationState.enabled mockLocationViewModel.setEnabled(enabled) if (mockLocationState.status == MockLocationStatus.ORPHANED || (enabled && !mockLocationState.setupComplete)) { @@ -520,55 +516,9 @@ fun SettingsScreen( } -/** - * Stateless presentation composable for the Settings screen layout. - * - * @param navController Navigation controller for in-app navigation routes. - * @param clientId Unique identifier for the client installation. - * @param currentPlan Active subscription tier (Basic or Supporter). - * @param notificationsAllowed True if OS notification permissions are granted. - * @param notificationsPermanentlyDenied True if notification permissions were permanently declined. - * @param requestAllowNotifications Callback to request system notification permission. - * @param allowProductUpdates True if product update telemetry is enabled. - * @param toggleAllowProductUpdates Callback to toggle product update telemetry. - * @param provideControlMode Current relay/provider routing mode. - * @param setProvideControlMode Callback to select a new relay/provider mode. - * @param deviceName User-assigned label for this device. - * @param deviceSpec Hardware specifications string. - * @param onEditDeviceName Callback to initiate device renaming dialog. - * @param setShowDeleteAccountDialog Callback to control delete account dialog visibility. - * @param showDeleteAccountDialog True if the delete account dialog is visible. - * @param deleteAccount Callback to execute account deletion with success/failure handlers. - * @param isDeletingAccount True if account deletion is currently in progress. - * @param routeLocal True if LAN local routing is enabled. - * @param toggleRouteLocal Callback to toggle LAN local routing. - * @param snackbarHostState State manager for snackbar notifications. - * @param signAndVerifySeekerHolder Callback to trigger Seeker token wallet verification. - * @param isSeekerHolder True if the device has verified ownership of a Seeker token. - * @param version Application build version string. - * @param allowProvideCell True if relaying is allowed over cellular connections. - * @param toggleProvideCell Callback to toggle cellular relaying. - * @param authCodeCreate Callback to generate a new device pairing auth code. - * @param authCode Current pairing auth code, if generated. - * @param isCreatingAuthCode True if an auth code is being generated. - * @param setDisplayAuthCodeDialog Callback to control auth code dialog display. - * @param provideIndicatorColor Status color for the relay mode indicator. - * @param provideIndicatorRingColor Optional ring accent color for the relay indicator. - * @param stripePortalUrl Customer billing portal URL, if available. - * @param authMethods List of active authentication methods linked to the account. - * @param onRemoveAuthMethod Callback to remove an authentication method. - * @param onAddAuthMethodClick Callback to present add authentication sheet. - * @param hasSeedphrase True if a seed phrase authentication method is configured. - * @param isGeneratingSeedphrase True if seed phrase generation is underway. - * @param isRegeneratingSeedphrase True if seed phrase regeneration is underway. - * @param onSeedphraseActionClick Callback for seed phrase actions (export/regenerate). - * @param mockLocationEnabled True if mock location synchronization is toggled on. - * @param mockLocationSetupComplete True if all Android OS mock location prerequisites are met. - * @param mockLocationStatus Current lifecycle state of mock location synchronization. - * @param mockLocationTarget Current exit provider coordinates being simulated, if active. - * @param onToggleMockLocation Callback invoked when user toggles mock location sync switch. - * @param onOpenMockLocationGuide Callback to navigate to the mock location setup guide. - */ +// stateless presentation half of the settings screen: every value and +// callback is hoisted into the stateful composable above, which is what makes +// the previews at the bottom of this file possible @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SettingsScreen( @@ -1058,8 +1008,15 @@ private fun SettingsScreen( ) } + // ORPHANED is not a togglable state: the test providers are + // stuck until URnetwork is re-selected in developer options + // and cleanup retries itself, so a switch that still reads ON + // would be lying. Every other state stays tappable — an + // incomplete setup reaches the guide through this switch. URSwitch( - checked = mockLocationEnabled, + checked = mockLocationEnabled && + mockLocationStatus != MockLocationStatus.ORPHANED, + enabled = mockLocationStatus != MockLocationStatus.ORPHANED, toggle = onToggleMockLocation, ) } @@ -1067,6 +1024,8 @@ private fun SettingsScreen( val statusSubtitle = when { mockLocationStatus == MockLocationStatus.ORPHANED -> stringResource(id = R.string.mock_location_status_stuck) + mockLocationStatus == MockLocationStatus.ERROR_TRANSIENT -> + stringResource(id = R.string.mock_location_status_retrying) mockLocationEnabled && !mockLocationSetupComplete -> stringResource(id = R.string.mock_location_needs_setup) mockLocationStatus == MockLocationStatus.ACTIVE && mockLocationTarget != null -> @@ -1077,11 +1036,15 @@ private fun SettingsScreen( } if (statusSubtitle != null) { + // the two failure states are the only red ones; the rest of + // the chain is progress, not a problem + val statusIsError = mockLocationStatus == MockLocationStatus.ORPHANED || + mockLocationStatus == MockLocationStatus.ERROR_TRANSIENT Spacer(modifier = Modifier.height(2.dp)) Text( statusSubtitle, style = MaterialTheme.typography.bodySmall, - color = if (mockLocationStatus == MockLocationStatus.ORPHANED) + color = if (statusIsError) MaterialTheme.colorScheme.error else TextMuted @@ -1146,6 +1109,8 @@ private fun SettingsScreen( Spacer(modifier = Modifier.height(32.dp)) + // product updates is an account email preference — it flips a flag + // on the network account and collects nothing from the device URTextInputLabel(text = stringResource(id = R.string.stay_in_touch)) Row( diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml index e4f548f56..10fb98b35 100644 --- a/app/app/src/main/res/values/strings.xml +++ b/app/app/src/main/res/values/strings.xml @@ -434,26 +434,44 @@ Longest streak Manage subscription Member + Syncing with %1$s Apps can tell the location is simulated. Banking, ride-hailing, delivery and some game apps may refuse to work while this is on. This changes the location reported to every app on your device, not just URnetwork. Turn this off in URnetwork before you turn off developer options, deselect URnetwork, or uninstall the app — otherwise your device location can stay frozen until you restart. + URnetwork could not remove the simulated location. Re-select URnetwork under developer options and turn this off, or restart your device. - GPS frozen — action needed - Simulated location is stuck URnetwork was deselected while active, so Android locked the simulated GPS. Re-select URnetwork in Developer options to restore your real GPS, or restart your phone. + Simulated location is stuck + Grant permission When enabled, apps on this device see the location of the provider you have been connected to the longest, instead of your real location. Sync device location Setup required Open About phone + Open app settings Open developer options Open location settings Ready. Turn on the toggle to sync your device location with the oldest provider. Device location sync + Retrying — simulated location paused + GPS frozen — action needed Turn on developer options: open About phone and tap Build number seven times. + Optional: allow approximate location so Chrome and other Google Play apps follow the simulated location too. Everything else works without it. Turn on Location in system settings so apps can receive the location. - Allow approximate location access so Google Play Services can sync simulated location with Chrome and apps. - Grant permission In developer options, tap Select mock location app and choose URnetwork. Waiting for a provider location Multiple IPs diff --git a/app/app/src/solana_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt b/app/app/src/solana_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt index 2b3813e1f..bec9929df 100644 --- a/app/app/src/solana_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt +++ b/app/app/src/solana_dapp/java/com/bringyour/network/location/FusedMockLocationSupport.kt @@ -2,6 +2,7 @@ package com.bringyour.network.location import android.content.Context import android.location.Location +import android.os.SystemClock import android.util.Log import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability @@ -9,13 +10,15 @@ import com.google.android.gms.location.LocationServices private const val TAG = "FusedMockLocation" -/** - * Checks whether Google Play Services is available on the device to support mocking - * the FusedLocationProviderClient directly (MOCKLOCATION.md §3.2). - * - * @param context Application or component context used to query Google Play Services availability. - * @return True if Google Play Services is installed, enabled, and operational. - */ +// GMS layer of the mock location engine (MOCKLOCATION.md §3.2): mirror the +// mock fix into the Google Play services fused location provider so FLP-based +// consumers (Chrome, Google Maps et al.) reliably follow it. Beyond the +// developer-options selection the platform path already needs, this leg also +// needs the ACCESS_COARSE_LOCATION runtime grant — the controller gates the +// mirror on it. The platform test providers need no runtime permission (§8) +// and carry the feature on their own, so every failure here is logged and +// swallowed rather than surfaced. + fun supportsFusedMockLocation(context: Context): Boolean { return try { GoogleApiAvailability.getInstance() @@ -25,15 +28,10 @@ fun supportsFusedMockLocation(context: Context): Boolean { } } -/** - * Sets whether the Google Play Services Fused Location Provider is in mock mode. - * - * Entering mock mode clears FLP caches and ensures FLP clients only receive mock locations - * pushed through [setFusedMockLocation]. Exiting mock mode restores standard provider fusion. - * - * @param context Application context used to obtain the FusedLocationProviderClient. - * @param enabled True to engage mock mode; false to disengage and restore normal location. - */ +// setMockMode is device-global (affects all FLP clients in every process) — +// callers must always exit mock mode on every teardown path. Both Task +// outcomes are worth a line: this runs twice per arm/disarm cycle, not per +// tick, and a silently failed exit is what leaves other processes mocked. fun setFusedMockMode(context: Context, enabled: Boolean) { if (!supportsFusedMockLocation(context)) { return @@ -53,13 +51,32 @@ fun setFusedMockMode(context: Context, enabled: Boolean) { } } -/** - * Pushes a mock fix to the Google Play Services Fused Location Provider so FLP-based consumers - * (such as Google Chrome and Google Maps) receive the synced location. - * - * @param context Application context used to obtain the FusedLocationProviderClient. - * @param location The complete [Location] fix containing monotonic timestamps and coordinates. - */ +// setMockLocation runs at 1 Hz for as long as the tunnel is up, so an +// unthrottled failure listener writes the same line every second, forever. +// Dedup like MainApplication's contract status log: the first failure speaks, +// an identical one stays quiet until the backoff expires. +private const val FAILURE_LOG_INTERVAL_MILLIS = 60_000L +private var lastMockLocationFailureMessage: String? = null +private var lastMockLocationFailureLogMillis = 0L + +// only the failure listener touches this state, and GMS delivers Task +// callbacks on the main looper, so it stays single-threaded and needs no +// locking; the catch blocks below run on the caller's thread and are +// deliberately left unthrottled +private fun shouldLogMockLocationFailure(message: String): Boolean { + val now = SystemClock.elapsedRealtime() + if (message == lastMockLocationFailureMessage && + now - lastMockLocationFailureLogMillis < FAILURE_LOG_INTERVAL_MILLIS + ) { + return false + } + lastMockLocationFailureMessage = message + lastMockLocationFailureLogMillis = now + return true +} + +// the mirror leg of the 1 Hz poster; the fix has to carry monotonically +// increasing timestamps (§3.2), which the caller builds fun setFusedMockLocation(context: Context, location: Location) { if (!supportsFusedMockLocation(context)) { return @@ -67,7 +84,10 @@ fun setFusedMockLocation(context: Context, location: Location) { try { LocationServices.getFusedLocationProviderClient(context).setMockLocation(location) .addOnFailureListener { e -> - Log.w(TAG, "GMS fused location provider setMockLocation failed: ${e.message}") + val message = e.message ?: e.toString() + if (shouldLogMockLocationFailure(message)) { + Log.w(TAG, "GMS fused location provider setMockLocation failed: $message") + } } } catch (e: SecurityException) { Log.w(TAG, "GMS setMockLocation security exception: ${e.message}") diff --git a/app/app/src/test/java/com/bringyour/network/location/MockLocationGracePolicyTest.kt b/app/app/src/test/java/com/bringyour/network/location/MockLocationGracePolicyTest.kt new file mode 100644 index 000000000..edaddc0af --- /dev/null +++ b/app/app/src/test/java/com/bringyour/network/location/MockLocationGracePolicyTest.kt @@ -0,0 +1,180 @@ +package com.bringyour.network.location + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MockLocationGracePolicyTest { + + private val tokyo = MockLocationTarget( + clientId = "client-1", + label = "Tokyo, Japan", + lat = 35.6762, + lon = 139.6503, + ) + + private val osaka = MockLocationTarget( + clientId = "client-2", + label = "Osaka, Japan", + lat = 34.6937, + lon = 135.5023, + ) + + @Test + fun aResolvedTargetIsPushedAndBecomesTheProtectedFix() { + val policy = MockLocationGracePolicy() + val decision = policy.onTargetResolved(tokyo) + + assertEquals(MockTargetAction.PUSH, decision.action) + assertEquals(tokyo, decision.target) + assertEquals(tokyo, policy.lastKnownTarget) + } + + @Test + fun anEmptyProviderListWithNothingHeldClearsImmediately() { + val policy = MockLocationGracePolicy() + val decision = policy.onTargetResolved(null) + + // no fix has ever landed, so there is nothing a window could protect + assertEquals(MockTargetAction.CLEAR, decision.action) + assertNull(policy.lastKnownTarget) + } + + @Test + fun aProviderDipHoldsTheLastKnownTargetForTheFullWindow() { + val policy = MockLocationGracePolicy() + policy.onTargetResolved(tokyo) + val hold = policy.onTargetResolved(null) + + assertEquals(MockTargetAction.HOLD, hold.action) + assertEquals(tokyo, hold.target) + assertEquals(TARGET_GRACE_PERIOD_MILLIS, hold.delayMillis) + // the controller keeps re-posting the held fix until the window expires + assertEquals(tokyo, policy.lastKnownTarget) + } + + @Test + fun repeatedDipsLetTheRunningWindowOwnTheClear() { + val policy = MockLocationGracePolicy() + policy.onTargetResolved(tokyo) + val hold = policy.onTargetResolved(null) + val again = policy.onTargetResolved(null) + + // restarting the window on every flap would extend it without bound + assertEquals(MockTargetAction.ALREADY_HOLDING, again.action) + assertEquals(0L, again.delayMillis) + assertEquals(tokyo, policy.lastKnownTarget) + // and the expiry queued by the first dip is still the live one + assertTrue(policy.graceExpired(hold.generation)) + assertNull(policy.lastKnownTarget) + } + + @Test + fun anExpiryClearsExactlyOnce() { + val policy = MockLocationGracePolicy() + policy.onTargetResolved(tokyo) + val hold = policy.onTargetResolved(null) + + assertTrue(policy.graceExpired(hold.generation)) + // a duplicate callback must not clear a target held by a later window + assertFalse(policy.graceExpired(hold.generation)) + } + + @Test + fun aFreshTargetDuringTheWindowInvalidatesTheQueuedExpiry() { + val policy = MockLocationGracePolicy() + policy.onTargetResolved(tokyo) + val hold = policy.onTargetResolved(null) + val repush = policy.onTargetResolved(osaka) + + assertEquals(MockTargetAction.PUSH, repush.action) + // the expiry is already on the looper when the new fix lands; without + // the generation guard it would clear a target that is live again + assertFalse(policy.graceExpired(hold.generation)) + assertEquals(osaka, policy.lastKnownTarget) + } + + @Test + fun aDipAfterARepushOpensAFreshWindowAndTheOlderGenerationIsDead() { + val policy = MockLocationGracePolicy() + policy.onTargetResolved(tokyo) + val first = policy.onTargetResolved(null) + policy.onTargetResolved(osaka) + val second = policy.onTargetResolved(null) + + assertEquals(MockTargetAction.HOLD, second.action) + assertEquals(osaka, second.target) + assertNotEquals(first.generation, second.generation) + assertFalse(policy.graceExpired(first.generation)) + assertTrue(policy.graceExpired(second.generation)) + } + + @Test + fun cancelDropsTheHeldTargetAndKillsTheQueuedExpiry() { + val policy = MockLocationGracePolicy() + policy.onTargetResolved(tokyo) + val hold = policy.onTargetResolved(null) + policy.cancel() + + // tunnel down, device swap or shutdown ends the connection outright; + // the window exists for provider flaps, not for that + assertNull(policy.lastKnownTarget) + assertFalse(policy.graceExpired(hold.generation)) + } + + @Test + fun aDeviceSwapResetsBackToTheNothingHeldState() { + val policy = MockLocationGracePolicy() + policy.onTargetResolved(tokyo) + policy.cancel() + + // the swapped-in device has no fix to protect yet, so an empty provider + // list clears rather than reopening a window over the old device's fix + assertEquals(MockTargetAction.CLEAR, policy.onTargetResolved(null).action) + + val fresh = policy.onTargetResolved(osaka) + assertEquals(MockTargetAction.PUSH, fresh.action) + assertEquals(osaka, policy.lastKnownTarget) + } + + @Test + fun anExpiredWindowIsNotReopenedByTheNextEmptyList() { + val policy = MockLocationGracePolicy() + policy.onTargetResolved(tokyo) + val hold = policy.onTargetResolved(null) + policy.graceExpired(hold.generation) + + assertEquals(MockTargetAction.CLEAR, policy.onTargetResolved(null).action) + } + + @Test + fun anExpiryCannotFireBeforeItsWindowOpens() { + val policy = MockLocationGracePolicy() + policy.onTargetResolved(tokyo) + + // nothing is holding, so no generation may clear the live fix + assertFalse(policy.graceExpired(0L)) + assertFalse(policy.graceExpired(1L)) + assertEquals(tokyo, policy.lastKnownTarget) + } + + @Test + fun theWindowLengthComesFromTheConstructor() { + val policy = MockLocationGracePolicy(gracePeriodMillis = 250L) + policy.onTargetResolved(tokyo) + + assertEquals(250L, policy.onTargetResolved(null).delayMillis) + } + + @Test + fun theWindowStaysUnderTheStaleFixCeiling() { + // §6.1: getCurrentLocation discards fixes older than 30 s, so a window + // that reached it would strand a consumer on a held target the + // controller is still re-posting at 1 Hz + assertTrue(TARGET_GRACE_PERIOD_MILLIS > 0L) + assertTrue(TARGET_GRACE_PERIOD_MILLIS < 30_000L) + } +} diff --git a/app/app/src/test/java/com/bringyour/network/location/MockLocationStateTest.kt b/app/app/src/test/java/com/bringyour/network/location/MockLocationStateTest.kt index 5b35aeb21..e693446f3 100644 --- a/app/app/src/test/java/com/bringyour/network/location/MockLocationStateTest.kt +++ b/app/app/src/test/java/com/bringyour/network/location/MockLocationStateTest.kt @@ -2,6 +2,7 @@ package com.bringyour.network.location import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -22,8 +23,6 @@ class MockLocationStateTest { tunnelUp: Boolean = true, target: MockLocationTarget? = tokyo, orphaned: Boolean = false, - requiresLocationPermission: Boolean = false, - locationPermissionGranted: Boolean = false, ): MockLocationStatus { return resolveMockLocationStatus( enabled = enabled, @@ -33,11 +32,13 @@ class MockLocationStateTest { tunnelUp = tunnelUp, target = target, orphaned = orphaned, - requiresLocationPermission = requiresLocationPermission, - locationPermissionGranted = locationPermissionGranted, ) } + // Every rung is pinned twice below: once against the signals under it (so a + // rung cannot sink) and once against the signals over it (so it cannot be + // hoisted). Asserting only that a gate fires proves neither. + @Test fun disabledWinsOverEverySignalWhenNotOrphaned() { assertEquals(MockLocationStatus.DISABLED, resolve(enabled = false)) @@ -82,25 +83,55 @@ class MockLocationStateTest { @Test fun devOptionsGateComesFirst() { + // alone + assertEquals(MockLocationStatus.NEEDS_DEV_OPTIONS, resolve(devOptionsEnabled = false)) + // over every signal below it assertEquals( MockLocationStatus.NEEDS_DEV_OPTIONS, resolve( devOptionsEnabled = false, isSelectedMockApp = false, locationServicesEnabled = false, + tunnelUp = false, + target = null, ), ) + // and under the two that outrank it + assertEquals( + MockLocationStatus.DISABLED, + resolve(enabled = false, devOptionsEnabled = false), + ) + assertEquals( + MockLocationStatus.ORPHANED, + resolve(orphaned = true, devOptionsEnabled = false), + ) } @Test fun selectionGateComesSecond() { + assertEquals(MockLocationStatus.NEEDS_SELECTION, resolve(isSelectedMockApp = false)) assertEquals( MockLocationStatus.NEEDS_SELECTION, resolve( isSelectedMockApp = false, locationServicesEnabled = false, + tunnelUp = false, + target = null, ), ) + // dev options is the earlier signal and must win + assertEquals( + MockLocationStatus.NEEDS_DEV_OPTIONS, + resolve(devOptionsEnabled = false, isSelectedMockApp = false), + ) + assertEquals( + MockLocationStatus.DISABLED, + resolve(enabled = false, isSelectedMockApp = false), + ) + assertEquals( + MockLocationStatus.ORPHANED, + resolve(orphaned = true, isSelectedMockApp = false), + ) } @Test @@ -109,32 +140,66 @@ class MockLocationStateTest { MockLocationStatus.NEEDS_LOCATION_ON, resolve(locationServicesEnabled = false), ) - } - - @Test - fun locationPermissionGateComesFourthWhenRequired() { + // only the tunnel/target decision sits below it assertEquals( - MockLocationStatus.NEEDS_LOCATION_PERMISSION, - resolve( - requiresLocationPermission = true, - locationPermissionGranted = false, - ), + MockLocationStatus.NEEDS_LOCATION_ON, + resolve(locationServicesEnabled = false, tunnelUp = false, target = null), ) - // when permission is granted, gate passes + // every earlier signal outranks it assertEquals( - MockLocationStatus.ACTIVE, - resolve( - requiresLocationPermission = true, - locationPermissionGranted = true, - ), + MockLocationStatus.NEEDS_SELECTION, + resolve(isSelectedMockApp = false, locationServicesEnabled = false), + ) + assertEquals( + MockLocationStatus.NEEDS_DEV_OPTIONS, + resolve(devOptionsEnabled = false, locationServicesEnabled = false), ) - // when permission is not required, gate is bypassed assertEquals( - MockLocationStatus.ACTIVE, + MockLocationStatus.DISABLED, + resolve(enabled = false, locationServicesEnabled = false), + ) + assertEquals( + MockLocationStatus.ORPHANED, + resolve(orphaned = true, locationServicesEnabled = false), + ) + } + + // all 2^6 signal combinations against both target shapes, addressed as a + // bitmask so a rung added back anywhere in the ladder is caught whatever it + // keys on. The COARSE grant is not among the inputs at all: it gates the + // optional FLP mirror in the controller (§3.2), never the ladder, and a + // gate here would strand the permission-free AOSP providers on every GMS + // device without the grant. + private fun everyResolvedStatus(): List = + (0 until 128).map { bits -> resolve( - requiresLocationPermission = false, - locationPermissionGranted = false, + enabled = (bits and 1) != 0, + devOptionsEnabled = (bits and 2) != 0, + isSelectedMockApp = (bits and 4) != 0, + locationServicesEnabled = (bits and 8) != 0, + tunnelUp = (bits and 16) != 0, + target = if ((bits and 32) != 0) tokyo else null, + orphaned = (bits and 64) != 0, + ) + } + + @Test + fun theLadderReachesEveryStatusExceptTheTwoAdvisoryOnes() { + // NEEDS_LOCATION_PERMISSION and ERROR_TRANSIENT are documented as never + // returned here: the first is advisory (the UI reads the two permission + // signals off the state), the second is overlaid by the controller + // while a retry is pending + assertEquals( + setOf( + MockLocationStatus.DISABLED, + MockLocationStatus.NEEDS_DEV_OPTIONS, + MockLocationStatus.NEEDS_SELECTION, + MockLocationStatus.NEEDS_LOCATION_ON, + MockLocationStatus.ELIGIBLE, + MockLocationStatus.ACTIVE, + MockLocationStatus.ORPHANED, ), + everyResolvedStatus().toSet(), ) } @@ -194,11 +259,69 @@ class MockLocationStateTest { assertFalse(state(devOptionsEnabled = false).setupComplete) assertFalse(state(mockAppSelected = false).setupComplete) assertFalse(state(locationServicesEnabled = false).setupComplete) - assertFalse( + } + + // the COARSE grant buys the optional FLP mirror only (§3.2); setup is + // complete without it or the whole feature dies on GMS devices + @Test + fun setupIsCompleteWithoutTheOptionalLocationPermission() { + assertTrue( state( requiresLocationPermission = true, locationPermissionGranted = false, - ).setupComplete + ).setupComplete, + ) + } + + @Test + fun setupCompleteIgnoresBothPermissionSignalsInEveryCombination() { + for (requires in listOf(false, true)) { + for (granted in listOf(false, true)) { + // neither signal can complete setup on its own... + assertFalse( + state( + devOptionsEnabled = false, + requiresLocationPermission = requires, + locationPermissionGranted = granted, + ).setupComplete, + ) + // ...nor withhold it from a device that is otherwise set up + assertTrue( + state( + requiresLocationPermission = requires, + locationPermissionGranted = granted, + ).setupComplete, + ) + } + } + } + + // the guide's optional-permission step is the only thing that reads these + // two, and it reads them off the state rather than off `status`. If the + // controller ever stops publishing them the step disappears silently and + // the FLP mirror goes with it. + @Test + fun bothPermissionSignalsSurviveOnTheState() { + val needsGrant = state(requiresLocationPermission = true, locationPermissionGranted = false) + assertTrue(needsGrant.requiresLocationPermission) + assertFalse(needsGrant.locationPermissionGranted) + } + + // the controller's first published state passes only status/enabled/target + // and leans on these defaults for every signal + @Test + fun defaultedSignalsReadAsNotYetSetUp() { + val initial = MockLocationState( + status = MockLocationStatus.DISABLED, + enabled = false, + target = null, ) + assertFalse(initial.devOptionsEnabled) + assertFalse(initial.mockAppSelected) + assertFalse(initial.locationServicesEnabled) + assertFalse(initial.locationPermissionGranted) + assertFalse(initial.requiresLocationPermission) + assertFalse(initial.setupComplete) + assertNull(initial.target) } } diff --git a/app/app/src/ungoogle/java/com/bringyour/network/location/FusedMockLocationSupport.kt b/app/app/src/ungoogle/java/com/bringyour/network/location/FusedMockLocationSupport.kt index ecabba79d..0be567f52 100644 --- a/app/app/src/ungoogle/java/com/bringyour/network/location/FusedMockLocationSupport.kt +++ b/app/app/src/ungoogle/java/com/bringyour/network/location/FusedMockLocationSupport.kt @@ -6,27 +6,8 @@ import android.location.Location // github flavor: platform-only mock location (no Google Play services // dependency). The LocationManager test providers cover gps/network/fused. -/** - * Indicates whether Google Play Services fused location mocking is supported. - * Always returns false in the ungoogle / github flavor. - * - * @param context Application or component context. - * @return Always false for ungoogle builds. - */ fun supportsFusedMockLocation(context: Context): Boolean = false -/** - * No-op stub for setting GMS fused mock mode in ungoogle builds. - * - * @param context Application context. - * @param enabled Whether to enable or disable mock mode. - */ fun setFusedMockMode(context: Context, enabled: Boolean) = Unit -/** - * No-op stub for pushing GMS fused mock location in ungoogle builds. - * - * @param context Application context. - * @param location The mock location to push. - */ fun setFusedMockLocation(context: Context, location: Location) = Unit