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 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