TrackerService.kt
| 1 | package org.opentracker |
| 2 | |
| 3 | import android.Manifest |
| 4 | import android.app.Notification |
| 5 | import android.app.NotificationChannel |
| 6 | import android.app.NotificationManager |
| 7 | import android.app.PendingIntent |
| 8 | import android.app.Service |
| 9 | import android.content.Context |
| 10 | import android.content.Intent |
| 11 | import android.content.pm.PackageManager |
| 12 | import android.content.pm.ServiceInfo |
| 13 | import android.location.Location |
| 14 | import android.location.LocationManager |
| 15 | import android.net.ConnectivityManager |
| 16 | import android.net.Network |
| 17 | import android.os.BatteryManager |
| 18 | import android.os.Handler |
| 19 | import android.os.HandlerThread |
| 20 | import android.text.format.DateFormat |
| 21 | import android.util.Log |
| 22 | import java.io.IOException |
| 23 | import org.json.JSONObject |
| 24 | |
| 25 | /** |
| 26 | * The location foreground service. One thread owns all tracking state: |
| 27 | * |
| 28 | * ```text |
| 29 | * LocationSource -> SamplingPolicy -> Outbox -> Api |
| 30 | * ``` |
| 31 | */ |
| 32 | class TrackerService : Service() { |
| 33 | private lateinit var prefs: Prefs |
| 34 | private lateinit var thread: HandlerThread |
| 35 | private lateinit var handler: Handler |
| 36 | private lateinit var outbox: Outbox |
| 37 | private lateinit var source: LocationSource |
| 38 | private lateinit var motion: MotionDetector |
| 39 | private val policy = SamplingPolicy() |
| 40 | private var started = false |
| 41 | private var online = true |
| 42 | private var backoffMs = 0L |
| 43 | private var uploadAtMs = Long.MAX_VALUE |
| 44 | private var maxUpload = MAX_UPLOAD |
| 45 | private var lastFixMs = 0L |
| 46 | /** Batching should deliver at least one fix in this time, else it is broken. 0 while not batching GNSS. */ |
| 47 | private var batchWindowMs = 0L |
| 48 | private val upload = Runnable { upload() } |
| 49 | private val batchCheck = Runnable { checkBatching(System.currentTimeMillis()) } |
| 50 | private val network = object : ConnectivityManager.NetworkCallback() { |
| 51 | override fun onAvailable(n: Network) { |
| 52 | handler.post { |
| 53 | online = true |
| 54 | backoffMs = 0 |
| 55 | schedule(urgent = true) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | override fun onLost(n: Network) { |
| 60 | handler.post { online = false } |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | override fun onBind(intent: Intent?) = null |
| 65 | |
| 66 | override fun onCreate() { |
| 67 | prefs = Prefs(this) |
| 68 | thread = HandlerThread("tracker").apply { start() } |
| 69 | handler = Handler(thread.looper) |
| 70 | outbox = Outbox(this) |
| 71 | source = LocationSource(getSystemService(LocationManager::class.java), handler::post, ::onFixes) |
| 72 | motion = MotionDetector(this) { handler.post(::onMotion) } |
| 73 | getSystemService(NotificationManager::class.java).createNotificationChannel( |
| 74 | NotificationChannel(CHANNEL, getString(R.string.tracking), NotificationManager.IMPORTANCE_LOW), |
| 75 | ) |
| 76 | } |
| 77 | |
| 78 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { |
| 79 | // From Android 14, starting a location service without the permission throws. |
| 80 | val granted = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED |
| 81 | if (!prefs.tracking || prefs.token == null || !granted) { |
| 82 | stopSelf() |
| 83 | return START_NOT_STICKY |
| 84 | } |
| 85 | try { |
| 86 | startForeground(NOTIFICATION, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) |
| 87 | } catch (e: RuntimeException) { |
| 88 | // Android refuses a location service started in the background without "Allow all the time", |
| 89 | // for example after a reboot. Then the user has to open the app. |
| 90 | prefs.error = getString(R.string.tracking_stopped) |
| 91 | stopSelf() |
| 92 | return START_NOT_STICKY |
| 93 | } |
| 94 | Watchdog.arm(this) |
| 95 | val action = intent?.action |
| 96 | handler.post { |
| 97 | if (!started) { |
| 98 | started = true |
| 99 | begin() |
| 100 | } |
| 101 | when (action) { |
| 102 | ACTION_UPLOAD -> schedule(urgent = true) |
| 103 | ACTION_TICK -> { |
| 104 | checkBatching(System.currentTimeMillis()) |
| 105 | policy.tick(System.currentTimeMillis()) |
| 106 | afterChange(urgent = false) |
| 107 | // Handler delays stop in deep sleep, so a due upload may still be waiting. |
| 108 | if (System.currentTimeMillis() >= uploadAtMs) upload() |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | return START_STICKY |
| 113 | } |
| 114 | |
| 115 | private fun begin() { |
| 116 | getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(network, handler) |
| 117 | afterChange(urgent = true) |
| 118 | } |
| 119 | |
| 120 | override fun onDestroy() { |
| 121 | handler.post { |
| 122 | source.stop() |
| 123 | motion.disarm() |
| 124 | runCatching { getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(network) } |
| 125 | outbox.close() |
| 126 | thread.quitSafely() |
| 127 | } |
| 128 | // A low-memory kill also ends here. Then the watchdog has to stay armed to bring tracking back. |
| 129 | if (!prefs.tracking) Watchdog.cancel(this) |
| 130 | } |
| 131 | |
| 132 | private fun onFixes(locations: List<Location>) { |
| 133 | lastFixMs = System.currentTimeMillis() |
| 134 | if (batchWindowMs > 0) { |
| 135 | handler.removeCallbacks(batchCheck) |
| 136 | handler.postDelayed(batchCheck, batchWindowMs) |
| 137 | } |
| 138 | val battery = getSystemService(BatteryManager::class.java).getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) |
| 139 | val before = policy.mode |
| 140 | var kept = 0 |
| 141 | for (l in locations) { |
| 142 | val fix = Fix(l.time, l.latitude, l.longitude, l.accuracyOrNull(), if (l.hasSpeed()) l.speed else null) |
| 143 | if (policy.onFix(fix)) { |
| 144 | outbox.add(l.time / 1000, json(l, battery)) |
| 145 | kept++ |
| 146 | } |
| 147 | } |
| 148 | Log.d(TAG, "${locations.size} fixes, $kept kept, ${policy.mode}") |
| 149 | afterChange(urgent = policy.mode != before) |
| 150 | } |
| 151 | |
| 152 | private fun onMotion() { |
| 153 | policy.wake(System.currentTimeMillis()) |
| 154 | afterChange(urgent = false) |
| 155 | } |
| 156 | |
| 157 | /** Applies the policy's request and plans the next upload. */ |
| 158 | private fun afterChange(urgent: Boolean) { |
| 159 | val bm = getSystemService(BatteryManager::class.java) |
| 160 | val low = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) < LOW_BATTERY |
| 161 | val req = policy.request(lowBattery = low, charging = bm.isCharging) |
| 162 | if (source.apply(req)) { |
| 163 | batchWindowMs = if (req.gnss && req.maxDelayMs > 0 && source.batching) 2 * req.maxDelayMs + req.intervalMs else 0 |
| 164 | handler.removeCallbacks(batchCheck) |
| 165 | if (batchWindowMs > 0) { |
| 166 | lastFixMs = System.currentTimeMillis() |
| 167 | handler.postDelayed(batchCheck, batchWindowMs) |
| 168 | } |
| 169 | } |
| 170 | if (policy.mode == Mode.STATIONARY) motion.arm() else motion.disarm() |
| 171 | if (prefs.mode != policy.mode.name) { |
| 172 | prefs.mode = policy.mode.name |
| 173 | updateNotification() |
| 174 | } |
| 175 | // A heartbeat point goes alone: one point is one radio wakeup either way. |
| 176 | schedule(urgent = urgent || policy.mode == Mode.STATIONARY) |
| 177 | } |
| 178 | |
| 179 | /** Indoors, GNSS also stays silent. Then this turns batching off needlessly, but only until the service restarts. */ |
| 180 | private fun checkBatching(now: Long) { |
| 181 | if (batchWindowMs > 0 && now - lastFixMs >= batchWindowMs) { |
| 182 | batchWindowMs = 0 |
| 183 | Log.w(TAG, "no fixes with batching, turning it off") |
| 184 | source.batching = false |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | private fun schedule(urgent: Boolean) { |
| 189 | val count = outbox.count() |
| 190 | prefs.queued = count |
| 191 | if (count == 0L || !online) return |
| 192 | val now = System.currentTimeMillis() |
| 193 | val due = when { |
| 194 | backoffMs > 0 -> uploadAtMs |
| 195 | urgent || count >= BATCH -> now |
| 196 | else -> (outbox.oldest() ?: return) * 1000 + MAX_WAIT_MS |
| 197 | } |
| 198 | uploadAtMs = due |
| 199 | handler.removeCallbacks(upload) |
| 200 | handler.postDelayed(upload, (due - now).coerceAtLeast(0)) |
| 201 | } |
| 202 | |
| 203 | private fun upload() { |
| 204 | handler.removeCallbacks(upload) |
| 205 | val url = prefs.url |
| 206 | val token = prefs.token |
| 207 | if (url == null || token == null) return |
| 208 | val batch = outbox.peek(maxUpload) |
| 209 | if (batch.isEmpty()) return |
| 210 | try { |
| 211 | Api(this, url, token).upload(batch.joinToString(",", "[", "]") { it.second }) |
| 212 | outbox.removeUpTo(batch.last().first) |
| 213 | backoffMs = 0 |
| 214 | uploadAtMs = Long.MAX_VALUE |
| 215 | prefs.lastUploadMs = System.currentTimeMillis() |
| 216 | prefs.error = null |
| 217 | updateNotification() |
| 218 | if (outbox.count() > 0) handler.post(upload) else prefs.queued = 0 |
| 219 | } catch (e: HttpError) { |
| 220 | when { |
| 221 | // Proxies and captive portals also answer 401, so confirm it before the token goes. |
| 222 | e.status == 401 && tokenRevoked(url, token) -> return removed() |
| 223 | // The server rejects a malformed batch with these. A retry fails again and blocks every later point. |
| 224 | e.status == 400 || e.status == 422 -> { |
| 225 | outbox.removeUpTo(batch.last().first) |
| 226 | prefs.error = resources.getQuantityString(R.plurals.server_refused_points, batch.size, batch.size, e.message) |
| 227 | schedule(urgent = true) |
| 228 | } |
| 229 | // A proxy body limit. The server accepts a full batch. |
| 230 | e.status == 413 && maxUpload > 1 -> { |
| 231 | maxUpload /= 2 |
| 232 | handler.post(upload) |
| 233 | } |
| 234 | else -> retry(getString(R.string.server_refused_upload, e.message)) |
| 235 | } |
| 236 | } catch (e: IOException) { |
| 237 | retry(getString(R.string.no_connection)) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | private fun retry(error: String) { |
| 242 | prefs.error = error |
| 243 | backoffMs = (backoffMs * 2).coerceIn(MIN_BACKOFF_MS, MAX_BACKOFF_MS) |
| 244 | uploadAtMs = System.currentTimeMillis() + backoffMs |
| 245 | handler.postDelayed(upload, backoffMs) |
| 246 | prefs.queued = outbox.count() |
| 247 | } |
| 248 | |
| 249 | private fun tokenRevoked(url: String, token: String): Boolean = try { |
| 250 | Api(this, url, token).device() |
| 251 | false |
| 252 | } catch (e: IOException) { |
| 253 | e is HttpError && e.status == 401 |
| 254 | } |
| 255 | |
| 256 | /** The device was deleted on the server. Its token is useless now. */ |
| 257 | private fun removed() { |
| 258 | prefs.token = null |
| 259 | prefs.tracking = false |
| 260 | prefs.error = getString(R.string.phone_removed) |
| 261 | stopSelf() |
| 262 | } |
| 263 | |
| 264 | private fun updateNotification() { |
| 265 | getSystemService(NotificationManager::class.java).notify(NOTIFICATION, notification()) |
| 266 | } |
| 267 | |
| 268 | private fun notification(): Notification { |
| 269 | val open = PendingIntent.getActivity( |
| 270 | this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE, |
| 271 | ) |
| 272 | val last = prefs.lastUploadMs |
| 273 | val text = listOfNotNull( |
| 274 | prefs.modeLabel(this), |
| 275 | if (last > 0) getString(R.string.last_upload, DateFormat.getTimeFormat(this).format(last)) else null, |
| 276 | ).joinToString(" ยท ") |
| 277 | return Notification.Builder(this, CHANNEL) |
| 278 | .setSmallIcon(R.drawable.ic_notification) |
| 279 | .setContentTitle(getString(R.string.notification_title)) |
| 280 | .setContentText(text) |
| 281 | .setContentIntent(open) |
| 282 | .setOngoing(true) |
| 283 | .build() |
| 284 | } |
| 285 | |
| 286 | companion object { |
| 287 | const val ACTION_UPLOAD = "upload" |
| 288 | const val ACTION_TICK = "tick" |
| 289 | private const val TAG = "opentracker" |
| 290 | private const val CHANNEL = "tracking" |
| 291 | private const val NOTIFICATION = 1 |
| 292 | private const val BATCH = 20 |
| 293 | private const val MAX_UPLOAD = 1000 |
| 294 | private const val MAX_WAIT_MS = 2 * 60_000L |
| 295 | private const val MIN_BACKOFF_MS = 30_000L |
| 296 | private const val MAX_BACKOFF_MS = 15 * 60_000L |
| 297 | private const val LOW_BATTERY = 15 |
| 298 | |
| 299 | fun start(context: Context, action: String? = null) { |
| 300 | context.startForegroundService(Intent(context, TrackerService::class.java).setAction(action)) |
| 301 | } |
| 302 | |
| 303 | private fun Location.accuracyOrNull() = if (hasAccuracy()) accuracy else null |
| 304 | |
| 305 | private fun json(l: Location, battery: Int): String = JSONObject().apply { |
| 306 | put("ts", l.time / 1000) |
| 307 | put("lat", l.latitude) |
| 308 | put("lon", l.longitude) |
| 309 | if (l.hasAccuracy()) put("acc", l.accuracy.toDouble()) |
| 310 | if (l.hasAltitude()) put("alt", l.altitude) |
| 311 | if (l.hasSpeed()) put("speed", l.speed.toDouble()) |
| 312 | if (l.hasBearing()) put("bearing", l.bearing.toDouble()) |
| 313 | if (battery in 0..100) put("battery", battery) |
| 314 | }.toString() |
| 315 | } |
| 316 | } |
| 317 |