Compare commits

..
Author SHA1 Message Date
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
admin e49e7c358e Obsłuż wielodniowe kursy kierowcy 2026-07-10 14:50:38 +02:00
admin 3267fee439 Usprawnij wybór urlopu przy konfliktach 2026-07-09 00:31:06 +02:00
admin 8a0651e578 Rozdziel załadunek i dodaj kalendarz urlopów 2026-07-09 00:05:57 +02:00
admin 32d45f6430 Bump driver app version to 51 2026-07-08 02:03:33 +02:00
admin 4a06e9da8b Retry route actions after photo upload 2026-07-08 02:01:37 +02:00
admin d6b36a1d3a Fix driver route crash on null photos 2026-07-08 00:02:15 +02:00
admin e811b4bc43 Limit confirmed route actions in driver projection 2026-07-07 23:18:10 +02:00
admin 5d3b355163 Bump driver app version code to 48 2026-07-07 23:07:31 +02:00
admin 81f3b05653 Keep confirmed route actions in driver status projection 2026-07-07 23:03:58 +02:00
46 changed files with 3906 additions and 431 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 = 47
versionName = "1.0.44"
versionCode = 107
versionName = "1.0.54"
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") }
}
}
}
@@ -18,6 +18,7 @@ import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
@@ -61,14 +62,17 @@ class DriverRepository(
suspend fun leaveRequests(): List<DriverLeaveRequestDto> =
api.leaveRequests(authHeader(requireToken())).data
suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> =
api.leaveCalendar(authHeader(requireToken()), from, to).data
suspend fun leaveRequest(id: String): DriverLeaveRequestDto =
api.leaveRequest(authHeader(requireToken()), id).data
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)
@@ -194,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)) }
}
@@ -10,6 +10,7 @@ import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveCalendarResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse
@@ -76,6 +77,13 @@ interface MobileDriverApi {
@Header("Authorization") authorization: String,
): LeaveRequestListResponse
@GET("mobile/driver/leave-requests/calendar")
suspend fun leaveCalendar(
@Header("Authorization") authorization: String,
@Query("from") from: String,
@Query("to") to: String,
): LeaveCalendarResponse
@POST("mobile/driver/leave-requests")
suspend fun createLeaveRequest(
@Header("Authorization") authorization: String,
@@ -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,
@@ -167,6 +171,10 @@ data class LeaveRequestListResponse(
val data: List<DriverLeaveRequestDto> = emptyList(),
)
data class LeaveCalendarResponse(
val data: List<DriverLeaveCalendarEntryDto> = emptyList(),
)
data class LeaveRequestResponse(
val data: DriverLeaveRequestDto,
)
@@ -189,6 +197,7 @@ data class CreateLeaveRequestBody(
data class CancelLeaveRequestBody(
val comment: String? = null,
val expectedVersion: Long? = null,
)
data class DriverLeaveRequestDto(
@@ -200,12 +209,32 @@ 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,
val events: List<DriverLeaveRequestEventDto> = emptyList(),
)
data class DriverLeaveCalendarEntryDto(
val id: String,
val dateFrom: String?,
val dateTo: String?,
val type: String,
val typeLabel: String? = null,
val status: String,
val driver: DriverLeaveCalendarDriverDto,
)
data class DriverLeaveCalendarDriverDto(
val id: String,
val displayName: String?,
val phoneNumber: String?,
)
data class DriverLeaveRequestEventDto(
val id: String,
val action: String,
@@ -224,6 +253,9 @@ data class RealtimeStatusBody(
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,
@@ -300,14 +332,14 @@ data class PhotoUploadReceiptDto(
data class StartRouteBody(
val clientActionId: String,
val loadingWeight: Double,
val loadingWeight: Double?,
val occurredAt: String,
val photoClientRequestIds: List<String>,
)
data class FinishRouteBody(
val clientActionId: String,
val unloadingWeight: Double,
val unloadingWeight: Double?,
val occurredAt: String,
val photoClientRequestIds: List<String>,
)
@@ -13,18 +13,27 @@ interface DriverCacheDao {
@Query("SELECT * FROM driver_bootstrap_cache WHERE date = :date LIMIT 1")
suspend fun bootstrap(date: String): DriverBootstrapCacheEntity?
@Query("SELECT * FROM driver_bootstrap_cache ORDER BY syncedAtEpochMillis DESC")
suspend fun allBootstrapCaches(): List<DriverBootstrapCacheEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertRoute(cache: DriverRouteCacheEntity)
@Query("SELECT * FROM driver_route_cache WHERE routeId = :routeId LIMIT 1")
suspend fun route(routeId: String): DriverRouteCacheEntity?
@Query("SELECT * FROM driver_route_cache ORDER BY syncedAtEpochMillis DESC")
suspend fun allRouteCaches(): List<DriverRouteCacheEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSyncState(state: DriverSyncStateEntity)
@Query("SELECT * FROM driver_sync_states WHERE scope = :scope AND ifnull(date, '-') = ifnull(:date, '-') AND ifnull(routeId, '-') = ifnull(:routeId, '-') LIMIT 1")
suspend fun syncState(scope: String, date: String?, routeId: String?): DriverSyncStateEntity?
@Query("SELECT * FROM driver_sync_states ORDER BY syncedAtEpochMillis DESC")
suspend fun allSyncStates(): List<DriverSyncStateEntity>
@Query("DELETE FROM driver_bootstrap_cache")
suspend fun clearBootstrap()
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.data.sync
import android.content.Context
import com.google.gson.Gson
import java.io.IOException
import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto
@@ -60,6 +61,24 @@ class DriverSyncRepository(
cachedRoute(routeId, throwable)
}
suspend fun bootstrapFromCache(date: String?): CachedValue<BootstrapResponse>? {
val cached = dao.bootstrap(date ?: currentDateFallback()) ?: return null
return CachedValue(
value = gson.fromJson(cached.payloadJson, BootstrapResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? {
val cached = dao.route(routeId) ?: return null
return CachedValue(
value = gson.fromJson(cached.payloadJson, RouteResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) {
val syncedAt = System.currentTimeMillis()
dao.upsertRoute(
@@ -72,18 +91,19 @@ class DriverSyncRepository(
),
)
val selectedDate = date ?: return
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap(
cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
(routeVisibleDates(route) + listOfNotNull(date)).distinct().forEach { selectedDate ->
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return@forEach
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap(
cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
}
}
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
@@ -96,21 +116,25 @@ class DriverSyncRepository(
suspend fun saveSyncStates(response: SyncStateResponse?) {
response?.scopes.orEmpty().forEach { scope ->
dao.upsertSyncState(
DriverSyncStateEntity(
key = syncStateKey(scope.scope, scope.date, scope.routeId),
scope = scope.scope,
date = scope.date,
routeId = scope.routeId,
checksum = scope.checksum,
version = scope.version,
computedAt = scope.computedAt,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
saveSyncScope(scope)
}
}
suspend fun saveSyncScope(scope: SyncScopeDto) {
dao.upsertSyncState(
DriverSyncStateEntity(
key = syncStateKey(scope.scope, scope.date, scope.routeId),
scope = scope.scope,
date = scope.date,
routeId = scope.routeId,
checksum = scope.checksum,
version = scope.version,
computedAt = scope.computedAt,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
}
suspend fun clearCache() {
dao.clearBootstrap()
dao.clearRoutes()
@@ -142,15 +166,29 @@ 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()
private fun routeVisibleDates(route: DriverRouteDto): List<String> {
val start = route.routeDate?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return emptyList()
val end = route.unloadingDate
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?.takeUnless { it.isBefore(start) }
?: start
return generateSequence(start) { date -> date.plusDays(1).takeIf { !it.isAfter(end) } }
.map(LocalDate::toString)
.toList()
}
companion object {
const val SCOPE_ROUTES = "routes"
const val SCOPE_ROUTE_DETAIL = "route_detail"
const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
const val SCOPE_LEAVE_REQUESTS = "leave_requests"
const val SCOPE_LEAVE_CALENDAR = "leave_calendar"
const val SCOPE_SESSION = "session"
}
}
@@ -13,9 +13,7 @@ class NetworkMonitor(context: Context) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val isOnline: Flow<Boolean> = callbackFlow {
fun current(): Boolean = connectivityManager.activeNetwork
?.let(connectivityManager::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
fun current(): Boolean = isCurrentlyValidated()
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
@@ -35,4 +33,17 @@ class NetworkMonitor(context: Context) {
connectivityManager.registerDefaultNetworkCallback(callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
fun isCurrentlyValidated(): Boolean = connectivityManager.activeNetwork
?.let(connectivityManager::getNetworkCapabilities)
?.let(::hasValidatedInternet) == true
}
internal fun hasValidatedInternet(capabilities: NetworkCapabilities): Boolean =
hasValidatedInternetCapabilities(
hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
hasValidated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
)
internal fun hasValidatedInternetCapabilities(hasInternet: Boolean, hasValidated: Boolean): Boolean =
hasInternet && hasValidated
@@ -14,6 +14,15 @@ interface DispatchSheetUploadDao {
@Query("SELECT * FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId LIMIT 1")
suspend fun find(clientRequestId: String): DispatchSheetUploadEntity?
@Query("SELECT * FROM dispatch_sheet_uploads WHERE status != 'CONFIRMED' AND status != 'CANCELLED'")
suspend fun allUnsent(): List<DispatchSheetUploadEntity>
@Query("SELECT * FROM dispatch_sheet_uploads")
suspend fun allRecords(): List<DispatchSheetUploadEntity>
@Query("SELECT COUNT(*) FROM dispatch_sheet_uploads WHERE status != 'CONFIRMED' AND status != 'CANCELLED'")
fun observeUnsentCount(): Flow<Int>
@Query(
"""
SELECT * FROM dispatch_sheet_uploads
@@ -63,4 +72,7 @@ interface DispatchSheetUploadDao {
@Query("DELETE FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String)
@Query("DELETE FROM dispatch_sheet_uploads")
suspend fun deleteAll()
}
@@ -9,6 +9,7 @@ import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
class DispatchSheetUploadWorker(
appContext: Context,
@@ -18,6 +19,7 @@ class DispatchSheetUploadWorker(
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure()
val upload = dao.find(clientRequestId) ?: return Result.failure()
val file = File(upload.localPath)
@@ -62,6 +64,8 @@ class DispatchSheetUploadWorker(
}
dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
file.delete()
dao.delete(clientRequestId)
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
@@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
DriverRouteCacheEntity::class,
DriverSyncStateEntity::class,
],
version = 5,
version = 6,
exportSchema = false,
)
abstract class DriverDatabase : RoomDatabase() {
@@ -164,13 +164,51 @@ abstract class DriverDatabase : RoomDatabase() {
}
}
private val migration5To6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE route_actions_new (
clientActionId TEXT NOT NULL PRIMARY KEY,
routeId TEXT NOT NULL,
action TEXT NOT NULL,
weight REAL,
occurredAt TEXT NOT NULL,
photoClientRequestIdsJson TEXT NOT NULL,
status TEXT NOT NULL,
attemptCount INTEGER NOT NULL,
lastError TEXT,
createdAtEpochMillis INTEGER NOT NULL,
updatedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
INSERT INTO route_actions_new (
clientActionId, routeId, action, weight, occurredAt, photoClientRequestIdsJson,
status, attemptCount, lastError, createdAtEpochMillis, updatedAtEpochMillis
)
SELECT
clientActionId, routeId, action, weight, occurredAt, photoClientRequestIdsJson,
status, attemptCount, lastError, createdAtEpochMillis, updatedAtEpochMillis
FROM route_actions
""".trimIndent(),
)
db.execSQL("DROP TABLE route_actions")
db.execSQL("ALTER TABLE route_actions_new RENAME TO route_actions")
db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_routeId ON route_actions(routeId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_status ON route_actions(status)")
}
}
fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
DriverDatabase::class.java,
"driver-local-outbox.db",
).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5).build().also { instance = it }
).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5, migration5To6).build().also { instance = it }
}
}
}
@@ -0,0 +1,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,15 +9,20 @@ 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,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val dao = DriverDatabase.get(appContext).photoUploadDao()
private val database = DriverDatabase.get(appContext)
private val dao = database.photoUploadDao()
private val routeActionDao = database.routeActionDao()
private val routeActionOutbox = RouteActionOutbox(appContext)
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 +67,7 @@ class PhotoUploadWorker(
}
dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
enqueueRouteActionsWaitingForPhoto(clientRequestId)
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
@@ -90,6 +96,12 @@ class PhotoUploadWorker(
}
}
private suspend fun enqueueRouteActionsWaitingForPhoto(clientRequestId: String) {
routeActionDao.waitingForPhotosActions()
.filter { routeActionReferencesPhotoClientRequest(it, clientRequestId) }
.forEach { routeActionOutbox.enqueueWorker(it.clientActionId) }
}
companion object {
const val KEY_CLIENT_REQUEST_ID = "clientRequestId"
const val KEY_PROGRESS = "progress"
@@ -14,11 +14,26 @@ 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>
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT')
AND status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'CONFIRMED', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT')
ORDER BY createdAtEpochMillis DESC
""",
)
@@ -28,6 +43,14 @@ interface RouteActionDao {
"""
SELECT * FROM route_actions
WHERE status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT')
OR clientActionId IN (
SELECT confirmed.clientActionId
FROM route_actions AS confirmed
WHERE confirmed.status = 'CONFIRMED'
AND confirmed.routeId = route_actions.routeId
ORDER BY confirmed.createdAtEpochMillis DESC
LIMIT 2
)
ORDER BY createdAtEpochMillis DESC
""",
)
@@ -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,
@@ -53,7 +59,7 @@ class RouteActionOutbox(
return entity
}
private fun enqueueWorker(clientActionId: String) {
fun enqueueWorker(clientActionId: String) {
val request = OneTimeWorkRequestBuilder<RouteActionWorker>()
.setInputData(workDataOf(RouteActionWorker.KEY_CLIENT_ACTION_ID to clientActionId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
@@ -0,0 +1,17 @@
package pl.firmatpp.kierowca.data.upload
import com.google.gson.Gson
private val routeActionPhotoDependencyGson = Gson()
fun routeActionReferencesPhotoClientRequest(action: RouteActionEntity, clientRequestId: String): Boolean {
val needle = clientRequestId.trim()
if (needle.isBlank()) return false
return runCatching {
routeActionPhotoDependencyGson
.fromJson(action.photoClientRequestIdsJson, Array<String>::class.java)
?.any { it.trim() == needle }
?: false
}.getOrDefault(false)
}
@@ -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"
@@ -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
@@ -44,10 +50,27 @@ class OkHttpLiveWebSocketFactory(
private val client: OkHttpClient,
) : LiveWebSocketFactory {
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket =
client.newWebSocket(Request.Builder().url(url).build(), listener)
client.newWebSocket(
Request.Builder()
.url(url)
.header("Origin", 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")
}
@@ -176,14 +251,14 @@ class DriverLiveSyncClient(
val wsUrl = synchronized(lock) {
val config = realtimeConfig ?: return
val id = driverId ?: return
if (!desiredActive || !networkAvailable || !isConfigUsable(config)) return
if (!desiredActive || !foregroundActive || !networkAvailable || !isConfigUsable(config)) return
if (state == LiveSyncConnectionState.Connecting || state == LiveSyncConnectionState.Subscribing || state == LiveSyncConnectionState.Connected) return
if (resetAttempt) reconnectAttempt = 0
reconnectJob?.cancel()
reconnectJob = null
socketId = null
state = LiveSyncConnectionState.Connecting
updateState(LiveSyncConnectionState.Connecting)
val appKey = config.reverbAppKey.orEmpty()
val wsBaseUrl = config.reverbWsBaseUrl.orEmpty()
@@ -227,6 +302,7 @@ class DriverLiveSyncClient(
false
} else {
messageVersion += 1
lastMessageAtEpochMillis = System.currentTimeMillis()
true
}
}
@@ -244,7 +320,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 +330,7 @@ class DriverLiveSyncClient(
if (channel == "private-driver-mobile.$id") {
synchronized(lock) {
reconnectAttempt = 0
state = LiveSyncConnectionState.Connected
updateState(LiveSyncConnectionState.Connected)
}
reportRealtimeStatus("connected")
startHeartbeat()
@@ -272,22 +348,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 +384,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 +404,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 +482,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) {
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
data class LeaveRequestsConfig(
val enabled: Boolean = false,
@@ -52,4 +53,41 @@ object DriverLeaveRequestUiRules {
val start = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return false
return start.isAfter(today)
}
fun calendarEntriesForDate(entries: List<DriverLeaveCalendarEntryDto>, date: LocalDate): List<DriverLeaveCalendarEntryDto> =
entries.filter { entry ->
val from = entry.dateFrom?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
val to = entry.dateTo?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
from != null && to != null && !date.isBefore(from) && !date.isAfter(to)
}
fun calendarEntriesForRange(entries: List<DriverLeaveCalendarEntryDto>, dateFrom: String, dateTo: String): List<DriverLeaveCalendarEntryDto> {
val selectedFrom = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return emptyList()
val selectedTo = runCatching { LocalDate.parse(dateTo) }.getOrNull() ?: return emptyList()
val from = minOf(selectedFrom, selectedTo)
val to = maxOf(selectedFrom, selectedTo)
return entries.filter { entry ->
val entryFrom = entry.dateFrom?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
val entryTo = entry.dateTo?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
entryFrom != null && entryTo != null && !entryTo.isBefore(from) && !entryFrom.isAfter(to)
}
}
fun phoneDialUri(phoneNumber: String?): String? {
val raw = phoneNumber?.trim().orEmpty()
if (raw.isBlank()) return null
val digits = raw.filter { it.isDigit() }
if (digits.isBlank()) return null
val normalized = when {
digits.startsWith("00") -> "+${digits.drop(2)}"
raw.startsWith("+") -> "+$digits"
digits.length == 9 -> "+48$digits"
else -> "+$digits"
}
return "tel:$normalized"
}
}
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.ui
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType
@@ -21,14 +22,18 @@ internal val routeActionVisibleStatuses = setOf(
RouteActionStatus.FailedPermanent,
)
private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
val projectable = actions
.filter { it.routeId == route.id && it.status in routeActionProjectionStatuses }
var projectedRoute = route
var loadingWeightPending = false
var unloadingWeightPending = false
visible
projectable
.sortedWith(compareBy<RouteActionEntity> { it.createdAtEpochMillis }.thenBy { it.clientActionId })
.forEach { action ->
when (action.action) {
@@ -37,8 +42,9 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
status = "W TRAKCIE",
loadingWeight = action.weight,
trackingStatus = "active",
photos = projectedRoute.safePhotos(),
)
loadingWeightPending = true
loadingWeightPending = action.status != RouteActionStatus.Confirmed
}
RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy(
@@ -46,8 +52,9 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
unloadingWeight = action.weight,
trackingStatus = "finished",
completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(),
)
unloadingWeightPending = true
unloadingWeightPending = action.status != RouteActionStatus.Confirmed
}
}
}
@@ -67,3 +74,5 @@ fun projectDriverRoutes(routes: List<DriverRouteDto>, actions: List<RouteActionE
fun routeActionNeedsAttention(status: String): Boolean =
status == RouteActionStatus.FailedConflict || status == RouteActionStatus.FailedPermanent
internal fun DriverRouteDto.safePhotos(): List<RoutePhotoDto> = photos.orEmpty()
@@ -34,6 +34,7 @@ data class RouteFlowStepUi(
val title: String,
val state: RouteFlowStepState,
val stateLabel: String,
val dateLabel: String? = null,
)
data class RouteLifecycleActionUi(
@@ -44,17 +45,74 @@ data class RouteLifecycleActionUi(
data class RouteSyncCalloutUi(
val text: String,
val isConflict: Boolean,
val canCorrect: Boolean,
)
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
fun routeIsActiveToday(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean {
val viewedDate = runCatching { LocalDate.parse(selectedDate) }.getOrNull() ?: return false
val loadingDate = route.routeDate?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return false
val unloadingDate = route.unloadingDate
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: loadingDate
return viewedDate == today && !today.isBefore(loadingDate) && !today.isAfter(unloadingDate)
}
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() }
?: ""
fun routeScheduleSummary(route: DriverRouteDto): String {
val loading = routeScheduleDateLabel(route.routeDate)
val unloading = routeScheduleDateLabel(routeUnloadingDate(route))
return if (route.unloadingDate == null) {
"Załadunek: $loading · Rozładunek: ten sam dzień"
} else {
"Załadunek: $loading · Rozładunek: $unloading"
}
}
fun routeTodayInstruction(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): String? {
if (!routeIsActiveToday(route, selectedDate, today)) return null
val loadingDate = route.routeDate?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return null
val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate
return when {
route.status == "ZAKOŃCZONA" -> "Kurs zakończony."
loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek."
today == loadingDate -> "Dziś: załadunek."
today == unloadingDate && route.status == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj."
today == unloadingDate -> "Dziś: rozładunek."
else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}."
}
}
fun canCompleteRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
fun canStartRouteFromDriverApp(
@@ -62,7 +120,7 @@ fun canStartRouteFromDriverApp(
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
routeIsActiveToday(route, selectedDate, today)
&& route.status == "ZAPLANOWANA"
fun canFinishRouteFromDriverApp(
@@ -70,22 +128,94 @@ fun canFinishRouteFromDriverApp(
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
&& route.status == "W TRAKCIE"
route.status == "W TRAKCIE"
&& 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 routeStageSubmitBlocker(weightText: String, serverPhotoCount: Int, localUploadCount: Int): String? {
fun normalizeRouteStageRequirement(value: String?): String = when (value) {
RouteStageRequirement.Optional -> RouteStageRequirement.Optional
RouteStageRequirement.Disabled -> RouteStageRequirement.Disabled
else -> RouteStageRequirement.Required
}
fun routeStageRequirementIsVisible(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) != RouteStageRequirement.Disabled
fun routeStageRequirementIsRequired(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
fun canSubmitRouteStageForm(
weightText: String,
serverPhotoCount: Int,
localUploadCount: Int,
weightRequirement: String = RouteStageRequirement.Required,
photoRequirement: String = RouteStageRequirement.Required,
): Boolean {
return routeStageSubmitBlocker(
weightText,
serverPhotoCount,
localUploadCount,
weightRequirement,
photoRequirement,
) == null
}
fun loadingPhotosSubmitBlocker(
serverPhotoCount: Int,
localUploadCount: Int,
photoRequirement: String = RouteStageRequirement.Required,
): String? =
if (
routeStageRequirementIsRequired(photoRequirement) &&
visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0
) {
"Dodaj co najmniej jedno zdjęcie załadunku."
} else {
null
}
fun loadingWeightSubmitBlocker(
weightText: String,
serverPhotoCount: Int,
localUploadCount: Int,
weightRequirement: String = RouteStageRequirement.Required,
photoRequirement: String = RouteStageRequirement.Required,
): String? {
val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when {
trimmed.isBlank() -> "Podaj wagę."
normalizedWeight == null -> "Podaj poprawną wagę."
normalizedWeight <= 0.0 -> "Waga musi być większa od zera."
visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
else -> null
}
}
fun routeStageSubmitBlocker(
weightText: String,
serverPhotoCount: Int,
localUploadCount: Int,
weightRequirement: String = RouteStageRequirement.Required,
photoRequirement: String = RouteStageRequirement.Required,
): String? {
val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when {
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
else -> null
}
}
@@ -96,8 +226,8 @@ fun routeLifecyclePrimaryAction(
today: LocalDate = LocalDate.now(),
): RouteLifecycleActionUi? =
when {
canStartRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Rozpocznij kurs", RouteLifecycleAction.Start)
canFinishRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Zakończ kurs", RouteLifecycleAction.Finish)
canStartRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Załaduj", RouteLifecycleAction.Start)
canFinishRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Rozładuj", RouteLifecycleAction.Finish)
else -> null
}
@@ -114,18 +244,34 @@ fun routeSyncCallout(actions: List<RouteActionEntity>): RouteSyncCalloutUi? {
} else {
when (unresolved.status) {
RouteActionStatus.WaitingForPhotos -> "Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu."
RouteActionStatus.Pending,
RouteActionStatus.Syncing -> "Trwa wysyłanie zmiany statusu kursu."
RouteActionStatus.FailedRetryable -> "Nie udało się wysłać. Aplikacja spróbuje ponownie."
else -> "Zmiana zapisana w telefonie. Wyślemypo odzyskaniu internetu."
else -> "Zmiana zapisana w telefonie. Aplikacja wyśle ją automatycznie."
}
},
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 loadingState = startOverride ?: when {
val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size
val loadingPhotosState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete
loadingPhotoCount > 0 -> RouteFlowStepState.LocalComplete
else -> RouteFlowStepState.Todo
}
val loadingWeightState = startOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete
@@ -144,16 +290,21 @@ fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): Lis
else -> RouteFlowStepState.Todo
}
return listOf(
RouteFlowStepUi("loading", "Załadunek", loadingState, routeFlowStateLabel(loadingState)),
RouteFlowStepUi("transit", "W trasie", transitState, routeFlowStateLabel(transitState)),
RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState)),
)
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> {
fun routePhotosForStage(photos: List<RoutePhotoDto>?, stage: String): List<RoutePhotoDto> {
val normalized = normalizedRoutePhotoStage(stage)
return photos.filter { normalizedRoutePhotoStage(it.stage) == normalized }
return photos.orEmpty().filter { normalizedRoutePhotoStage(it.stage) == normalized }
}
fun normalizedRoutePhotoStage(stage: String?): String =
@@ -352,6 +503,21 @@ fun offlineStaleBannerMessage(isOnline: Boolean, isStale: Boolean, syncLabel: St
else -> "Dane mogą być nieaktualne. Ostatnia synchronizacja: $syncLabel."
}
fun connectionStatusBannerMessage(
isOnline: Boolean,
isStale: Boolean,
serverConnectionState: ServerConnectionState,
showRealtimeReconnecting: Boolean,
syncLabel: String,
): String? = when {
!isOnline -> offlineStaleBannerMessage(false, isStale, syncLabel)
serverConnectionState == ServerConnectionState.Degraded ->
"Internet działa, ale serwer jest chwilowo niedostępny. Pokazujemy dane z $syncLabel."
showRealtimeReconnecting -> "Ponowne łączenie. Dane są uzgadniane z serwerem."
isStale -> offlineStaleBannerMessage(true, true, syncLabel)
else -> null
}
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
package pl.firmatpp.kierowca.data.sync
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NetworkMonitorTest {
@Test
fun captivePortalIsNotTreatedAsOnline() {
assertFalse(hasValidatedInternetCapabilities(hasInternet = true, hasValidated = false))
}
@Test
fun validatedInternetIsOnline() {
assertTrue(hasValidatedInternetCapabilities(hasInternet = true, hasValidated = true))
}
}
@@ -0,0 +1,31 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RouteActionPhotoDependenciesTest {
@Test
fun detectsRouteActionWaitingForConfirmedPhotoClientRequest() {
val action = action("""["photo-a","photo-b"]""")
assertTrue(routeActionReferencesPhotoClientRequest(action, "photo-b"))
assertFalse(routeActionReferencesPhotoClientRequest(action, "photo-c"))
}
@Test
fun malformedPhotoRequestJsonDoesNotMatch() {
assertFalse(routeActionReferencesPhotoClientRequest(action("not-json"), "photo-a"))
}
private fun action(photoClientRequestIdsJson: String): RouteActionEntity =
RouteActionEntity(
clientActionId = "action-1",
routeId = "route-1",
action = RouteActionType.Start,
weight = 12.5,
occurredAt = "2026-06-30T07:40:00Z",
photoClientRequestIdsJson = photoClientRequestIdsJson,
status = RouteActionStatus.WaitingForPhotos,
)
}
@@ -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"))
}
}
@@ -25,6 +25,13 @@ class DriverLiveSyncClientTest {
reverbWsBaseUrl = "wss://example.test/app",
)
@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 reconnectsWithBackoffAfterSocketFailure() = runTest {
val factory = FakeWebSocketFactory()
@@ -81,6 +88,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 +131,21 @@ class DriverLiveSyncClientTest {
assertTrue(factory.sockets.single().sent.any { it.contains("pusher:pong") })
}
@Test
fun subscribesToOwnUpdatesAndSharedLeaveCalendarUpdates() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = this)
client.start("driver-1", config)
factory.sockets.single().message(connectionEstablished("socket-1"))
runCurrent()
val sent = factory.sockets.single().sent
assertTrue(sent.any { it.contains("private-driver-mobile.driver-1") })
assertTrue(sent.any { it.contains("private-driver-mobile.0") })
client.stop()
}
@Test
fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest {
val factory = FakeWebSocketFactory()
@@ -0,0 +1,24 @@
package pl.firmatpp.kierowca.sync
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DriverRuntimeSyncStateTest {
@After
fun reset() {
DriverRuntimeSyncState.foreground = false
DriverRuntimeSyncState.realtimeConnected = false
}
@Test
fun websocketSuppressesDuplicateFcmSyncOnlyWhenForegroundAndConnected() {
DriverRuntimeSyncState.foreground = true
DriverRuntimeSyncState.realtimeConnected = true
assertTrue(DriverRuntimeSyncState.websocketOwnsForegroundHints())
DriverRuntimeSyncState.realtimeConnected = false
assertFalse(DriverRuntimeSyncState.websocketOwnsForegroundHints())
}
}
@@ -5,6 +5,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
class DriverLeaveRequestUiRulesTest {
@Test
@@ -48,4 +50,59 @@ class DriverLeaveRequestUiRulesTest {
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = yesterday))
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "rejected", dateFrom = tomorrow))
}
@Test
fun findsCalendarEntriesThatOverlapSelectedDate() {
val entries = listOf(
calendarEntry(id = "week", dateFrom = "2026-08-03", dateTo = "2026-08-09"),
calendarEntry(id = "single-day", dateFrom = "2026-08-05", dateTo = "2026-08-05"),
calendarEntry(id = "outside", dateFrom = "2026-08-10", dateTo = "2026-08-12"),
)
val matching = DriverLeaveRequestUiRules.calendarEntriesForDate(entries, LocalDate.parse("2026-08-05"))
assertEquals(listOf("week", "single-day"), matching.map { it.id })
}
@Test
fun findsCalendarEntriesThatOverlapSelectedRange() {
val entries = listOf(
calendarEntry(id = "starts-before", dateFrom = "2026-08-01", dateTo = "2026-08-04"),
calendarEntry(id = "inside", dateFrom = "2026-08-06", dateTo = "2026-08-07"),
calendarEntry(id = "ends-after", dateFrom = "2026-08-09", dateTo = "2026-08-12"),
calendarEntry(id = "before", dateFrom = "2026-07-20", dateTo = "2026-07-22"),
calendarEntry(id = "after", dateFrom = "2026-08-13", dateTo = "2026-08-14"),
)
val matching = DriverLeaveRequestUiRules.calendarEntriesForRange(
entries = entries,
dateFrom = "2026-08-03",
dateTo = "2026-08-10",
)
assertEquals(listOf("starts-before", "inside", "ends-after"), matching.map { it.id })
}
@Test
fun normalizesPhoneNumberIntoDialerUri() {
assertEquals("tel:+48600700800", DriverLeaveRequestUiRules.phoneDialUri("+48 600 700 800"))
assertEquals("tel:+48600700800", DriverLeaveRequestUiRules.phoneDialUri("0048 600 700 800"))
assertEquals(null, DriverLeaveRequestUiRules.phoneDialUri(null))
assertEquals(null, DriverLeaveRequestUiRules.phoneDialUri(" "))
}
private fun calendarEntry(id: String, dateFrom: String, dateTo: String): DriverLeaveCalendarEntryDto =
DriverLeaveCalendarEntryDto(
id = id,
dateFrom = dateFrom,
dateTo = dateTo,
type = "URLOP",
typeLabel = "Urlop",
status = "approved",
driver = DriverLeaveCalendarDriverDto(
id = "driver-$id",
displayName = "Kierowca $id",
phoneNumber = "+48 600 700 800",
),
)
}
@@ -1,11 +1,13 @@
package pl.firmatpp.kierowca.ui
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType
@@ -57,6 +59,54 @@ class DriverRouteProjectionTest {
assertEquals("finished", projection.route.trackingStatus)
}
@Test
fun confirmedFinishStillProjectsPlannedRouteAsFinishedWithoutSyncCallout() {
val projection = projectDriverRoute(
route = route(status = "ZAPLANOWANA"),
actions = listOf(
action(RouteActionType.Finish, RouteActionStatus.Confirmed, weight = 11.8, createdAt = 2000),
),
)
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("finished", projection.route.trackingStatus)
assertFalse(projection.unloadingWeightPending)
assertEquals(emptyList<RouteActionEntity>(), projection.visibleActions)
}
@Test
fun routeProjectionNormalizesNullPhotosFromCachedJsonBeforeCopy() {
val route = Gson().fromJson(
"""
{
"id": "1",
"startsAt": "",
"originName": "Baza",
"destinationName": "Instalacja",
"contractorName": "TPP",
"contractName": "Kontrakt",
"contractCode": "TPP-1",
"relationLabel": "Baza -> Instalacja",
"status": "ZAPLANOWANA",
"distanceKm": 1.0,
"notes": null,
"truck": null,
"photos": null
}
""".trimIndent(),
DriverRouteDto::class.java,
)
val projection = projectDriverRoute(
route = route,
actions = listOf(action(RouteActionType.Start, RouteActionStatus.Confirmed, weight = 12.5)),
)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(emptyList<RoutePhotoDto>(), projection.route.photos)
}
@Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() {
val projection = projectDriverRoute(
@@ -68,7 +118,7 @@ class DriverRouteProjectionTest {
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals(RouteFlowStepState.NeedsAttention, steps[2].state)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false)
}
@@ -83,7 +133,7 @@ class DriverRouteProjectionTest {
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight)
assertEquals(RouteFlowStepState.NeedsAttention, steps[0].state)
assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
}
@Test
@@ -104,6 +154,7 @@ class DriverRouteProjectionTest {
): DriverRouteDto =
DriverRouteDto(
id = "1",
routeDate = "2026-06-30",
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",
@@ -39,6 +39,32 @@ class DriverUiRulesTest {
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
}
@Test
fun multiDayRouteCanLoadTodayButCanUnloadOnlyOnItsUnloadingDate() {
val route = route(
status = "ZAPLANOWANA",
routeDate = "2026-06-29",
unloadingDate = "2026-06-30",
)
assertTrue(canStartRouteFromDriverApp(route, "2026-06-30", today))
assertEquals("Załaduj", routeLifecyclePrimaryAction(route, "2026-06-30", today)?.label)
assertEquals("Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj.", routeTodayInstruction(route, "2026-06-30", today))
val activeRoute = route.copy(status = "W TRAKCIE")
assertTrue(canFinishRouteFromDriverApp(activeRoute, "2026-06-30", today))
assertEquals("Rozładuj", routeLifecyclePrimaryAction(activeRoute, "2026-06-30", today)?.label)
assertFalse(canFinishRouteFromDriverApp(activeRoute, "2026-06-29", LocalDate.parse("2026-06-29")))
}
@Test
fun routeScheduleShowsBothDispatcherDates() {
val route = route(routeDate = "2026-06-30", unloadingDate = "2026-07-02")
assertEquals("Załadunek: 30.06 · Rozładunek: 02.07", routeScheduleSummary(route))
assertEquals("Załadunek: 30.06 · Rozładunek: ten sam dzień", routeScheduleSummary(route.copy(unloadingDate = null)))
}
@Test
fun showsLiveRouteDayUpdateOnlyForViewedDate() {
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12"))
@@ -235,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(
@@ -295,14 +347,27 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route, emptyList())
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today)
assertEquals(listOf("Załadunek", "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)
assertEquals("Rozpocznij kurs", action?.label)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Załaduj", action?.label)
assertEquals(RouteLifecycleAction.Start, action?.action)
}
@Test
fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() {
val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading")))
val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.LocalComplete, steps[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
}
@Test
fun activeRouteShowsTransitStateAndFinishAction() {
val route = route(status = "W TRAKCIE", loadingWeight = 12.5)
@@ -311,9 +376,10 @@ class DriverUiRulesTest {
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today)
assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.LocalComplete, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals("Zakończ kurs", action?.label)
assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.LocalComplete, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Rozładuj", action?.label)
assertEquals(RouteLifecycleAction.Finish, action?.action)
}
@@ -327,6 +393,7 @@ class DriverUiRulesTest {
assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.Confirmed, steps[2].state)
assertEquals(RouteFlowStepState.Confirmed, steps[3].state)
assertEquals(null, action)
}
@@ -337,7 +404,7 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions)
val callout = routeSyncCallout(actions)
assertEquals(RouteFlowStepState.Syncing, steps[0].state)
assertEquals(RouteFlowStepState.Syncing, steps[1].state)
assertEquals("Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu.", callout?.text)
assertFalse(callout?.isConflict ?: true)
}
@@ -349,27 +416,118 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions)
val callout = routeSyncCallout(actions)
assertEquals(RouteFlowStepState.NeedsAttention, steps[2].state)
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))
}
@Test
fun loadingPhotosStepRequiresAtLeastOneLoadingPhotoBeforeWeightStep() {
assertEquals(
"Dodaj co najmniej jedno zdjęcie załadunku.",
loadingPhotosSubmitBlocker(serverPhotoCount = 0, localUploadCount = 0),
)
assertEquals(null, loadingPhotosSubmitBlocker(serverPhotoCount = 1, localUploadCount = 0))
assertEquals(null, loadingPhotosSubmitBlocker(serverPhotoCount = 0, localUploadCount = 1))
}
@Test
fun loadingWeightStepRequiresPositiveWeightAndPreviousLoadingPhoto() {
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),
)
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,
status: String = "ZAPLANOWANA",
loadingWeight: Double? = null,
unloadingWeight: Double? = null,
photos: List<RoutePhotoDto> = emptyList(),
routeDate: String? = "2026-06-30",
unloadingDate: String? = null,
): DriverRouteDto =
DriverRouteDto(
id = "1",
routeDate = routeDate,
unloadingDate = unloadingDate,
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",
@@ -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")
@@ -70,18 +104,92 @@ class DriverUiStateTest {
assertEquals(12.5, state.displaySelectedRoute?.loadingWeight)
}
private fun leaveRequest(id: String, status: String): DriverLeaveRequestDto =
@Test
fun displayRoutesMovesCompletedRoutesBelowActiveDispatcherOrder() {
val state = DriverUiState(
routes = listOf(
route(id = "completed", status = "ZAKOŃCZONA"),
route(id = "planned", status = "ZAPLANOWANA"),
route(id = "active", status = "W TRAKCIE"),
),
)
assertEquals(listOf("planned", "active", "completed"), state.displayRoutes.map { it.id })
}
@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(status: String): DriverRouteDto =
private fun route(
id: String = "1",
status: String,
ordering: Int? = null,
dayOrderings: Map<String, Int> = emptyMap(),
): DriverRouteDto =
DriverRouteDto(
id = "1",
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