step 13: significant-motion detector, GNSS off while stationary
Mandroid/app/src/main/java/net/lexcom/opentracker/TrackerService.kt
@@ -17,6 +17,9 @@ import android.os.SystemClock
import android.util.Log
import net.lexcom.opentracker.loc.AospLocationSource
import net.lexcom.opentracker.loc.Fix
import net.lexcom.opentracker.loc.Motion
import net.lexcom.opentracker.loc.MotionDetector
import net.lexcom.opentracker.loc.Request
import net.lexcom.opentracker.loc.SamplingPolicy
import net.lexcom.opentracker.net.NetWatcher
import net.lexcom.opentracker.net.Round
@@ -58,6 +61,7 @@ class TrackerService : Service() {
private lateinit var transport: Transport
private lateinit var uplink: Uplink
private lateinit var source: AospLocationSource
private lateinit var detector: MotionDetector
private lateinit var wakeLock: PowerManager.WakeLock
private var watcher: NetWatcher? = null
@@ -125,6 +129,9 @@ class TrackerService : Service() {
// not thread-safe and a round may be in flight right now. quitSafely
// runs what is already queued, including this, and then stops.
handler.post {
// Disarmed on the tracker thread, which is the only thread that
// ever arms it. A trigger left registered outlives the service.
detector.disarm()
source.stop()
transport.close()
queue.close()
@@ -164,6 +171,7 @@ class TrackerService : Service() {
// long as the jump. A point's own timestamp comes from the fix.
uplink = Uplink(transport, queue, credentials, SystemClock::elapsedRealtime)
source = AospLocationSource(this, thread.looper)
detector = MotionDetector(this)
wakeLock = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)
@@ -179,7 +187,7 @@ class TrackerService : Service() {
// service that is already running until the first round reports in.
TrackerState.report(STATUS_STARTING, policy.motion, queue.size)
handler.post {
source.start(policy.request, ::onFix)
source.start(reconcileMotion(), ::onFix)
round()
}
@@ -207,7 +215,44 @@ class TrackerService : Service() {
TrackerState.reportFix(point)
scheduleIn(0)
}
if (decision.requestChanged) source.update(policy.request)
if (decision.requestChanged) source.update(reconcileMotion())
}
/**
* Match the motion trigger to the mode, and answer what the source should
* actually be asked for.
*
* The trigger only earns its keep in STATIONARY, where GNSS is off and
* nothing else can notice a departure. In every faster mode GNSS is already
* running and its fixes are better evidence, so the trigger is cancelled.
*/
private fun reconcileMotion(): Request {
if (policy.motion != Motion.STATIONARY) {
detector.disarm()
return policy.request
}
if (detector.arm(::onMotion)) return policy.request
// No significant-motion sensor on this device. Nothing would ever wake
// the phone, so it would sit in STATIONARY with GNSS off forever and
// never report that it moved. Keep GNSS registered instead: the old,
// more expensive behaviour is the only acceptable way to degrade. A
// tracker that costs battery is a complaint; one that silently stops
// reporting movement is a failure.
Log.i(TAG, "no significant-motion sensor; keeping GNSS on while stationary")
return policy.request.copy(useGnss = true)
}
/**
* The motion trigger fired, on a sensor thread.
*
* Posted across at once: [SamplingPolicy], [PointQueue] and [Uplink] are all
* single-threaded and owned by the tracker thread.
*/
@SuppressLint("MissingPermission") // Same grant as startTracking.
private fun onMotion() {
handler.post {
if (policy.wake(SystemClock.elapsedRealtime())) source.update(reconcileMotion())
}
}
/**
Mandroid/app/src/main/java/net/lexcom/opentracker/loc/AospLocationSource.kt
@@ -92,10 +92,13 @@ class AospLocationSource(context: Context, private val looper: Looper) : Locatio
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) {
// In STATIONARY the policy clears useGnss and only the network provider
// is registered. GNSS on a parked phone is the whole battery bill of
// this app, and it buys nothing: the position is not changing. Network
// fixes are cheap and keep the heartbeat producing points, so the phone
// still reports "here, still" rather than going silent. What gets the
// GPS back is MotionDetector, which fires SamplingPolicy.wake.
for (provider in providersFor(request)) {
if (!lm.isProviderEnabled(provider)) continue
try {
lm.requestLocationUpdates(
@@ -130,7 +133,12 @@ class AospLocationSource(context: Context, private val looper: Looper) : Locatio
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)
/** Both when GNSS is wanted: GPS is silent indoors and network is
* silent offline. Network alone otherwise. */
fun providersFor(request: Request): List<String> = if (request.useGnss) {
listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
} else {
listOf(LocationManager.NETWORK_PROVIDER)
}
}
}
Aandroid/app/src/main/java/net/lexcom/opentracker/loc/MotionDetector.kt
@@ -0,0 +1,73 @@
package net.lexcom.opentracker.loc
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import android.hardware.TriggerEvent
import android.hardware.TriggerEventListener
/**
* The hardware significant-motion trigger, wrapped.
*
* This is what makes dropping GNSS in STATIONARY safe. With no GPS registered
* nothing can notice a departure, so something else has to, and this sensor is
* the cheapest thing on the phone that can: it runs in the sensor hub, not on
* the CPU, and it fires when the device has plausibly changed location. Then
* [SamplingPolicy.wake] promotes the mode and GNSS comes back.
*
* The sensor is one-shot. The framework cancels the request as it delivers the
* event, so a handler that wants another trigger must call [arm] again. That is
* the single easiest thing to get wrong here, and getting it wrong means the
* phone wakes once and then never again.
*
* The callback arrives on a sensor thread, not the caller's. Everything this
* app keeps state in is single-threaded, so the handler has to post its work
* onto the thread that owns that state.
*
* No permission is required for this sensor.
*
* ponytail: one sensor and nothing else. The ceiling is what the vendor's
* implementation decides is significant. A phone that slides across a car seat
* can fire it, and a slow drift on a boat or a train may not fire it at all, in
* which case the departure is noticed by the next network fix instead of at
* once. Upgrade path if that shows up in real traces: add TYPE_STEP_DETECTOR as
* a second trigger, or sample the accelerometer on the heartbeat.
*/
class MotionDetector(context: Context) {
private val manager = context.getSystemService(SensorManager::class.java)
private val sensor = manager?.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION)
private var listener: TriggerEventListener? = null
/**
* Request one trigger. Returns false when this device has no such sensor,
* which the caller must handle: there is then nothing that can ever wake
* the phone.
*
* Arming twice is harmless. The old request is cancelled first, so only one
* is ever outstanding.
*/
fun arm(onMotion: () -> Unit): Boolean {
val sm = manager ?: return false
val s = sensor ?: return false
disarm()
// TriggerEventListener is an abstract class, not an interface, so it
// cannot be a SAM lambda.
val l = object : TriggerEventListener() {
override fun onTrigger(event: TriggerEvent?) {
// The request is already cancelled by now. Forget it here too,
// so a later disarm does not try to cancel a dead one.
listener = null
onMotion()
}
}
listener = l
return sm.requestTriggerSensor(l, s)
}
fun disarm() {
val l = listener ?: return
listener = null
manager?.cancelTriggerSensor(l, sensor)
}
}
Mandroid/app/src/main/java/net/lexcom/opentracker/loc/SamplingPolicy.kt
@@ -109,8 +109,17 @@ data class Fix(
val isMock: Boolean,
)
/** What the caller should ask the OS location API for. */
data class Request(val intervalMs: Long, val minDistanceM: Float)
/**
* What the caller should ask the OS location API for.
*
* [useGnss] is false in STATIONARY only. GNSS is the expensive part of this app
* and a parked phone gains nothing from it, so the source drops the GPS provider
* and a hardware motion trigger calls [SamplingPolicy.wake] when the phone is
* worth watching again. The network provider stays registered in every mode: it
* is cheap, and it is what keeps the heartbeat producing points while GNSS is
* off.
*/
data class Request(val intervalMs: Long, val minDistanceM: Float, val useGnss: Boolean)
/**
* The answer to one [SamplingPolicy.offer].
@@ -167,6 +176,34 @@ class SamplingPolicy {
return Decision(toPoint(fix, lowAccuracy), motion, changed)
}
/**
* The hardware motion trigger says the phone moved. Returns true when the
* request changed and the caller must re-register the location source.
*
* In STATIONARY there is no GNSS running, so no fix can report the
* departure and this is the only thing that can. It promotes to DWELL and
* not to WALK: the trigger says something moved, not what, and DWELL is the
* grace state this class already uses for exactly that uncertainty. A
* pocket, a passing lorry or a slammed door fires the same sensor.
*
* A false trigger therefore costs one [DWELL_TIMEOUT_MS] of GNSS and
* nothing else. The normal slow-down path sees the still fixes and drops
* back to STATIONARY on its own, so no separate timeout is needed here.
*
* In any faster mode this is a no-op. GNSS is already registered there and
* the fixes are better evidence than the sensor.
*/
fun wake(nowMs: Long): Boolean {
if (motion != Motion.STATIONARY) return false
val before = request
motion = Motion.DWELL
// The dwell timer has to start now. A stale slowerSince from an earlier
// slow-down would expire immediately and undo the promotion on the next
// fix.
slowerSince = nowMs
return request != before
}
/** Long.MAX_VALUE before the first keep, so a cold start reports at once. */
private fun elapsedSinceKeep(nowMs: Long): Long =
lastKeptMs?.let { nowMs - it } ?: Long.MAX_VALUE
@@ -235,11 +272,14 @@ class SamplingPolicy {
}
}
private fun requestFor(motion: Motion): Request = when (motion) {
Motion.VEHICLE -> Request(VEHICLE_INTERVAL_MS, VEHICLE_MIN_DISTANCE_M)
Motion.WALK -> Request(WALK_INTERVAL_MS, WALK_MIN_DISTANCE_M)
Motion.DWELL -> Request(DWELL_INTERVAL_MS, DWELL_MIN_DISTANCE_M)
Motion.STATIONARY -> Request(STATIONARY_INTERVAL_MS, STATIONARY_MIN_DISTANCE_M)
private fun requestFor(motion: Motion): Request {
val gnss = motion != Motion.STATIONARY
return when (motion) {
Motion.VEHICLE -> Request(VEHICLE_INTERVAL_MS, VEHICLE_MIN_DISTANCE_M, gnss)
Motion.WALK -> Request(WALK_INTERVAL_MS, WALK_MIN_DISTANCE_M, gnss)
Motion.DWELL -> Request(DWELL_INTERVAL_MS, DWELL_MIN_DISTANCE_M, gnss)
Motion.STATIONARY -> Request(STATIONARY_INTERVAL_MS, STATIONARY_MIN_DISTANCE_M, gnss)
}
}
/** Bearings arrive as 0..360 but a negative or a 360 must not become 36000,
Mandroid/app/src/test/java/net/lexcom/opentracker/SamplingPolicyTest.kt
@@ -9,6 +9,7 @@ import net.lexcom.opentracker.loc.SamplingPolicy
import net.lexcom.opentracker.wire.PointFlags
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -100,6 +101,69 @@ class SamplingPolicyTest {
assertEquals(Motion.WALK, policy.offer(fix(over, northM = 400.0, speedMps = 2f), over).motion)
}
@Test
fun `GNSS is asked for in every mode except STATIONARY`() {
// The whole point of step 13: a parked phone runs on the network
// provider alone, and every mode that implies real movement gets GPS.
val policy = SamplingPolicy()
assertEquals(Motion.STATIONARY, policy.motion)
assertFalse(policy.request.useGnss)
assertEquals(Motion.VEHICLE, policy.offer(fix(0, speedMps = 30f), 0).motion)
assertTrue(policy.request.useGnss)
// Slowing down takes two fixes: the first only starts the timer.
policy.offer(fix(1_000, speedMps = 2f), 1_000)
val walking = 1_000 + DWELL_TIMEOUT_MS
policy.offer(fix(walking, northM = 400.0, speedMps = 2f), walking)
assertEquals(Motion.WALK, policy.motion)
assertTrue(policy.request.useGnss)
val stopped = walking + 1_000
policy.offer(fix(stopped, speedMps = 0f), stopped)
assertEquals(Motion.DWELL, policy.motion)
assertTrue(policy.request.useGnss)
}
@Test
fun `a motion trigger promotes STATIONARY to DWELL and changes the request`() {
// With GNSS off there is no fix that could report the departure, so
// the sensor is the only thing that can. DWELL, not WALK: the trigger
// says something moved, not what.
val policy = SamplingPolicy()
assertTrue(policy.wake(1_000))
assertEquals(Motion.DWELL, policy.motion)
assertTrue(policy.request.useGnss)
}
@Test
fun `a motion trigger in a faster mode changes nothing`() {
val policy = SamplingPolicy()
policy.offer(fix(0, speedMps = 30f), 0)
assertEquals(Motion.VEHICLE, policy.motion)
val before = policy.request
assertFalse(policy.wake(1_000))
assertEquals(Motion.VEHICLE, policy.motion)
assertEquals(before, policy.request)
}
@Test
fun `a motion trigger with no real movement behind it falls back to STATIONARY`() {
// This is what makes a false trigger cheap. A pocket or a passing lorry
// buys one dwell period of GNSS and nothing more, so no separate
// timeout is needed for the wake path.
val policy = SamplingPolicy()
val woke = 1_000L
assertTrue(policy.wake(woke))
val nearly = woke + DWELL_TIMEOUT_MS - 1
assertEquals(Motion.DWELL, policy.offer(fix(nearly, speedMps = 0f), nearly).motion)
val over = woke + DWELL_TIMEOUT_MS
assertEquals(Motion.STATIONARY, policy.offer(fix(over, speedMps = 0f), over).motion)
assertFalse(policy.request.useGnss)
}
@Test
fun `a fix worse than the accuracy ceiling is discarded`() {
val policy = SamplingPolicy()