From 724fbe1bdb6c7021ab2fa8a4e1178cb33cc971a9 Mon Sep 17 00:00:00 2001 From: fiatcode Date: Thu, 30 Apr 2026 11:10:23 +0700 Subject: [PATCH] feat: add LocationTrackingService foreground service --- .../service/LocationTrackingService.kt | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt diff --git a/android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt b/android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt new file mode 100644 index 0000000..048ec12 --- /dev/null +++ b/android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt @@ -0,0 +1,350 @@ +package com.traccar.traccar_client.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.location.Location +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import com.traccar.traccar_client.MainActivity +import com.traccar.traccar_client.R +import com.traccar.traccar_client.location.DistanceFilterProcessor +import com.traccar.traccar_client.location.FusedLocationProvider +import com.traccar.traccar_client.location.HeartbeatScheduler +import com.traccar.traccar_client.model.Location as TraccarLocation +import com.traccar.traccar_client.network.ConnectivityReceiver +import com.traccar.traccar_client.network.TraccarConfig +import com.traccar.traccar_client.network.TraccarHttpClient +import com.traccar.traccar_client.storage.AppDatabase +import com.traccar.traccar_client.storage.EventLogEntity +import com.traccar.traccar_client.storage.LocationEntity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +class LocationTrackingService : Service() { + + private val binder = LocalBinder() + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private lateinit var fusedLocationProvider: FusedLocationProvider + private lateinit var distanceFilterProcessor: DistanceFilterProcessor + private lateinit var heartbeatScheduler: HeartbeatScheduler + private lateinit var database: AppDatabase + private lateinit var httpClient: TraccarHttpClient + private lateinit var connectivityReceiver: ConnectivityReceiver + + private var wakeLock: PowerManager.WakeLock? = null + private var isTracking = false + private var isNetworkAvailable = true + + private var currentConfig: TrackingConfig? = null + + inner class LocalBinder : Binder() { + fun getService(): LocationTrackingService = this@LocationTrackingService + } + + override fun onCreate() { + super.onCreate() + fusedLocationProvider = FusedLocationProvider(this) + distanceFilterProcessor = DistanceFilterProcessor() + heartbeatScheduler = HeartbeatScheduler(this) + database = AppDatabase.getInstance(this) + httpClient = TraccarHttpClient() + connectivityReceiver = ConnectivityReceiver { online -> + isNetworkAvailable = online + logEvent("NETWORK_CHANGE", "Network: ${if (online) "online" else "offline"}") + if (online) syncBufferedLocations() + } + + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> startTracking() + ACTION_STOP -> stopTracking() + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder = binder + + override fun onDestroy() { + super.onDestroy() + stopTracking() + serviceScope.cancel() + } + + fun startTracking() { + if (isTracking) return + + val config = currentConfig ?: TrackingConfig() + currentConfig = config + + acquireWakeLock() + startForeground(NOTIFICATION_ID, createNotification("Starting...")) + + isTracking = true + distanceFilterProcessor.reset() + + fusedLocationProvider.startLocationUpdates( + interval = config.interval * 1000L, + fastestInterval = config.fastestInterval * 1000L, + accuracy = config.accuracy + ) { location -> + onLocationReceived(location) + } + + if (config.heartbeat > 0) { + heartbeatScheduler.scheduleHeartbeat(config.heartbeat.toLong()) { + onHeartbeat() + } + } + + logEvent("INFO", "Tracking started") + } + + fun stopTracking() { + if (!isTracking) return + + isTracking = false + fusedLocationProvider.stopLocationUpdates() + heartbeatScheduler.cancelHeartbeat() + releaseWakeLock() + + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + + logEvent("INFO", "Tracking stopped") + } + + fun updateConfig(config: TrackingConfig) { + currentConfig = config + if (isTracking) { + stopTracking() + startTracking() + } + } + + private fun onLocationReceived(location: Location) { + val config = currentConfig ?: return + + val filterConfig = com.traccar.traccar_client.location.FilterConfig( + distanceFilter = config.distanceFilter, + intervalFilter = config.interval, + angleFilter = config.angleFilter + ) + + if (!distanceFilterProcessor.shouldAccept(location, filterConfig)) { + logEvent("FILTERED", "Location filtered by distance/interval/angle") + return + } + + val traccarLocation = TraccarLocation( + timestamp = location.time, + latitude = location.latitude, + longitude = location.longitude, + accuracy = if (location.hasAccuracy()) location.accuracy else null, + speed = if (location.hasSpeed()) location.speed else null, + heading = if (location.hasBearing()) location.bearing else null, + altitude = if (location.hasAltitude()) location.altitude else null, + isMoving = location.speed > 1.0f // Consider moving if speed > 1 m/s + ) + + logEvent("LOCATION", "Lat: ${location.latitude}, Lon: ${location.longitude}, Speed: ${location.speed}") + + updateNotification(location) + + if (isNetworkAvailable) { + sendLocationToServer(traccarLocation) + } else { + bufferLocation(traccarLocation) + } + } + + private fun onHeartbeat() { + logEvent("HEARTBEAT", "Heartbeat fired") + fusedLocationProvider.getLastLocation { location -> + location?.let { onLocationReceived(it) } + } + } + + private fun sendLocationToServer(location: TraccarLocation) { + val config = currentConfig ?: return + val traccarConfig = TraccarConfig( + serverUrl = config.serverUrl, + deviceId = config.deviceId, + password = config.password + ) + + serviceScope.launch { + val result = httpClient.sendLocation(traccarConfig, location) + if (result.isSuccess) { + logEvent("SYNC", "Location sent to server") + } else { + logEvent("ERROR", "Failed to send location: ${result.exceptionOrNull()?.message}") + bufferLocation(location) + } + } + } + + private fun bufferLocation(location: TraccarLocation) { + serviceScope.launch { + val entity = LocationEntity( + timestamp = location.timestamp, + latitude = location.latitude, + longitude = location.longitude, + accuracy = location.accuracy, + speed = location.speed, + heading = location.heading, + altitude = location.altitude, + isMoving = location.isMoving, + synced = false + ) + database.locationDao().insert(entity) + } + } + + private fun syncBufferedLocations() { + val config = currentConfig ?: return + val traccarConfig = TraccarConfig( + serverUrl = config.serverUrl, + deviceId = config.deviceId, + password = config.password + ) + + serviceScope.launch { + val unsyncedLocations = database.locationDao().getUnsyncedLocations() + if (unsyncedLocations.isEmpty()) return@launch + + val locations = unsyncedLocations.map { entity -> + TraccarLocation( + timestamp = entity.timestamp, + latitude = entity.latitude, + longitude = entity.longitude, + accuracy = entity.accuracy, + speed = entity.speed, + heading = entity.heading, + altitude = entity.altitude, + isMoving = entity.isMoving + ) + } + + val result = httpClient.sendLocations(traccarConfig, locations) + if (result.isSuccess) { + val ids = unsyncedLocations.map { it.id } + database.locationDao().markAllAsSynced(ids) + logEvent("SYNC", "Synced ${result.getOrNull()} buffered locations") + } + } + } + + private fun logEvent(eventType: String, message: String) { + serviceScope.launch { + val entry = EventLogEntity( + timestamp = System.currentTimeMillis(), + eventType = eventType, + message = message + ) + database.eventLogDao().insert(entry) + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Traccar Tracking", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Location tracking notification" + setShowBadge(false) + } + + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + } + + private fun createNotification(content: String): Notification { + val pendingIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("📍 Traccar Client") + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + private fun updateNotification(location: Location) { + val content = buildString { + append("Lat: %.4f Lon: %.4f".format(location.latitude, location.longitude)) + if (location.hasSpeed()) { + append("\nSpeed: %.0f km/h".format(location.speed * 3.6)) + } + val time = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()) + .format(java.util.Date(location.time)) + append("\nLast: $time") + } + + val notification = createNotification(content) + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.notify(NOTIFICATION_ID, notification) + } + + private fun acquireWakeLock() { + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "TraccarClient::LocationWakeLock" + ).apply { + acquire(10 * 60 * 60 * 1000L) // 10 hours max + } + } + + private fun releaseWakeLock() { + wakeLock?.let { + if (it.isHeld) it.release() + wakeLock = null + } + } + + companion object { + const val ACTION_START = "com.traccar.traccar_client.ACTION_START" + const val ACTION_STOP = "com.traccar.traccar_client.ACTION_STOP" + private const val CHANNEL_ID = "traccar_tracking_channel" + private const val NOTIFICATION_ID = 1 + } +} + +data class TrackingConfig( + val serverUrl: String = "https://demo.traccar.org", + val deviceId: String = "", + val password: String = "", + val accuracy: Int = 0, // 0=HIGH, 1=HIGH_ACCURACY, 2=BALANCED, 3=LOW + val interval: Int = 300, + val fastestInterval: Int = 30, + val distanceFilter: Int = 75, + val angleFilter: Int = 0, + val heartbeat: Int = 60, + val offlineBuffer: Boolean = true, + val stopDetection: Boolean = true +)