Compare commits

...
19 Commits
Author SHA1 Message Date
admin 905711d8de chore: release android driver 1.0.58 (111) 2026-07-16 11:06:24 +02:00
admin 7c9cb3fb0d Dodaj timeout i ręczny tryb offline przy uruchamianiu 2026-07-16 11:04:36 +02:00
admin ae0d1b2d3d chore: release android driver 1.0.57 (110) 2026-07-16 10:31:11 +02:00
admin c5b3c5dae9 Przeliczaj wagi kierowcy z kilogramów na tony 2026-07-16 10:28:30 +02:00
admin 3fa3b4eac1 chore: release android driver 1.0.56 (109) 2026-07-12 22:34:20 +02:00
admin 197bcc6f5e Przypomnij o zgodzie na powiadomienia Android 2026-07-12 22:33:52 +02:00
admin f4f5cec2be chore: release android driver 1.0.55 (108) 2026-07-12 22:04:46 +02:00
admin 5a630f8401 Użyj origin realtime z bootstrapu 2026-07-12 21:59:46 +02:00
admin 54a3c58a22 chore: release android driver 1.0.54 (107) 2026-07-12 21:57:39 +02:00
admin 1c6a044c37 Dodaj Origin dla WebSocket Reverb 2026-07-12 21:55:01 +02:00
admin fcc022db64 chore: release android driver 1.0.53 (106) 2026-07-11 00:35:40 +02:00
admin a908a2da18 fix: avoid release verification pipe failure 2026-07-11 00:35:21 +02:00
admin 2c25647fa5 chore: ignore backup Android resources 2026-07-11 00:29:47 +02:00
admin 956f33140e chore: automate Android release builds 2026-07-11 00:27:54 +02:00
admin 7bba4dc878 fix: stop orphaned GPS synchronization 2026-07-11 00:27:52 +02:00
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
43 changed files with 3107 additions and 287 deletions
+2
View File
@@ -7,3 +7,5 @@ app/google-services.json:Zone.Identifier
*.iml
keystore.properties
*.jks
release-artifacts/
app/src/main/res_bak/
+20
View File
@@ -21,3 +21,23 @@ Natywna aplikacja Android Jetpack Compose dla kierowcow TPP.
```bash
./gradlew :app:testDebugUnitTest
```
## Release Gitea i Google Play
Przed wydaniem zapisz wszystkie zmiany źródłowe w commicie. Następnie uruchom:
```bash
./scripts/release-android.sh
```
Skrypt automatycznie zwiększa `versionCode` i ostatni człon `versionName`, uruchamia testy,
buduje podpisany APK dla Gitea oraz AAB dla Google Play Console, tworzy commit wersji,
tag, push i Gitea Release. Artefakty zostają również zapisane lokalnie w
`release-artifacts/<tag>/`.
Do autoryzacji Gitea używany jest `GITEA_TOKEN` albo dane zapisane w Git credential helper.
Jeśli publikacja została przerwana już po utworzeniu commita wersji, można ją wznowić:
```bash
./scripts/release-android.sh --reuse-current
```
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 103
versionName = "1.0.50"
versionCode = 111
versionName = "1.0.58"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -1,12 +1,23 @@
package pl.firmatpp.kierowca
import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import pl.firmatpp.kierowca.data.upload.OfflineOutboxManager
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverApplication : Application() {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
AppDiagnostics.installFirebaseCrashlytics()
AppDiagnostics.log("app_started")
applicationScope.launch {
runCatching { OfflineOutboxManager(this@DriverApplication).repairGpsQueue() }
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") }
}
}
}
@@ -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,
@@ -113,6 +117,7 @@ data class RealtimeConfigDto(
val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null,
val reverbOrigin: String? = null,
)
data class RoutesBucketDto(
@@ -193,6 +198,7 @@ data class CreateLeaveRequestBody(
data class CancelLeaveRequestBody(
val comment: String? = null,
val expectedVersion: Long? = null,
)
data class DriverLeaveRequestDto(
@@ -204,6 +210,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 +255,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 +333,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,27 @@ 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 hasBootstrapCache(date: String?): Boolean =
dao.bootstrap(date ?: currentDateFallback()) != null
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 +119,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 +169,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 +191,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
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.withTimeoutOrNull
import pl.firmatpp.kierowca.domain.StartupSessionPolicy
class StartupOfflineFallbackLoader(
private val timeoutMillis: Long = StartupSessionPolicy.OFFLINE_FALLBACK_MILLIS,
) {
suspend fun <T> load(
onlineLoad: suspend () -> T,
offlineLoad: suspend () -> T?,
): T {
val onlineValue = withTimeoutOrNull(timeoutMillis) {
onlineLoad()
}
if (onlineValue != null) {
return onlineValue
}
return offlineLoad()
?: throw IOException(
"Serwer nie odpowiedział w ciągu ${StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS} sekund " +
"i w telefonie nie ma zapisanych danych offline.",
)
}
}
@@ -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,63 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.work.WorkManager
import java.io.File
import java.util.concurrent.TimeUnit
class OfflineOutboxManager(private val 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 repairGpsQueue() {
val retryableStatuses = setOf(
RoutePointStatus.Pending,
RoutePointStatus.Syncing,
RoutePointStatus.FailedRetryable,
)
val routeIds = database.routePointDao().allUnsent()
.filter { it.status in retryableStatuses }
.map { it.routeId }
.distinct()
routeIds.forEach { routeId ->
runCatching {
workManager.cancelUniqueWork(RoutePointWorker.uniqueWorkName(routeId))
.result
.get(5, TimeUnit.SECONDS)
}
RoutePointOutbox(context).enqueueWorker(routeId, delaySeconds = 0L)
}
}
suspend fun clearAll() {
val photos = database.photoUploadDao().allRecords()
val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
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,20 @@ 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("SELECT MIN(createdAtEpochMillis) FROM route_points WHERE status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')")
fun observeOldestUnsentAt(): Flow<Long?>
@Query(
"""
UPDATE route_points
@@ -47,4 +57,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()
}
@@ -4,12 +4,14 @@ import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import java.util.UUID
import kotlinx.coroutines.delay
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
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,32 +22,36 @@ 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()
while (!isStopped) {
val points = dao.pendingForRoute(routeId, BATCH_LIMIT)
if (points.isEmpty()) return Result.success()
val pointIds = points.map { it.clientPointId }
dao.updateStatus(pointIds, RoutePointStatus.Syncing, null, attemptIncrement = 1)
val pointIds = points.map { it.clientPointId }
dao.updateStatus(pointIds, RoutePointStatus.Syncing, null, attemptIncrement = 1)
return runCatching {
val response = repository.storeRoutePoints(
routeId = routeId,
body = RoutePointBatchBody(
clientBatchId = UUID.randomUUID().toString(),
points = points.map { it.toDto() },
),
)
dao.updateStatus(pointIds, RoutePointStatus.Confirmed, null, attemptIncrement = 0)
if (response.arrived) {
ActiveRouteTrackingService.markArrived(applicationContext, routeId)
val outcome = runCatching {
repository.storeRoutePoints(
routeId = routeId,
body = RoutePointBatchBody(
clientBatchId = UUID.randomUUID().toString(),
points = points.map { it.toDto() },
),
)
}
if (dao.pendingCount(routeId) > 0) {
RoutePointOutbox(applicationContext).enqueueWorker(routeId, delaySeconds = 1L)
val response = outcome.getOrNull()
if (response != null) {
dao.delete(pointIds)
if (response.arrived) ActiveRouteTrackingService.markArrived(applicationContext, routeId)
if (dao.pendingCount(routeId) == 0) {
delay(750L)
if (dao.pendingCount(routeId) == 0) return Result.success()
}
continue
}
Result.success()
}.getOrElse { throwable ->
val throwable = outcome.exceptionOrNull() ?: return Result.retry()
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
@@ -59,11 +65,18 @@ class RoutePointWorker(
"retryable" to error.retryable,
),
)
if (isInactiveRoutePointError(error.code)) {
dao.delete(pointIds)
ActiveRouteTrackingService.stop(applicationContext, routeId)
continue
}
val status = if (error.retryable) RoutePointStatus.FailedRetryable else RoutePointStatus.FailedPermanent
dao.updateStatus(pointIds, status, error.message, attemptIncrement = 0)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
return if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
return Result.retry()
}
private fun RoutePointEntity.toDto(): RoutePointDto =
@@ -88,3 +101,5 @@ class RoutePointWorker(
fun uniqueWorkName(routeId: String): String = "route-point-sync-$routeId"
}
}
internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS"
@@ -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"
@@ -0,0 +1,51 @@
package pl.firmatpp.kierowca.domain
data class StartupSessionMessage(
val title: String,
val detail: String,
)
object StartupSessionPolicy {
const val OFFLINE_BUTTON_DELAY_SECONDS = 4
const val OFFLINE_FALLBACK_SECONDS = 15
const val OFFLINE_FALLBACK_MILLIS = OFFLINE_FALLBACK_SECONDS * 1_000L
fun remainingSeconds(elapsedSeconds: Int): Int =
(OFFLINE_FALLBACK_SECONDS - elapsedSeconds).coerceAtLeast(0)
fun shouldShowOfflineButton(elapsedSeconds: Int, offlineDataAvailable: Boolean): Boolean =
offlineDataAvailable && elapsedSeconds >= OFFLINE_BUTTON_DELAY_SECONDS
fun message(elapsedSeconds: Int): StartupSessionMessage = when {
elapsedSeconds < 3 -> StartupSessionMessage(
title = "Sprawdzamy zapisaną sesję",
detail = "Odczytujemy bezpieczne logowanie kierowcy.",
)
elapsedSeconds < 6 -> StartupSessionMessage(
title = "Potwierdzamy logowanie",
detail = "Łączymy telefon z serwerem.",
)
elapsedSeconds < 9 -> StartupSessionMessage(
title = "Pobieramy aktualne kursy",
detail = "Synchronizujemy plan dnia i ustawienia aplikacji.",
)
elapsedSeconds < 12 -> StartupSessionMessage(
title = "To trwa dłużej niż zwykle",
detail = "Przy słabym zasięgu skorzystamy z danych zapisanych w telefonie.",
)
else -> StartupSessionMessage(
title = "Przygotowujemy tryb offline",
detail = "Za chwilę pokażemy ostatnie zapisane kursy.",
)
}
fun countdownLabel(elapsedSeconds: Int): String {
val remaining = remainingSeconds(elapsedSeconds)
return if (remaining > 0) {
"Tryb offline najpóźniej za $remaining s"
} else {
"Otwieramy zapisane dane offline"
}
}
}
@@ -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"],
)
}
}
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.sync
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.net.URI
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -11,6 +12,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 +23,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
@@ -37,17 +43,34 @@ class DriverRepositoryLiveSyncGateway(
}
interface LiveWebSocketFactory {
fun newWebSocket(url: String, listener: WebSocketListener): WebSocket
fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket
}
class OkHttpLiveWebSocketFactory(
private val client: OkHttpClient,
) : LiveWebSocketFactory {
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket =
client.newWebSocket(Request.Builder().url(url).build(), listener)
override fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket =
client.newWebSocket(
Request.Builder()
.url(url)
.header("Origin", origin?.takeIf { it.isNotBlank() } ?: websocketOrigin(url))
.build(),
listener,
)
}
private enum class LiveSyncConnectionState {
internal fun websocketOrigin(url: String): String =
URI(
url
.replaceFirst("wss://", "https://")
.replaceFirst("ws://", "http://")
).let { uri ->
val defaultPort = (uri.scheme == "https" && uri.port == 443) || (uri.scheme == "http" && uri.port == 80)
val port = if (uri.port > 0 && !defaultPort) ":${uri.port}" else ""
"${uri.scheme}://${uri.host}$port"
}
enum class LiveSyncConnectionState {
Stopped,
WaitingForNetwork,
Disconnected,
@@ -56,10 +79,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 +107,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 +143,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 +183,7 @@ class DriverLiveSyncClient(
fun ensureConnected() {
val shouldConnect = synchronized(lock) {
desiredActive &&
foregroundActive &&
networkAvailable &&
state !in setOf(
LiveSyncConnectionState.Connecting,
@@ -136,22 +201,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 +237,7 @@ class DriverLiveSyncClient(
realtimeConfig = null
socketId = null
reconnectAttempt = 0
state = LiveSyncConnectionState.Stopped
updateState(LiveSyncConnectionState.Stopped)
}
reportRealtimeStatus("disconnected", "client_stop")
}
@@ -173,25 +248,29 @@ class DriverLiveSyncClient(
}
private fun connectNow(resetAttempt: Boolean = false) {
val wsUrl = synchronized(lock) {
val (wsUrl, origin) = 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()
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false"
Pair(
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false",
config.reverbOrigin,
)
}
val socket = webSocketFactory.newWebSocket(
wsUrl,
origin,
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text)
@@ -227,6 +306,7 @@ class DriverLiveSyncClient(
false
} else {
messageVersion += 1
lastMessageAtEpochMillis = System.currentTimeMillis()
true
}
}
@@ -244,7 +324,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 +334,7 @@ class DriverLiveSyncClient(
if (channel == "private-driver-mobile.$id") {
synchronized(lock) {
reconnectAttempt = 0
state = LiveSyncConnectionState.Connected
updateState(LiveSyncConnectionState.Connected)
}
reportRealtimeStatus("connected")
startHeartbeat()
@@ -272,22 +352,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 +388,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 +408,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 +486,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 {
@@ -56,6 +56,10 @@ class ActiveRouteTrackingService : Service(), LocationListener {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
if (!requestedRouteId.isNullOrBlank() && routeId != requestedRouteId) {
return START_NOT_STICKY
}
stopSelf()
return START_NOT_STICKY
}
@@ -64,13 +68,25 @@ class ActiveRouteTrackingService : Service(), LocationListener {
if (!id.isNullOrBlank() && (routeId == null || id == routeId)) {
routeId = id
arrived = true
runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification())
}
return START_STICKY
return START_NOT_STICKY
}
else -> {
routeId = intent?.getStringExtra(EXTRA_ROUTE_ID) ?: routeId
driverId = intent?.getStringExtra(EXTRA_DRIVER_ID) ?: driverId
val nextRouteId = intent?.getStringExtra(EXTRA_ROUTE_ID) ?: routeId
val nextDriverId = intent?.getStringExtra(EXTRA_DRIVER_ID) ?: driverId
if (nextRouteId.isNullOrBlank() || nextDriverId.isNullOrBlank()) {
stopSelf()
return START_NOT_STICKY
}
val routeChanged = nextRouteId != routeId
routeId = nextRouteId
driverId = nextDriverId
getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString(ACTIVE_ROUTE_ID, nextRouteId)
.apply()
routeLabel = intent?.getStringExtra(EXTRA_ROUTE_LABEL) ?: routeLabel
destinationLat = intent?.takeIf { it.hasExtra(EXTRA_DESTINATION_LAT) }?.getDoubleExtra(EXTRA_DESTINATION_LAT, 0.0)
?: destinationLat
@@ -80,10 +96,10 @@ class ActiveRouteTrackingService : Service(), LocationListener {
?.takeIf { it > 0.0 }
?: totalDistanceMeters
progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled
arrived = false
if (routeChanged) arrived = false
startForegroundCompat(buildNotification())
requestLocationUpdates()
return START_STICKY
if (!arrived) requestLocationUpdates()
return START_NOT_STICKY
}
}
}
@@ -96,6 +112,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) {
arrived = true
runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification())
} else {
startForegroundCompat(buildNotification())
@@ -119,14 +136,19 @@ class ActiveRouteTrackingService : Service(), LocationListener {
override fun onDestroy() {
runCatching { locationManager.removeUpdates(this) }
getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.edit()
.remove(ACTIVE_ROUTE_ID)
.apply()
scope.cancel()
super.onDestroy()
}
private fun requestLocationUpdates() {
if (!hasLocationPermission()) {
if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) {
return
}
runCatching { locationManager.removeUpdates(this) }
runCatching {
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, LOCATION_INTERVAL_MS, 0f, this)
}
@@ -266,6 +288,8 @@ class ActiveRouteTrackingService : Service(), LocationListener {
private const val EXTRA_DESTINATION_LON = "destinationLon"
private const val EXTRA_TOTAL_DISTANCE_METERS = "totalDistanceMeters"
private const val EXTRA_PROGRESS_ENABLED = "progressEnabled"
private const val TRACKING_PREFERENCES = "active_route_tracking"
private const val ACTIVE_ROUTE_ID = "active_route_id"
fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) {
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
@@ -281,12 +305,16 @@ class ActiveRouteTrackingService : Service(), LocationListener {
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
context.startService(
Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_STOP
},
)
fun stop(context: Context, routeId: String? = null) {
val activeRouteId = context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.getString(ACTIVE_ROUTE_ID, null)
if (routeId == null || routeId == activeRouteId) {
context.stopService(Intent(context, ActiveRouteTrackingService::class.java))
context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.edit()
.remove(ACTIVE_ROUTE_ID)
.apply()
}
}
fun markArrived(context: Context, routeId: String) {
@@ -12,9 +12,12 @@ import android.location.LocationManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
@@ -58,6 +61,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 +89,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
@@ -112,6 +118,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.Dp
@@ -151,8 +158,12 @@ 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.domain.StartupSessionPolicy
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
import pl.firmatpp.kierowca.ui.theme.TppTheme
@@ -187,20 +198,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(
@@ -215,10 +233,29 @@ fun DriverApp(
viewModel.openLeaveRequest(initialLeaveRequestId)
}
}
LaunchedEffect(state.screen, state.notifyNewRoutes, state.notificationPermissionDenied, appInForeground) {
if (!appInForeground || state.screen != DriverScreen.Profile || !state.notifyNewRoutes) return@LaunchedEffect
if (canPostNotifications(context)) {
if (state.notificationPermissionDenied) {
viewModel.clearNotificationPermissionWarning()
}
return@LaunchedEffect
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasPostNotificationsRuntimePermission(context)) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
viewModel.markNotificationPermissionBlocked()
}
}
Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) {
when (state.screen) {
DriverScreen.Initializing -> StartupScreen()
DriverScreen.Initializing -> StartupScreen(
offlineDataAvailable = state.startupOfflineAvailable,
onUseOfflineNow = viewModel::useOfflineStartupDataNow,
)
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back)
DriverScreen.Routes -> RoutesScreen(
@@ -235,7 +272,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 +290,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 +304,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 +344,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") },
@@ -350,7 +400,21 @@ fun DriverApp(
}
@Composable
private fun StartupScreen() {
private fun StartupScreen(
offlineDataAvailable: Boolean,
onUseOfflineNow: () -> Unit,
) {
var elapsedSeconds by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
while (elapsedSeconds < StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS) {
delay(1_000L)
elapsedSeconds += 1
}
}
val sessionMessage = StartupSessionPolicy.message(elapsedSeconds)
val countdownLabel = StartupSessionPolicy.countdownLabel(elapsedSeconds)
val showOfflineButton = StartupSessionPolicy.shouldShowOfflineButton(elapsedSeconds, offlineDataAvailable)
BoxWithConstraints(
Modifier
.fillMaxSize()
@@ -375,13 +439,100 @@ private fun StartupScreen() {
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(color = TppTheme.colors.forest)
Spacer(Modifier.height(20.dp))
Spacer(Modifier.height(22.dp))
Text(
"Sprawdzanie sesji",
color = TppTheme.colors.muted,
"URUCHAMIAMY APLIKACJĘ",
color = TppTheme.colors.forest,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
Spacer(Modifier.height(10.dp))
AnimatedContent(
targetState = sessionMessage,
transitionSpec = {
fadeIn(animationSpec = tween(260)) togetherWith
fadeOut(animationSpec = tween(220))
},
label = "startup-session-message",
) { message ->
Column(
modifier = Modifier.heightIn(min = 72.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
Text(
message.title,
modifier = Modifier.fillMaxWidth(),
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(6.dp))
Text(
message.detail,
modifier = Modifier.fillMaxWidth(),
color = TppTheme.colors.muted,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
Spacer(Modifier.height(14.dp))
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.panel),
border = BorderStroke(1.dp, TppTheme.colors.outline.copy(alpha = 0.72f)),
shape = RoundedCornerShape(6.dp),
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AnimatedContent(
targetState = countdownLabel,
transitionSpec = {
fadeIn(animationSpec = tween(180)) togetherWith
fadeOut(animationSpec = tween(140))
},
label = "startup-offline-countdown",
) { label ->
Text(
label,
color = TppTheme.colors.forest,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(4.dp))
Text(
"Jeśli kursy były wcześniej zapisane w telefonie.",
color = TppTheme.colors.muted,
fontSize = 12.sp,
)
}
}
AnimatedVisibility(
visible = showOfflineButton,
enter = fadeIn(animationSpec = tween(260)),
exit = fadeOut(animationSpec = tween(160)),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onUseOfflineNow,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
border = BorderStroke(1.dp, TppTheme.colors.forest),
colors = ButtonDefaults.outlinedButtonColors(contentColor = TppTheme.colors.forest),
shape = RoundedCornerShape(6.dp),
) {
Text(
"Przejdź do trybu offline teraz",
fontWeight = FontWeight.Bold,
)
}
}
}
}
}
}
@@ -778,7 +929,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 +1058,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 +1140,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 +1161,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 +1255,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 +1343,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 +1580,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 +1643,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 +1869,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 +1892,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 +2268,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 +2302,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,17 +2356,25 @@ 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) {
Text(
"Android blokuje powiadomienia dla tej aplikacji. Token FCM może być zarejestrowany, ale powiadomienie nie pojawi się na telefonie, dopóki nie włączysz zgody systemowej.",
color = TppTheme.colors.error,
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = onOpenNotificationSettings,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy),
shape = MaterialTheme.shapes.small,
) {
Text("Przejdź do ustawień", fontWeight = FontWeight.Bold)
Text("Otwórz ustawienia powiadomień", fontWeight = FontWeight.Bold)
}
}
HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f))
@@ -2258,6 +2431,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 +2453,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 +2752,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 +2831,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,16 +2855,14 @@ private fun RouteStageScreen(
label = { Text(weightLabel) },
trailingIcon = {
Text(
"kg",
"t / kg",
color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
},
supportingText = {
if (weightBlocker != null) {
Text(weightBlocker)
}
Text(weightBlocker ?: routeWeightInputHelpText(state.routeStageWeightText))
},
isError = weightBlocker != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
@@ -2589,7 +2994,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 +3009,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 +3112,7 @@ private fun DetailScreen(
onRefresh: () -> Unit,
onStartRoute: () -> Unit,
onFinishRoute: () -> Unit,
onCorrectRouteAction: () -> Unit,
) {
val context = LocalContext.current
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
@@ -2723,9 +3143,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 +3207,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 +3247,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 +3730,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 +4304,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),
)
@@ -1,11 +1,15 @@
package pl.firmatpp.kierowca.ui
import java.io.File
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlin.math.abs
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
@@ -15,6 +19,8 @@ import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
private val routeWeightKilogramInputThreshold = BigDecimal("1000")
private const val routeWeightMaxTons = 999.999
enum class RouteFlowStepState {
Todo,
@@ -45,6 +51,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 +73,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 +138,117 @@ 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 parseRouteWeightTons(weightText: String): Double? {
val enteredWeight = weightText.trim().replace(',', '.').toBigDecimalOrNull() ?: return null
val tons = if (enteredWeight.abs() >= routeWeightKilogramInputThreshold) {
enteredWeight.movePointLeft(3)
} else {
enteredWeight
}
return tons.setScale(3, RoundingMode.HALF_UP).toDouble()
}
fun routeWeightInputHelpText(weightText: String): String {
val enteredWeight = weightText.trim().replace(',', '.').toDoubleOrNull()
val tons = parseRouteWeightTons(weightText)
val enteredAsKilograms = enteredWeight != null &&
abs(enteredWeight) >= routeWeightKilogramInputThreshold.toDouble() &&
tons != null
return if (enteredAsKilograms) {
"Wpisano kilogramy — zapiszemy ${String.format(Locale("pl", "PL"), "%.3f", tons)} t."
} else {
"Wpisz tony (np. 23,4). Wartości od 1000 rozpoznajemy jako kilogramy (np. 23400)."
}
}
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 normalizedWeight = parseRouteWeightTons(trimmed)
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."
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
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 normalizedWeight = parseRouteWeightTons(trimmed)
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."
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
else -> null
}
}
@@ -196,10 +284,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 +323,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 +536,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,70 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class StartupOfflineFallbackLoaderTest {
@Test
fun returnsServerDataBeforeTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = { "server" },
offlineLoad = { "cache" },
)
assertEquals("server", result)
}
@Test
fun returnsCachedDataWhenServerExceedsTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { "cache" },
)
assertEquals("cache", result)
}
@Test
fun reportsMissingOfflineDataAfterTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val error = runCatching {
loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { null },
)
}.exceptionOrNull()
assertTrue(error is IOException)
}
@Test
fun doesNotHideAuthenticationFailures() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val authenticationFailure = IllegalStateException("401")
val error = runCatching {
loader.load(
onlineLoad = { throw authenticationFailure },
offlineLoad = { "cache" },
)
}.exceptionOrNull()
assertTrue(error is IllegalStateException)
assertEquals(authenticationFailure.message, error?.message)
}
}
@@ -0,0 +1,18 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RoutePointWorkerRulesTest {
@Test
fun inactiveRouteConflictDiscardsObsoleteGpsPoints() {
assertTrue(isInactiveRoutePointError("ROUTE_POINTS_INVALID_STATUS"))
}
@Test
fun temporaryServerFailureDoesNotDiscardGpsPoints() {
assertFalse(isInactiveRoutePointError("SERVER_UNAVAILABLE"))
assertFalse(isInactiveRoutePointError(null))
}
}
@@ -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"))
}
}
@@ -0,0 +1,32 @@
package pl.firmatpp.kierowca.domain
import org.junit.Assert.assertEquals
import org.junit.Test
class StartupSessionPolicyTest {
@Test
fun rotatesStartupMessagesAsWaitingTimeIncreases() {
assertEquals("Sprawdzamy zapisaną sesję", StartupSessionPolicy.message(0).title)
assertEquals("Potwierdzamy logowanie", StartupSessionPolicy.message(3).title)
assertEquals("Pobieramy aktualne kursy", StartupSessionPolicy.message(6).title)
assertEquals("To trwa dłużej niż zwykle", StartupSessionPolicy.message(9).title)
assertEquals("Przygotowujemy tryb offline", StartupSessionPolicy.message(12).title)
}
@Test
fun countsDownToOfflineFallbackWithoutGoingBelowZero() {
assertEquals(15, StartupSessionPolicy.remainingSeconds(0))
assertEquals(7, StartupSessionPolicy.remainingSeconds(8))
assertEquals(0, StartupSessionPolicy.remainingSeconds(15))
assertEquals(0, StartupSessionPolicy.remainingSeconds(30))
assertEquals("Tryb offline najpóźniej za 5 s", StartupSessionPolicy.countdownLabel(10))
assertEquals("Otwieramy zapisane dane offline", StartupSessionPolicy.countdownLabel(15))
}
@Test
fun showsManualOfflineButtonAfterFourSecondsOnlyWhenCacheExists() {
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(3, offlineDataAvailable = true))
assertEquals(true, StartupSessionPolicy.shouldShowOfflineButton(4, offlineDataAvailable = true))
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(10, offlineDataAvailable = false))
}
}
@@ -23,8 +23,26 @@ class DriverLiveSyncClientTest {
reverbEnabled = true,
reverbAppKey = "app-key",
reverbWsBaseUrl = "wss://example.test/app",
reverbOrigin = "https://bootstrap-origin.test",
)
@Test
fun derivesBrowserCompatibleOriginFromWebSocketUrl() {
assertEquals("https://api-intranet.firmatpp.pl", websocketOrigin("wss://api-intranet.firmatpp.pl/ws/app/app-key"))
assertEquals("https://example.test:8443", websocketOrigin("wss://example.test:8443/app/app-key"))
assertEquals("http://localhost:8080", websocketOrigin("ws://localhost:8080/app/app-key"))
}
@Test
fun passesBootstrapOriginToWebSocketFactory() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
assertEquals("https://bootstrap-origin.test", factory.origins.single())
}
@Test
fun reconnectsWithBackoffAfterSocketFailure() = runTest {
val factory = FakeWebSocketFactory()
@@ -81,6 +99,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 +142,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()
@@ -172,10 +222,12 @@ class DriverLiveSyncClientTest {
private class FakeWebSocketFactory : LiveWebSocketFactory {
val sockets = mutableListOf<FakeWebSocket>()
val origins = mutableListOf<String?>()
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket {
override fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket {
val socket = FakeWebSocket(listener)
sockets += socket
origins += origin
return socket
}
}
@@ -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(
@@ -280,11 +306,20 @@ class DriverUiRulesTest {
fun routeStageFormRequiresPositiveWeightAndAtLeastOnePhotoOrQueuedUpload() {
assertTrue(canSubmitRouteStageForm(weightText = "12,5", serverPhotoCount = 1, localUploadCount = 0))
assertTrue(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 1))
assertTrue(canSubmitRouteStageForm(weightText = "23400", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "0", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 0))
}
@Test
fun parsesRouteWeightsEnteredInTonsOrKilograms() {
assertEquals(23.4, parseRouteWeightTons("23,4") ?: 0.0, 0.0)
assertEquals(23.4, parseRouteWeightTons("23400") ?: 0.0, 0.0)
assertEquals(23.401, parseRouteWeightTons("23400,5") ?: 0.0, 0.0)
assertEquals("Wpisano kilogramy — zapiszemy 23,400 t.", routeWeightInputHelpText("23400"))
}
@Test
fun filtersRoutePhotosByStageWithOtherFallback() {
val photos = listOf(
@@ -321,7 +356,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 +428,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 +460,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 +470,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,
@@ -1,8 +1,10 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
@@ -26,6 +28,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 +72,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 +117,79 @@ 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 })
}
@Test
fun freshGpsPointsDoNotShowSynchronizationBannerWhenOnlineIsHealthy() {
val state = DriverUiState(
isOnline = true,
serverConnectionState = ServerConnectionState.Reachable,
pendingGpsPoints = 48,
oldestPendingGpsAtEpochMillis = System.currentTimeMillis() - 30_000L,
)
assertEquals(0, state.pendingOperationalItems)
assertFalse(state.operationalQueueNeedsAttention)
}
@Test
fun staleGpsBacklogRequiresAttentionWhenOnline() {
val state = DriverUiState(
isOnline = true,
serverConnectionState = ServerConnectionState.Reachable,
pendingGpsPoints = 48,
oldestPendingGpsAtEpochMillis = System.currentTimeMillis() - 3 * 60_000L,
)
assertEquals(48, state.pendingOperationalItems)
assertTrue(state.operationalQueueNeedsAttention)
}
@Test
fun offlineGpsPointsAreShownAsSavedLocallyNotAsFailure() {
val state = DriverUiState(
isOnline = false,
pendingGpsPoints = 48,
oldestPendingGpsAtEpochMillis = System.currentTimeMillis() - 3 * 60_000L,
)
assertEquals(48, state.pendingOperationalItems)
assertFalse(state.operationalQueueNeedsAttention)
}
private fun leaveRequest(id: String, status: String, version: Long = 1): DriverLeaveRequestDto =
DriverLeaveRequestDto(
id = id,
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",
+363
View File
@@ -0,0 +1,363 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION_FILE="$ROOT_DIR/app/build.gradle.kts"
REMOTE_NAME="${GIT_REMOTE:-origin}"
NOTES_FILE=""
SKIP_TESTS=0
REUSE_CURRENT=0
VERSION_FILE_CHANGED=0
VERSION_COMMITTED=0
VERSION_BACKUP=""
RELEASE_SUCCEEDED=0
usage() {
cat <<'EOF'
Użycie: scripts/release-android.sh [opcje]
Buduje i publikuje kolejną wersję aplikacji kierowcy:
- automatycznie zwiększa versionCode o 1 i patch versionName o 1,
- uruchamia testy jednostkowe,
- buduje podpisany APK dla Gitea Release,
- buduje podpisany AAB dla Google Play Console,
- zapisuje zmianę wersji w commicie, tworzy tag i wykonuje push,
- tworzy lub aktualizuje Gitea Release i wysyła APK oraz SHA256SUMS.
Opcje:
--notes-file PLIK Treść release notes z podanego pliku.
--skip-tests Pomiń testDebugUnitTest.
--reuse-current Nie zwiększaj wersji. Wznów publikację wersji zapisanej
obecnie w app/build.gradle.kts.
-h, --help Pokaż pomoc.
Zmienne środowiskowe:
GITEA_TOKEN Token API Gitea. Jeśli go brak, używany jest git credential helper.
GITEA_BASE_URL Nadpisuje adres Gitea, np. https://gitea.example.com.
GITEA_REPOSITORY Nadpisuje owner/repository.
GIT_REMOTE Remote Git, domyślnie origin.
Wersja początkowa jest zawsze odczytywana z app/build.gradle.kts.
Google Play Console otrzymuje plik AAB, ponieważ jest to właściwy format publikacyjny.
EOF
}
while (($# > 0)); do
case "$1" in
--notes-file)
NOTES_FILE="${2:-}"
[[ -n "$NOTES_FILE" ]] || { echo "Brak wartości dla --notes-file." >&2; exit 2; }
shift 2
;;
--skip-tests)
SKIP_TESTS=1
shift
;;
--reuse-current)
REUSE_CURRENT=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Nieznana opcja: $1" >&2
usage >&2
exit 2
;;
esac
done
cd "$ROOT_DIR"
for command in git curl jq perl sha256sum jarsigner; do
command -v "$command" >/dev/null 2>&1 || {
echo "Brak wymaganego polecenia: $command" >&2
exit 1
}
done
[[ -x ./gradlew ]] || { echo "Brak wykonywalnego ./gradlew." >&2; exit 1; }
[[ -f keystore.properties ]] || { echo "Brak keystore.properties wymaganego do podpisania release." >&2; exit 1; }
[[ -f "$VERSION_FILE" ]] || { echo "Brak $VERSION_FILE." >&2; exit 1; }
branch="$(git branch --show-current)"
[[ -n "$branch" ]] || { echo "Release nie może być wykonany z detached HEAD." >&2; exit 1; }
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Repozytorium ma niezapisane zmiany śledzonych plików. Najpierw wykonaj commit." >&2
exit 1
fi
relevant_untracked="$(git status --porcelain --untracked-files=all -- app gradle scripts build.gradle.kts settings.gradle.kts gradle.properties | awk '$1 == "??" { print }')"
if [[ -n "$relevant_untracked" ]]; then
echo "Repozytorium ma nieśledzone pliki źródłowe. Najpierw zdecyduj, czy mają wejść do release:" >&2
printf '%s\n' "$relevant_untracked" >&2
exit 1
fi
git fetch --tags "$REMOTE_NAME"
remote_url="$(git remote get-url "$REMOTE_NAME")"
sanitized_remote="$(printf '%s' "$remote_url" | sed -E 's#^(https?://)[^/@]+@#\1#')"
if [[ "$sanitized_remote" =~ ^(https?)://([^/]+)/(.+)$ ]]; then
detected_protocol="${BASH_REMATCH[1]}"
detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[3]%.git}"
else
detected_protocol="https"
detected_host=""
detected_repository=""
fi
gitea_base_url="${GITEA_BASE_URL:-${detected_protocol}://${detected_host}}"
gitea_repository="${GITEA_REPOSITORY:-$detected_repository}"
gitea_base_url="${gitea_base_url%/}"
[[ "$gitea_base_url" =~ ^https?://[^/]+$ ]] || { echo "Ustaw poprawne GITEA_BASE_URL." >&2; exit 1; }
[[ "$gitea_repository" =~ ^[^/]+/[^/]+$ ]] || { echo "Ustaw poprawne GITEA_REPOSITORY=owner/repository." >&2; exit 1; }
auth_args=()
if [[ -n "${GITEA_TOKEN:-}" ]]; then
auth_args=(-H "Authorization: token $GITEA_TOKEN")
else
credential="$(printf 'protocol=%s\nhost=%s\n\n' "$detected_protocol" "$detected_host" | git credential fill)"
gitea_username="$(printf '%s\n' "$credential" | sed -n 's/^username=//p')"
gitea_password="$(printf '%s\n' "$credential" | sed -n 's/^password=//p')"
[[ -n "$gitea_username" && -n "$gitea_password" ]] || {
echo "Brak danych Gitea. Ustaw GITEA_TOKEN albo skonfiguruj git credential helper." >&2
exit 1
}
auth_args=(-u "$gitea_username:$gitea_password")
fi
api_base="$gitea_base_url/api/v1/repos/$gitea_repository"
preflight_status="$(curl -sS "${auth_args[@]}" -o /dev/null -w '%{http_code}' "$api_base")"
[[ "$preflight_status" == "200" ]] || {
echo "Brak dostępu do repozytorium Gitea $gitea_repository (HTTP $preflight_status)." >&2
exit 1
}
read_version() {
local code name
code="$(sed -nE 's/^[[:space:]]*versionCode = ([0-9]+).*$/\1/p' "$VERSION_FILE" | sed -n '1p')"
name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)".*$/\1/p' "$VERSION_FILE" | sed -n '1p')"
[[ "$code" =~ ^[0-9]+$ ]] || { echo "Nie można odczytać versionCode." >&2; return 1; }
[[ "$name" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "versionName musi mieć format major.minor.patch." >&2; return 1; }
printf '%s %s\n' "$code" "$name"
}
read -r current_code current_name < <(read_version)
if ((REUSE_CURRENT == 1)); then
next_code="$current_code"
next_name="$current_name"
else
IFS=. read -r version_major version_minor version_patch <<<"$current_name"
next_code=$((current_code + 1))
next_name="${version_major}.${version_minor}.$((version_patch + 1))"
fi
tag_name="v${next_name}-${next_code}"
artifact_dir="$ROOT_DIR/release-artifacts/$tag_name"
gitea_apk_name="TPP-Kierowca-${next_name}-${next_code}.apk"
play_aab_name="TPP-Kierowca-${next_name}-${next_code}.aab"
cleanup_on_exit() {
local exit_code=$?
if ((RELEASE_SUCCEEDED == 0 && VERSION_FILE_CHANGED == 1 && VERSION_COMMITTED == 0)) && [[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]]; then
cp "$VERSION_BACKUP" "$VERSION_FILE"
echo "Przywrócono poprzednią wersję w app/build.gradle.kts." >&2
fi
[[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP"
if ((RELEASE_SUCCEEDED == 0)); then
echo "Release nie został ukończony. Po usunięciu przyczyny użyj --reuse-current, jeśli commit wersji już powstał." >&2
fi
trap - EXIT INT TERM
exit "$exit_code"
}
trap cleanup_on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if ((REUSE_CURRENT == 0)); then
if git show-ref --verify --quiet "refs/tags/$tag_name" || git ls-remote --exit-code --tags "$REMOTE_NAME" "refs/tags/$tag_name" >/dev/null 2>&1; then
echo "Tag $tag_name już istnieje. Użyj --reuse-current albo zwiększ wersję ręcznie." >&2
exit 1
fi
VERSION_BACKUP="$(mktemp)"
cp "$VERSION_FILE" "$VERSION_BACKUP"
perl -0pi -e "s/versionCode = \\d+/versionCode = $next_code/" "$VERSION_FILE"
perl -0pi -e "s/versionName = \"[^\"]+\"/versionName = \"$next_name\"/" "$VERSION_FILE"
VERSION_FILE_CHANGED=1
read -r written_code written_name < <(read_version)
[[ "$written_code" == "$next_code" && "$written_name" == "$next_name" ]] || {
echo "Nie udało się jednoznacznie zapisać nowej wersji." >&2
exit 1
}
fi
echo "Buduję TPP Kierowca $next_name ($next_code)..."
gradle_tasks=(assembleRelease bundleRelease)
if ((SKIP_TESTS == 0)); then
gradle_tasks=(testDebugUnitTest "${gradle_tasks[@]}")
fi
./gradlew "${gradle_tasks[@]}"
apk_source="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name '*.apk' -print -quit)"
aab_source="$(find app/build/outputs/bundle/release -maxdepth 1 -type f -name '*.aab' -print -quit)"
[[ -n "$apk_source" && -f "$apk_source" ]] || { echo "Nie znaleziono release APK." >&2; exit 1; }
[[ -n "$aab_source" && -f "$aab_source" ]] || { echo "Nie znaleziono release AAB." >&2; exit 1; }
sdk_dir="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -z "$sdk_dir" && -f local.properties ]]; then
sdk_dir="$(sed -n 's/^sdk.dir=//p' local.properties | sed -n '1p')"
fi
apksigner="$(find "$sdk_dir/build-tools" -type f -name apksigner 2>/dev/null | sort -V | tail -n 1)"
aapt="$(find "$sdk_dir/build-tools" -type f -name aapt 2>/dev/null | sort -V | tail -n 1)"
[[ -x "$apksigner" && -x "$aapt" ]] || { echo "Nie znaleziono apksigner/aapt w Android SDK." >&2; exit 1; }
"$apksigner" verify --verbose "$apk_source"
apk_badging="$("$aapt" dump badging "$apk_source" | sed -n '1p')"
[[ "$apk_badging" == *"versionCode='$next_code'"* && "$apk_badging" == *"versionName='$next_name'"* ]] || {
echo "APK ma inny numer wersji niż oczekiwany $next_name ($next_code)." >&2
exit 1
}
jarsigner -verify "$aab_source" >/dev/null
rm -rf "$artifact_dir"
mkdir -p "$artifact_dir/gitea" "$artifact_dir/google-play"
cp "$apk_source" "$artifact_dir/gitea/$gitea_apk_name"
cp "$aab_source" "$artifact_dir/google-play/$play_aab_name"
(
cd "$artifact_dir"
sha256sum "gitea/$gitea_apk_name" "google-play/$play_aab_name" > SHA256SUMS.txt
)
if ((REUSE_CURRENT == 0)); then
git add "$VERSION_FILE"
git diff --cached --check
git commit -m "chore: release android driver $next_name ($next_code)"
VERSION_COMMITTED=1
fi
git push "$REMOTE_NAME" "$branch"
if ! git show-ref --verify --quiet "refs/tags/$tag_name"; then
git tag -a "$tag_name" -m "TPP Kierowca $next_name ($next_code)"
fi
tag_commit="$(git rev-list -n 1 "$tag_name")"
head_commit="$(git rev-parse HEAD)"
[[ "$tag_commit" == "$head_commit" ]] || {
echo "Tag $tag_name nie wskazuje aktualnego commita ($head_commit)." >&2
exit 1
}
git push "$REMOTE_NAME" "$tag_name"
if [[ -n "$NOTES_FILE" ]]; then
[[ -f "$NOTES_FILE" ]] || { echo "Nie istnieje plik release notes: $NOTES_FILE" >&2; exit 1; }
release_notes="$(<"$NOTES_FILE")"
else
previous_tag="$(git tag --sort=-version:refname | grep -Fxv "$tag_name" | sed -n '1p' || true)"
if [[ -n "$previous_tag" ]]; then
changes="$(git log --format='- %s (%h)' "$previous_tag..HEAD")"
else
changes="$(git log -20 --format='- %s (%h)')"
fi
release_notes="TPP Kierowca $next_name ($next_code)
Zmiany:
${changes:-'- Wydanie techniczne.'}
Artefakt Google Play Console (AAB) znajduje się lokalnie w:
release-artifacts/$tag_name/google-play/$play_aab_name"
fi
release_response="$(mktemp)"
release_status="$(curl -sS "${auth_args[@]}" -o "$release_response" -w '%{http_code}' "$api_base/releases/tags/$tag_name")"
if [[ "$release_status" == "200" ]]; then
release_id="$(jq -r '.id' "$release_response")"
elif [[ "$release_status" == "404" ]]; then
release_payload="$(jq -n \
--arg tag "$tag_name" \
--arg target "$branch" \
--arg name "TPP Kierowca $next_name ($next_code)" \
--arg body "$release_notes" \
'{tag_name:$tag,target_commitish:$target,name:$name,body:$body,draft:false,prerelease:false}')"
release_status="$(curl -sS "${auth_args[@]}" \
-H 'Content-Type: application/json' \
-o "$release_response" \
-w '%{http_code}' \
-X POST \
-d "$release_payload" \
"$api_base/releases")"
[[ "$release_status" == "201" ]] || {
echo "Nie udało się utworzyć Gitea Release (HTTP $release_status)." >&2
jq '{message,errors}' "$release_response" >&2 || true
exit 1
}
release_id="$(jq -r '.id' "$release_response")"
else
echo "Nie udało się sprawdzić Gitea Release (HTTP $release_status)." >&2
exit 1
fi
upload_asset() {
local file_path="$1"
local asset_name="$2"
local mime_type="$3"
local assets_response existing_asset_id upload_response upload_status
assets_response="$(mktemp)"
curl -fsS "${auth_args[@]}" -o "$assets_response" "$api_base/releases/$release_id/assets"
existing_asset_id="$(jq -r --arg name "$asset_name" '.[] | select(.name == $name) | .id' "$assets_response" | sed -n '1p')"
if [[ -n "$existing_asset_id" ]]; then
curl -fsS "${auth_args[@]}" -X DELETE "$api_base/releases/$release_id/assets/$existing_asset_id" >/dev/null
fi
upload_response="$(mktemp)"
upload_status="$(curl -sS "${auth_args[@]}" \
-o "$upload_response" \
-w '%{http_code}' \
-X POST \
-F "attachment=@$file_path;type=$mime_type" \
"$api_base/releases/$release_id/assets?name=$asset_name")"
[[ "$upload_status" == "201" ]] || {
echo "Nie udało się wysłać $asset_name (HTTP $upload_status)." >&2
jq '{message,errors}' "$upload_response" >&2 || true
return 1
}
}
upload_asset "$artifact_dir/gitea/$gitea_apk_name" "$gitea_apk_name" "application/vnd.android.package-archive"
upload_asset "$artifact_dir/SHA256SUMS.txt" "SHA256SUMS.txt" "text/plain"
assets_response="$(mktemp)"
curl -fsS "${auth_args[@]}" -o "$assets_response" "$api_base/releases/$release_id/assets"
published_apk_url="$(jq -r --arg name "$gitea_apk_name" '.[] | select(.name == $name) | .browser_download_url' "$assets_response" | sed -n '1p')"
[[ -n "$published_apk_url" && "$published_apk_url" != "null" ]] || {
echo "Nie znaleziono opublikowanego APK w Gitea Release." >&2
exit 1
}
downloaded_apk="$(mktemp)"
curl -fsSL "${auth_args[@]}" -o "$downloaded_apk" "$published_apk_url"
local_apk_hash="$(sha256sum "$artifact_dir/gitea/$gitea_apk_name" | awk '{print $1}')"
published_apk_hash="$(sha256sum "$downloaded_apk" | awk '{print $1}')"
[[ "$local_apk_hash" == "$published_apk_hash" ]] || {
echo "Checksum opublikowanego APK nie zgadza się z lokalnym plikiem." >&2
exit 1
}
release_url="$gitea_base_url/$gitea_repository/releases/tag/$tag_name"
RELEASE_SUCCEEDED=1
trap - EXIT INT TERM
[[ -n "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP"
cat <<EOF
Release zakończony:
Wersja: $next_name ($next_code)
Tag: $tag_name
Gitea: $release_url
APK: $artifact_dir/gitea/$gitea_apk_name
Google Play: $artifact_dir/google-play/$play_aab_name
Checksumy: $artifact_dir/SHA256SUMS.txt
EOF