fix(todo): turn to GPS where the fused provider stays silent (ADR 0033, D32)

A stock Android 17 emulator without Google's services left GPS off at
balanced accuracy and gave no fix in five minutes, while the
notification said it was watching. A Pixel's fused provider turns GPS
and the network on by itself, but nothing noticed where one doesn't.
After twice the wait asked for without a usable fix, the service asks
GPS as well, and leaves it once the fused provider gives a usable fix;
a rough one can't tell a circle, so it counts as silence.

All four walks (arrive, drive past, reboot, leave) passed on that
emulator on GPS alone, 22 fixes, no crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-05 21:43:17 +01:00
commit 5e02344699
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 95 additions and 6 deletions

View file

@ -58,6 +58,13 @@ object LocationDwell {
/** Whether the phone, [moved] metres since the last fix, lies still. */ /** Whether the phone, [moved] metres since the last fix, lies still. */
fun still(moved: Float): Boolean = moved < STILL_METRES fun still(moved: Float): Boolean = moved < STILL_METRES
/**
* How long without a usable fix, [asked] being the wait asked for, until
* the fused provider counts as silent and GPS is asked itself. Twice the
* wait, as one fix late is no silence.
*/
fun silentAfter(asked: Long): Long = asked * 2
/** /**
* How many fixes the phone has lain still, [before] this one, [moved] metres * How many fixes the phone has lain still, [before] this one, [moved] metres
* from the last and [waited] milliseconds after it, [asked] being the wait * from the last and [waited] milliseconds after it, [asked] being the wait

View file

@ -15,6 +15,7 @@ import android.location.LocationListener
import android.location.LocationManager import android.location.LocationManager
import android.location.LocationRequest import android.location.LocationRequest
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
@ -26,12 +27,29 @@ import kotlin.math.abs
* which misses a short stop altogether, and its other way of asking in the * which misses a short stop altogether, and its other way of asking in the
* background is held to the same; a foreground service isn't. Its notification * background is held to the same; a foreground service isn't. Its notification
* is the quietest there is, and the fixes come as seldom as a phone far from * is the quietest there is, and the fixes come as seldom as a phone far from
* every place, or lying still, can afford ([LocationDwell.interval]). * every place, or lying still, can afford ([LocationDwell.interval]). Where
* the fused provider stays silent (on stock Android without Google's services,
* an emulator's asked at balanced accuracy never turned GPS on), this asks GPS
* itself until the fused provider speaks again, as a phone showing it watches
* should watch.
*/ */
class LocationWatchService : Service(), LocationListener { class LocationWatchService : Service(), LocationListener {
private var requested: Long? = null private var requested: Long? = null
private var last: Location? = null private var last: Location? = null
private var stillFixes = 0 private var stillFixes = 0
private var askingFused = false
private var askingGps = false
private val handler = Handler(Looper.getMainLooper())
private val silent = Runnable { askGps() }
private val gps = object : LocationListener {
override fun onLocationChanged(fix: Location) {
take(fix)
}
override fun onProviderDisabled(provider: String) = Unit
override fun onProviderEnabled(provider: String) = Unit
}
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@ -93,23 +111,71 @@ class LocationWatchService : Service(), LocationListener {
mainExecutor, mainExecutor,
this, this,
) )
askingFused = true
} else { } else {
manager.requestLocationUpdates(LocationManager.GPS_PROVIDER, interval, 0f, this, Looper.getMainLooper()) manager.requestLocationUpdates(LocationManager.GPS_PROVIDER, interval, 0f, this, Looper.getMainLooper())
askingFused = false
} }
requested = interval requested = interval
LocationReminders.log(this, "a fix every ${interval / 1000} s") LocationReminders.log(this, "a fix every ${interval / 1000} s")
if (askingGps) requestGps(interval)
expectFix()
} catch (error: SecurityException) { } catch (error: SecurityException) {
LocationReminders.log(this, "no fixes: $error") LocationReminders.log(this, "no fixes: $error")
} }
} }
/** Waits for the next usable fix, and turns to GPS if none comes. */
private fun expectFix() {
handler.removeCallbacks(silent)
val interval = requested ?: return
if (askingFused) handler.postDelayed(silent, LocationDwell.silentAfter(interval))
}
private fun askGps() {
val interval = requested ?: return
if (askingGps || !askingFused) return
LocationReminders.log(this, "silence: asking GPS")
askingGps = requestGps(interval)
}
@SuppressLint("MissingPermission") // As in [ask].
private fun requestGps(interval: Long): Boolean {
val manager = getSystemService(LocationManager::class.java)
return try {
manager.removeUpdates(gps)
manager.requestLocationUpdates(LocationManager.GPS_PROVIDER, interval, 0f, gps, Looper.getMainLooper())
true
} catch (error: SecurityException) {
LocationReminders.log(this, "no GPS: $error")
false
} catch (error: IllegalArgumentException) {
// No GPS on this device.
LocationReminders.log(this, "no GPS: $error")
false
}
}
private fun stopGps() {
if (!askingGps) return
LocationReminders.log(this, "the fused provider speaks: leaving GPS")
getSystemService(LocationManager::class.java).removeUpdates(gps)
askingGps = false
}
override fun onLocationChanged(fix: Location) { override fun onLocationChanged(fix: Location) {
// A rough one doesn't count: it can't tell the circle, so GPS still can.
if (take(fix) && askingFused) stopGps()
}
/** Whether [fix] was new and usable, and so taken. */
private fun take(fix: Location): Boolean {
val before = last val before = last
// What Android hands over again as it's asked anew says nothing new. // What Android hands over again as it's asked anew says nothing new.
if (before != null && fix.elapsedRealtimeNanos <= before.elapsedRealtimeNanos) return if (before != null && fix.elapsedRealtimeNanos <= before.elapsedRealtimeNanos) return false
val accuracy = if (fix.hasAccuracy()) fix.accuracy else null val accuracy = if (fix.hasAccuracy()) fix.accuracy else null
LocationReminders.log(this, "fix, ${accuracy ?: "?"} m") LocationReminders.log(this, "fix, ${accuracy ?: "?"} m, ${fix.provider}")
if (!LocationDwell.usable(accuracy)) return if (!LocationDwell.usable(accuracy)) return false
stillFixes = if (before == null) { stillFixes = if (before == null) {
0 0
} else { } else {
@ -123,6 +189,8 @@ class LocationWatchService : Service(), LocationListener {
last = fix last = fix
LocationReminders.seen(this, fix.latitude, fix.longitude) LocationReminders.seen(this, fix.latitude, fix.longitude)
ask(next(fix)) ask(next(fix))
expectFix()
return true
} }
// Only the phone's location being switched off or on: fixes stop and go on. // Only the phone's location being switched off or on: fixes stop and go on.
@ -131,7 +199,10 @@ class LocationWatchService : Service(), LocationListener {
override fun onProviderEnabled(provider: String) = Unit override fun onProviderEnabled(provider: String) = Unit
override fun onDestroy() { override fun onDestroy() {
getSystemService(LocationManager::class.java).removeUpdates(this) handler.removeCallbacks(silent)
val manager = getSystemService(LocationManager::class.java)
manager.removeUpdates(gps)
manager.removeUpdates(this)
running = null running = null
super.onDestroy() super.onDestroy()
} }

View file

@ -102,6 +102,12 @@ class LocationDwellTest {
assertFalse(LocationDwell.still(moved = 50f)) assertFalse(LocationDwell.still(moved = 50f))
} }
@Test
fun `silence for twice the wait asked for turns to GPS`() {
assertEquals(60_000L, LocationDwell.silentAfter(asked = 30_000L))
assertEquals(600_000L, LocationDwell.silentAfter(asked = 300_000L))
}
@Test @Test
fun `a still fix after the wait asked for counts`() { fun `a still fix after the wait asked for counts`() {
assertEquals(3, LocationDwell.stillFixes(before = 2, moved = 10f, waited = 60_000L, asked = 60_000L)) assertEquals(3, LocationDwell.stillFixes(before = 2, moved = 10f, waited = 60_000L, asked = 60_000L))

View file

@ -650,7 +650,12 @@ The developer's review of 4 Nov 2026, again beside Todoist's screens.
phone lay still (moved under 50 m, and the wait asked for over, at least three phone lay still (moved under 50 m, and the wait asked for over, at least three
quarters of it, as asking anew brings a fix at once and an emulator's walk reached quarters of it, as asking anew brings a fix at once and an emulator's walk reached
2 min within 40 s), between 30 s and 5 min, at balanced accuracy on 2 min within 40 s), between 30 s and 5 min, at balanced accuracy on
the fused provider of Android's own, GPS before Android 12. What it costs a day the fused provider of Android's own, GPS before Android 12. Where that provider
gives no usable fix for twice the wait, the service asks GPS as well, until it
does: on a Pixel it turns GPS and the network on itself, but a stock Android 17
emulator without Google's services left GPS off and gave nothing, unnoticed,
behind a notification saying it watches. Always asking high accuracy was the
alternative, simpler, with GPS busier on every phone. What it costs a day
wants measuring. A fix rougher than 100 m says nothing of a circle. wants measuring. A fix rougher than 100 m says nothing of a circle.
- *Dwell*, so that driving past an EDEKA on the way to the next doesn't remind (the - *Dwell*, so that driving past an EDEKA on the way to the next doesn't remind (the
developer's question): crossing the circle (in at 150 m, out beyond 200 m, so a fix's error developer's question): crossing the circle (in at 150 m, out beyond 200 m, so a fix's error