package `in`.carecollect.field.location import android.app.* import android.content.Context import android.content.Intent import android.os.BatteryManager import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import com.google.android.gms.location.* import `in`.carecollect.field.data.FieldDb import `in`.carecollect.field.data.PendingPing import `in`.carecollect.field.ui.MainActivity import `in`.carecollect.field.work.SyncScheduler import kotlinx.coroutines.* /** * Keeps location flowing while the phlebotomist is on duty. * * This class is the reason the app is native Kotlin rather than a cross * platform wrapper. Everything below exists because of one specific problem: * cheap Android phones aggressively kill background work, and a tracking * feature that stops when the screen locks is worse than no tracking at all, * because the dispatcher trusts a stale marker. * * The defences, in order of how much they actually matter: * * 1. A foreground service with a visible notification. On Android 10+ this * is the only supported way to read location with the screen off. The * notification is deliberately not dismissible. * 2. Points are written to Room FIRST and uploaded second. If the process is * killed mid-batch, nothing is lost — the sync worker picks it up. * 3. Adaptive interval. Ten seconds while travelling to a patient, thirty * while idle, and nothing at all while off duty. Battery complaints are * the most common reason field staff disable an app. * 4. START_STICKY plus a boot receiver, so an OS-initiated kill recovers. * * None of this survives a user tapping "Force stop" or an OEM battery * optimiser that has not been whitelisted. That is a setup step on the * handset, not something code can fix — see README-android.md. */ class DutyLocationService : Service() { companion object { const val ACTION_START = "cc.duty.start" const val ACTION_STOP = "cc.duty.stop" const val ACTION_TRAVELLING = "cc.duty.travelling" const val ACTION_IDLE = "cc.duty.idle" private const val CHANNEL_ID = "cc_duty" private const val NOTIFICATION_ID = 4201 private const val INTERVAL_TRAVELLING_MS = 10_000L private const val INTERVAL_IDLE_MS = 30_000L private const val MIN_DISTANCE_M = 15f /** Upload once we have this many points, or every 60s, whichever first. */ private const val FLUSH_AT_POINTS = 6 } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private lateinit var client: FusedLocationProviderClient private var buffered = 0 private var travelling = false private val callback = object : LocationCallback() { override fun onLocationResult(result: LocationResult) { val loc = result.lastLocation ?: return // A phone reporting exactly 0,0 has not got a fix; it is the // Atlantic. Dropping it here keeps junk out of the database. if (loc.latitude == 0.0 && loc.longitude == 0.0) return scope.launch { FieldDb.get(applicationContext).pings().insert( PendingPing( lat = loc.latitude, lng = loc.longitude, accuracy = loc.accuracy.toInt(), speedKmph = (loc.speed * 3.6f).toInt(), battery = batteryPercent(), // Reported, never hidden. The server flags it for the // dispatcher rather than refusing the point, because // refusing tells whoever is faking it what to fix. isMock = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) loc.isMock else @Suppress("DEPRECATION") loc.isFromMockProvider, recordedAt = System.currentTimeMillis() ) ) if (++buffered >= FLUSH_AT_POINTS) { buffered = 0 SyncScheduler.flushNow(applicationContext) } } } } override fun onCreate() { super.onCreate() client = LocationServices.getFusedLocationProviderClient(this) createChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_STOP -> { stopUpdates() stopSelf() return START_NOT_STICKY } ACTION_TRAVELLING -> setInterval(true) ACTION_IDLE -> setInterval(false) else -> { startForeground(NOTIFICATION_ID, buildNotification("On duty")) setInterval(false) } } // Sticky: if Android reclaims the process, restart us. The empty // intent lands in the else branch above and duty resumes. return START_STICKY } private fun setInterval(isTravelling: Boolean) { if (travelling == isTravelling && buffered > 0) return travelling = isTravelling val interval = if (isTravelling) INTERVAL_TRAVELLING_MS else INTERVAL_IDLE_MS val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, interval) .setMinUpdateIntervalMillis(interval / 2) .setMinUpdateDistanceMeters(MIN_DISTANCE_M) .setWaitForAccurateLocation(false) .build() try { client.removeLocationUpdates(callback) client.requestLocationUpdates(request, callback, mainLooper) } catch (e: SecurityException) { // Permission was revoked while on duty. Stop cleanly and let the // UI ask again rather than dying with an exception in the log. stopSelf() return } startForeground( NOTIFICATION_ID, buildNotification(if (isTravelling) "On the way to a patient" else "On duty") ) } private fun stopUpdates() { try { client.removeLocationUpdates(callback) } catch (_: SecurityException) {} scope.launch { SyncScheduler.flushNow(applicationContext) } } private fun buildNotification(text: String): Notification { val open = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("CareCollect") .setContentText(text) .setSmallIcon(android.R.drawable.ic_menu_mylocation) .setOngoing(true) .setSilent(true) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setContentIntent(open) .build() } private fun createChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val channel = NotificationChannel( CHANNEL_ID, "Duty tracking", NotificationManager.IMPORTANCE_LOW ).apply { description = "Shows while you are on duty. Do not turn this off, or your route stops updating." setShowBadge(false) } getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } private fun batteryPercent(): Int = (getSystemService(Context.BATTERY_SERVICE) as BatteryManager) .getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) override fun onDestroy() { stopUpdates() scope.cancel() super.onDestroy() } override fun onBind(intent: Intent?): IBinder? = null }