step 12: boot and watchdog receivers, doze wakeups, tracking-enabled flag
Mandroid/app/src/main/java/net/lexcom/opentracker/BootReceiver.kt
@@ -1,9 +1,11 @@
package net.lexcom.opentracker
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import net.lexcom.opentracker.store.Prefs
/**
* Restarts tracking after a reboot or an app update.
@@ -15,14 +17,78 @@ import android.util.Log
* `dataSync`, `camera`, `mediaPlayback`, `phoneCall`, `mediaProjection` and
* `microphone`, and not `location`.
*
* Implemented at step 12, together with the kill-survival matrix.
* The decision itself lives in [shouldRestartTracking] so it can be tested on
* the JVM. Everything in this class is framework wiring around it.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.i(TAG, "received ${intent.action}; nothing to restart yet")
// The manifest filter already narrows this, but a receiver is exported
// and anyone may send it an Intent with any action.
if (intent.action != Intent.ACTION_BOOT_COMPLETED &&
intent.action != Intent.ACTION_MY_PACKAGE_REPLACED
) {
return
}
val prefs = Prefs(context)
val enabled = prefs.isTrackingEnabled()
val hasCredentials = prefs.load() != null
val restart = shouldRestartTracking(
enabled = enabled,
hasCredentials = hasCredentials,
hasBackgroundLocation = PermissionGate.hasBackgroundLocation(context),
)
if (restart) {
Log.i(TAG, "resuming tracking after ${intent.action}")
Watchdog.arm(context)
TrackerService.start(context)
return
}
// Only worth a notification when the user actually wanted tracking on.
// Anything else is the normal state of an app nobody has logged into.
if (enabled && hasCredentials) {
Log.w(TAG, "not resuming tracking: background location is missing")
alert(context)
}
}
/** Same shape as `TrackerService.postAlert`: without the grant the framework
* drops `notify` anyway, so asking first saves a pointless call. */
private fun alert(context: Context) {
if (!PermissionGate.canPostNotifications(context)) return
Notifications.ensureChannels(context)
context.getSystemService(NotificationManager::class.java)
.notify(Notifications.ID_ALERT, Notifications.alert(context, ALERT_TITLE, ALERT_TEXT))
}
private companion object {
const val TAG = "OpenTracker"
const val ALERT_TITLE = "Tracking did not resume"
const val ALERT_TEXT =
"opentracker may only start sharing in the background with the " +
"\"Allow all the time\" location permission. Open opentracker " +
"and grant it, then start sharing again."
}
}
/**
* Whether a restart without the app on screen will actually produce positions.
*
* All three conditions must hold, and the third is the subtle one. A foreground
* service started while the app is not visible only receives fixes if
* `ACCESS_BACKGROUND_LOCATION` is granted. Starting without it yields a service
* that runs, holds a wake lock, drains the battery and reports nothing, which is
* worse for the user than not starting at all. See
* [PermissionGate.hasBackgroundLocation].
*
* Pure and `internal` so it can be tested on the JVM, where every framework
* getter returns a default.
*/
internal fun shouldRestartTracking(
enabled: Boolean,
hasCredentials: Boolean,
hasBackgroundLocation: Boolean,
): Boolean = enabled && hasCredentials && hasBackgroundLocation
Mandroid/app/src/main/java/net/lexcom/opentracker/MainActivity.kt
@@ -78,12 +78,25 @@ class MainActivity : ComponentActivity() {
missingPermissions = missing,
serverHost = "${current.udpHost}:${current.udpPort}",
onGrantPermissions = ::requestNextPermissions,
onStart = { TrackerService.start(activity) },
onStop = { TrackerService.stop(activity) },
// The flag is what BootReceiver and
// WatchdogReceiver read to tell a user stop from
// a system kill. It is always written before the
// service call, because TrackerService.onDestroy
// reads it to decide whether to leave the
// watchdog alarm armed.
onStart = {
Prefs(activity).setTrackingEnabled(true)
TrackerService.start(activity)
},
onStop = {
Prefs(activity).setTrackingEnabled(false)
TrackerService.stop(activity)
},
onSignOut = {
// Stop first. Clearing the credentials under a
// running service leaves it sending with a token
// the user just gave up.
Prefs(activity).setTrackingEnabled(false)
TrackerService.stop(activity)
Prefs(activity).clear()
credentials = null
Mandroid/app/src/main/java/net/lexcom/opentracker/TrackerService.kt
@@ -12,6 +12,7 @@ import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import net.lexcom.opentracker.loc.AospLocationSource
@@ -57,6 +58,7 @@ class TrackerService : Service() {
private lateinit var transport: Transport
private lateinit var uplink: Uplink
private lateinit var source: AospLocationSource
private lateinit var wakeLock: PowerManager.WakeLock
private var watcher: NetWatcher? = null
private val policy = SamplingPolicy()
@@ -75,6 +77,13 @@ class TrackerService : Service() {
if (running) {
// A second start from the UI, the boot receiver or the watchdog.
// Everything is already wired; re-wiring would leak the old socket.
//
// It is not a no-op though: it kicks a round. That is what the
// watchdog alarm is for. scheduleIn uses postDelayed, which counts
// uptimeMillis and does not advance in deep sleep, so the 60 s idle
// pump can stall for a whole doze window. The alarm is the only
// thing that still fires, and this line is what it buys.
scheduleIn(0)
return START_STICKY
}
@@ -99,6 +108,11 @@ class TrackerService : Service() {
}
override fun onDestroy() {
// Only a user stop takes the heartbeat down with it. A low-memory kill
// also lands here, and there the alarm is the one thing that can bring
// tracking back, so it has to stay armed.
if (!Prefs(this).isTrackingEnabled()) Watchdog.cancel(this)
if (!running) return
running = false
TrackerState.reportStopped()
@@ -124,6 +138,12 @@ class TrackerService : Service() {
* missing thing and spin. */
private fun refuse(startId: Int, reason: String): Int {
Log.w(TAG, "cannot track: $reason")
// Clear the flag before stopping, so onDestroy takes the watchdog alarm
// down with it. Leaving it armed would wake the device every 15 minutes
// to refuse again, and re-post this alert each time. Nothing here fixes
// itself; the user has to grant something or log in, and both paths go
// through the Start button, which sets the flag again.
Prefs(this).setTrackingEnabled(false)
TrackerState.reportStopped()
postAlert(ALERT_TITLE_STOPPED, reason)
stopSelf(startId)
@@ -144,6 +164,12 @@ 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)
wakeLock = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)
// Armed here rather than in the UI, so a service the system restarts by
// itself also gets its heartbeat back.
Watchdog.arm(this)
running = true
// Before the first round, so the UI's start/stop button is right as soon
@@ -224,13 +250,30 @@ class TrackerService : Service() {
* timestamp comes from the fix and not from when it was queued. Upgrade path
* if that ever stops being true: move the uplink onto a second thread and
* put a lock around the queue.
*
* The wake lock is what makes a round survive doze. A foreground service
* does not hold the CPU awake, and the wake lock the system grants a
* broadcast expires the moment `onReceive` returns, so without this the
* device can fall asleep halfway through a send and the reply never arrives.
*
* ponytail: the acquire has a timeout as a safety net, so no bug in here can
* pin the CPU on for the rest of the day. The ceiling is a round that
* legitimately takes longer than [WAKE_LOCK_MS], which then finishes with the
* device free to sleep again. At a two-second reply timeout that is 30x of
* headroom. Upgrade path if the uplink ever grows a long operation: pass the
* expected duration in instead of one constant.
*/
private fun round() {
val hello = helloPending
val result = if (hello) {
uplink.hello(appVersionCode(), Build.VERSION.SDK_INT, firstLaunch = false)
} else {
uplink.sendRound()
wakeLock.acquire(WAKE_LOCK_MS)
val result = try {
if (hello) {
uplink.hello(appVersionCode(), Build.VERSION.SDK_INT, firstLaunch = false)
} else {
uplink.sendRound()
}
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
// BackOff means nothing was sent, so the announcement still owes a try.
// Any other outcome means it went out; it is best effort, not a retry loop.
@@ -344,6 +387,14 @@ class TrackerService : Service() {
private const val BATTERY_TTL_MS = 60_000L
/** Namespaced tag, as the platform asks for: it shows up in battery
* stats and in `dumpsys power`, where an unprefixed name is anonymous. */
private const val WAKE_LOCK_TAG = "opentracker:round"
/** Safety net only. A round is a datagram and a reply, so the real cost
* is milliseconds; see the ceiling noted on `round`. */
private const val WAKE_LOCK_MS = 60_000L
private const val STATUS_STARTING = "Starting"
private const val STATUS_SHARING = "Sharing"
private const val STATUS_OFFLINE = "Offline"
Aandroid/app/src/main/java/net/lexcom/opentracker/Watchdog.kt
@@ -0,0 +1,68 @@
package net.lexcom.opentracker
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.SystemClock
/**
* The heartbeat that unsticks the service after the device has slept.
*
* [TrackerService] paces itself with `Handler.postDelayed`, which counts
* `uptimeMillis`. That clock stops in deep sleep, so the 60 s idle pump can stall
* for the whole length of a doze window. An alarm is the only thing the system
* guarantees still fires, so it is what wakes the service back up.
*
* `setAndAllowWhileIdle` is inexact and needs no permission. See
* [WatchdogReceiver] for why the interval is 15 minutes and why
* `SCHEDULE_EXACT_ALARM` is not requested.
*/
object Watchdog {
/** Matches the "roughly every 15 minutes" the receiver's KDoc promises. */
private const val INTERVAL_MS = 15 * 60 * 1000L
/** Stable, so [arm] replaces its own alarm instead of stacking a new one. */
private const val REQUEST_CODE = 1
/**
* Schedules the next single wakeup, replacing any pending one.
*
* One shot and re-armed by the receiver, not `setRepeating`: a repeating
* alarm is inexact *and* does not fire in doze at all, which is exactly the
* case this exists for.
*
* ELAPSED_REALTIME_WAKEUP and not RTC: a wall-clock correction, a timezone
* change or an NTP jump must not move the heartbeat. WAKEUP because a
* heartbeat that waits for the user to pick up the phone is not one.
*/
fun arm(context: Context) {
alarmManager(context).setAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + INTERVAL_MS,
pendingIntent(context),
)
}
/** Cancels the alarm and the PendingIntent, so nothing is left to fire. */
fun cancel(context: Context) {
val pending = pendingIntent(context)
alarmManager(context).cancel(pending)
pending.cancel()
}
private fun alarmManager(context: Context): AlarmManager =
context.getSystemService(AlarmManager::class.java)
// Explicit Intent: an implicit broadcast would be delivered to whoever else
// declares the action. FLAG_IMMUTABLE is mandatory from API 31 and correct
// here, because nothing ever fills this Intent in later.
private fun pendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
REQUEST_CODE,
Intent(context, WatchdogReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
Mandroid/app/src/main/java/net/lexcom/opentracker/WatchdogReceiver.kt
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import net.lexcom.opentracker.store.Prefs
/**
* Target of an inexact `setAndAllowWhileIdle` alarm, roughly every 15 minutes:
@@ -14,14 +15,29 @@ import android.util.Log
* once per 9 minutes, and `setExactAndAllowWhileIdle` would require
* `SCHEDULE_EXACT_ALARM` from API 31 — a permission this app deliberately does
* not request.
*
* Implemented at step 12.
*/
class WatchdogReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// The broadcast wake lock expires when onReceive returns, so any async
// work here must take its own PARTIAL_WAKE_LOCK first.
Log.i(TAG, "watchdog fired; no work scheduled yet")
// work here must take its own PARTIAL_WAKE_LOCK first. TrackerService
// does exactly that around a round; nothing here runs after the return.
if (!Prefs(context).isTrackingEnabled()) {
// A leftover alarm from a session the user has since stopped. Left
// armed it would wake the device every 15 minutes forever.
Log.i(TAG, "watchdog fired but tracking is off; cancelling")
Watchdog.cancel(context)
return
}
// Re-arm first. If the start below throws, the next heartbeat is already
// scheduled and the watchdog recovers on its own.
Watchdog.arm(context)
// A start on an already-running service is not a no-op: it kicks a round.
// That is the whole point of this alarm, because the service's own timer
// does not advance while the device sleeps.
TrackerService.start(context)
}
private companion object {
Mandroid/app/src/main/java/net/lexcom/opentracker/store/Prefs.kt
@@ -66,11 +66,32 @@ class Prefs(context: Context) {
}
}
/**
* Whether the user wants to be tracked, as opposed to whether the service
* happens to be running right now.
*
* Without this flag a receiver cannot tell "the user tapped Stop" from "the
* system killed us", and those two need opposite answers. BootReceiver and
* WatchdogReceiver read it to decide whether restarting is wanted at all.
*
* Default false, so a fresh install never starts tracking on its own.
*/
fun isTrackingEnabled(): Boolean = sp.getBoolean(KEY_TRACKING_ENABLED, false)
/** `commit()` for the same reason as [save]: the next reader may be a
* receiver in a process the system is about to kill. */
fun setTrackingEnabled(on: Boolean) {
sp.edit(commit = true) { putBoolean(KEY_TRACKING_ENABLED, on) }
}
/**
* Removes the keys, not just a flag.
*
* A `REVOKED` notice calls this. Leaving the token key on disk after it would
* keep a dead credential around for anyone who later gets the file.
*
* This wipes [KEY_TRACKING_ENABLED] too, because it clears the whole file.
* That is wanted: without credentials there is nothing to restart.
*/
fun clear() {
sp.edit(commit = true) { clear() }
@@ -85,6 +106,7 @@ class Prefs(context: Context) {
const val KEY_UDP_PORT = "udp_port"
const val KEY_TLS_URL = "tls_url"
const val KEY_CONFIG_VERSION = "config_version"
const val KEY_TRACKING_ENABLED = "tracking_enabled"
}
}
Aandroid/app/src/test/java/net/lexcom/opentracker/BootReceiverTest.kt
@@ -0,0 +1,63 @@
package net.lexcom.opentracker
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The one piece of [BootReceiver] that is a decision rather than framework
* wiring: whether restarting after a reboot will actually produce positions.
*
* The receiver itself is not tested. Under `isReturnDefaultValues` every
* framework getter answers with a default, so a test of `onReceive` would only
* assert that the stubs still return their defaults.
*/
class BootReceiverTest {
@Test
fun `restarts when the user wants it and it can work`() {
assertTrue(
shouldRestartTracking(
enabled = true,
hasCredentials = true,
hasBackgroundLocation = true,
),
)
}
@Test
fun `does not restart what the user turned off`() {
assertFalse(
shouldRestartTracking(
enabled = false,
hasCredentials = true,
hasBackgroundLocation = true,
),
)
}
@Test
fun `does not restart without credentials`() {
// Nothing to authenticate with, so every datagram would be dropped.
assertFalse(
shouldRestartTracking(
enabled = true,
hasCredentials = false,
hasBackgroundLocation = true,
),
)
}
@Test
fun `does not restart without background location`() {
// The one that costs battery for nothing: the service would run and
// never receive a fix, because the app is not visible.
assertFalse(
shouldRestartTracking(
enabled = true,
hasCredentials = true,
hasBackgroundLocation = false,
),
)
}
}