From 7bba4dc8788e3ed3c24b84d4ad4cf9bafa77a6de Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 11 Jul 2026 00:27:52 +0200 Subject: [PATCH] fix: stop orphaned GPS synchronization --- .../pl/firmatpp/kierowca/DriverApplication.kt | 11 ++++ .../data/upload/OfflineOutboxManager.kt | 24 +++++++- .../kierowca/data/upload/RoutePointDao.kt | 3 + .../kierowca/data/upload/RoutePointOutbox.kt | 2 +- .../kierowca/data/upload/RoutePointWorker.kt | 57 ++++++++++++------- .../tracking/ActiveRouteTrackingService.kt | 54 +++++++++++++----- .../firmatpp/kierowca/ui/DriverViewModel.kt | 57 ++++++++++++++++++- .../data/upload/RoutePointWorkerRulesTest.kt | 18 ++++++ .../firmatpp/kierowca/ui/DriverUiStateTest.kt | 39 +++++++++++++ 9 files changed, 225 insertions(+), 40 deletions(-) create mode 100644 app/src/test/java/pl/firmatpp/kierowca/data/upload/RoutePointWorkerRulesTest.kt diff --git a/app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt b/app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt index 0457716..533f342 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt @@ -1,12 +1,23 @@ package pl.firmatpp.kierowca import android.app.Application +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import pl.firmatpp.kierowca.data.upload.OfflineOutboxManager import pl.firmatpp.kierowca.diagnostics.AppDiagnostics class DriverApplication : Application() { + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + override fun onCreate() { super.onCreate() AppDiagnostics.installFirebaseCrashlytics() AppDiagnostics.log("app_started") + applicationScope.launch { + runCatching { OfflineOutboxManager(this@DriverApplication).repairGpsQueue() } + .onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") } + } } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManager.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManager.kt index ac87f8a..1d61d84 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManager.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManager.kt @@ -3,8 +3,9 @@ package pl.firmatpp.kierowca.data.upload import android.content.Context import androidx.work.WorkManager import java.io.File +import java.util.concurrent.TimeUnit -class OfflineOutboxManager(context: Context) { +class OfflineOutboxManager(private val context: Context) { private val database = DriverDatabase.get(context) private val workManager = WorkManager.getInstance(context) @@ -14,6 +15,27 @@ class OfflineOutboxManager(context: Context) { database.routeActionDao().allUnsent().size + database.routePointDao().allUnsent().size + suspend fun repairGpsQueue() { + val retryableStatuses = setOf( + RoutePointStatus.Pending, + RoutePointStatus.Syncing, + RoutePointStatus.FailedRetryable, + ) + val routeIds = database.routePointDao().allUnsent() + .filter { it.status in retryableStatuses } + .map { it.routeId } + .distinct() + + routeIds.forEach { routeId -> + runCatching { + workManager.cancelUniqueWork(RoutePointWorker.uniqueWorkName(routeId)) + .result + .get(5, TimeUnit.SECONDS) + } + RoutePointOutbox(context).enqueueWorker(routeId, delaySeconds = 0L) + } + } + suspend fun clearAll() { val photos = database.photoUploadDao().allRecords() val dispatchSheets = database.dispatchSheetUploadDao().allRecords() diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointDao.kt index 1730b6e..30e1d8b 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointDao.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointDao.kt @@ -37,6 +37,9 @@ interface RoutePointDao { @Query("SELECT COUNT(*) FROM route_points WHERE status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')") fun observeUnsentCount(): Flow + @Query("SELECT MIN(createdAtEpochMillis) FROM route_points WHERE status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')") + fun observeOldestUnsentAt(): Flow + @Query( """ UPDATE route_points diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointOutbox.kt index 2778f70..1193f04 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointOutbox.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointOutbox.kt @@ -54,7 +54,7 @@ class RoutePointOutbox( workManager.enqueueUniqueWork( RoutePointWorker.uniqueWorkName(routeId), - ExistingWorkPolicy.APPEND_OR_REPLACE, + ExistingWorkPolicy.KEEP, request, ) } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt index 87aa5c4..6af4026 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import java.util.UUID +import kotlinx.coroutines.delay import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.DriverRepository @@ -23,31 +24,34 @@ class RoutePointWorker( override suspend fun doWork(): Result { if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry() val routeId = inputData.getString(KEY_ROUTE_ID) ?: return Result.failure() - val points = dao.pendingForRoute(routeId, BATCH_LIMIT) - if (points.isEmpty()) return Result.success() + while (!isStopped) { + val points = dao.pendingForRoute(routeId, BATCH_LIMIT) + if (points.isEmpty()) return Result.success() - val pointIds = points.map { it.clientPointId } - dao.updateStatus(pointIds, RoutePointStatus.Syncing, null, attemptIncrement = 1) + val pointIds = points.map { it.clientPointId } + dao.updateStatus(pointIds, RoutePointStatus.Syncing, null, attemptIncrement = 1) - return runCatching { - val response = repository.storeRoutePoints( - routeId = routeId, - body = RoutePointBatchBody( - clientBatchId = UUID.randomUUID().toString(), - points = points.map { it.toDto() }, - ), - ) - dao.delete(pointIds) - - if (response.arrived) { - ActiveRouteTrackingService.markArrived(applicationContext, routeId) + val outcome = runCatching { + repository.storeRoutePoints( + routeId = routeId, + body = RoutePointBatchBody( + clientBatchId = UUID.randomUUID().toString(), + points = points.map { it.toDto() }, + ), + ) } - if (dao.pendingCount(routeId) > 0) { - RoutePointOutbox(applicationContext).enqueueWorker(routeId, delaySeconds = 1L) + val response = outcome.getOrNull() + if (response != null) { + dao.delete(pointIds) + if (response.arrived) ActiveRouteTrackingService.markArrived(applicationContext, routeId) + if (dao.pendingCount(routeId) == 0) { + delay(750L) + if (dao.pendingCount(routeId) == 0) return Result.success() + } + continue } - Result.success() - }.getOrElse { throwable -> + val throwable = outcome.exceptionOrNull() ?: return Result.retry() val error = ApiErrorMapper.map(throwable) AppDiagnostics.reportNonFatal( throwable = throwable, @@ -61,11 +65,18 @@ class RoutePointWorker( "retryable" to error.retryable, ), ) + if (isInactiveRoutePointError(error.code)) { + dao.delete(pointIds) + ActiveRouteTrackingService.stop(applicationContext, routeId) + continue + } + val status = if (error.retryable) RoutePointStatus.FailedRetryable else RoutePointStatus.FailedPermanent dao.updateStatus(pointIds, status, error.message, attemptIncrement = 0) - - if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure() + return if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure() } + + return Result.retry() } private fun RoutePointEntity.toDto(): RoutePointDto = @@ -90,3 +101,5 @@ class RoutePointWorker( fun uniqueWorkName(routeId: String): String = "route-point-sync-$routeId" } } + +internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS" diff --git a/app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt b/app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt index 4b52860..b048f80 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt @@ -56,6 +56,10 @@ class ActiveRouteTrackingService : Service(), LocationListener { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_STOP -> { + val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) + if (!requestedRouteId.isNullOrBlank() && routeId != requestedRouteId) { + return START_NOT_STICKY + } stopSelf() return START_NOT_STICKY } @@ -64,13 +68,25 @@ class ActiveRouteTrackingService : Service(), LocationListener { if (!id.isNullOrBlank() && (routeId == null || id == routeId)) { routeId = id arrived = true + runCatching { locationManager.removeUpdates(this) } startForegroundCompat(buildNotification()) } - return START_STICKY + return START_NOT_STICKY } else -> { - routeId = intent?.getStringExtra(EXTRA_ROUTE_ID) ?: routeId - driverId = intent?.getStringExtra(EXTRA_DRIVER_ID) ?: driverId + val nextRouteId = intent?.getStringExtra(EXTRA_ROUTE_ID) ?: routeId + val nextDriverId = intent?.getStringExtra(EXTRA_DRIVER_ID) ?: driverId + if (nextRouteId.isNullOrBlank() || nextDriverId.isNullOrBlank()) { + stopSelf() + return START_NOT_STICKY + } + val routeChanged = nextRouteId != routeId + routeId = nextRouteId + driverId = nextDriverId + getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(ACTIVE_ROUTE_ID, nextRouteId) + .apply() routeLabel = intent?.getStringExtra(EXTRA_ROUTE_LABEL) ?: routeLabel destinationLat = intent?.takeIf { it.hasExtra(EXTRA_DESTINATION_LAT) }?.getDoubleExtra(EXTRA_DESTINATION_LAT, 0.0) ?: destinationLat @@ -80,10 +96,10 @@ class ActiveRouteTrackingService : Service(), LocationListener { ?.takeIf { it > 0.0 } ?: totalDistanceMeters progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled - arrived = false + if (routeChanged) arrived = false startForegroundCompat(buildNotification()) - requestLocationUpdates() - return START_STICKY + if (!arrived) requestLocationUpdates() + return START_NOT_STICKY } } } @@ -96,6 +112,7 @@ class ActiveRouteTrackingService : Service(), LocationListener { if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) { arrived = true + runCatching { locationManager.removeUpdates(this) } startForegroundCompat(buildNotification()) } else { startForegroundCompat(buildNotification()) @@ -119,14 +136,19 @@ class ActiveRouteTrackingService : Service(), LocationListener { override fun onDestroy() { runCatching { locationManager.removeUpdates(this) } + getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .remove(ACTIVE_ROUTE_ID) + .apply() scope.cancel() super.onDestroy() } private fun requestLocationUpdates() { - if (!hasLocationPermission()) { + if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) { return } + runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, LOCATION_INTERVAL_MS, 0f, this) } @@ -266,6 +288,8 @@ class ActiveRouteTrackingService : Service(), LocationListener { private const val EXTRA_DESTINATION_LON = "destinationLon" private const val EXTRA_TOTAL_DISTANCE_METERS = "totalDistanceMeters" private const val EXTRA_PROGRESS_ENABLED = "progressEnabled" + private const val TRACKING_PREFERENCES = "active_route_tracking" + private const val ACTIVE_ROUTE_ID = "active_route_id" fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) { val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { @@ -281,12 +305,16 @@ class ActiveRouteTrackingService : Service(), LocationListener { ContextCompat.startForegroundService(context, intent) } - fun stop(context: Context) { - context.startService( - Intent(context, ActiveRouteTrackingService::class.java).apply { - action = ACTION_STOP - }, - ) + fun stop(context: Context, routeId: String? = null) { + val activeRouteId = context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE) + .getString(ACTIVE_ROUTE_ID, null) + if (routeId == null || routeId == activeRouteId) { + context.stopService(Intent(context, ActiveRouteTrackingService::class.java)) + context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .remove(ACTIVE_ROUTE_ID) + .apply() + } } fun markArrived(context: Context, routeId: String) { diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index bf0a1da..7115162 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -118,6 +118,7 @@ data class DriverUiState( val pendingLogoutItems: Int = 0, val pendingDispatchUploads: Int = 0, val pendingGpsPoints: Int = 0, + val oldestPendingGpsAtEpochMillis: Long? = null, val diagnostics: DiagnosticSnapshot? = null, val diagnosticsLoading: Boolean = false, val diagnosticsError: String? = null, @@ -138,13 +139,20 @@ data class DriverUiState( val displaySelectedRoute: DriverRouteDto? get() = selectedRouteProjection?.route - val pendingOperationalItems: Int + private val pendingUserOperationalItems: Int get() = queuedPhotoUploads.count { it.status != PhotoUploadStatus.Confirmed.storageValue && it.status != PhotoUploadStatus.Cancelled.storageValue } + - visibleRouteActions.count { it.status != RouteActionStatus.Confirmed } + pendingDispatchUploads + pendingGpsPoints + visibleRouteActions.count { it.status != RouteActionStatus.Confirmed } + pendingDispatchUploads + + val gpsQueueNeedsAttention: Boolean + get() = isOnline && oldestPendingGpsAtEpochMillis?.let { System.currentTimeMillis() - it >= 2 * 60 * 1000L } == true + + val pendingOperationalItems: Int + get() = pendingUserOperationalItems + if (!isOnline || serverConnectionState == ServerConnectionState.Degraded || gpsQueueNeedsAttention) pendingGpsPoints else 0 val operationalQueueNeedsAttention: Boolean get() = queuedPhotoUploads.any { it.status == PhotoUploadStatus.FailedPermanent.storageValue } || - visibleRouteActions.any { it.status == RouteActionStatus.FailedPermanent || it.status == RouteActionStatus.FailedConflict } + visibleRouteActions.any { it.status == RouteActionStatus.FailedPermanent || it.status == RouteActionStatus.FailedConflict } || + gpsQueueNeedsAttention } internal fun DriverUiState.withApiError(throwable: Throwable): DriverUiState { @@ -240,6 +248,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(pendingGpsPoints = count) } } } + viewModelScope.launch { + DriverDatabase.get(application).routePointDao().observeOldestUnsentAt().collect { oldest -> + _state.update { it.copy(oldestPendingGpsAtEpochMillis = oldest) } + } + } viewModelScope.launch { liveSyncClient.connectionState.collect { connectionState -> DriverRuntimeSyncState.realtimeConnected = connectionState == LiveSyncConnectionState.Connected @@ -405,6 +418,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId) } observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate) + if (_state.value.isOnline && !cached.stale && settings?.selectedDate == settings?.today) { + reconcileTrackingService(response.routes.today, response.session.driver) + } }.onFailure { throwable -> if (generation != routesRequestGeneration) return@onFailure reportHandledException("load_routes", throwable, mapOf("date" to date)) @@ -457,6 +473,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } observePhotoUploads(routeId) observeRouteActions(routeId) + if (_state.value.isOnline && !cached.stale) { + reconcileTrackingServiceForRoute(response.route, _state.value.driver) + } } fun refreshSelectedRoute() { @@ -484,6 +503,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) error = null, ) } + if (_state.value.isOnline && !cached.stale) { + reconcileTrackingServiceForRoute(response.route, _state.value.driver) + } } .onFailure { throwable -> if (generation == routeDetailRequestGeneration) _state.update { it.withApiError(throwable) } @@ -1389,6 +1411,35 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } + private fun reconcileTrackingService(routes: List, driver: DriverDto) { + val activeRoute = routes.firstOrNull { it.status == "W TRAKCIE" || it.trackingStatus == "active" } + if (activeRoute == null) { + ActiveRouteTrackingService.stop(getApplication()) + } else { + ActiveRouteTrackingService.start( + context = getApplication(), + route = activeRoute, + driver = driver, + progressEnabled = _state.value.routeProgressNotificationEnabled, + ) + } + } + + private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) { + if (route.status == "W TRAKCIE" || route.trackingStatus == "active") { + if (driver != null) { + ActiveRouteTrackingService.start( + context = getApplication(), + route = route, + driver = driver, + progressEnabled = _state.value.routeProgressNotificationEnabled, + ) + } + } else { + ActiveRouteTrackingService.stop(getApplication(), route.id) + } + } + private fun observeRouteActions(routeId: String) { routeActionsJob?.cancel() routeActionsJob = viewModelScope.launch { diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/upload/RoutePointWorkerRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/upload/RoutePointWorkerRulesTest.kt new file mode 100644 index 0000000..8aa5272 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/upload/RoutePointWorkerRulesTest.kt @@ -0,0 +1,18 @@ +package pl.firmatpp.kierowca.data.upload + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RoutePointWorkerRulesTest { + @Test + fun inactiveRouteConflictDiscardsObsoleteGpsPoints() { + assertTrue(isInactiveRoutePointError("ROUTE_POINTS_INVALID_STATUS")) + } + + @Test + fun temporaryServerFailureDoesNotDiscardGpsPoints() { + assertFalse(isInactiveRoutePointError("SERVER_UNAVAILABLE")) + assertFalse(isInactiveRoutePointError(null)) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt index cdbdc97..6ae3b08 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt @@ -1,6 +1,7 @@ package pl.firmatpp.kierowca.ui import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.io.IOException @@ -130,6 +131,44 @@ class DriverUiStateTest { assertEquals(listOf("above-first", "above-second", "span"), state.displayRoutes.map { it.id }) } + @Test + fun freshGpsPointsDoNotShowSynchronizationBannerWhenOnlineIsHealthy() { + val state = DriverUiState( + isOnline = true, + serverConnectionState = ServerConnectionState.Reachable, + pendingGpsPoints = 48, + oldestPendingGpsAtEpochMillis = System.currentTimeMillis() - 30_000L, + ) + + assertEquals(0, state.pendingOperationalItems) + assertFalse(state.operationalQueueNeedsAttention) + } + + @Test + fun staleGpsBacklogRequiresAttentionWhenOnline() { + val state = DriverUiState( + isOnline = true, + serverConnectionState = ServerConnectionState.Reachable, + pendingGpsPoints = 48, + oldestPendingGpsAtEpochMillis = System.currentTimeMillis() - 3 * 60_000L, + ) + + assertEquals(48, state.pendingOperationalItems) + assertTrue(state.operationalQueueNeedsAttention) + } + + @Test + fun offlineGpsPointsAreShownAsSavedLocallyNotAsFailure() { + val state = DriverUiState( + isOnline = false, + pendingGpsPoints = 48, + oldestPendingGpsAtEpochMillis = System.currentTimeMillis() - 3 * 60_000L, + ) + + assertEquals(48, state.pendingOperationalItems) + assertFalse(state.operationalQueueNeedsAttention) + } + private fun leaveRequest(id: String, status: String, version: Long = 1): DriverLeaveRequestDto = DriverLeaveRequestDto( id = id,