step 9: TrackerService, Notifications and PermissionGate
Aandroid/app/src/main/java/net/lexcom/opentracker/Notifications.kt
@@ -0,0 +1,101 @@
package net.lexcom.opentracker
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
/**
* The app's two notification channels and the notifications posted on them.
*
* This file only builds. Nothing here posts, because the one notification that
* matters is handed to `startForeground()` as an object, not looked up after
* being posted.
*
* The split into two channels exists so the user can silence one without
* silencing the other. The tracking notification is a status line that stays up
* for the whole trip, so it must never make a sound while the user drives. A
* revoked token is the opposite: nothing works until the user acts, so it is
* allowed to interrupt.
*
* Strings are Kotlin constants because this app has no res/values/strings.xml
* and is not localized. See the lint block in build.gradle.kts.
*/
object Notifications {
const val CHANNEL_TRACKING = "tracking"
const val CHANNEL_ALERTS = "alerts"
const val ID_TRACKING = 1
const val ID_ALERT = 2
private const val TRACKING_NAME = "Location tracking"
private const val TRACKING_DESC =
"The permanent notice shown while your location is being shared. " +
"Silent by design. Turning it off does not stop tracking."
private const val ALERTS_NAME = "Alerts"
private const val ALERTS_DESC =
"Problems that need you, such as being signed out and having to log in again."
private const val TRACKING_TITLE = "opentracker"
/**
* Creates both channels. Safe to call on every service start: the framework
* treats creating an existing channel as a no-op, and it never resets an
* importance the user has changed by hand.
*/
fun ensureChannels(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
// IMPORTANCE_LOW: visible in the shade, no sound, no heads-up.
NotificationChannel(CHANNEL_TRACKING, TRACKING_NAME, NotificationManager.IMPORTANCE_LOW)
.apply { description = TRACKING_DESC },
)
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ALERTS, ALERTS_NAME, NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = ALERTS_DESC },
)
}
/**
* The ongoing foreground-service notification. [text] is the live status the
* service rewrites as it runs, for example the motion mode and queue depth.
*/
fun tracking(context: Context, text: String): Notification =
Notification.Builder(context, CHANNEL_TRACKING)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentTitle(TRACKING_TITLE)
.setContentText(text)
.setContentIntent(openApp(context))
.setOngoing(true)
// The channel is already IMPORTANCE_LOW, so this is silent anyway.
// It matters because the user can raise the channel: then this keeps
// a status-text update from buzzing every few seconds.
// (setSilent() is NotificationCompat only; the framework builder has
// no equivalent that is not deprecated.)
.setOnlyAlertOnce(true)
.build()
/** A one-off the user can swipe away, for example "you were signed out". */
fun alert(context: Context, title: String, text: String): Notification =
Notification.Builder(context, CHANNEL_ALERTS)
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setContentTitle(title)
.setContentText(text)
.setStyle(Notification.BigTextStyle().bigText(text))
.setContentIntent(openApp(context))
.setAutoCancel(true)
.build()
// FLAG_IMMUTABLE is mandatory from API 31 and lint fails the build without
// it. We never fill this intent in later, so immutable is also correct.
private fun openApp(context: Context): PendingIntent =
PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
Aandroid/app/src/main/java/net/lexcom/opentracker/PermissionGate.kt
@@ -0,0 +1,70 @@
package net.lexcom.opentracker
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
/**
* Answers "may we do this yet?" for the three permissions that decide whether
* tracking works.
*
* This object only reads state. It shows no dialog and holds no Activity, so
* the service and any composable can call it. Requesting the permissions is
* MainActivity's job, because only an Activity can launch the system prompt.
*/
object PermissionGate {
/** The one the service cannot run without. No fix, nothing to send. */
fun hasLocation(context: Context): Boolean =
granted(context, Manifest.permission.ACCESS_FINE_LOCATION)
/**
* Granted separately from [hasLocation], and from API 30 only through a
* second trip into system settings. The distinction that confuses everyone:
* a foreground service started while the app is visible keeps getting fixes
* without this permission, even after the user leaves the app. What needs it
* is *starting* to track while the app is not visible, which is exactly what
* BootReceiver and WatchdogReceiver do.
*/
fun hasBackgroundLocation(context: Context): Boolean =
granted(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
/**
* Runtime-granted from API 33, implicitly granted below it.
*
* Denial is not fatal and the service must not treat it as such. The
* foreground service still starts and still tracks; its notification is
* simply never drawn. The user then has no way to see or stop tracking from
* the shade, which is a reason to ask again, not a reason to refuse to run.
*/
fun canPostNotifications(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
granted(context, Manifest.permission.POST_NOTIFICATIONS)
/**
* The permissions still missing, in the order they must be requested.
*
* The order is the point of this function. Android rejects the background
* location prompt outright until foreground location is granted, so asking
* for both at once loses the background one silently.
*/
fun missing(context: Context): List<String> = buildList {
if (!hasLocation(context)) {
// COARSE goes with FINE: asking for FINE alone lets the system
// dialog downgrade the grant to COARSE. See AndroidManifest.xml.
add(Manifest.permission.ACCESS_COARSE_LOCATION)
add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (!canPostNotifications(context)) {
add(Manifest.permission.POST_NOTIFICATIONS)
}
if (!hasBackgroundLocation(context)) {
add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
// context.checkSelfPermission is API 23+, so at minSdk 29 ContextCompat
// would only add an import around the same call.
private fun granted(context: Context, permission: String): Boolean =
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}
Mandroid/app/src/main/java/net/lexcom/opentracker/TrackerService.kt
@@ -1,31 +1,377 @@
package net.lexcom.opentracker
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.os.BatteryManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
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.SamplingPolicy
import net.lexcom.opentracker.net.NetWatcher
import net.lexcom.opentracker.net.Round
import net.lexcom.opentracker.net.Transport
import net.lexcom.opentracker.net.UdpTransport
import net.lexcom.opentracker.net.Uplink
import net.lexcom.opentracker.queue.PointQueue
import net.lexcom.opentracker.store.Credentials
import net.lexcom.opentracker.store.Prefs
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.PointFlags
import java.io.File
/**
* The `location`-type foreground service that will drive
* policy → source → frame → queue → uplink.
* The `location` foreground service: the one thing that keeps this app alive.
*
* Declared in the manifest from the start so the permission and
* `foregroundServiceType` wiring is validated by the build, but not yet
* implemented — nothing starts it. Filled in at step 9 of the implementation
* order, alongside `Notifications` and `PermissionGate`.
* It owns no logic of its own. It wires the five pieces that already exist and
* are already tested on the JVM:
*
* ```text
* AospLocationSource -> SamplingPolicy -> PointQueue -> Uplink -> UdpTransport
* ```
*
* One [HandlerThread] named "tracker" owns all of it. [PointQueue] and [Uplink]
* are both documented as single-threaded and they share the peek/ack pairing, so
* they must run on the same thread. [AospLocationSource] is built with that
* thread's `Looper`, so fixes already arrive there. [NetWatcher] is the one
* callback that does not: it fires on a framework thread and is posted across.
*
* Every durable input comes from [Prefs], never from an Intent extra. The system
* recreates a `START_STICKY` service with a null Intent, so an extra would be
* lost exactly when the service is restarted after a low-memory kill.
*/
class TrackerService : Service() {
private lateinit var thread: HandlerThread
private lateinit var handler: Handler
private lateinit var queue: PointQueue
private lateinit var transport: Transport
private lateinit var uplink: Uplink
private lateinit var source: AospLocationSource
private var watcher: NetWatcher? = null
private val policy = SamplingPolicy()
private var running = false
/** Sent once after start and once after every network change, never per round. */
private var helloPending = true
private var battery = Battery(null, false)
private var batteryReadAtMs = Long.MIN_VALUE
private val pump = Runnable { round() }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// START_STICKY recreates the service after a low-memory kill, but with a
// null Intent — so state must come from Prefs, never from Intent extras.
Log.w(TAG, "TrackerService started before it was implemented; stopping")
Notifications.ensureChannels(this)
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.
return START_STICKY
}
// Checked before startForeground on purpose. From API 34 the framework
// throws SecurityException if a `location` foreground service starts
// without the location grant, and a crash loop is worse than an alert.
// Reaching stopSelf without ever calling startForeground is safe: the
// five-second deadline from startForegroundService is cancelled when the
// service is destroyed.
if (!PermissionGate.hasLocation(this)) {
return refuse(startId, ALERT_NO_LOCATION)
}
val credentials = Prefs(this).load() ?: return refuse(startId, ALERT_NO_LOGIN)
startForeground(
Notifications.ID_TRACKING,
Notifications.tracking(this, STATUS_STARTING),
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
)
startTracking(credentials)
return START_STICKY
}
override fun onDestroy() {
if (!running) return
running = false
// Before anything else: a callback left registered outlives the service
// and goes on waking the process on every network change.
watcher?.stop()
watcher = null
handler.removeCallbacks(pump)
// Closing on the owning thread, because the queue and the socket are
// 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 {
source.stop()
transport.close()
queue.close()
}
thread.quitSafely()
}
override fun onBind(intent: Intent?): IBinder? = null
/** Say what is missing, then stop. Not sticky: a restart would find the same
* missing thing and spin. */
private fun refuse(startId: Int, reason: String): Int {
Log.w(TAG, "cannot track: $reason")
postAlert(ALERT_TITLE_STOPPED, reason)
stopSelf(startId)
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
// -- wiring --------------------------------------------------------------
@SuppressLint("MissingPermission") // PermissionGate.hasLocation was checked above.
private fun startTracking(credentials: Credentials) {
thread = HandlerThread("tracker").apply { start() }
handler = Handler(thread.looper)
queue = PointQueue(File(filesDir, QUEUE_FILE), QUEUE_CAPACITY)
transport = UdpTransport(credentials.udpHost, credentials.udpPort)
// elapsedRealtime, not currentTimeMillis: this clock only paces backoff,
// and a wall clock that jumps backwards would stall the uplink for as
// 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)
running = true
handler.post {
source.start(policy.request, ::onFix)
round()
}
watcher = NetWatcher(this).apply {
start {
handler.post {
// The backoff was earned by an outage that has just ended.
uplink.clearBackOff()
helloPending = true
scheduleIn(0)
}
}
}
}
// -- sampling ------------------------------------------------------------
/** Runs on the tracker thread: [AospLocationSource] was built with its looper. */
@SuppressLint("MissingPermission") // Same grant as startTracking; the service stops if it is lost.
private fun onFix(fix: Fix) {
val decision = policy.offer(fix, SystemClock.elapsedRealtime())
decision.keep?.let {
queue.append(withBattery(it))
scheduleIn(0)
}
if (decision.requestChanged) source.update(policy.request)
}
/**
* Fills the two fields [SamplingPolicy] deliberately leaves unset.
*
* The sticky `ACTION_BATTERY_CHANGED` Intent is readable with no receiver
* and no permission. It is still a binder round trip, so the answer is held
* for [BATTERY_TTL_MS]. A battery does not move a whole percent in a minute,
* and in VEHICLE mode a fix arrives every five seconds.
*/
private fun withBattery(point: Point): Point {
val now = SystemClock.elapsedRealtime()
if (now - batteryReadAtMs >= BATTERY_TTL_MS) {
val sticky = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
battery = if (sticky == null) {
Battery(null, false)
} else {
batteryOf(
level = sticky.getIntExtra(BatteryManager.EXTRA_LEVEL, -1),
scale = sticky.getIntExtra(BatteryManager.EXTRA_SCALE, -1),
status = sticky.getIntExtra(BatteryManager.EXTRA_STATUS, -1),
)
}
batteryReadAtMs = now
}
return point.copy(
batPct = battery.pct,
flags = if (battery.charging) point.flags or PointFlags.CHARGING else point.flags,
)
}
// -- uplink --------------------------------------------------------------
/**
* One round, on the tracker thread.
*
* ponytail: `sendRound` blocks up to `Uplink.REPLY_TIMEOUT_MS` waiting for a
* reply, and location callbacks queue behind it for that long. The ceiling
* is two seconds of delayed appends, which costs nothing because a point's
* 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.
*/
private fun round() {
val hello = helloPending
val result = if (hello) {
uplink.hello(appVersionCode(), Build.VERSION.SDK_INT, firstLaunch = false)
} else {
uplink.sendRound()
}
// 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.
if (hello && result !is Round.BackOff) helloPending = false
when (result) {
is Round.Acked -> {
updateNotification(STATUS_SHARING)
scheduleNext()
}
private companion object {
const val TAG = "OpenTracker"
// Nothing queued. Sending again at once would just be a busy loop;
// the next kept fix schedules a round itself.
Round.Idle -> {
updateNotification(STATUS_SHARING)
scheduleIn(IDLE_MS)
}
// Silent data loss, so it gets a notification and not a log line.
// The server refused points it will never accept, and the usual
// cause is a device clock outside the server's +/-30-day window.
is Round.Dropped -> {
Log.w(TAG, "server rejected ${result.count} points as malformed")
postAlert(ALERT_TITLE_DROPPED, ALERT_DROPPED)
scheduleNext()
}
// The uplink already decided when it may speak again. Polling faster
// than it asked is how a saturated server stays saturated.
//
// The status text matters here. This notification is the only place
// the user can see whether sharing actually works, and leaving it
// reading "Starting" through an outage is how they find out too late.
is Round.NoReply -> {
updateNotification(STATUS_OFFLINE)
scheduleAt(result.retryAtMs)
}
is Round.BackOff -> scheduleAt(result.retryAtMs)
is Round.Throttled -> {
updateNotification(STATUS_BUSY)
scheduleAt(result.retryAtMs)
}
// The credentials on disk are worthless. Keeping them would leave a
// dead token key on the device, and retrying would only be refused.
is Round.TokenDead -> {
Log.w(TAG, "token is dead; clearing credentials and stopping")
Prefs(this).clear()
postAlert(ALERT_TITLE_STOPPED, ALERT_TOKEN_DEAD)
stopSelf()
}
}
}
/** More waiting means keep going; an empty queue means wait for a fix. */
private fun scheduleNext() = scheduleIn(if (queue.size > 0) 0 else IDLE_MS)
private fun scheduleAt(atMs: Long) = scheduleIn(atMs - SystemClock.elapsedRealtime())
private fun scheduleIn(delayMs: Long) {
if (!running) return
handler.removeCallbacks(pump)
handler.postDelayed(pump, delayMs.coerceIn(0, IDLE_MS))
}
// -- notifications -------------------------------------------------------
private fun updateNotification(state: String) {
val text = "$state - ${policy.motion.name.lowercase()} - ${queue.size} queued"
notifier().notify(Notifications.ID_TRACKING, Notifications.tracking(this, text))
}
private fun postAlert(title: String, text: String) {
// Without the grant `notify` is dropped by the framework anyway. The
// service still runs; see PermissionGate.canPostNotifications.
if (!PermissionGate.canPostNotifications(this)) return
notifier().notify(Notifications.ID_ALERT, Notifications.alert(this, title, text))
}
private fun notifier(): NotificationManager =
getSystemService(NotificationManager::class.java)
/** `buildConfig = false`, so there is no generated `VERSION_CODE` to read. */
private fun appVersionCode(): Int =
packageManager.getPackageInfo(packageName, 0).longVersionCode.toInt()
companion object {
private const val TAG = "OpenTracker"
private const val QUEUE_FILE = "points.queue"
/**
* 8192 slots of 32 bytes: 256 KiB, preallocated once.
*
* VEHICLE samples every 5 s, so at most 720 points an hour, and the ring
* covers about 11 hours of continuous driving with no network. In
* STATIONARY the heartbeat keeps at most 12 points an hour, which is
* roughly four weeks. Both are far longer than any outage worth
* surviving, and 256 KiB is nothing next to the APK.
*/
private const val QUEUE_CAPACITY = 8192
/** Nothing to send. Long enough to be free, short enough to recover from
* a missed wakeup before the user notices. */
private const val IDLE_MS = 60_000L
private const val BATTERY_TTL_MS = 60_000L
private const val STATUS_STARTING = "Starting"
private const val STATUS_SHARING = "Sharing"
private const val STATUS_OFFLINE = "Offline"
private const val STATUS_BUSY = "Server busy"
private const val ALERT_TITLE_STOPPED = "Tracking stopped"
private const val ALERT_TITLE_DROPPED = "Some positions were lost"
private const val ALERT_NO_LOGIN = "Not signed in. Open opentracker and log in."
private const val ALERT_NO_LOCATION =
"Location permission is missing. Open opentracker and grant it."
private const val ALERT_TOKEN_DEAD =
"This device was signed out. Open opentracker and log in again."
private const val ALERT_DROPPED =
"The server refused some positions and they were discarded. " +
"The usual cause is a wrong date or time on this phone."
/** One place that builds the Intent. BootReceiver, the watchdog and the
* UI all start the service, and three copies would drift apart. */
fun start(context: Context) {
context.startForegroundService(Intent(context, TrackerService::class.java))
}
fun stop(context: Context) {
context.stopService(Intent(context, TrackerService::class.java))
}
}
}
/** What the sticky battery Intent says, once it is no longer an Intent. */
internal data class Battery(val pct: Int?, val charging: Boolean)
/**
* The battery Intent's three extras as a percentage and a charging flag.
*
* Takes Ints and not an Intent so it can be tested on the JVM, where every
* framework getter returns a default. A missing or zero scale yields a null
* percentage, which the wire encodes as "unknown"; inventing 100 would put a
* wrong number in front of the user.
*/
internal fun batteryOf(level: Int, scale: Int, status: Int): Battery = Battery(
pct = if (level < 0 || scale <= 0) null else (level * 100 / scale).coerceIn(0, 100),
charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL,
)
Aandroid/app/src/test/java/net/lexcom/opentracker/TrackerServiceTest.kt
@@ -0,0 +1,49 @@
package net.lexcom.opentracker
import android.os.BatteryManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The one piece of [TrackerService] that is logic rather than framework wiring:
* the battery Intent's extras turned into a percentage and a charging flag.
*
* Everything else in that file is a Handler, a Service callback or a call into a
* class that already has its own JVM test, so it is not tested here.
*/
class TrackerServiceTest {
@Test
fun `scales level against scale`() {
assertEquals(50, batteryOf(50, 100, 0).pct)
// Some devices report a scale other than 100.
assertEquals(50, batteryOf(128, 256, 0).pct)
assertEquals(100, batteryOf(100, 100, 0).pct)
assertEquals(0, batteryOf(0, 100, 0).pct)
}
@Test
fun `unknown rather than wrong when the extras are missing`() {
// A scale of 0 would divide by zero; a missing extra reads back as -1.
assertNull(batteryOf(50, 0, 0).pct)
assertNull(batteryOf(-1, -1, -1).pct)
assertNull(batteryOf(-1, 100, 0).pct)
}
@Test
fun `a broken level cannot leave the 0 to 100 range`() {
assertEquals(100, batteryOf(200, 100, 0).pct)
}
@Test
fun `charging covers full as well`() {
assertTrue(batteryOf(50, 100, BatteryManager.BATTERY_STATUS_CHARGING).charging)
// FULL is still on the cable, and the wire flag means "on power".
assertTrue(batteryOf(100, 100, BatteryManager.BATTERY_STATUS_FULL).charging)
assertFalse(batteryOf(50, 100, BatteryManager.BATTERY_STATUS_DISCHARGING).charging)
assertFalse(batteryOf(50, 100, -1).charging)
}
}