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
3 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. */
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
* 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.LocationRequest
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
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
* 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
* 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 {
private var requested: Long? = null
private var last: Location? = null
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
@ -93,23 +111,71 @@ class LocationWatchService : Service(), LocationListener {
mainExecutor,
this,
)
askingFused = true
} else {
manager.requestLocationUpdates(LocationManager.GPS_PROVIDER, interval, 0f, this, Looper.getMainLooper())
askingFused = false
}
requested = interval
LocationReminders.log(this, "a fix every ${interval / 1000} s")
if (askingGps) requestGps(interval)
expectFix()
} catch (error: SecurityException) {
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) {
// 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
// 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
LocationReminders.log(this, "fix, ${accuracy ?: "?"} m")
if (!LocationDwell.usable(accuracy)) return
LocationReminders.log(this, "fix, ${accuracy ?: "?"} m, ${fix.provider}")
if (!LocationDwell.usable(accuracy)) return false
stillFixes = if (before == null) {
0
} else {
@ -123,6 +189,8 @@ class LocationWatchService : Service(), LocationListener {
last = fix
LocationReminders.seen(this, fix.latitude, fix.longitude)
ask(next(fix))
expectFix()
return true
}
// 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 onDestroy() {
getSystemService(LocationManager::class.java).removeUpdates(this)
handler.removeCallbacks(silent)
val manager = getSystemService(LocationManager::class.java)
manager.removeUpdates(gps)
manager.removeUpdates(this)
running = null
super.onDestroy()
}

View file

@ -102,6 +102,12 @@ class LocationDwellTest {
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
fun `a still fix after the wait asked for counts`() {
assertEquals(3, LocationDwell.stillFixes(before = 2, moved = 10f, waited = 60_000L, asked = 60_000L))