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:
parent
62f5dbb2d6
commit
5e02344699
4 changed files with 95 additions and 6 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue