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