TrackerService.kt
โއ
Raw
1package org.opentracker
2
3import android.Manifest
4import android.app.Notification
5import android.app.NotificationChannel
6import android.app.NotificationManager
7import android.app.PendingIntent
8import android.app.Service
9import android.content.Context
10import android.content.Intent
11import android.content.pm.PackageManager
12import android.content.pm.ServiceInfo
13import android.location.Location
14import android.location.LocationManager
15import android.net.ConnectivityManager
16import android.net.Network
17import android.os.BatteryManager
18import android.os.Handler
19import android.os.HandlerThread
20import android.text.format.DateFormat
21import android.util.Log
22import java.io.IOException
23import 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 */
32class 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