diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 95ad52a..402bfbb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,8 +34,8 @@ android { applicationId = "pl.firmatpp.kierowca" minSdk = 26 targetSdk = 35 - versionCode = 104 - versionName = "1.0.51" + versionCode = 105 + versionName = "1.0.52" setProperty("archivesBaseName", "pl.firmatpp.kierowca") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt index ef3f996..5bdc777 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -198,9 +198,9 @@ class DriverRepository( api.deletePhoto(authHeader(requireToken()), photoId) } - suspend fun logout() { + suspend fun logout(notifyServer: Boolean = true) { val token = tokenStore.read() - if (token != null) { + if (token != null && notifyServer) { runCatching { api.deletePushToken(authHeader(token)) } runCatching { api.logout(authHeader(token)) } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index a88412d..18cd4e9 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -62,6 +62,10 @@ data class DriverAppSettingsDto( val allowGalleryUploads: Boolean?, val allowRouteCompletion: Boolean?, val requirePreciseLocationForPhotos: Boolean?, + val loadingPhotoRequirement: String? = null, + val loadingWeightRequirement: String? = null, + val unloadingPhotoRequirement: String? = null, + val unloadingWeightRequirement: String? = null, val dispatchSheetRemindersEnabled: Boolean? = null, val dispatchSheetOnFridays: Boolean? = null, val dispatchSheetOnLastWorkingDay: Boolean? = null, @@ -328,14 +332,14 @@ data class PhotoUploadReceiptDto( data class StartRouteBody( val clientActionId: String, - val loadingWeight: Double, + val loadingWeight: Double?, val occurredAt: String, val photoClientRequestIds: List, ) data class FinishRouteBody( val clientActionId: String, - val unloadingWeight: Double, + val unloadingWeight: Double?, val occurredAt: String, val photoClientRequestIds: List, ) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheDao.kt index 6cb2a58..1258ef3 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheDao.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheDao.kt @@ -13,18 +13,27 @@ interface DriverCacheDao { @Query("SELECT * FROM driver_bootstrap_cache WHERE date = :date LIMIT 1") suspend fun bootstrap(date: String): DriverBootstrapCacheEntity? + @Query("SELECT * FROM driver_bootstrap_cache ORDER BY syncedAtEpochMillis DESC") + suspend fun allBootstrapCaches(): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertRoute(cache: DriverRouteCacheEntity) @Query("SELECT * FROM driver_route_cache WHERE routeId = :routeId LIMIT 1") suspend fun route(routeId: String): DriverRouteCacheEntity? + @Query("SELECT * FROM driver_route_cache ORDER BY syncedAtEpochMillis DESC") + suspend fun allRouteCaches(): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertSyncState(state: DriverSyncStateEntity) @Query("SELECT * FROM driver_sync_states WHERE scope = :scope AND ifnull(date, '-') = ifnull(:date, '-') AND ifnull(routeId, '-') = ifnull(:routeId, '-') LIMIT 1") suspend fun syncState(scope: String, date: String?, routeId: String?): DriverSyncStateEntity? + @Query("SELECT * FROM driver_sync_states ORDER BY syncedAtEpochMillis DESC") + suspend fun allSyncStates(): List + @Query("DELETE FROM driver_bootstrap_cache") suspend fun clearBootstrap() diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt index d4dd2b7..918b904 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt @@ -61,6 +61,24 @@ class DriverSyncRepository( cachedRoute(routeId, throwable) } + suspend fun bootstrapFromCache(date: String?): CachedValue? { + val cached = dao.bootstrap(date ?: currentDateFallback()) ?: return null + return CachedValue( + value = gson.fromJson(cached.payloadJson, BootstrapResponse::class.java), + stale = true, + syncedAtEpochMillis = cached.syncedAtEpochMillis, + ) + } + + suspend fun routeFromCache(routeId: String): CachedValue? { + val cached = dao.route(routeId) ?: return null + return CachedValue( + value = gson.fromJson(cached.payloadJson, RouteResponse::class.java), + stale = true, + syncedAtEpochMillis = cached.syncedAtEpochMillis, + ) + } + suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) { val syncedAt = System.currentTimeMillis() dao.upsertRoute( @@ -148,7 +166,7 @@ class DriverSyncRepository( } private fun Throwable.isReadFallbackAllowed(): Boolean = - this is IOException || (this is HttpException && code() != 401 && code() != 403) + this is IOException || (this is HttpException && code() in 500..599) private fun currentDateFallback(): String = java.time.LocalDate.now().toString() @@ -171,5 +189,6 @@ class DriverSyncRepository( const val SCOPE_DISPATCH_SHEET = "dispatch_sheet" const val SCOPE_LEAVE_REQUESTS = "leave_requests" const val SCOPE_LEAVE_CALENDAR = "leave_calendar" + const val SCOPE_SESSION = "session" } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/sync/NetworkMonitor.kt b/app/src/main/java/pl/firmatpp/kierowca/data/sync/NetworkMonitor.kt index c7b19fd..fdb2a17 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/sync/NetworkMonitor.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/NetworkMonitor.kt @@ -13,9 +13,7 @@ class NetworkMonitor(context: Context) { private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val isOnline: Flow = callbackFlow { - fun current(): Boolean = connectivityManager.activeNetwork - ?.let(connectivityManager::getNetworkCapabilities) - ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + fun current(): Boolean = isCurrentlyValidated() val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { @@ -35,4 +33,17 @@ class NetworkMonitor(context: Context) { connectivityManager.registerDefaultNetworkCallback(callback) awaitClose { connectivityManager.unregisterNetworkCallback(callback) } }.distinctUntilChanged() + + fun isCurrentlyValidated(): Boolean = connectivityManager.activeNetwork + ?.let(connectivityManager::getNetworkCapabilities) + ?.let(::hasValidatedInternet) == true } + +internal fun hasValidatedInternet(capabilities: NetworkCapabilities): Boolean = + hasValidatedInternetCapabilities( + hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET), + hasValidated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED), + ) + +internal fun hasValidatedInternetCapabilities(hasInternet: Boolean, hasValidated: Boolean): Boolean = + hasInternet && hasValidated diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadDao.kt index 9094467..e3f7236 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadDao.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadDao.kt @@ -14,6 +14,15 @@ interface DispatchSheetUploadDao { @Query("SELECT * FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId LIMIT 1") suspend fun find(clientRequestId: String): DispatchSheetUploadEntity? + @Query("SELECT * FROM dispatch_sheet_uploads WHERE status != 'CONFIRMED' AND status != 'CANCELLED'") + suspend fun allUnsent(): List + + @Query("SELECT * FROM dispatch_sheet_uploads") + suspend fun allRecords(): List + + @Query("SELECT COUNT(*) FROM dispatch_sheet_uploads WHERE status != 'CONFIRMED' AND status != 'CANCELLED'") + fun observeUnsentCount(): Flow + @Query( """ SELECT * FROM dispatch_sheet_uploads @@ -63,4 +72,7 @@ interface DispatchSheetUploadDao { @Query("DELETE FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId") suspend fun delete(clientRequestId: String) + + @Query("DELETE FROM dispatch_sheet_uploads") + suspend fun deleteAll() } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt index d271d9f..a22201e 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt @@ -9,6 +9,7 @@ import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.diagnostics.AppDiagnostics +import pl.firmatpp.kierowca.data.sync.NetworkMonitor class DispatchSheetUploadWorker( appContext: Context, @@ -18,6 +19,7 @@ class DispatchSheetUploadWorker( private val repository = DriverRepository(appContext) override suspend fun doWork(): Result { + if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry() val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure() val upload = dao.find(clientRequestId) ?: return Result.failure() val file = File(upload.localPath) @@ -62,6 +64,8 @@ class DispatchSheetUploadWorker( } dao.markConfirmed(clientRequestId, receipt.serverPhotoId) + file.delete() + dao.delete(clientRequestId) Result.success() }.getOrElse { throwable -> val error = ApiErrorMapper.map(throwable) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt index 96084e3..efe277c 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt @@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity DriverRouteCacheEntity::class, DriverSyncStateEntity::class, ], - version = 5, + version = 6, exportSchema = false, ) abstract class DriverDatabase : RoomDatabase() { @@ -164,13 +164,51 @@ abstract class DriverDatabase : RoomDatabase() { } } + private val migration5To6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE route_actions_new ( + clientActionId TEXT NOT NULL PRIMARY KEY, + routeId TEXT NOT NULL, + action TEXT NOT NULL, + weight REAL, + occurredAt TEXT NOT NULL, + photoClientRequestIdsJson TEXT NOT NULL, + status TEXT NOT NULL, + attemptCount INTEGER NOT NULL, + lastError TEXT, + createdAtEpochMillis INTEGER NOT NULL, + updatedAtEpochMillis INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO route_actions_new ( + clientActionId, routeId, action, weight, occurredAt, photoClientRequestIdsJson, + status, attemptCount, lastError, createdAtEpochMillis, updatedAtEpochMillis + ) + SELECT + clientActionId, routeId, action, weight, occurredAt, photoClientRequestIdsJson, + status, attemptCount, lastError, createdAtEpochMillis, updatedAtEpochMillis + FROM route_actions + """.trimIndent(), + ) + db.execSQL("DROP TABLE route_actions") + db.execSQL("ALTER TABLE route_actions_new RENAME TO route_actions") + db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_routeId ON route_actions(routeId)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_status ON route_actions(status)") + } + } + fun get(context: Context): DriverDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, DriverDatabase::class.java, "driver-local-outbox.db", - ).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5).build().also { instance = it } + ).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5, migration5To6).build().also { instance = it } } } } 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 new file mode 100644 index 0000000..ac87f8a --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManager.kt @@ -0,0 +1,41 @@ +package pl.firmatpp.kierowca.data.upload + +import android.content.Context +import androidx.work.WorkManager +import java.io.File + +class OfflineOutboxManager(context: Context) { + private val database = DriverDatabase.get(context) + private val workManager = WorkManager.getInstance(context) + + suspend fun pendingCount(): Int = + database.photoUploadDao().allUnsent().size + + database.dispatchSheetUploadDao().allUnsent().size + + database.routeActionDao().allUnsent().size + + database.routePointDao().allUnsent().size + + suspend fun clearAll() { + val photos = database.photoUploadDao().allRecords() + val dispatchSheets = database.dispatchSheetUploadDao().allRecords() + val actions = database.routeActionDao().allUnsent() + val points = database.routePointDao().allUnsent() + + photos.forEach { + workManager.cancelUniqueWork(PhotoUploadWorker.uniqueWorkName(it.clientRequestId)) + File(it.localPath).delete() + } + dispatchSheets.forEach { + workManager.cancelUniqueWork(DispatchSheetUploadWorker.uniqueWorkName(it.clientRequestId)) + File(it.localPath).delete() + } + actions.forEach { workManager.cancelUniqueWork(RouteActionWorker.uniqueWorkName(it.clientActionId)) } + points.map { it.routeId }.distinct().forEach { + workManager.cancelUniqueWork(RoutePointWorker.uniqueWorkName(it)) + } + + database.photoUploadDao().deleteAll() + database.dispatchSheetUploadDao().deleteAll() + database.routeActionDao().deleteAll() + database.routePointDao().deleteAll() + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadDao.kt index e2ab4cc..408f9c5 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadDao.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadDao.kt @@ -43,6 +43,12 @@ interface PhotoUploadDao { ) suspend fun confirmedForRoute(routeId: String): List + @Query("SELECT * FROM photo_uploads WHERE status != 'CONFIRMED' AND status != 'CANCELLED'") + suspend fun allUnsent(): List + + @Query("SELECT * FROM photo_uploads") + suspend fun allRecords(): List + @Query( """ UPDATE photo_uploads @@ -82,4 +88,7 @@ interface PhotoUploadDao { @Query("DELETE FROM photo_uploads WHERE clientRequestId = :clientRequestId") suspend fun delete(clientRequestId: String) + + @Query("DELETE FROM photo_uploads") + suspend fun deleteAll() } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt index 92178db..cd67801 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt @@ -9,6 +9,7 @@ import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.diagnostics.AppDiagnostics +import pl.firmatpp.kierowca.data.sync.NetworkMonitor class PhotoUploadWorker( appContext: Context, @@ -21,6 +22,7 @@ class PhotoUploadWorker( private val repository = DriverRepository(appContext) override suspend fun doWork(): Result { + if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry() val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure() val upload = dao.find(clientRequestId) ?: return Result.failure() val file = File(upload.localPath) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt index b0cbf41..2f065e1 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt @@ -14,6 +14,18 @@ interface RouteActionDao { @Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1") suspend fun find(clientActionId: String): RouteActionEntity? + @Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'") + suspend fun allUnsent(): List + + @Query("DELETE FROM route_actions WHERE clientActionId = :clientActionId") + suspend fun delete(clientActionId: String) + + @Query("DELETE FROM route_actions") + suspend fun deleteAll() + + @Query("DELETE FROM route_actions WHERE status = 'CONFIRMED' AND updatedAtEpochMillis < :beforeEpochMillis") + suspend fun pruneConfirmed(beforeEpochMillis: Long) + @Query("SELECT * FROM route_actions WHERE status = 'WAITING_FOR_PHOTOS'") suspend fun waitingForPhotosActions(): List diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt index 8d92547..41906fc 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt @@ -30,7 +30,7 @@ data class RouteActionEntity( @PrimaryKey val clientActionId: String, val routeId: String, val action: String, - val weight: Double, + val weight: Double?, val occurredAt: String, val photoClientRequestIdsJson: String, val status: String = RouteActionStatus.Pending, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt index 305ea0d..a0a6208 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt @@ -26,18 +26,24 @@ class RouteActionOutbox( fun observeVisibleActions(): Flow> = dao.observeVisibleActions() - suspend fun enqueueStart(routeId: String, loadingWeight: Double, photoClientRequestIds: List): RouteActionEntity = + suspend fun discard(clientActionId: String) { + workManager.cancelUniqueWork(RouteActionWorker.uniqueWorkName(clientActionId)) + dao.delete(clientActionId) + } + + suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List): RouteActionEntity = enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds) - suspend fun enqueueFinish(routeId: String, unloadingWeight: Double, photoClientRequestIds: List): RouteActionEntity = + suspend fun enqueueFinish(routeId: String, unloadingWeight: Double?, photoClientRequestIds: List): RouteActionEntity = enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds) private suspend fun enqueue( action: String, routeId: String, - weight: Double, + weight: Double?, photoClientRequestIds: List, ): RouteActionEntity { + dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)) val entity = RouteActionEntity( clientActionId = UUID.randomUUID().toString(), routeId = routeId, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt index 3df1858..61b7eb4 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt @@ -11,6 +11,7 @@ import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.RouteActionResponse import pl.firmatpp.kierowca.data.model.StartRouteBody import pl.firmatpp.kierowca.data.sync.DriverSyncRepository +import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.sync.DriverSyncWorker @@ -26,13 +27,24 @@ class RouteActionWorker( private val gson = Gson() override suspend fun doWork(): Result { + if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry() val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure() val action = dao.find(clientActionId) ?: return Result.failure() val photoClientRequestIds = photoClientRequestIds(action) - val waitingForPhotos = photoClientRequestIds - .mapNotNull { photoDao.find(it) } - .any { it.status != PhotoUploadStatus.Confirmed.storageValue } + val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) } + val permanentlyFailedPhoto = photoDependencies.firstOrNull { it.status == PhotoUploadStatus.FailedPermanent.storageValue } + if (permanentlyFailedPhoto != null) { + dao.updateStatus( + clientActionId = clientActionId, + status = RouteActionStatus.FailedPermanent, + lastError = "Wymagane zdjęcie nie zostało wysłane. Popraw zdjęcie i ponów potwierdzenie etapu.", + attemptIncrement = 0, + ) + return Result.failure() + } + + val waitingForPhotos = photoDependencies.any { it.status != PhotoUploadStatus.Confirmed.storageValue } if (waitingForPhotos) { dao.updateStatus( 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 1a5d1e3..1730b6e 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 @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import kotlinx.coroutines.flow.Flow @Dao interface RoutePointDao { @@ -14,7 +15,7 @@ interface RoutePointDao { """ SELECT * FROM route_points WHERE routeId = :routeId - AND status != 'CONFIRMED' + AND status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE') ORDER BY timestamp ASC LIMIT :limit """, @@ -25,11 +26,17 @@ interface RoutePointDao { """ SELECT COUNT(*) FROM route_points WHERE routeId = :routeId - AND status != 'CONFIRMED' + AND status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE') """, ) suspend fun pendingCount(routeId: String): Int + @Query("SELECT * FROM route_points WHERE status != 'CONFIRMED'") + suspend fun allUnsent(): List + + @Query("SELECT COUNT(*) FROM route_points WHERE status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')") + fun observeUnsentCount(): Flow + @Query( """ UPDATE route_points @@ -47,4 +54,10 @@ interface RoutePointDao { attemptIncrement: Int, updatedAt: Long = System.currentTimeMillis(), ) + + @Query("DELETE FROM route_points WHERE clientPointId IN (:clientPointIds)") + suspend fun delete(clientPointIds: List) + + @Query("DELETE FROM route_points") + suspend fun deleteAll() } 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 1193f04..2778f70 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.KEEP, + ExistingWorkPolicy.APPEND_OR_REPLACE, 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 6fe36d4..87aa5c4 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 @@ -10,6 +10,7 @@ import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.model.RoutePointBatchBody import pl.firmatpp.kierowca.data.model.RoutePointDto import pl.firmatpp.kierowca.diagnostics.AppDiagnostics +import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService class RoutePointWorker( @@ -20,6 +21,7 @@ class RoutePointWorker( private val repository = DriverRepository(appContext) 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() @@ -35,7 +37,7 @@ class RoutePointWorker( points = points.map { it.toDto() }, ), ) - dao.updateStatus(pointIds, RoutePointStatus.Confirmed, null, attemptIncrement = 0) + dao.delete(pointIds) if (response.arrived) { ActiveRouteTrackingService.markArrived(applicationContext, routeId) diff --git a/app/src/main/java/pl/firmatpp/kierowca/diagnostics/DiagnosticSnapshotProvider.kt b/app/src/main/java/pl/firmatpp/kierowca/diagnostics/DiagnosticSnapshotProvider.kt new file mode 100644 index 0000000..6c88b58 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/diagnostics/DiagnosticSnapshotProvider.kt @@ -0,0 +1,349 @@ +package pl.firmatpp.kierowca.diagnostics + +import android.Manifest +import android.app.ActivityManager +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.os.BatteryManager +import android.os.Environment +import android.os.PowerManager +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import com.google.firebase.FirebaseApp +import com.google.firebase.messaging.FirebaseMessaging +import java.io.File +import java.security.MessageDigest +import java.time.Instant +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import pl.firmatpp.kierowca.BuildConfig +import pl.firmatpp.kierowca.data.DeviceInfoProvider +import pl.firmatpp.kierowca.data.TokenStore +import pl.firmatpp.kierowca.data.upload.DriverDatabase + +data class DiagnosticEntry( + val label: String, + val value: String, + val severity: DiagnosticSeverity = DiagnosticSeverity.Neutral, +) + +enum class DiagnosticSeverity { Neutral, Good, Warning, Error } + +data class DiagnosticSection( + val title: String, + val entries: List, +) + +data class DiagnosticSnapshot( + val generatedAtEpochMillis: Long, + val sections: List, +) { + fun asPlainText(): String = buildString { + appendLine("TPP Kierowca, raport diagnostyczny") + appendLine("Wygenerowano: ${Instant.ofEpochMilli(generatedAtEpochMillis)}") + sections.forEach { section -> + appendLine() + appendLine("[${section.title}]") + section.entries.forEach { entry -> appendLine("${entry.label}: ${entry.value}") } + } + appendLine() + append("Tokeny są celowo przedstawione jako fingerprinty, raport nie zawiera danych pozwalających przejąć sesję.") + } +} + +class DiagnosticSnapshotProvider(private val context: Context) { + private val database = DriverDatabase.get(context) + private val tokenStore = TokenStore(context) + + suspend fun capture(runtimeSections: List): DiagnosticSnapshot = withContext(Dispatchers.IO) { + val token = tokenStore.read() + val fcmToken = withTimeoutOrNull(5_000L) { readFirebaseToken() } + ?: Result.failure(IllegalStateException("timeout po 5 s")) + val device = DeviceInfoProvider(context, tokenStore).currentDeviceInfo() + val network = currentNetworkEntries() + val photos = database.photoUploadDao().allRecords() + val dispatchSheets = database.dispatchSheetUploadDao().allRecords() + val actions = database.routeActionDao().allUnsent() + val points = database.routePointDao().allUnsent() + val cacheDao = database.driverCacheDao() + val bootstrapCaches = cacheDao.allBootstrapCaches() + val routeCaches = cacheDao.allRouteCaches() + val syncStates = cacheDao.allSyncStates() + val queuedFiles = (photos.map { it.localPath } + dispatchSheets.map { it.localPath }).map(::File) + val activeWork = activeWorkInfo() + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val firebaseOptions = FirebaseApp.getApps(context).firstOrNull()?.options + val databaseFile = context.getDatabasePath("driver-local-outbox.db") + + val sections = buildList { + add( + DiagnosticSection( + "Aplikacja", + listOf( + DiagnosticEntry("Pakiet", BuildConfig.APPLICATION_ID), + DiagnosticEntry("Wersja", "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"), + DiagnosticEntry("Typ buildu", BuildConfig.BUILD_TYPE), + DiagnosticEntry("Debug", yesNo(BuildConfig.DEBUG)), + DiagnosticEntry("API", BuildConfig.API_BASE_URL), + DiagnosticEntry("Reverb URL", BuildConfig.REVERB_WS_BASE_URL), + DiagnosticEntry("Reverb app key", secureIdentifierSummary(BuildConfig.REVERB_APP_KEY)), + DiagnosticEntry("Firebase App ID", firebaseOptions?.applicationId ?: "brak"), + DiagnosticEntry("Firebase Project ID", firebaseOptions?.projectId ?: "brak"), + DiagnosticEntry("Firebase Sender ID", firebaseOptions?.gcmSenderId ?: "brak"), + DiagnosticEntry("Pierwsza instalacja", Instant.ofEpochMilli(packageInfo.firstInstallTime).toString()), + DiagnosticEntry("Ostatnia aktualizacja", Instant.ofEpochMilli(packageInfo.lastUpdateTime).toString()), + ), + ), + ) + add( + DiagnosticSection( + "Sesja i identyfikatory", + listOf( + DiagnosticEntry("Token sesji", secureIdentifierSummary(token), if (token == null) DiagnosticSeverity.Error else DiagnosticSeverity.Good), + DiagnosticEntry("Token FCM", fcmToken.fold(::secureIdentifierSummary) { "Błąd odczytu: ${it.message ?: it::class.java.simpleName}" }), + DiagnosticEntry("Device ID", secureIdentifierSummary(device.deviceId)), + DiagnosticEntry("Android ID", secureIdentifierSummary(device.androidId)), + DiagnosticEntry("Numer seryjny", secureIdentifierSummary(device.serialNumber)), + ), + ), + ) + add( + DiagnosticSection( + "Urządzenie", + listOf( + DiagnosticEntry("Producent", device.manufacturer.orEmpty().ifBlank { "brak" }), + DiagnosticEntry("Marka / model", listOfNotNull(device.brand, device.model).joinToString(" ").ifBlank { "brak" }), + DiagnosticEntry("Device / product", listOfNotNull(device.device, device.product).joinToString(" / ").ifBlank { "brak" }), + DiagnosticEntry("Android", "${device.androidVersion ?: "?"}, API ${device.sdkInt}"), + DiagnosticEntry("Build fingerprint", Build.FINGERPRINT), + DiagnosticEntry("Sprzęt / bootloader", "${Build.HARDWARE} / ${Build.BOOTLOADER}"), + DiagnosticEntry("ABI", Build.SUPPORTED_ABIS.joinToString()), + DiagnosticEntry("Język", context.resources.configuration.locales.toLanguageTags().ifBlank { "brak" }), + DiagnosticEntry("Strefa czasowa", java.util.TimeZone.getDefault().id), + DiagnosticEntry("Pamięć aplikacji", storageSummary(context.filesDir)), + DiagnosticEntry("Pamięć współdzielona", storageSummary(Environment.getExternalStorageDirectory())), + DiagnosticEntry("RAM", memorySummary()), + DiagnosticEntry("Bateria", batterySummary()), + ), + ), + ) + add(DiagnosticSection("Sieć urządzenia", network)) + add( + DiagnosticSection( + "Uprawnienia i system", + listOf( + permissionEntry("Aparat", Manifest.permission.CAMERA), + permissionEntry("Lokalizacja dokładna", Manifest.permission.ACCESS_FINE_LOCATION), + permissionEntry("Lokalizacja przybliżona", Manifest.permission.ACCESS_COARSE_LOCATION), + notificationPermissionEntry(), + DiagnosticEntry("Powiadomienia systemowe", yesNo(NotificationManagerCompat.from(context).areNotificationsEnabled())), + DiagnosticEntry("Optymalizacja baterii wyłączona", yesNo(isIgnoringBatteryOptimizations())), + DiagnosticEntry("Kanały powiadomień", notificationChannelSummary()), + ), + ), + ) + addAll(runtimeSections) + add( + DiagnosticSection( + "WorkManager", + buildList { + add(DiagnosticEntry("Aktywne zadania", activeWork.size.toString(), if (activeWork.isEmpty()) DiagnosticSeverity.Good else DiagnosticSeverity.Warning)) + activeWork.forEach { work -> + add( + DiagnosticEntry( + work.tags.map { it.substringAfterLast('.') }.sorted().joinToString().ifBlank { work.id.toString() }, + "${work.state}, próba ${work.runAttemptCount}, id ${work.id}", + ), + ) + } + }, + ), + ) + add( + DiagnosticSection( + "Lokalna kolejka", + buildList { + add(DiagnosticEntry("Zdjęcia", statusSummary(photos.map { it.status }), queueSeverity(photos.map { it.status }))) + add(DiagnosticEntry("Karty spedycyjne", statusSummary(dispatchSheets.map { it.status }), queueSeverity(dispatchSheets.map { it.status }))) + add(DiagnosticEntry("Akcje kursu", statusSummary(actions.map { it.status }), queueSeverity(actions.map { it.status }))) + add(DiagnosticEntry("Punkty GPS", statusSummary(points.map { it.status }), queueSeverity(points.map { it.status }))) + add(DiagnosticEntry("Pliki lokalne", "${queuedFiles.count(File::exists)}/${queuedFiles.size} obecnych, ${formatBytes(queuedFiles.filter(File::exists).sumOf(File::length))}")) + add(DiagnosticEntry("Brakujące pliki", queuedFiles.filterNot(File::exists).joinToString { it.name }.ifBlank { "brak" }, if (queuedFiles.any { !it.exists() }) DiagnosticSeverity.Error else DiagnosticSeverity.Good)) + val errors = (photos.mapNotNull { it.lastError } + dispatchSheets.mapNotNull { it.lastError } + actions.mapNotNull { it.lastError } + points.mapNotNull { it.lastError }).distinct() + add(DiagnosticEntry("Błędy kolejki", errors.joinToString(" | ").ifBlank { "brak" }, if (errors.isEmpty()) DiagnosticSeverity.Good else DiagnosticSeverity.Warning)) + }, + ), + ) + add( + DiagnosticSection( + "Cache i rewizje", + buildList { + add(DiagnosticEntry("Wersja schematu Room", databaseSchemaVersion().toString())) + add(DiagnosticEntry("Plik bazy", "${if (databaseFile.exists()) formatBytes(databaseFile.length()) else "brak"}, ${databaseFile.absolutePath}")) + add(DiagnosticEntry("Snapshoty list tras", "${bootstrapCaches.size}: ${bootstrapCaches.joinToString { "${it.date}@v${it.version ?: "?"}" }.ifBlank { "brak" }}")) + add(DiagnosticEntry("Snapshoty tras", "${routeCaches.size}: ${routeCaches.joinToString { "${it.routeId}@v${it.version ?: "?"}" }.ifBlank { "brak" }}")) + add(DiagnosticEntry("Scope synchronizacji", syncStates.size.toString())) + syncStates.forEach { sync -> + add( + DiagnosticEntry( + sync.scope, + listOfNotNull(sync.date, sync.routeId).joinToString(" / ").let { target -> + "${target.ifBlank { "globalny" }}, v${sync.version}, checksum ${shortFingerprint(sync.checksum)}, zapis ${Instant.ofEpochMilli(sync.syncedAtEpochMillis)}" + }, + ), + ) + } + }, + ), + ) + } + + DiagnosticSnapshot(System.currentTimeMillis(), sections) + } + + private fun currentNetworkEntries(): List { + val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetwork = manager.activeNetwork + val capabilities = activeNetwork?.let(manager::getNetworkCapabilities) + if (capabilities == null) { + return listOf(DiagnosticEntry("Aktywna sieć", "brak", DiagnosticSeverity.Error)) + } + val transports = buildList { + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) add("Wi-Fi") + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) add("sieć komórkowa") + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) add("Ethernet") + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) add("VPN") + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) add("Bluetooth") + }.ifEmpty { listOf("inny") } + val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + val links = activeNetwork?.let(manager::getLinkProperties) + return listOf( + DiagnosticEntry("Transport", transports.joinToString()), + DiagnosticEntry("Internet zweryfikowany", yesNo(validated), if (validated) DiagnosticSeverity.Good else DiagnosticSeverity.Error), + DiagnosticEntry("NET_CAPABILITY_INTERNET", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET))), + DiagnosticEntry("NET_CAPABILITY_VALIDATED", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))), + DiagnosticEntry("Sieć mierzona", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))), + DiagnosticEntry("Roaming", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))), + DiagnosticEntry("Pasmo downstream / upstream", "${capabilities.linkDownstreamBandwidthKbps} / ${capabilities.linkUpstreamBandwidthKbps} kb/s"), + DiagnosticEntry("Interfejs", links?.interfaceName ?: "brak"), + DiagnosticEntry("Adresy IP", links?.linkAddresses?.joinToString { it.toString() }?.ifBlank { "brak" } ?: "brak"), + DiagnosticEntry("DNS", links?.dnsServers?.joinToString { it.hostAddress ?: it.toString() }?.ifBlank { "brak" } ?: "brak"), + DiagnosticEntry("MTU", links?.mtu?.toString() ?: "brak"), + DiagnosticEntry("Proxy", links?.httpProxy?.toString() ?: "brak"), + ) + } + + private fun activeWorkInfo(): List = runCatching { + val query = WorkQuery.Builder.fromStates( + listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED), + ).build() + WorkManager.getInstance(context).getWorkInfos(query).get(3, TimeUnit.SECONDS) + }.getOrDefault(emptyList()) + + private fun databaseSchemaVersion(): Int = runCatching { + database.openHelper.readableDatabase.query("PRAGMA user_version").use { cursor -> + if (cursor.moveToFirst()) cursor.getInt(0) else -1 + } + }.getOrDefault(-1) + + private fun memorySummary(): String { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val info = ActivityManager.MemoryInfo() + manager.getMemoryInfo(info) + return "wolne ${formatBytes(info.availMem)} z ${formatBytes(info.totalMem)}, niski stan: ${yesNo(info.lowMemory)}" + } + + private fun batterySummary(): String { + val manager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager + val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).takeIf { it >= 0 }?.let { "$it%" } ?: "brak" + return "$level, ładowanie: ${yesNo(manager.isCharging)}" + } + + private fun notificationChannelSummary(): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return "niewspierane" + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channels = manager.notificationChannels + return if (channels.isEmpty()) "0" else channels.joinToString { "${it.id}:${it.importance}" } + } + + private fun permissionEntry(label: String, permission: String): DiagnosticEntry { + val granted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + return DiagnosticEntry(label, if (granted) "przyznane" else "brak", if (granted) DiagnosticSeverity.Good else DiagnosticSeverity.Warning) + } + + private fun notificationPermissionEntry(): DiagnosticEntry = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + DiagnosticEntry("Uprawnienie powiadomień", "niewymagane na tej wersji Androida") + } else { + permissionEntry("Uprawnienie powiadomień", Manifest.permission.POST_NOTIFICATIONS) + } + + private fun isIgnoringBatteryOptimizations(): Boolean { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + private suspend fun readFirebaseToken(): Result = suspendCancellableCoroutine { continuation -> + runCatching { FirebaseMessaging.getInstance().token } + .onFailure { continuation.resume(Result.failure(it)) } + .onSuccess { task -> + task.addOnCompleteListener { completed -> + if (!continuation.isActive) return@addOnCompleteListener + if (completed.isSuccessful) continuation.resume(Result.success(completed.result)) + else continuation.resume(Result.failure(completed.exception ?: IllegalStateException("Nieznany błąd FCM"))) + } + } + } +} + +internal fun secureIdentifierSummary(value: String?): String { + if (value.isNullOrBlank()) return "brak" + val fingerprint = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray()) + .joinToString("") { "%02x".format(it) } + .take(12) + val suffix = value.takeLast(4) + return "obecny, długość ${value.length}, SHA-256 $fingerprint, końcówka …$suffix" +} + +private fun shortFingerprint(value: String): String = + MessageDigest.getInstance("SHA-256").digest(value.toByteArray()).joinToString("") { "%02x".format(it) }.take(10) + +private fun statusSummary(statuses: List): String = + if (statuses.isEmpty()) "0" else statuses.groupingBy { it }.eachCount().entries.sortedBy { it.key }.joinToString { "${it.key}: ${it.value}" } + +private fun queueSeverity(statuses: List): DiagnosticSeverity = when { + statuses.any { it.contains("PERMANENT") || it.contains("CONFLICT") } -> DiagnosticSeverity.Error + statuses.any { it.contains("FAILED") } -> DiagnosticSeverity.Warning + statuses.isNotEmpty() -> DiagnosticSeverity.Warning + else -> DiagnosticSeverity.Good +} + +private fun storageSummary(directory: File): String = + "wolne ${formatBytes(directory.usableSpace)} z ${formatBytes(directory.totalSpace)}" + +private fun formatBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val units = listOf("KB", "MB", "GB", "TB") + var value = bytes.toDouble() + var unit = -1 + while (value >= 1024 && unit < units.lastIndex) { + value /= 1024 + unit++ + } + return "%.1f %s".format(java.util.Locale.US, value, units[unit]) +} + +private fun yesNo(value: Boolean): String = if (value) "tak" else "nie" diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt index 92f4041..7006901 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import pl.firmatpp.kierowca.data.DriverRepository +import pl.firmatpp.kierowca.data.sync.NetworkMonitor class DriverFirebaseMessagingService : FirebaseMessagingService() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -14,7 +15,7 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { scope.launch { val repository = DriverRepository(applicationContext) - if (repository.hasToken()) { + if (repository.hasToken() && NetworkMonitor(applicationContext).isCurrentlyValidated()) { runCatching { repository.storePushToken(token) } } } @@ -38,20 +39,26 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() { title = data["title"], body = data["body"], ) - DriverSyncWorker.enqueue( - context = applicationContext, - date = data["date"], - routeId = data["routeId"], - ) + if (!DriverRuntimeSyncState.websocketOwnsForegroundHints()) { + DriverSyncWorker.enqueue( + context = applicationContext, + date = data["date"], + routeId = data["routeId"], + ) + } return } if (data["type"] != "driver_sync_hint") return + if (DriverRuntimeSyncState.websocketOwnsForegroundHints()) return DriverSyncWorker.enqueue( context = applicationContext, date = data["date"], routeId = data["routeId"], + scope = data["scope"], + version = data["version"]?.toLongOrNull(), + checksum = data["checksum"], ) } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt index 9ae4660..ac50043 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt @@ -11,6 +11,9 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.WebSocket @@ -19,6 +22,8 @@ import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.RealtimeConfigDto import pl.firmatpp.kierowca.diagnostics.AppDiagnostics +import pl.firmatpp.kierowca.data.ApiErrorKind +import pl.firmatpp.kierowca.data.ApiErrorMapper interface DriverLiveSyncGateway { suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse @@ -47,7 +52,7 @@ class OkHttpLiveWebSocketFactory( client.newWebSocket(Request.Builder().url(url).build(), listener) } -private enum class LiveSyncConnectionState { +enum class LiveSyncConnectionState { Stopped, WaitingForNetwork, Disconnected, @@ -56,10 +61,24 @@ private enum class LiveSyncConnectionState { Connected, } +data class LiveSyncDiagnostics( + val state: LiveSyncConnectionState, + val desiredActive: Boolean, + val networkAvailable: Boolean, + val foregroundActive: Boolean, + val configured: Boolean, + val socketId: String?, + val reconnectAttempt: Int, + val messageVersion: Long, + val lastMessageAtEpochMillis: Long?, + val lastDisconnectReason: String?, +) + class DriverLiveSyncClient( private val gateway: DriverLiveSyncGateway, private val onConnected: () -> Unit, private val onHint: (DriverSyncHint) -> Unit, + private val onAuthenticationFailed: () -> Unit = {}, private val webSocketFactory: LiveWebSocketFactory = OkHttpLiveWebSocketFactory(defaultOkHttpClient()), private val gson: Gson = Gson(), private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), @@ -70,12 +89,14 @@ class DriverLiveSyncClient( repository: DriverRepository, onConnected: () -> Unit, onHint: (DriverSyncHint) -> Unit, + onAuthenticationFailed: () -> Unit = {}, client: OkHttpClient = defaultOkHttpClient(), gson: Gson = Gson(), ) : this( gateway = DriverRepositoryLiveSyncGateway(repository), onConnected = onConnected, onHint = onHint, + onAuthenticationFailed = onAuthenticationFailed, webSocketFactory = OkHttpLiveWebSocketFactory(client), gson = gson, ) @@ -104,6 +125,31 @@ class DriverLiveSyncClient( private var staleWatchdogJob: Job? = null private var reconnectAttempt: Int = 0 private var messageVersion: Long = 0 + private var lastMessageAtEpochMillis: Long? = null + private var lastDisconnectReason: String? = null + private var foregroundActive: Boolean = true + private val _connectionState = MutableStateFlow(LiveSyncConnectionState.Stopped) + val connectionState: StateFlow = _connectionState.asStateFlow() + + fun diagnostics(): LiveSyncDiagnostics = synchronized(lock) { + LiveSyncDiagnostics( + state = state, + desiredActive = desiredActive, + networkAvailable = networkAvailable, + foregroundActive = foregroundActive, + configured = realtimeConfig != null, + socketId = socketId, + reconnectAttempt = reconnectAttempt, + messageVersion = messageVersion, + lastMessageAtEpochMillis = lastMessageAtEpochMillis, + lastDisconnectReason = lastDisconnectReason, + ) + } + + private fun updateState(next: LiveSyncConnectionState) { + state = next + _connectionState.value = next + } fun start(driverId: String, config: RealtimeConfigDto?) { if (!isConfigUsable(config)) return @@ -119,6 +165,7 @@ class DriverLiveSyncClient( fun ensureConnected() { val shouldConnect = synchronized(lock) { desiredActive && + foregroundActive && networkAvailable && state !in setOf( LiveSyncConnectionState.Connecting, @@ -136,22 +183,32 @@ class DriverLiveSyncClient( reconnectJob?.cancel() reconnectJob = null closeSocketLocked("network_lost") - state = if (desiredActive) LiveSyncConnectionState.WaitingForNetwork else LiveSyncConnectionState.Stopped + updateState(if (desiredActive) LiveSyncConnectionState.WaitingForNetwork else LiveSyncConnectionState.Stopped) false } else { desiredActive && state == LiveSyncConnectionState.WaitingForNetwork } } - if (available) { - reportRealtimeStatus("reconnecting", "network_available") - } else { - reportRealtimeStatus("disconnected", "network_lost") - } - if (shouldReconnect) connectNow(resetAttempt = true) } + fun setForeground(active: Boolean) { + val shouldConnect = synchronized(lock) { + foregroundActive = active + if (!active) { + reconnectJob?.cancel() + reconnectJob = null + closeSocketLocked("app_background") + updateState(LiveSyncConnectionState.Stopped) + false + } else { + desiredActive && networkAvailable + } + } + if (shouldConnect) connectNow(resetAttempt = true) + } + fun stop() { synchronized(lock) { desiredActive = false @@ -162,7 +219,7 @@ class DriverLiveSyncClient( realtimeConfig = null socketId = null reconnectAttempt = 0 - state = LiveSyncConnectionState.Stopped + updateState(LiveSyncConnectionState.Stopped) } reportRealtimeStatus("disconnected", "client_stop") } @@ -176,14 +233,14 @@ class DriverLiveSyncClient( val wsUrl = synchronized(lock) { val config = realtimeConfig ?: return val id = driverId ?: return - if (!desiredActive || !networkAvailable || !isConfigUsable(config)) return + if (!desiredActive || !foregroundActive || !networkAvailable || !isConfigUsable(config)) return if (state == LiveSyncConnectionState.Connecting || state == LiveSyncConnectionState.Subscribing || state == LiveSyncConnectionState.Connected) return if (resetAttempt) reconnectAttempt = 0 reconnectJob?.cancel() reconnectJob = null socketId = null - state = LiveSyncConnectionState.Connecting + updateState(LiveSyncConnectionState.Connecting) val appKey = config.reverbAppKey.orEmpty() val wsBaseUrl = config.reverbWsBaseUrl.orEmpty() @@ -227,6 +284,7 @@ class DriverLiveSyncClient( false } else { messageVersion += 1 + lastMessageAtEpochMillis = System.currentTimeMillis() true } } @@ -244,7 +302,7 @@ class DriverLiveSyncClient( val nextSocketId = root.dataObject()?.string("socket_id") ?: return synchronized(lock) { socketId = nextSocketId - state = LiveSyncConnectionState.Subscribing + updateState(LiveSyncConnectionState.Subscribing) } subscribe(socket, nextSocketId) } @@ -254,7 +312,7 @@ class DriverLiveSyncClient( if (channel == "private-driver-mobile.$id") { synchronized(lock) { reconnectAttempt = 0 - state = LiveSyncConnectionState.Connected + updateState(LiveSyncConnectionState.Connected) } reportRealtimeStatus("connected") startHeartbeat() @@ -290,6 +348,10 @@ class DriverLiveSyncClient( } }.onFailure { throwable -> AppDiagnostics.log("realtime_subscription_error: ${throwable.message ?: throwable::class.java.simpleName}") + if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) { + handleAuthenticationFailure(socket) + return@onFailure + } socket.close(1000, "subscription_failed") handleDisconnect("error", "subscription_failed", socket) } @@ -304,15 +366,16 @@ class DriverLiveSyncClient( stopStaleWatchdogLocked() webSocket = null socketId = null + lastDisconnectReason = reason if (!desiredActive) { - state = LiveSyncConnectionState.Stopped + updateState(LiveSyncConnectionState.Stopped) false } else if (!networkAvailable) { - state = LiveSyncConnectionState.WaitingForNetwork + updateState(LiveSyncConnectionState.WaitingForNetwork) false } else { - state = LiveSyncConnectionState.Disconnected + updateState(LiveSyncConnectionState.Disconnected) true } } @@ -323,7 +386,7 @@ class DriverLiveSyncClient( private fun scheduleReconnect() { val delayMs = synchronized(lock) { - if (!desiredActive || !networkAvailable || state == LiveSyncConnectionState.Stopped) return + if (!desiredActive || !foregroundActive || !networkAvailable || state == LiveSyncConnectionState.Stopped) return if (reconnectJob?.isActive == true) return val delay = reconnectDelaysMs.getOrElse(reconnectAttempt) { reconnectDelaysMs.last() } reconnectAttempt += 1 @@ -401,10 +464,26 @@ class DriverLiveSyncClient( scope.launch { runCatching { gateway.storeRealtimeStatus(status, socketId, error) + }.onFailure { throwable -> + if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) handleAuthenticationFailure() } } } + private fun handleAuthenticationFailure(socket: WebSocket? = null) { + val notify = synchronized(lock) { + if (socket != null && webSocket !== socket) return + val wasActive = desiredActive + desiredActive = false + reconnectJob?.cancel() + reconnectJob = null + closeSocketLocked("authentication_failed") + updateState(LiveSyncConnectionState.Stopped) + wasActive + } + if (notify) onAuthenticationFailed() + } + private fun isConfigUsable(config: RealtimeConfigDto?): Boolean = config?.reverbEnabled == true && !config.reverbAppKey.isNullOrBlank() && diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverRuntimeSyncState.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverRuntimeSyncState.kt new file mode 100644 index 0000000..2c0109e --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverRuntimeSyncState.kt @@ -0,0 +1,8 @@ +package pl.firmatpp.kierowca.sync + +object DriverRuntimeSyncState { + @Volatile var foreground: Boolean = false + @Volatile var realtimeConnected: Boolean = false + + fun websocketOwnsForegroundHints(): Boolean = foreground && realtimeConnected +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt index 8094a29..a857632 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt @@ -14,6 +14,8 @@ import java.util.concurrent.TimeUnit import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.sync.DriverSyncRepository +import pl.firmatpp.kierowca.data.sync.NetworkMonitor +import pl.firmatpp.kierowca.data.model.SyncScopeDto import pl.firmatpp.kierowca.diagnostics.AppDiagnostics class DriverSyncWorker( @@ -23,9 +25,20 @@ class DriverSyncWorker( private val syncRepository = DriverSyncRepository(appContext) override suspend fun doWork(): Result = + if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) Result.retry() else runCatching { val date = inputData.getString(KEY_DATE) val routeId = inputData.getString(KEY_ROUTE_ID) + val hintedScope = inputData.getString(KEY_SCOPE)?.let { scope -> + val version = inputData.getLong(KEY_VERSION, -1L) + val checksum = inputData.getString(KEY_CHECKSUM) + if (version >= 0L && !checksum.isNullOrBlank()) { + SyncScopeDto(scope, date, routeId, version, checksum, computedAt = "") + } else null + } + if (hintedScope != null && !syncRepository.shouldRefresh(hintedScope)) { + return@runCatching Result.success() + } val response = syncRepository.fetchSyncState(date, routeId) var refreshed = false @@ -85,17 +98,33 @@ class DriverSyncWorker( companion object { private const val KEY_DATE = "date" private const val KEY_ROUTE_ID = "routeId" + private const val KEY_SCOPE = "scope" + private const val KEY_VERSION = "version" + private const val KEY_CHECKSUM = "checksum" - fun enqueue(context: Context, date: String?, routeId: String?) { + fun enqueue( + context: Context, + date: String?, + routeId: String?, + scope: String? = null, + version: Long? = null, + checksum: String? = null, + ) { val request = OneTimeWorkRequestBuilder() - .setInputData(workDataOf(KEY_DATE to date, KEY_ROUTE_ID to routeId)) + .setInputData(workDataOf( + KEY_DATE to date, + KEY_ROUTE_ID to routeId, + KEY_SCOPE to scope, + KEY_VERSION to version, + KEY_CHECKSUM to checksum, + )) .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .build() WorkManager.getInstance(context).enqueueUniqueWork( - listOf("driver-sync", date ?: "-", routeId ?: "-").joinToString("-"), - ExistingWorkPolicy.REPLACE, + listOf("driver-sync", scope ?: "all", date ?: "-", routeId ?: "-").joinToString("-"), + ExistingWorkPolicy.KEEP, request, ) } diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt index 0c77b1a..0de6441 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt @@ -26,6 +26,7 @@ import pl.firmatpp.kierowca.R import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.DriverRepository +import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import retrofit2.HttpException @@ -36,6 +37,7 @@ class NewRouteNotificationWorker( private val repository = DriverRepository(appContext) override suspend fun doWork(): Result { + if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry() val routeId = inputData.getString(KEY_ROUTE_ID)?.takeIf(String::isNotBlank) ?: return Result.success() return runCatching { diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index 0995081..fcc7009 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -58,6 +58,8 @@ import androidx.compose.material.icons.outlined.CameraAlt import androidx.compose.material.icons.outlined.CalendarToday import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.CloudUpload +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Info @@ -84,6 +86,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -151,6 +154,9 @@ import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus import pl.firmatpp.kierowca.data.upload.RouteActionEntity +import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry +import pl.firmatpp.kierowca.diagnostics.DiagnosticSeverity +import pl.firmatpp.kierowca.sync.LiveSyncConnectionState import pl.firmatpp.kierowca.domain.OtpCodeExtractor import pl.firmatpp.kierowca.domain.RouteDisplayMapper import pl.firmatpp.kierowca.ui.theme.AppThemeMode @@ -187,20 +193,27 @@ fun DriverApp( appInForeground = true viewModel.onAppForegrounded() } - Lifecycle.Event.ON_STOP -> appInForeground = false + Lifecycle.Event.ON_STOP -> { + appInForeground = false + viewModel.onAppBackgrounded() + } else -> Unit } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - LaunchedEffect(state.screen, state.selectedDate, state.autoRefreshSeconds, appInForeground) { - if (state.screen != DriverScreen.Routes || !appInForeground) return@LaunchedEffect + LaunchedEffect(state.realtimeConnectionState, state.autoRefreshSeconds, state.isOnline, appInForeground) { + if (!appInForeground || !state.isOnline || state.driver == null) return@LaunchedEffect - val intervalMillis = state.autoRefreshSeconds.coerceIn(30, 600) * 1000L + val intervalMillis = if (state.realtimeConnectionState == LiveSyncConnectionState.Connected) { + 5 * 60 * 1000L + } else { + state.autoRefreshSeconds.coerceIn(30, 600) * 1000L + } while (true) { delay(intervalMillis) - viewModel.refreshRoutesSilently() + viewModel.periodicSyncCheck() } } SmsUserConsentEffect( @@ -235,7 +248,9 @@ fun DriverApp( state = state, onRoutes = viewModel::refreshRoutes, onProfile = viewModel::openProfile, - onLogout = viewModel::logout, + onLogout = viewModel::requestLogout, + onConfirmLogout = viewModel::confirmLogoutAndDeletePending, + onDismissLogout = viewModel::dismissLogoutWarning, onLeaveRequests = viewModel::openLeaveRequests, onNewRouteNotificationsChanged = { enabled -> if (!enabled) { @@ -251,6 +266,12 @@ fun DriverApp( onOpenNotificationSettings = { openAppNotificationSettings(context) }, onThemeModeChanged = viewModel::setThemeMode, onRouteProgressNotificationChanged = viewModel::setRouteProgressNotificationEnabled, + onDiagnostics = viewModel::openDiagnostics, + ) + DriverScreen.Diagnostics -> DiagnosticsScreen( + state = state, + onBack = viewModel::back, + onRefresh = viewModel::refreshDiagnostics, ) DriverScreen.Detail -> DetailScreen( state, @@ -259,30 +280,32 @@ fun DriverApp( viewModel::refreshSelectedRoute, viewModel::openStartRoute, viewModel::openFinishRoute, + viewModel::correctFailedRouteAction, ) DriverScreen.StartRoute -> RouteStageScreen( state = state, - title = "Zdjęcia załadunku", + title = if (routeStageRequirementIsVisible(state.loadingPhotoRequirement)) "Zdjęcia załadunku" else "Potwierdź załadunek", stage = "loading", - weightLabel = "Waga na załadunku", - submitLabel = "Gotowe, jadę na wagę", + weightLabel = "Tonaż załadunku", + submitLabel = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) "Dalej: tonaż" else "Potwierdź załadunek", showWeightInput = false, - showPhotoActions = true, + showPhotoActions = routeStageRequirementIsVisible(state.loadingPhotoRequirement), + showPhotoGrid = routeStageRequirementIsVisible(state.loadingPhotoRequirement), onBack = viewModel::back, onWeightChange = viewModel::updateRouteStageWeight, onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") }, onPhoto = viewModel::openPhoto, onDeleteUpload = viewModel::deleteConfirmedUpload, onRetryUpload = viewModel::retryPhotoUpload, - onSubmit = viewModel::openLoadingWeight, + onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute, ) DriverScreen.LoadingWeight -> RouteStageScreen( state = state, - title = "Waga załadunku", + title = "Tonaż załadunku", stage = "loading", - weightLabel = "Waga z wagi", - submitLabel = "Załaduj", - showWeightInput = true, + weightLabel = "Tonaż załadunku", + submitLabel = "Potwierdź załadunek", + showWeightInput = routeStageRequirementIsVisible(state.loadingWeightRequirement), showPhotoActions = false, showPhotoGrid = false, onBack = viewModel::back, @@ -297,8 +320,11 @@ fun DriverApp( state = state, title = "Rozładunek", stage = "unloading", - weightLabel = "Waga na rozładunku", - submitLabel = "Rozładuj", + weightLabel = "Tonaż rozładunku", + submitLabel = "Potwierdź rozładunek", + showWeightInput = routeStageRequirementIsVisible(state.unloadingWeightRequirement), + showPhotoActions = routeStageRequirementIsVisible(state.unloadingPhotoRequirement), + showPhotoGrid = routeStageRequirementIsVisible(state.unloadingPhotoRequirement), onBack = viewModel::back, onWeightChange = viewModel::updateRouteStageWeight, onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") }, @@ -778,7 +804,7 @@ private fun RoutesScreen( } } item { OfflineStaleBanner(state) } - item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) } + item { OperationalQueueBanner(state, onPhotoQueue) } if (state.displayRoutes.isEmpty()) { item { EmptyState("Brak kursow na wybrany dzien") } } else { @@ -907,10 +933,12 @@ private fun LeaveRequestsScreen( contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + item { OfflineStaleBanner(state) } item { FeedbackAndError(state.feedback, state.error) } item { Button( onClick = onAdd, + enabled = state.isOnline, modifier = Modifier.fillMaxWidth().height(56.dp), colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), shape = MaterialTheme.shapes.medium, @@ -987,6 +1015,7 @@ private fun LeaveRequestDetailScreen( contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { + item { OfflineStaleBanner(state) } item { FeedbackAndError(state.feedback, state.error) } item { Card( @@ -1007,6 +1036,7 @@ private fun LeaveRequestDetailScreen( if (DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) { Button( onClick = onCancel, + enabled = state.isOnline, modifier = Modifier.fillMaxWidth().height(54.dp), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB45309)), shape = MaterialTheme.shapes.medium, @@ -1100,6 +1130,7 @@ private fun LeaveCalendarScreen( contentPadding = PaddingValues(start = 20.dp, top = 16.dp, end = 20.dp, bottom = 120.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { + item { OfflineStaleBanner(state) } item { FeedbackAndError(null, state.error) } items(months, key = { it.toString() }) { month -> LeaveCalendarMonth( @@ -1187,7 +1218,7 @@ private fun LeaveCalendarContinueBar( ) Button( onClick = onContinue, - enabled = state.leaveCalendarHasSelection, + enabled = state.leaveCalendarHasSelection && state.isOnline, colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), shape = MaterialTheme.shapes.medium, modifier = Modifier.height(48.dp), @@ -1424,6 +1455,7 @@ private fun AddLeaveRequestScreen( contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + item { OfflineStaleBanner(state) } item { FeedbackAndError(null, state.error) } item { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -1486,6 +1518,7 @@ private fun AddLeaveRequestScreen( item { Button( onClick = onSubmit, + enabled = state.isOnline, modifier = Modifier.fillMaxWidth().height(58.dp), colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), shape = MaterialTheme.shapes.medium, @@ -1711,8 +1744,9 @@ private fun DispatchSheetReminderCard( } @Composable -private fun PhotoQueueBanner(uploads: List, onPhotoQueue: () -> Unit) { - val count = queuedPhotoUploadCount(uploads.map { it.status }) +private fun OperationalQueueBanner(state: DriverUiState, onPhotoQueue: () -> Unit) { + val count = state.pendingOperationalItems + val photoCount = queuedPhotoUploadCount(state.queuedPhotoUploads.map { it.status }) if (count <= 0) return Card( @@ -1733,18 +1767,20 @@ private fun PhotoQueueBanner(uploads: List, onPhotoQueue: () Icon(Icons.Outlined.CloudUpload, contentDescription = null, tint = TppTheme.colors.forest) } Text( - "W kolejce do wysłania jest $count ${photoCountLabel(count)}.", - color = TppTheme.colors.ink, + if (state.operationalQueueNeedsAttention) "$count elementów wymaga uwagi." else if (state.isOnline) "Synchronizuję $count elementów z serwerem." else "$count elementów zapisano w telefonie. Wyślemy je po odzyskaniu internetu.", + color = if (state.operationalQueueNeedsAttention) TppTheme.colors.error else TppTheme.colors.ink, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), ) - Button( - onClick = onPhotoQueue, - colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), - shape = MaterialTheme.shapes.small, - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), - ) { - Text("Kolejka", color = Color.White, fontWeight = FontWeight.Bold) + if (photoCount > 0) { + Button( + onClick = onPhotoQueue, + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + shape = MaterialTheme.shapes.small, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), + ) { + Text("Zdjęcia", color = Color.White, fontWeight = FontWeight.Bold) + } } } } @@ -2107,11 +2143,14 @@ private fun ProfileScreen( onRoutes: () -> Unit, onProfile: () -> Unit, onLogout: () -> Unit, + onConfirmLogout: () -> Unit, + onDismissLogout: () -> Unit, onLeaveRequests: () -> Unit, onNewRouteNotificationsChanged: (Boolean) -> Unit, onOpenNotificationSettings: () -> Unit, onThemeModeChanged: (AppThemeMode) -> Unit, onRouteProgressNotificationChanged: (Boolean) -> Unit, + onDiagnostics: () -> Unit, ) { BoxWithConstraints(Modifier.fillMaxSize()) { val screenWidthDp = maxWidth.value.toInt() @@ -2138,6 +2177,7 @@ private fun ProfileScreen( .padding(horizontal = horizontalPadding, vertical = 24.dp), verticalArrangement = Arrangement.spacedBy(18.dp), ) { + OfflineStaleBanner(state) Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), @@ -2191,9 +2231,12 @@ private fun ProfileScreen( Switch( checked = state.notifyNewRoutes, onCheckedChange = onNewRouteNotificationsChanged, - enabled = !state.refreshing && !state.loading, + enabled = state.isOnline && !state.refreshing && !state.loading, ) } + if (!state.isOnline) { + Text("Zmiana powiadomień wymaga połączenia z internetem.", color = TppTheme.colors.muted, style = MaterialTheme.typography.bodySmall) + } if (state.notificationPermissionDenied) { Button( onClick = onOpenNotificationSettings, @@ -2258,6 +2301,17 @@ private fun ProfileScreen( } } } + OutlinedButton( + onClick = onDiagnostics, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = MaterialTheme.shapes.small, + border = BorderStroke(1.dp, TppTheme.colors.navy), + colors = ButtonDefaults.outlinedButtonColors(contentColor = TppTheme.colors.navy), + ) { + Icon(Icons.Outlined.BugReport, contentDescription = null) + Spacer(Modifier.width(10.dp)) + Text("Diagnostyka aplikacji", fontWeight = FontWeight.Bold) + } Button( onClick = onLogout, modifier = Modifier.fillMaxWidth().height(56.dp), @@ -2269,6 +2323,218 @@ private fun ProfileScreen( } } } + + if (state.showLogoutWarning) { + AlertDialog( + onDismissRequest = onDismissLogout, + title = { Text("Niewysłane dane", fontWeight = FontWeight.Bold) }, + text = { + Text("W telefonie jest ${state.pendingLogoutItems} niewysłanych elementów. Wylogowanie usunie je bez możliwości odzyskania.") + }, + confirmButton = { + TextButton(onClick = onConfirmLogout) { + Text("Wyloguj i usuń dane", color = TppTheme.colors.error, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = onDismissLogout) { Text("Wróć i zsynchronizuj") } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DiagnosticsScreen( + state: DriverUiState, + onBack: () -> Unit, + onRefresh: () -> Unit, +) { + val context = LocalContext.current + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(2_500L) + copied = false + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("Diagnostyka", fontWeight = FontWeight.Bold) + Text("Stan aplikacji i synchronizacji", style = MaterialTheme.typography.labelMedium, color = TppTheme.colors.muted) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Outlined.ArrowBack, contentDescription = "Wróć do profilu") + } + }, + actions = { + IconButton(onClick = onRefresh, enabled = !state.diagnosticsLoading) { + Icon(Icons.Outlined.Refresh, contentDescription = "Odśwież diagnostykę") + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = TppTheme.colors.card, titleContentColor = TppTheme.colors.ink), + ) + }, + containerColor = TppTheme.colors.surface, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .background(TppTheme.colors.panel, MaterialTheme.shapes.medium) + .border(1.dp, TppTheme.colors.outline, MaterialTheme.shapes.medium) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Raport dla wsparcia", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = TppTheme.colors.ink) + Text( + "Tokeny są pokazane jako bezpieczne fingerprinty. Raport zawiera informacje techniczne, ale nie pozwala zalogować się na konto kierowcy.", + style = MaterialTheme.typography.bodyMedium, + color = TppTheme.colors.muted, + ) + } + } + + if (state.diagnosticsError != null) { + item { + Text( + state.diagnosticsError, + modifier = Modifier.fillMaxWidth().background(TppTheme.colors.error.copy(alpha = 0.08f), MaterialTheme.shapes.small).padding(14.dp), + color = TppTheme.colors.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + if (state.diagnosticsLoading && state.diagnostics == null) { + item { + Row( + Modifier.fillMaxWidth().padding(vertical = 32.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = TppTheme.colors.forest, strokeWidth = 3.dp) + Spacer(Modifier.width(12.dp)) + Text("Zbieram dane diagnostyczne…", color = TppTheme.colors.muted) + } + } + } + + state.diagnostics?.let { diagnostics -> + item { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + copyDiagnosticReport(context, diagnostics.asPlainText()) + copied = true + }, + modifier = Modifier.weight(1f).height(52.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy), + shape = MaterialTheme.shapes.small, + ) { + Icon(Icons.Outlined.ContentCopy, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(if (copied) "Skopiowano" else "Kopiuj raport", fontWeight = FontWeight.Bold) + } + OutlinedButton( + onClick = onRefresh, + enabled = !state.diagnosticsLoading, + modifier = Modifier.height(52.dp), + shape = MaterialTheme.shapes.small, + border = BorderStroke(1.dp, TppTheme.colors.outline), + ) { + if (state.diagnosticsLoading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = TppTheme.colors.forest) + } else { + Icon(Icons.Outlined.Refresh, contentDescription = null, tint = TppTheme.colors.forest) + } + } + } + } + + diagnostics.sections.forEach { section -> + item(key = section.title) { + DiagnosticSectionView(section.title, section.entries) + } + } + + item { + Text( + "Wygenerowano ${formatSyncTime(diagnostics.generatedAtEpochMillis)}", + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + color = TppTheme.colors.muted, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } +} + +@Composable +private fun DiagnosticSectionView(title: String, entries: List) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(TppTheme.colors.card, MaterialTheme.shapes.medium) + .border(1.dp, TppTheme.colors.outline, MaterialTheme.shapes.medium), + ) { + Text( + title, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = TppTheme.colors.ink, + ) + entries.forEachIndexed { index, entry -> + if (index > 0) HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.45f)) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + Modifier + .padding(top = 6.dp) + .size(8.dp) + .background(diagnosticSeverityColor(entry.severity), RoundedCornerShape(2.dp)), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(entry.label, style = MaterialTheme.typography.labelLarge, color = TppTheme.colors.muted) + Text( + entry.value, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = TppTheme.colors.ink, + ) + } + } + } + } +} + +@Composable +private fun diagnosticSeverityColor(severity: DiagnosticSeverity): Color = when (severity) { + DiagnosticSeverity.Neutral -> TppTheme.colors.muted + DiagnosticSeverity.Good -> TppTheme.colors.forest + DiagnosticSeverity.Warning -> Color(0xFF9A6700) + DiagnosticSeverity.Error -> TppTheme.colors.error +} + +private fun copyDiagnosticReport(context: Context, report: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("TPP Kierowca, diagnostyka", report)) } private fun canPostNotifications(context: Context): Boolean = @@ -2356,20 +2622,27 @@ private fun RouteStageScreen( val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty() val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size) + val photoRequirement = if (stage == "loading") state.loadingPhotoRequirement else state.unloadingPhotoRequirement + val weightRequirement = if (stage == "loading") state.loadingWeightRequirement else state.unloadingWeightRequirement val submitBlocker = when { !showWeightInput -> loadingPhotosSubmitBlocker( serverPhotoCount = stagePhotos.size, localUploadCount = stageUploads.size, + photoRequirement = photoRequirement, ) stage == "loading" -> loadingWeightSubmitBlocker( weightText = state.routeStageWeightText, serverPhotoCount = stagePhotos.size, localUploadCount = stageUploads.size, + weightRequirement = weightRequirement, + photoRequirement = photoRequirement, ) else -> routeStageSubmitBlocker( weightText = state.routeStageWeightText, serverPhotoCount = stagePhotos.size, localUploadCount = stageUploads.size, + weightRequirement = weightRequirement, + photoRequirement = photoRequirement, ) } val weightBlocker = submitBlocker?.takeIf { it.contains("wag", ignoreCase = true) || it.contains("popraw", ignoreCase = true) } @@ -2428,16 +2701,20 @@ private fun RouteStageScreen( item { RouteStageChecklist( hasValidWeight = if (showWeightInput) { - routeStageSubmitBlocker( + state.routeStageWeightText.trim().isNotBlank() && routeStageSubmitBlocker( weightText = state.routeStageWeightText, serverPhotoCount = 1, localUploadCount = 0, + weightRequirement = weightRequirement, + photoRequirement = RouteStageRequirement.Optional, ) == null } else { null }, photoCount = attachmentCount, photoLabel = if (stage == "loading") "Zdjęcie załadunku" else "Zdjęcie etapu", + weightRequirement = weightRequirement, + photoRequirement = photoRequirement, ) } if (showWeightInput) { @@ -2448,7 +2725,7 @@ private fun RouteStageScreen( label = { Text(weightLabel) }, trailingIcon = { Text( - "kg", + "t", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, @@ -2589,7 +2866,13 @@ private fun RouteStageSummaryFact(label: String, value: String, modifier: Modifi } @Composable -private fun RouteStageChecklist(hasValidWeight: Boolean?, photoCount: Int, photoLabel: String) { +private fun RouteStageChecklist( + hasValidWeight: Boolean?, + photoCount: Int, + photoLabel: String, + weightRequirement: String, + photoRequirement: String, +) { Card( colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), border = BorderStroke(1.dp, TppTheme.colors.outline), @@ -2598,23 +2881,31 @@ private fun RouteStageChecklist(hasValidWeight: Boolean?, photoCount: Int, photo ) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - "Wymagane przed wysłaniem", + "Dane etapu", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium, ) - if (hasValidWeight != null) { - RouteStageRequirementRow( - label = "Waga", - done = hasValidWeight, - detail = if (hasValidWeight) "uzupełniona" else "wpisz wartość w kg", + if (!routeStageRequirementIsVisible(weightRequirement) && !routeStageRequirementIsVisible(photoRequirement)) { + Text( + "Nie wymagamy dodatkowych danych. Możesz od razu potwierdzić etap.", + color = TppTheme.colors.muted, + ) + } + if (hasValidWeight != null && routeStageRequirementIsVisible(weightRequirement)) { + RouteStageRequirementRow( + label = if (routeStageRequirementIsRequired(weightRequirement)) "Tonaż" else "Tonaż (opcjonalnie)", + done = hasValidWeight, + detail = if (hasValidWeight) "uzupełniony" else if (routeStageRequirementIsRequired(weightRequirement)) "wpisz wartość w tonach" else "możesz pominąć", + ) + } + if (routeStageRequirementIsVisible(photoRequirement)) { + RouteStageRequirementRow( + label = if (routeStageRequirementIsRequired(photoRequirement)) photoLabel else "$photoLabel (opcjonalnie)", + done = photoCount > 0, + detail = if (photoCount > 0) "$photoCount ${photoCountLabel(photoCount)}" else if (routeStageRequirementIsRequired(photoRequirement)) "dodaj minimum jedno" else "możesz pominąć", ) } - RouteStageRequirementRow( - label = photoLabel, - done = photoCount > 0, - detail = if (photoCount > 0) "$photoCount ${photoCountLabel(photoCount)}" else "dodaj minimum jedno", - ) } } } @@ -2693,6 +2984,7 @@ private fun DetailScreen( onRefresh: () -> Unit, onStartRoute: () -> Unit, onFinishRoute: () -> Unit, + onCorrectRouteAction: () -> Unit, ) { val context = LocalContext.current val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh) @@ -2723,9 +3015,12 @@ private fun DetailScreen( feedback = state.feedback, routeActions = projection.visibleActions, routeProgressNotificationEnabled = state.routeProgressNotificationEnabled, + loadingPhotoRequirement = state.loadingPhotoRequirement, + loadingWeightRequirement = state.loadingWeightRequirement, onRefresh = onRefresh, onStart = onStartRoute, onFinish = onFinishRoute, + onCorrect = onCorrectRouteAction, ) } item { @@ -2784,11 +3079,14 @@ private fun RouteLifecycleSection( feedback: String?, routeActions: List, routeProgressNotificationEnabled: Boolean, + loadingPhotoRequirement: String, + loadingWeightRequirement: String, onRefresh: () -> Unit, onStart: () -> Unit, onFinish: () -> Unit, + onCorrect: () -> Unit, ) { - val steps = routeFlowSteps(route, routeActions) + val steps = routeFlowSteps(route, routeActions, loadingPhotoRequirement, loadingWeightRequirement) val callout = routeSyncCallout(routeActions) val action = routeLifecyclePrimaryAction(route, selectedDate) val instruction = routeTodayInstruction(route, selectedDate) @@ -2821,8 +3119,8 @@ private fun RouteLifecycleSection( container = if (callout.isConflict) TppTheme.colors.warningContainer else TppTheme.colors.panel, outline = if (callout.isConflict) TppTheme.colors.warningOutline else TppTheme.colors.outline, color = if (callout.isConflict) TppTheme.colors.error else TppTheme.colors.muted, - actionLabel = "Odśwież dane", - onAction = onRefresh, + actionLabel = if (callout.canCorrect) "Popraw dane" else "Odśwież dane", + onAction = if (callout.canCorrect) onCorrect else onRefresh, ) } @@ -3304,7 +3602,7 @@ private fun RouteStageDocumentationBlock( } private fun formatRouteWeight(weight: Double): String = - if (weight % 1.0 == 0.0) "${weight.toInt()} kg" else String.format(Locale("pl", "PL"), "%.2f kg", weight) + if (weight % 1.0 == 0.0) "${weight.toInt()} t" else String.format(Locale("pl", "PL"), "%.3f t", weight) @Composable private fun CargoActionButton(label: String, icon: ImageVector, color: Color, onClick: () -> Unit) { @@ -3878,22 +4176,25 @@ private fun ErrorText(error: String?) { @Composable private fun OfflineStaleBanner(state: DriverUiState) { - val syncLabel = state.lastSuccessfulSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji" - val message = offlineStaleBannerMessage( + val syncLabel = state.lastServerSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji" + val message = connectionStatusBannerMessage( isOnline = state.isOnline, isStale = state.isStale, + serverConnectionState = state.serverConnectionState, + showRealtimeReconnecting = state.showRealtimeReconnecting, syncLabel = syncLabel, ) ?: return + val warning = !state.isOnline || state.serverConnectionState == ServerConnectionState.Degraded || state.isStale Card( - colors = CardDefaults.cardColors(containerColor = TppTheme.colors.warningContainer), - border = BorderStroke(1.dp, TppTheme.colors.warningOutline), + colors = CardDefaults.cardColors(containerColor = if (warning) TppTheme.colors.warningContainer else TppTheme.colors.panel), + border = BorderStroke(1.dp, if (warning) TppTheme.colors.warningOutline else TppTheme.colors.outline), shape = MaterialTheme.shapes.small, modifier = Modifier.fillMaxWidth(), ) { Text( text = message, - color = Color(0xFF5F4200), + color = if (warning) Color(0xFF5F4200) else TppTheme.colors.muted, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(12.dp), ) diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt index 222454d..1f0e309 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -45,6 +45,7 @@ data class RouteLifecycleActionUi( data class RouteSyncCalloutUi( val text: String, val isConflict: Boolean, + val canCorrect: Boolean, ) fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean = @@ -131,39 +132,90 @@ fun canFinishRouteFromDriverApp( && runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today && routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today -fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUploadCount: Int): Boolean { - return routeStageSubmitBlocker(weightText, serverPhotoCount, localUploadCount) == null +object RouteStageRequirement { + const val Required = "required" + const val Optional = "optional" + const val Disabled = "disabled" } -fun loadingPhotosSubmitBlocker(serverPhotoCount: Int, localUploadCount: Int): String? = - if (visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0) { +fun normalizeRouteStageRequirement(value: String?): String = when (value) { + RouteStageRequirement.Optional -> RouteStageRequirement.Optional + RouteStageRequirement.Disabled -> RouteStageRequirement.Disabled + else -> RouteStageRequirement.Required +} + +fun routeStageRequirementIsVisible(requirement: String): Boolean = + normalizeRouteStageRequirement(requirement) != RouteStageRequirement.Disabled + +fun routeStageRequirementIsRequired(requirement: String): Boolean = + normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required + +fun canSubmitRouteStageForm( + weightText: String, + serverPhotoCount: Int, + localUploadCount: Int, + weightRequirement: String = RouteStageRequirement.Required, + photoRequirement: String = RouteStageRequirement.Required, +): Boolean { + return routeStageSubmitBlocker( + weightText, + serverPhotoCount, + localUploadCount, + weightRequirement, + photoRequirement, + ) == null +} + +fun loadingPhotosSubmitBlocker( + serverPhotoCount: Int, + localUploadCount: Int, + photoRequirement: String = RouteStageRequirement.Required, +): String? = + if ( + routeStageRequirementIsRequired(photoRequirement) && + visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 + ) { "Dodaj co najmniej jedno zdjęcie załadunku." } else { null } -fun loadingWeightSubmitBlocker(weightText: String, serverPhotoCount: Int, localUploadCount: Int): String? { +fun loadingWeightSubmitBlocker( + weightText: String, + serverPhotoCount: Int, + localUploadCount: Int, + weightRequirement: String = RouteStageRequirement.Required, + photoRequirement: String = RouteStageRequirement.Required, +): String? { val trimmed = weightText.trim() val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() + val weightVisible = routeStageRequirementIsVisible(weightRequirement) return when { - trimmed.isBlank() -> "Podaj wagę." - normalizedWeight == null -> "Podaj poprawną wagę." - normalizedWeight <= 0.0 -> "Waga musi być większa od zera." - loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount) != null -> "Dodaj co najmniej jedno zdjęcie załadunku." + weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż." + weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż." + weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera." + loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku." else -> null } } -fun routeStageSubmitBlocker(weightText: String, serverPhotoCount: Int, localUploadCount: Int): String? { +fun routeStageSubmitBlocker( + weightText: String, + serverPhotoCount: Int, + localUploadCount: Int, + weightRequirement: String = RouteStageRequirement.Required, + photoRequirement: String = RouteStageRequirement.Required, +): String? { val trimmed = weightText.trim() val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() + val weightVisible = routeStageRequirementIsVisible(weightRequirement) return when { - trimmed.isBlank() -> "Podaj wagę." - normalizedWeight == null -> "Podaj poprawną wagę." - normalizedWeight <= 0.0 -> "Waga musi być większa od zera." - visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu." + weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż." + weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż." + weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera." + routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu." else -> null } } @@ -199,10 +251,16 @@ fun routeSyncCallout(actions: List): RouteSyncCalloutUi? { } }, isConflict = isConflict, + canCorrect = unresolved.status == RouteActionStatus.FailedPermanent, ) } -fun routeFlowSteps(route: DriverRouteDto, actions: List): List { +fun routeFlowSteps( + route: DriverRouteDto, + actions: List, + loadingPhotoRequirement: String = RouteStageRequirement.Required, + loadingWeightRequirement: String = RouteStageRequirement.Required, +): List { val startOverride = routeActionStepState(actions, RouteActionType.Start) val finishOverride = routeActionStepState(actions, RouteActionType.Finish) val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size @@ -232,12 +290,16 @@ fun routeFlowSteps(route: DriverRouteDto, actions: List): Lis else -> RouteFlowStepState.Todo } - return listOf( - RouteFlowStepUi("loading_photos", "Zdjęcia załadunku", loadingPhotosState, routeFlowStateLabel(loadingPhotosState), routeScheduleDateLabel(route.routeDate)), - RouteFlowStepUi("loading_weight", "Waga załadunku", loadingWeightState, routeFlowStateLabel(loadingWeightState), routeScheduleDateLabel(route.routeDate)), - RouteFlowStepUi("transit", "W trasie", transitState, routeFlowStateLabel(transitState)), - RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState), routeScheduleDateLabel(routeUnloadingDate(route))), - ) + return buildList { + if (routeStageRequirementIsVisible(loadingPhotoRequirement)) { + add(RouteFlowStepUi("loading_photos", "Zdjęcia załadunku", loadingPhotosState, routeFlowStateLabel(loadingPhotosState), routeScheduleDateLabel(route.routeDate))) + } + if (routeStageRequirementIsVisible(loadingWeightRequirement)) { + add(RouteFlowStepUi("loading_weight", "Tonaż załadunku", loadingWeightState, routeFlowStateLabel(loadingWeightState), routeScheduleDateLabel(route.routeDate))) + } + add(RouteFlowStepUi("transit", "W trasie", transitState, routeFlowStateLabel(transitState))) + add(RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState), routeScheduleDateLabel(routeUnloadingDate(route)))) + } } fun routePhotosForStage(photos: List?, stage: String): List { @@ -441,6 +503,21 @@ fun offlineStaleBannerMessage(isOnline: Boolean, isStale: Boolean, syncLabel: St else -> "Dane mogą być nieaktualne. Ostatnia synchronizacja: $syncLabel." } +fun connectionStatusBannerMessage( + isOnline: Boolean, + isStale: Boolean, + serverConnectionState: ServerConnectionState, + showRealtimeReconnecting: Boolean, + syncLabel: String, +): String? = when { + !isOnline -> offlineStaleBannerMessage(false, isStale, syncLabel) + serverConnectionState == ServerConnectionState.Degraded -> + "Internet działa, ale serwer jest chwilowo niedostępny. Pokazujemy dane z $syncLabel." + showRealtimeReconnecting -> "Ponowne łączenie. Dane są uzgadniane z serwerem." + isStale -> offlineStaleBannerMessage(true, true, syncLabel) + else -> null +} + private fun parseIsoOffsetEpochMillis(value: String?): Long? = value?.takeIf { it.isNotBlank() }?.let { runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull() 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 87efc40..bf0a1da 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -11,8 +11,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.time.LocalDate import java.time.Instant +import java.io.IOException import pl.firmatpp.kierowca.data.AppPreferencesStore import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorKind @@ -30,16 +32,30 @@ import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox +import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus +import pl.firmatpp.kierowca.data.upload.DriverDatabase +import pl.firmatpp.kierowca.data.upload.OfflineOutboxManager import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionOutbox +import pl.firmatpp.kierowca.data.upload.RouteActionStatus +import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.diagnostics.AppDiagnostics +import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry +import pl.firmatpp.kierowca.diagnostics.DiagnosticSection +import pl.firmatpp.kierowca.diagnostics.DiagnosticSeverity +import pl.firmatpp.kierowca.diagnostics.DiagnosticSnapshot +import pl.firmatpp.kierowca.diagnostics.DiagnosticSnapshotProvider import pl.firmatpp.kierowca.sync.DriverLiveSyncClient +import pl.firmatpp.kierowca.sync.LiveSyncConnectionState import pl.firmatpp.kierowca.sync.DriverSyncHint import pl.firmatpp.kierowca.sync.DriverSyncWorker +import pl.firmatpp.kierowca.sync.DriverRuntimeSyncState import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService import pl.firmatpp.kierowca.ui.theme.AppThemeMode -enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, StartRoute, LoadingWeight, FinishRoute, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, LeaveCalendar, AddLeaveRequest } +enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Diagnostics, Detail, StartRoute, LoadingWeight, FinishRoute, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, LeaveCalendar, AddLeaveRequest } + +enum class ServerConnectionState { Unknown, Reachable, Degraded } data class DriverUiState( val screen: DriverScreen = DriverScreen.Initializing, @@ -57,6 +73,10 @@ data class DriverUiState( val allowGalleryUploads: Boolean = true, val allowRouteCompletion: Boolean = false, val requirePreciseLocationForPhotos: Boolean = false, + val loadingPhotoRequirement: String = RouteStageRequirement.Required, + val loadingWeightRequirement: String = RouteStageRequirement.Required, + val unloadingPhotoRequirement: String = RouteStageRequirement.Required, + val unloadingWeightRequirement: String = RouteStageRequirement.Required, val notifyNewRoutes: Boolean = false, val routeProgressNotificationEnabled: Boolean = true, val notificationPermissionDenied: Boolean = false, @@ -85,12 +105,22 @@ data class DriverUiState( val completingRoute: Boolean = false, val imageAuthHeader: String? = null, val isOnline: Boolean = true, + val serverConnectionState: ServerConnectionState = ServerConnectionState.Unknown, + val realtimeConnectionState: LiveSyncConnectionState = LiveSyncConnectionState.Stopped, + val showRealtimeReconnecting: Boolean = false, val isStale: Boolean = false, - val lastSuccessfulSyncAtEpochMillis: Long? = null, + val lastServerSyncAtEpochMillis: Long? = null, val routeDayLiveUpdateMessage: String? = null, val themeMode: AppThemeMode = AppThemeMode.Default, val feedback: String? = null, val error: String? = null, + val showLogoutWarning: Boolean = false, + val pendingLogoutItems: Int = 0, + val pendingDispatchUploads: Int = 0, + val pendingGpsPoints: Int = 0, + val diagnostics: DiagnosticSnapshot? = null, + val diagnosticsLoading: Boolean = false, + val diagnosticsError: String? = null, ) { private val projectionActions: List get() = (visibleRouteActions + routeActions).distinctBy { it.clientActionId } @@ -107,13 +137,21 @@ data class DriverUiState( val displaySelectedRoute: DriverRouteDto? get() = selectedRouteProjection?.route + + val pendingOperationalItems: Int + get() = queuedPhotoUploads.count { it.status != PhotoUploadStatus.Confirmed.storageValue && it.status != PhotoUploadStatus.Cancelled.storageValue } + + visibleRouteActions.count { it.status != RouteActionStatus.Confirmed } + pendingDispatchUploads + pendingGpsPoints + + val operationalQueueNeedsAttention: Boolean + get() = queuedPhotoUploads.any { it.status == PhotoUploadStatus.FailedPermanent.storageValue } || + visibleRouteActions.any { it.status == RouteActionStatus.FailedPermanent || it.status == RouteActionStatus.FailedConflict } } -private fun DriverUiState.withApiError(throwable: Throwable): DriverUiState { +internal fun DriverUiState.withApiError(throwable: Throwable): DriverUiState { val apiError = ApiErrorMapper.map(throwable) return copy( - isOnline = if (apiError.kind == ApiErrorKind.Network) false else isOnline, - isStale = if (apiError.kind == ApiErrorKind.Network && lastSuccessfulSyncAtEpochMillis != null) true else isStale, + serverConnectionState = if (apiError.kind == ApiErrorKind.Network || apiError.kind == ApiErrorKind.Server) ServerConnectionState.Degraded else serverConnectionState, + isStale = if ((apiError.kind == ApiErrorKind.Network || apiError.kind == ApiErrorKind.Server) && lastServerSyncAtEpochMillis != null) true else isStale, error = apiError.message, ) } @@ -146,20 +184,29 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) private val photoUploadOutbox = PhotoUploadOutbox(application) private val routeActionOutbox = RouteActionOutbox(application) private val dispatchSheetUploadOutbox = DispatchSheetUploadOutbox(application) + private val offlineOutboxManager = OfflineOutboxManager(application) + private val diagnosticSnapshotProvider = DiagnosticSnapshotProvider(application) private val _state = MutableStateFlow(DriverUiState(loading = true)) private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy() private val liveSyncClient = DriverLiveSyncClient( repository = repository, onConnected = { viewModelScope.launch { checkRemoteSyncState() } }, onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } }, + onAuthenticationFailed = { viewModelScope.launch { handleExpiredSession() } }, ) private var photoUploadsJob: Job? = null private var routeActionsJob: Job? = null private var dispatchSheetUploadsJob: Job? = null + private var realtimeBannerJob: Job? = null private var pushTokenRegisteredForDriverId: String? = null private val leaveMutationMutex = Mutex() + private val syncCheckMutex = Mutex() + private val syncHintMutex = Mutex() + private val highestHintVersions = mutableMapOf() private var pendingLeaveHint: DriverSyncHint? = null private var leaveCalendarRequestGeneration: Long = 0 + private var routesRequestGeneration: Long = 0 + private var routeDetailRequestGeneration: Long = 0 val state: StateFlow = _state init { @@ -183,18 +230,53 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(visibleRouteActions = actions) } } } + viewModelScope.launch { + DriverDatabase.get(application).dispatchSheetUploadDao().observeUnsentCount().collect { count -> + _state.update { it.copy(pendingDispatchUploads = count) } + } + } + viewModelScope.launch { + DriverDatabase.get(application).routePointDao().observeUnsentCount().collect { count -> + _state.update { it.copy(pendingGpsPoints = count) } + } + } + viewModelScope.launch { + liveSyncClient.connectionState.collect { connectionState -> + DriverRuntimeSyncState.realtimeConnected = connectionState == LiveSyncConnectionState.Connected + realtimeBannerJob?.cancel() + _state.update { + it.copy( + realtimeConnectionState = connectionState, + showRealtimeReconnecting = false, + ) + } + if (connectionState in setOf( + LiveSyncConnectionState.Connecting, + LiveSyncConnectionState.Subscribing, + LiveSyncConnectionState.Disconnected, + )) { + realtimeBannerJob = viewModelScope.launch { + kotlinx.coroutines.delay(3_000L) + _state.update { + it.copy(showRealtimeReconnecting = it.isOnline && it.realtimeConnectionState == connectionState) + } + } + } + } + } viewModelScope.launch { networkMonitor.isOnline.collect { online -> liveSyncClient.onNetworkAvailable(online) _state.update { it.copy( isOnline = online, - isStale = if (!online && it.lastSuccessfulSyncAtEpochMillis != null) true else it.isStale, + isStale = if (!online && it.lastServerSyncAtEpochMillis != null) true else it.isStale, + serverConnectionState = if (!online) ServerConnectionState.Unknown else it.serverConnectionState, ) } if (online && repository.hasToken()) { liveSyncClient.ensureConnected() - refreshCurrentScopeFromSyncState() + checkRemoteSyncState() } } } @@ -208,6 +290,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } fun requestOtp(phone: String) = runLoading("request_otp") { + if (!_state.value.isOnline) throw IOException("Logowanie wymaga połączenia z internetem.") val response = repository.requestOtp(phone) otpAutoSubmitPolicy.reset() _state.update { @@ -222,6 +305,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } fun verifyOtp(code: String) = runLoading("verify_otp") { + if (!_state.value.isOnline) throw IOException("Weryfikacja kodu wymaga połączenia z internetem.") val driver = repository.verifyOtp(_state.value.phone, code, android.os.Build.MODEL ?: "Android") AppDiagnostics.setDriverId(driver.id) _state.update { it.copy(driver = driver, imageAuthHeader = repository.imageAuthHeader()) } @@ -242,11 +326,21 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false) fun onAppForegrounded() { + DriverRuntimeSyncState.foreground = true + liveSyncClient.setForeground(true) + if (!_state.value.isOnline || _state.value.driver == null) return liveSyncClient.ensureConnected() + refreshCurrentVisibleScope() + } + + fun onAppBackgrounded() { + DriverRuntimeSyncState.foreground = false + liveSyncClient.setForeground(false) + } + + fun periodicSyncCheck() { + if (!_state.value.isOnline) return viewModelScope.launch { checkRemoteSyncState() } - if (_state.value.screen == DriverScreen.Routes) { - refreshRoutesSilently() - } } fun selectRouteDate(date: String) { @@ -259,14 +353,21 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) { + val generation = ++routesRequestGeneration viewModelScope.launch { _state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) } runCatching { - val cached = syncRepository.bootstrap(date) + val cached = if (_state.value.isOnline) { + syncRepository.bootstrap(date) + } else { + syncRepository.bootstrapFromCache(date) + ?: throw IOException("Brak zapisanych danych dla wybranego dnia.") + } val response = cached.value val settings = response.driverAppSettings AppDiagnostics.setDriverId(response.session.driver.id) + if (generation != routesRequestGeneration) return@runCatching _state.update { it.copy( driver = response.session.driver, @@ -280,6 +381,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion, requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos ?: it.requirePreciseLocationForPhotos, + loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement), + loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement), + unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement), + unloadingWeightRequirement = normalizeRouteStageRequirement(settings?.unloadingWeightRequirement), leaveRequestsConfig = settings?.leaveRequests?.let { config -> LeaveRequestsConfig(enabled = config.enabled, types = config.types) }, @@ -290,16 +395,18 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes, imageAuthHeader = repository.imageAuthHeader(), isStale = cached.stale || !it.isOnline, - lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis, + lastServerSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastServerSyncAtEpochMillis, + serverConnectionState = if (cached.stale) ServerConnectionState.Degraded else ServerConnectionState.Reachable, error = null, ) } response.session.driver.id.let { driverId -> liveSyncClient.start(driverId, response.realtime) - registerPushTokenIfAvailable(driverId) + if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId) } observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate) }.onFailure { throwable -> + if (generation != routesRequestGeneration) return@onFailure reportHandledException("load_routes", throwable, mapOf("date" to date)) _state.update { val apiError = ApiErrorMapper.map(throwable) @@ -313,7 +420,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } - _state.update { + if (generation == routesRequestGeneration) _state.update { it.copy(loading = false, refreshing = false) } } @@ -322,7 +429,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun openRoute(routeId: String) = openRouteWithTarget(routeId, DriverScreen.Detail) private fun openRouteWithTarget(routeId: String, targetScreen: DriverScreen) = runLoading("open_route", mapOf("route_id" to routeId)) { - val cached = syncRepository.route(routeId) + val generation = ++routeDetailRequestGeneration + val cached = if (_state.value.isOnline) { + syncRepository.route(routeId) + } else { + syncRepository.routeFromCache(routeId) + ?: throw IOException("Szczegóły tej trasy nie zostały zapisane w telefonie.") + } + if (generation != routeDetailRequestGeneration) return@runLoading val response = cached.value photoUploadOutbox.discardConfirmedServerPhotos(response.route) _state.update { @@ -336,7 +450,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) }, feedback = null, isStale = cached.stale || !it.isOnline, - lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis, + lastServerSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastServerSyncAtEpochMillis, + serverConnectionState = if (cached.stale) ServerConnectionState.Degraded else ServerConnectionState.Reachable, error = null, ) } @@ -346,12 +461,17 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun refreshSelectedRoute() { val routeId = _state.value.selectedRoute?.id ?: return + val generation = ++routeDetailRequestGeneration viewModelScope.launch { _state.update { it.copy(refreshing = true, feedback = null, error = null) } - runCatching { syncRepository.route(routeId) } + runCatching { + if (_state.value.isOnline) syncRepository.route(routeId) + else syncRepository.routeFromCache(routeId) ?: throw IOException("Szczegóły tej trasy nie zostały zapisane w telefonie.") + } .onSuccess { cached -> + if (generation != routeDetailRequestGeneration) return@onSuccess val response = cached.value photoUploadOutbox.discardConfirmedServerPhotos(response.route) _state.update { @@ -359,16 +479,17 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) selectedRoute = response.route, routes = it.routes.map { route -> if (route.id == routeId) response.route else route }, isStale = cached.stale || !it.isOnline, - lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis, + lastServerSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastServerSyncAtEpochMillis, + serverConnectionState = if (cached.stale) ServerConnectionState.Degraded else ServerConnectionState.Reachable, error = null, ) } } .onFailure { throwable -> - _state.update { it.withApiError(throwable) } + if (generation == routeDetailRequestGeneration) _state.update { it.withApiError(throwable) } } - _state.update { it.copy(refreshing = false) } + if (generation == routeDetailRequestGeneration) _state.update { it.copy(refreshing = false) } } } @@ -376,7 +497,97 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(screen = DriverScreen.Profile, error = null) } } + fun openDiagnostics() { + _state.update { it.copy(screen = DriverScreen.Diagnostics, diagnosticsError = null) } + refreshDiagnostics() + } + + fun refreshDiagnostics() { + viewModelScope.launch { + _state.update { it.copy(diagnosticsLoading = true, diagnosticsError = null) } + val runtime = _state.value + val liveDiagnostics = liveSyncClient.diagnostics() + val hintVersions = syncHintMutex.withLock { highestHintVersions.toMap() } + val runtimeSections = listOf( + DiagnosticSection( + "Połączenie i synchronizacja", + listOf( + DiagnosticEntry("Sieć według aplikacji", if (runtime.isOnline) "online" else "offline", if (runtime.isOnline) DiagnosticSeverity.Good else DiagnosticSeverity.Error), + DiagnosticEntry("Serwer", runtime.serverConnectionState.name, if (runtime.serverConnectionState == ServerConnectionState.Reachable) DiagnosticSeverity.Good else DiagnosticSeverity.Warning), + DiagnosticEntry("Realtime", runtime.realtimeConnectionState.name, if (runtime.realtimeConnectionState == LiveSyncConnectionState.Connected) DiagnosticSeverity.Good else DiagnosticSeverity.Warning), + DiagnosticEntry("Realtime skonfigurowany", if (liveDiagnostics.configured) "tak" else "nie"), + DiagnosticEntry("Realtime aktywny docelowo", if (liveDiagnostics.desiredActive) "tak" else "nie"), + DiagnosticEntry("Sieć widziana przez realtime", if (liveDiagnostics.networkAvailable) "dostępna" else "niedostępna"), + DiagnosticEntry("Aplikacja na pierwszym planie", if (DriverRuntimeSyncState.foreground) "tak" else "nie"), + DiagnosticEntry("Foreground klienta realtime", if (liveDiagnostics.foregroundActive) "tak" else "nie"), + DiagnosticEntry("WebSocket obsługuje hinty", if (DriverRuntimeSyncState.websocketOwnsForegroundHints()) "tak" else "nie"), + DiagnosticEntry("Socket ID", liveDiagnostics.socketId ?: "brak"), + DiagnosticEntry("Próba reconnect", liveDiagnostics.reconnectAttempt.toString()), + DiagnosticEntry("Licznik wiadomości", liveDiagnostics.messageVersion.toString()), + DiagnosticEntry("Ostatnia wiadomość realtime", liveDiagnostics.lastMessageAtEpochMillis?.let { Instant.ofEpochMilli(it).toString() } ?: "brak"), + DiagnosticEntry("Ostatnie rozłączenie", liveDiagnostics.lastDisconnectReason ?: "brak"), + DiagnosticEntry("Dane nieaktualne", if (runtime.isStale) "tak" else "nie", if (runtime.isStale) DiagnosticSeverity.Warning else DiagnosticSeverity.Good), + DiagnosticEntry("Ostatnia synchronizacja serwera", runtime.lastServerSyncAtEpochMillis?.let { Instant.ofEpochMilli(it).toString() } ?: "brak"), + DiagnosticEntry("Fallback kontroli", "co ${runtime.autoRefreshSeconds} s bez zdrowego realtime; co 5 min z realtime"), + DiagnosticEntry("Najwyższe odebrane hinty", hintVersions.entries.sortedBy { it.key }.joinToString { "${it.key}=v${it.value}" }.ifBlank { "brak" }), + DiagnosticEntry("Oczekujący hint urlopowy", pendingLeaveHint?.let { "${it.scope}, v${it.version}" } ?: "brak"), + ), + ), + DiagnosticSection( + "Bieżąca sesja", + listOf( + DiagnosticEntry("Kierowca", runtime.driver?.displayName.orEmpty().ifBlank { "brak" }), + DiagnosticEntry("ID kierowcy", runtime.driver?.id ?: "brak"), + DiagnosticEntry("Ekran", runtime.screen.name), + DiagnosticEntry("Wybrany dzień", runtime.selectedDate), + DiagnosticEntry("Wybrana trasa", runtime.selectedRoute?.id ?: "brak"), + DiagnosticEntry("Trasy w pamięci", runtime.routes.size.toString()), + DiagnosticEntry("Wnioski w pamięci", runtime.leaveRequests.size.toString()), + DiagnosticEntry("Push zarejestrowany dla sesji", if (pushTokenRegisteredForDriverId == runtime.driver?.id) "tak" else "nie", if (pushTokenRegisteredForDriverId == runtime.driver?.id) DiagnosticSeverity.Good else DiagnosticSeverity.Warning), + DiagnosticEntry("Ostatni komunikat", runtime.feedback ?: "brak"), + DiagnosticEntry("Ostatni błąd UI", runtime.error ?: "brak", if (runtime.error == null) DiagnosticSeverity.Good else DiagnosticSeverity.Warning), + ), + ), + DiagnosticSection( + "Konfiguracja aplikacji", + listOf( + DiagnosticEntry("Zakres dni", "${runtime.minRouteDate} – ${runtime.maxRouteDate}"), + DiagnosticEntry("Galeria", if (runtime.allowGalleryUploads) "włączona" else "wyłączona"), + DiagnosticEntry("Kończenie kursu", if (runtime.allowRouteCompletion) "włączone" else "wyłączone"), + DiagnosticEntry("Dokładna lokalizacja zdjęć", if (runtime.requirePreciseLocationForPhotos) "wymagana" else "niewymagana"), + DiagnosticEntry("Załadunek, zdjęcie", runtime.loadingPhotoRequirement), + DiagnosticEntry("Załadunek, tonaż", runtime.loadingWeightRequirement), + DiagnosticEntry("Rozładunek, zdjęcie", runtime.unloadingPhotoRequirement), + DiagnosticEntry("Rozładunek, tonaż", runtime.unloadingWeightRequirement), + DiagnosticEntry("Powiadomienia o trasach", if (runtime.notifyNewRoutes) "włączone" else "wyłączone"), + DiagnosticEntry("Pasek aktywnego kursu", if (runtime.routeProgressNotificationEnabled) "włączony" else "wyłączony"), + DiagnosticEntry("Motyw", runtime.themeMode.name), + DiagnosticEntry("Wnioski urlopowe", if (runtime.leaveRequestsConfig?.enabled == true) "włączone" else "wyłączone"), + ), + ), + ) + + runCatching { diagnosticSnapshotProvider.capture(runtimeSections) } + .onSuccess { snapshot -> + _state.update { it.copy(diagnostics = snapshot, diagnosticsLoading = false, diagnosticsError = null) } + } + .onFailure { throwable -> + reportHandledException("diagnostics_snapshot", throwable) + _state.update { + it.copy( + diagnosticsLoading = false, + diagnosticsError = "Nie udało się zebrać wszystkich danych: ${throwable.message ?: throwable::class.java.simpleName}", + ) + } + } + } + } + fun updateNewRouteNotifications(enabled: Boolean) { + if (!_state.value.isOnline) { + _state.update { it.copy(error = "Zmiana powiadomień wymaga połączenia z internetem.") } + return + } viewModelScope.launch { _state.update { it.copy(refreshing = true, error = null, notificationPermissionDenied = false) } runCatching { repository.updateNotificationPreferences(enabled) } @@ -428,11 +639,24 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun openLeaveRequests() { if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return + if (!_state.value.isOnline) { + _state.update { + it.copy( + screen = DriverScreen.LeaveRequests, + error = "Wnioski urlopowe są dostępne tylko online. Pokazujemy dane zapisane w tej sesji.", + ) + } + return + } viewModelScope.launch { loadLeaveRequests(showLoading = true, navigateToList = true) } } fun refreshLeaveRequests() { if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return + if (!_state.value.isOnline) { + _state.update { it.copy(error = "Odświeżenie wniosków wymaga połączenia z internetem.") } + return + } viewModelScope.launch { loadLeaveRequests(showLoading = false, navigateToList = false) } } @@ -448,6 +672,17 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun openLeaveRequest(id: String) { if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return + if (!_state.value.isOnline) { + val cached = _state.value.leaveRequests.firstOrNull { it.id == id } + _state.update { + it.copy( + screen = if (cached != null) DriverScreen.LeaveRequestDetail else it.screen, + selectedLeaveRequest = cached ?: it.selectedLeaveRequest, + error = "Szczegóły wniosku wymagają połączenia z internetem.", + ) + } + return + } viewModelScope.launch { _state.update { it.copy(loading = true, error = null, feedback = null) } runCatching { repository.leaveRequest(id) } @@ -462,6 +697,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun openAddLeaveRequest() { val config = _state.value.leaveRequestsConfig if (!DriverLeaveRequestUiRules.isFeatureVisible(config)) return + if (!_state.value.isOnline) { + _state.update { it.copy(error = "Złożenie wniosku urlopowego wymaga połączenia z internetem.") } + return + } val today = LocalDate.now().toString() _state.update { it.copy( @@ -550,6 +789,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun loadMoreLeaveCalendarMonths(reset: Boolean = false) { val snapshot = _state.value if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig) || snapshot.leaveCalendarLoading) return + if (!snapshot.isOnline) { + _state.update { it.copy(error = "Kalendarz urlopów wymaga połączenia z internetem.") } + return + } val from = if (reset || snapshot.leaveCalendarLoadedUntil == null) { LocalDate.now() @@ -593,6 +836,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun submitLeaveRequest() { val snapshot = _state.value if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return + if (!snapshot.isOnline) { + _state.update { it.copy(error = "Wysłanie wniosku wymaga połączenia z internetem.") } + return + } if (!leaveMutationMutex.tryLock()) return if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) { _state.update { it.copy(error = "Data od nie może być z przeszłości.") } @@ -634,6 +881,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun cancelSelectedLeaveRequest() { val request = _state.value.selectedLeaveRequest ?: return + if (!_state.value.isOnline) { + _state.update { it.copy(error = "Anulowanie wniosku wymaga połączenia z internetem.") } + return + } if (!DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) return if (!leaveMutationMutex.tryLock()) return @@ -697,9 +948,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") } return } + val targetScreen = if ( + !routeStageRequirementIsVisible(snapshot.loadingPhotoRequirement) && + routeStageRequirementIsVisible(snapshot.loadingWeightRequirement) + ) DriverScreen.LoadingWeight else DriverScreen.StartRoute _state.update { it.copy( - screen = DriverScreen.StartRoute, + screen = targetScreen, routeStageWeightText = "", feedback = null, error = null, @@ -715,6 +970,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) val blocker = loadingPhotosSubmitBlocker( serverPhotoCount = stagePhotos.size, localUploadCount = stageUploads.size, + photoRequirement = snapshot.loadingPhotoRequirement, ) if (blocker != null) { _state.update { it.copy(error = blocker) } @@ -753,6 +1009,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(routeStageWeightText = normalized) } } + fun correctFailedRouteAction() { + val snapshot = _state.value + val action = (snapshot.visibleRouteActions + snapshot.routeActions) + .distinctBy { it.clientActionId } + .filter { it.status == RouteActionStatus.FailedPermanent } + .maxByOrNull { it.createdAtEpochMillis } + ?: return + + viewModelScope.launch { + runCatching { routeActionOutbox.discard(action.clientActionId) } + .onSuccess { + _state.update { + it.copy( + routeActions = it.routeActions.filterNot { item -> item.clientActionId == action.clientActionId }, + visibleRouteActions = it.visibleRouteActions.filterNot { item -> item.clientActionId == action.clientActionId }, + error = null, + ) + } + if (action.action == RouteActionType.Start) openStartRoute() else openFinishRoute() + } + .onFailure { throwable -> _state.update { it.withApiError(throwable) } } + } + } + fun submitStartRoute() { submitRouteStage(stage = "loading") } @@ -780,7 +1060,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) return } val driver = snapshot.driver - val weight = snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull() + val photoRequirement = if (stage == "loading") snapshot.loadingPhotoRequirement else snapshot.unloadingPhotoRequirement + val weightRequirement = if (stage == "loading") snapshot.loadingWeightRequirement else snapshot.unloadingWeightRequirement + val weight = if (routeStageRequirementIsVisible(weightRequirement)) { + snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull() + } else { + null + } val stagePhotos = routePhotosForStage(route.photos, stage) val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } val submitBlocker = if (stage == "loading") { @@ -788,26 +1074,34 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) weightText = snapshot.routeStageWeightText, serverPhotoCount = stagePhotos.size, localUploadCount = stageUploads.size, + weightRequirement = weightRequirement, + photoRequirement = photoRequirement, ) } else { routeStageSubmitBlocker( weightText = snapshot.routeStageWeightText, serverPhotoCount = stagePhotos.size, localUploadCount = stageUploads.size, + weightRequirement = weightRequirement, + photoRequirement = photoRequirement, ) } - if (weight == null || submitBlocker != null) { - _state.update { it.copy(error = submitBlocker ?: "Podaj poprawną wagę.") } + if (submitBlocker != null) { + _state.update { it.copy(error = submitBlocker) } return } - val photoClientRequestIds = (stagePhotos.mapNotNull { it.clientRequestId } + stageUploads.map { it.clientRequestId }) + val photoClientRequestIds = if (routeStageRequirementIsVisible(photoRequirement)) { + (stagePhotos.mapNotNull { it.clientRequestId } + stageUploads.map { it.clientRequestId }) .map { it.trim() } .filter { it.isNotBlank() } .distinct() + } else { + emptyList() + } - if (photoClientRequestIds.isEmpty()) { + if (routeStageRequirementIsRequired(photoRequirement) && photoClientRequestIds.isEmpty()) { _state.update { it.copy(error = "Zdjęcie musi mieć lokalny identyfikator synchronizacji.") } return } @@ -838,7 +1132,6 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) else -> route } - syncRepository.cacheConfirmedRoute(updatedRoute, snapshot.selectedDate) _state.update { it.copy( screen = DriverScreen.Detail, @@ -846,9 +1139,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item }, routeStageWeightText = "", feedback = if (stage == "loading") { - "Kurs rozpoczęty lokalnie. Synchronizacja pójdzie w tle." + if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu." } else { - "Kurs zakończony lokalnie. Synchronizacja pójdzie w tle." + if (snapshot.isOnline) "Potwierdzam rozładunek z serwerem." else "Rozładunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu." }, error = null, ) @@ -905,6 +1198,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun deletePhoto(photo: RoutePhotoDto) { val route = _state.value.selectedRoute ?: return + if (!_state.value.isOnline) { + _state.update { it.copy(error = "Usunięcie zdjęcia wymaga połączenia z internetem.") } + return + } viewModelScope.launch { _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + photo.id, error = null) } runCatching { @@ -927,6 +1224,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun deleteConfirmedUpload(upload: PhotoUploadEntity) { val route = _state.value.selectedRoute ?: return + if (!_state.value.isOnline) { + _state.update { it.copy(error = "Usunięcie wysłanego zdjęcia wymaga połączenia z internetem.") } + return + } val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) ?: return viewModelScope.launch { @@ -953,7 +1254,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun completeSelectedRoute() { val snapshot = _state.value val route = snapshot.selectedRoute ?: return - if (snapshot.completingRoute || !snapshot.isOnline) return + if (snapshot.completingRoute) return + if (!snapshot.isOnline) { + _state.update { it.copy(error = "Ta operacja zakończenia kursu wymaga połączenia z internetem.") } + return + } if (!canCompleteRouteFromDriverApp(route, snapshot.selectedDate)) { _state.update { it.copy(error = "Zakończenie kursu jest dostępne w dniu zaplanowanego rozładunku.") } return @@ -1014,18 +1319,41 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) it.copy(screen = DriverScreen.Routes, selectedRoute = null, routeActions = emptyList(), photoUploads = emptyList(), feedback = null) } DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes) + DriverScreen.Diagnostics -> it.copy(screen = DriverScreen.Profile) DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone) else -> it } } } - fun logout() = runLoading("logout") { + fun requestLogout() { + viewModelScope.launch { + val pending = offlineOutboxManager.pendingCount() + if (pending > 0) { + _state.update { it.copy(showLogoutWarning = true, pendingLogoutItems = pending) } + } else { + performLogout() + } + } + } + + fun dismissLogoutWarning() { + _state.update { it.copy(showLogoutWarning = false) } + } + + fun confirmLogoutAndDeletePending() { + _state.update { it.copy(showLogoutWarning = false) } + performLogout() + } + + private fun performLogout() = runLoading("logout") { photoUploadsJob?.cancel() routeActionsJob?.cancel() dispatchSheetUploadsJob?.cancel() + realtimeBannerJob?.cancel() liveSyncClient.stop() - repository.logout() + offlineOutboxManager.clearAll() + repository.logout(notifyServer = _state.value.isOnline) syncRepository.clearCache() pushTokenRegisteredForDriverId = null AppDiagnostics.clearDriverId() @@ -1039,11 +1367,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) { viewModelScope.launch { _state.update { it.copy(loading = true, error = null) } - runCatching { block() } - .onFailure { throwable -> - reportHandledException(operation, throwable, keys) + val result = runCatching { block() } + result.exceptionOrNull()?.let { throwable -> + reportHandledException(operation, throwable, keys) + if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth && _state.value.driver != null) { + handleExpiredSession() + } else { _state.update { it.withApiError(throwable) } } + } _state.update { it.copy(loading = false) } } } @@ -1080,45 +1412,68 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } - private fun refreshCurrentScopeFromSyncState() { - val snapshot = _state.value - if (snapshot.screen == DriverScreen.Routes) { - refreshRoutesSilently() - return - } - if (snapshot.screen == DriverScreen.Detail) { - refreshSelectedRoute() - } - if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) { - refreshLeaveRequests() + private fun refreshCurrentVisibleScope() { + when (_state.value.screen) { + DriverScreen.Routes -> refreshRoutesSilently() + DriverScreen.Detail -> refreshSelectedRoute() + DriverScreen.LeaveRequests, DriverScreen.LeaveRequestDetail -> refreshLeaveRequests() + else -> viewModelScope.launch { checkRemoteSyncState() } } } private suspend fun checkRemoteSyncState() { - val snapshot = _state.value - val routeId = snapshot.selectedRoute?.id - val remote = runCatching { syncRepository.fetchSyncState(snapshot.selectedDate, routeId) }.getOrNull() ?: return - val staleScopes = remote.scopes.filter { syncRepository.shouldRefresh(it) } + if (!_state.value.isOnline) return + syncCheckMutex.withLock { + syncHintMutex.withLock { highestHintVersions.clear() } + val snapshot = _state.value + val routeId = snapshot.selectedRoute?.id + val result = runCatching { syncRepository.fetchSyncState(snapshot.selectedDate, routeId) } + val remote = result.getOrElse { throwable -> + val apiError = ApiErrorMapper.map(throwable) + if (apiError.kind == ApiErrorKind.Auth) { + handleExpiredSession() + } else { + _state.update { + it.copy( + serverConnectionState = ServerConnectionState.Degraded, + isStale = it.lastServerSyncAtEpochMillis != null, + ) + } + } + return@withLock + } + _state.update { it.copy(serverConnectionState = ServerConnectionState.Reachable) } + val staleScopes = remote.scopes.filter { syncRepository.shouldRefresh(it) } - if (staleScopes.isEmpty()) { - syncRepository.saveSyncStates(remote) - return - } + if (staleScopes.isEmpty()) { + syncRepository.saveSyncStates(remote) + return@withLock + } - staleScopes.forEach { scope -> - handleSyncHint( - DriverSyncHint( - scope = scope.scope, - date = scope.date, - routeId = scope.routeId, - version = scope.version, - checksum = scope.checksum, - ), - ) + staleScopes.forEach { scope -> + if (scope.scope == DriverSyncRepository.SCOPE_SESSION) { + syncRepository.saveSyncScope(scope) + return@forEach + } + handleSyncHint( + DriverSyncHint( + scope = scope.scope, + date = scope.date, + routeId = scope.routeId, + version = scope.version, + checksum = scope.checksum, + ), + ) + } } } private suspend fun handleSyncHint(hint: DriverSyncHint) { + syncHintMutex.withLock { + val hintKey = listOf(hint.scope, hint.date ?: "-", hint.routeId ?: "-").joinToString(":") + val previousVersion = highestHintVersions[hintKey] + if (previousVersion != null && hint.version <= previousVersion) return + highestHintVersions[hintKey] = hint.version if (!syncRepository.shouldRefresh(hint.asScope())) return val snapshot = _state.value @@ -1132,14 +1487,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } } else { - DriverSyncWorker.enqueue(getApplication(), hint.date, null) + DriverSyncWorker.enqueue(getApplication(), hint.date, null, hint.scope, hint.version, hint.checksum) } } DriverSyncRepository.SCOPE_ROUTE_DETAIL -> { if (hint.routeId == snapshot.selectedRoute?.id) { refreshSelectedRoute() } else { - DriverSyncWorker.enqueue(getApplication(), hint.date, hint.routeId) + DriverSyncWorker.enqueue(getApplication(), hint.date, hint.routeId, hint.scope, hint.version, hint.checksum) } } DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently() @@ -1165,6 +1520,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) syncRepository.saveSyncScope(hint.asScope()) } } + DriverSyncRepository.SCOPE_SESSION -> { + runCatching { syncRepository.fetchSyncState(snapshot.selectedDate, snapshot.selectedRoute?.id) } + .onSuccess { syncRepository.saveSyncStates(it) } + .onFailure { throwable -> + if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) handleExpiredSession() + } + } + } + } + } + + private suspend fun handleExpiredSession() { + liveSyncClient.stop() + offlineOutboxManager.clearAll() + repository.logout(notifyServer = false) + syncRepository.clearCache() + pushTokenRegisteredForDriverId = null + AppDiagnostics.clearDriverId() + _state.update { + DriverUiState( + screen = DriverScreen.Phone, + loading = false, + error = "Sesja wygasła lub została cofnięta. Zaloguj się ponownie.", + ) } } @@ -1242,6 +1621,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } override fun onCleared() { + DriverRuntimeSyncState.foreground = false + DriverRuntimeSyncState.realtimeConnected = false liveSyncClient.close() photoUploadsJob?.cancel() routeActionsJob?.cancel() diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/sync/NetworkMonitorTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/sync/NetworkMonitorTest.kt new file mode 100644 index 0000000..086c5e4 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/sync/NetworkMonitorTest.kt @@ -0,0 +1,17 @@ +package pl.firmatpp.kierowca.data.sync + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NetworkMonitorTest { + @Test + fun captivePortalIsNotTreatedAsOnline() { + assertFalse(hasValidatedInternetCapabilities(hasInternet = true, hasValidated = false)) + } + + @Test + fun validatedInternetIsOnline() { + assertTrue(hasValidatedInternetCapabilities(hasInternet = true, hasValidated = true)) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/diagnostics/DiagnosticSnapshotProviderTest.kt b/app/src/test/java/pl/firmatpp/kierowca/diagnostics/DiagnosticSnapshotProviderTest.kt new file mode 100644 index 0000000..bafe5c8 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/diagnostics/DiagnosticSnapshotProviderTest.kt @@ -0,0 +1,39 @@ +package pl.firmatpp.kierowca.diagnostics + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiagnosticSnapshotProviderTest { + @Test + fun secureIdentifierSummaryDoesNotExposeWholeToken() { + val token = "secret-session-token-123456" + + val summary = secureIdentifierSummary(token) + + assertFalse(summary.contains(token)) + assertTrue(summary.contains("długość ${token.length}")) + assertTrue(summary.contains("końcówka …3456")) + assertTrue(summary.contains("SHA-256")) + } + + @Test + fun copiedReportExplainsThatCredentialsAreRedacted() { + val snapshot = DiagnosticSnapshot( + generatedAtEpochMillis = 1_700_000_000_000, + sections = listOf( + DiagnosticSection( + title = "Sesja", + entries = listOf(DiagnosticEntry("Token", secureIdentifierSummary("top-secret"))), + ), + ), + ) + + val report = snapshot.asPlainText() + + assertTrue(report.contains("[Sesja]")) + assertTrue(report.contains("Token: obecny")) + assertTrue(report.contains("nie zawiera danych pozwalających przejąć sesję")) + assertFalse(report.contains("top-secret")) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClientTest.kt b/app/src/test/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClientTest.kt index ed2943a..8b15a85 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClientTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClientTest.kt @@ -81,6 +81,23 @@ class DriverLiveSyncClientTest { assertEquals(2, factory.sockets.size) } + @Test + fun backgroundClosesSocketAndForegroundReconnects() = runTest { + val factory = FakeWebSocketFactory() + val client = liveClient(factory = factory, scope = backgroundScope) + + client.start("driver-1", config) + client.setForeground(false) + + assertTrue(factory.sockets.single().closed) + assertEquals(LiveSyncConnectionState.Stopped, client.connectionState.value) + + client.setForeground(true) + advanceUntilIdle() + + assertEquals(2, factory.sockets.size) + } + @Test fun retriesWhenPrivateChannelSubscriptionAuthFails() = runTest { val gateway = FakeLiveSyncGateway(authFailure = RuntimeException("offline")) diff --git a/app/src/test/java/pl/firmatpp/kierowca/sync/DriverRuntimeSyncStateTest.kt b/app/src/test/java/pl/firmatpp/kierowca/sync/DriverRuntimeSyncStateTest.kt new file mode 100644 index 0000000..743ca1c --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/sync/DriverRuntimeSyncStateTest.kt @@ -0,0 +1,24 @@ +package pl.firmatpp.kierowca.sync + +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DriverRuntimeSyncStateTest { + @After + fun reset() { + DriverRuntimeSyncState.foreground = false + DriverRuntimeSyncState.realtimeConnected = false + } + + @Test + fun websocketSuppressesDuplicateFcmSyncOnlyWhenForegroundAndConnected() { + DriverRuntimeSyncState.foreground = true + DriverRuntimeSyncState.realtimeConnected = true + assertTrue(DriverRuntimeSyncState.websocketOwnsForegroundHints()) + + DriverRuntimeSyncState.realtimeConnected = false + assertFalse(DriverRuntimeSyncState.websocketOwnsForegroundHints()) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt index 73c10cd..d554c08 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -261,6 +261,32 @@ class DriverUiRulesTest { ) } + @Test + fun healthyOnlineStateDoesNotShowOfflineBanner() { + assertEquals( + null, + connectionStatusBannerMessage( + isOnline = true, + isStale = false, + serverConnectionState = ServerConnectionState.Reachable, + showRealtimeReconnecting = false, + syncLabel = "12:30", + ), + ) + } + + @Test + fun serverFailureAndRealtimeReconnectAreNotCalledOffline() { + assertEquals( + "Internet działa, ale serwer jest chwilowo niedostępny. Pokazujemy dane z 12:30.", + connectionStatusBannerMessage(true, true, ServerConnectionState.Degraded, false, "12:30"), + ) + assertEquals( + "Ponowne łączenie. Dane są uzgadniane z serwerem.", + connectionStatusBannerMessage(true, false, ServerConnectionState.Reachable, true, "12:30"), + ) + } + @Test fun explainsStaleCachedDataWhenOnline() { assertEquals( @@ -321,7 +347,7 @@ class DriverUiRulesTest { val steps = routeFlowSteps(route, emptyList()) val action = routeLifecyclePrimaryAction(route, "2026-06-30", today) - assertEquals(listOf("Zdjęcia załadunku", "Waga załadunku", "W trasie", "Rozładunek"), steps.map { it.title }) + assertEquals(listOf("Zdjęcia załadunku", "Tonaż załadunku", "W trasie", "Rozładunek"), steps.map { it.title }) assertEquals(RouteFlowStepState.Todo, steps[0].state) assertEquals(RouteFlowStepState.Todo, steps[1].state) assertEquals(RouteFlowStepState.Todo, steps[2].state) @@ -393,12 +419,22 @@ class DriverUiRulesTest { assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state) assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text) assertTrue(callout?.isConflict ?: false) + assertFalse(callout?.canCorrect ?: true) + } + + @Test + fun permanentlyRejectedRouteActionOffersDataCorrection() { + val callout = routeSyncCallout( + listOf(action(RouteActionType.Start, RouteActionStatus.FailedPermanent, "Brakuje wymaganych danych.")), + ) + + assertTrue(callout?.canCorrect ?: false) } @Test fun routeStageSubmitBlockerExplainsMissingRequirements() { - assertEquals("Podaj wagę.", routeStageSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0)) - assertEquals("Waga musi być większa od zera.", routeStageSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0)) + assertEquals("Podaj tonaż.", routeStageSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0)) + assertEquals("Tonaż musi być większy od zera.", routeStageSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0)) assertEquals("Dodaj co najmniej jedno zdjęcie etapu.", routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 0)) assertEquals(null, routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1)) } @@ -415,9 +451,9 @@ class DriverUiRulesTest { @Test fun loadingWeightStepRequiresPositiveWeightAndPreviousLoadingPhoto() { - assertEquals("Podaj wagę.", loadingWeightSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0)) - assertEquals("Podaj poprawną wagę.", loadingWeightSubmitBlocker("abc", serverPhotoCount = 1, localUploadCount = 0)) - assertEquals("Waga musi być większa od zera.", loadingWeightSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0)) + assertEquals("Podaj tonaż.", loadingWeightSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0)) + assertEquals("Podaj poprawny tonaż.", loadingWeightSubmitBlocker("abc", serverPhotoCount = 1, localUploadCount = 0)) + assertEquals("Tonaż musi być większy od zera.", loadingWeightSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0)) assertEquals( "Dodaj co najmniej jedno zdjęcie załadunku.", loadingWeightSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 0), @@ -425,6 +461,61 @@ class DriverUiRulesTest { assertEquals(null, loadingWeightSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1)) } + @Test + fun optionalStageInputsAreVisibleButDoNotBlockConfirmation() { + assertEquals( + null, + routeStageSubmitBlocker( + weightText = "", + serverPhotoCount = 0, + localUploadCount = 0, + weightRequirement = RouteStageRequirement.Optional, + photoRequirement = RouteStageRequirement.Optional, + ), + ) + assertEquals( + "Podaj poprawny tonaż.", + routeStageSubmitBlocker( + weightText = "abc", + serverPhotoCount = 0, + localUploadCount = 0, + weightRequirement = RouteStageRequirement.Optional, + photoRequirement = RouteStageRequirement.Optional, + ), + ) + } + + @Test + fun disabledStageInputsAreHiddenAndDoNotBlockConfirmation() { + assertEquals(RouteStageRequirement.Required, normalizeRouteStageRequirement(null)) + assertEquals(RouteStageRequirement.Required, normalizeRouteStageRequirement("unknown")) + assertFalse(routeStageRequirementIsVisible(RouteStageRequirement.Disabled)) + assertTrue(routeStageRequirementIsVisible(RouteStageRequirement.Optional)) + assertTrue(routeStageRequirementIsRequired(RouteStageRequirement.Required)) + assertEquals( + null, + routeStageSubmitBlocker( + weightText = "invalid value is ignored", + serverPhotoCount = 0, + localUploadCount = 0, + weightRequirement = RouteStageRequirement.Disabled, + photoRequirement = RouteStageRequirement.Disabled, + ), + ) + } + + @Test + fun disabledLoadingStepsAreRemovedFromRouteFlow() { + val steps = routeFlowSteps( + route(status = "ZAPLANOWANA"), + emptyList(), + loadingPhotoRequirement = RouteStageRequirement.Disabled, + loadingWeightRequirement = RouteStageRequirement.Disabled, + ) + + assertEquals(listOf("W trasie", "Rozładunek"), steps.map { it.title }) + } + private fun route( status: String = "ZAPLANOWANA", loadingWeight: Double? = 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 5e18ff0..cdbdc97 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt @@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.ui import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.io.IOException import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.upload.RouteActionEntity @@ -26,6 +27,21 @@ class DriverUiStateTest { assertEquals(AppThemeMode.Material3, state.themeMode) } + @Test + fun networkTimeoutDoesNotChangeValidatedNetworkState() { + val state = DriverUiState( + isOnline = true, + serverConnectionState = ServerConnectionState.Reachable, + lastServerSyncAtEpochMillis = 123L, + ) + + val failed = state.withApiError(IOException("timeout")) + + assertTrue(failed.isOnline) + assertTrue(failed.isStale) + assertEquals(ServerConnectionState.Degraded, failed.serverConnectionState) + } + @Test fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() { val state = DriverUiState(