From 9ba1b1fa130575340614edc5796121e38eb81af3 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 1 Jul 2026 10:59:21 +0200 Subject: [PATCH] =?UTF-8?q?Dodaj=20live=20sync=20i=20kolejk=C4=99=20zdj?= =?UTF-8?q?=C4=99=C4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + app/build.gradle.kts | 18 +- app/src/main/AndroidManifest.xml | 8 + .../kierowca/data/DriverRepository.kt | 20 ++ .../kierowca/data/api/MobileDriverApi.kt | 29 +++ .../kierowca/data/model/DriverModels.kt | 29 +++ .../kierowca/data/sync/DriverCacheDao.kt | 36 ++++ .../kierowca/data/sync/DriverCacheEntities.kt | 49 +++++ .../data/sync/DriverSyncRepository.kt | 127 +++++++++++++ .../kierowca/data/sync/NetworkMonitor.kt | 38 ++++ .../kierowca/data/upload/DriverDatabase.kt | 60 +++++- .../kierowca/data/upload/PhotoUploadDao.kt | 10 + .../kierowca/data/upload/PhotoUploadOutbox.kt | 3 + .../sync/DriverFirebaseMessagingService.kt | 33 ++++ .../kierowca/sync/DriverLiveSyncClient.kt | 135 ++++++++++++++ .../firmatpp/kierowca/sync/DriverSyncHint.kt | 21 +++ .../kierowca/sync/DriverSyncWorker.kt | 77 ++++++++ .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 176 +++++++++++++++++- .../pl/firmatpp/kierowca/ui/DriverUiRules.kt | 3 + .../firmatpp/kierowca/ui/DriverViewModel.kt | 157 +++++++++++++++- .../firmatpp/kierowca/ui/DriverUiRulesTest.kt | 19 ++ build.gradle.kts | 1 + gradle/libs.versions.toml | 5 + 23 files changed, 1039 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheDao.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheEntities.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/sync/NetworkMonitor.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncHint.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt diff --git a/.gitignore b/.gitignore index 6253853..1a2cd4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .gradle/ build/ local.properties +app/google-services.json +app/google-services.json:Zone.Identifier */build/ *.iml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 00c523d..46f51f0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,19 +5,31 @@ plugins { alias(libs.plugins.ksp) } +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} + android { namespace = "pl.firmatpp.kierowca" compileSdk = 35 + fun configValue(name: String, default: String): String = + providers.gradleProperty(name) + .orElse(providers.environmentVariable(name)) + .orElse(default) + .get() + defaultConfig { applicationId = "pl.firmatpp.kierowca" minSdk = 26 targetSdk = 35 - versionCode = 17 - versionName = "1.0.16" + versionCode = 18 + versionName = "1.0.17" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"") + buildConfigField("String", "REVERB_APP_KEY", "\"${configValue("REVERB_APP_KEY", "")}\"") + buildConfigField("String", "REVERB_WS_BASE_URL", "\"${configValue("REVERB_WS_BASE_URL", "wss://api-intranet.firmatpp.pl/app/")}\"") } buildTypes { @@ -77,6 +89,8 @@ dependencies { implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.tooling.preview) implementation(libs.coroutines.android) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging) implementation(libs.okhttp) implementation(libs.okhttp.logging) implementation(libs.play.services.auth) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1229ae2..bd4844a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,5 +30,13 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> + + + + + + 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 67e9453..c92e7a5 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -12,11 +12,15 @@ import okhttp3.RequestBody.Companion.toRequestBody import pl.firmatpp.kierowca.data.api.ApiFactory import pl.firmatpp.kierowca.data.api.MobileDriverApi import pl.firmatpp.kierowca.data.model.BootstrapResponse +import pl.firmatpp.kierowca.data.model.BroadcastAuthBody +import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.PhotoUploadResponse +import pl.firmatpp.kierowca.data.model.PushTokenBody import pl.firmatpp.kierowca.data.model.RequestOtpBody import pl.firmatpp.kierowca.data.model.RouteResponse +import pl.firmatpp.kierowca.data.model.SyncStateResponse import pl.firmatpp.kierowca.data.model.VerifyOtpBody import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity @@ -42,6 +46,21 @@ class DriverRepository( suspend fun route(routeId: String): RouteResponse = api.route(authHeader(requireToken()), routeId) + suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse = + api.syncState(authHeader(requireToken()), date, routeId) + + suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse = + api.broadcastAuth(authHeader(requireToken()), BroadcastAuthBody(socketId, channelName)) + + suspend fun storePushToken(token: String) { + api.storePushToken(authHeader(requireToken()), PushTokenBody(token)) + } + + suspend fun deletePushToken() { + val token = tokenStore.read() ?: return + runCatching { api.deletePushToken(authHeader(token)) } + } + suspend fun uploadPhoto( routeId: String, uri: Uri, @@ -106,6 +125,7 @@ class DriverRepository( suspend fun logout() { val token = tokenStore.read() if (token != null) { + runCatching { api.deletePushToken(authHeader(token)) } runCatching { api.logout(authHeader(token)) } } tokenStore.clear() diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt index 503a075..1579c90 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt @@ -3,10 +3,14 @@ package pl.firmatpp.kierowca.data.api import okhttp3.MultipartBody import okhttp3.RequestBody import pl.firmatpp.kierowca.data.model.BootstrapResponse +import pl.firmatpp.kierowca.data.model.BroadcastAuthBody +import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.PhotoUploadResponse +import pl.firmatpp.kierowca.data.model.PushTokenBody import pl.firmatpp.kierowca.data.model.RequestOtpBody import pl.firmatpp.kierowca.data.model.RouteResponse +import pl.firmatpp.kierowca.data.model.SyncStateResponse import pl.firmatpp.kierowca.data.model.VerifyOtpBody import pl.firmatpp.kierowca.data.model.VerifyOtpResponse import retrofit2.http.Body @@ -15,6 +19,7 @@ import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.PUT import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query @@ -41,6 +46,30 @@ interface MobileDriverApi { @Path("routeId") routeId: String, ): RouteResponse + @GET("mobile/driver/sync-state") + suspend fun syncState( + @Header("Authorization") authorization: String, + @Query("date") date: String?, + @Query("routeId") routeId: String?, + ): SyncStateResponse + + @POST("mobile/driver/broadcasting/auth") + suspend fun broadcastAuth( + @Header("Authorization") authorization: String, + @Body body: BroadcastAuthBody, + ): BroadcastAuthResponse + + @PUT("mobile/driver/push-token") + suspend fun storePushToken( + @Header("Authorization") authorization: String, + @Body body: PushTokenBody, + ): Map + + @DELETE("mobile/driver/push-token") + suspend fun deletePushToken( + @Header("Authorization") authorization: String, + ): Map + @Multipart @POST("mobile/driver/routes/{routeId}/photos") suspend fun uploadPhoto( 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 bd0919f..2ae5a67 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 @@ -46,6 +46,7 @@ data class BootstrapResponse( val session: DriverSessionDto, val routes: RoutesBucketDto, val driverAppSettings: DriverAppSettingsDto?, + val syncState: SyncStateResponse? = null, ) data class DriverAppSettingsDto( @@ -69,6 +70,34 @@ data class RoutesBucketDto( data class RouteResponse( val route: DriverRouteDto, + val syncState: SyncStateResponse? = null, +) + +data class SyncStateResponse( + val scopes: List = emptyList(), +) + +data class SyncScopeDto( + val scope: String, + val date: String? = null, + val routeId: String? = null, + val version: Long, + val checksum: String, + val computedAt: String, +) + +data class BroadcastAuthBody( + val socket_id: String, + val channel_name: String, +) + +data class BroadcastAuthResponse( + val auth: String, +) + +data class PushTokenBody( + val token: String, + val platform: String = "android", ) data class DriverRouteDto( 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 new file mode 100644 index 0000000..6cb2a58 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheDao.kt @@ -0,0 +1,36 @@ +package pl.firmatpp.kierowca.data.sync + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface DriverCacheDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertBootstrap(cache: DriverBootstrapCacheEntity) + + @Query("SELECT * FROM driver_bootstrap_cache WHERE date = :date LIMIT 1") + suspend fun bootstrap(date: String): DriverBootstrapCacheEntity? + + @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? + + @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("DELETE FROM driver_bootstrap_cache") + suspend fun clearBootstrap() + + @Query("DELETE FROM driver_route_cache") + suspend fun clearRoutes() + + @Query("DELETE FROM driver_sync_states") + suspend fun clearSyncStates() +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheEntities.kt b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheEntities.kt new file mode 100644 index 0000000..aafd6d1 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheEntities.kt @@ -0,0 +1,49 @@ +package pl.firmatpp.kierowca.data.sync + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "driver_bootstrap_cache") +data class DriverBootstrapCacheEntity( + @PrimaryKey val date: String, + val payloadJson: String, + val checksum: String?, + val version: Long?, + val syncedAtEpochMillis: Long, +) + +@Entity(tableName = "driver_route_cache") +data class DriverRouteCacheEntity( + @PrimaryKey val routeId: String, + val payloadJson: String, + val checksum: String?, + val version: Long?, + val syncedAtEpochMillis: Long, +) + +@Entity( + tableName = "driver_sync_states", + indices = [ + Index(value = ["scope", "date", "routeId"], unique = true), + ], +) +data class DriverSyncStateEntity( + @PrimaryKey val key: String, + val scope: String, + val date: String?, + val routeId: String?, + val checksum: String, + val version: Long, + val computedAt: String, + val syncedAtEpochMillis: Long, +) + +data class CachedValue( + val value: T, + val stale: Boolean, + val syncedAtEpochMillis: Long?, +) + +fun syncStateKey(scope: String, date: String?, routeId: String?): String = + listOf(scope, date ?: "-", routeId ?: "-").joinToString(":") 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 new file mode 100644 index 0000000..657fde4 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt @@ -0,0 +1,127 @@ +package pl.firmatpp.kierowca.data.sync + +import android.content.Context +import com.google.gson.Gson +import java.io.IOException +import pl.firmatpp.kierowca.data.DriverRepository +import pl.firmatpp.kierowca.data.model.BootstrapResponse +import pl.firmatpp.kierowca.data.model.RouteResponse +import pl.firmatpp.kierowca.data.model.SyncScopeDto +import pl.firmatpp.kierowca.data.model.SyncStateResponse +import pl.firmatpp.kierowca.data.upload.DriverDatabase +import retrofit2.HttpException + +class DriverSyncRepository( + context: Context, + private val repository: DriverRepository = DriverRepository(context), + private val dao: DriverCacheDao = DriverDatabase.get(context).driverCacheDao(), + private val gson: Gson = Gson(), +) { + suspend fun bootstrap(date: String?): CachedValue { + val requestedDate = date ?: currentDateFallback() + + return runCatching { + val response = repository.bootstrap(date) + val selectedDate = response.driverAppSettings?.selectedDate ?: requestedDate + val routeState = response.syncState?.scopes?.firstOrNull { it.scope == SCOPE_ROUTES } + dao.upsertBootstrap( + DriverBootstrapCacheEntity( + date = selectedDate, + payloadJson = gson.toJson(response), + checksum = routeState?.checksum, + version = routeState?.version, + syncedAtEpochMillis = System.currentTimeMillis(), + ), + ) + saveSyncStates(response.syncState) + CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis()) + }.getOrElse { throwable -> + cachedBootstrap(requestedDate, throwable) + } + } + + suspend fun route(routeId: String): CachedValue = + runCatching { + val response = repository.route(routeId) + val routeState = response.syncState?.scopes?.firstOrNull { it.scope == SCOPE_ROUTE_DETAIL } + dao.upsertRoute( + DriverRouteCacheEntity( + routeId = routeId, + payloadJson = gson.toJson(response), + checksum = routeState?.checksum, + version = routeState?.version, + syncedAtEpochMillis = System.currentTimeMillis(), + ), + ) + saveSyncStates(response.syncState) + CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis()) + }.getOrElse { throwable -> + cachedRoute(routeId, throwable) + } + + suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse = + repository.syncState(date, routeId) + + suspend fun shouldRefresh(scope: SyncScopeDto): Boolean { + val local = dao.syncState(scope.scope, scope.date, scope.routeId) + return local == null || local.checksum != scope.checksum || local.version < scope.version + } + + suspend fun saveSyncStates(response: SyncStateResponse?) { + response?.scopes.orEmpty().forEach { scope -> + dao.upsertSyncState( + DriverSyncStateEntity( + key = syncStateKey(scope.scope, scope.date, scope.routeId), + scope = scope.scope, + date = scope.date, + routeId = scope.routeId, + checksum = scope.checksum, + version = scope.version, + computedAt = scope.computedAt, + syncedAtEpochMillis = System.currentTimeMillis(), + ), + ) + } + } + + suspend fun clearCache() { + dao.clearBootstrap() + dao.clearRoutes() + dao.clearSyncStates() + } + + private suspend fun cachedBootstrap(date: String, throwable: Throwable): CachedValue { + val cached = dao.bootstrap(date) + if (cached != null && throwable.isReadFallbackAllowed()) { + return CachedValue( + gson.fromJson(cached.payloadJson, BootstrapResponse::class.java), + stale = true, + syncedAtEpochMillis = cached.syncedAtEpochMillis, + ) + } + throw throwable + } + + private suspend fun cachedRoute(routeId: String, throwable: Throwable): CachedValue { + val cached = dao.route(routeId) + if (cached != null && throwable.isReadFallbackAllowed()) { + return CachedValue( + gson.fromJson(cached.payloadJson, RouteResponse::class.java), + stale = true, + syncedAtEpochMillis = cached.syncedAtEpochMillis, + ) + } + throw throwable + } + + private fun Throwable.isReadFallbackAllowed(): Boolean = + this is IOException || (this is HttpException && code() != 401 && code() != 403) + + private fun currentDateFallback(): String = java.time.LocalDate.now().toString() + + companion object { + const val SCOPE_ROUTES = "routes" + const val SCOPE_ROUTE_DETAIL = "route_detail" + const val SCOPE_SETTINGS = "settings" + } +} 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 new file mode 100644 index 0000000..c7b19fd --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/NetworkMonitor.kt @@ -0,0 +1,38 @@ +package pl.firmatpp.kierowca.data.sync + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +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 + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(current()) + } + + override fun onLost(network: Network) { + trySend(current()) + } + + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + trySend(current()) + } + } + + trySend(current()) + connectivityManager.registerDefaultNetworkCallback(callback) + awaitClose { connectivityManager.unregisterNetworkCallback(callback) } + }.distinctUntilChanged() +} 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 9781984..81a745c 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 @@ -1,29 +1,83 @@ package pl.firmatpp.kierowca.data.upload import android.content.Context +import androidx.room.migration.Migration import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import pl.firmatpp.kierowca.data.sync.DriverBootstrapCacheEntity +import pl.firmatpp.kierowca.data.sync.DriverCacheDao +import pl.firmatpp.kierowca.data.sync.DriverRouteCacheEntity +import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity @Database( - entities = [PhotoUploadEntity::class], - version = 1, + entities = [ + PhotoUploadEntity::class, + DriverBootstrapCacheEntity::class, + DriverRouteCacheEntity::class, + DriverSyncStateEntity::class, + ], + version = 2, exportSchema = false, ) abstract class DriverDatabase : RoomDatabase() { abstract fun photoUploadDao(): PhotoUploadDao + abstract fun driverCacheDao(): DriverCacheDao companion object { @Volatile private var instance: DriverDatabase? = null + private val migration1To2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS driver_bootstrap_cache ( + date TEXT NOT NULL PRIMARY KEY, + payloadJson TEXT NOT NULL, + checksum TEXT, + version INTEGER, + syncedAtEpochMillis INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS driver_route_cache ( + routeId TEXT NOT NULL PRIMARY KEY, + payloadJson TEXT NOT NULL, + checksum TEXT, + version INTEGER, + syncedAtEpochMillis INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS driver_sync_states ( + key TEXT NOT NULL PRIMARY KEY, + scope TEXT NOT NULL, + date TEXT, + routeId TEXT, + checksum TEXT NOT NULL, + version INTEGER NOT NULL, + computedAt TEXT NOT NULL, + syncedAtEpochMillis INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_driver_sync_states_scope_date_routeId ON driver_sync_states(scope, date, routeId)") + } + } + fun get(context: Context): DriverDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, DriverDatabase::class.java, "driver-local-outbox.db", - ).build().also { instance = it } + ).addMigrations(migration1To2).build().also { instance = it } } } } 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 41329ba..1435bf5 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 @@ -24,6 +24,16 @@ interface PhotoUploadDao { ) fun observeVisibleForRoute(routeId: String): Flow> + @Query( + """ + SELECT * FROM photo_uploads + WHERE status != 'CONFIRMED' + AND status != 'CANCELLED' + ORDER BY createdAtEpochMillis ASC + """, + ) + fun observeQueuedUploads(): Flow> + @Query( """ SELECT * FROM photo_uploads diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt index 7bc8129..8d40510 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt @@ -25,6 +25,9 @@ class PhotoUploadOutbox( fun observeVisibleForRoute(routeId: String): Flow> = dao.observeVisibleForRoute(routeId) + fun observeQueuedUploads(): Flow> = + dao.observeQueuedUploads() + suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata): PhotoUploadEntity { val clientRequestId = UUID.randomUUID().toString() val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt new file mode 100644 index 0000000..3d5bc29 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt @@ -0,0 +1,33 @@ +package pl.firmatpp.kierowca.sync + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import pl.firmatpp.kierowca.data.DriverRepository + +class DriverFirebaseMessagingService : FirebaseMessagingService() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onNewToken(token: String) { + scope.launch { + val repository = DriverRepository(applicationContext) + if (repository.hasToken()) { + runCatching { repository.storePushToken(token) } + } + } + } + + override fun onMessageReceived(message: RemoteMessage) { + val data = message.data + if (data["type"] != "driver_sync_hint") return + + DriverSyncWorker.enqueue( + context = applicationContext, + date = data["date"], + routeId = data["routeId"], + ) + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt new file mode 100644 index 0000000..cc41de8 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt @@ -0,0 +1,135 @@ +package pl.firmatpp.kierowca.sync + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import pl.firmatpp.kierowca.BuildConfig +import pl.firmatpp.kierowca.data.DriverRepository + +class DriverLiveSyncClient( + private val repository: DriverRepository, + private val onConnected: () -> Unit, + private val onHint: (DriverSyncHint) -> Unit, + private val client: OkHttpClient = OkHttpClient(), + private val gson: Gson = Gson(), +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val started = AtomicBoolean(false) + private var webSocket: WebSocket? = null + private var driverId: String? = null + + fun start(driverId: String) { + if (BuildConfig.REVERB_APP_KEY.isBlank()) return + this.driverId = driverId + if (!started.compareAndSet(false, true)) return + + val wsUrl = BuildConfig.REVERB_WS_BASE_URL.trimEnd('/') + + "/" + BuildConfig.REVERB_APP_KEY + + "?protocol=7&client=android&version=1.0&flash=false" + + webSocket = client.newWebSocket( + Request.Builder().url(wsUrl).build(), + object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) { + handleMessage(webSocket, text) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + started.set(false) + scheduleReconnect() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { + started.set(false) + scheduleReconnect() + } + }, + ) + } + + fun stop() { + started.set(false) + webSocket?.close(1000, "logout") + webSocket = null + driverId = null + } + + fun close() { + stop() + scope.cancel() + } + + private fun handleMessage(socket: WebSocket, text: String) { + val root = runCatching { JsonParser.parseString(text).asJsonObject }.getOrNull() ?: return + val event = root.string("event") ?: return + + when (event) { + "pusher:connection_established" -> { + val socketId = root.dataObject()?.string("socket_id") ?: return + subscribe(socket, socketId) + onConnected() + } + "DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint) + } + } + + private fun subscribe(socket: WebSocket, socketId: String) { + val id = driverId ?: return + val channel = "private-driver-mobile.$id" + + scope.launch { + runCatching { + val auth = repository.broadcastAuth(socketId, channel).auth + val payload = mapOf( + "event" to "pusher:subscribe", + "data" to mapOf( + "channel" to channel, + "auth" to auth, + ), + ) + socket.send(gson.toJson(payload)) + } + } + } + + private fun parseHint(data: JsonObject?): DriverSyncHint? { + if (data == null || data.string("type") != "driver_sync_hint") return null + + return DriverSyncHint( + scope = data.string("scope") ?: return null, + date = data.string("date"), + routeId = data.string("routeId"), + version = data.string("version")?.toLongOrNull() ?: data.get("version")?.asLong ?: return null, + checksum = data.string("checksum") ?: return null, + ) + } + + private fun JsonObject.dataObject(): JsonObject? { + val data = get("data") ?: return null + return if (data.isJsonObject) data.asJsonObject else runCatching { JsonParser.parseString(data.asString).asJsonObject }.getOrNull() + } + + private fun JsonObject.string(name: String): String? = + get(name)?.takeIf { !it.isJsonNull }?.asString + + private fun scheduleReconnect() { + val id = driverId ?: return + scope.launch { + delay(5_000) + if (!started.get() && driverId == id) { + start(id) + } + } + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncHint.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncHint.kt new file mode 100644 index 0000000..a3f97c3 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncHint.kt @@ -0,0 +1,21 @@ +package pl.firmatpp.kierowca.sync + +import pl.firmatpp.kierowca.data.model.SyncScopeDto + +data class DriverSyncHint( + val scope: String, + val date: String?, + val routeId: String?, + val version: Long, + val checksum: String, +) { + fun asScope(): SyncScopeDto = + SyncScopeDto( + scope = scope, + date = date, + routeId = routeId, + version = version, + checksum = checksum, + computedAt = "", + ) +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt new file mode 100644 index 0000000..278638c --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt @@ -0,0 +1,77 @@ +package pl.firmatpp.kierowca.sync + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import java.util.concurrent.TimeUnit +import pl.firmatpp.kierowca.data.ApiErrorKind +import pl.firmatpp.kierowca.data.ApiErrorMapper +import pl.firmatpp.kierowca.data.sync.DriverSyncRepository + +class DriverSyncWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + private val syncRepository = DriverSyncRepository(appContext) + + override suspend fun doWork(): Result = + runCatching { + val date = inputData.getString(KEY_DATE) + val routeId = inputData.getString(KEY_ROUTE_ID) + val response = syncRepository.fetchSyncState(date, routeId) + var refreshed = false + + response.scopes.forEach { scope -> + if (syncRepository.shouldRefresh(scope)) { + when (scope.scope) { + DriverSyncRepository.SCOPE_ROUTES -> { + syncRepository.bootstrap(scope.date ?: date) + refreshed = true + } + DriverSyncRepository.SCOPE_ROUTE_DETAIL -> { + val id = scope.routeId ?: routeId + if (!id.isNullOrBlank()) { + syncRepository.route(id) + refreshed = true + } + } + } + } + } + + if (!refreshed) { + syncRepository.saveSyncStates(response) + } + + Result.success() + }.getOrElse { throwable -> + val error = ApiErrorMapper.map(throwable) + if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure() + } + + companion object { + private const val KEY_DATE = "date" + private const val KEY_ROUTE_ID = "routeId" + + fun enqueue(context: Context, date: String?, routeId: String?) { + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(KEY_DATE to date, KEY_ROUTE_ID to routeId)) + .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, + request, + ) + } + } +} 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 661ea96..1566dd4 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -51,6 +51,7 @@ import androidx.compose.material.icons.outlined.AddPhotoAlternate import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.material.icons.outlined.CameraAlt import androidx.compose.material.icons.outlined.CalendarToday +import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.LocationOn @@ -115,6 +116,7 @@ import com.google.android.gms.common.api.Status import java.io.File import java.time.Instant import java.time.LocalDate +import java.time.ZoneId import java.time.format.DateTimeFormatter import kotlinx.coroutines.delay import java.util.Locale @@ -176,6 +178,7 @@ fun DriverApp(viewModel: DriverViewModel) { onRefresh = viewModel::refreshRoutesSilently, onDate = viewModel::selectRouteDate, onProfile = viewModel::openProfile, + onPhotoQueue = viewModel::openPhotoQueue, onRoute = viewModel::openRoute, ) DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout) @@ -190,6 +193,11 @@ fun DriverApp(viewModel: DriverViewModel) { viewModel::refreshSelectedRoute, ) DriverScreen.Photo -> PhotoScreen(state, viewModel::back) + DriverScreen.PhotoQueue -> PhotoQueueScreen( + state = state, + onBack = viewModel::back, + onRetryUpload = viewModel::retryPhotoUpload, + ) } if (state.loading && state.screen != DriverScreen.Initializing) { @@ -406,6 +414,7 @@ private fun RoutesScreen( onRefresh: () -> Unit, onDate: (String) -> Unit, onProfile: () -> Unit, + onPhotoQueue: () -> Unit, onRoute: (String) -> Unit, ) { Scaffold( @@ -449,6 +458,8 @@ private fun RoutesScreen( onDate = onDate, ) } + item { OfflineStaleBanner(state) } + item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) } if (state.routes.isEmpty()) { item { EmptyState("Brak kursow na wybrany dzien") } } else { @@ -467,6 +478,129 @@ private fun RoutesScreen( } } +@Composable +private fun PhotoQueueBanner(uploads: List, onPhotoQueue: () -> Unit) { + val count = queuedPhotoUploadCount(uploads.map { it.status }) + if (count <= 0) return + + Card( + colors = CardDefaults.cardColors(containerColor = Color.White), + border = BorderStroke(1.dp, TppColors.Outline), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + Modifier.size(42.dp).background(TppColors.ContainerGreen, RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.CloudUpload, contentDescription = null, tint = TppColors.Forest) + } + Text( + "W kolejce do wysłania jest $count ${photoCountLabel(count)}.", + color = TppColors.Ink, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Button( + onClick = onPhotoQueue, + colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), + ) { + Text("Kolejka", color = Color.White, fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +private fun PhotoQueueScreen( + state: DriverUiState, + onBack: () -> Unit, + onRetryUpload: (PhotoUploadEntity) -> Unit, +) { + val uploads = state.queuedPhotoUploads.sortedBy { it.createdAtEpochMillis } + + Scaffold( + topBar = { DetailHeader(onBack, title = "Kolejka zdjęć") }, + containerColor = TppColors.Surface, + ) { padding -> + LazyColumn( + Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { OfflineStaleBanner(state) } + if (uploads.isEmpty()) { + item { EmptyState("Brak zdjęć oczekujących na przesłanie") } + } else { + item { + Text( + "Zdjęcia do przesłania (${uploads.size})", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = TppColors.Ink, + ) + } + items(uploads, key = { it.clientRequestId }) { upload -> + PhotoQueueItem(upload, onRetryUpload) + } + } + } + } +} + +@Composable +private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploadEntity) -> Unit) { + val status = upload.statusType + Card( + colors = CardDefaults.cardColors(containerColor = Color.White), + border = BorderStroke(1.dp, TppColors.Outline), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = File(upload.localPath), + contentDescription = "Zdjęcie z kolejki", + contentScale = ContentScale.Crop, + modifier = Modifier.size(76.dp).background(TppColors.Panel, RoundedCornerShape(6.dp)), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Kurs #${upload.routeId}", color = TppColors.Ink, fontWeight = FontWeight.Bold) + Text( + upload.lastError?.takeIf { status == PhotoUploadStatus.FailedPermanent } ?: status.label, + color = if (status == PhotoUploadStatus.FailedPermanent) TppColors.Error else TppColors.Muted, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.labelMedium, + ) + if (status == PhotoUploadStatus.Uploading || status == PhotoUploadStatus.Verifying) { + androidx.compose.material3.LinearProgressIndicator( + progress = { (upload.progress.coerceIn(0, 100) / 100f) }, + modifier = Modifier.fillMaxWidth(), + color = TppColors.Forest, + trackColor = TppColors.Outline.copy(alpha = 0.4f), + ) + } + } + if (status == PhotoUploadStatus.FailedRetryable) { + TextButton(onClick = { onRetryUpload(upload) }) { + Text("Ponów", color = TppColors.Forest, fontWeight = FontWeight.Bold) + } + } + } + } +} + @Composable private fun StitchHeader() { Column(Modifier.fillMaxWidth().background(Color.White).statusBarsPadding()) { @@ -784,6 +918,7 @@ private fun DetailScreen( verticalArrangement = Arrangement.spacedBy(24.dp), ) { item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) } + item { OfflineStaleBanner(state) } item { CargoDocumentationSection( photos = route.photos, @@ -825,7 +960,7 @@ private fun DetailScreen( } @Composable -private fun DetailHeader(onBack: () -> Unit) { +private fun DetailHeader(onBack: () -> Unit, title: String = "Szczegóły Trasy") { Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) { Row( Modifier.fillMaxWidth().height(64.dp).padding(start = 8.dp, end = 16.dp), @@ -835,7 +970,7 @@ private fun DetailHeader(onBack: () -> Unit) { Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz", tint = TppColors.Ink) } Text( - "Szczegóły Trasy", + title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = TppColors.Ink, @@ -846,6 +981,13 @@ private fun DetailHeader(onBack: () -> Unit) { } } +private fun photoCountLabel(count: Int): String = + when { + count == 1 -> "zdjęcie" + count in 2..4 -> "zdjęcia" + else -> "zdjęć" + } + @Composable private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointDto) -> Unit) { val stripColor = routeStatusColor(route.status) @@ -1334,6 +1476,36 @@ private fun ErrorText(error: String?) { if (!error.isNullOrBlank()) Text(error, color = TppColors.Error, modifier = Modifier.padding(vertical = 8.dp)) } +@Composable +private fun OfflineStaleBanner(state: DriverUiState) { + if (state.isOnline && !state.isStale) return + + val syncLabel = state.lastSuccessfulSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji" + Card( + colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF7E6)), + border = BorderStroke(1.dp, Color(0xFFE6B85C)), + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = if (state.isOnline) { + "Dane mogą być nieaktualne. Ostatnia synchronizacja: $syncLabel." + } else { + "Brak połączenia z serwerem. Dane z $syncLabel mogą być nieaktualne." + }, + color = Color(0xFF5F4200), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(12.dp), + ) + } +} + +private fun formatSyncTime(epochMillis: Long): String = + runCatching { + DateTimeFormatter.ofPattern("HH:mm", Locale("pl", "PL")) + .format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault())) + }.getOrDefault("nieznana godzina") + private fun polishPhoneDigits(value: String): String { val digits = value.filter(Char::isDigit) val withoutCountryCode = if (digits.length > 9 && digits.startsWith("48")) digits.drop(2) else digits 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 d8a22d2..01e1209 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -12,6 +12,9 @@ fun inlinePhotoGridRows(photoCount: Int): Int = fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int = serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0) +fun queuedPhotoUploadCount(statuses: List): Int = + statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" } + fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Long): Long = parseIsoOffsetEpochMillis(createdAt) ?: parseIsoOffsetEpochMillis(takenAt) 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 f5b94d8..5c1c6b4 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -11,15 +12,21 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate import pl.firmatpp.kierowca.data.ApiErrorMapper +import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.PhotoUploadMetadata +import pl.firmatpp.kierowca.data.sync.DriverSyncRepository +import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox +import pl.firmatpp.kierowca.sync.DriverLiveSyncClient +import pl.firmatpp.kierowca.sync.DriverSyncHint +import pl.firmatpp.kierowca.sync.DriverSyncWorker -enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo } +enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue } data class DriverUiState( val screen: DriverScreen = DriverScreen.Initializing, @@ -38,20 +45,50 @@ data class DriverUiState( val selectedRoute: DriverRouteDto? = null, val selectedPhoto: RoutePhotoDto? = null, val photoUploads: List = emptyList(), + val queuedPhotoUploads: List = emptyList(), val deletingPhotoIds: Set = emptySet(), val imageAuthHeader: String? = null, + val isOnline: Boolean = true, + val isStale: Boolean = false, + val lastSuccessfulSyncAtEpochMillis: Long? = null, val error: String? = null, ) class DriverViewModel(application: Application) : AndroidViewModel(application) { private val repository = DriverRepository(application) + private val syncRepository = DriverSyncRepository(application, repository) + private val networkMonitor = NetworkMonitor(application) private val photoUploadOutbox = PhotoUploadOutbox(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) } }, + ) private var photoUploadsJob: Job? = null + private var pushTokenRegisteredForDriverId: String? = null val state: StateFlow = _state init { + viewModelScope.launch { + photoUploadOutbox.observeQueuedUploads().collect { uploads -> + _state.update { it.copy(queuedPhotoUploads = uploads) } + } + } + viewModelScope.launch { + networkMonitor.isOnline.collect { online -> + _state.update { + it.copy( + isOnline = online, + isStale = if (!online && it.lastSuccessfulSyncAtEpochMillis != null) true else it.isStale, + ) + } + if (online && repository.hasToken()) { + refreshCurrentScopeFromSyncState() + } + } + } viewModelScope.launch { if (repository.hasToken()) { refreshRoutes() @@ -101,7 +138,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null) } runCatching { - val response = repository.bootstrap(date) + val cached = syncRepository.bootstrap(date) + val response = cached.value val settings = response.driverAppSettings _state.update { it.copy( @@ -114,14 +152,26 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds, allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads, imageAuthHeader = repository.imageAuthHeader(), + isStale = cached.stale || !it.isOnline, + lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis, error = null, ) } + response.session.driver.id.let { driverId -> + liveSyncClient.start(driverId) + registerPushTokenIfAvailable(driverId) + } }.onFailure { throwable -> _state.update { + val apiError = ApiErrorMapper.map(throwable) it.copy( - screen = if (it.screen == DriverScreen.Initializing) DriverScreen.Phone else it.screen, - error = throwable.message ?: "Wystapil blad.", + screen = when { + apiError.kind == ApiErrorKind.Auth -> DriverScreen.Phone + it.screen == DriverScreen.Initializing -> DriverScreen.Routes + else -> it.screen + }, + isStale = it.lastSuccessfulSyncAtEpochMillis != null, + error = apiError.message, ) } } @@ -133,9 +183,18 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } fun openRoute(routeId: String) = runLoading { - val response = repository.route(routeId) + val cached = syncRepository.route(routeId) + val response = cached.value photoUploadOutbox.discardConfirmedServerPhotos(response.route) - _state.update { it.copy(screen = DriverScreen.Detail, selectedRoute = response.route, error = null) } + _state.update { + it.copy( + screen = DriverScreen.Detail, + selectedRoute = response.route, + isStale = cached.stale || !it.isOnline, + lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis, + error = null, + ) + } observePhotoUploads(routeId) } @@ -145,13 +204,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) viewModelScope.launch { _state.update { it.copy(refreshing = true, error = null) } - runCatching { repository.route(routeId) } - .onSuccess { response -> + runCatching { syncRepository.route(routeId) } + .onSuccess { cached -> + val response = cached.value photoUploadOutbox.discardConfirmedServerPhotos(response.route) _state.update { it.copy( 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, error = null, ) } @@ -168,6 +230,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(screen = DriverScreen.Profile, error = null) } } + fun openPhotoQueue() { + _state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } + } + fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) { val route = _state.value.selectedRoute ?: return viewModelScope.launch { @@ -194,7 +260,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + photo.id, error = null) } runCatching { repository.deletePhoto(photo.id) - val response = repository.route(route.id) + val response = syncRepository.route(route.id).value photoUploadOutbox.discardConfirmedServerPhotos(response.route) _state.update { it.copy( @@ -219,7 +285,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) runCatching { repository.deletePhoto(serverPhotoId) photoUploadOutbox.discard(upload.clientRequestId) - val response = repository.route(route.id) + val response = syncRepository.route(route.id).value photoUploadOutbox.discardConfirmedServerPhotos(response.route) _state.update { it.copy( @@ -243,6 +309,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { when (it.screen) { DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) + DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) DriverScreen.Detail -> { photoUploadsJob?.cancel() it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList()) @@ -256,7 +323,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun logout() = runLoading { photoUploadsJob?.cancel() + liveSyncClient.stop() repository.logout() + syncRepository.clearCache() + pushTokenRegisteredForDriverId = null _state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) } } @@ -279,4 +349,71 @@ 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() + } + } + + private suspend fun checkRemoteSyncState() { + val snapshot = _state.value + val routeId = snapshot.selectedRoute?.id + val remote = runCatching { syncRepository.fetchSyncState(snapshot.selectedDate, routeId) }.getOrNull() ?: return + val needsRefresh = remote.scopes.any { syncRepository.shouldRefresh(it) } + + if (needsRefresh) { + refreshCurrentScopeFromSyncState() + } else { + syncRepository.saveSyncStates(remote) + } + } + + private suspend fun handleSyncHint(hint: DriverSyncHint) { + if (!syncRepository.shouldRefresh(hint.asScope())) return + + val snapshot = _state.value + when (hint.scope) { + DriverSyncRepository.SCOPE_ROUTES -> { + if (hint.date == null || hint.date == snapshot.selectedDate) { + refreshRoutesSilently() + } else { + DriverSyncWorker.enqueue(getApplication(), hint.date, null) + } + } + DriverSyncRepository.SCOPE_ROUTE_DETAIL -> { + if (hint.routeId == snapshot.selectedRoute?.id) { + refreshSelectedRoute() + } else { + DriverSyncWorker.enqueue(getApplication(), hint.date, hint.routeId) + } + } + DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently() + } + } + + private fun registerPushTokenIfAvailable(driverId: String) { + if (pushTokenRegisteredForDriverId == driverId) return + + runCatching { + FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> + viewModelScope.launch { + runCatching { + repository.storePushToken(token) + pushTokenRegisteredForDriverId = driverId + } + } + } + } + } + + override fun onCleared() { + liveSyncClient.close() + super.onCleared() + } } 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 5eec9f6..cea7d4d 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -38,6 +38,25 @@ class DriverUiRulesTest { assertEquals(3, visiblePhotoAttachmentCount(serverPhotoCount = 1, localUploadCount = 2)) } + @Test + fun countsOnlyUnconfirmedNonCancelledUploadsInQueue() { + assertEquals(0, queuedPhotoUploadCount(emptyList())) + assertEquals( + 4, + queuedPhotoUploadCount( + listOf( + "PENDING", + "UPLOADING", + "VERIFYING", + "FAILED_RETRYABLE", + "CONFIRMED", + "CANCELLED", + ), + ), + ) + assertEquals(1, queuedPhotoUploadCount(listOf("FAILED_PERMANENT"))) + } + @Test fun usesCreatedAtBeforeTakenAtForNewestFirstPhotoSorting() { val fallback = 100L diff --git a/build.gradle.kts b/build.gradle.kts index 9deb573..7ea37b1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.google.services) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02695a6..f4fba62 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,8 @@ ksp = "2.1.10-1.0.31" playServicesAuth = "21.6.0" playServicesAuthApiPhone = "18.3.0" junit = "4.13.2" +firebaseBom = "33.7.0" +googleServices = "4.4.2" [libraries] activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -51,9 +53,12 @@ play-services-auth = { group = "com.google.android.gms", name = "play-services-a play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }