Compare commits

..
Author SHA1 Message Date
admin 89235fbb46 feat: harden offline sync and add diagnostics 2026-07-10 23:58:07 +02:00
admin 0564101e9e chore: release android driver 1.0.51 2026-07-10 22:35:04 +02:00
admin 2baa544b32 fix: reconcile leave requests in realtime 2026-07-10 22:32:13 +02:00
admin f9c3a2e305 Sortuj kursy kierowcy według dnia 2026-07-10 21:31:17 +02:00
34 changed files with 2034 additions and 232 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 103
versionName = "1.0.50"
versionCode = 105
versionName = "1.0.52"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -71,8 +71,8 @@ class DriverRepository(
suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto =
api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data
suspend fun cancelLeaveRequest(id: String, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment)).data
suspend fun cancelLeaveRequest(id: String, expectedVersion: Long, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment, expectedVersion)).data
suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId)
@@ -198,9 +198,9 @@ class DriverRepository(
api.deletePhoto(authHeader(requireToken()), photoId)
}
suspend fun logout() {
suspend fun logout(notifyServer: Boolean = true) {
val token = tokenStore.read()
if (token != null) {
if (token != null && notifyServer) {
runCatching { api.deletePushToken(authHeader(token)) }
runCatching { api.logout(authHeader(token)) }
}
@@ -62,6 +62,10 @@ data class DriverAppSettingsDto(
val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?,
val loadingPhotoRequirement: String? = null,
val loadingWeightRequirement: String? = null,
val unloadingPhotoRequirement: String? = null,
val unloadingWeightRequirement: String? = null,
val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null,
@@ -193,6 +197,7 @@ data class CreateLeaveRequestBody(
data class CancelLeaveRequestBody(
val comment: String? = null,
val expectedVersion: Long? = null,
)
data class DriverLeaveRequestDto(
@@ -204,6 +209,10 @@ data class DriverLeaveRequestDto(
val typeLabel: String? = null,
val note: String? = null,
val status: String,
val version: Long = 1,
val updatedAt: String? = null,
val allowedDecisions: List<String> = emptyList(),
val canCancel: Boolean = false,
val submittedAt: String? = null,
val decidedAt: String? = null,
val decisionComment: String? = null,
@@ -245,6 +254,8 @@ data class DriverRouteDto(
val id: String,
val routeDate: String? = null,
val unloadingDate: String? = null,
val ordering: Int? = null,
val dayOrderings: Map<String, Int>? = emptyMap(),
val startsAt: String,
val originName: String,
val destinationName: String,
@@ -321,14 +332,14 @@ data class PhotoUploadReceiptDto(
data class StartRouteBody(
val clientActionId: String,
val loadingWeight: Double,
val loadingWeight: Double?,
val occurredAt: String,
val photoClientRequestIds: List<String>,
)
data class FinishRouteBody(
val clientActionId: String,
val unloadingWeight: Double,
val unloadingWeight: Double?,
val occurredAt: String,
val photoClientRequestIds: List<String>,
)
@@ -13,18 +13,27 @@ interface DriverCacheDao {
@Query("SELECT * FROM driver_bootstrap_cache WHERE date = :date LIMIT 1")
suspend fun bootstrap(date: String): DriverBootstrapCacheEntity?
@Query("SELECT * FROM driver_bootstrap_cache ORDER BY syncedAtEpochMillis DESC")
suspend fun allBootstrapCaches(): List<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?
@Query("SELECT * FROM driver_route_cache ORDER BY syncedAtEpochMillis DESC")
suspend fun allRouteCaches(): List<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("SELECT * FROM driver_sync_states ORDER BY syncedAtEpochMillis DESC")
suspend fun allSyncStates(): List<DriverSyncStateEntity>
@Query("DELETE FROM driver_bootstrap_cache")
suspend fun clearBootstrap()
@@ -61,6 +61,24 @@ class DriverSyncRepository(
cachedRoute(routeId, throwable)
}
suspend fun bootstrapFromCache(date: String?): CachedValue<BootstrapResponse>? {
val cached = dao.bootstrap(date ?: currentDateFallback()) ?: return null
return CachedValue(
value = gson.fromJson(cached.payloadJson, BootstrapResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? {
val cached = dao.route(routeId) ?: return null
return CachedValue(
value = gson.fromJson(cached.payloadJson, RouteResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) {
val syncedAt = System.currentTimeMillis()
dao.upsertRoute(
@@ -98,21 +116,25 @@ class DriverSyncRepository(
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(),
),
)
saveSyncScope(scope)
}
}
suspend fun saveSyncScope(scope: SyncScopeDto) {
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()
@@ -144,7 +166,7 @@ class DriverSyncRepository(
}
private fun Throwable.isReadFallbackAllowed(): Boolean =
this is IOException || (this is HttpException && code() != 401 && code() != 403)
this is IOException || (this is HttpException && code() in 500..599)
private fun currentDateFallback(): String = java.time.LocalDate.now().toString()
@@ -166,5 +188,7 @@ class DriverSyncRepository(
const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
const val SCOPE_LEAVE_REQUESTS = "leave_requests"
const val SCOPE_LEAVE_CALENDAR = "leave_calendar"
const val SCOPE_SESSION = "session"
}
}
@@ -13,9 +13,7 @@ class NetworkMonitor(context: Context) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val isOnline: Flow<Boolean> = callbackFlow {
fun current(): Boolean = connectivityManager.activeNetwork
?.let(connectivityManager::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
fun current(): Boolean = isCurrentlyValidated()
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
@@ -35,4 +33,17 @@ class NetworkMonitor(context: Context) {
connectivityManager.registerDefaultNetworkCallback(callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
fun isCurrentlyValidated(): Boolean = connectivityManager.activeNetwork
?.let(connectivityManager::getNetworkCapabilities)
?.let(::hasValidatedInternet) == true
}
internal fun hasValidatedInternet(capabilities: NetworkCapabilities): Boolean =
hasValidatedInternetCapabilities(
hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
hasValidated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
)
internal fun hasValidatedInternetCapabilities(hasInternet: Boolean, hasValidated: Boolean): Boolean =
hasInternet && hasValidated
@@ -14,6 +14,15 @@ interface DispatchSheetUploadDao {
@Query("SELECT * FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId LIMIT 1")
suspend fun find(clientRequestId: String): DispatchSheetUploadEntity?
@Query("SELECT * FROM dispatch_sheet_uploads WHERE status != 'CONFIRMED' AND status != 'CANCELLED'")
suspend fun allUnsent(): List<DispatchSheetUploadEntity>
@Query("SELECT * FROM dispatch_sheet_uploads")
suspend fun allRecords(): List<DispatchSheetUploadEntity>
@Query("SELECT COUNT(*) FROM dispatch_sheet_uploads WHERE status != 'CONFIRMED' AND status != 'CANCELLED'")
fun observeUnsentCount(): Flow<Int>
@Query(
"""
SELECT * FROM dispatch_sheet_uploads
@@ -63,4 +72,7 @@ interface DispatchSheetUploadDao {
@Query("DELETE FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String)
@Query("DELETE FROM dispatch_sheet_uploads")
suspend fun deleteAll()
}
@@ -9,6 +9,7 @@ import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
class DispatchSheetUploadWorker(
appContext: Context,
@@ -18,6 +19,7 @@ class DispatchSheetUploadWorker(
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure()
val upload = dao.find(clientRequestId) ?: return Result.failure()
val file = File(upload.localPath)
@@ -62,6 +64,8 @@ class DispatchSheetUploadWorker(
}
dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
file.delete()
dao.delete(clientRequestId)
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
@@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
DriverRouteCacheEntity::class,
DriverSyncStateEntity::class,
],
version = 5,
version = 6,
exportSchema = false,
)
abstract class DriverDatabase : RoomDatabase() {
@@ -164,13 +164,51 @@ abstract class DriverDatabase : RoomDatabase() {
}
}
private val migration5To6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE route_actions_new (
clientActionId TEXT NOT NULL PRIMARY KEY,
routeId TEXT NOT NULL,
action TEXT NOT NULL,
weight REAL,
occurredAt TEXT NOT NULL,
photoClientRequestIdsJson TEXT NOT NULL,
status TEXT NOT NULL,
attemptCount INTEGER NOT NULL,
lastError TEXT,
createdAtEpochMillis INTEGER NOT NULL,
updatedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
INSERT INTO route_actions_new (
clientActionId, routeId, action, weight, occurredAt, photoClientRequestIdsJson,
status, attemptCount, lastError, createdAtEpochMillis, updatedAtEpochMillis
)
SELECT
clientActionId, routeId, action, weight, occurredAt, photoClientRequestIdsJson,
status, attemptCount, lastError, createdAtEpochMillis, updatedAtEpochMillis
FROM route_actions
""".trimIndent(),
)
db.execSQL("DROP TABLE route_actions")
db.execSQL("ALTER TABLE route_actions_new RENAME TO route_actions")
db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_routeId ON route_actions(routeId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_status ON route_actions(status)")
}
}
fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
DriverDatabase::class.java,
"driver-local-outbox.db",
).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5).build().also { instance = it }
).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5, migration5To6).build().also { instance = it }
}
}
}
@@ -0,0 +1,41 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.work.WorkManager
import java.io.File
class OfflineOutboxManager(context: Context) {
private val database = DriverDatabase.get(context)
private val workManager = WorkManager.getInstance(context)
suspend fun pendingCount(): Int =
database.photoUploadDao().allUnsent().size +
database.dispatchSheetUploadDao().allUnsent().size +
database.routeActionDao().allUnsent().size +
database.routePointDao().allUnsent().size
suspend fun clearAll() {
val photos = database.photoUploadDao().allRecords()
val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
val actions = database.routeActionDao().allUnsent()
val points = database.routePointDao().allUnsent()
photos.forEach {
workManager.cancelUniqueWork(PhotoUploadWorker.uniqueWorkName(it.clientRequestId))
File(it.localPath).delete()
}
dispatchSheets.forEach {
workManager.cancelUniqueWork(DispatchSheetUploadWorker.uniqueWorkName(it.clientRequestId))
File(it.localPath).delete()
}
actions.forEach { workManager.cancelUniqueWork(RouteActionWorker.uniqueWorkName(it.clientActionId)) }
points.map { it.routeId }.distinct().forEach {
workManager.cancelUniqueWork(RoutePointWorker.uniqueWorkName(it))
}
database.photoUploadDao().deleteAll()
database.dispatchSheetUploadDao().deleteAll()
database.routeActionDao().deleteAll()
database.routePointDao().deleteAll()
}
}
@@ -43,6 +43,12 @@ interface PhotoUploadDao {
)
suspend fun confirmedForRoute(routeId: String): List<PhotoUploadEntity>
@Query("SELECT * FROM photo_uploads WHERE status != 'CONFIRMED' AND status != 'CANCELLED'")
suspend fun allUnsent(): List<PhotoUploadEntity>
@Query("SELECT * FROM photo_uploads")
suspend fun allRecords(): List<PhotoUploadEntity>
@Query(
"""
UPDATE photo_uploads
@@ -82,4 +88,7 @@ interface PhotoUploadDao {
@Query("DELETE FROM photo_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String)
@Query("DELETE FROM photo_uploads")
suspend fun deleteAll()
}
@@ -9,6 +9,7 @@ import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
class PhotoUploadWorker(
appContext: Context,
@@ -21,6 +22,7 @@ class PhotoUploadWorker(
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure()
val upload = dao.find(clientRequestId) ?: return Result.failure()
val file = File(upload.localPath)
@@ -14,6 +14,18 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity?
@Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'")
suspend fun allUnsent(): List<RouteActionEntity>
@Query("DELETE FROM route_actions WHERE clientActionId = :clientActionId")
suspend fun delete(clientActionId: String)
@Query("DELETE FROM route_actions")
suspend fun deleteAll()
@Query("DELETE FROM route_actions WHERE status = 'CONFIRMED' AND updatedAtEpochMillis < :beforeEpochMillis")
suspend fun pruneConfirmed(beforeEpochMillis: Long)
@Query("SELECT * FROM route_actions WHERE status = 'WAITING_FOR_PHOTOS'")
suspend fun waitingForPhotosActions(): List<RouteActionEntity>
@@ -30,7 +30,7 @@ data class RouteActionEntity(
@PrimaryKey val clientActionId: String,
val routeId: String,
val action: String,
val weight: Double,
val weight: Double?,
val occurredAt: String,
val photoClientRequestIdsJson: String,
val status: String = RouteActionStatus.Pending,
@@ -26,18 +26,24 @@ class RouteActionOutbox(
fun observeVisibleActions(): Flow<List<RouteActionEntity>> =
dao.observeVisibleActions()
suspend fun enqueueStart(routeId: String, loadingWeight: Double, photoClientRequestIds: List<String>): RouteActionEntity =
suspend fun discard(clientActionId: String) {
workManager.cancelUniqueWork(RouteActionWorker.uniqueWorkName(clientActionId))
dao.delete(clientActionId)
}
suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds)
suspend fun enqueueFinish(routeId: String, unloadingWeight: Double, photoClientRequestIds: List<String>): RouteActionEntity =
suspend fun enqueueFinish(routeId: String, unloadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity =
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds)
private suspend fun enqueue(
action: String,
routeId: String,
weight: Double,
weight: Double?,
photoClientRequestIds: List<String>,
): RouteActionEntity {
dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
val entity = RouteActionEntity(
clientActionId = UUID.randomUUID().toString(),
routeId = routeId,
@@ -11,6 +11,7 @@ import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.sync.DriverSyncWorker
@@ -26,13 +27,24 @@ class RouteActionWorker(
private val gson = Gson()
override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
val action = dao.find(clientActionId) ?: return Result.failure()
val photoClientRequestIds = photoClientRequestIds(action)
val waitingForPhotos = photoClientRequestIds
.mapNotNull { photoDao.find(it) }
.any { it.status != PhotoUploadStatus.Confirmed.storageValue }
val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
val permanentlyFailedPhoto = photoDependencies.firstOrNull { it.status == PhotoUploadStatus.FailedPermanent.storageValue }
if (permanentlyFailedPhoto != null) {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.FailedPermanent,
lastError = "Wymagane zdjęcie nie zostało wysłane. Popraw zdjęcie i ponów potwierdzenie etapu.",
attemptIncrement = 0,
)
return Result.failure()
}
val waitingForPhotos = photoDependencies.any { it.status != PhotoUploadStatus.Confirmed.storageValue }
if (waitingForPhotos) {
dao.updateStatus(
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface RoutePointDao {
@@ -14,7 +15,7 @@ interface RoutePointDao {
"""
SELECT * FROM route_points
WHERE routeId = :routeId
AND status != 'CONFIRMED'
AND status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')
ORDER BY timestamp ASC
LIMIT :limit
""",
@@ -25,11 +26,17 @@ interface RoutePointDao {
"""
SELECT COUNT(*) FROM route_points
WHERE routeId = :routeId
AND status != 'CONFIRMED'
AND status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')
""",
)
suspend fun pendingCount(routeId: String): Int
@Query("SELECT * FROM route_points WHERE status != 'CONFIRMED'")
suspend fun allUnsent(): List<RoutePointEntity>
@Query("SELECT COUNT(*) FROM route_points WHERE status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')")
fun observeUnsentCount(): Flow<Int>
@Query(
"""
UPDATE route_points
@@ -47,4 +54,10 @@ interface RoutePointDao {
attemptIncrement: Int,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("DELETE FROM route_points WHERE clientPointId IN (:clientPointIds)")
suspend fun delete(clientPointIds: List<String>)
@Query("DELETE FROM route_points")
suspend fun deleteAll()
}
@@ -54,7 +54,7 @@ class RoutePointOutbox(
workManager.enqueueUniqueWork(
RoutePointWorker.uniqueWorkName(routeId),
ExistingWorkPolicy.KEEP,
ExistingWorkPolicy.APPEND_OR_REPLACE,
request,
)
}
@@ -10,6 +10,7 @@ import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.RoutePointBatchBody
import pl.firmatpp.kierowca.data.model.RoutePointDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService
class RoutePointWorker(
@@ -20,6 +21,7 @@ class RoutePointWorker(
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val routeId = inputData.getString(KEY_ROUTE_ID) ?: return Result.failure()
val points = dao.pendingForRoute(routeId, BATCH_LIMIT)
if (points.isEmpty()) return Result.success()
@@ -35,7 +37,7 @@ class RoutePointWorker(
points = points.map { it.toDto() },
),
)
dao.updateStatus(pointIds, RoutePointStatus.Confirmed, null, attemptIncrement = 0)
dao.delete(pointIds)
if (response.arrived) {
ActiveRouteTrackingService.markArrived(applicationContext, routeId)
@@ -0,0 +1,349 @@
package pl.firmatpp.kierowca.diagnostics
import android.Manifest
import android.app.ActivityManager
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.os.BatteryManager
import android.os.Environment
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging
import java.io.File
import java.security.MessageDigest
import java.time.Instant
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.DeviceInfoProvider
import pl.firmatpp.kierowca.data.TokenStore
import pl.firmatpp.kierowca.data.upload.DriverDatabase
data class DiagnosticEntry(
val label: String,
val value: String,
val severity: DiagnosticSeverity = DiagnosticSeverity.Neutral,
)
enum class DiagnosticSeverity { Neutral, Good, Warning, Error }
data class DiagnosticSection(
val title: String,
val entries: List<DiagnosticEntry>,
)
data class DiagnosticSnapshot(
val generatedAtEpochMillis: Long,
val sections: List<DiagnosticSection>,
) {
fun asPlainText(): String = buildString {
appendLine("TPP Kierowca, raport diagnostyczny")
appendLine("Wygenerowano: ${Instant.ofEpochMilli(generatedAtEpochMillis)}")
sections.forEach { section ->
appendLine()
appendLine("[${section.title}]")
section.entries.forEach { entry -> appendLine("${entry.label}: ${entry.value}") }
}
appendLine()
append("Tokeny są celowo przedstawione jako fingerprinty, raport nie zawiera danych pozwalających przejąć sesję.")
}
}
class DiagnosticSnapshotProvider(private val context: Context) {
private val database = DriverDatabase.get(context)
private val tokenStore = TokenStore(context)
suspend fun capture(runtimeSections: List<DiagnosticSection>): DiagnosticSnapshot = withContext(Dispatchers.IO) {
val token = tokenStore.read()
val fcmToken = withTimeoutOrNull(5_000L) { readFirebaseToken() }
?: Result.failure(IllegalStateException("timeout po 5 s"))
val device = DeviceInfoProvider(context, tokenStore).currentDeviceInfo()
val network = currentNetworkEntries()
val photos = database.photoUploadDao().allRecords()
val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
val actions = database.routeActionDao().allUnsent()
val points = database.routePointDao().allUnsent()
val cacheDao = database.driverCacheDao()
val bootstrapCaches = cacheDao.allBootstrapCaches()
val routeCaches = cacheDao.allRouteCaches()
val syncStates = cacheDao.allSyncStates()
val queuedFiles = (photos.map { it.localPath } + dispatchSheets.map { it.localPath }).map(::File)
val activeWork = activeWorkInfo()
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val firebaseOptions = FirebaseApp.getApps(context).firstOrNull()?.options
val databaseFile = context.getDatabasePath("driver-local-outbox.db")
val sections = buildList {
add(
DiagnosticSection(
"Aplikacja",
listOf(
DiagnosticEntry("Pakiet", BuildConfig.APPLICATION_ID),
DiagnosticEntry("Wersja", "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"),
DiagnosticEntry("Typ buildu", BuildConfig.BUILD_TYPE),
DiagnosticEntry("Debug", yesNo(BuildConfig.DEBUG)),
DiagnosticEntry("API", BuildConfig.API_BASE_URL),
DiagnosticEntry("Reverb URL", BuildConfig.REVERB_WS_BASE_URL),
DiagnosticEntry("Reverb app key", secureIdentifierSummary(BuildConfig.REVERB_APP_KEY)),
DiagnosticEntry("Firebase App ID", firebaseOptions?.applicationId ?: "brak"),
DiagnosticEntry("Firebase Project ID", firebaseOptions?.projectId ?: "brak"),
DiagnosticEntry("Firebase Sender ID", firebaseOptions?.gcmSenderId ?: "brak"),
DiagnosticEntry("Pierwsza instalacja", Instant.ofEpochMilli(packageInfo.firstInstallTime).toString()),
DiagnosticEntry("Ostatnia aktualizacja", Instant.ofEpochMilli(packageInfo.lastUpdateTime).toString()),
),
),
)
add(
DiagnosticSection(
"Sesja i identyfikatory",
listOf(
DiagnosticEntry("Token sesji", secureIdentifierSummary(token), if (token == null) DiagnosticSeverity.Error else DiagnosticSeverity.Good),
DiagnosticEntry("Token FCM", fcmToken.fold(::secureIdentifierSummary) { "Błąd odczytu: ${it.message ?: it::class.java.simpleName}" }),
DiagnosticEntry("Device ID", secureIdentifierSummary(device.deviceId)),
DiagnosticEntry("Android ID", secureIdentifierSummary(device.androidId)),
DiagnosticEntry("Numer seryjny", secureIdentifierSummary(device.serialNumber)),
),
),
)
add(
DiagnosticSection(
"Urządzenie",
listOf(
DiagnosticEntry("Producent", device.manufacturer.orEmpty().ifBlank { "brak" }),
DiagnosticEntry("Marka / model", listOfNotNull(device.brand, device.model).joinToString(" ").ifBlank { "brak" }),
DiagnosticEntry("Device / product", listOfNotNull(device.device, device.product).joinToString(" / ").ifBlank { "brak" }),
DiagnosticEntry("Android", "${device.androidVersion ?: "?"}, API ${device.sdkInt}"),
DiagnosticEntry("Build fingerprint", Build.FINGERPRINT),
DiagnosticEntry("Sprzęt / bootloader", "${Build.HARDWARE} / ${Build.BOOTLOADER}"),
DiagnosticEntry("ABI", Build.SUPPORTED_ABIS.joinToString()),
DiagnosticEntry("Język", context.resources.configuration.locales.toLanguageTags().ifBlank { "brak" }),
DiagnosticEntry("Strefa czasowa", java.util.TimeZone.getDefault().id),
DiagnosticEntry("Pamięć aplikacji", storageSummary(context.filesDir)),
DiagnosticEntry("Pamięć współdzielona", storageSummary(Environment.getExternalStorageDirectory())),
DiagnosticEntry("RAM", memorySummary()),
DiagnosticEntry("Bateria", batterySummary()),
),
),
)
add(DiagnosticSection("Sieć urządzenia", network))
add(
DiagnosticSection(
"Uprawnienia i system",
listOf(
permissionEntry("Aparat", Manifest.permission.CAMERA),
permissionEntry("Lokalizacja dokładna", Manifest.permission.ACCESS_FINE_LOCATION),
permissionEntry("Lokalizacja przybliżona", Manifest.permission.ACCESS_COARSE_LOCATION),
notificationPermissionEntry(),
DiagnosticEntry("Powiadomienia systemowe", yesNo(NotificationManagerCompat.from(context).areNotificationsEnabled())),
DiagnosticEntry("Optymalizacja baterii wyłączona", yesNo(isIgnoringBatteryOptimizations())),
DiagnosticEntry("Kanały powiadomień", notificationChannelSummary()),
),
),
)
addAll(runtimeSections)
add(
DiagnosticSection(
"WorkManager",
buildList {
add(DiagnosticEntry("Aktywne zadania", activeWork.size.toString(), if (activeWork.isEmpty()) DiagnosticSeverity.Good else DiagnosticSeverity.Warning))
activeWork.forEach { work ->
add(
DiagnosticEntry(
work.tags.map { it.substringAfterLast('.') }.sorted().joinToString().ifBlank { work.id.toString() },
"${work.state}, próba ${work.runAttemptCount}, id ${work.id}",
),
)
}
},
),
)
add(
DiagnosticSection(
"Lokalna kolejka",
buildList {
add(DiagnosticEntry("Zdjęcia", statusSummary(photos.map { it.status }), queueSeverity(photos.map { it.status })))
add(DiagnosticEntry("Karty spedycyjne", statusSummary(dispatchSheets.map { it.status }), queueSeverity(dispatchSheets.map { it.status })))
add(DiagnosticEntry("Akcje kursu", statusSummary(actions.map { it.status }), queueSeverity(actions.map { it.status })))
add(DiagnosticEntry("Punkty GPS", statusSummary(points.map { it.status }), queueSeverity(points.map { it.status })))
add(DiagnosticEntry("Pliki lokalne", "${queuedFiles.count(File::exists)}/${queuedFiles.size} obecnych, ${formatBytes(queuedFiles.filter(File::exists).sumOf(File::length))}"))
add(DiagnosticEntry("Brakujące pliki", queuedFiles.filterNot(File::exists).joinToString { it.name }.ifBlank { "brak" }, if (queuedFiles.any { !it.exists() }) DiagnosticSeverity.Error else DiagnosticSeverity.Good))
val errors = (photos.mapNotNull { it.lastError } + dispatchSheets.mapNotNull { it.lastError } + actions.mapNotNull { it.lastError } + points.mapNotNull { it.lastError }).distinct()
add(DiagnosticEntry("Błędy kolejki", errors.joinToString(" | ").ifBlank { "brak" }, if (errors.isEmpty()) DiagnosticSeverity.Good else DiagnosticSeverity.Warning))
},
),
)
add(
DiagnosticSection(
"Cache i rewizje",
buildList {
add(DiagnosticEntry("Wersja schematu Room", databaseSchemaVersion().toString()))
add(DiagnosticEntry("Plik bazy", "${if (databaseFile.exists()) formatBytes(databaseFile.length()) else "brak"}, ${databaseFile.absolutePath}"))
add(DiagnosticEntry("Snapshoty list tras", "${bootstrapCaches.size}: ${bootstrapCaches.joinToString { "${it.date}@v${it.version ?: "?"}" }.ifBlank { "brak" }}"))
add(DiagnosticEntry("Snapshoty tras", "${routeCaches.size}: ${routeCaches.joinToString { "${it.routeId}@v${it.version ?: "?"}" }.ifBlank { "brak" }}"))
add(DiagnosticEntry("Scope synchronizacji", syncStates.size.toString()))
syncStates.forEach { sync ->
add(
DiagnosticEntry(
sync.scope,
listOfNotNull(sync.date, sync.routeId).joinToString(" / ").let { target ->
"${target.ifBlank { "globalny" }}, v${sync.version}, checksum ${shortFingerprint(sync.checksum)}, zapis ${Instant.ofEpochMilli(sync.syncedAtEpochMillis)}"
},
),
)
}
},
),
)
}
DiagnosticSnapshot(System.currentTimeMillis(), sections)
}
private fun currentNetworkEntries(): List<DiagnosticEntry> {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = manager.activeNetwork
val capabilities = activeNetwork?.let(manager::getNetworkCapabilities)
if (capabilities == null) {
return listOf(DiagnosticEntry("Aktywna sieć", "brak", DiagnosticSeverity.Error))
}
val transports = buildList {
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) add("Wi-Fi")
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) add("sieć komórkowa")
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) add("Ethernet")
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) add("VPN")
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) add("Bluetooth")
}.ifEmpty { listOf("inny") }
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val links = activeNetwork?.let(manager::getLinkProperties)
return listOf(
DiagnosticEntry("Transport", transports.joinToString()),
DiagnosticEntry("Internet zweryfikowany", yesNo(validated), if (validated) DiagnosticSeverity.Good else DiagnosticSeverity.Error),
DiagnosticEntry("NET_CAPABILITY_INTERNET", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET))),
DiagnosticEntry("NET_CAPABILITY_VALIDATED", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))),
DiagnosticEntry("Sieć mierzona", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))),
DiagnosticEntry("Roaming", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))),
DiagnosticEntry("Pasmo downstream / upstream", "${capabilities.linkDownstreamBandwidthKbps} / ${capabilities.linkUpstreamBandwidthKbps} kb/s"),
DiagnosticEntry("Interfejs", links?.interfaceName ?: "brak"),
DiagnosticEntry("Adresy IP", links?.linkAddresses?.joinToString { it.toString() }?.ifBlank { "brak" } ?: "brak"),
DiagnosticEntry("DNS", links?.dnsServers?.joinToString { it.hostAddress ?: it.toString() }?.ifBlank { "brak" } ?: "brak"),
DiagnosticEntry("MTU", links?.mtu?.toString() ?: "brak"),
DiagnosticEntry("Proxy", links?.httpProxy?.toString() ?: "brak"),
)
}
private fun activeWorkInfo(): List<WorkInfo> = runCatching {
val query = WorkQuery.Builder.fromStates(
listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED),
).build()
WorkManager.getInstance(context).getWorkInfos(query).get(3, TimeUnit.SECONDS)
}.getOrDefault(emptyList())
private fun databaseSchemaVersion(): Int = runCatching {
database.openHelper.readableDatabase.query("PRAGMA user_version").use { cursor ->
if (cursor.moveToFirst()) cursor.getInt(0) else -1
}
}.getOrDefault(-1)
private fun memorySummary(): String {
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val info = ActivityManager.MemoryInfo()
manager.getMemoryInfo(info)
return "wolne ${formatBytes(info.availMem)} z ${formatBytes(info.totalMem)}, niski stan: ${yesNo(info.lowMemory)}"
}
private fun batterySummary(): String {
val manager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).takeIf { it >= 0 }?.let { "$it%" } ?: "brak"
return "$level, ładowanie: ${yesNo(manager.isCharging)}"
}
private fun notificationChannelSummary(): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return "niewspierane"
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channels = manager.notificationChannels
return if (channels.isEmpty()) "0" else channels.joinToString { "${it.id}:${it.importance}" }
}
private fun permissionEntry(label: String, permission: String): DiagnosticEntry {
val granted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
return DiagnosticEntry(label, if (granted) "przyznane" else "brak", if (granted) DiagnosticSeverity.Good else DiagnosticSeverity.Warning)
}
private fun notificationPermissionEntry(): DiagnosticEntry =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
DiagnosticEntry("Uprawnienie powiadomień", "niewymagane na tej wersji Androida")
} else {
permissionEntry("Uprawnienie powiadomień", Manifest.permission.POST_NOTIFICATIONS)
}
private fun isIgnoringBatteryOptimizations(): Boolean {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(context.packageName)
}
private suspend fun readFirebaseToken(): Result<String?> = suspendCancellableCoroutine { continuation ->
runCatching { FirebaseMessaging.getInstance().token }
.onFailure { continuation.resume(Result.failure(it)) }
.onSuccess { task ->
task.addOnCompleteListener { completed ->
if (!continuation.isActive) return@addOnCompleteListener
if (completed.isSuccessful) continuation.resume(Result.success(completed.result))
else continuation.resume(Result.failure(completed.exception ?: IllegalStateException("Nieznany błąd FCM")))
}
}
}
}
internal fun secureIdentifierSummary(value: String?): String {
if (value.isNullOrBlank()) return "brak"
val fingerprint = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray())
.joinToString("") { "%02x".format(it) }
.take(12)
val suffix = value.takeLast(4)
return "obecny, długość ${value.length}, SHA-256 $fingerprint, końcówka …$suffix"
}
private fun shortFingerprint(value: String): String =
MessageDigest.getInstance("SHA-256").digest(value.toByteArray()).joinToString("") { "%02x".format(it) }.take(10)
private fun statusSummary(statuses: List<String>): String =
if (statuses.isEmpty()) "0" else statuses.groupingBy { it }.eachCount().entries.sortedBy { it.key }.joinToString { "${it.key}: ${it.value}" }
private fun queueSeverity(statuses: List<String>): DiagnosticSeverity = when {
statuses.any { it.contains("PERMANENT") || it.contains("CONFLICT") } -> DiagnosticSeverity.Error
statuses.any { it.contains("FAILED") } -> DiagnosticSeverity.Warning
statuses.isNotEmpty() -> DiagnosticSeverity.Warning
else -> DiagnosticSeverity.Good
}
private fun storageSummary(directory: File): String =
"wolne ${formatBytes(directory.usableSpace)} z ${formatBytes(directory.totalSpace)}"
private fun formatBytes(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = listOf("KB", "MB", "GB", "TB")
var value = bytes.toDouble()
var unit = -1
while (value >= 1024 && unit < units.lastIndex) {
value /= 1024
unit++
}
return "%.1f %s".format(java.util.Locale.US, value, units[unit])
}
private fun yesNo(value: Boolean): String = if (value) "tak" else "nie"
@@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
class DriverFirebaseMessagingService : FirebaseMessagingService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -14,7 +15,7 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
scope.launch {
val repository = DriverRepository(applicationContext)
if (repository.hasToken()) {
if (repository.hasToken() && NetworkMonitor(applicationContext).isCurrentlyValidated()) {
runCatching { repository.storePushToken(token) }
}
}
@@ -38,20 +39,26 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() {
title = data["title"],
body = data["body"],
)
DriverSyncWorker.enqueue(
context = applicationContext,
date = data["date"],
routeId = data["routeId"],
)
if (!DriverRuntimeSyncState.websocketOwnsForegroundHints()) {
DriverSyncWorker.enqueue(
context = applicationContext,
date = data["date"],
routeId = data["routeId"],
)
}
return
}
if (data["type"] != "driver_sync_hint") return
if (DriverRuntimeSyncState.websocketOwnsForegroundHints()) return
DriverSyncWorker.enqueue(
context = applicationContext,
date = data["date"],
routeId = data["routeId"],
scope = data["scope"],
version = data["version"]?.toLongOrNull(),
checksum = data["checksum"],
)
}
}
@@ -11,6 +11,9 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
@@ -19,6 +22,8 @@ import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
interface DriverLiveSyncGateway {
suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse
@@ -47,7 +52,7 @@ class OkHttpLiveWebSocketFactory(
client.newWebSocket(Request.Builder().url(url).build(), listener)
}
private enum class LiveSyncConnectionState {
enum class LiveSyncConnectionState {
Stopped,
WaitingForNetwork,
Disconnected,
@@ -56,10 +61,24 @@ private enum class LiveSyncConnectionState {
Connected,
}
data class LiveSyncDiagnostics(
val state: LiveSyncConnectionState,
val desiredActive: Boolean,
val networkAvailable: Boolean,
val foregroundActive: Boolean,
val configured: Boolean,
val socketId: String?,
val reconnectAttempt: Int,
val messageVersion: Long,
val lastMessageAtEpochMillis: Long?,
val lastDisconnectReason: String?,
)
class DriverLiveSyncClient(
private val gateway: DriverLiveSyncGateway,
private val onConnected: () -> Unit,
private val onHint: (DriverSyncHint) -> Unit,
private val onAuthenticationFailed: () -> Unit = {},
private val webSocketFactory: LiveWebSocketFactory = OkHttpLiveWebSocketFactory(defaultOkHttpClient()),
private val gson: Gson = Gson(),
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
@@ -70,12 +89,14 @@ class DriverLiveSyncClient(
repository: DriverRepository,
onConnected: () -> Unit,
onHint: (DriverSyncHint) -> Unit,
onAuthenticationFailed: () -> Unit = {},
client: OkHttpClient = defaultOkHttpClient(),
gson: Gson = Gson(),
) : this(
gateway = DriverRepositoryLiveSyncGateway(repository),
onConnected = onConnected,
onHint = onHint,
onAuthenticationFailed = onAuthenticationFailed,
webSocketFactory = OkHttpLiveWebSocketFactory(client),
gson = gson,
)
@@ -104,6 +125,31 @@ class DriverLiveSyncClient(
private var staleWatchdogJob: Job? = null
private var reconnectAttempt: Int = 0
private var messageVersion: Long = 0
private var lastMessageAtEpochMillis: Long? = null
private var lastDisconnectReason: String? = null
private var foregroundActive: Boolean = true
private val _connectionState = MutableStateFlow(LiveSyncConnectionState.Stopped)
val connectionState: StateFlow<LiveSyncConnectionState> = _connectionState.asStateFlow()
fun diagnostics(): LiveSyncDiagnostics = synchronized(lock) {
LiveSyncDiagnostics(
state = state,
desiredActive = desiredActive,
networkAvailable = networkAvailable,
foregroundActive = foregroundActive,
configured = realtimeConfig != null,
socketId = socketId,
reconnectAttempt = reconnectAttempt,
messageVersion = messageVersion,
lastMessageAtEpochMillis = lastMessageAtEpochMillis,
lastDisconnectReason = lastDisconnectReason,
)
}
private fun updateState(next: LiveSyncConnectionState) {
state = next
_connectionState.value = next
}
fun start(driverId: String, config: RealtimeConfigDto?) {
if (!isConfigUsable(config)) return
@@ -119,6 +165,7 @@ class DriverLiveSyncClient(
fun ensureConnected() {
val shouldConnect = synchronized(lock) {
desiredActive &&
foregroundActive &&
networkAvailable &&
state !in setOf(
LiveSyncConnectionState.Connecting,
@@ -136,22 +183,32 @@ class DriverLiveSyncClient(
reconnectJob?.cancel()
reconnectJob = null
closeSocketLocked("network_lost")
state = if (desiredActive) LiveSyncConnectionState.WaitingForNetwork else LiveSyncConnectionState.Stopped
updateState(if (desiredActive) LiveSyncConnectionState.WaitingForNetwork else LiveSyncConnectionState.Stopped)
false
} else {
desiredActive && state == LiveSyncConnectionState.WaitingForNetwork
}
}
if (available) {
reportRealtimeStatus("reconnecting", "network_available")
} else {
reportRealtimeStatus("disconnected", "network_lost")
}
if (shouldReconnect) connectNow(resetAttempt = true)
}
fun setForeground(active: Boolean) {
val shouldConnect = synchronized(lock) {
foregroundActive = active
if (!active) {
reconnectJob?.cancel()
reconnectJob = null
closeSocketLocked("app_background")
updateState(LiveSyncConnectionState.Stopped)
false
} else {
desiredActive && networkAvailable
}
}
if (shouldConnect) connectNow(resetAttempt = true)
}
fun stop() {
synchronized(lock) {
desiredActive = false
@@ -162,7 +219,7 @@ class DriverLiveSyncClient(
realtimeConfig = null
socketId = null
reconnectAttempt = 0
state = LiveSyncConnectionState.Stopped
updateState(LiveSyncConnectionState.Stopped)
}
reportRealtimeStatus("disconnected", "client_stop")
}
@@ -176,14 +233,14 @@ class DriverLiveSyncClient(
val wsUrl = synchronized(lock) {
val config = realtimeConfig ?: return
val id = driverId ?: return
if (!desiredActive || !networkAvailable || !isConfigUsable(config)) return
if (!desiredActive || !foregroundActive || !networkAvailable || !isConfigUsable(config)) return
if (state == LiveSyncConnectionState.Connecting || state == LiveSyncConnectionState.Subscribing || state == LiveSyncConnectionState.Connected) return
if (resetAttempt) reconnectAttempt = 0
reconnectJob?.cancel()
reconnectJob = null
socketId = null
state = LiveSyncConnectionState.Connecting
updateState(LiveSyncConnectionState.Connecting)
val appKey = config.reverbAppKey.orEmpty()
val wsBaseUrl = config.reverbWsBaseUrl.orEmpty()
@@ -227,6 +284,7 @@ class DriverLiveSyncClient(
false
} else {
messageVersion += 1
lastMessageAtEpochMillis = System.currentTimeMillis()
true
}
}
@@ -244,7 +302,7 @@ class DriverLiveSyncClient(
val nextSocketId = root.dataObject()?.string("socket_id") ?: return
synchronized(lock) {
socketId = nextSocketId
state = LiveSyncConnectionState.Subscribing
updateState(LiveSyncConnectionState.Subscribing)
}
subscribe(socket, nextSocketId)
}
@@ -254,7 +312,7 @@ class DriverLiveSyncClient(
if (channel == "private-driver-mobile.$id") {
synchronized(lock) {
reconnectAttempt = 0
state = LiveSyncConnectionState.Connected
updateState(LiveSyncConnectionState.Connected)
}
reportRealtimeStatus("connected")
startHeartbeat()
@@ -272,22 +330,28 @@ class DriverLiveSyncClient(
private fun subscribe(socket: WebSocket, socketId: String) {
val id = synchronized(lock) { driverId } ?: return
val channel = "private-driver-mobile.$id"
val channels = listOf("private-driver-mobile.$id", "private-driver-mobile.0")
scope.launch {
runCatching {
val auth = gateway.broadcastAuth(socketId, channel).auth
if (!isCurrentSocket(socket)) return@launch
val payload = mapOf(
"event" to "pusher:subscribe",
"data" to mapOf(
"channel" to channel,
"auth" to auth,
),
)
check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" }
channels.forEach { channel ->
val auth = gateway.broadcastAuth(socketId, channel).auth
if (!isCurrentSocket(socket)) return@launch
val payload = mapOf(
"event" to "pusher:subscribe",
"data" to mapOf(
"channel" to channel,
"auth" to auth,
),
)
check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" }
}
}.onFailure { throwable ->
AppDiagnostics.log("realtime_subscription_error: ${throwable.message ?: throwable::class.java.simpleName}")
if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) {
handleAuthenticationFailure(socket)
return@onFailure
}
socket.close(1000, "subscription_failed")
handleDisconnect("error", "subscription_failed", socket)
}
@@ -302,15 +366,16 @@ class DriverLiveSyncClient(
stopStaleWatchdogLocked()
webSocket = null
socketId = null
lastDisconnectReason = reason
if (!desiredActive) {
state = LiveSyncConnectionState.Stopped
updateState(LiveSyncConnectionState.Stopped)
false
} else if (!networkAvailable) {
state = LiveSyncConnectionState.WaitingForNetwork
updateState(LiveSyncConnectionState.WaitingForNetwork)
false
} else {
state = LiveSyncConnectionState.Disconnected
updateState(LiveSyncConnectionState.Disconnected)
true
}
}
@@ -321,7 +386,7 @@ class DriverLiveSyncClient(
private fun scheduleReconnect() {
val delayMs = synchronized(lock) {
if (!desiredActive || !networkAvailable || state == LiveSyncConnectionState.Stopped) return
if (!desiredActive || !foregroundActive || !networkAvailable || state == LiveSyncConnectionState.Stopped) return
if (reconnectJob?.isActive == true) return
val delay = reconnectDelaysMs.getOrElse(reconnectAttempt) { reconnectDelaysMs.last() }
reconnectAttempt += 1
@@ -399,10 +464,26 @@ class DriverLiveSyncClient(
scope.launch {
runCatching {
gateway.storeRealtimeStatus(status, socketId, error)
}.onFailure { throwable ->
if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) handleAuthenticationFailure()
}
}
}
private fun handleAuthenticationFailure(socket: WebSocket? = null) {
val notify = synchronized(lock) {
if (socket != null && webSocket !== socket) return
val wasActive = desiredActive
desiredActive = false
reconnectJob?.cancel()
reconnectJob = null
closeSocketLocked("authentication_failed")
updateState(LiveSyncConnectionState.Stopped)
wasActive
}
if (notify) onAuthenticationFailed()
}
private fun isConfigUsable(config: RealtimeConfigDto?): Boolean =
config?.reverbEnabled == true &&
!config.reverbAppKey.isNullOrBlank() &&
@@ -0,0 +1,8 @@
package pl.firmatpp.kierowca.sync
object DriverRuntimeSyncState {
@Volatile var foreground: Boolean = false
@Volatile var realtimeConnected: Boolean = false
fun websocketOwnsForegroundHints(): Boolean = foreground && realtimeConnected
}
@@ -14,6 +14,8 @@ import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.model.SyncScopeDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverSyncWorker(
@@ -23,9 +25,20 @@ class DriverSyncWorker(
private val syncRepository = DriverSyncRepository(appContext)
override suspend fun doWork(): Result =
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) Result.retry() else
runCatching {
val date = inputData.getString(KEY_DATE)
val routeId = inputData.getString(KEY_ROUTE_ID)
val hintedScope = inputData.getString(KEY_SCOPE)?.let { scope ->
val version = inputData.getLong(KEY_VERSION, -1L)
val checksum = inputData.getString(KEY_CHECKSUM)
if (version >= 0L && !checksum.isNullOrBlank()) {
SyncScopeDto(scope, date, routeId, version, checksum, computedAt = "")
} else null
}
if (hintedScope != null && !syncRepository.shouldRefresh(hintedScope)) {
return@runCatching Result.success()
}
val response = syncRepository.fetchSyncState(date, routeId)
var refreshed = false
@@ -52,6 +65,10 @@ class DriverSyncWorker(
syncRepository.saveSyncStates(response)
refreshed = true
}
DriverSyncRepository.SCOPE_LEAVE_CALENDAR -> {
syncRepository.saveSyncStates(response)
refreshed = true
}
}
}
}
@@ -81,17 +98,33 @@ class DriverSyncWorker(
companion object {
private const val KEY_DATE = "date"
private const val KEY_ROUTE_ID = "routeId"
private const val KEY_SCOPE = "scope"
private const val KEY_VERSION = "version"
private const val KEY_CHECKSUM = "checksum"
fun enqueue(context: Context, date: String?, routeId: String?) {
fun enqueue(
context: Context,
date: String?,
routeId: String?,
scope: String? = null,
version: Long? = null,
checksum: String? = null,
) {
val request = OneTimeWorkRequestBuilder<DriverSyncWorker>()
.setInputData(workDataOf(KEY_DATE to date, KEY_ROUTE_ID to routeId))
.setInputData(workDataOf(
KEY_DATE to date,
KEY_ROUTE_ID to routeId,
KEY_SCOPE to scope,
KEY_VERSION to version,
KEY_CHECKSUM to checksum,
))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
listOf("driver-sync", date ?: "-", routeId ?: "-").joinToString("-"),
ExistingWorkPolicy.REPLACE,
listOf("driver-sync", scope ?: "all", date ?: "-", routeId ?: "-").joinToString("-"),
ExistingWorkPolicy.KEEP,
request,
)
}
@@ -26,6 +26,7 @@ import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import retrofit2.HttpException
@@ -36,6 +37,7 @@ class NewRouteNotificationWorker(
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val routeId = inputData.getString(KEY_ROUTE_ID)?.takeIf(String::isNotBlank) ?: return Result.success()
return runCatching {
@@ -58,6 +58,8 @@ import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Factory
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info
@@ -84,6 +86,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -151,6 +154,9 @@ import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry
import pl.firmatpp.kierowca.diagnostics.DiagnosticSeverity
import pl.firmatpp.kierowca.sync.LiveSyncConnectionState
import pl.firmatpp.kierowca.domain.OtpCodeExtractor
import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
@@ -187,20 +193,27 @@ fun DriverApp(
appInForeground = true
viewModel.onAppForegrounded()
}
Lifecycle.Event.ON_STOP -> appInForeground = false
Lifecycle.Event.ON_STOP -> {
appInForeground = false
viewModel.onAppBackgrounded()
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(state.screen, state.selectedDate, state.autoRefreshSeconds, appInForeground) {
if (state.screen != DriverScreen.Routes || !appInForeground) return@LaunchedEffect
LaunchedEffect(state.realtimeConnectionState, state.autoRefreshSeconds, state.isOnline, appInForeground) {
if (!appInForeground || !state.isOnline || state.driver == null) return@LaunchedEffect
val intervalMillis = state.autoRefreshSeconds.coerceIn(30, 600) * 1000L
val intervalMillis = if (state.realtimeConnectionState == LiveSyncConnectionState.Connected) {
5 * 60 * 1000L
} else {
state.autoRefreshSeconds.coerceIn(30, 600) * 1000L
}
while (true) {
delay(intervalMillis)
viewModel.refreshRoutesSilently()
viewModel.periodicSyncCheck()
}
}
SmsUserConsentEffect(
@@ -235,7 +248,9 @@ fun DriverApp(
state = state,
onRoutes = viewModel::refreshRoutes,
onProfile = viewModel::openProfile,
onLogout = viewModel::logout,
onLogout = viewModel::requestLogout,
onConfirmLogout = viewModel::confirmLogoutAndDeletePending,
onDismissLogout = viewModel::dismissLogoutWarning,
onLeaveRequests = viewModel::openLeaveRequests,
onNewRouteNotificationsChanged = { enabled ->
if (!enabled) {
@@ -251,6 +266,12 @@ fun DriverApp(
onOpenNotificationSettings = { openAppNotificationSettings(context) },
onThemeModeChanged = viewModel::setThemeMode,
onRouteProgressNotificationChanged = viewModel::setRouteProgressNotificationEnabled,
onDiagnostics = viewModel::openDiagnostics,
)
DriverScreen.Diagnostics -> DiagnosticsScreen(
state = state,
onBack = viewModel::back,
onRefresh = viewModel::refreshDiagnostics,
)
DriverScreen.Detail -> DetailScreen(
state,
@@ -259,30 +280,32 @@ fun DriverApp(
viewModel::refreshSelectedRoute,
viewModel::openStartRoute,
viewModel::openFinishRoute,
viewModel::correctFailedRouteAction,
)
DriverScreen.StartRoute -> RouteStageScreen(
state = state,
title = "Zdjęcia załadunku",
title = if (routeStageRequirementIsVisible(state.loadingPhotoRequirement)) "Zdjęcia załadunku" else "Potwierdź załadunek",
stage = "loading",
weightLabel = "Waga na załadunku",
submitLabel = "Gotowe, jadę na wagę",
weightLabel = "Tonaż załadunku",
submitLabel = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) "Dalej: tonaż" else "Potwierdź załadunek",
showWeightInput = false,
showPhotoActions = true,
showPhotoActions = routeStageRequirementIsVisible(state.loadingPhotoRequirement),
showPhotoGrid = routeStageRequirementIsVisible(state.loadingPhotoRequirement),
onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") },
onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deleteConfirmedUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::openLoadingWeight,
onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute,
)
DriverScreen.LoadingWeight -> RouteStageScreen(
state = state,
title = "Waga załadunku",
title = "Tonaż załadunku",
stage = "loading",
weightLabel = "Waga z wagi",
submitLabel = "Załaduj",
showWeightInput = true,
weightLabel = "Tonaż załadunku",
submitLabel = "Potwierdź załadunek",
showWeightInput = routeStageRequirementIsVisible(state.loadingWeightRequirement),
showPhotoActions = false,
showPhotoGrid = false,
onBack = viewModel::back,
@@ -297,8 +320,11 @@ fun DriverApp(
state = state,
title = "Rozładunek",
stage = "unloading",
weightLabel = "Waga na rozładunku",
submitLabel = "Rozładuj",
weightLabel = "Tonaż rozładunku",
submitLabel = "Potwierdź rozładunek",
showWeightInput = routeStageRequirementIsVisible(state.unloadingWeightRequirement),
showPhotoActions = routeStageRequirementIsVisible(state.unloadingPhotoRequirement),
showPhotoGrid = routeStageRequirementIsVisible(state.unloadingPhotoRequirement),
onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") },
@@ -778,7 +804,7 @@ private fun RoutesScreen(
}
}
item { OfflineStaleBanner(state) }
item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) }
item { OperationalQueueBanner(state, onPhotoQueue) }
if (state.displayRoutes.isEmpty()) {
item { EmptyState("Brak kursow na wybrany dzien") }
} else {
@@ -907,10 +933,12 @@ private fun LeaveRequestsScreen(
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { OfflineStaleBanner(state) }
item { FeedbackAndError(state.feedback, state.error) }
item {
Button(
onClick = onAdd,
enabled = state.isOnline,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.medium,
@@ -987,6 +1015,7 @@ private fun LeaveRequestDetailScreen(
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { OfflineStaleBanner(state) }
item { FeedbackAndError(state.feedback, state.error) }
item {
Card(
@@ -1007,6 +1036,7 @@ private fun LeaveRequestDetailScreen(
if (DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) {
Button(
onClick = onCancel,
enabled = state.isOnline,
modifier = Modifier.fillMaxWidth().height(54.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB45309)),
shape = MaterialTheme.shapes.medium,
@@ -1100,6 +1130,7 @@ private fun LeaveCalendarScreen(
contentPadding = PaddingValues(start = 20.dp, top = 16.dp, end = 20.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { OfflineStaleBanner(state) }
item { FeedbackAndError(null, state.error) }
items(months, key = { it.toString() }) { month ->
LeaveCalendarMonth(
@@ -1187,7 +1218,7 @@ private fun LeaveCalendarContinueBar(
)
Button(
onClick = onContinue,
enabled = state.leaveCalendarHasSelection,
enabled = state.leaveCalendarHasSelection && state.isOnline,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.height(48.dp),
@@ -1424,6 +1455,7 @@ private fun AddLeaveRequestScreen(
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { OfflineStaleBanner(state) }
item { FeedbackAndError(null, state.error) }
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -1486,6 +1518,7 @@ private fun AddLeaveRequestScreen(
item {
Button(
onClick = onSubmit,
enabled = state.isOnline,
modifier = Modifier.fillMaxWidth().height(58.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.medium,
@@ -1711,8 +1744,9 @@ private fun DispatchSheetReminderCard(
}
@Composable
private fun PhotoQueueBanner(uploads: List<PhotoUploadEntity>, onPhotoQueue: () -> Unit) {
val count = queuedPhotoUploadCount(uploads.map { it.status })
private fun OperationalQueueBanner(state: DriverUiState, onPhotoQueue: () -> Unit) {
val count = state.pendingOperationalItems
val photoCount = queuedPhotoUploadCount(state.queuedPhotoUploads.map { it.status })
if (count <= 0) return
Card(
@@ -1733,18 +1767,20 @@ private fun PhotoQueueBanner(uploads: List<PhotoUploadEntity>, onPhotoQueue: ()
Icon(Icons.Outlined.CloudUpload, contentDescription = null, tint = TppTheme.colors.forest)
}
Text(
"W kolejce do wysłania jest $count ${photoCountLabel(count)}.",
color = TppTheme.colors.ink,
if (state.operationalQueueNeedsAttention) "$count elementów wymaga uwagi." else if (state.isOnline) "Synchronizuję $count elementów z serwerem." else "$count elementów zapisano w telefonie. Wyślemy je po odzyskaniu internetu.",
color = if (state.operationalQueueNeedsAttention) TppTheme.colors.error else TppTheme.colors.ink,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Button(
onClick = onPhotoQueue,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.small,
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
) {
Text("Kolejka", color = Color.White, fontWeight = FontWeight.Bold)
if (photoCount > 0) {
Button(
onClick = onPhotoQueue,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.small,
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
) {
Text("Zdjęcia", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
@@ -2107,11 +2143,14 @@ private fun ProfileScreen(
onRoutes: () -> Unit,
onProfile: () -> Unit,
onLogout: () -> Unit,
onConfirmLogout: () -> Unit,
onDismissLogout: () -> Unit,
onLeaveRequests: () -> Unit,
onNewRouteNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit,
onThemeModeChanged: (AppThemeMode) -> Unit,
onRouteProgressNotificationChanged: (Boolean) -> Unit,
onDiagnostics: () -> Unit,
) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val screenWidthDp = maxWidth.value.toInt()
@@ -2138,6 +2177,7 @@ private fun ProfileScreen(
.padding(horizontal = horizontalPadding, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
OfflineStaleBanner(state)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
@@ -2191,9 +2231,12 @@ private fun ProfileScreen(
Switch(
checked = state.notifyNewRoutes,
onCheckedChange = onNewRouteNotificationsChanged,
enabled = !state.refreshing && !state.loading,
enabled = state.isOnline && !state.refreshing && !state.loading,
)
}
if (!state.isOnline) {
Text("Zmiana powiadomień wymaga połączenia z internetem.", color = TppTheme.colors.muted, style = MaterialTheme.typography.bodySmall)
}
if (state.notificationPermissionDenied) {
Button(
onClick = onOpenNotificationSettings,
@@ -2258,6 +2301,17 @@ private fun ProfileScreen(
}
}
}
OutlinedButton(
onClick = onDiagnostics,
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = MaterialTheme.shapes.small,
border = BorderStroke(1.dp, TppTheme.colors.navy),
colors = ButtonDefaults.outlinedButtonColors(contentColor = TppTheme.colors.navy),
) {
Icon(Icons.Outlined.BugReport, contentDescription = null)
Spacer(Modifier.width(10.dp))
Text("Diagnostyka aplikacji", fontWeight = FontWeight.Bold)
}
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth().height(56.dp),
@@ -2269,6 +2323,218 @@ private fun ProfileScreen(
}
}
}
if (state.showLogoutWarning) {
AlertDialog(
onDismissRequest = onDismissLogout,
title = { Text("Niewysłane dane", fontWeight = FontWeight.Bold) },
text = {
Text("W telefonie jest ${state.pendingLogoutItems} niewysłanych elementów. Wylogowanie usunie je bez możliwości odzyskania.")
},
confirmButton = {
TextButton(onClick = onConfirmLogout) {
Text("Wyloguj i usuń dane", color = TppTheme.colors.error, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = onDismissLogout) { Text("Wróć i zsynchronizuj") }
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DiagnosticsScreen(
state: DriverUiState,
onBack: () -> Unit,
onRefresh: () -> Unit,
) {
val context = LocalContext.current
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(2_500L)
copied = false
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("Diagnostyka", fontWeight = FontWeight.Bold)
Text("Stan aplikacji i synchronizacji", style = MaterialTheme.typography.labelMedium, color = TppTheme.colors.muted)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Outlined.ArrowBack, contentDescription = "Wróć do profilu")
}
},
actions = {
IconButton(onClick = onRefresh, enabled = !state.diagnosticsLoading) {
Icon(Icons.Outlined.Refresh, contentDescription = "Odśwież diagnostykę")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = TppTheme.colors.card, titleContentColor = TppTheme.colors.ink),
)
},
containerColor = TppTheme.colors.surface,
) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.background(TppTheme.colors.panel, MaterialTheme.shapes.medium)
.border(1.dp, TppTheme.colors.outline, MaterialTheme.shapes.medium)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Raport dla wsparcia", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = TppTheme.colors.ink)
Text(
"Tokeny są pokazane jako bezpieczne fingerprinty. Raport zawiera informacje techniczne, ale nie pozwala zalogować się na konto kierowcy.",
style = MaterialTheme.typography.bodyMedium,
color = TppTheme.colors.muted,
)
}
}
if (state.diagnosticsError != null) {
item {
Text(
state.diagnosticsError,
modifier = Modifier.fillMaxWidth().background(TppTheme.colors.error.copy(alpha = 0.08f), MaterialTheme.shapes.small).padding(14.dp),
color = TppTheme.colors.error,
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (state.diagnosticsLoading && state.diagnostics == null) {
item {
Row(
Modifier.fillMaxWidth().padding(vertical = 32.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = TppTheme.colors.forest, strokeWidth = 3.dp)
Spacer(Modifier.width(12.dp))
Text("Zbieram dane diagnostyczne…", color = TppTheme.colors.muted)
}
}
}
state.diagnostics?.let { diagnostics ->
item {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(
onClick = {
copyDiagnosticReport(context, diagnostics.asPlainText())
copied = true
},
modifier = Modifier.weight(1f).height(52.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy),
shape = MaterialTheme.shapes.small,
) {
Icon(Icons.Outlined.ContentCopy, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(if (copied) "Skopiowano" else "Kopiuj raport", fontWeight = FontWeight.Bold)
}
OutlinedButton(
onClick = onRefresh,
enabled = !state.diagnosticsLoading,
modifier = Modifier.height(52.dp),
shape = MaterialTheme.shapes.small,
border = BorderStroke(1.dp, TppTheme.colors.outline),
) {
if (state.diagnosticsLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = TppTheme.colors.forest)
} else {
Icon(Icons.Outlined.Refresh, contentDescription = null, tint = TppTheme.colors.forest)
}
}
}
}
diagnostics.sections.forEach { section ->
item(key = section.title) {
DiagnosticSectionView(section.title, section.entries)
}
}
item {
Text(
"Wygenerowano ${formatSyncTime(diagnostics.generatedAtEpochMillis)}",
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
color = TppTheme.colors.muted,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
}
@Composable
private fun DiagnosticSectionView(title: String, entries: List<DiagnosticEntry>) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(TppTheme.colors.card, MaterialTheme.shapes.medium)
.border(1.dp, TppTheme.colors.outline, MaterialTheme.shapes.medium),
) {
Text(
title,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = TppTheme.colors.ink,
)
entries.forEachIndexed { index, entry ->
if (index > 0) HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.45f))
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Box(
Modifier
.padding(top = 6.dp)
.size(8.dp)
.background(diagnosticSeverityColor(entry.severity), RoundedCornerShape(2.dp)),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(entry.label, style = MaterialTheme.typography.labelLarge, color = TppTheme.colors.muted)
Text(
entry.value,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = TppTheme.colors.ink,
)
}
}
}
}
}
@Composable
private fun diagnosticSeverityColor(severity: DiagnosticSeverity): Color = when (severity) {
DiagnosticSeverity.Neutral -> TppTheme.colors.muted
DiagnosticSeverity.Good -> TppTheme.colors.forest
DiagnosticSeverity.Warning -> Color(0xFF9A6700)
DiagnosticSeverity.Error -> TppTheme.colors.error
}
private fun copyDiagnosticReport(context: Context, report: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("TPP Kierowca, diagnostyka", report))
}
private fun canPostNotifications(context: Context): Boolean =
@@ -2356,20 +2622,27 @@ private fun RouteStageScreen(
val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty()
val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size)
val photoRequirement = if (stage == "loading") state.loadingPhotoRequirement else state.unloadingPhotoRequirement
val weightRequirement = if (stage == "loading") state.loadingWeightRequirement else state.unloadingWeightRequirement
val submitBlocker = when {
!showWeightInput -> loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
photoRequirement = photoRequirement,
)
stage == "loading" -> loadingWeightSubmitBlocker(
weightText = state.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
weightRequirement = weightRequirement,
photoRequirement = photoRequirement,
)
else -> routeStageSubmitBlocker(
weightText = state.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
weightRequirement = weightRequirement,
photoRequirement = photoRequirement,
)
}
val weightBlocker = submitBlocker?.takeIf { it.contains("wag", ignoreCase = true) || it.contains("popraw", ignoreCase = true) }
@@ -2428,16 +2701,20 @@ private fun RouteStageScreen(
item {
RouteStageChecklist(
hasValidWeight = if (showWeightInput) {
routeStageSubmitBlocker(
state.routeStageWeightText.trim().isNotBlank() && routeStageSubmitBlocker(
weightText = state.routeStageWeightText,
serverPhotoCount = 1,
localUploadCount = 0,
weightRequirement = weightRequirement,
photoRequirement = RouteStageRequirement.Optional,
) == null
} else {
null
},
photoCount = attachmentCount,
photoLabel = if (stage == "loading") "Zdjęcie załadunku" else "Zdjęcie etapu",
weightRequirement = weightRequirement,
photoRequirement = photoRequirement,
)
}
if (showWeightInput) {
@@ -2448,7 +2725,7 @@ private fun RouteStageScreen(
label = { Text(weightLabel) },
trailingIcon = {
Text(
"kg",
"t",
color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -2589,7 +2866,13 @@ private fun RouteStageSummaryFact(label: String, value: String, modifier: Modifi
}
@Composable
private fun RouteStageChecklist(hasValidWeight: Boolean?, photoCount: Int, photoLabel: String) {
private fun RouteStageChecklist(
hasValidWeight: Boolean?,
photoCount: Int,
photoLabel: String,
weightRequirement: String,
photoRequirement: String,
) {
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
border = BorderStroke(1.dp, TppTheme.colors.outline),
@@ -2598,23 +2881,31 @@ private fun RouteStageChecklist(hasValidWeight: Boolean?, photoCount: Int, photo
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Wymagane przed wysłaniem",
"Dane etapu",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
if (hasValidWeight != null) {
RouteStageRequirementRow(
label = "Waga",
done = hasValidWeight,
detail = if (hasValidWeight) "uzupełniona" else "wpisz wartość w kg",
if (!routeStageRequirementIsVisible(weightRequirement) && !routeStageRequirementIsVisible(photoRequirement)) {
Text(
"Nie wymagamy dodatkowych danych. Możesz od razu potwierdzić etap.",
color = TppTheme.colors.muted,
)
}
if (hasValidWeight != null && routeStageRequirementIsVisible(weightRequirement)) {
RouteStageRequirementRow(
label = if (routeStageRequirementIsRequired(weightRequirement)) "Tonaż" else "Tonaż (opcjonalnie)",
done = hasValidWeight,
detail = if (hasValidWeight) "uzupełniony" else if (routeStageRequirementIsRequired(weightRequirement)) "wpisz wartość w tonach" else "możesz pominąć",
)
}
if (routeStageRequirementIsVisible(photoRequirement)) {
RouteStageRequirementRow(
label = if (routeStageRequirementIsRequired(photoRequirement)) photoLabel else "$photoLabel (opcjonalnie)",
done = photoCount > 0,
detail = if (photoCount > 0) "$photoCount ${photoCountLabel(photoCount)}" else if (routeStageRequirementIsRequired(photoRequirement)) "dodaj minimum jedno" else "możesz pominąć",
)
}
RouteStageRequirementRow(
label = photoLabel,
done = photoCount > 0,
detail = if (photoCount > 0) "$photoCount ${photoCountLabel(photoCount)}" else "dodaj minimum jedno",
)
}
}
}
@@ -2693,6 +2984,7 @@ private fun DetailScreen(
onRefresh: () -> Unit,
onStartRoute: () -> Unit,
onFinishRoute: () -> Unit,
onCorrectRouteAction: () -> Unit,
) {
val context = LocalContext.current
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
@@ -2723,9 +3015,12 @@ private fun DetailScreen(
feedback = state.feedback,
routeActions = projection.visibleActions,
routeProgressNotificationEnabled = state.routeProgressNotificationEnabled,
loadingPhotoRequirement = state.loadingPhotoRequirement,
loadingWeightRequirement = state.loadingWeightRequirement,
onRefresh = onRefresh,
onStart = onStartRoute,
onFinish = onFinishRoute,
onCorrect = onCorrectRouteAction,
)
}
item {
@@ -2784,11 +3079,14 @@ private fun RouteLifecycleSection(
feedback: String?,
routeActions: List<RouteActionEntity>,
routeProgressNotificationEnabled: Boolean,
loadingPhotoRequirement: String,
loadingWeightRequirement: String,
onRefresh: () -> Unit,
onStart: () -> Unit,
onFinish: () -> Unit,
onCorrect: () -> Unit,
) {
val steps = routeFlowSteps(route, routeActions)
val steps = routeFlowSteps(route, routeActions, loadingPhotoRequirement, loadingWeightRequirement)
val callout = routeSyncCallout(routeActions)
val action = routeLifecyclePrimaryAction(route, selectedDate)
val instruction = routeTodayInstruction(route, selectedDate)
@@ -2821,8 +3119,8 @@ private fun RouteLifecycleSection(
container = if (callout.isConflict) TppTheme.colors.warningContainer else TppTheme.colors.panel,
outline = if (callout.isConflict) TppTheme.colors.warningOutline else TppTheme.colors.outline,
color = if (callout.isConflict) TppTheme.colors.error else TppTheme.colors.muted,
actionLabel = "Odśwież dane",
onAction = onRefresh,
actionLabel = if (callout.canCorrect) "Popraw dane" else "Odśwież dane",
onAction = if (callout.canCorrect) onCorrect else onRefresh,
)
}
@@ -3304,7 +3602,7 @@ private fun RouteStageDocumentationBlock(
}
private fun formatRouteWeight(weight: Double): String =
if (weight % 1.0 == 0.0) "${weight.toInt()} kg" else String.format(Locale("pl", "PL"), "%.2f kg", weight)
if (weight % 1.0 == 0.0) "${weight.toInt()} t" else String.format(Locale("pl", "PL"), "%.3f t", weight)
@Composable
private fun CargoActionButton(label: String, icon: ImageVector, color: Color, onClick: () -> Unit) {
@@ -3878,22 +4176,25 @@ private fun ErrorText(error: String?) {
@Composable
private fun OfflineStaleBanner(state: DriverUiState) {
val syncLabel = state.lastSuccessfulSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji"
val message = offlineStaleBannerMessage(
val syncLabel = state.lastServerSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji"
val message = connectionStatusBannerMessage(
isOnline = state.isOnline,
isStale = state.isStale,
serverConnectionState = state.serverConnectionState,
showRealtimeReconnecting = state.showRealtimeReconnecting,
syncLabel = syncLabel,
) ?: return
val warning = !state.isOnline || state.serverConnectionState == ServerConnectionState.Degraded || state.isStale
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.warningContainer),
border = BorderStroke(1.dp, TppTheme.colors.warningOutline),
colors = CardDefaults.cardColors(containerColor = if (warning) TppTheme.colors.warningContainer else TppTheme.colors.panel),
border = BorderStroke(1.dp, if (warning) TppTheme.colors.warningOutline else TppTheme.colors.outline),
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = message,
color = Color(0xFF5F4200),
color = if (warning) Color(0xFF5F4200) else TppTheme.colors.muted,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(12.dp),
)
@@ -45,6 +45,7 @@ data class RouteLifecycleActionUi(
data class RouteSyncCalloutUi(
val text: String,
val isConflict: Boolean,
val canCorrect: Boolean,
)
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
@@ -66,6 +67,9 @@ fun routeIsActiveToday(
fun routeUnloadingDate(route: DriverRouteDto): String? = route.unloadingDate ?: route.routeDate
fun routeDayOrdering(route: DriverRouteDto, date: String): Int =
route.dayOrderings?.get(date) ?: route.ordering ?: Int.MAX_VALUE
fun routeScheduleDateLabel(date: String?): String =
date
?.let { runCatching { LocalDate.parse(it).format(shortDateFormatter) }.getOrNull() }
@@ -128,39 +132,90 @@ fun canFinishRouteFromDriverApp(
&& runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUploadCount: Int): Boolean {
return routeStageSubmitBlocker(weightText, serverPhotoCount, localUploadCount) == null
object RouteStageRequirement {
const val Required = "required"
const val Optional = "optional"
const val Disabled = "disabled"
}
fun loadingPhotosSubmitBlocker(serverPhotoCount: Int, localUploadCount: Int): String? =
if (visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0) {
fun normalizeRouteStageRequirement(value: String?): String = when (value) {
RouteStageRequirement.Optional -> RouteStageRequirement.Optional
RouteStageRequirement.Disabled -> RouteStageRequirement.Disabled
else -> RouteStageRequirement.Required
}
fun routeStageRequirementIsVisible(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) != RouteStageRequirement.Disabled
fun routeStageRequirementIsRequired(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
fun canSubmitRouteStageForm(
weightText: String,
serverPhotoCount: Int,
localUploadCount: Int,
weightRequirement: String = RouteStageRequirement.Required,
photoRequirement: String = RouteStageRequirement.Required,
): Boolean {
return routeStageSubmitBlocker(
weightText,
serverPhotoCount,
localUploadCount,
weightRequirement,
photoRequirement,
) == null
}
fun loadingPhotosSubmitBlocker(
serverPhotoCount: Int,
localUploadCount: Int,
photoRequirement: String = RouteStageRequirement.Required,
): String? =
if (
routeStageRequirementIsRequired(photoRequirement) &&
visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0
) {
"Dodaj co najmniej jedno zdjęcie załadunku."
} else {
null
}
fun loadingWeightSubmitBlocker(weightText: String, serverPhotoCount: Int, localUploadCount: Int): String? {
fun loadingWeightSubmitBlocker(
weightText: String,
serverPhotoCount: Int,
localUploadCount: Int,
weightRequirement: String = RouteStageRequirement.Required,
photoRequirement: String = RouteStageRequirement.Required,
): String? {
val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when {
trimmed.isBlank() -> "Podaj wagę."
normalizedWeight == null -> "Podaj poprawną wagę."
normalizedWeight <= 0.0 -> "Waga musi być większa od zera."
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
else -> null
}
}
fun routeStageSubmitBlocker(weightText: String, serverPhotoCount: Int, localUploadCount: Int): String? {
fun routeStageSubmitBlocker(
weightText: String,
serverPhotoCount: Int,
localUploadCount: Int,
weightRequirement: String = RouteStageRequirement.Required,
photoRequirement: String = RouteStageRequirement.Required,
): String? {
val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when {
trimmed.isBlank() -> "Podaj wagę."
normalizedWeight == null -> "Podaj poprawną wagę."
normalizedWeight <= 0.0 -> "Waga musi być większa od zera."
visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
else -> null
}
}
@@ -196,10 +251,16 @@ fun routeSyncCallout(actions: List<RouteActionEntity>): RouteSyncCalloutUi? {
}
},
isConflict = isConflict,
canCorrect = unresolved.status == RouteActionStatus.FailedPermanent,
)
}
fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): List<RouteFlowStepUi> {
fun routeFlowSteps(
route: DriverRouteDto,
actions: List<RouteActionEntity>,
loadingPhotoRequirement: String = RouteStageRequirement.Required,
loadingWeightRequirement: String = RouteStageRequirement.Required,
): List<RouteFlowStepUi> {
val startOverride = routeActionStepState(actions, RouteActionType.Start)
val finishOverride = routeActionStepState(actions, RouteActionType.Finish)
val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size
@@ -229,12 +290,16 @@ fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): Lis
else -> RouteFlowStepState.Todo
}
return listOf(
RouteFlowStepUi("loading_photos", "Zdjęcia załadunku", loadingPhotosState, routeFlowStateLabel(loadingPhotosState), routeScheduleDateLabel(route.routeDate)),
RouteFlowStepUi("loading_weight", "Waga załadunku", loadingWeightState, routeFlowStateLabel(loadingWeightState), routeScheduleDateLabel(route.routeDate)),
RouteFlowStepUi("transit", "W trasie", transitState, routeFlowStateLabel(transitState)),
RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState), routeScheduleDateLabel(routeUnloadingDate(route))),
)
return buildList {
if (routeStageRequirementIsVisible(loadingPhotoRequirement)) {
add(RouteFlowStepUi("loading_photos", "Zdjęcia załadunku", loadingPhotosState, routeFlowStateLabel(loadingPhotosState), routeScheduleDateLabel(route.routeDate)))
}
if (routeStageRequirementIsVisible(loadingWeightRequirement)) {
add(RouteFlowStepUi("loading_weight", "Tonaż załadunku", loadingWeightState, routeFlowStateLabel(loadingWeightState), routeScheduleDateLabel(route.routeDate)))
}
add(RouteFlowStepUi("transit", "W trasie", transitState, routeFlowStateLabel(transitState)))
add(RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState), routeScheduleDateLabel(routeUnloadingDate(route))))
}
}
fun routePhotosForStage(photos: List<RoutePhotoDto>?, stage: String): List<RoutePhotoDto> {
@@ -438,6 +503,21 @@ fun offlineStaleBannerMessage(isOnline: Boolean, isStale: Boolean, syncLabel: St
else -> "Dane mogą być nieaktualne. Ostatnia synchronizacja: $syncLabel."
}
fun connectionStatusBannerMessage(
isOnline: Boolean,
isStale: Boolean,
serverConnectionState: ServerConnectionState,
showRealtimeReconnecting: Boolean,
syncLabel: String,
): String? = when {
!isOnline -> offlineStaleBannerMessage(false, isStale, syncLabel)
serverConnectionState == ServerConnectionState.Degraded ->
"Internet działa, ale serwer jest chwilowo niedostępny. Pokazujemy dane z $syncLabel."
showRealtimeReconnecting -> "Ponowne łączenie. Dane są uzgadniane z serwerem."
isStale -> offlineStaleBannerMessage(true, true, syncLabel)
else -> null
}
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
package pl.firmatpp.kierowca.data.sync
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NetworkMonitorTest {
@Test
fun captivePortalIsNotTreatedAsOnline() {
assertFalse(hasValidatedInternetCapabilities(hasInternet = true, hasValidated = false))
}
@Test
fun validatedInternetIsOnline() {
assertTrue(hasValidatedInternetCapabilities(hasInternet = true, hasValidated = true))
}
}
@@ -0,0 +1,39 @@
package pl.firmatpp.kierowca.diagnostics
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DiagnosticSnapshotProviderTest {
@Test
fun secureIdentifierSummaryDoesNotExposeWholeToken() {
val token = "secret-session-token-123456"
val summary = secureIdentifierSummary(token)
assertFalse(summary.contains(token))
assertTrue(summary.contains("długość ${token.length}"))
assertTrue(summary.contains("końcówka …3456"))
assertTrue(summary.contains("SHA-256"))
}
@Test
fun copiedReportExplainsThatCredentialsAreRedacted() {
val snapshot = DiagnosticSnapshot(
generatedAtEpochMillis = 1_700_000_000_000,
sections = listOf(
DiagnosticSection(
title = "Sesja",
entries = listOf(DiagnosticEntry("Token", secureIdentifierSummary("top-secret"))),
),
),
)
val report = snapshot.asPlainText()
assertTrue(report.contains("[Sesja]"))
assertTrue(report.contains("Token: obecny"))
assertTrue(report.contains("nie zawiera danych pozwalających przejąć sesję"))
assertFalse(report.contains("top-secret"))
}
}
@@ -81,6 +81,23 @@ class DriverLiveSyncClientTest {
assertEquals(2, factory.sockets.size)
}
@Test
fun backgroundClosesSocketAndForegroundReconnects() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
client.setForeground(false)
assertTrue(factory.sockets.single().closed)
assertEquals(LiveSyncConnectionState.Stopped, client.connectionState.value)
client.setForeground(true)
advanceUntilIdle()
assertEquals(2, factory.sockets.size)
}
@Test
fun retriesWhenPrivateChannelSubscriptionAuthFails() = runTest {
val gateway = FakeLiveSyncGateway(authFailure = RuntimeException("offline"))
@@ -107,6 +124,21 @@ class DriverLiveSyncClientTest {
assertTrue(factory.sockets.single().sent.any { it.contains("pusher:pong") })
}
@Test
fun subscribesToOwnUpdatesAndSharedLeaveCalendarUpdates() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = this)
client.start("driver-1", config)
factory.sockets.single().message(connectionEstablished("socket-1"))
runCurrent()
val sent = factory.sockets.single().sent
assertTrue(sent.any { it.contains("private-driver-mobile.driver-1") })
assertTrue(sent.any { it.contains("private-driver-mobile.0") })
client.stop()
}
@Test
fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest {
val factory = FakeWebSocketFactory()
@@ -0,0 +1,24 @@
package pl.firmatpp.kierowca.sync
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DriverRuntimeSyncStateTest {
@After
fun reset() {
DriverRuntimeSyncState.foreground = false
DriverRuntimeSyncState.realtimeConnected = false
}
@Test
fun websocketSuppressesDuplicateFcmSyncOnlyWhenForegroundAndConnected() {
DriverRuntimeSyncState.foreground = true
DriverRuntimeSyncState.realtimeConnected = true
assertTrue(DriverRuntimeSyncState.websocketOwnsForegroundHints())
DriverRuntimeSyncState.realtimeConnected = false
assertFalse(DriverRuntimeSyncState.websocketOwnsForegroundHints())
}
}
@@ -261,6 +261,32 @@ class DriverUiRulesTest {
)
}
@Test
fun healthyOnlineStateDoesNotShowOfflineBanner() {
assertEquals(
null,
connectionStatusBannerMessage(
isOnline = true,
isStale = false,
serverConnectionState = ServerConnectionState.Reachable,
showRealtimeReconnecting = false,
syncLabel = "12:30",
),
)
}
@Test
fun serverFailureAndRealtimeReconnectAreNotCalledOffline() {
assertEquals(
"Internet działa, ale serwer jest chwilowo niedostępny. Pokazujemy dane z 12:30.",
connectionStatusBannerMessage(true, true, ServerConnectionState.Degraded, false, "12:30"),
)
assertEquals(
"Ponowne łączenie. Dane są uzgadniane z serwerem.",
connectionStatusBannerMessage(true, false, ServerConnectionState.Reachable, true, "12:30"),
)
}
@Test
fun explainsStaleCachedDataWhenOnline() {
assertEquals(
@@ -321,7 +347,7 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route, emptyList())
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today)
assertEquals(listOf("Zdjęcia załadunku", "Waga załadunku", "W trasie", "Rozładunek"), steps.map { it.title })
assertEquals(listOf("Zdjęcia załadunku", "Tonaż załadunku", "W trasie", "Rozładunek"), steps.map { it.title })
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
@@ -393,12 +419,22 @@ class DriverUiRulesTest {
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false)
assertFalse(callout?.canCorrect ?: true)
}
@Test
fun permanentlyRejectedRouteActionOffersDataCorrection() {
val callout = routeSyncCallout(
listOf(action(RouteActionType.Start, RouteActionStatus.FailedPermanent, "Brakuje wymaganych danych.")),
)
assertTrue(callout?.canCorrect ?: false)
}
@Test
fun routeStageSubmitBlockerExplainsMissingRequirements() {
assertEquals("Podaj wagę.", routeStageSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Waga musi być większa od zera.", routeStageSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Podaj tonaż.", routeStageSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Tonaż musi być większy od zera.", routeStageSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Dodaj co najmniej jedno zdjęcie etapu.", routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 0))
assertEquals(null, routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1))
}
@@ -415,9 +451,9 @@ class DriverUiRulesTest {
@Test
fun loadingWeightStepRequiresPositiveWeightAndPreviousLoadingPhoto() {
assertEquals("Podaj wagę.", loadingWeightSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Podaj poprawną wagę.", loadingWeightSubmitBlocker("abc", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Waga musi być większa od zera.", loadingWeightSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Podaj tonaż.", loadingWeightSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Podaj poprawny tonaż.", loadingWeightSubmitBlocker("abc", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Tonaż musi być większy od zera.", loadingWeightSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0))
assertEquals(
"Dodaj co najmniej jedno zdjęcie załadunku.",
loadingWeightSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 0),
@@ -425,6 +461,61 @@ class DriverUiRulesTest {
assertEquals(null, loadingWeightSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1))
}
@Test
fun optionalStageInputsAreVisibleButDoNotBlockConfirmation() {
assertEquals(
null,
routeStageSubmitBlocker(
weightText = "",
serverPhotoCount = 0,
localUploadCount = 0,
weightRequirement = RouteStageRequirement.Optional,
photoRequirement = RouteStageRequirement.Optional,
),
)
assertEquals(
"Podaj poprawny tonaż.",
routeStageSubmitBlocker(
weightText = "abc",
serverPhotoCount = 0,
localUploadCount = 0,
weightRequirement = RouteStageRequirement.Optional,
photoRequirement = RouteStageRequirement.Optional,
),
)
}
@Test
fun disabledStageInputsAreHiddenAndDoNotBlockConfirmation() {
assertEquals(RouteStageRequirement.Required, normalizeRouteStageRequirement(null))
assertEquals(RouteStageRequirement.Required, normalizeRouteStageRequirement("unknown"))
assertFalse(routeStageRequirementIsVisible(RouteStageRequirement.Disabled))
assertTrue(routeStageRequirementIsVisible(RouteStageRequirement.Optional))
assertTrue(routeStageRequirementIsRequired(RouteStageRequirement.Required))
assertEquals(
null,
routeStageSubmitBlocker(
weightText = "invalid value is ignored",
serverPhotoCount = 0,
localUploadCount = 0,
weightRequirement = RouteStageRequirement.Disabled,
photoRequirement = RouteStageRequirement.Disabled,
),
)
}
@Test
fun disabledLoadingStepsAreRemovedFromRouteFlow() {
val steps = routeFlowSteps(
route(status = "ZAPLANOWANA"),
emptyList(),
loadingPhotoRequirement = RouteStageRequirement.Disabled,
loadingWeightRequirement = RouteStageRequirement.Disabled,
)
assertEquals(listOf("W trasie", "Rozładunek"), steps.map { it.title })
}
private fun route(
status: String = "ZAPLANOWANA",
loadingWeight: Double? = null,
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
@@ -26,6 +27,21 @@ class DriverUiStateTest {
assertEquals(AppThemeMode.Material3, state.themeMode)
}
@Test
fun networkTimeoutDoesNotChangeValidatedNetworkState() {
val state = DriverUiState(
isOnline = true,
serverConnectionState = ServerConnectionState.Reachable,
lastServerSyncAtEpochMillis = 123L,
)
val failed = state.withApiError(IOException("timeout"))
assertTrue(failed.isOnline)
assertTrue(failed.isStale)
assertEquals(ServerConnectionState.Degraded, failed.serverConnectionState)
}
@Test
fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() {
val state = DriverUiState(
@@ -55,6 +71,23 @@ class DriverUiStateTest {
assertEquals(1, refreshed.leaveRequests.size)
}
@Test
fun staleLeaveRequestRefreshDoesNotOverwriteNewerMutationResult() {
val state = DriverUiState(
screen = DriverScreen.LeaveRequestDetail,
leaveRequests = listOf(leaveRequest(id = "leave-1", status = "cancel_requested", version = 3)),
selectedLeaveRequest = leaveRequest(id = "leave-1", status = "cancel_requested", version = 3),
)
val refreshed = state.withLoadedLeaveRequests(
requests = listOf(leaveRequest(id = "leave-1", status = "approved", version = 2)),
navigateToList = false,
)
assertEquals("cancel_requested", refreshed.leaveRequests.single().status)
assertEquals(3L, refreshed.selectedLeaveRequest?.version)
}
@Test
fun displayRoutesAndSelectedRouteUseLocalRouteActionsProjection() {
val route = route(status = "ZAPLANOWANA")
@@ -83,19 +116,41 @@ class DriverUiStateTest {
assertEquals(listOf("planned", "active", "completed"), state.displayRoutes.map { it.id })
}
private fun leaveRequest(id: String, status: String): DriverLeaveRequestDto =
@Test
fun displayRoutesUsesTheSelectedDayOrderingForMultiDayRoutes() {
val state = DriverUiState(
selectedDate = "2026-07-02",
routes = listOf(
route(id = "span", status = "ZAPLANOWANA", ordering = 0, dayOrderings = mapOf("2026-07-02" to 2)),
route(id = "above-first", status = "ZAPLANOWANA", ordering = 0, dayOrderings = mapOf("2026-07-02" to 0)),
route(id = "above-second", status = "ZAPLANOWANA", ordering = 1, dayOrderings = mapOf("2026-07-02" to 1)),
),
)
assertEquals(listOf("above-first", "above-second", "span"), state.displayRoutes.map { it.id })
}
private fun leaveRequest(id: String, status: String, version: Long = 1): DriverLeaveRequestDto =
DriverLeaveRequestDto(
id = id,
dateFrom = "2026-07-10",
dateTo = "2026-07-12",
type = "URLOP",
status = status,
version = version,
)
private fun route(id: String = "1", status: String): DriverRouteDto =
private fun route(
id: String = "1",
status: String,
ordering: Int? = null,
dayOrderings: Map<String, Int> = emptyMap(),
): DriverRouteDto =
DriverRouteDto(
id = id,
routeDate = "2026-06-30",
ordering = ordering,
dayOrderings = dayOrderings,
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",