Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 270 additions & 19 deletions MOCKLOCATION.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@ 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
import com.google.android.gms.location.LocationServices

private const val TAG = "FusedMockLocation"

// 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.
// 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 {
Expand All @@ -23,29 +29,69 @@ 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.
// 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
}
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}")
}
}

// 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
}
try {
LocationServices.getFusedLocationProviderClient(context).setMockLocation(location)
.addOnFailureListener { e ->
val message = e.message ?: e.toString()
if (shouldLogMockLocationFailure(message)) {
Log.w(TAG, "GMS fused location provider setMockLocation failed: $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}")
}
}
12 changes: 12 additions & 0 deletions app/app/src/github/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!-- src/main declares COARSE for the optional GMS fused mock mirror. This
flavor compiles src/ungoogle, where supportsFusedMockLocation() is a
hardcoded false and no play-services-location dependency is added, so
the F-Droid build can never use the grant and must not ask for it. -->
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
tools:node="remove" />
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@ 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
import com.google.android.gms.location.LocationServices

private const val TAG = "FusedMockLocation"

// 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.
// 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 {
Expand All @@ -23,29 +29,69 @@ 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.
// 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
}
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}")
}
}

// 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
}
try {
LocationServices.getFusedLocationProviderClient(context).setMockLocation(location)
.addOnFailureListener { e ->
val message = e.message ?: e.toString()
if (shouldLogMockLocationFailure(message)) {
Log.w(TAG, "GMS fused location provider setMockLocation failed: $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}")
}
}
16 changes: 15 additions & 1 deletion app/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,24 @@

<!-- prevent these from ever being added by a dependency -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" tools:node="remove"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" tools:node="remove"/>
<uses-feature android:name="android.hardware.location.gps"
android:required="false" tools:node="remove"/>

<!-- the one location permission we do declare, and only the optional Fused
Location Provider mirror uses it (MOCKLOCATION.md 3.2): the AOSP test
providers that carry the feature need no runtime grant, and nothing
here wants real fixes. This file merges into all four flavors, so
src/github removes it again - that build compiles src/ungoogle, whose
supportsFusedMockLocation() is a hardcoded false. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

<!-- ACCESS_COARSE_LOCATION implies android.hardware.location as *required*,
which Play would then use to filter out TVs and other devices with no
location hardware. The mirror is optional and the app works without it,
so declare the feature not required, as with the tv features above. -->
<uses-feature android:name="android.hardware.location"
android:required="false" />




Expand Down
36 changes: 33 additions & 3 deletions app/app/src/main/java/com/bringyour/network/MainApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -167,6 +168,9 @@ class MainApplication : Application() {
@Inject
lateinit var mockLocationController: MockLocationController

@Inject
lateinit var mockLocationFeeder: MockLocationFeeder

var vpnRequestStart: Boolean = false
private set

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1364,6 +1377,7 @@ class MainApplication : Application() {
tunnelChangeSub = null
contractStatusChangeSub?.close()
contractStatusChangeSub = null
lastLoggedContractStatus = null

// provideEnabled = false
// connectEnabled = false
Expand Down Expand Up @@ -1483,13 +1497,17 @@ class MainApplication : Application() {
addThermalStatusListener()

updateTunnelStarted()
lastLoggedContractStatus = null
updateContractStatus()
service?.get()?.onDeviceAvailable()
updateVpnService()

return true
}

/**
* Logs transitions in device tunnel state.
*/
private fun updateTunnelStarted() {
device?.tunnelStarted?.let { tunnelStarted ->
Log.i(TAG, "[tunnel]started=$tunnelStarted")
Expand All @@ -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")
}
}
Expand Down
Loading
Loading