step 8: LocationSource and AospLocationSource
Aandroid/app/src/main/java/net/lexcom/opentracker/loc/AospLocationSource.kt
@@ -0,0 +1,136 @@
package net.lexcom.opentracker.loc
import android.Manifest
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.Looper
import android.util.Log
import androidx.annotation.RequiresPermission
/**
* The device implementation of [LocationSource], on plain
* `android.location.LocationManager`.
*
* No Play Services and no AndroidX location: this app must run on a de-Googled
* phone, and the fused provider is the one thing that would prevent it.
*
* The class is deliberately dumb. It hands every fix from every enabled
* provider to the callback and filters nothing. [SamplingPolicy] already
* decides what is accurate enough and what has moved far enough, and a second
* copy of that decision here would drift out of step with it.
*
* Not thread-safe. Every method must be called from the thread that owns
* [looper], which is also the thread the callback runs on.
*
* The caller guarantees `ACCESS_FINE_LOCATION` is granted. Step 9 owns the
* runtime permission gate.
*/
class AospLocationSource(context: Context, private val looper: Looper) : LocationSource {
private val manager = context.getSystemService(LocationManager::class.java)
private var onFix: ((Fix) -> Unit)? = null
private val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
// A reading with no usable coordinate is dropped rather than
// substituted. See toE7.
val latE7 = toE7(location.latitude, LAT_MAX_E7) ?: return
val lonE7 = toE7(location.longitude, LON_MAX_E7) ?: return
onFix?.invoke(
fixFrom(
tsMs = location.time,
latE7 = latE7,
lonE7 = lonE7,
accuracyM = if (location.hasAccuracy()) location.accuracy else null,
speedMps = if (location.hasSpeed()) location.speed else null,
altM = if (location.hasAltitude()) location.altitude.toFloat() else null,
bearingDeg = if (location.hasBearing()) location.bearing else null,
fromNetwork = location.provider == LocationManager.NETWORK_PROVIDER,
isMock = isMock(location),
),
)
}
// Spelled out because these three are only `default` methods from API
// 30. On API 29 the interface still declares them abstract, so a SAM
// lambda would crash the moment the system called one.
@Deprecated("Removed from the framework at API 29, still dispatched below it.")
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
override fun onProviderEnabled(provider: String) = Unit
override fun onProviderDisabled(provider: String) = Unit
}
@RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION)
override fun start(request: Request, onFix: (Fix) -> Unit) {
this.onFix = onFix
register(request)
}
/** `LocationManager` cannot re-tune a live registration, so the only way to
* change interval or distance is to drop it and ask again. */
@RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION)
override fun update(request: Request) {
manager?.removeUpdates(listener)
register(request)
}
override fun stop() {
manager?.removeUpdates(listener)
onFix = null
}
@RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION)
private fun register(request: Request) {
val lm = manager
if (lm == null) {
Log.e(TAG, "no LocationManager; no fixes will be reported")
return
}
// ponytail: GPS stays registered even in STATIONARY, which is the
// battery ceiling of this class. Step 13 unregisters it in STATIONARY
// and wakes on a motion sensor instead.
for (provider in PROVIDERS) {
if (!lm.isProviderEnabled(provider)) continue
try {
lm.requestLocationUpdates(
provider,
request.intervalMs,
request.minDistanceM,
listener,
looper,
)
} catch (e: SecurityException) {
// The permission was revoked while the service ran. Rethrowing
// would kill the service; swallowing would leave a tracker that
// looks alive and reports nothing. So: log loudly and leave the
// source stopped. Step 9 owns asking for the grant again.
Log.e(TAG, "location permission denied; source stopped", e)
stop()
return
}
}
}
/** `isMock` only exists from API 31, and `isFromMockProvider()` is
* deprecated from the same level. minSdk is 29, so both are needed. */
@Suppress("DEPRECATION")
private fun isMock(location: Location): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
location.isMock
} else {
location.isFromMockProvider
}
private companion object {
const val TAG = "OpenTracker"
/** Both, always. GPS is silent indoors and network is silent offline. */
val PROVIDERS = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
}
}
Aandroid/app/src/main/java/net/lexcom/opentracker/loc/LocationSource.kt
@@ -0,0 +1,93 @@
package net.lexcom.opentracker.loc
import kotlin.math.roundToLong
/**
* Where fixes come from, and how one becomes a [Fix].
*
* The interface exists so the service can be driven by a fake on the JVM. The
* device implementation is `AospLocationSource`, and it is the only file in the
* app that touches `android.location`.
*
* The mapping below takes primitives, never an `android.location.Location`.
* Unit tests run with `isReturnDefaultValues = true`, where every framework
* getter answers 0, false or null, so a test built on a real Location would
* assert nothing. Keeping the arithmetic on primitives is what makes it
* testable.
*/
/** Widest coordinate the wire accepts. Mirrors `LAT_MAX_E7` / `LON_MAX_E7` in
* `crates/otproto/src/point.rs`, which rejects a point outside them. */
const val LAT_MAX_E7 = 900_000_000
const val LON_MAX_E7 = 1_800_000_000
/**
* Accuracy assumed when the provider makes no accuracy claim, in metres.
*
* Deliberately above [ACCURACY_CEILING_M]. A fix that does not say how good it
* is has not earned trust, so the policy treats it as coarse and flags it
* `LOW_ACCURACY`. It is not lost: the staleness bypass still accepts it once
* nothing better has arrived for [STALENESS_TIMEOUT_MS]. Roughly the spread of
* a cell-tower estimate, which is what such a fix usually is.
*/
const val ACCURACY_UNKNOWN_M = 500f
interface LocationSource {
/** Begin delivering fixes. [onFix] is called on the source's callback thread. */
fun start(request: Request, onFix: (Fix) -> Unit)
/** Apply new sampling parameters, keeping the same callback. */
fun update(request: Request)
fun stop()
}
/**
* One provider reading turned into a [Fix].
*
* Every optional argument is null when the device did not measure it, which is
* what [net.lexcom.opentracker.wire.Point] encodes as the field's sentinel.
*/
fun fixFrom(
tsMs: Long,
latE7: Int,
lonE7: Int,
accuracyM: Float?,
speedMps: Float?,
altM: Float?,
bearingDeg: Float?,
fromNetwork: Boolean,
isMock: Boolean,
): Fix = Fix(
tsMs = tsMs,
latE7 = latE7,
lonE7 = lonE7,
accM = accuracyM ?: ACCURACY_UNKNOWN_M,
speedMps = speedMps,
altM = altM,
bearingDeg = bearingDeg,
fromNetwork = fromNetwork,
isMock = isMock,
)
/**
* Degrees to the wire's 1e-7 fixed point, clamped to [maxE7]. Null when the
* value is not a number.
*
* Callers pass [LAT_MAX_E7] or [LON_MAX_E7]. The bound is an argument because
* the two axes differ by a factor of two, and a latitude clamped at the
* longitude bound would let a broken provider push a point past the pole.
*
* Clamping, not wrapping: a coordinate out of range means the provider is
* wrong, and a wrap would turn that into a plausible position somewhere else.
*
* NaN gets no coordinate at all. A mock provider can inject one, and any
* substitute value is a position the phone was never at. Zero would be the
* worst of them, because it reads as a real place in the Atlantic. The caller
* drops the reading instead.
*/
fun toE7(deg: Double, maxE7: Int): Int? {
if (deg.isNaN()) return null
val scaled = (deg * 1e7).roundToLong()
return scaled.coerceIn(-maxE7.toLong(), maxE7.toLong()).toInt()
}
Aandroid/app/src/test/java/net/lexcom/opentracker/LocationSourceTest.kt
@@ -0,0 +1,151 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.loc.ACCURACY_CEILING_M
import net.lexcom.opentracker.loc.ACCURACY_UNKNOWN_M
import net.lexcom.opentracker.loc.LAT_MAX_E7
import net.lexcom.opentracker.loc.LON_MAX_E7
import net.lexcom.opentracker.loc.SamplingPolicy
import net.lexcom.opentracker.loc.fixFrom
import net.lexcom.opentracker.loc.toE7
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.PointFlags
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The half of the location source that has no Android in it.
*
* Unit tests run with `isReturnDefaultValues = true`, so an
* `android.location.Location` answers 0 and null to everything and proves
* nothing. These tests defend the part that survives that: the field mapping,
* the fixed-point conversion, and the agreement between an unknown accuracy and
* what the policy then does with it.
*/
class LocationSourceTest {
private val tsMs = 1_785_000_042_000L
@Test
fun `a complete reading maps every field through unchanged`() {
val fix = fixFrom(
tsMs = tsMs,
latE7 = 525_200_080,
lonE7 = 134_050_000,
accuracyM = 7.5f,
speedMps = 12.25f,
altM = 41f,
bearingDeg = 183.5f,
fromNetwork = false,
isMock = false,
)
assertEquals(tsMs, fix.tsMs)
assertEquals(525_200_080, fix.latE7)
assertEquals(134_050_000, fix.lonE7)
assertEquals(7.5f, fix.accM)
assertEquals(12.25f, fix.speedMps)
assertEquals(41f, fix.altM)
assertEquals(183.5f, fix.bearingDeg)
}
@Test
fun `an unmeasured accuracy is coarse enough for the policy to flag it`() {
// A provider that makes no accuracy claim has not earned trust. The
// constant only works if it lands above the policy's ceiling, and this
// is the test that ties the two halves together. A cold start is stale,
// so the fix is still kept rather than dropped.
assertTrue(ACCURACY_UNKNOWN_M > ACCURACY_CEILING_M)
val fix = fixFrom(
tsMs = tsMs,
latE7 = 525_200_080,
lonE7 = 134_050_000,
accuracyM = null,
speedMps = null,
altM = null,
bearingDeg = null,
fromNetwork = true,
isMock = false,
)
assertEquals(ACCURACY_UNKNOWN_M, fix.accM)
val kept = SamplingPolicy().offer(fix, tsMs).keep
assertNotNull(kept)
assertTrue(kept.flags and PointFlags.LOW_ACCURACY != 0)
}
@Test
fun `toE7 round-trips a normal coordinate`() {
assertEquals(525_200_080, toE7(52.5200080, LAT_MAX_E7))
assertEquals(-134_050_000, toE7(-13.4050000, LON_MAX_E7))
}
@Test
fun `toE7 clamps past the pole and past the antimeridian`() {
// 95 degrees of latitude is 950_000_000, which still fits an Int but is
// off the planet, and the server rejects the whole point for it.
assertEquals(LAT_MAX_E7, toE7(95.0, LAT_MAX_E7))
assertEquals(-LAT_MAX_E7, toE7(-95.0, LAT_MAX_E7))
// 200 degrees of longitude is 2_000_000_000, and doubling it overflows
// Int, so the clamp has to happen in Long.
assertEquals(LON_MAX_E7, toE7(200.0, LON_MAX_E7))
assertEquals(-LON_MAX_E7, toE7(-400.0, LON_MAX_E7))
}
@Test
fun `toE7 refuses NaN rather than reporting a place in the Atlantic`() {
// A mock provider can inject NaN. Any substitute is a position the
// phone was never at, and zero is the worst one because it looks real.
assertNull(toE7(Double.NaN, LAT_MAX_E7))
assertNull(toE7(Double.NaN, LON_MAX_E7))
}
@Test
fun `unmeasured speed altitude and bearing stay null so the wire sends sentinels`() {
val fix = fixFrom(
tsMs = tsMs,
latE7 = 525_200_080,
lonE7 = 134_050_000,
accuracyM = 20f,
speedMps = null,
altM = null,
bearingDeg = null,
fromNetwork = false,
isMock = false,
)
assertNull(fix.speedMps)
assertNull(fix.altM)
assertNull(fix.bearingDeg)
val kept = SamplingPolicy().offer(fix, tsMs).keep
assertNotNull(kept)
val decoded = Point.fromBytes(kept.toBytes())
assertNull(decoded.spdCms)
assertNull(decoded.altM)
assertNull(decoded.brgCdeg)
}
@Test
fun `a network fix and a mock fix reach the wire flags`() {
val fix = fixFrom(
tsMs = tsMs,
latE7 = 525_200_080,
lonE7 = 134_050_000,
accuracyM = 30f,
speedMps = null,
altM = null,
bearingDeg = null,
fromNetwork = true,
isMock = true,
)
assertTrue(fix.fromNetwork)
assertTrue(fix.isMock)
val kept = SamplingPolicy().offer(fix, tsMs).keep
assertNotNull(kept)
assertTrue(kept.flags and PointFlags.NETWORK_FIX != 0)
assertTrue(kept.flags and PointFlags.MOCK != 0)
}
}