feat: add LocationTrackingService foreground service
This commit is contained in:
parent
8ba3a017cd
commit
724fbe1bdb
1 changed files with 350 additions and 0 deletions
|
|
@ -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
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue