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" }