Compare commits

...
72 Commits
Author SHA1 Message Date
admin c212a6a488 Wydaj aplikację kierowcy 1.0.80 (133) 2026-08-10 11:07:31 +02:00
admin 08fcb25a2a Napraw aparat po obrocie telefonu 2026-08-10 11:06:13 +02:00
admin b8b135ee63 Wydaj aplikację kierowcy 1.0.79 (132) 2026-08-01 00:40:26 +02:00
admin 6066527dc0 Przyspiesz wyświetlanie dodawanych zdjęć 2026-08-01 00:32:05 +02:00
admin 1f39f27618 Wydaj aplikację kierowcy 1.0.78 (131) 2026-07-31 23:14:01 +02:00
admin 956be6a6de Obsłuż konfigurowalne kroki i dokumenty trasy 2026-07-31 23:11:24 +02:00
admin 90ca9775ef Wydaj aplikację kierowcy 1.0.77 (130) 2026-07-30 13:30:22 +02:00
admin 0e33564b95 Popraw zapis i usuwanie zdjęć kierowcy 2026-07-30 13:24:19 +02:00
admin 321b260e3d Wydaj aplikację kierowcy 1.0.76 (129) 2026-07-30 12:08:10 +02:00
admin be3c885988 Dodaj seryjne zdjęcia załadunku i rozładunku 2026-07-30 12:07:05 +02:00
admin 802393962e Wydaj aplikację kierowcy 1.0.75 (128) 2026-07-30 02:04:03 +02:00
admin ac31308443 Ukrywaj wybrane salda w aplikacji kierowcy 2026-07-30 02:01:46 +02:00
admin af832dee93 Wydaj aplikację kierowcy 1.0.74 (127) 2026-07-30 01:44:37 +02:00
admin 8ca5f20a98 Rozszerz mobilną obsługę urlopów i nieobecności 2026-07-30 01:43:26 +02:00
admin 26b17ece26 Wydaj aplikację kierowcy 1.0.73 (126) 2026-07-30 00:26:21 +02:00
admin 2e7eb7e47b Uprość obsługę urlopów w aplikacji kierowcy 2026-07-30 00:24:39 +02:00
admin 062415a75d Wydaj aplikację kierowcy 1.0.72 (125) 2026-07-29 18:07:48 +02:00
admin ab68be4c1e Napraw pętlę uruchamiania aplikacji kierowcy 2026-07-29 18:06:24 +02:00
admin 513c1785ef Wydaj aplikację kierowcy 1.0.71 (124) 2026-07-24 13:40:46 +02:00
admin e70a53eca7 Dodaj potwierdzanie odczytów tachografu 2026-07-24 13:33:27 +02:00
admin c3004a3d2e Dodaj kartę przypomnienia o tachografie 2026-07-24 13:11:16 +02:00
admin fda4b462dc Wydaj aplikację kierowcy 1.0.70 (123) 2026-07-24 12:55:12 +02:00
admin 6a1a15ade9 Add route date calendar picker 2026-07-24 12:49:32 +02:00
admin ea44cf5d2e Wydaj aplikację kierowcy 1.0.69 (122) 2026-07-24 01:30:07 +02:00
admin 984feb170a Wydaj aplikację kierowcy 1.0.68 (121) 2026-07-23 15:14:50 +02:00
admin 5691c87e91 Dostosuj aplikację do Androida 16 2026-07-23 15:14:05 +02:00
admin 227e08a671 Wydaj aplikację kierowcy 1.0.67 (120) 2026-07-23 15:03:18 +02:00
admin 3bfb5f9788 Usuń nieaktualny konflikt zakończenia kursu 2026-07-23 15:02:21 +02:00
admin bf1cf3865a Napraw synchronizację statusu kursu kierowcy 2026-07-23 14:15:34 +02:00
admin 0c3bed6213 Dostosuj lokalizację kursu do ustawień 2026-07-23 12:51:26 +02:00
admin f1cb2d618d Wydaj aplikację kierowcy 1.0.66 (119) 2026-07-23 11:35:47 +02:00
admin 8c9a3b5b37 Aktualizuj wersję aplikacji w sesji kierowcy 2026-07-23 11:33:47 +02:00
admin de8510bc79 Wydaj aplikację kierowcy 1.0.65 (118) 2026-07-23 11:33:17 +02:00
admin 34d01b312e Obsłuż adres SSH w skrypcie wydania Androida 2026-07-23 11:19:10 +02:00
admin f89332b28c Dodaj uwagi i dyktowanie na etapach trasy 2026-07-23 11:05:49 +02:00
admin 8f12bf32bc Wydaj aplikację kierowcy 1.0.64 (117) 2026-07-18 17:37:53 +02:00
admin b9ae128842 Napraw obsługę kodów OTP podczas logowania 2026-07-18 17:33:40 +02:00
admin a86dca50b8 Wydaj aplikację kierowcy 1.0.63 (116) 2026-07-18 16:34:32 +02:00
admin fc49eae512 Zatrzymaj aktywny kurs po wygaśnięciu sesji 2026-07-18 16:29:34 +02:00
admin ffbae9edfd Wydaj aplikację kierowcy 1.0.62 (115) 2026-07-18 02:57:39 +02:00
admin 2268c64250 Zautomatyzuj publikację aplikacji w Google Play 2026-07-18 02:57:03 +02:00
admin c954fd9bb8 chore: release android driver 1.0.61 (114) 2026-07-18 02:34:10 +02:00
admin b9757348f2 Dodaj aktualizacje aplikacji kierowcy przez Google Play 2026-07-18 02:32:31 +02:00
admin 130f8bbecc chore: release android driver 1.0.60 (113) 2026-07-17 14:10:03 +02:00
admin 4c00d46a14 Napraw otwieranie nawigacji do geofence 2026-07-17 14:05:11 +02:00
admin 07fc4021e1 chore: release android driver 1.0.59 (112) 2026-07-17 13:33:46 +02:00
admin 431436d0db Napraw synchronizację kursu w aplikacji kierowcy 2026-07-17 13:28:49 +02:00
admin 905711d8de chore: release android driver 1.0.58 (111) 2026-07-16 11:06:24 +02:00
admin 7c9cb3fb0d Dodaj timeout i ręczny tryb offline przy uruchamianiu 2026-07-16 11:04:36 +02:00
admin ae0d1b2d3d chore: release android driver 1.0.57 (110) 2026-07-16 10:31:11 +02:00
admin c5b3c5dae9 Przeliczaj wagi kierowcy z kilogramów na tony 2026-07-16 10:28:30 +02:00
admin 3fa3b4eac1 chore: release android driver 1.0.56 (109) 2026-07-12 22:34:20 +02:00
admin 197bcc6f5e Przypomnij o zgodzie na powiadomienia Android 2026-07-12 22:33:52 +02:00
admin f4f5cec2be chore: release android driver 1.0.55 (108) 2026-07-12 22:04:46 +02:00
admin 5a630f8401 Użyj origin realtime z bootstrapu 2026-07-12 21:59:46 +02:00
admin 54a3c58a22 chore: release android driver 1.0.54 (107) 2026-07-12 21:57:39 +02:00
admin 1c6a044c37 Dodaj Origin dla WebSocket Reverb 2026-07-12 21:55:01 +02:00
admin fcc022db64 chore: release android driver 1.0.53 (106) 2026-07-11 00:35:40 +02:00
admin a908a2da18 fix: avoid release verification pipe failure 2026-07-11 00:35:21 +02:00
admin 2c25647fa5 chore: ignore backup Android resources 2026-07-11 00:29:47 +02:00
admin 956f33140e chore: automate Android release builds 2026-07-11 00:27:54 +02:00
admin 7bba4dc878 fix: stop orphaned GPS synchronization 2026-07-11 00:27:52 +02:00
admin 89235fbb46 feat: harden offline sync and add diagnostics 2026-07-10 23:58:07 +02:00
admin 0564101e9e chore: release android driver 1.0.51 2026-07-10 22:35:04 +02:00
admin 2baa544b32 fix: reconcile leave requests in realtime 2026-07-10 22:32:13 +02:00
admin f9c3a2e305 Sortuj kursy kierowcy według dnia 2026-07-10 21:31:17 +02:00
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
81 changed files with 11651 additions and 784 deletions
+5
View File
@@ -1,4 +1,5 @@
.gradle/ .gradle/
.kotlin/
build/ build/
local.properties local.properties
app/google-services.json app/google-services.json
@@ -7,3 +8,7 @@ app/google-services.json:Zone.Identifier
*.iml *.iml
keystore.properties keystore.properties
*.jks *.jks
release-artifacts/
app/src/main/res_bak/
scripts/*.json
scripts/*.json:Zone.Identifier
+62
View File
@@ -21,3 +21,65 @@ Natywna aplikacja Android Jetpack Compose dla kierowcow TPP.
```bash ```bash
./gradlew :app:testDebugUnitTest ./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 oraz AAB, a po poprawnym buildzie pyta, czy wysłać aktualizację do
Google Play. Po odpowiedzi „tak” publikuje AAB w skonfigurowanym teście zamkniętym i
wysyła zmiany do weryfikacji. Następnie tworzy commit wersji, tag, push i Gitea Release.
Artefakty zostają również zapisane lokalnie w `release-artifacts/<tag>/`.
Do publikacji Google Play konto serwisowe musi mieć dla aplikacji
`pl.firmatpp.kierowca` uprawnienia „Wyświetlanie informacji o aplikacji (tylko do odczytu)”
oraz „Publikowanie aplikacji na ścieżkach testowych”. Pobrany klucz JSON umieść
bezpośrednio w folderze `scripts`, na przykład:
```text
scripts/google-play-service-account.json
```
Skrypt sam znajduje jedyny plik `scripts/*.json`, sprawdza, czy jest prawidłowym kluczem
konta serwisowego, i używa go do autoryzacji. Pliki `scripts/*.json` są ignorowane przez
Git i nie mogą zostać przypadkowo dodane do repozytorium. Jeśli w folderze znajduje się
więcej niż jeden prawidłowy klucz, skrypt przerwie działanie i poprosi o pozostawienie
jednego.
`GOOGLE_PLAY_TRACK` musi być identyfikatorem istniejącego testu zamkniętego. Domyślna
wartość to `alpha`. Opcjonalnie można ustawić `GOOGLE_PLAY_PACKAGE_NAME` i
`GOOGLE_PLAY_RELEASE_LANGUAGE` (domyślnie `pl-PL`). Zmienna
`GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS` pozostaje opcjonalnym sposobem wskazania klucza
spoza folderu `scripts`. Skrypt nie zapisuje tokenu ani klucza w repozytorium; prywatny
klucz tymczasowy jest usuwany po zakończeniu.
Przed zatwierdzeniem skrypt waliduje edycję Google Play. Commit edycji jawnie wysyła
zmiany do weryfikacji. Jeżeli inne zmiany są już sprawdzane, skrypt nie anuluje ich —
kończy się błędem i można go wznowić po zakończeniu poprzedniej weryfikacji.
Jeżeli na koncie jest włączone publikowanie zarządzane, po akceptacji Google nadal trzeba
wybrać „Opublikuj zmiany” w Play Console; bez publikowania zarządzanego zaakceptowane
wydanie zostanie udostępnione testerom automatycznie.
Awaryjne wydanie wyłącznie do Gitea, bez Google Play:
```bash
./scripts/release-android.sh --skip-google-play
```
W środowisku bez interaktywnego terminala decyzję trzeba podać jawnie:
```bash
./scripts/release-android.sh --publish-google-play
```
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
```
+6 -4
View File
@@ -22,7 +22,7 @@ val keystoreProperties = Properties().apply {
android { android {
namespace = "pl.firmatpp.kierowca" namespace = "pl.firmatpp.kierowca"
compileSdk = 35 compileSdk = 36
fun configValue(name: String, default: String): String = fun configValue(name: String, default: String): String =
providers.gradleProperty(name) providers.gradleProperty(name)
@@ -33,9 +33,9 @@ android {
defaultConfig { defaultConfig {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 36
versionCode = 49 versionCode = 133
versionName = "1.0.44" versionName = "1.0.80"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -130,6 +130,8 @@ dependencies {
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.play.services.auth) implementation(libs.play.services.auth)
implementation(libs.play.services.auth.api.phone) implementation(libs.play.services.auth.api.phone)
implementation(libs.play.app.update)
implementation(libs.play.app.update.ktx)
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.retrofit.gson) implementation(libs.retrofit.gson)
+11
View File
@@ -2,12 +2,23 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -1,12 +1,26 @@
package pl.firmatpp.kierowca package pl.firmatpp.kierowca
import android.app.Application 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 import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverApplication : Application() { class DriverApplication : Application() {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
AppDiagnostics.installFirebaseCrashlytics() AppDiagnostics.installFirebaseCrashlytics()
AppDiagnostics.log("app_started") AppDiagnostics.log("app_started")
applicationScope.launch {
val outboxManager = OfflineOutboxManager(this@DriverApplication)
runCatching { outboxManager.repairOperationalQueues() }
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_operational_queues") }
runCatching { outboxManager.repairGpsQueue() }
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") }
}
} }
} }
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -11,17 +12,33 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import pl.firmatpp.kierowca.ui.DriverApp import pl.firmatpp.kierowca.ui.DriverApp
import pl.firmatpp.kierowca.ui.DriverViewModel import pl.firmatpp.kierowca.ui.DriverViewModel
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
import pl.firmatpp.kierowca.update.PlayAppUpdateState
import pl.firmatpp.kierowca.update.PlayInAppUpdateController
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null) private var notificationRouteId by mutableStateOf<String?>(null)
private var notificationRouteTarget by mutableStateOf<String?>(null) private var notificationRouteTarget by mutableStateOf<String?>(null)
private var notificationLeaveRequestId by mutableStateOf<String?>(null) private var notificationLeaveRequestId by mutableStateOf<String?>(null)
private var playAppUpdateState by mutableStateOf(PlayAppUpdateState())
private lateinit var playInAppUpdates: PlayInAppUpdateController
private val appUpdateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult(),
) { result ->
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.onUpdateFlowResult(result.resultCode)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET) notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID) notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
playInAppUpdates = PlayInAppUpdateController(
activity = this,
launcher = appUpdateLauncher,
onStateChanged = { playAppUpdateState = it },
)
setContent { setContent {
val viewModel: DriverViewModel = viewModel() val viewModel: DriverViewModel = viewModel()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -32,9 +49,27 @@ class MainActivity : ComponentActivity() {
initialRouteId = notificationRouteId, initialRouteId = notificationRouteId,
initialRouteTarget = notificationRouteTarget, initialRouteTarget = notificationRouteTarget,
initialLeaveRequestId = notificationLeaveRequestId, initialLeaveRequestId = notificationLeaveRequestId,
playAppUpdateState = playAppUpdateState,
onStartAppUpdate = playInAppUpdates::startUpdate,
onCompleteAppUpdate = playInAppUpdates::completeDownloadedUpdate,
) )
} }
} }
playInAppUpdates.checkForUpdate()
}
override fun onResume() {
super.onResume()
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.checkForUpdate()
}
}
override fun onDestroy() {
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.dispose()
}
super.onDestroy()
} }
override fun onNewIntent(intent: android.content.Intent) { override fun onNewIntent(intent: android.content.Intent) {
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data
import java.io.IOException import java.io.IOException
import java.net.UnknownHostException import java.net.UnknownHostException
import kotlin.coroutines.cancellation.CancellationException
import retrofit2.HttpException import retrofit2.HttpException
enum class ApiErrorKind { enum class ApiErrorKind {
@@ -49,7 +50,10 @@ object ApiErrorMapper {
map(throwable).kind != ApiErrorKind.Network map(throwable).kind != ApiErrorKind.Network
fun mapHttpStatus(statusCode: Int, body: String?): ApiError { fun mapHttpStatus(statusCode: Int, body: String?): ApiError {
val code = body?.let { """"code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) } val code = body?.let {
""""code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1)
?: """"reason"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1)
}
val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) } val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) }
val retryable = body?.let { """"retryable"\s*:\s*(true|false)""".toRegex().find(it)?.groupValues?.getOrNull(1)?.toBooleanStrictOrNull() } val retryable = body?.let { """"retryable"\s*:\s*(true|false)""".toRegex().find(it)?.groupValues?.getOrNull(1)?.toBooleanStrictOrNull() }
@@ -80,15 +84,20 @@ object ApiErrorMapper {
} }
val defaultRetryable = statusCode == 429 || statusCode in 500..599 val defaultRetryable = statusCode == 429 || statusCode in 500..599
val resolvedRetryable = retryable ?: defaultRetryable val resolvedRetryable = retryable ?: defaultRetryable
val resolvedMessage = message?.takeIf { it.isNotBlank() } ?: when (kind) { val resolvedMessage = when {
ApiErrorKind.Auth -> "Sesja wygasła. Zaloguj się ponownie." code == "INVALID_OTP" -> "Kod jest nieprawidłowy lub wygasł. Użyj kodu z najnowszego SMS-a."
ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji." kind == ApiErrorKind.RateLimited -> "Za dużo prób. Odczekaj chwilę i spróbuj ponownie."
ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana." !message.isNullOrBlank() -> message
ApiErrorKind.Conflict -> "Operacja jest w konflikcie z aktualnym stanem danych." else -> when (kind) {
ApiErrorKind.RateLimited -> "Za dużo prób. Aplikacja spróbuje ponownie później." ApiErrorKind.Auth -> "Sesja wygasła. Zaloguj się ponownie."
ApiErrorKind.Server -> "Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie." ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji."
ApiErrorKind.Network -> "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona." ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana."
ApiErrorKind.Unknown -> "Wystąpił błąd. Operacja nie została potwierdzona." ApiErrorKind.Conflict -> "Operacja jest w konflikcie z aktualnym stanem danych."
ApiErrorKind.RateLimited -> "Za dużo prób. Odczekaj chwilę i spróbuj ponownie."
ApiErrorKind.Server -> "Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie."
ApiErrorKind.Network -> "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona."
ApiErrorKind.Unknown -> "Wystąpił błąd. Operacja nie została potwierdzona."
}
} }
return ApiError( return ApiError(
@@ -102,7 +111,15 @@ object ApiErrorMapper {
} }
private fun mapHttpException(exception: HttpException): ApiError { private fun mapHttpException(exception: HttpException): ApiError {
val body = runCatching { exception.response()?.errorBody()?.string() }.getOrNull() val body = runCatching {
val source = exception.response()?.errorBody()?.source() ?: return@runCatching null
source.request(Long.MAX_VALUE)
source.buffer.clone().readUtf8()
}.getOrNull()
return mapHttpStatus(exception.code(), body) return mapHttpStatus(exception.code(), body)
} }
} }
fun Throwable.rethrowIfCancellation() {
if (this is CancellationException) throw this
}
@@ -20,6 +20,7 @@ class AppPreferencesStore(
private val themeModeKey = stringPreferencesKey("theme_mode") private val themeModeKey = stringPreferencesKey("theme_mode")
private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled") private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled")
private val routeLocationEnabledKey = booleanPreferencesKey("route_location_enabled")
val themeMode: Flow<AppThemeMode> = dataStore.data val themeMode: Flow<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) } .map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
@@ -27,6 +28,9 @@ class AppPreferencesStore(
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true } .map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
val routeLocationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeLocationEnabledKey] ?: false }
suspend fun setThemeMode(themeMode: AppThemeMode) { suspend fun setThemeMode(themeMode: AppThemeMode) {
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[themeModeKey] = themeMode.storedValue preferences[themeModeKey] = themeMode.storedValue
@@ -38,4 +42,10 @@ class AppPreferencesStore(
preferences[routeProgressNotificationEnabledKey] = enabled preferences[routeProgressNotificationEnabledKey] = enabled
} }
} }
suspend fun setRouteLocationEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[routeLocationEnabledKey] = enabled
}
}
} }
@@ -22,17 +22,6 @@ class DeviceInfoProvider(
androidVersion = Build.VERSION.RELEASE, androidVersion = Build.VERSION.RELEASE,
sdkInt = Build.VERSION.SDK_INT, sdkInt = Build.VERSION.SDK_INT,
appVersion = BuildConfig.VERSION_NAME, appVersion = BuildConfig.VERSION_NAME,
serialNumber = readSerialNumber(), serialNumber = null,
) )
private fun readSerialNumber(): String? =
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Build.getSerial()
} else {
@Suppress("DEPRECATION")
Build.SERIAL
}
}.getOrNull()
?.takeUnless { it.isBlank() || it.equals(Build.UNKNOWN, ignoreCase = true) }
} }
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.util.UUID import java.util.UUID
@@ -12,14 +13,20 @@ import okhttp3.RequestBody.Companion.toRequestBody
import pl.firmatpp.kierowca.data.api.ApiFactory import pl.firmatpp.kierowca.data.api.ApiFactory
import pl.firmatpp.kierowca.data.api.MobileDriverApi import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveDocumentDto
import pl.firmatpp.kierowca.data.model.DriverLeaveForecastDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveForecastBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.OtpResponse
@@ -32,6 +39,7 @@ import pl.firmatpp.kierowca.data.model.RoutePointsResponse
import pl.firmatpp.kierowca.data.model.RouteResponse import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.SyncStateResponse import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.VerifyOtpBody import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -55,20 +63,104 @@ class DriverRepository(
suspend fun bootstrap(date: String? = null): BootstrapResponse = suspend fun bootstrap(date: String? = null): BootstrapResponse =
api.bootstrap(authHeader(requireToken()), date) api.bootstrap(authHeader(requireToken()), date)
suspend fun acknowledgeTachographReminder(taskIds: List<String>): TachographReminderDto =
api.acknowledgeTachographReminder(
authHeader(requireToken()),
AcknowledgeTachographReminderBody(taskIds),
).tachographReminder
suspend fun route(routeId: String): RouteResponse = suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId) api.route(authHeader(requireToken()), routeId)
suspend fun leaveRequests(): List<DriverLeaveRequestDto> = suspend fun leaveRequests(): List<DriverLeaveRequestDto> =
api.leaveRequests(authHeader(requireToken())).data api.leaveRequests(authHeader(requireToken())).data
suspend fun leaveSummary(): DriverLeaveSummaryDto =
api.leaveSummary(authHeader(requireToken())).data
suspend fun leaveForecast(
dateFrom: String,
dateTo: String,
type: String,
requestMode: String,
requestedQuantity: Int?,
details: Map<String, String>,
): DriverLeaveForecastDto =
api.leaveForecast(
authHeader(requireToken()),
LeaveForecastBody(type, dateFrom, dateTo, requestMode, requestedQuantity, details),
).data
suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> =
api.leaveCalendar(authHeader(requireToken()), from, to).data
suspend fun leaveRequest(id: String): DriverLeaveRequestDto = suspend fun leaveRequest(id: String): DriverLeaveRequestDto =
api.leaveRequest(authHeader(requireToken()), id).data api.leaveRequest(authHeader(requireToken()), id).data
suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto = suspend fun createLeaveRequest(
api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data dateFrom: String,
dateTo: String,
type: String,
requestMode: String,
requestedQuantity: Int?,
details: Map<String, String>,
idempotencyKey: String,
note: String?,
): DriverLeaveRequestDto =
api.createLeaveRequest(
authHeader(requireToken()),
CreateLeaveRequestBody(
dateFrom = dateFrom,
dateTo = dateTo,
type = type,
requestMode = requestMode,
requestedQuantity = requestedQuantity,
details = details,
idempotencyKey = idempotencyKey,
note = note,
),
).data
suspend fun cancelLeaveRequest(id: String, comment: String? = null): DriverLeaveRequestDto = suspend fun cancelLeaveRequest(id: String, expectedVersion: Long, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment)).data api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment, expectedVersion)).data
suspend fun leaveDocuments(id: String): List<DriverLeaveDocumentDto> =
api.leaveDocuments(authHeader(requireToken()), id).data
suspend fun uploadLeaveDocument(id: String, documentType: String, uri: Uri): DriverLeaveDocumentDto {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "application/octet-stream"
val fileMetadata = resolver.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
null,
null,
null,
)?.use { cursor ->
if (!cursor.moveToFirst()) {
null
} else {
cursor.getString(0) to cursor.getLong(1)
}
}
if ((fileMetadata?.second ?: 0L) > 20L * 1024L * 1024L) {
error("Dokument jest za duży. Maksymalny rozmiar pliku to 20 MB.")
}
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Nie można odczytać wybranego dokumentu.")
val originalName = fileMetadata?.first
?.takeIf { it.isNotBlank() }
?: "dokument"
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val file = MultipartBody.Part.createFormData("file", originalName, body)
return api.uploadLeaveDocument(
authHeader(requireToken()),
id,
file,
documentType.toPlainTextBody(),
).data
}
suspend fun completeRoute(routeId: String) = suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId) api.completeRoute(authHeader(requireToken()), routeId)
@@ -110,6 +202,9 @@ class DriverRepository(
source: String, source: String,
stage: String = "other", stage: String = "other",
metadata: PhotoUploadMetadata = PhotoUploadMetadata(), metadata: PhotoUploadMetadata = PhotoUploadMetadata(),
workflowVersion: String? = null,
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): PhotoUploadResponse { ): PhotoUploadResponse {
val resolver = context.contentResolver val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "image/jpeg" val mimeType = resolver.getType(uri) ?: "image/jpeg"
@@ -130,6 +225,9 @@ class DriverRepository(
contentSha256.toPlainTextBody(), contentSha256.toPlainTextBody(),
sourceBody, sourceBody,
stage.toPlainTextBody(), stage.toPlainTextBody(),
workflowVersion?.toPlainTextBody(),
workflowStepId?.toPlainTextBody(),
workflowDocumentId?.toPlainTextBody(),
metadataParts["takenAt"], metadataParts["takenAt"],
metadataParts["latitude"], metadataParts["latitude"],
metadataParts["longitude"], metadataParts["longitude"],
@@ -157,6 +255,9 @@ class DriverRepository(
upload.contentSha256.toPlainTextBody(), upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(), upload.source.toPlainTextBody(),
upload.stage.toPlainTextBody(), upload.stage.toPlainTextBody(),
upload.workflowVersion?.toPlainTextBody(),
upload.workflowStepId?.toPlainTextBody(),
upload.workflowDocumentId?.toPlainTextBody(),
metadataParts["takenAt"], metadataParts["takenAt"],
metadataParts["latitude"], metadataParts["latitude"],
metadataParts["longitude"], metadataParts["longitude"],
@@ -194,9 +295,9 @@ class DriverRepository(
api.deletePhoto(authHeader(requireToken()), photoId) api.deletePhoto(authHeader(requireToken()), photoId)
} }
suspend fun logout() { suspend fun logout(notifyServer: Boolean = true) {
val token = tokenStore.read() val token = tokenStore.read()
if (token != null) { if (token != null && notifyServer) {
runCatching { api.deletePushToken(authHeader(token)) } runCatching { api.deletePushToken(authHeader(token)) }
runCatching { api.logout(authHeader(token)) } runCatching { api.logout(authHeader(token)) }
} }
@@ -1,5 +1,6 @@
package pl.firmatpp.kierowca.data package pl.firmatpp.kierowca.data
import java.io.Serializable
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
@@ -9,7 +10,7 @@ data class PhotoUploadMetadata(
val latitude: Double? = null, val latitude: Double? = null,
val longitude: Double? = null, val longitude: Double? = null,
val locationAccuracyMeters: Double? = null, val locationAccuracyMeters: Double? = null,
) ) : Serializable
fun PhotoUploadMetadata.toMultipartTextParts(): Map<String, RequestBody> = buildMap { fun PhotoUploadMetadata.toMultipartTextParts(): Map<String, RequestBody> = buildMap {
takenAt?.let { put("takenAt", it.toPlainTextBody()) } takenAt?.let { put("takenAt", it.toPlainTextBody()) }
@@ -1,11 +1,19 @@
package pl.firmatpp.kierowca.data.api package pl.firmatpp.kierowca.data.api
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import pl.firmatpp.kierowca.BuildConfig import pl.firmatpp.kierowca.BuildConfig
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
internal const val DRIVER_APP_VERSION_HEADER = "X-Driver-App-Version"
internal fun Request.withDriverAppVersion(appVersion: String): Request =
newBuilder()
.header(DRIVER_APP_VERSION_HEADER, appVersion)
.build()
object ApiFactory { object ApiFactory {
fun mobileDriverApi(): MobileDriverApi { fun mobileDriverApi(): MobileDriverApi {
val logging = HttpLoggingInterceptor().apply { val logging = HttpLoggingInterceptor().apply {
@@ -13,6 +21,9 @@ object ApiFactory {
} }
val client = OkHttpClient.Builder() val client = OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(chain.request().withDriverAppVersion(BuildConfig.VERSION_NAME))
}
.addInterceptor(logging) .addInterceptor(logging)
.build() .build()
@@ -3,6 +3,8 @@ package pl.firmatpp.kierowca.data.api
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderBody
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -10,9 +12,15 @@ import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveCalendarResponse
import pl.firmatpp.kierowca.data.model.LeaveDocumentListResponse
import pl.firmatpp.kierowca.data.model.LeaveDocumentResponse
import pl.firmatpp.kierowca.data.model.LeaveForecastBody
import pl.firmatpp.kierowca.data.model.LeaveForecastResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestResponse import pl.firmatpp.kierowca.data.model.LeaveRequestResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse
import pl.firmatpp.kierowca.data.model.LeaveSummaryResponse
import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
@@ -66,16 +74,40 @@ interface MobileDriverApi {
@Query("date") date: String?, @Query("date") date: String?,
): BootstrapResponse ): BootstrapResponse
@POST("mobile/driver/tachograph-reminder/acknowledge")
suspend fun acknowledgeTachographReminder(
@Header("Authorization") authorization: String,
@Body body: AcknowledgeTachographReminderBody,
): AcknowledgeTachographReminderResponse
@GET("mobile/driver/leave-request-types") @GET("mobile/driver/leave-request-types")
suspend fun leaveRequestTypes( suspend fun leaveRequestTypes(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
): LeaveRequestTypesResponse ): LeaveRequestTypesResponse
@GET("mobile/driver/leave-summary")
suspend fun leaveSummary(
@Header("Authorization") authorization: String,
): LeaveSummaryResponse
@POST("mobile/driver/leave-forecast")
suspend fun leaveForecast(
@Header("Authorization") authorization: String,
@Body body: LeaveForecastBody,
): LeaveForecastResponse
@GET("mobile/driver/leave-requests") @GET("mobile/driver/leave-requests")
suspend fun leaveRequests( suspend fun leaveRequests(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
): LeaveRequestListResponse ): 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") @POST("mobile/driver/leave-requests")
suspend fun createLeaveRequest( suspend fun createLeaveRequest(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -88,6 +120,21 @@ interface MobileDriverApi {
@Path("id") id: String, @Path("id") id: String,
): LeaveRequestResponse ): LeaveRequestResponse
@GET("mobile/driver/leave-requests/{id}/documents")
suspend fun leaveDocuments(
@Header("Authorization") authorization: String,
@Path("id") id: String,
): LeaveDocumentListResponse
@Multipart
@POST("mobile/driver/leave-requests/{id}/documents")
suspend fun uploadLeaveDocument(
@Header("Authorization") authorization: String,
@Path("id") id: String,
@Part file: MultipartBody.Part,
@Part("documentType") documentType: RequestBody,
): LeaveDocumentResponse
@POST("mobile/driver/leave-requests/{id}/cancel") @POST("mobile/driver/leave-requests/{id}/cancel")
suspend fun cancelLeaveRequest( suspend fun cancelLeaveRequest(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -169,6 +216,9 @@ interface MobileDriverApi {
@Part("contentSha256") contentSha256: RequestBody, @Part("contentSha256") contentSha256: RequestBody,
@Part("source") source: RequestBody, @Part("source") source: RequestBody,
@Part("stage") stage: RequestBody?, @Part("stage") stage: RequestBody?,
@Part("workflowVersion") workflowVersion: RequestBody?,
@Part("workflowStepId") workflowStepId: RequestBody?,
@Part("workflowDocumentId") workflowDocumentId: RequestBody?,
@Part("takenAt") takenAt: RequestBody?, @Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?, @Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?, @Part("longitude") longitude: RequestBody?,
@@ -47,9 +47,20 @@ data class BootstrapResponse(
val routes: RoutesBucketDto, val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?, val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null, val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null, val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null, val syncState: SyncStateResponse? = null,
val appUpdate: AppUpdateDto? = null,
)
data class AppUpdateDto(
val latestVersionCode: Int? = null,
val latestVersionName: String? = null,
val minimumSupportedVersionCode: Int = 0,
val playStoreUrl: String? = null,
val track: String? = null,
val checkedAt: String? = null,
) )
data class DriverAppSettingsDto( data class DriverAppSettingsDto(
@@ -62,15 +73,56 @@ data class DriverAppSettingsDto(
val allowGalleryUploads: Boolean?, val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?, val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?, val requirePreciseLocationForPhotos: Boolean?,
val routeLocationMode: String? = null,
val loadingPhotoRequirement: String? = null,
val loadingWeightRequirement: String? = null,
val unloadingPhotoRequirement: String? = null,
val unloadingWeightRequirement: String? = null,
val routeWorkflow: DriverRouteWorkflowDto? = null,
val dispatchSheetRemindersEnabled: Boolean? = null, val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null, val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null, val dispatchSheetOnLastWorkingDay: Boolean? = null,
val tachographRemindersEnabled: Boolean? = null,
val leaveRequests: LeaveRequestsConfigDto? = null, val leaveRequests: LeaveRequestsConfigDto? = null,
) )
data class DriverRouteWorkflowDto(
val version: String,
val customized: Boolean = false,
val loading: DriverRouteStageWorkflowDto,
val unloading: DriverRouteStageWorkflowDto,
)
data class DriverRouteStageWorkflowDto(
val steps: List<DriverRouteWorkflowStepDto> = emptyList(),
)
data class DriverRouteWorkflowStepDto(
val id: String,
val type: String,
val title: String,
val description: String = "",
val requirement: String = "required",
val documents: List<DriverRouteWorkflowDocumentDto> = emptyList(),
)
data class DriverRouteWorkflowDocumentDto(
val id: String,
val label: String,
val description: String = "",
val requirement: String = "required",
)
data class LeaveRequestsConfigDto( data class LeaveRequestsConfigDto(
val enabled: Boolean = false, val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"), val types: List<String> = listOf("URLOP"),
val usagePoolCodes: List<String> = listOf(
"ANNUAL",
"ANNUAL_DISABILITY",
"FORCE_MAJEURE",
"CHILD_CARE_14",
"CARE_LEAVE",
),
) )
data class DispatchSheetReminderDto( data class DispatchSheetReminderDto(
@@ -84,6 +136,30 @@ data class DispatchSheetReminderDto(
val canUpload: Boolean = false, val canUpload: Boolean = false,
) )
data class TachographReminderDto(
val enabled: Boolean = false,
val visible: Boolean = false,
val title: String? = null,
val message: String? = null,
val confirmationLabel: String? = null,
val tasks: List<TachographReminderTaskDto>? = null,
)
data class TachographReminderTaskDto(
val id: String,
val type: String,
val label: String,
)
data class AcknowledgeTachographReminderBody(
val taskIds: List<String>,
)
data class AcknowledgeTachographReminderResponse(
val ok: Boolean,
val tachographReminder: TachographReminderDto,
)
data class DispatchSheetPhotoDto( data class DispatchSheetPhotoDto(
val id: String, val id: String,
val driverId: String, val driverId: String,
@@ -113,6 +189,7 @@ data class RealtimeConfigDto(
val reverbEnabled: Boolean = false, val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null, val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null, val reverbWsBaseUrl: String? = null,
val reverbOrigin: String? = null,
) )
data class RoutesBucketDto( data class RoutesBucketDto(
@@ -167,6 +244,10 @@ data class LeaveRequestListResponse(
val data: List<DriverLeaveRequestDto> = emptyList(), val data: List<DriverLeaveRequestDto> = emptyList(),
) )
data class LeaveCalendarResponse(
val data: List<DriverLeaveCalendarEntryDto> = emptyList(),
)
data class LeaveRequestResponse( data class LeaveRequestResponse(
val data: DriverLeaveRequestDto, val data: DriverLeaveRequestDto,
) )
@@ -177,18 +258,117 @@ data class LeaveRequestTypesResponse(
data class LeaveRequestTypeDto( data class LeaveRequestTypeDto(
val value: String, val value: String,
val code: String? = null,
val ruleCode: String? = null,
val label: String, val label: String,
val category: String? = null,
val unit: String? = null,
val poolCode: String? = null,
val requestModes: List<String> = listOf("days"),
val requiredFields: List<String> = emptyList(),
val requiredDocuments: List<String> = emptyList(),
val fieldOptions: Map<String, Map<String, String>> = emptyMap(),
val description: String? = null,
val decisionMode: String? = null,
val payrollMode: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
val legalBasis: String? = null,
)
data class LeaveSummaryResponse(
val data: DriverLeaveSummaryDto,
)
data class DriverLeaveSummaryDto(
val pools: List<DriverLeavePoolDto> = emptyList(),
val dailyNormMinutes: Int = 480,
val balanceConfigured: Boolean = false,
val overdueQuantity: Int = 0,
val visibleUsagePoolCodes: List<String> = listOf(
"ANNUAL",
"ANNUAL_DISABILITY",
"FORCE_MAJEURE",
"CHILD_CARE_14",
"CARE_LEAVE",
),
val availableTypes: List<LeaveRequestTypeDto> = emptyList(),
val reminders: List<DriverLeaveReminderDto> = emptyList(),
)
data class DriverLeavePoolDto(
val id: Int,
val poolCode: String,
val year: Int,
val unit: String,
val calculatedQuantity: Int = 0,
val confirmedQuantity: Int? = null,
val availableQuantity: Int = 0,
val reservedQuantity: Int = 0,
val usedQuantity: Int = 0,
val grantedQuantity: Int = 0,
val overdue: Boolean = false,
val status: String,
)
data class DriverLeaveReminderDto(
val code: String,
val message: String,
val poolId: Int? = null,
)
data class LeaveForecastBody(
val type: String,
val dateFrom: String,
val dateTo: String,
val requestMode: String,
val requestedQuantity: Int? = null,
val details: Map<String, String> = emptyMap(),
)
data class LeaveForecastResponse(
val data: DriverLeaveForecastDto,
)
data class DriverLeaveForecastDto(
val ruleCode: String,
val payrollMode: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
val quantityUnit: String,
val days: List<DriverLeaveForecastDayDto> = emptyList(),
val workingDaysCount: Int = 0,
val requestedQuantity: Int = 0,
val availableQuantity: Int = 0,
val balanceConfigured: Boolean = false,
val sufficientBalance: Boolean = true,
val pools: List<DriverLeavePoolDto> = emptyList(),
)
data class DriverLeaveForecastDayDto(
val date: String,
val quantity: Int,
val source: String? = null,
) )
data class CreateLeaveRequestBody( data class CreateLeaveRequestBody(
val dateFrom: String, val dateFrom: String,
val dateTo: String, val dateTo: String,
val type: String, val type: String,
val requestMode: String = "days",
val requestedQuantity: Int? = null,
val details: Map<String, String> = emptyMap(),
val idempotencyKey: String? = null,
val note: String?, val note: String?,
) )
data class CancelLeaveRequestBody( data class CancelLeaveRequestBody(
val comment: String? = null, val comment: String? = null,
val expectedVersion: Long? = null,
) )
data class DriverLeaveRequestDto( data class DriverLeaveRequestDto(
@@ -197,13 +377,98 @@ data class DriverLeaveRequestDto(
val dateFrom: String?, val dateFrom: String?,
val dateTo: String?, val dateTo: String?,
val type: String, val type: String,
val ruleCode: String? = null,
val typeLabel: String? = null, val typeLabel: String? = null,
val requestMode: String = "days",
val requestedQuantity: Int? = null,
val quantityUnit: String? = null,
val rule: DriverLeaveRuleSnapshotDto? = null,
val details: Map<String, String> = emptyMap(),
val note: String? = null, val note: String? = null,
val status: String, 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 submittedAt: String? = null,
val decidedAt: String? = null, val decidedAt: String? = null,
val decisionComment: String? = null, val decisionComment: String? = null,
val events: List<DriverLeaveRequestEventDto> = emptyList(), val events: List<DriverLeaveRequestEventDto> = emptyList(),
val allocations: List<DriverLeaveAllocationDto> = emptyList(),
val balanceConfigurationRequired: Boolean = false,
val missingDocumentTypes: List<String> = emptyList(),
val payrollMode: String? = null,
val payrollItemTypes: List<String> = emptyList(),
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresLegalVerification: Boolean = false,
val legalVerificationChecks: List<String> = emptyList(),
)
data class DriverLeaveRuleSnapshotDto(
val code: String? = null,
val label: String? = null,
val unit: String? = null,
val decisionMode: String? = null,
val payrollMode: String? = null,
val poolCode: String? = null,
val rule: DriverLeaveRuleRequirementsDto? = null,
val legalBasis: String? = null,
)
data class DriverLeaveRuleRequirementsDto(
val requestModes: List<String> = listOf("days"),
val requiredFields: List<String> = emptyList(),
val requiredDocuments: List<String> = emptyList(),
val fieldOptions: Map<String, Map<String, String>> = emptyMap(),
val description: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
)
data class DriverLeaveAllocationDto(
val id: Int,
val date: String?,
val quantity: Int,
val status: String,
val poolId: Int? = null,
val sourceYear: Int? = null,
)
data class LeaveDocumentListResponse(
val data: List<DriverLeaveDocumentDto> = emptyList(),
)
data class LeaveDocumentResponse(
val data: DriverLeaveDocumentDto,
)
data class DriverLeaveDocumentDto(
val id: Int,
val documentType: String,
val originalName: String,
val mimeType: String? = null,
val sizeBytes: Long = 0,
val uploadedAt: String? = null,
)
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( data class DriverLeaveRequestEventDto(
@@ -224,6 +489,9 @@ data class RealtimeStatusBody(
data class DriverRouteDto( data class DriverRouteDto(
val id: String, val id: String,
val routeDate: String? = null, val routeDate: String? = null,
val unloadingDate: String? = null,
val ordering: Int? = null,
val dayOrderings: Map<String, Int>? = emptyMap(),
val startsAt: String, val startsAt: String,
val originName: String, val originName: String,
val destinationName: String, val destinationName: String,
@@ -232,14 +500,19 @@ data class DriverRouteDto(
val contractCode: String?, val contractCode: String?,
val relationLabel: String, val relationLabel: String,
val status: String, val status: String,
val driverStatus: String? = null,
val operationalStatus: String? = null,
val loadingWeight: Double? = null, val loadingWeight: Double? = null,
val loadingNotes: String? = null,
val unloadingWeight: Double? = null, val unloadingWeight: Double? = null,
val unloadingNotes: String? = null,
val trackingStatus: String? = null, val trackingStatus: String? = null,
val arrivedAt: String? = null, val arrivedAt: String? = null,
val distanceKm: Double?, val distanceKm: Double?,
val notes: String?, val notes: String?,
val truck: TruckDto?, val truck: TruckDto?,
val completedAt: String? = null, val completedAt: String? = null,
val operationalCompletedAt: String? = null,
val completedByDriver: DriverDto? = null, val completedByDriver: DriverDto? = null,
val originNavigation: NavigationPointDto? = null, val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null, val destinationNavigation: NavigationPointDto? = null,
@@ -247,6 +520,9 @@ data class DriverRouteDto(
val photosByStage: RoutePhotosByStageDto? = null, val photosByStage: RoutePhotosByStageDto? = null,
) )
fun DriverRouteDto.driverLifecycleStatus(): String =
driverStatus?.takeIf { it in setOf("ZAPLANOWANA", "W TRAKCIE", "ZAKOŃCZONA") } ?: status
data class RoutePhotosByStageDto( data class RoutePhotosByStageDto(
val loading: List<RoutePhotoDto> = emptyList(), val loading: List<RoutePhotoDto> = emptyList(),
val unloading: List<RoutePhotoDto> = emptyList(), val unloading: List<RoutePhotoDto> = emptyList(),
@@ -274,6 +550,10 @@ data class RoutePhotoDto(
val contentSha256: String? = null, val contentSha256: String? = null,
val source: String, val source: String,
val stage: String = "other", val stage: String = "other",
val workflowVersion: String? = null,
val workflowStepId: String? = null,
val workflowDocumentId: String? = null,
val originalName: String? = null,
val mimeType: String?, val mimeType: String?,
val size: Long, val size: Long,
val url: String, val url: String,
@@ -300,16 +580,22 @@ data class PhotoUploadReceiptDto(
data class StartRouteBody( data class StartRouteBody(
val clientActionId: String, val clientActionId: String,
val loadingWeight: Double, val loadingWeight: Double?,
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val loadingNotes: String? = null,
val workflowVersion: String? = null,
val completedStepIds: List<String> = emptyList(),
) )
data class FinishRouteBody( data class FinishRouteBody(
val clientActionId: String, val clientActionId: String,
val unloadingWeight: Double, val unloadingWeight: Double?,
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val unloadingNotes: String? = null,
val workflowVersion: String? = null,
val completedStepIds: List<String> = emptyList(),
) )
data class RouteActionResponse( data class RouteActionResponse(
@@ -13,18 +13,27 @@ interface DriverCacheDao {
@Query("SELECT * FROM driver_bootstrap_cache WHERE date = :date LIMIT 1") @Query("SELECT * FROM driver_bootstrap_cache WHERE date = :date LIMIT 1")
suspend fun bootstrap(date: String): DriverBootstrapCacheEntity? 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertRoute(cache: DriverRouteCacheEntity) suspend fun upsertRoute(cache: DriverRouteCacheEntity)
@Query("SELECT * FROM driver_route_cache WHERE routeId = :routeId LIMIT 1") @Query("SELECT * FROM driver_route_cache WHERE routeId = :routeId LIMIT 1")
suspend fun route(routeId: String): DriverRouteCacheEntity? 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSyncState(state: DriverSyncStateEntity) 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") @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? 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") @Query("DELETE FROM driver_bootstrap_cache")
suspend fun clearBootstrap() suspend fun clearBootstrap()
@@ -3,7 +3,9 @@ package pl.firmatpp.kierowca.data.sync
import android.content.Context import android.content.Context
import com.google.gson.Gson import com.google.gson.Gson
import java.io.IOException import java.io.IOException
import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RouteResponse import pl.firmatpp.kierowca.data.model.RouteResponse
@@ -37,6 +39,7 @@ class DriverSyncRepository(
saveSyncStates(response.syncState) saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis()) CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
cachedBootstrap(requestedDate, throwable) cachedBootstrap(requestedDate, throwable)
} }
} }
@@ -57,9 +60,31 @@ class DriverSyncRepository(
saveSyncStates(response.syncState) saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis()) CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
cachedRoute(routeId, throwable) cachedRoute(routeId, throwable)
} }
suspend fun bootstrapFromCache(date: String?): CachedValue<BootstrapResponse>? {
val cached = dao.bootstrap(date ?: currentDateFallback()) ?: return null
return CachedValue(
value = gson.fromJson(cached.payloadJson, BootstrapResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
suspend fun hasBootstrapCache(date: String?): Boolean =
dao.bootstrap(date ?: currentDateFallback()) != null
suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? {
val cached = dao.route(routeId) ?: return null
return CachedValue(
value = gson.fromJson(cached.payloadJson, RouteResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) { suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) {
val syncedAt = System.currentTimeMillis() val syncedAt = System.currentTimeMillis()
dao.upsertRoute( dao.upsertRoute(
@@ -72,18 +97,19 @@ class DriverSyncRepository(
), ),
) )
val selectedDate = date ?: return (routeVisibleDates(route) + listOfNotNull(date)).distinct().forEach { selectedDate ->
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return val cachedBootstrap = dao.bootstrap(selectedDate) ?: return@forEach
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java) val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item } val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap( dao.upsertBootstrap(
cachedBootstrap.copy( cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))), payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null, checksum = null,
version = null, version = null,
syncedAtEpochMillis = syncedAt, syncedAtEpochMillis = syncedAt,
), ),
) )
}
} }
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse = suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
@@ -96,21 +122,25 @@ class DriverSyncRepository(
suspend fun saveSyncStates(response: SyncStateResponse?) { suspend fun saveSyncStates(response: SyncStateResponse?) {
response?.scopes.orEmpty().forEach { scope -> response?.scopes.orEmpty().forEach { scope ->
dao.upsertSyncState( saveSyncScope(scope)
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 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() { suspend fun clearCache() {
dao.clearBootstrap() dao.clearBootstrap()
dao.clearRoutes() dao.clearRoutes()
@@ -142,15 +172,29 @@ class DriverSyncRepository(
} }
private fun Throwable.isReadFallbackAllowed(): Boolean = 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 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 { companion object {
const val SCOPE_ROUTES = "routes" const val SCOPE_ROUTES = "routes"
const val SCOPE_ROUTE_DETAIL = "route_detail" const val SCOPE_ROUTE_DETAIL = "route_detail"
const val SCOPE_SETTINGS = "settings" const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet" const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
const val SCOPE_LEAVE_REQUESTS = "leave_requests" 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 private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val isOnline: Flow<Boolean> = callbackFlow { val isOnline: Flow<Boolean> = callbackFlow {
fun current(): Boolean = connectivityManager.activeNetwork fun current(): Boolean = isCurrentlyValidated()
?.let(connectivityManager::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
val callback = object : ConnectivityManager.NetworkCallback() { val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
@@ -35,4 +33,17 @@ class NetworkMonitor(context: Context) {
connectivityManager.registerDefaultNetworkCallback(callback) connectivityManager.registerDefaultNetworkCallback(callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) } awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged() }.distinctUntilChanged()
fun isCurrentlyValidated(): Boolean = connectivityManager.activeNetwork
?.let(connectivityManager::getNetworkCapabilities)
?.let(::hasValidatedInternet) == true
} }
internal fun hasValidatedInternet(capabilities: NetworkCapabilities): Boolean =
hasValidatedInternetCapabilities(
hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
hasValidated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
)
internal fun hasValidatedInternetCapabilities(hasInternet: Boolean, hasValidated: Boolean): Boolean =
hasInternet && hasValidated
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.withTimeoutOrNull
import pl.firmatpp.kierowca.domain.StartupSessionPolicy
class StartupOfflineFallbackLoader(
private val timeoutMillis: Long = StartupSessionPolicy.OFFLINE_FALLBACK_MILLIS,
) {
suspend fun <T> load(
onlineLoad: suspend () -> T,
offlineLoad: suspend () -> T?,
): T {
val onlineValue = withTimeoutOrNull(timeoutMillis) {
onlineLoad()
}
if (onlineValue != null) {
return onlineValue
}
return offlineLoad()
?: throw IOException(
"Serwer nie odpowiedział w ciągu ${StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS} sekund " +
"i w telefonie nie ma zapisanych danych offline.",
)
}
}
@@ -14,6 +14,15 @@ interface DispatchSheetUploadDao {
@Query("SELECT * FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId LIMIT 1") @Query("SELECT * FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId LIMIT 1")
suspend fun find(clientRequestId: String): DispatchSheetUploadEntity? 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( @Query(
""" """
SELECT * FROM dispatch_sheet_uploads SELECT * FROM dispatch_sheet_uploads
@@ -63,4 +72,7 @@ interface DispatchSheetUploadDao {
@Query("DELETE FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId") @Query("DELETE FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String) suspend fun delete(clientRequestId: String)
@Query("DELETE FROM dispatch_sheet_uploads")
suspend fun deleteAll()
} }
@@ -63,12 +63,26 @@ class DispatchSheetUploadOutbox(
} }
suspend fun discard(clientRequestId: String) { suspend fun discard(clientRequestId: String) {
workManager.cancelUniqueWork(DispatchSheetUploadWorker.uniqueWorkName(clientRequestId))
dao.find(clientRequestId)?.let { upload -> dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete() File(upload.localPath).delete()
dao.delete(upload.clientRequestId) dao.delete(upload.clientRequestId)
} }
} }
suspend fun retry(clientRequestId: String) {
val existing = dao.find(clientRequestId) ?: return
dao.upsert(
existing.copy(
status = PhotoUploadStatus.Pending.storageValue,
progress = 0,
lastError = null,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)
enqueueWorker(clientRequestId)
}
private fun enqueueWorker(clientRequestId: String) { private fun enqueueWorker(clientRequestId: String) {
val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>() val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>()
.setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId)) .setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
@@ -8,7 +8,9 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
class DispatchSheetUploadWorker( class DispatchSheetUploadWorker(
appContext: Context, appContext: Context,
@@ -18,6 +20,7 @@ class DispatchSheetUploadWorker(
private val repository = DriverRepository(appContext) private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure() val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure()
val upload = dao.find(clientRequestId) ?: return Result.failure() val upload = dao.find(clientRequestId) ?: return Result.failure()
val file = File(upload.localPath) val file = File(upload.localPath)
@@ -62,8 +65,11 @@ class DispatchSheetUploadWorker(
} }
dao.markConfirmed(clientRequestId, receipt.serverPhotoId) dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
file.delete()
dao.delete(clientRequestId)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
DriverRouteCacheEntity::class, DriverRouteCacheEntity::class,
DriverSyncStateEntity::class, DriverSyncStateEntity::class,
], ],
version = 5, version = 8,
exportSchema = false, exportSchema = false,
) )
abstract class DriverDatabase : RoomDatabase() { abstract class DriverDatabase : RoomDatabase() {
@@ -164,13 +164,75 @@ 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)")
}
}
private val migration6To7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE route_actions ADD COLUMN notes TEXT")
}
}
private val migration7To8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowVersion TEXT")
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowStepId TEXT")
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowDocumentId TEXT")
db.execSQL("ALTER TABLE route_actions ADD COLUMN workflowVersion TEXT")
db.execSQL("ALTER TABLE route_actions ADD COLUMN completedStepIdsJson TEXT NOT NULL DEFAULT '[]'")
}
}
fun get(context: Context): DriverDatabase = fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
context.applicationContext, context.applicationContext,
DriverDatabase::class.java, DriverDatabase::class.java,
"driver-local-outbox.db", "driver-local-outbox.db",
).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5).build().also { instance = it } ).addMigrations(
migration1To2,
migration2To3,
migration3To4,
migration4To5,
migration5To6,
migration6To7,
migration7To8,
).build().also { instance = it }
} }
} }
} }
@@ -0,0 +1,109 @@
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 repairOperationalQueues() {
val photoOutbox = PhotoUploadOutbox(context)
val dispatchSheetOutbox = DispatchSheetUploadOutbox(context)
val routeActionOutbox = RouteActionOutbox(context)
database.photoUploadDao().allUnsent()
.filter { shouldRepairPhotoUpload(it.status, it.lastError) }
.forEach { photoOutbox.retry(it.clientRequestId) }
database.dispatchSheetUploadDao().allUnsent()
.filter { shouldRepairPhotoUpload(it.status, it.lastError) }
.forEach { dispatchSheetOutbox.retry(it.clientRequestId) }
database.routeActionDao().allUnsent()
.filter { shouldRepairRouteAction(it.status, it.lastError) }
.forEach { routeActionOutbox.retry(it.clientActionId) }
}
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()
}
}
internal fun isWorkerCancellationFailure(lastError: String?): Boolean =
lastError?.let { error ->
error.contains("Job was cancelled", ignoreCase = true) ||
error.contains("CancellationException", ignoreCase = true)
} == true
internal fun isPhotoDependencyFailure(lastError: String?): Boolean =
lastError?.contains("Wymagane zdjęcie nie zostało wysłane", ignoreCase = true) == true
internal fun shouldRepairPhotoUpload(status: String, lastError: String?): Boolean =
status in setOf(
PhotoUploadStatus.Pending.storageValue,
PhotoUploadStatus.Uploading.storageValue,
PhotoUploadStatus.Verifying.storageValue,
PhotoUploadStatus.FailedRetryable.storageValue,
) || (status == PhotoUploadStatus.FailedPermanent.storageValue && isWorkerCancellationFailure(lastError))
internal fun shouldRepairRouteAction(status: String, lastError: String?): Boolean =
status in setOf(
RouteActionStatus.Pending,
RouteActionStatus.WaitingForPhotos,
RouteActionStatus.Syncing,
RouteActionStatus.FailedRetryable,
) || (
status == RouteActionStatus.FailedPermanent &&
(isWorkerCancellationFailure(lastError) || isPhotoDependencyFailure(lastError))
)
@@ -43,6 +43,12 @@ interface PhotoUploadDao {
) )
suspend fun confirmedForRoute(routeId: String): List<PhotoUploadEntity> 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( @Query(
""" """
UPDATE photo_uploads UPDATE photo_uploads
@@ -82,4 +88,7 @@ interface PhotoUploadDao {
@Query("DELETE FROM photo_uploads WHERE clientRequestId = :clientRequestId") @Query("DELETE FROM photo_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String) suspend fun delete(clientRequestId: String)
@Query("DELETE FROM photo_uploads")
suspend fun deleteAll()
} }
@@ -17,6 +17,9 @@ data class PhotoUploadEntity(
val localPath: String, val localPath: String,
val source: String, val source: String,
val stage: String = "other", val stage: String = "other",
val workflowVersion: String? = null,
val workflowStepId: String? = null,
val workflowDocumentId: String? = null,
val takenAt: String?, val takenAt: String?,
val latitude: Double?, val latitude: Double?,
val longitude: Double?, val longitude: Double?,
@@ -13,9 +13,12 @@ import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class PhotoUploadOutbox( class PhotoUploadOutbox(
private val context: Context, private val context: Context,
@@ -28,12 +31,22 @@ class PhotoUploadOutbox(
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> = fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> =
dao.observeQueuedUploads() dao.observeQueuedUploads()
suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata, stage: String = "other"): PhotoUploadEntity { suspend fun enqueue(
routeId: String,
uri: Uri,
source: String,
metadata: PhotoUploadMetadata,
stage: String = "other",
workflowVersion: String? = null,
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): PhotoUploadEntity = withContext(Dispatchers.IO) {
val clientRequestId = UUID.randomUUID().toString() val clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) { val extension = when (mimeType) {
"image/png" -> "png" "image/png" -> "png"
"image/webp" -> "webp" "image/webp" -> "webp"
"application/pdf" -> "pdf"
else -> "jpg" else -> "jpg"
} }
val uploadDir = File(context.filesDir, "photo-upload-outbox").apply { mkdirs() } val uploadDir = File(context.filesDir, "photo-upload-outbox").apply { mkdirs() }
@@ -45,6 +58,9 @@ class PhotoUploadOutbox(
localPath = localFile.absolutePath, localPath = localFile.absolutePath,
source = source, source = source,
stage = stage, stage = stage,
workflowVersion = workflowVersion,
workflowStepId = workflowStepId,
workflowDocumentId = workflowDocumentId,
takenAt = metadata.takenAt, takenAt = metadata.takenAt,
latitude = metadata.latitude, latitude = metadata.latitude,
longitude = metadata.longitude, longitude = metadata.longitude,
@@ -57,7 +73,7 @@ class PhotoUploadOutbox(
dao.upsert(upload) dao.upsert(upload)
enqueueWorker(clientRequestId) enqueueWorker(clientRequestId)
return upload upload
} }
suspend fun retry(clientRequestId: String) { suspend fun retry(clientRequestId: String) {
@@ -74,6 +90,7 @@ class PhotoUploadOutbox(
} }
suspend fun discard(clientRequestId: String) { suspend fun discard(clientRequestId: String) {
workManager.cancelUniqueWork(PhotoUploadWorker.uniqueWorkName(clientRequestId))
dao.find(clientRequestId)?.let { upload -> dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete() File(upload.localPath).delete()
dao.delete(upload.clientRequestId) dao.delete(upload.clientRequestId)
@@ -81,10 +98,13 @@ class PhotoUploadOutbox(
} }
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) { suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
dao.confirmedForRoute(route.id).forEach { upload -> dao.allRecords()
File(upload.localPath).delete() .filter { it.routeId == route.id }
dao.delete(upload.clientRequestId) .filter { upload ->
} upload.status == PhotoUploadStatus.Confirmed.storageValue ||
route.photos.orEmpty().any { serverPhoto -> serverPhotoConfirmsUpload(upload, serverPhoto) }
}
.forEach { upload -> discard(upload.clientRequestId) }
} }
private fun enqueueWorker(clientRequestId: String) { private fun enqueueWorker(clientRequestId: String) {
@@ -118,3 +138,9 @@ class PhotoUploadOutbox(
return digest.digest().joinToString("") { "%02x".format(it) } return digest.digest().joinToString("") { "%02x".format(it) }
} }
} }
internal fun serverPhotoConfirmsUpload(upload: PhotoUploadEntity, serverPhoto: RoutePhotoDto): Boolean =
serverPhoto.clientRequestId == upload.clientRequestId && (
serverPhoto.contentSha256.isNullOrBlank() ||
serverPhoto.contentSha256.equals(upload.contentSha256, ignoreCase = true)
)
@@ -8,16 +8,22 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
class PhotoUploadWorker( class PhotoUploadWorker(
appContext: Context, appContext: Context,
params: WorkerParameters, params: WorkerParameters,
) : CoroutineWorker(appContext, params) { ) : 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) private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure() val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure()
val upload = dao.find(clientRequestId) ?: return Result.failure() val upload = dao.find(clientRequestId) ?: return Result.failure()
val file = File(upload.localPath) val file = File(upload.localPath)
@@ -62,8 +68,10 @@ class PhotoUploadWorker(
} }
dao.markConfirmed(clientRequestId, receipt.serverPhotoId) dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
enqueueRouteActionsWaitingForPhoto(clientRequestId)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -90,6 +98,12 @@ class PhotoUploadWorker(
} }
} }
private suspend fun enqueueRouteActionsWaitingForPhoto(clientRequestId: String) {
routeActionDao.waitingForPhotosActions()
.filter { routeActionReferencesPhotoClientRequest(it, clientRequestId) }
.forEach { routeActionOutbox.enqueueWorker(it.clientActionId) }
}
companion object { companion object {
const val KEY_CLIENT_REQUEST_ID = "clientRequestId" const val KEY_CLIENT_REQUEST_ID = "clientRequestId"
const val KEY_PROGRESS = "progress" const val KEY_PROGRESS = "progress"
@@ -14,6 +14,76 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1") @Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity? suspend fun find(clientActionId: String): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'start'
AND createdAtEpochMillis < :beforeEpochMillis
ORDER BY createdAtEpochMillis DESC
LIMIT 1
""",
)
suspend fun latestStartBefore(routeId: String, beforeEpochMillis: Long): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis > :afterEpochMillis
AND status != 'CONFIRMED'
ORDER BY createdAtEpochMillis ASC
""",
)
suspend fun unconfirmedFinishesAfter(routeId: String, afterEpochMillis: Long): List<RouteActionEntity>
@Query(
"""
SELECT EXISTS(
SELECT 1 FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND status = 'CONFIRMED'
AND createdAtEpochMillis > :afterEpochMillis
)
""",
)
suspend fun hasConfirmedFinishAfter(routeId: String, afterEpochMillis: Long): Boolean
@Query(
"""
UPDATE route_actions
SET status = 'CONFIRMED',
lastError = NULL,
updatedAtEpochMillis = :updatedAt
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis < :beforeEpochMillis
AND status != 'CONFIRMED'
""",
)
suspend fun confirmSupersededFinishes(
routeId: String,
beforeEpochMillis: Long,
updatedAt: Long = System.currentTimeMillis(),
)
@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( @Query(
""" """
SELECT * FROM route_actions SELECT * FROM route_actions
@@ -30,12 +30,15 @@ data class RouteActionEntity(
@PrimaryKey val clientActionId: String, @PrimaryKey val clientActionId: String,
val routeId: String, val routeId: String,
val action: String, val action: String,
val weight: Double, val weight: Double?,
val occurredAt: String, val occurredAt: String,
val photoClientRequestIdsJson: String, val photoClientRequestIdsJson: String,
val workflowVersion: String? = null,
val completedStepIdsJson: String = "[]",
val status: String = RouteActionStatus.Pending, val status: String = RouteActionStatus.Pending,
val attemptCount: Int = 0, val attemptCount: Int = 0,
val lastError: String? = null, val lastError: String? = null,
val createdAtEpochMillis: Long = System.currentTimeMillis(), val createdAtEpochMillis: Long = System.currentTimeMillis(),
val updatedAtEpochMillis: Long = System.currentTimeMillis(), val updatedAtEpochMillis: Long = System.currentTimeMillis(),
val notes: String? = null,
) )
@@ -26,18 +26,53 @@ class RouteActionOutbox(
fun observeVisibleActions(): Flow<List<RouteActionEntity>> = fun observeVisibleActions(): Flow<List<RouteActionEntity>> =
dao.observeVisibleActions() dao.observeVisibleActions()
suspend fun enqueueStart(routeId: String, loadingWeight: Double, photoClientRequestIds: List<String>): RouteActionEntity = suspend fun discard(clientActionId: String) {
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds) workManager.cancelUniqueWork(RouteActionWorker.uniqueWorkName(clientActionId))
dao.delete(clientActionId)
}
suspend fun enqueueFinish(routeId: String, unloadingWeight: Double, photoClientRequestIds: List<String>): RouteActionEntity = suspend fun retry(clientActionId: String) {
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds) val existing = dao.find(clientActionId) ?: return
dao.upsert(
existing.copy(
status = RouteActionStatus.Pending,
lastError = null,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)
enqueueWorker(clientActionId)
}
suspend fun enqueueStart(
routeId: String,
loadingWeight: Double?,
photoClientRequestIds: List<String>,
notes: String? = null,
workflowVersion: String? = null,
completedStepIds: List<String> = emptyList(),
): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes, workflowVersion, completedStepIds)
suspend fun enqueueFinish(
routeId: String,
unloadingWeight: Double?,
photoClientRequestIds: List<String>,
notes: String? = null,
workflowVersion: String? = null,
completedStepIds: List<String> = emptyList(),
): RouteActionEntity =
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes, workflowVersion, completedStepIds)
private suspend fun enqueue( private suspend fun enqueue(
action: String, action: String,
routeId: String, routeId: String,
weight: Double, weight: Double?,
photoClientRequestIds: List<String>, photoClientRequestIds: List<String>,
notes: String?,
workflowVersion: String?,
completedStepIds: List<String>,
): RouteActionEntity { ): RouteActionEntity {
dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
val entity = RouteActionEntity( val entity = RouteActionEntity(
clientActionId = UUID.randomUUID().toString(), clientActionId = UUID.randomUUID().toString(),
routeId = routeId, routeId = routeId,
@@ -45,6 +80,9 @@ class RouteActionOutbox(
weight = weight, weight = weight,
occurredAt = Instant.now().toString(), occurredAt = Instant.now().toString(),
photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()), photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()),
workflowVersion = workflowVersion,
completedStepIdsJson = gson.toJson(completedStepIds.distinct()),
notes = notes?.trim()?.takeIf { it.isNotEmpty() },
) )
dao.upsert(entity) dao.upsert(entity)
@@ -53,7 +91,7 @@ class RouteActionOutbox(
return entity return entity
} }
private fun enqueueWorker(clientActionId: String) { fun enqueueWorker(clientActionId: String) {
val request = OneTimeWorkRequestBuilder<RouteActionWorker>() val request = OneTimeWorkRequestBuilder<RouteActionWorker>()
.setInputData(workDataOf(RouteActionWorker.KEY_CLIENT_ACTION_ID to clientActionId)) .setInputData(workDataOf(RouteActionWorker.KEY_CLIENT_ACTION_ID to clientActionId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .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)
}
@@ -7,10 +7,14 @@ import com.google.gson.Gson
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.sync.DriverSyncWorker import pl.firmatpp.kierowca.sync.DriverSyncWorker
@@ -26,13 +30,51 @@ class RouteActionWorker(
private val gson = Gson() private val gson = Gson()
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure() val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
val action = dao.find(clientActionId) ?: return Result.failure() val action = dao.find(clientActionId) ?: return Result.failure()
if (action.status == RouteActionStatus.Confirmed) return Result.success()
val startPrerequisite = if (action.action == RouteActionType.Finish) {
dao.latestStartBefore(action.routeId, action.createdAtEpochMillis)
} else {
null
}
when (routeActionPrerequisiteState(action, startPrerequisite)) {
RouteActionPrerequisiteState.Waiting -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Pending,
lastError = "Czekam na potwierdzenie rozpoczęcia kursu.",
attemptIncrement = 0,
)
return Result.retry()
}
RouteActionPrerequisiteState.Failed -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.FailedConflict,
lastError = "Najpierw trzeba potwierdzić rozpoczęcie kursu.",
attemptIncrement = 0,
)
return Result.failure()
}
RouteActionPrerequisiteState.Ready -> Unit
}
val photoClientRequestIds = photoClientRequestIds(action) val photoClientRequestIds = photoClientRequestIds(action)
val waitingForPhotos = photoClientRequestIds val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
.mapNotNull { photoDao.find(it) } val permanentlyFailedPhoto = photoDependencies.firstOrNull { it.status == PhotoUploadStatus.FailedPermanent.storageValue }
.any { it.status != PhotoUploadStatus.Confirmed.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) { if (waitingForPhotos) {
dao.updateStatus( dao.updateStatus(
@@ -59,11 +101,25 @@ class RouteActionWorker(
lastError = null, lastError = null,
attemptIncrement = 0, attemptIncrement = 0,
) )
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate) syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate)
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id) DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
if (action.action == RouteActionType.Start) {
val outbox = RouteActionOutbox(applicationContext)
dao.unconfirmedFinishesAfter(action.routeId, action.createdAtEpochMillis)
.forEach { outbox.enqueueWorker(it.clientActionId) }
}
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
val serverRoute = if (error.kind == ApiErrorKind.Conflict) {
runCatching { repository.route(action.routeId).route }.getOrNull()
} else {
null
}
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
operation = "route_action_worker", operation = "route_action_worker",
@@ -75,8 +131,30 @@ class RouteActionWorker(
"api_error_code" to error.code, "api_error_code" to error.code,
"api_status_code" to error.statusCode, "api_status_code" to error.statusCode,
"retryable" to error.retryable, "retryable" to error.retryable,
"server_route_status" to serverRoute?.driverLifecycleStatus(),
), ),
) )
val supersededByConfirmedFinish = action.action == RouteActionType.Finish &&
dao.hasConfirmedFinishAfter(action.routeId, action.createdAtEpochMillis)
if (serverRoute != null &&
(
routeActionMatchesServer(action, photoClientRequestIds, serverRoute) ||
routeActionWasSuperseded(action, supersededByConfirmedFinish, serverRoute)
)
) {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Confirmed,
lastError = null,
attemptIncrement = 0,
)
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(serverRoute, serverRoute.routeDate)
DriverSyncWorker.enqueue(applicationContext, serverRoute.routeDate, serverRoute.id)
return@getOrElse Result.success()
}
val status = when { val status = when {
error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict
error.retryable -> RouteActionStatus.FailedRetryable error.retryable -> RouteActionStatus.FailedRetryable
@@ -85,7 +163,7 @@ class RouteActionWorker(
dao.updateStatus( dao.updateStatus(
clientActionId = clientActionId, clientActionId = clientActionId,
status = status, status = status,
lastError = error.message, lastError = routeActionConflictMessage(error.message, serverRoute),
attemptIncrement = 0, attemptIncrement = 0,
) )
@@ -102,6 +180,9 @@ class RouteActionWorker(
loadingWeight = action.weight, loadingWeight = action.weight,
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
loadingNotes = action.notes,
workflowVersion = action.workflowVersion,
completedStepIds = completedStepIds(action),
), ),
) )
RouteActionType.Finish -> repository.finishRoute( RouteActionType.Finish -> repository.finishRoute(
@@ -111,6 +192,9 @@ class RouteActionWorker(
unloadingWeight = action.weight, unloadingWeight = action.weight,
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
unloadingNotes = action.notes,
workflowVersion = action.workflowVersion,
completedStepIds = completedStepIds(action),
), ),
) )
else -> error("Nieznana akcja kursu: ${action.action}") else -> error("Nieznana akcja kursu: ${action.action}")
@@ -124,9 +208,98 @@ class RouteActionWorker(
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.distinct() .distinct()
private fun completedStepIds(action: RouteActionEntity): List<String> =
runCatching {
gson.fromJson(action.completedStepIdsJson, Array<String>::class.java)?.toList().orEmpty()
}.getOrDefault(emptyList())
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
companion object { companion object {
const val KEY_CLIENT_ACTION_ID = "clientActionId" const val KEY_CLIENT_ACTION_ID = "clientActionId"
fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId" fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId"
} }
} }
internal fun routeActionMatchesServer(
action: RouteActionEntity,
photoClientRequestIds: List<String>,
serverRoute: DriverRouteDto,
): Boolean {
val lifecycleMatches = when (action.action) {
RouteActionType.Start -> serverRoute.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA")
RouteActionType.Finish -> serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
else -> false
}
if (!lifecycleMatches) return false
val serverWeight = when (action.action) {
RouteActionType.Start -> serverRoute.loadingWeight
RouteActionType.Finish -> serverRoute.unloadingWeight
else -> null
}
val serverNotes = when (action.action) {
RouteActionType.Start -> serverRoute.loadingNotes
RouteActionType.Finish -> serverRoute.unloadingNotes
else -> null
}
val weightMatches = when {
action.weight == null && serverWeight == null -> true
action.weight == null || serverWeight == null -> false
else -> kotlin.math.abs(action.weight - serverWeight) < 0.0005
}
val notesMatch = action.notes.normalizedRouteActionNotes() == serverNotes.normalizedRouteActionNotes()
val serverPhotoRequestIds = serverRoute.photos
.mapNotNull { it.clientRequestId?.trim()?.takeIf(String::isNotBlank) }
.toSet()
val photosMatch = photoClientRequestIds.all(serverPhotoRequestIds::contains)
return weightMatches && notesMatch && photosMatch
}
internal fun routeActionWasSuperseded(
action: RouteActionEntity,
hasConfirmedFinishAfter: Boolean,
serverRoute: DriverRouteDto,
): Boolean =
action.action == RouteActionType.Finish &&
hasConfirmedFinishAfter &&
serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
internal fun routeActionConflictMessage(message: String, serverRoute: DriverRouteDto?): String {
val serverStatus = serverRoute?.driverLifecycleStatus() ?: return message
val operationalStatus = serverRoute.operationalStatus
?.takeIf(String::isNotBlank)
?.let { ", status operacyjny: $it" }
.orEmpty()
return "$message Status kursu na serwerze: $serverStatus$operationalStatus. Dane w telefonie pozostają zachowane."
}
private fun String?.normalizedRouteActionNotes(): String? =
this?.trim()?.takeIf(String::isNotEmpty)
internal enum class RouteActionPrerequisiteState {
Ready,
Waiting,
Failed,
}
internal fun routeActionPrerequisiteState(
action: RouteActionEntity,
precedingStart: RouteActionEntity?,
): RouteActionPrerequisiteState {
if (action.action != RouteActionType.Finish || precedingStart == null) {
return RouteActionPrerequisiteState.Ready
}
return when (precedingStart.status) {
RouteActionStatus.Confirmed -> RouteActionPrerequisiteState.Ready
RouteActionStatus.FailedConflict,
RouteActionStatus.FailedPermanent,
-> RouteActionPrerequisiteState.Failed
else -> RouteActionPrerequisiteState.Waiting
}
}
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface RoutePointDao { interface RoutePointDao {
@@ -14,7 +15,7 @@ interface RoutePointDao {
""" """
SELECT * FROM route_points SELECT * FROM route_points
WHERE routeId = :routeId WHERE routeId = :routeId
AND status != 'CONFIRMED' AND status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')
ORDER BY timestamp ASC ORDER BY timestamp ASC
LIMIT :limit LIMIT :limit
""", """,
@@ -25,11 +26,20 @@ interface RoutePointDao {
""" """
SELECT COUNT(*) FROM route_points SELECT COUNT(*) FROM route_points
WHERE routeId = :routeId WHERE routeId = :routeId
AND status != 'CONFIRMED' AND status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')
""", """,
) )
suspend fun pendingCount(routeId: String): Int 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( @Query(
""" """
UPDATE route_points UPDATE route_points
@@ -47,4 +57,10 @@ interface RoutePointDao {
attemptIncrement: Int, attemptIncrement: Int,
updatedAt: Long = System.currentTimeMillis(), 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,15 @@ import android.content.Context
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.delay
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.RoutePointBatchBody import pl.firmatpp.kierowca.data.model.RoutePointBatchBody
import pl.firmatpp.kierowca.data.model.RoutePointDto import pl.firmatpp.kierowca.data.model.RoutePointDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService
class RoutePointWorker( class RoutePointWorker(
@@ -20,32 +23,37 @@ class RoutePointWorker(
private val repository = DriverRepository(appContext) private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val routeId = inputData.getString(KEY_ROUTE_ID) ?: return Result.failure() val routeId = inputData.getString(KEY_ROUTE_ID) ?: return Result.failure()
val points = dao.pendingForRoute(routeId, BATCH_LIMIT) while (!isStopped) {
if (points.isEmpty()) return Result.success() val points = dao.pendingForRoute(routeId, BATCH_LIMIT)
if (points.isEmpty()) return Result.success()
val pointIds = points.map { it.clientPointId } val pointIds = points.map { it.clientPointId }
dao.updateStatus(pointIds, RoutePointStatus.Syncing, null, attemptIncrement = 1) dao.updateStatus(pointIds, RoutePointStatus.Syncing, null, attemptIncrement = 1)
return runCatching { val outcome = runCatching {
val response = repository.storeRoutePoints( repository.storeRoutePoints(
routeId = routeId, routeId = routeId,
body = RoutePointBatchBody( body = RoutePointBatchBody(
clientBatchId = UUID.randomUUID().toString(), clientBatchId = UUID.randomUUID().toString(),
points = points.map { it.toDto() }, points = points.map { it.toDto() },
), ),
) )
dao.updateStatus(pointIds, RoutePointStatus.Confirmed, null, attemptIncrement = 0)
if (response.arrived) {
ActiveRouteTrackingService.markArrived(applicationContext, routeId)
} }
if (dao.pendingCount(routeId) > 0) { val response = outcome.getOrNull()
RoutePointOutbox(applicationContext).enqueueWorker(routeId, delaySeconds = 1L) 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() val throwable = outcome.exceptionOrNull() ?: return Result.retry()
}.getOrElse { throwable -> throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -59,11 +67,20 @@ class RoutePointWorker(
"retryable" to error.retryable, "retryable" to error.retryable,
), ),
) )
if (shouldStopRouteTrackingAfterPointError(error.kind, error.code)) {
ActiveRouteTrackingService.stop(applicationContext, routeId)
}
if (isInactiveRoutePointError(error.code)) {
dao.delete(pointIds)
continue
}
val status = if (error.retryable) RoutePointStatus.FailedRetryable else RoutePointStatus.FailedPermanent val status = if (error.retryable) RoutePointStatus.FailedRetryable else RoutePointStatus.FailedPermanent
dao.updateStatus(pointIds, status, error.message, attemptIncrement = 0) dao.updateStatus(pointIds, status, error.message, attemptIncrement = 0)
return if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
} }
return Result.retry()
} }
private fun RoutePointEntity.toDto(): RoutePointDto = private fun RoutePointEntity.toDto(): RoutePointDto =
@@ -88,3 +105,8 @@ class RoutePointWorker(
fun uniqueWorkName(routeId: String): String = "route-point-sync-$routeId" fun uniqueWorkName(routeId: String): String = "route-point-sync-$routeId"
} }
} }
internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS"
internal fun shouldStopRouteTrackingAfterPointError(kind: ApiErrorKind, code: String?): Boolean =
kind == ApiErrorKind.Auth || isInactiveRoutePointError(code)
@@ -0,0 +1,359 @@
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)
val roaming = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))
} else {
"niedostępne"
}
val mtu = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
links?.mtu?.toString() ?: "brak"
} else {
"niedostępne"
}
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", 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", mtu),
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"
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.domain
import kotlin.math.roundToInt import kotlin.math.roundToInt
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
data class RouteCardDisplay( data class RouteCardDisplay(
val id: String, val id: String,
@@ -32,7 +33,7 @@ object RouteDisplayMapper {
destination = route.destinationName.ifBlank { "Cel" }, destination = route.destinationName.ifBlank { "Cel" },
contractLabel = contract, contractLabel = contract,
time = route.startsAt.ifBlank { "--:--" }, time = route.startsAt.ifBlank { "--:--" },
status = route.status, status = route.driverLifecycleStatus(),
distanceLabel = distance, distanceLabel = distance,
) )
} }
@@ -0,0 +1,51 @@
package pl.firmatpp.kierowca.domain
data class StartupSessionMessage(
val title: String,
val detail: String,
)
object StartupSessionPolicy {
const val OFFLINE_BUTTON_DELAY_SECONDS = 4
const val OFFLINE_FALLBACK_SECONDS = 15
const val OFFLINE_FALLBACK_MILLIS = OFFLINE_FALLBACK_SECONDS * 1_000L
fun remainingSeconds(elapsedSeconds: Int): Int =
(OFFLINE_FALLBACK_SECONDS - elapsedSeconds).coerceAtLeast(0)
fun shouldShowOfflineButton(elapsedSeconds: Int, offlineDataAvailable: Boolean): Boolean =
offlineDataAvailable && elapsedSeconds >= OFFLINE_BUTTON_DELAY_SECONDS
fun message(elapsedSeconds: Int): StartupSessionMessage = when {
elapsedSeconds < 3 -> StartupSessionMessage(
title = "Sprawdzamy zapisaną sesję",
detail = "Odczytujemy bezpieczne logowanie kierowcy.",
)
elapsedSeconds < 6 -> StartupSessionMessage(
title = "Potwierdzamy logowanie",
detail = "Łączymy telefon z serwerem.",
)
elapsedSeconds < 9 -> StartupSessionMessage(
title = "Pobieramy aktualne kursy",
detail = "Synchronizujemy plan dnia i ustawienia aplikacji.",
)
elapsedSeconds < 12 -> StartupSessionMessage(
title = "To trwa dłużej niż zwykle",
detail = "Przy słabym zasięgu skorzystamy z danych zapisanych w telefonie.",
)
else -> StartupSessionMessage(
title = "Przygotowujemy tryb offline",
detail = "Za chwilę pokażemy ostatnie zapisane kursy.",
)
}
fun countdownLabel(elapsedSeconds: Int): String {
val remaining = remainingSeconds(elapsedSeconds)
return if (remaining > 0) {
"Tryb offline najpóźniej za $remaining s"
} else {
"Otwieramy zapisane dane offline"
}
}
}
@@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
class DriverFirebaseMessagingService : FirebaseMessagingService() { class DriverFirebaseMessagingService : FirebaseMessagingService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -14,7 +15,7 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) { override fun onNewToken(token: String) {
scope.launch { scope.launch {
val repository = DriverRepository(applicationContext) val repository = DriverRepository(applicationContext)
if (repository.hasToken()) { if (repository.hasToken() && NetworkMonitor(applicationContext).isCurrentlyValidated()) {
runCatching { repository.storePushToken(token) } runCatching { repository.storePushToken(token) }
} }
} }
@@ -38,20 +39,26 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() {
title = data["title"], title = data["title"],
body = data["body"], body = data["body"],
) )
DriverSyncWorker.enqueue( if (!DriverRuntimeSyncState.websocketOwnsForegroundHints()) {
context = applicationContext, DriverSyncWorker.enqueue(
date = data["date"], context = applicationContext,
routeId = data["routeId"], date = data["date"],
) routeId = data["routeId"],
)
}
return return
} }
if (data["type"] != "driver_sync_hint") return if (data["type"] != "driver_sync_hint") return
if (DriverRuntimeSyncState.websocketOwnsForegroundHints()) return
DriverSyncWorker.enqueue( DriverSyncWorker.enqueue(
context = applicationContext, context = applicationContext,
date = data["date"], date = data["date"],
routeId = data["routeId"], 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.Gson
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.google.gson.JsonParser import com.google.gson.JsonParser
import java.net.URI
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -11,6 +12,9 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.WebSocket 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.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
interface DriverLiveSyncGateway { interface DriverLiveSyncGateway {
suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse
@@ -37,17 +43,34 @@ class DriverRepositoryLiveSyncGateway(
} }
interface LiveWebSocketFactory { interface LiveWebSocketFactory {
fun newWebSocket(url: String, listener: WebSocketListener): WebSocket fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket
} }
class OkHttpLiveWebSocketFactory( class OkHttpLiveWebSocketFactory(
private val client: OkHttpClient, private val client: OkHttpClient,
) : LiveWebSocketFactory { ) : LiveWebSocketFactory {
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket = override fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket =
client.newWebSocket(Request.Builder().url(url).build(), listener) client.newWebSocket(
Request.Builder()
.url(url)
.header("Origin", origin?.takeIf { it.isNotBlank() } ?: websocketOrigin(url))
.build(),
listener,
)
} }
private enum class LiveSyncConnectionState { internal fun websocketOrigin(url: String): String =
URI(
url
.replaceFirst("wss://", "https://")
.replaceFirst("ws://", "http://")
).let { uri ->
val defaultPort = (uri.scheme == "https" && uri.port == 443) || (uri.scheme == "http" && uri.port == 80)
val port = if (uri.port > 0 && !defaultPort) ":${uri.port}" else ""
"${uri.scheme}://${uri.host}$port"
}
enum class LiveSyncConnectionState {
Stopped, Stopped,
WaitingForNetwork, WaitingForNetwork,
Disconnected, Disconnected,
@@ -56,10 +79,24 @@ private enum class LiveSyncConnectionState {
Connected, 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( class DriverLiveSyncClient(
private val gateway: DriverLiveSyncGateway, private val gateway: DriverLiveSyncGateway,
private val onConnected: () -> Unit, private val onConnected: () -> Unit,
private val onHint: (DriverSyncHint) -> Unit, private val onHint: (DriverSyncHint) -> Unit,
private val onAuthenticationFailed: () -> Unit = {},
private val webSocketFactory: LiveWebSocketFactory = OkHttpLiveWebSocketFactory(defaultOkHttpClient()), private val webSocketFactory: LiveWebSocketFactory = OkHttpLiveWebSocketFactory(defaultOkHttpClient()),
private val gson: Gson = Gson(), private val gson: Gson = Gson(),
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
@@ -70,12 +107,14 @@ class DriverLiveSyncClient(
repository: DriverRepository, repository: DriverRepository,
onConnected: () -> Unit, onConnected: () -> Unit,
onHint: (DriverSyncHint) -> Unit, onHint: (DriverSyncHint) -> Unit,
onAuthenticationFailed: () -> Unit = {},
client: OkHttpClient = defaultOkHttpClient(), client: OkHttpClient = defaultOkHttpClient(),
gson: Gson = Gson(), gson: Gson = Gson(),
) : this( ) : this(
gateway = DriverRepositoryLiveSyncGateway(repository), gateway = DriverRepositoryLiveSyncGateway(repository),
onConnected = onConnected, onConnected = onConnected,
onHint = onHint, onHint = onHint,
onAuthenticationFailed = onAuthenticationFailed,
webSocketFactory = OkHttpLiveWebSocketFactory(client), webSocketFactory = OkHttpLiveWebSocketFactory(client),
gson = gson, gson = gson,
) )
@@ -104,6 +143,31 @@ class DriverLiveSyncClient(
private var staleWatchdogJob: Job? = null private var staleWatchdogJob: Job? = null
private var reconnectAttempt: Int = 0 private var reconnectAttempt: Int = 0
private var messageVersion: Long = 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?) { fun start(driverId: String, config: RealtimeConfigDto?) {
if (!isConfigUsable(config)) return if (!isConfigUsable(config)) return
@@ -119,6 +183,7 @@ class DriverLiveSyncClient(
fun ensureConnected() { fun ensureConnected() {
val shouldConnect = synchronized(lock) { val shouldConnect = synchronized(lock) {
desiredActive && desiredActive &&
foregroundActive &&
networkAvailable && networkAvailable &&
state !in setOf( state !in setOf(
LiveSyncConnectionState.Connecting, LiveSyncConnectionState.Connecting,
@@ -136,22 +201,32 @@ class DriverLiveSyncClient(
reconnectJob?.cancel() reconnectJob?.cancel()
reconnectJob = null reconnectJob = null
closeSocketLocked("network_lost") closeSocketLocked("network_lost")
state = if (desiredActive) LiveSyncConnectionState.WaitingForNetwork else LiveSyncConnectionState.Stopped updateState(if (desiredActive) LiveSyncConnectionState.WaitingForNetwork else LiveSyncConnectionState.Stopped)
false false
} else { } else {
desiredActive && state == LiveSyncConnectionState.WaitingForNetwork desiredActive && state == LiveSyncConnectionState.WaitingForNetwork
} }
} }
if (available) {
reportRealtimeStatus("reconnecting", "network_available")
} else {
reportRealtimeStatus("disconnected", "network_lost")
}
if (shouldReconnect) connectNow(resetAttempt = true) 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() { fun stop() {
synchronized(lock) { synchronized(lock) {
desiredActive = false desiredActive = false
@@ -162,7 +237,7 @@ class DriverLiveSyncClient(
realtimeConfig = null realtimeConfig = null
socketId = null socketId = null
reconnectAttempt = 0 reconnectAttempt = 0
state = LiveSyncConnectionState.Stopped updateState(LiveSyncConnectionState.Stopped)
} }
reportRealtimeStatus("disconnected", "client_stop") reportRealtimeStatus("disconnected", "client_stop")
} }
@@ -173,25 +248,29 @@ class DriverLiveSyncClient(
} }
private fun connectNow(resetAttempt: Boolean = false) { private fun connectNow(resetAttempt: Boolean = false) {
val wsUrl = synchronized(lock) { val (wsUrl, origin) = synchronized(lock) {
val config = realtimeConfig ?: return val config = realtimeConfig ?: return
val id = driverId ?: 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 (state == LiveSyncConnectionState.Connecting || state == LiveSyncConnectionState.Subscribing || state == LiveSyncConnectionState.Connected) return
if (resetAttempt) reconnectAttempt = 0 if (resetAttempt) reconnectAttempt = 0
reconnectJob?.cancel() reconnectJob?.cancel()
reconnectJob = null reconnectJob = null
socketId = null socketId = null
state = LiveSyncConnectionState.Connecting updateState(LiveSyncConnectionState.Connecting)
val appKey = config.reverbAppKey.orEmpty() val appKey = config.reverbAppKey.orEmpty()
val wsBaseUrl = config.reverbWsBaseUrl.orEmpty() val wsBaseUrl = config.reverbWsBaseUrl.orEmpty()
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false" Pair(
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false",
config.reverbOrigin,
)
} }
val socket = webSocketFactory.newWebSocket( val socket = webSocketFactory.newWebSocket(
wsUrl, wsUrl,
origin,
object : WebSocketListener() { object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text) handleMessage(webSocket, text)
@@ -227,6 +306,7 @@ class DriverLiveSyncClient(
false false
} else { } else {
messageVersion += 1 messageVersion += 1
lastMessageAtEpochMillis = System.currentTimeMillis()
true true
} }
} }
@@ -244,7 +324,7 @@ class DriverLiveSyncClient(
val nextSocketId = root.dataObject()?.string("socket_id") ?: return val nextSocketId = root.dataObject()?.string("socket_id") ?: return
synchronized(lock) { synchronized(lock) {
socketId = nextSocketId socketId = nextSocketId
state = LiveSyncConnectionState.Subscribing updateState(LiveSyncConnectionState.Subscribing)
} }
subscribe(socket, nextSocketId) subscribe(socket, nextSocketId)
} }
@@ -254,7 +334,7 @@ class DriverLiveSyncClient(
if (channel == "private-driver-mobile.$id") { if (channel == "private-driver-mobile.$id") {
synchronized(lock) { synchronized(lock) {
reconnectAttempt = 0 reconnectAttempt = 0
state = LiveSyncConnectionState.Connected updateState(LiveSyncConnectionState.Connected)
} }
reportRealtimeStatus("connected") reportRealtimeStatus("connected")
startHeartbeat() startHeartbeat()
@@ -272,22 +352,28 @@ class DriverLiveSyncClient(
private fun subscribe(socket: WebSocket, socketId: String) { private fun subscribe(socket: WebSocket, socketId: String) {
val id = synchronized(lock) { driverId } ?: return 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 { scope.launch {
runCatching { runCatching {
val auth = gateway.broadcastAuth(socketId, channel).auth channels.forEach { channel ->
if (!isCurrentSocket(socket)) return@launch val auth = gateway.broadcastAuth(socketId, channel).auth
val payload = mapOf( if (!isCurrentSocket(socket)) return@launch
"event" to "pusher:subscribe", val payload = mapOf(
"data" to mapOf( "event" to "pusher:subscribe",
"channel" to channel, "data" to mapOf(
"auth" to auth, "channel" to channel,
), "auth" to auth,
) ),
check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" } )
check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" }
}
}.onFailure { throwable -> }.onFailure { throwable ->
AppDiagnostics.log("realtime_subscription_error: ${throwable.message ?: throwable::class.java.simpleName}") 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") socket.close(1000, "subscription_failed")
handleDisconnect("error", "subscription_failed", socket) handleDisconnect("error", "subscription_failed", socket)
} }
@@ -302,15 +388,16 @@ class DriverLiveSyncClient(
stopStaleWatchdogLocked() stopStaleWatchdogLocked()
webSocket = null webSocket = null
socketId = null socketId = null
lastDisconnectReason = reason
if (!desiredActive) { if (!desiredActive) {
state = LiveSyncConnectionState.Stopped updateState(LiveSyncConnectionState.Stopped)
false false
} else if (!networkAvailable) { } else if (!networkAvailable) {
state = LiveSyncConnectionState.WaitingForNetwork updateState(LiveSyncConnectionState.WaitingForNetwork)
false false
} else { } else {
state = LiveSyncConnectionState.Disconnected updateState(LiveSyncConnectionState.Disconnected)
true true
} }
} }
@@ -321,7 +408,7 @@ class DriverLiveSyncClient(
private fun scheduleReconnect() { private fun scheduleReconnect() {
val delayMs = synchronized(lock) { val delayMs = synchronized(lock) {
if (!desiredActive || !networkAvailable || state == LiveSyncConnectionState.Stopped) return if (!desiredActive || !foregroundActive || !networkAvailable || state == LiveSyncConnectionState.Stopped) return
if (reconnectJob?.isActive == true) return if (reconnectJob?.isActive == true) return
val delay = reconnectDelaysMs.getOrElse(reconnectAttempt) { reconnectDelaysMs.last() } val delay = reconnectDelaysMs.getOrElse(reconnectAttempt) { reconnectDelaysMs.last() }
reconnectAttempt += 1 reconnectAttempt += 1
@@ -399,10 +486,26 @@ class DriverLiveSyncClient(
scope.launch { scope.launch {
runCatching { runCatching {
gateway.storeRealtimeStatus(status, socketId, error) 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 = private fun isConfigUsable(config: RealtimeConfigDto?): Boolean =
config?.reverbEnabled == true && config?.reverbEnabled == true &&
!config.reverbAppKey.isNullOrBlank() && !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
}
@@ -13,7 +13,10 @@ import androidx.work.workDataOf
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository 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 import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverSyncWorker( class DriverSyncWorker(
@@ -23,9 +26,20 @@ class DriverSyncWorker(
private val syncRepository = DriverSyncRepository(appContext) private val syncRepository = DriverSyncRepository(appContext)
override suspend fun doWork(): Result = override suspend fun doWork(): Result =
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) Result.retry() else
runCatching { runCatching {
val date = inputData.getString(KEY_DATE) val date = inputData.getString(KEY_DATE)
val routeId = inputData.getString(KEY_ROUTE_ID) 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) val response = syncRepository.fetchSyncState(date, routeId)
var refreshed = false var refreshed = false
@@ -52,6 +66,10 @@ class DriverSyncWorker(
syncRepository.saveSyncStates(response) syncRepository.saveSyncStates(response)
refreshed = true refreshed = true
} }
DriverSyncRepository.SCOPE_LEAVE_CALENDAR -> {
syncRepository.saveSyncStates(response)
refreshed = true
}
} }
} }
} }
@@ -62,6 +80,7 @@ class DriverSyncWorker(
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -81,17 +100,33 @@ class DriverSyncWorker(
companion object { companion object {
private const val KEY_DATE = "date" private const val KEY_DATE = "date"
private const val KEY_ROUTE_ID = "routeId" 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>() 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()) .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build() .build()
WorkManager.getInstance(context).enqueueUniqueWork( WorkManager.getInstance(context).enqueueUniqueWork(
listOf("driver-sync", date ?: "-", routeId ?: "-").joinToString("-"), listOf("driver-sync", scope ?: "all", date ?: "-", routeId ?: "-").joinToString("-"),
ExistingWorkPolicy.REPLACE, ExistingWorkPolicy.KEEP,
request, request,
) )
} }
@@ -57,7 +57,11 @@ class LeaveRequestDecisionNotificationWorker(
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build() .build()
NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification) try {
NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification)
} catch (_: SecurityException) {
return Result.success()
}
return Result.success() return Result.success()
} }
@@ -26,6 +26,8 @@ import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import retrofit2.HttpException import retrofit2.HttpException
@@ -36,6 +38,7 @@ class NewRouteNotificationWorker(
private val repository = DriverRepository(appContext) private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result { 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() val routeId = inputData.getString(KEY_ROUTE_ID)?.takeIf(String::isNotBlank) ?: return Result.success()
return runCatching { return runCatching {
@@ -70,9 +73,14 @@ class NewRouteNotificationWorker(
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build() .build()
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification) try {
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
} catch (_: SecurityException) {
return@runCatching Result.success()
}
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
if (throwable is HttpException && throwable.code() == 404) { if (throwable is HttpException && throwable.code() == 404) {
Result.success() Result.success()
} else { } else {
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.tracking package pl.firmatpp.kierowca.tracking
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -54,8 +55,17 @@ class ActiveRouteTrackingService : Service(), LocationListener {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != ACTION_STOP && !hasLocationPermission()) {
stopSelf()
return START_NOT_STICKY
}
when (intent?.action) { when (intent?.action) {
ACTION_STOP -> { ACTION_STOP -> {
val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
if (!requestedRouteId.isNullOrBlank() && routeId != requestedRouteId) {
return START_NOT_STICKY
}
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -64,13 +74,27 @@ class ActiveRouteTrackingService : Service(), LocationListener {
if (!id.isNullOrBlank() && (routeId == null || id == routeId)) { if (!id.isNullOrBlank() && (routeId == null || id == routeId)) {
routeId = id routeId = id
arrived = true arrived = true
startForegroundCompat(buildNotification()) runCatching { locationManager.removeUpdates(this) }
if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
} }
return START_STICKY return START_NOT_STICKY
} }
else -> { else -> {
routeId = intent?.getStringExtra(EXTRA_ROUTE_ID) ?: routeId val nextRouteId = intent?.getStringExtra(EXTRA_ROUTE_ID) ?: routeId
driverId = intent?.getStringExtra(EXTRA_DRIVER_ID) ?: driverId 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 routeLabel = intent?.getStringExtra(EXTRA_ROUTE_LABEL) ?: routeLabel
destinationLat = intent?.takeIf { it.hasExtra(EXTRA_DESTINATION_LAT) }?.getDoubleExtra(EXTRA_DESTINATION_LAT, 0.0) destinationLat = intent?.takeIf { it.hasExtra(EXTRA_DESTINATION_LAT) }?.getDoubleExtra(EXTRA_DESTINATION_LAT, 0.0)
?: destinationLat ?: destinationLat
@@ -80,10 +104,12 @@ class ActiveRouteTrackingService : Service(), LocationListener {
?.takeIf { it > 0.0 } ?.takeIf { it > 0.0 }
?: totalDistanceMeters ?: totalDistanceMeters
progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled
arrived = false if (routeChanged) arrived = false
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) {
requestLocationUpdates() return START_NOT_STICKY
return START_STICKY }
if (!arrived) requestLocationUpdates()
return START_NOT_STICKY
} }
} }
} }
@@ -96,9 +122,10 @@ class ActiveRouteTrackingService : Service(), LocationListener {
if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) { if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) {
arrived = true arrived = true
startForegroundCompat(buildNotification()) runCatching { locationManager.removeUpdates(this) }
if (!startForegroundCompat(buildNotification())) return
} else { } else {
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) return
} }
val (battery, charging) = batterySnapshot() val (battery, charging) = batterySnapshot()
@@ -119,14 +146,27 @@ class ActiveRouteTrackingService : Service(), LocationListener {
override fun onDestroy() { override fun onDestroy() {
runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.removeUpdates(this) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
getSystemService(NotificationManager::class.java).cancel(NOTIFICATION_ID)
getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.edit()
.remove(ACTIVE_ROUTE_ID)
.apply()
scope.cancel() scope.cancel()
super.onDestroy() super.onDestroy()
} }
@SuppressLint("MissingPermission")
private fun requestLocationUpdates() { private fun requestLocationUpdates() {
if (!hasLocationPermission()) { if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) {
return return
} }
runCatching { locationManager.removeUpdates(this) }
runCatching { runCatching {
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, LOCATION_INTERVAL_MS, 0f, this) locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, LOCATION_INTERVAL_MS, 0f, this)
} }
@@ -175,11 +215,25 @@ class ActiveRouteTrackingService : Service(), LocationListener {
return builder.build() return builder.build()
} }
private fun startForegroundCompat(notification: Notification) { private fun startForegroundCompat(notification: Notification): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && hasLocationPermission()) { if (!hasLocationPermission()) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) stopSelf()
} else { return false
startForeground(NOTIFICATION_ID, notification) }
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
} else {
startForeground(NOTIFICATION_ID, notification)
}
true
} catch (_: SecurityException) {
stopSelf()
false
} catch (_: IllegalStateException) {
stopSelf()
false
} }
} }
@@ -191,7 +245,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
"Aktywny kurs", "Aktywny kurs",
NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_LOW,
).apply { ).apply {
description = "Postęp aktywnego kursu i lokalizacja w tle" description = "Informacje o aktywnym kursie"
} }
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
@@ -266,8 +320,12 @@ class ActiveRouteTrackingService : Service(), LocationListener {
private const val EXTRA_DESTINATION_LON = "destinationLon" private const val EXTRA_DESTINATION_LON = "destinationLon"
private const val EXTRA_TOTAL_DISTANCE_METERS = "totalDistanceMeters" private const val EXTRA_TOTAL_DISTANCE_METERS = "totalDistanceMeters"
private const val EXTRA_PROGRESS_ENABLED = "progressEnabled" 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) { fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) {
if (!hasLocationPermission(context)) return
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_START action = ACTION_START
putExtra(EXTRA_ROUTE_ID, route.id) putExtra(EXTRA_ROUTE_ID, route.id)
@@ -278,23 +336,36 @@ class ActiveRouteTrackingService : Service(), LocationListener {
route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) } route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) }
route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) } route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) }
} }
ContextCompat.startForegroundService(context, intent) runCatching { ContextCompat.startForegroundService(context, intent) }
} }
fun stop(context: Context) { fun stop(context: Context, routeId: String? = null) {
context.startService( val activeRouteId = context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
Intent(context, ActiveRouteTrackingService::class.java).apply { .getString(ACTIVE_ROUTE_ID, null)
action = ACTION_STOP 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) { fun markArrived(context: Context, routeId: String) {
if (!hasLocationPermission(context)) return
val activeRouteId = context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.getString(ACTIVE_ROUTE_ID, null)
if (activeRouteId != routeId) return
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_MARK_ARRIVED action = ACTION_MARK_ARRIVED
putExtra(EXTRA_ROUTE_ID, routeId) putExtra(EXTRA_ROUTE_ID, routeId)
} }
ContextCompat.startForegroundService(context, intent) runCatching { ContextCompat.startForegroundService(context, intent) }
} }
private fun hasLocationPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,24 +1,37 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import java.time.LocalDate import java.time.LocalDate
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeavePoolDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
import pl.firmatpp.kierowca.data.model.LeaveRequestTypeDto
data class LeaveRequestsConfig( data class LeaveRequestsConfig(
val enabled: Boolean = false, val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"), val types: List<String> = listOf("URLOP"),
) )
data class AnnualLeaveTotals(
val availableMinutes: Int,
val reservedMinutes: Int,
val usedMinutes: Int,
val grantedMinutes: Int,
val overdueMinutes: Int,
)
object DriverLeaveRequestUiRules { object DriverLeaveRequestUiRules {
fun isFeatureVisible(config: LeaveRequestsConfig?): Boolean = fun isFeatureVisible(config: LeaveRequestsConfig?): Boolean =
config?.enabled == true && config.types.isNotEmpty() config?.enabled == true && config.types.isNotEmpty()
fun statusLabel(status: String): String = fun statusLabel(status: String): String =
when (status) { when (status) {
"pending" -> "Oczekuje" "pending" -> "Czeka na decyzję"
"approved" -> "Zatwierdzony" "approved" -> "Zaakceptowany"
"rejected" -> "Odrzucony" "rejected" -> "Odrzucony"
"cancel_requested" -> "Anulowanie do decyzji" "cancel_requested" -> "Czeka na anulowanie"
"cancelled" -> "Anulowany" "cancelled" -> "Anulowany"
"revoked" -> "Cofnięto decyzję" "revoked" -> "Akceptacja cofnięta"
else -> "Nieznany status" else -> "Nieznany status"
} }
@@ -32,19 +45,245 @@ object DriverLeaveRequestUiRules {
"cancellation_requested" -> "Kierowca poprosił o anulowanie" "cancellation_requested" -> "Kierowca poprosił o anulowanie"
"cancellation_approved" -> "Anulowanie zatwierdzone" "cancellation_approved" -> "Anulowanie zatwierdzone"
"cancellation_rejected" -> "Anulowanie odrzucone" "cancellation_rejected" -> "Anulowanie odrzucone"
"period_amended" -> "Zmieniono termin wniosku"
"interrupted" -> "Nieobecność została przerwana"
"annual_leave_interrupted" -> "Przywrócono urlop wypoczynkowy"
else -> "Aktualizacja wniosku" else -> "Aktualizacja wniosku"
} }
fun typeLabel(type: String): String = fun typeLabel(type: String): String =
when (type) { when (type) {
"URLOP" -> "Urlop" "URLOP", "ANNUAL" -> "Urlop wypoczynkowy"
"CHOROBOWE" -> "Chorobowe" "ANNUAL_ON_DEMAND" -> "Urlop na żądanie"
"SZKOLENIE" -> "Szkolenie" "ANNUAL_DISABILITY" -> "Dodatkowy urlop"
"CHOROBOWE", "SICKNESS" -> "Niezdolność do pracy"
"SZKOLENIE", "TRAINING" -> "Urlop szkoleniowy"
"WOLNE" -> "Dzień wolny" "WOLNE" -> "Dzień wolny"
"INNE" -> "Inne" "INNE", "OTHER_OPERATIONAL" -> "Inna nieobecność"
"FORCE_MAJEURE" -> "Siła wyższa"
"CHILD_CARE_14" -> "Opieka nad dzieckiem do 14 lat"
"CARE_LEAVE" -> "Urlop opiekuńczy"
"MATERNITY" -> "Urlop macierzyński"
"MATERNITY_EQUIVALENT" -> "Urlop na warunkach macierzyńskiego"
"MATERNITY_SUPPLEMENTARY" -> "Uzupełniający urlop macierzyński"
"PARENTAL" -> "Urlop rodzicielski"
"PATERNITY" -> "Urlop ojcowski"
"CHILDCARE_UNPAID" -> "Urlop wychowawczy"
"OCCASIONAL" -> "Zwolnienie okolicznościowe"
"REHABILITATION" -> "Turnus rehabilitacyjny"
"UNPAID" -> "Urlop bezpłatny"
"PREGNANCY_EXAMS" -> "Badania w ciąży"
"OCCUPATIONAL_EXAMS" -> "Badania pracownicze"
"BLOOD_DONATION" -> "Zwolnienie krwiodawcy"
"AUTHORITY_SUMMONS" -> "Wezwanie urzędowe"
"RESCUE_DUTIES" -> "Obowiązki ratownicze"
"MILITARY_DUTIES" -> "Obowiązki wojskowe"
"ORGAN_DONOR_EXAMS" -> "Badania dawcy"
"CARE_SICKNESS" -> "Opieka nad chorym dzieckiem lub członkiem rodziny"
else -> type else -> type
} }
fun requestType(request: DriverLeaveRequestDto): String =
request.typeLabel?.takeIf { it.isNotBlank() }
?: request.rule?.label?.takeIf { it.isNotBlank() }
?: typeLabel(request.ruleCode ?: request.type)
fun annualTotals(summary: DriverLeaveSummaryDto?): AnnualLeaveTotals {
val annualPools = summary?.pools.orEmpty().filter {
it.poolCode == "ANNUAL" && it.unit == "minutes" && it.status == "active"
}
return AnnualLeaveTotals(
availableMinutes = annualPools.sumOf { it.availableQuantity },
reservedMinutes = annualPools.sumOf { it.reservedQuantity },
usedMinutes = annualPools.sumOf { it.usedQuantity },
grantedMinutes = annualPools.sumOf { it.grantedQuantity },
overdueMinutes = annualPools.filter { it.year < LocalDate.now().year }.sumOf { it.availableQuantity },
)
}
fun annualPools(summary: DriverLeaveSummaryDto?): List<DriverLeavePoolDto> =
summary?.pools.orEmpty()
.filter { it.poolCode == "ANNUAL" && it.status == "active" }
.sortedByDescending { it.year }
fun isUsageVisible(summary: DriverLeaveSummaryDto?, poolCode: String): Boolean =
summary?.visibleUsagePoolCodes?.contains(poolCode) ?: true
fun otherEntitlementPools(summary: DriverLeaveSummaryDto?): List<DriverLeavePoolDto> =
summary?.pools.orEmpty()
.filter { it.poolCode != "ANNUAL" && it.status == "active" }
.sortedWith(compareBy<DriverLeavePoolDto>({ poolLabel(it.poolCode) }, { -it.year }))
fun poolLabel(poolCode: String): String =
when (poolCode) {
"ANNUAL" -> "Urlop wypoczynkowy"
"ANNUAL_DISABILITY" -> "Dodatkowy urlop wypoczynkowy"
"FORCE_MAJEURE" -> "Siła wyższa"
"CHILD_CARE_14" -> "Opieka nad dzieckiem do 14 lat"
"CARE_LEAVE" -> "Urlop opiekuńczy"
else -> typeLabel(poolCode)
}
fun requestQuantityUnit(request: DriverLeaveRequestDto): String {
request.quantityUnit?.takeIf { it == "days" || it == "minutes" }?.let { return it }
if (request.requestMode == "hours") return "minutes"
if (request.rule?.unit == "calendar_days") return "days"
val poolCode = request.rule?.poolCode
return if (
request.requestMode == "days" &&
poolCode != null &&
poolCode !in setOf("ANNUAL", "ANNUAL_DISABILITY")
) {
"days"
} else {
"minutes"
}
}
fun allocationStatusLabel(status: String): String =
when (status) {
"reserved" -> "Uwzględniono w przyszłej nieobecności"
"used" -> "Wykorzystano"
"released" -> "Przywrócono do wykorzystania"
else -> "Rozliczono"
}
fun categoryLabel(category: String?): String =
when (category) {
"annual" -> "Urlop wypoczynkowy"
"statutory_short" -> "Krótkie uprawnienia ustawowe"
"family" -> "Rodzina i opieka"
"parental" -> "Rodzicielstwo"
"health" -> "Zdrowie i rehabilitacja"
"training" -> "Nauka i szkolenia"
"statutory_exemption" -> "Pozostałe zwolnienia ustawowe"
"unpaid" -> "Nieobecności bezpłatne"
"operational" -> "Pozostałe nieobecności"
else -> "Inne rodzaje nieobecności"
}
fun formatQuantity(quantity: Int, unit: String, dailyNormMinutes: Int = 480): String {
if (unit == "days") return dayCountLabel(quantity)
val safeNorm = dailyNormMinutes.coerceAtLeast(1)
val fullDays = quantity / safeNorm
val remainingMinutes = quantity % safeNorm
val hours = quantity / 60
val minutes = quantity % 60
val dayPart = when {
fullDays == 0 && remainingMinutes == 0 -> "0 dni"
remainingMinutes == 0 -> dayCountLabel(fullDays)
fullDays == 0 -> "mniej niż 1 dzień"
else -> "${dayCountLabel(fullDays)} + ${formatHours(remainingMinutes)}"
}
return "$dayPart · ${hours} godz.${if (minutes > 0) " ${minutes} min" else ""}"
}
fun formatHours(minutes: Int): String {
val hours = minutes / 60
val remainder = minutes % 60
return when {
hours == 0 -> "$remainder min"
remainder == 0 -> "$hours godz."
else -> "$hours godz. $remainder min"
}
}
fun dayCountLabel(days: Int): String {
val suffix = when {
days == 1 -> "dzień"
days % 10 in 2..4 && days % 100 !in 12..14 -> "dni"
else -> "dni"
}
return "$days $suffix"
}
fun modeLabel(mode: String): String = if (mode == "hours") "Kilka godzin" else "Całe dni"
fun decisionExplanation(type: LeaveRequestTypeDto): String =
when (type.decisionMode) {
"mandatory_registration" -> "Po spełnieniu wymaganych warunków wniosek jest rejestrowany zgodnie z przepisami."
"external_event" -> "Nieobecność wymaga potwierdzenia odpowiednim dokumentem."
else -> "Wniosek zostanie przekazany do osoby, która podejmie decyzję."
}
fun annualEntitlementImpact(impact: String?): String? =
when (impact) {
"proportional_after_one_month" ->
"Nie pomniejszy puli od razu, ale okres trwający co najmniej miesiąc może zmniejszyć roczny wymiar urlopu po powrocie do pracy."
"proportional_on_return_if_started_before_year" ->
"Po powrocie z urlopu rozpoczętego w poprzednim roku wymiar urlopu wypoczynkowego zostanie naliczony proporcjonalnie."
else -> null
}
fun fieldLabel(field: String): String =
when (field) {
"childBirthDate" -> "Data urodzenia dziecka"
"settlementMode" -> "Sposób wykorzystania w tym roku"
"careRecipientName" -> "Imię i nazwisko osoby wymagającej opieki"
"careRecipientRelationship" -> "Kim jest dla Ciebie ta osoba"
"careReason" -> "Dlaczego potrzebna jest opieka"
"careRecipientAddress" -> "Adres osoby wymagającej opieki"
"childrenBornCount" -> "Liczba dzieci urodzonych przy jednym porodzie"
"entitlementEventKey" -> "Opis zdarzenia"
"partNumber" -> "Która to część urlopu"
"childIdentityKey" -> "Imię dziecka"
"occasionType" -> "Rodzaj zdarzenia rodzinnego"
"examType" -> "Rodzaj egzaminu lub nauki"
"educationProgramKey" -> "Nazwa szkoły lub programu"
"classification" -> "Czego dotyczy nieobecność"
"urgentFamilyReason" -> "Jaka pilna sprawa rodzinna wymaga Twojej obecności?"
"childrenPlacedCount" -> "Liczba dzieci przyjętych na wychowanie"
"placementType" -> "Sposób przyjęcia dziecka na wychowanie"
"hospitalizationGroup" -> "Która sytuacja hospitalizacji dotyczy dziecka?"
"eligibleHospitalDays" -> "Liczba dni hospitalizacji potwierdzona przez szpital"
"maternityLeaveEndsAt" -> "Data zakończenia urlopu macierzyńskiego"
"parentalEntitlementVariant" -> "Wymiar urlopu rodzicielskiego"
"childcareEntitlementVariant" -> "Rodzaj urlopu wychowawczego"
"careRecipientType" -> "Kogo dotyczy opieka?"
"careEntitlementEventKey" -> "Opis okresu opieki"
else -> "Dodatkowa informacja"
}
fun fieldHint(field: String): String? =
when (field) {
"childBirthDate" -> "Wpisz datę w formacie RRRR-MM-DD, np. 2020-05-12"
"entitlementEventKey" -> "Np. poród z 15.06.2026"
"partNumber" -> "Np. 1"
"childIdentityKey" -> "Wystarczy imię i rok urodzenia"
"educationProgramKey" -> "Np. Technikum Transportowe w Poznaniu"
"classification" -> "Krótko opisz powód, aby kadry mogły go prawidłowo zakwalifikować"
"urgentFamilyReason" -> "Krótko opisz chorobę lub wypadek oraz dlaczego Twoja natychmiastowa obecność jest konieczna."
"eligibleHospitalDays" -> "Wpisz liczbę z zaświadczenia, np. 12"
"careEntitlementEventKey" -> "Wpisz prosty opis pozwalający kadrom połączyć części tego samego uprawnienia."
else -> null
}
fun documentLabel(type: String): String =
when (type) {
"disability_certificate" -> "Orzeczenie o niepełnosprawności"
"parent_declaration" -> "Oświadczenie rodzica"
"birth_document", "birth_or_adoption_document" -> "Dokument potwierdzający urodzenie lub przysposobienie"
"adoption_or_care_document" -> "Dokument przysposobienia lub przyjęcia na wychowanie"
"hospitalization_certificate" -> "Zaświadczenie o hospitalizacji"
"parental_leave_declaration", "childcare_leave_declaration" -> "Oświadczenie dotyczące urlopu rodzicielskiego"
"event_document" -> "Dokument potwierdzający zdarzenie"
"training_confirmation" -> "Potwierdzenie nauki lub egzaminu"
"rehabilitation_referral" -> "Skierowanie na turnus rehabilitacyjny"
"medical_referral", "medical_referral_or_confirmation" -> "Skierowanie lub potwierdzenie badania"
"blood_donation_certificate" -> "Zaświadczenie o oddaniu krwi"
"authority_summons" -> "Wezwanie urzędowe"
"rescue_service_confirmation" -> "Potwierdzenie służby ratowniczej"
"military_document" -> "Dokument wojskowy"
"external_sickness_confirmation" -> "Potwierdzenie niezdolności do pracy"
"other_parent_declaration" -> "Oświadczenie drugiego rodzica"
"organ_donor_confirmation" -> "Potwierdzenie badań lub zabiegu dawcy"
"external_care_confirmation" -> "Potwierdzenie opieki lub e-ZLA"
else -> "Wymagany dokument"
}
fun canCancel(status: String, dateFrom: String, today: LocalDate = LocalDate.now()): Boolean { fun canCancel(status: String, dateFrom: String, today: LocalDate = LocalDate.now()): Boolean {
if (status == "pending") return true if (status == "pending") return true
if (status != "approved") return false if (status != "approved") return false
@@ -52,4 +291,41 @@ object DriverLeaveRequestUiRules {
val start = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return false val start = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return false
return start.isAfter(today) 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 package pl.firmatpp.kierowca.ui
import pl.firmatpp.kierowca.data.model.DriverRouteDto 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.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
@@ -21,13 +22,20 @@ internal val routeActionVisibleStatuses = setOf(
RouteActionStatus.FailedPermanent, RouteActionStatus.FailedPermanent,
) )
private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed private val routeActionDataProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
private val routeActionLifecycleProjectionStatuses = setOf(
RouteActionStatus.Pending,
RouteActionStatus.WaitingForPhotos,
RouteActionStatus.Syncing,
RouteActionStatus.FailedRetryable,
RouteActionStatus.Confirmed,
)
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute { fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses } .filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
val projectable = actions val projectable = actions
.filter { it.routeId == route.id && it.status in routeActionProjectionStatuses } .filter { it.routeId == route.id && it.status in routeActionDataProjectionStatuses }
var projectedRoute = route var projectedRoute = route
var loadingWeightPending = false var loadingWeightPending = false
var unloadingWeightPending = false var unloadingWeightPending = false
@@ -38,19 +46,33 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
when (action.action) { when (action.action) {
RouteActionType.Start -> { RouteActionType.Start -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
loadingWeight = action.weight, loadingWeight = action.weight,
trackingStatus = "active", loadingNotes = action.notes,
photos = projectedRoute.safePhotos(),
) )
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
trackingStatus = "active",
)
}
loadingWeightPending = action.status != RouteActionStatus.Confirmed loadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
RouteActionType.Finish -> { RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
unloadingWeight = action.weight, unloadingWeight = action.weight,
trackingStatus = "finished", unloadingNotes = action.notes,
completedAt = action.occurredAt, photos = projectedRoute.safePhotos(),
) )
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
trackingStatus = "finished",
completedAt = action.occurredAt,
)
}
unloadingWeightPending = action.status != RouteActionStatus.Confirmed unloadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
} }
@@ -71,3 +93,5 @@ fun projectDriverRoutes(routes: List<DriverRouteDto>, actions: List<RouteActionE
fun routeActionNeedsAttention(status: String): Boolean = fun routeActionNeedsAttention(status: String): Boolean =
status == RouteActionStatus.FailedConflict || status == RouteActionStatus.FailedPermanent status == RouteActionStatus.FailedConflict || status == RouteActionStatus.FailedPermanent
internal fun DriverRouteDto.safePhotos(): List<RoutePhotoDto> = photos.orEmpty()
@@ -1,24 +1,154 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import java.io.File import java.io.File
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlin.math.abs
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowStepDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.TachographReminderTaskDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
import pl.firmatpp.kierowca.data.upload.isWorkerCancellationFailure
import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM") private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
private val routeWeightKilogramInputThreshold = BigDecimal("1000")
private const val routeWeightMaxTons = 999.999
const val routeStageNotesMaxLength = 2000
private const val googleMapsPackage = "com.google.android.apps.maps"
data class NavigationIntentCandidate(
val uri: String,
val packageName: String? = null,
)
data class AppUpdateBannerUi(
val message: String,
val actionLabel: String,
val required: Boolean,
val downloaded: Boolean,
)
object RouteLocationMode {
const val Disabled = "disabled"
const val Optional = "optional"
const val Required = "required"
}
fun normalizeRouteLocationMode(value: String?): String = when (value) {
RouteLocationMode.Disabled,
RouteLocationMode.Required,
-> value
else -> RouteLocationMode.Optional
}
fun shouldTrackRouteLocation(
mode: String,
optionalLocationEnabled: Boolean,
hasLocationPermission: Boolean,
): Boolean = hasLocationPermission && when (normalizeRouteLocationMode(mode)) {
RouteLocationMode.Disabled -> false
RouteLocationMode.Required -> true
else -> optionalLocationEnabled
}
fun routeStartRequiresLocationPermission(
mode: String,
hasLocationPermission: Boolean,
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required && !hasLocationPermission
fun shouldWarnAboutRequiredRouteLocation(
mode: String,
hasActiveRoute: Boolean,
hasLocationPermission: Boolean,
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required &&
hasActiveRoute &&
!hasLocationPermission
fun appUpdateBannerUi(
currentVersionCode: Int,
latestVersionCode: Int?,
latestVersionName: String?,
minimumSupportedVersionCode: Int,
playUpdateAvailable: Boolean,
playAvailableVersionCode: Int?,
updateDownloaded: Boolean,
): AppUpdateBannerUi? {
val highestAvailableVersion = listOfNotNull(latestVersionCode, playAvailableVersionCode).maxOrNull()
val required = minimumSupportedVersionCode > 0 && currentVersionCode < minimumSupportedVersionCode
val available = updateDownloaded ||
playUpdateAvailable ||
highestAvailableVersion?.let { it > currentVersionCode } == true ||
required
if (!available) return null
val versionLabel = latestVersionName
?.takeIf { it.isNotBlank() }
?.let { " $it" }
.orEmpty()
val message = when {
updateDownloaded -> "Aktualizacja$versionLabel jest pobrana i gotowa do instalacji."
required -> "Ta wersja aplikacji nie jest już obsługiwana. Zaktualizuj ją, aby kontynuować."
else -> "Dostępna jest nowa wersja aplikacji$versionLabel."
}
return AppUpdateBannerUi(
message = message,
actionLabel = if (updateDownloaded) "Zainstaluj" else "Aktualizuj",
required = required,
downloaded = updateDownloaded,
)
}
fun hasValidNavigationCoordinates(latitude: Double?, longitude: Double?): Boolean =
latitude != null &&
longitude != null &&
latitude.isFinite() &&
longitude.isFinite() &&
latitude in -90.0..90.0 &&
longitude in -180.0..180.0
fun navigationIntentCandidates(
latitude: Double?,
longitude: Double?,
label: String?,
): List<NavigationIntentCandidate> {
if (!hasValidNavigationCoordinates(latitude, longitude)) return emptyList()
val coordinates = "$latitude,$longitude"
val encodedLabel = URLEncoder
.encode(label?.takeIf { it.isNotBlank() } ?: "Cel", Charsets.UTF_8.name())
.replace("+", "%20")
return listOf(
NavigationIntentCandidate(
uri = "google.navigation:q=$coordinates",
packageName = googleMapsPackage,
),
NavigationIntentCandidate(uri = "geo:0,0?q=$coordinates($encodedLabel)"),
NavigationIntentCandidate(uri = "https://www.google.com/maps/dir/?api=1&destination=$coordinates"),
)
}
enum class RouteFlowStepState { enum class RouteFlowStepState {
Todo, Todo,
LocalComplete, ServerSaved,
InProgress,
Syncing, Syncing,
Confirmed, Confirmed,
NeedsAttention, NeedsAttention,
@@ -34,6 +164,7 @@ data class RouteFlowStepUi(
val title: String, val title: String,
val state: RouteFlowStepState, val state: RouteFlowStepState,
val stateLabel: String, val stateLabel: String,
val dateLabel: String? = null,
) )
data class RouteLifecycleActionUi( data class RouteLifecycleActionUi(
@@ -44,48 +175,275 @@ data class RouteLifecycleActionUi(
data class RouteSyncCalloutUi( data class RouteSyncCalloutUi(
val text: String, val text: String,
val isConflict: Boolean, val isConflict: Boolean,
val canCorrect: Boolean,
) )
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean = fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false) 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.driverLifecycleStatus() == "ZAKOŃCZONA" -> "Kurs zakończony."
loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek."
today == loadingDate -> "Dziś: załadunek."
today == unloadingDate && route.driverLifecycleStatus() == "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( fun canCompleteRouteFromDriverApp(
route: DriverRouteDto, route: DriverRouteDto,
selectedDate: String, selectedDate: String,
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): Boolean = ): Boolean =
canManageRoutePhotos(selectedDate, today) runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE") && routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
&& route.driverLifecycleStatus() in setOf("ZAPLANOWANA", "W TRAKCIE")
fun canStartRouteFromDriverApp( fun canStartRouteFromDriverApp(
route: DriverRouteDto, route: DriverRouteDto,
selectedDate: String, selectedDate: String,
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): Boolean = ): Boolean =
canManageRoutePhotos(selectedDate, today) routeIsActiveToday(route, selectedDate, today)
&& route.status == "ZAPLANOWANA" && route.driverLifecycleStatus() == "ZAPLANOWANA"
fun canFinishRouteFromDriverApp( fun canFinishRouteFromDriverApp(
route: DriverRouteDto, route: DriverRouteDto,
selectedDate: String, selectedDate: String,
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): Boolean = ): Boolean =
canManageRoutePhotos(selectedDate, today) route.driverLifecycleStatus() == "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 { object RouteStageRequirement {
return routeStageSubmitBlocker(weightText, serverPhotoCount, localUploadCount) == null 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
object RouteWorkflowStepType {
const val Confirmation = "confirmation"
const val Weight = "weight"
const val Photos = "photos"
}
fun routeWorkflowSteps(workflow: DriverRouteWorkflowDto?, stage: String): List<DriverRouteWorkflowStepDto> =
when (stage) {
"loading" -> workflow?.loading?.steps
"unloading" -> workflow?.unloading?.steps
else -> null
}.orEmpty().filter { routeStageRequirementIsVisible(it.requirement) }
fun routeWorkflowSubmitBlocker(
workflow: DriverRouteWorkflowDto?,
stage: String,
completedStepIds: Set<String>,
weightText: String,
serverPhotos: List<RoutePhotoDto>,
localUploads: List<PhotoUploadEntity>,
): String? {
val steps = routeWorkflowSteps(workflow, stage)
if (steps.isEmpty()) return null
val normalizedWeight = parseRouteWeightTons(weightText)
for (step in steps) {
val required = routeStageRequirementIsRequired(step.requirement)
when (step.type) {
RouteWorkflowStepType.Confirmation -> if (required && step.id !in completedStepIds) {
return "Potwierdź krok: ${step.title}."
}
RouteWorkflowStepType.Weight -> when {
required && weightText.isBlank() -> return "Uzupełnij krok: ${step.title}."
weightText.isNotBlank() && normalizedWeight == null -> return "Podaj poprawny tonaż."
weightText.isNotBlank() && normalizedWeight != null && normalizedWeight <= 0.0 -> return "Tonaż musi być większy od zera."
weightText.isNotBlank() && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> return "Tonaż nie może przekraczać 999,999 t."
}
RouteWorkflowStepType.Photos -> if (
required &&
serverPhotos.none { it.workflowDocumentId.isNullOrBlank() } &&
localUploads.none {
it.workflowDocumentId.isNullOrBlank() &&
it.status != PhotoUploadStatus.Cancelled.storageValue &&
it.status != PhotoUploadStatus.FailedPermanent.storageValue
}
) {
return "Dodaj zdjęcie dla kroku: ${step.title}."
}
}
for (document in step.documents.filter { routeStageRequirementIsRequired(it.requirement) }) {
val serverPresent = serverPhotos.any {
it.workflowStepId == step.id && it.workflowDocumentId == document.id
}
val localPresent = localUploads.any {
it.workflowStepId == step.id &&
it.workflowDocumentId == document.id &&
it.status != PhotoUploadStatus.Cancelled.storageValue &&
it.status != PhotoUploadStatus.FailedPermanent.storageValue
}
if (!serverPresent && !localPresent) {
return "Dodaj dokument „${document.label}” w kroku „${step.title}”."
}
}
}
return null
}
fun normalizeRouteStageNotes(value: String): String = value.take(routeStageNotesMaxLength)
fun parseRouteWeightTons(weightText: String): Double? {
val enteredWeight = weightText.trim().replace(',', '.').toBigDecimalOrNull() ?: return null
val tons = if (enteredWeight.abs() >= routeWeightKilogramInputThreshold) {
enteredWeight.movePointLeft(3)
} else {
enteredWeight
}
return tons.setScale(3, RoundingMode.HALF_UP).toDouble()
}
fun routeWeightInputHelpText(weightText: String): String {
val enteredWeight = weightText.trim().replace(',', '.').toDoubleOrNull()
val tons = parseRouteWeightTons(weightText)
val enteredAsKilograms = enteredWeight != null &&
abs(enteredWeight) >= routeWeightKilogramInputThreshold.toDouble() &&
tons != null
return if (enteredAsKilograms) {
"Wpisano kilogramy — zapiszemy ${String.format(Locale("pl", "PL"), "%.3f", tons)} t."
} else {
"Wpisz tony (np. 23,4). Wartości od 1000 rozpoznajemy jako kilogramy (np. 23400)."
}
}
fun canSubmitRouteStageForm(
weightText: String,
serverPhotoCount: Int,
localUploadCount: Int,
weightRequirement: String = RouteStageRequirement.Required,
photoRequirement: String = RouteStageRequirement.Required,
): Boolean {
return routeStageSubmitBlocker(
weightText,
serverPhotoCount,
localUploadCount,
weightRequirement,
photoRequirement,
) == null
}
fun loadingPhotosSubmitBlocker(
serverPhotoCount: Int,
localUploadCount: Int,
photoRequirement: String = RouteStageRequirement.Required,
): String? =
if (
routeStageRequirementIsRequired(photoRequirement) &&
visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0
) {
"Dodaj co najmniej jedno zdjęcie załadunku."
} else {
null
}
fun loadingWeightSubmitBlocker(
weightText: String,
serverPhotoCount: Int,
localUploadCount: Int,
weightRequirement: String = RouteStageRequirement.Required,
photoRequirement: String = RouteStageRequirement.Required,
): String? {
val trimmed = weightText.trim() val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() val normalizedWeight = parseRouteWeightTons(trimmed)
val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when { return when {
trimmed.isBlank() -> "Podaj wagę." weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
normalizedWeight == null -> "Podaj poprawną wagę." weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
normalizedWeight <= 0.0 -> "Waga musi być większa od zera." weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu." weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
else -> null
}
}
fun routeStageSubmitBlocker(
weightText: String,
serverPhotoCount: Int,
localUploadCount: Int,
weightRequirement: String = RouteStageRequirement.Required,
photoRequirement: String = RouteStageRequirement.Required,
): String? {
val trimmed = weightText.trim()
val normalizedWeight = parseRouteWeightTons(trimmed)
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."
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
else -> null else -> null
} }
} }
@@ -96,8 +454,8 @@ fun routeLifecyclePrimaryAction(
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): RouteLifecycleActionUi? = ): RouteLifecycleActionUi? =
when { when {
canStartRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Rozpocznij kurs", RouteLifecycleAction.Start) canStartRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Załaduj", RouteLifecycleAction.Start)
canFinishRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Zakończ kurs", RouteLifecycleAction.Finish) canFinishRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Rozładuj", RouteLifecycleAction.Finish)
else -> null else -> null
} }
@@ -114,46 +472,68 @@ fun routeSyncCallout(actions: List<RouteActionEntity>): RouteSyncCalloutUi? {
} else { } else {
when (unresolved.status) { when (unresolved.status) {
RouteActionStatus.WaitingForPhotos -> "Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu." 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." 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, 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 startOverride = routeActionStepState(actions, RouteActionType.Start)
val finishOverride = routeActionStepState(actions, RouteActionType.Finish) val finishOverride = routeActionStepState(actions, RouteActionType.Finish)
val loadingState = startOverride ?: when { val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed val driverStatus = route.driverLifecycleStatus()
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed val loadingPhotosState = when {
route.loadingWeight != null -> RouteFlowStepState.LocalComplete startOverride != null -> startOverride
driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
loadingPhotoCount > 0 -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo
}
val loadingWeightState = startOverride ?: when {
driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
val transitState = when { val transitState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed finishOverride != null -> RouteFlowStepState.InProgress
finishOverride == RouteFlowStepState.NeedsAttention -> RouteFlowStepState.LocalComplete driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
finishOverride == RouteFlowStepState.Syncing -> RouteFlowStepState.LocalComplete startOverride != null -> startOverride
route.status == "W TRAKCIE" -> RouteFlowStepState.LocalComplete driverStatus == "W TRAKCIE" -> RouteFlowStepState.InProgress
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
val unloadingState = finishOverride ?: when { val unloadingState = finishOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.unloadingWeight != null -> RouteFlowStepState.LocalComplete route.unloadingWeight != null -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
return listOf( return buildList {
RouteFlowStepUi("loading", "Załadunek", loadingState, routeFlowStateLabel(loadingState)), if (routeStageRequirementIsVisible(loadingPhotoRequirement)) {
RouteFlowStepUi("transit", "W trasie", transitState, routeFlowStateLabel(transitState)), add(RouteFlowStepUi("loading_photos", "Zdjęcia załadunku", loadingPhotosState, routeFlowStateLabel(loadingPhotosState), routeScheduleDateLabel(route.routeDate)))
RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState)), }
) 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) val normalized = normalizedRoutePhotoStage(stage)
return photos.filter { normalizedRoutePhotoStage(it.stage) == normalized } return photos.orEmpty().filter { normalizedRoutePhotoStage(it.stage) == normalized }
} }
fun normalizedRoutePhotoStage(stage: String?): String = fun normalizedRoutePhotoStage(stage: String?): String =
@@ -196,7 +576,8 @@ private fun routeActionStepState(actions: List<RouteActionEntity>, actionType: S
private fun routeFlowStateLabel(state: RouteFlowStepState): String = private fun routeFlowStateLabel(state: RouteFlowStepState): String =
when (state) { when (state) {
RouteFlowStepState.Todo -> "do zrobienia" RouteFlowStepState.Todo -> "do zrobienia"
RouteFlowStepState.LocalComplete -> "uzupełnione lokalnie" RouteFlowStepState.ServerSaved -> "zapisane na serwerze"
RouteFlowStepState.InProgress -> "w trakcie"
RouteFlowStepState.Syncing -> "synchronizacja" RouteFlowStepState.Syncing -> "synchronizacja"
RouteFlowStepState.Confirmed -> "potwierdzone" RouteFlowStepState.Confirmed -> "potwierdzone"
RouteFlowStepState.NeedsAttention -> "wymaga uwagi" RouteFlowStepState.NeedsAttention -> "wymaga uwagi"
@@ -290,6 +671,18 @@ fun routeFactColumns(screenWidthDp: Int): Int =
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int = fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0) serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
fun photoUploadCanSatisfyStage(status: String): Boolean =
status in setOf(
PhotoUploadStatus.Pending.storageValue,
PhotoUploadStatus.Uploading.storageValue,
PhotoUploadStatus.Verifying.storageValue,
PhotoUploadStatus.Confirmed.storageValue,
PhotoUploadStatus.FailedRetryable.storageValue,
)
fun routeStageUsableUploads(uploads: List<PhotoUploadEntity>): List<PhotoUploadEntity> =
uploads.filter { photoUploadCanSatisfyStage(it.status) }
fun queuedPhotoUploadCount(statuses: List<String>): Int = fun queuedPhotoUploadCount(statuses: List<String>): Int =
statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" } statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" }
@@ -301,8 +694,15 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? = fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() } serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
fun canRetryPhotoUpload(status: String): Boolean = fun canRetryPhotoUpload(status: String, lastError: String? = null): Boolean =
status == "FAILED_RETRYABLE" status == PhotoUploadStatus.FailedRetryable.storageValue ||
(status == PhotoUploadStatus.FailedPermanent.storageValue && isWorkerCancellationFailure(lastError))
fun canDiscardLocalPhotoUpload(status: String, serverPhotoId: String?): Boolean =
serverPhotoId.isNullOrBlank() && status in setOf(
PhotoUploadStatus.FailedRetryable.storageValue,
PhotoUploadStatus.FailedPermanent.storageValue,
)
fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean = fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean =
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank() status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
@@ -330,12 +730,30 @@ fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
) )
} }
fun routeScreenAfterServerRefresh(currentScreen: DriverScreen, route: DriverRouteDto): DriverScreen =
when {
currentScreen in setOf(DriverScreen.StartRoute, DriverScreen.LoadingWeight) &&
route.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA") -> DriverScreen.Detail
currentScreen == DriverScreen.FinishRoute && route.driverLifecycleStatus() == "ZAKOŃCZONA" -> DriverScreen.Detail
else -> currentScreen
}
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean = fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
!requirePreciseLocation || hasFineLocation !requirePreciseLocation || hasFineLocation
fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean = fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean =
reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded") reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded")
fun shouldShowTachographReminderCard(reminder: TachographReminderDto?): Boolean =
reminder?.enabled == true &&
reminder.visible &&
!reminder.title.isNullOrBlank() &&
!reminder.message.isNullOrBlank() &&
!reminder.tasks.isNullOrEmpty()
fun defaultTachographConfirmationSelection(tasks: List<TachographReminderTaskDto>): Set<String> =
if (tasks.size == 1) setOf(tasks.single().id) else emptySet()
fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String = fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String =
when { when {
hasQueuedUpload -> "Wysyłam zdjęcie..." hasQueuedUpload -> "Wysyłam zdjęcie..."
@@ -352,6 +770,21 @@ fun offlineStaleBannerMessage(isOnline: Boolean, isStale: Boolean, syncLabel: St
else -> "Dane mogą być nieaktualne. Ostatnia synchronizacja: $syncLabel." 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? = private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let { value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull() runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,337 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.StopCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import pl.firmatpp.kierowca.ui.theme.TppTheme
internal data class VoiceDictationUiState(
val isListening: Boolean = false,
val message: String = "Możesz wpisać uwagę albo podyktować ją po polsku.",
val isError: Boolean = false,
)
internal fun mergeDictationText(existingText: String, recognizedText: String): String {
val existing = existingText.trimEnd()
val recognized = recognizedText.trim()
if (recognized.isBlank()) return normalizeRouteStageNotes(existingText)
return normalizeRouteStageNotes(
if (existing.isBlank()) recognized else "$existing $recognized",
)
}
@Composable
internal fun VoiceNotesField(
value: String,
onValueChange: (String) -> Unit,
stageLabel: String,
enabled: Boolean,
onListeningChanged: (Boolean) -> Unit = {},
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var dictationState by remember { mutableStateOf(VoiceDictationUiState()) }
val currentValue by rememberUpdatedState(value)
val currentOnValueChange by rememberUpdatedState(onValueChange)
val controller = remember(context) { AndroidLiveVoiceDictation(context.applicationContext) }
val startDictation = {
controller.start(
existingText = currentValue,
onText = currentOnValueChange,
onState = { dictationState = it },
)
}
val microphonePermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
startDictation()
} else {
dictationState = VoiceDictationUiState(
message = "Brak dostępu do mikrofonu. Możesz nadal wpisać uwagę ręcznie.",
isError = true,
)
}
}
LaunchedEffect(dictationState.isListening) {
onListeningChanged(dictationState.isListening)
}
DisposableEffect(controller) {
onDispose {
onListeningChanged(false)
controller.destroy()
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedTextField(
value = value,
onValueChange = { currentOnValueChange(normalizeRouteStageNotes(it)) },
label = { Text("Uwagi do $stageLabel (opcjonalnie)") },
placeholder = { Text("Np. oczekiwanie, uszkodzenie, problem z dokumentami…") },
supportingText = {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Zapisujemy tylko tekst uwagi.")
Text("${value.length}/$routeStageNotesMaxLength")
}
},
readOnly = dictationState.isListening,
enabled = enabled,
minLines = 3,
maxLines = 6,
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = {
if (dictationState.isListening) {
controller.stop()
} else if (
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
) {
startDictation()
} else {
microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
enabled = enabled,
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp),
) {
Icon(
imageVector = if (dictationState.isListening) Icons.Outlined.StopCircle else Icons.Outlined.Mic,
contentDescription = null,
)
Text(
text = if (dictationState.isListening) "Zakończ dyktowanie" else "Dyktuj uwagę",
modifier = Modifier.padding(start = 10.dp),
fontWeight = FontWeight.Bold,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (dictationState.isListening) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = TppTheme.colors.forest,
)
}
Text(
text = dictationState.message,
color = if (dictationState.isError) Color(0xFFB42318) else TppTheme.colors.muted,
)
}
}
}
private class AndroidLiveVoiceDictation(
private val context: Context,
) : RecognitionListener {
private var recognizer: SpeechRecognizer? = null
private var prefix: String = ""
private var onText: ((String) -> Unit)? = null
private var onState: ((VoiceDictationUiState) -> Unit)? = null
private var listening = false
fun start(
existingText: String,
onText: (String) -> Unit,
onState: (VoiceDictationUiState) -> Unit,
) {
if (! SpeechRecognizer.isRecognitionAvailable(context)) {
onState(
VoiceDictationUiState(
message = "Na tym telefonie nie ma usługi rozpoznawania mowy. Wpisz uwagę ręcznie.",
isError = true,
),
)
return
}
this.prefix = existingText
this.onText = onText
this.onState = onState
if (recognizer == null) {
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also {
it.setRecognitionListener(this)
}
}
listening = true
emitState("Uruchamiam mikrofon…")
runCatching {
recognizer?.startListening(recognitionIntent())
}.onFailure {
listening = false
emitState("Nie udało się uruchomić dyktowania. Spróbuj ponownie.", isError = true)
}
}
fun stop() {
if (! listening) return
emitState("Kończę i sprawdzam rozpoznany tekst…")
recognizer?.stopListening()
}
fun destroy() {
listening = false
onText = null
onState = null
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
override fun onReadyForSpeech(params: Bundle?) {
emitState("Słucham… Mów normalnie. Rozpoznany tekst pojawia się wyżej na żywo.")
}
override fun onBeginningOfSpeech() {
emitState("Rozpoznaję mowę na żywo…")
}
override fun onRmsChanged(rmsdB: Float) = Unit
override fun onBufferReceived(buffer: ByteArray?) = Unit
override fun onEndOfSpeech() {
emitState("Sprawdzam rozpoznany tekst…")
}
override fun onError(error: Int) {
listening = false
emitState(speechRecognitionErrorMessage(error), isError = true)
}
override fun onResults(results: Bundle?) {
val recognized = bestRecognitionResult(results)
if (recognized.isNotBlank()) {
onText?.invoke(mergeDictationText(prefix, recognized))
}
listening = false
emitState(
if (recognized.isBlank()) {
"Nie wykryto tekstu. Naciśnij „Dyktuj uwagę” i spróbuj ponownie."
} else {
"Rozpoznano: „${recognized.take(160)}”. Możesz teraz poprawić tekst."
},
isError = recognized.isBlank(),
)
}
override fun onPartialResults(partialResults: Bundle?) {
val recognized = bestRecognitionResult(partialResults)
if (recognized.isBlank()) return
onText?.invoke(mergeDictationText(prefix, recognized))
emitState("Na żywo: „${recognized.take(160)}")
}
override fun onEvent(eventType: Int, params: Bundle?) = Unit
private fun recognitionIntent(): Intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "pl-PL")
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, false)
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
putExtra(
RecognizerIntent.EXTRA_ENABLE_FORMATTING,
RecognizerIntent.FORMATTING_OPTIMIZE_LATENCY,
)
putExtra(RecognizerIntent.EXTRA_HIDE_PARTIAL_TRAILING_PUNCTUATION, true)
}
}
private fun bestRecognitionResult(bundle: Bundle?): String =
bundle
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
?.firstOrNull()
.orEmpty()
.trim()
private fun emitState(message: String, isError: Boolean = false) {
onState?.invoke(
VoiceDictationUiState(
isListening = listening,
message = message,
isError = isError,
),
)
}
}
internal fun speechRecognitionErrorMessage(error: Int): String = when (error) {
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ->
"Brak dostępu do mikrofonu. Możesz wpisać uwagę ręcznie."
SpeechRecognizer.ERROR_SPEECH_TIMEOUT ->
"Nie usłyszałem mowy. Naciśnij „Dyktuj uwagę” i spróbuj ponownie."
SpeechRecognizer.ERROR_NO_MATCH ->
"Nie rozpoznano słów. Mów wyraźnie i spróbuj ponownie."
SpeechRecognizer.ERROR_NETWORK,
SpeechRecognizer.ERROR_NETWORK_TIMEOUT ->
"Rozpoznawanie mowy nie ma teraz połączenia. Spróbuj ponownie lub wpisz uwagę."
SpeechRecognizer.ERROR_RECOGNIZER_BUSY ->
"Mikrofon jest teraz zajęty. Odczekaj chwilę i spróbuj ponownie."
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED,
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE ->
"Polskie rozpoznawanie mowy nie jest dostępne na tym telefonie."
else ->
"Nie udało się rozpoznać mowy. Spróbuj ponownie lub wpisz uwagę ręcznie."
}
@@ -0,0 +1,288 @@
package pl.firmatpp.kierowca.ui
import android.content.Context
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.view.CameraController
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.compose.LocalLifecycleOwner
import java.io.File
import kotlinx.coroutines.launch
import pl.firmatpp.kierowca.ui.theme.TppTheme
@Composable
internal fun MultiplePhotoCameraScreen(
stage: String,
onPhotoCaptured: suspend (Uri) -> Unit,
onClose: () -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val coroutineScope = rememberCoroutineScope()
val currentOnPhotoCaptured by rememberUpdatedState(onPhotoCaptured)
val cameraController = remember(context) {
LifecycleCameraController(context).apply {
setEnabledUseCases(CameraController.IMAGE_CAPTURE)
imageCaptureMode = ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
}
}
var capturedCount by rememberSaveable(stage) { mutableIntStateOf(0) }
var pendingUploads by remember { mutableIntStateOf(0) }
var isCapturing by remember { mutableStateOf(false) }
var cameraUnavailable by remember { mutableStateOf(false) }
var cameraError by remember { mutableStateOf<String?>(null) }
DisposableEffect(cameraController, lifecycleOwner) {
runCatching {
cameraController.bindToLifecycle(lifecycleOwner)
}.onFailure {
cameraUnavailable = true
cameraError = "Nie udało się uruchomić aparatu."
}
onDispose {
cameraController.unbind()
}
}
BackHandler {
if (!isCapturing && pendingUploads == 0) onClose()
}
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
AndroidView(
factory = { previewContext ->
PreviewView(previewContext).apply {
controller = cameraController
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FILL_CENTER
}
},
modifier = Modifier.fillMaxSize(),
)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.background(Color.Black.copy(alpha = 0.62f))
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onClose,
enabled = !isCapturing && pendingUploads == 0,
) {
Icon(
Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Wróć",
tint = Color.White,
)
}
Column {
Text(
text = if (stage == "loading") "Zdjęcia załadunku" else "Zdjęcia rozładunku",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = if (pendingUploads > 0) {
"Wykonano: $capturedCount · zapisuję: $pendingUploads"
} else {
"Wykonano: $capturedCount"
},
color = Color.White.copy(alpha = 0.78f),
style = MaterialTheme.typography.bodyMedium,
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.background(Color.Black.copy(alpha = 0.68f))
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
cameraError?.let { message ->
Text(
text = message,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF8C1D18), RoundedCornerShape(6.dp))
.padding(10.dp),
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(72.dp),
) {
Button(
onClick = {
if (isCapturing || cameraUnavailable) return@Button
val photoFile = createMultipleCameraFile(context)
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
isCapturing = true
cameraController.takePicture(
outputOptions,
ContextCompat.getMainExecutor(context),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
isCapturing = false
cameraError = null
capturedCount += 1
pendingUploads += 1
coroutineScope.launch {
try {
currentOnPhotoCaptured(cameraFileUri(context, photoFile))
} finally {
pendingUploads = (pendingUploads - 1).coerceAtLeast(0)
}
}
}
override fun onError(exception: ImageCaptureException) {
isCapturing = false
photoFile.delete()
cameraError = "Nie udało się zapisać zdjęcia. Spróbuj ponownie."
}
},
)
},
enabled = !isCapturing && !cameraUnavailable,
modifier = Modifier
.align(Alignment.Center)
.size(72.dp),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
disabledContainerColor = Color.White.copy(alpha = 0.55f),
),
contentPadding = ButtonDefaults.ContentPadding,
) {
if (isCapturing) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = TppTheme.colors.forest,
strokeWidth = 3.dp,
)
} else {
Icon(
Icons.Outlined.CameraAlt,
contentDescription = "Zrób zdjęcie",
tint = TppTheme.colors.forest,
modifier = Modifier.size(32.dp),
)
}
}
Button(
onClick = onClose,
enabled = !isCapturing && pendingUploads == 0,
modifier = Modifier
.align(Alignment.CenterEnd)
.width(104.dp)
.height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = TppTheme.colors.forest,
),
shape = RoundedCornerShape(6.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
) {
if (pendingUploads > 0) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Icon(
Icons.Outlined.CheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp),
)
}
Text(
text = if (pendingUploads > 0) "Zapisuję" else "Gotowe",
color = Color.White,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Clip,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
}
}
private fun createMultipleCameraFile(context: Context): File {
val directory = File(context.cacheDir, "camera").apply { mkdirs() }
return File.createTempFile("ladunek-", ".jpg", directory)
}
private fun cameraFileUri(context: Context, file: File): Uri =
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
@@ -0,0 +1,45 @@
package pl.firmatpp.kierowca.ui
class OtpAttemptCoordinator {
private var currentAttemptId: Long = 0L
private var bufferedCode: String? = null
fun beginAttempt(): Long {
currentAttemptId += 1L
bufferedCode = null
return currentAttemptId
}
fun isCurrent(attemptId: Long): Boolean = attemptId == currentAttemptId
fun bufferRetrievedCode(attemptId: Long, code: String): Boolean {
if (!isCurrent(attemptId) || !isRetrievedOtpCode(code)) return false
bufferedCode = code
return true
}
fun takeBufferedCode(attemptId: Long): String? {
if (!isCurrent(attemptId)) return null
return bufferedCode.also { bufferedCode = null }
}
fun discard(attemptId: Long) {
if (isCurrent(attemptId)) bufferedCode = null
}
}
internal fun sanitizeOtpCode(code: String): String =
code.filter(Char::isDigit).take(OTP_MAX_LENGTH)
internal fun isRetrievedOtpCode(code: String): Boolean =
code.length in OTP_MIN_LENGTH..OTP_MAX_LENGTH && code.all(Char::isDigit)
internal fun otpResendDelaySeconds(availableAtEpochMillis: Long, nowEpochMillis: Long): Int {
val remainingMillis = (availableAtEpochMillis - nowEpochMillis).coerceAtLeast(0L)
return ((remainingMillis + 999L) / 1_000L).toInt()
}
internal const val OTP_MIN_LENGTH = 4
internal const val OTP_DEFAULT_LENGTH = 6
internal const val OTP_MAX_LENGTH = 10
internal const val OTP_RESEND_COOLDOWN_MILLIS = 30_000L
@@ -4,14 +4,20 @@ class OtpAutoSubmitPolicy {
private var lastSubmittedCode: String? = null private var lastSubmittedCode: String? = null
fun shouldSubmit(code: String, loading: Boolean): Boolean { fun shouldSubmit(code: String, loading: Boolean): Boolean {
if (code.length < OTP_LENGTH) { return shouldSubmitCompleteCode(code, loading, code.length == OTP_DEFAULT_LENGTH)
}
fun shouldSubmitRetrieved(code: String, loading: Boolean): Boolean {
return shouldSubmitCompleteCode(code, loading, isRetrievedOtpCode(code))
}
private fun shouldSubmitCompleteCode(code: String, loading: Boolean, complete: Boolean): Boolean {
if (!complete) {
lastSubmittedCode = null lastSubmittedCode = null
return false return false
} }
if (loading || code.length != OTP_LENGTH || code == lastSubmittedCode) { if (loading || code == lastSubmittedCode) return false
return false
}
lastSubmittedCode = code lastSubmittedCode = code
return true return true
@@ -20,8 +26,4 @@ class OtpAutoSubmitPolicy {
fun reset() { fun reset() {
lastSubmittedCode = null lastSubmittedCode = null
} }
private companion object {
const val OTP_LENGTH = 6
}
} }
@@ -0,0 +1,97 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.CancellationSignal
import android.os.Looper
import androidx.core.content.ContextCompat
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
private const val PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS = 5_000L
internal suspend fun currentPhotoLocation(context: Context): Location? {
if (!hasAnyLocationPermission(context)) return null
val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
val hasFineLocation = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
val providers = buildList {
if (hasFineLocation) add(LocationManager.GPS_PROVIDER)
add(LocationManager.NETWORK_PROVIDER)
}.filter { provider -> runCatching { manager.isProviderEnabled(provider) }.getOrDefault(false) }
for (provider in providers) {
val location = withTimeoutOrNull(PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS) {
manager.awaitSingleLocation(context, provider)
}
if (location != null) return location
}
return bestLastKnownLocation(manager)
}
@SuppressLint("MissingPermission")
private suspend fun LocationManager.awaitSingleLocation(
context: Context,
provider: String,
): Location? = suspendCancellableCoroutine { continuation ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val cancellationSignal = CancellationSignal()
continuation.invokeOnCancellation { cancellationSignal.cancel() }
runCatching {
getCurrentLocation(provider, cancellationSignal, context.mainExecutor) { location ->
if (continuation.isActive) continuation.resume(location)
}
}.onFailure {
if (continuation.isActive) continuation.resume(null)
}
return@suspendCancellableCoroutine
}
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
removeUpdates(this)
if (continuation.isActive) continuation.resume(location)
}
}
continuation.invokeOnCancellation { runCatching { removeUpdates(listener) } }
runCatching {
@Suppress("DEPRECATION")
requestSingleUpdate(provider, listener, Looper.getMainLooper())
}.onFailure {
if (continuation.isActive) continuation.resume(null)
}
}
private fun hasAnyLocationPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
@SuppressLint("MissingPermission")
private fun bestLastKnownLocation(manager: LocationManager): Location? {
val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)
return providers.mapNotNull { provider ->
runCatching {
if (manager.isProviderEnabled(provider) || provider == LocationManager.PASSIVE_PROVIDER) {
manager.getLastKnownLocation(provider)
} else {
null
}
}.getOrNull()
}.maxByOrNull { it.time }
}
@@ -0,0 +1,146 @@
package pl.firmatpp.kierowca.update
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.InstallStateUpdatedListener
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
data class PlayAppUpdateState(
val updateAvailable: Boolean = false,
val availableVersionCode: Int? = null,
val downloaded: Boolean = false,
val checking: Boolean = false,
)
class PlayInAppUpdateController(
private val activity: ComponentActivity,
private val launcher: ActivityResultLauncher<IntentSenderRequest>,
private val onStateChanged: (PlayAppUpdateState) -> Unit,
private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(activity),
) {
private var state = PlayAppUpdateState()
private val installStateListener = InstallStateUpdatedListener { installState ->
when (installState.installStatus()) {
InstallStatus.DOWNLOADED -> updateState(state.copy(downloaded = true, updateAvailable = true))
InstallStatus.INSTALLED -> updateState(PlayAppUpdateState())
else -> Unit
}
}
init {
appUpdateManager.registerListener(installStateListener)
}
fun checkForUpdate() {
updateState(state.copy(checking = true))
appUpdateManager.appUpdateInfo
.addOnSuccessListener(::handleUpdateInfo)
.addOnFailureListener { updateState(state.copy(checking = false)) }
}
fun startUpdate(required: Boolean, playStoreUrl: String?) {
appUpdateManager.appUpdateInfo
.addOnSuccessListener { info ->
if (info.updateAvailability() != UpdateAvailability.UPDATE_AVAILABLE) {
openPlayStore(playStoreUrl)
return@addOnSuccessListener
}
val preferredType = if (required) AppUpdateType.IMMEDIATE else AppUpdateType.FLEXIBLE
val updateType = when {
info.isUpdateTypeAllowed(preferredType) -> preferredType
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> AppUpdateType.FLEXIBLE
info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) -> AppUpdateType.IMMEDIATE
else -> null
}
if (updateType == null || !launchUpdate(info, updateType)) {
openPlayStore(playStoreUrl)
}
}
.addOnFailureListener { openPlayStore(playStoreUrl) }
}
fun completeDownloadedUpdate() {
appUpdateManager.completeUpdate()
}
fun onUpdateFlowResult(resultCode: Int) {
if (resultCode != Activity.RESULT_OK) {
checkForUpdate()
}
}
fun dispose() {
appUpdateManager.unregisterListener(installStateListener)
}
private fun handleUpdateInfo(info: AppUpdateInfo) {
val downloaded = info.installStatus() == InstallStatus.DOWNLOADED
val available = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE ||
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS ||
downloaded
updateState(
PlayAppUpdateState(
updateAvailable = available,
availableVersionCode = info.availableVersionCode().takeIf { it > 0 },
downloaded = downloaded,
checking = false,
),
)
if (
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS &&
info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
) {
launchUpdate(info, AppUpdateType.IMMEDIATE)
}
}
private fun launchUpdate(info: AppUpdateInfo, updateType: Int): Boolean =
appUpdateManager.startUpdateFlowForResult(
info,
launcher,
AppUpdateOptions.newBuilder(updateType).build(),
)
private fun openPlayStore(playStoreUrl: String?) {
val packageName = activity.packageName
val candidates = listOf(
Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")).setPackage("com.android.vending"),
Intent(
Intent.ACTION_VIEW,
Uri.parse(playStoreUrl?.takeIf { it.isNotBlank() }
?: "https://play.google.com/store/apps/details?id=$packageName"),
),
)
for (intent in candidates) {
try {
activity.startActivity(intent)
return
} catch (_: ActivityNotFoundException) {
// Try the browser fallback.
} catch (_: SecurityException) {
// Try the browser fallback.
}
}
}
private fun updateState(next: PlayAppUpdateState) {
state = next
onStateChanged(next)
}
}
@@ -2,12 +2,31 @@ package pl.firmatpp.kierowca.data
import java.io.IOException import java.io.IOException
import java.net.UnknownHostException import java.net.UnknownHostException
import kotlin.coroutines.cancellation.CancellationException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
class ApiErrorMapperTest { class ApiErrorMapperTest {
@Test
fun rethrowsWorkerCancellationInsteadOfMappingItAsApplicationFailure() {
val cancellation = CancellationException("Job was cancelled")
try {
cancellation.rethrowIfCancellation()
fail("CancellationException should be rethrown")
} catch (thrown: CancellationException) {
assertSame(cancellation, thrown)
}
}
@Test @Test
fun mapsNetworkFailuresToRetryableOfflineMessage() { fun mapsNetworkFailuresToRetryableOfflineMessage() {
val error = ApiErrorMapper.map(IOException("timeout")) val error = ApiErrorMapper.map(IOException("timeout"))
@@ -69,4 +88,30 @@ class ApiErrorMapperTest {
assertEquals("Storage timeout", error.message) assertEquals("Storage timeout", error.message)
assertEquals(body, error.responseBody) assertEquals(body, error.responseBody)
} }
@Test
fun mapsInvalidOtpReasonToActionableMessage() {
val error = ApiErrorMapper.mapHttpStatus(422, """{"ok":false,"reason":"INVALID_OTP"}""")
assertEquals("INVALID_OTP", error.code)
assertEquals("Kod jest nieprawidłowy lub wygasł. Użyj kodu z najnowszego SMS-a.", error.message)
}
@Test
fun translatesRateLimitResponses() {
val error = ApiErrorMapper.mapHttpStatus(429, """{"message":"Too Many Attempts."}""")
assertEquals(ApiErrorKind.RateLimited, error.kind)
assertEquals("Za dużo prób. Odczekaj chwilę i spróbuj ponownie.", error.message)
}
@Test
fun preservesApiProblemWhenSameExceptionIsMappedMoreThanOnce() {
val body = """{"ok":false,"reason":"INVALID_OTP"}"""
.toResponseBody("application/json".toMediaType())
val exception = HttpException(Response.error<Any>(422, body))
assertEquals("INVALID_OTP", ApiErrorMapper.map(exception).code)
assertEquals("INVALID_OTP", ApiErrorMapper.map(exception).code)
}
} }
@@ -0,0 +1,94 @@
package pl.firmatpp.kierowca.data
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.LeaveSummaryResponse
class DriverLeaveApiContractTest {
private val gson = Gson()
@Test
fun parsesNewLeaveSummaryWithoutTechnicalFallbacks() {
val response = gson.fromJson(
"""
{
"data": {
"dailyNormMinutes": 480,
"balanceConfigured": true,
"overdueQuantity": 480,
"visibleUsagePoolCodes": ["ANNUAL", "CHILD_CARE_14"],
"pools": [{
"id": 7,
"poolCode": "ANNUAL",
"year": 2026,
"unit": "minutes",
"availableQuantity": 7680,
"reservedQuantity": 960,
"usedQuantity": 960,
"grantedQuantity": 9600,
"status": "active"
}],
"availableTypes": [{
"value": "URLOP",
"code": "ANNUAL",
"label": "Urlop wypoczynkowy",
"requestModes": ["days", "hours"],
"requiredFields": [],
"requiredDocuments": [],
"fieldOptions": {},
"description": "Urlop rozliczany według grafiku.",
"financialTreatmentLabel": "Płatny według danych przekazanych przez kadry i płace.",
"affectsAnnualLeaveBalance": true
}]
}
}
""".trimIndent(),
LeaveSummaryResponse::class.java,
)
assertEquals(480, response.data.dailyNormMinutes)
assertEquals(listOf("ANNUAL", "CHILD_CARE_14"), response.data.visibleUsagePoolCodes)
assertEquals(7680, response.data.pools.single().availableQuantity)
assertEquals("URLOP", response.data.availableTypes.single().value)
assertEquals("ANNUAL", response.data.availableTypes.single().code)
assertTrue("hours" in response.data.availableTypes.single().requestModes)
assertTrue(response.data.availableTypes.single().affectsAnnualLeaveBalance)
}
@Test
fun parsesSensitiveOwnRequestDetailsAsFormValues() {
val request = gson.fromJson(
"""
{
"id": "12",
"dateFrom": "2026-08-03",
"dateTo": "2026-08-03",
"type": "WOLNE",
"ruleCode": "CHILD_CARE_14",
"requestMode": "days",
"requestedQuantity": 1,
"quantityUnit": "days",
"details": {
"childrenBornCount": 1,
"childBirthDate": "2020-05-12"
},
"financialTreatmentLabel": "Płatne według zasad ustawowych.",
"affectsAnnualLeaveBalance": false,
"status": "pending",
"missingDocumentTypes": ["parent_declaration"]
}
""".trimIndent(),
DriverLeaveRequestDto::class.java,
)
assertEquals("1", request.details["childrenBornCount"])
assertEquals("2020-05-12", request.details["childBirthDate"])
assertEquals("days", request.quantityUnit)
assertEquals(false, request.affectsAnnualLeaveBalance)
assertEquals("Płatne według zasad ustawowych.", request.financialTreatmentLabel)
assertEquals(listOf("parent_declaration"), request.missingDocumentTypes)
}
}
@@ -0,0 +1,19 @@
package pl.firmatpp.kierowca.data.api
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Test
class ApiFactoryTest {
@Test
fun `request contains current driver app version header`() {
val request = Request.Builder()
.url("https://api.example.test/mobile/driver/bootstrap")
.header(DRIVER_APP_VERSION_HEADER, "old-version")
.build()
val updated = request.withDriverAppVersion("1.0.65")
assertEquals("1.0.65", updated.header(DRIVER_APP_VERSION_HEADER))
}
}
@@ -0,0 +1,17 @@
package pl.firmatpp.kierowca.data.sync
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NetworkMonitorTest {
@Test
fun captivePortalIsNotTreatedAsOnline() {
assertFalse(hasValidatedInternetCapabilities(hasInternet = true, hasValidated = false))
}
@Test
fun validatedInternetIsOnline() {
assertTrue(hasValidatedInternetCapabilities(hasInternet = true, hasValidated = true))
}
}
@@ -0,0 +1,70 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class StartupOfflineFallbackLoaderTest {
@Test
fun returnsServerDataBeforeTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = { "server" },
offlineLoad = { "cache" },
)
assertEquals("server", result)
}
@Test
fun returnsCachedDataWhenServerExceedsTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { "cache" },
)
assertEquals("cache", result)
}
@Test
fun reportsMissingOfflineDataAfterTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val error = runCatching {
loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { null },
)
}.exceptionOrNull()
assertTrue(error is IOException)
}
@Test
fun doesNotHideAuthenticationFailures() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val authenticationFailure = IllegalStateException("401")
val error = runCatching {
loader.load(
onlineLoad = { throw authenticationFailure },
offlineLoad = { "cache" },
)
}.exceptionOrNull()
assertTrue(error is IllegalStateException)
assertEquals(authenticationFailure.message, error?.message)
}
}
@@ -0,0 +1,43 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OfflineOutboxManagerTest {
@Test
fun recognizesLegacyCancellationFailureDetails() {
assertTrue(isWorkerCancellationFailure("Typ: błąd aplikacji\nKomunikat: Job was cancelled"))
assertTrue(isWorkerCancellationFailure("kotlinx.coroutines.JobCancellationException"))
assertFalse(isWorkerCancellationFailure("HTTP 422: Niepoprawne zdjęcie"))
assertFalse(isWorkerCancellationFailure(null))
}
@Test
fun repairsInterruptedAndRetryablePhotoUploadsButNotRealPermanentFailures() {
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Pending.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Uploading.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Verifying.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.FailedRetryable.storageValue, "timeout"))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.FailedPermanent.storageValue, "Job was cancelled"))
assertFalse(shouldRepairPhotoUpload(PhotoUploadStatus.FailedPermanent.storageValue, "HTTP 422"))
assertFalse(shouldRepairPhotoUpload(PhotoUploadStatus.Cancelled.storageValue, null))
}
@Test
fun repairsInterruptedRouteActionWithItsOriginalIdempotencyKey() {
assertTrue(shouldRepairRouteAction(RouteActionStatus.Pending, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.WaitingForPhotos, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.Syncing, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.FailedRetryable, "timeout"))
assertTrue(shouldRepairRouteAction(RouteActionStatus.FailedPermanent, "Job was cancelled"))
assertTrue(
shouldRepairRouteAction(
RouteActionStatus.FailedPermanent,
"Wymagane zdjęcie nie zostało wysłane. Popraw zdjęcie i ponów potwierdzenie etapu.",
),
)
assertFalse(shouldRepairRouteAction(RouteActionStatus.FailedPermanent, "Brakuje zdjęcia"))
assertFalse(shouldRepairRouteAction(RouteActionStatus.FailedConflict, "Kurs już rozpoczęty"))
}
}
@@ -0,0 +1,62 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class PhotoUploadReconciliationTest {
@Test
fun serverReceiptIdentityClearsFalseLocalFailure() {
val upload = upload(
clientRequestId = "request-1",
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
assertTrue(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", upload.contentSha256.uppercase())))
assertFalse(serverPhotoConfirmsUpload(upload, serverPhoto("request-2", upload.contentSha256)))
assertFalse(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")))
}
@Test
fun legacyServerPhotoWithoutHashStillConfirmsMatchingRequestId() {
val upload = upload("request-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
assertTrue(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", null)))
}
private fun upload(clientRequestId: String, contentSha256: String): PhotoUploadEntity =
PhotoUploadEntity(
clientRequestId = clientRequestId,
routeId = "10",
localPath = "/tmp/$clientRequestId.jpg",
source = "camera",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
mimeType = "image/jpeg",
size = 100,
contentSha256 = contentSha256,
status = PhotoUploadStatus.FailedPermanent.storageValue,
lastError = "Typ: błąd aplikacji\nKomunikat: Job was cancelled",
)
private fun serverPhoto(clientRequestId: String, contentSha256: String?): RoutePhotoDto =
RoutePhotoDto(
id = "42",
routeId = "10",
clientRequestId = clientRequestId,
contentSha256 = contentSha256,
source = "camera",
stage = "loading",
mimeType = "image/jpeg",
size = 100,
url = "https://example.test/photo/42",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
createdAt = null,
)
}
@@ -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,61 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteActionPrerequisiteTest {
@Test
fun finishWaitsUntilEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val pendingStart = action(RouteActionType.Start, RouteActionStatus.Syncing, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Waiting,
routeActionPrerequisiteState(finish, pendingStart),
)
}
@Test
fun finishRunsAfterEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val confirmedStart = action(RouteActionType.Start, RouteActionStatus.Confirmed, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, confirmedStart),
)
}
@Test
fun failedStartBlocksFinishInsteadOfSendingItOutOfOrder() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val failedStart = action(RouteActionType.Start, RouteActionStatus.FailedConflict, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Failed,
routeActionPrerequisiteState(finish, failedStart),
)
}
@Test
fun finishWithoutLocalStartCanUseServerLifecycleState() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, null),
)
}
private fun action(type: String, status: String, createdAt: Long): RouteActionEntity =
RouteActionEntity(
clientActionId = "$type-$createdAt",
routeId = "route-1",
action = type,
weight = 12.5,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = "[]",
status = status,
createdAtEpochMillis = createdAt,
)
}
@@ -0,0 +1,135 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class RouteActionServerReconciliationTest {
@Test
fun matchingFinishedRouteConfirmsDuplicateFinishSafely() {
assertTrue(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uszkodzona plomba."),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Uszkodzona plomba.",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun differingNotesKeepConflictVisibleAndPreserveLocalData() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uwagi z telefonu"),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Inna uwaga na serwerze",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun missingServerPhotoDoesNotDiscardLocalFinish() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = null, notes = null),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(status = "ZAKOŃCZONA", weight = null, notes = null),
),
)
}
@Test
fun laterConfirmedFinishSupersedesAnOlderConflictingFinish() {
assertTrue(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Starsza uwaga"),
hasConfirmedFinishAfter = true,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
@Test
fun completedServerRouteAloneDoesNotDiscardConflictingLocalData() {
assertFalse(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Nadal ważna uwaga"),
hasConfirmedFinishAfter = false,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
private fun finish(weight: Double?, notes: String?): RouteActionEntity =
RouteActionEntity(
clientActionId = "finish-1",
routeId = "route-1",
action = RouteActionType.Finish,
weight = weight,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = """["photo-1"]""",
notes = notes,
)
private fun route(
status: String,
weight: Double?,
notes: String?,
photoClientRequestIds: List<String> = emptyList(),
): DriverRouteDto =
DriverRouteDto(
id = "route-1",
routeDate = "2026-07-23",
startsAt = "",
originName = "Baza",
destinationName = "Cel",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza → Cel",
status = status,
driverStatus = status,
unloadingWeight = weight,
unloadingNotes = notes,
distanceKm = 1.0,
notes = null,
truck = null,
photos = photoClientRequestIds.mapIndexed { index, requestId ->
RoutePhotoDto(
id = "photo-$index",
routeId = "route-1",
clientRequestId = requestId,
source = "camera",
stage = "unloading",
mimeType = "image/jpeg",
size = 1,
url = "https://example.test/photo-$index",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
createdAt = null,
)
},
)
}
@@ -0,0 +1,29 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.ApiErrorKind
class RoutePointWorkerRulesTest {
@Test
fun inactiveRouteConflictDiscardsObsoleteGpsPoints() {
assertTrue(isInactiveRoutePointError("ROUTE_POINTS_INVALID_STATUS"))
}
@Test
fun temporaryServerFailureDoesNotDiscardGpsPoints() {
assertFalse(isInactiveRoutePointError("SERVER_UNAVAILABLE"))
assertFalse(isInactiveRoutePointError(null))
}
@Test
fun revokedSessionStopsRouteTracking() {
assertTrue(shouldStopRouteTrackingAfterPointError(ApiErrorKind.Auth, null))
}
@Test
fun temporaryFailureKeepsRouteTrackingActive() {
assertFalse(shouldStopRouteTrackingAfterPointError(ApiErrorKind.Server, "SERVER_UNAVAILABLE"))
}
}
@@ -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"))
}
}
@@ -16,7 +16,9 @@ class RouteDisplayMapperTest {
contractName = "Odpady przemyslowe", contractName = "Odpady przemyslowe",
contractCode = "TPP-ODP-24", contractCode = "TPP-ODP-24",
relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask", relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask",
status = "ZAPLANOWANA", status = "ZAKOŃCZONA",
driverStatus = "ZAPLANOWANA",
operationalStatus = "ZAKOŃCZONA",
distanceKm = 91.4, distanceKm = 91.4,
notes = null, notes = null,
truck = null, truck = null,
@@ -29,6 +31,7 @@ class RouteDisplayMapperTest {
assertEquals("Instalacja Odpadowa Slask", display.destination) assertEquals("Instalacja Odpadowa Slask", display.destination)
assertEquals("TPP-ODP-24", display.contractLabel) assertEquals("TPP-ODP-24", display.contractLabel)
assertEquals("09:15", display.time) assertEquals("09:15", display.time)
assertEquals("ZAPLANOWANA", display.status)
assertEquals("91 km", display.distanceLabel) assertEquals("91 km", display.distanceLabel)
} }
} }
@@ -0,0 +1,32 @@
package pl.firmatpp.kierowca.domain
import org.junit.Assert.assertEquals
import org.junit.Test
class StartupSessionPolicyTest {
@Test
fun rotatesStartupMessagesAsWaitingTimeIncreases() {
assertEquals("Sprawdzamy zapisaną sesję", StartupSessionPolicy.message(0).title)
assertEquals("Potwierdzamy logowanie", StartupSessionPolicy.message(3).title)
assertEquals("Pobieramy aktualne kursy", StartupSessionPolicy.message(6).title)
assertEquals("To trwa dłużej niż zwykle", StartupSessionPolicy.message(9).title)
assertEquals("Przygotowujemy tryb offline", StartupSessionPolicy.message(12).title)
}
@Test
fun countsDownToOfflineFallbackWithoutGoingBelowZero() {
assertEquals(15, StartupSessionPolicy.remainingSeconds(0))
assertEquals(7, StartupSessionPolicy.remainingSeconds(8))
assertEquals(0, StartupSessionPolicy.remainingSeconds(15))
assertEquals(0, StartupSessionPolicy.remainingSeconds(30))
assertEquals("Tryb offline najpóźniej za 5 s", StartupSessionPolicy.countdownLabel(10))
assertEquals("Otwieramy zapisane dane offline", StartupSessionPolicy.countdownLabel(15))
}
@Test
fun showsManualOfflineButtonAfterFourSecondsOnlyWhenCacheExists() {
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(3, offlineDataAvailable = true))
assertEquals(true, StartupSessionPolicy.shouldShowOfflineButton(4, offlineDataAvailable = true))
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(10, offlineDataAvailable = false))
}
}
@@ -23,8 +23,26 @@ class DriverLiveSyncClientTest {
reverbEnabled = true, reverbEnabled = true,
reverbAppKey = "app-key", reverbAppKey = "app-key",
reverbWsBaseUrl = "wss://example.test/app", reverbWsBaseUrl = "wss://example.test/app",
reverbOrigin = "https://bootstrap-origin.test",
) )
@Test
fun derivesBrowserCompatibleOriginFromWebSocketUrl() {
assertEquals("https://api-intranet.firmatpp.pl", websocketOrigin("wss://api-intranet.firmatpp.pl/ws/app/app-key"))
assertEquals("https://example.test:8443", websocketOrigin("wss://example.test:8443/app/app-key"))
assertEquals("http://localhost:8080", websocketOrigin("ws://localhost:8080/app/app-key"))
}
@Test
fun passesBootstrapOriginToWebSocketFactory() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
assertEquals("https://bootstrap-origin.test", factory.origins.single())
}
@Test @Test
fun reconnectsWithBackoffAfterSocketFailure() = runTest { fun reconnectsWithBackoffAfterSocketFailure() = runTest {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
@@ -81,6 +99,23 @@ class DriverLiveSyncClientTest {
assertEquals(2, factory.sockets.size) 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 @Test
fun retriesWhenPrivateChannelSubscriptionAuthFails() = runTest { fun retriesWhenPrivateChannelSubscriptionAuthFails() = runTest {
val gateway = FakeLiveSyncGateway(authFailure = RuntimeException("offline")) val gateway = FakeLiveSyncGateway(authFailure = RuntimeException("offline"))
@@ -107,6 +142,21 @@ class DriverLiveSyncClientTest {
assertTrue(factory.sockets.single().sent.any { it.contains("pusher:pong") }) 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 @Test
fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest { fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
@@ -172,10 +222,12 @@ class DriverLiveSyncClientTest {
private class FakeWebSocketFactory : LiveWebSocketFactory { private class FakeWebSocketFactory : LiveWebSocketFactory {
val sockets = mutableListOf<FakeWebSocket>() val sockets = mutableListOf<FakeWebSocket>()
val origins = mutableListOf<String?>()
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket { override fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket {
val socket = FakeWebSocket(listener) val socket = FakeWebSocket(listener)
sockets += socket sockets += socket
origins += origin
return socket return socket
} }
} }
@@ -0,0 +1,24 @@
package pl.firmatpp.kierowca.sync
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DriverRuntimeSyncStateTest {
@After
fun reset() {
DriverRuntimeSyncState.foreground = false
DriverRuntimeSyncState.realtimeConnected = false
}
@Test
fun websocketSuppressesDuplicateFcmSyncOnlyWhenForegroundAndConnected() {
DriverRuntimeSyncState.foreground = true
DriverRuntimeSyncState.realtimeConnected = true
assertTrue(DriverRuntimeSyncState.websocketOwnsForegroundHints())
DriverRuntimeSyncState.realtimeConnected = false
assertFalse(DriverRuntimeSyncState.websocketOwnsForegroundHints())
}
}
@@ -5,6 +5,12 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeavePoolDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRuleSnapshotDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
class DriverLeaveRequestUiRulesTest { class DriverLeaveRequestUiRulesTest {
@Test @Test
@@ -16,15 +22,35 @@ class DriverLeaveRequestUiRulesTest {
@Test @Test
fun exposesPolishStatusLabels() { fun exposesPolishStatusLabels() {
assertEquals("Oczekuje", DriverLeaveRequestUiRules.statusLabel("pending")) assertEquals("Czeka na decyzję", DriverLeaveRequestUiRules.statusLabel("pending"))
assertEquals("Zatwierdzony", DriverLeaveRequestUiRules.statusLabel("approved")) assertEquals("Zaakceptowany", DriverLeaveRequestUiRules.statusLabel("approved"))
assertEquals("Odrzucony", DriverLeaveRequestUiRules.statusLabel("rejected")) assertEquals("Odrzucony", DriverLeaveRequestUiRules.statusLabel("rejected"))
assertEquals("Anulowanie do decyzji", DriverLeaveRequestUiRules.statusLabel("cancel_requested")) assertEquals("Czeka na anulowanie", DriverLeaveRequestUiRules.statusLabel("cancel_requested"))
assertEquals("Anulowany", DriverLeaveRequestUiRules.statusLabel("cancelled")) assertEquals("Anulowany", DriverLeaveRequestUiRules.statusLabel("cancelled"))
assertEquals("Cofnięto decyzję", DriverLeaveRequestUiRules.statusLabel("revoked")) assertEquals("Akceptacja cofnięta", DriverLeaveRequestUiRules.statusLabel("revoked"))
assertEquals("Nieznany status", DriverLeaveRequestUiRules.statusLabel("some_raw_status")) assertEquals("Nieznany status", DriverLeaveRequestUiRules.statusLabel("some_raw_status"))
} }
@Test
fun explainsAnnualBalanceInDaysAndHours() {
val summary = DriverLeaveSummaryDto(
dailyNormMinutes = 480,
balanceConfigured = true,
pools = listOf(
annualPool(year = 2025, available = 480, reserved = 0, used = 480, granted = 960),
annualPool(year = 2026, available = 3_840, reserved = 960, used = 960, granted = 5_760),
),
)
val totals = DriverLeaveRequestUiRules.annualTotals(summary)
assertEquals(4_320, totals.availableMinutes)
assertEquals(960, totals.reservedMinutes)
assertEquals(1_440, totals.usedMinutes)
assertEquals("9 dni · 72 godz.", DriverLeaveRequestUiRules.formatQuantity(totals.availableMinutes, "minutes", 480))
assertEquals("2 dni + 2 godz. · 18 godz.", DriverLeaveRequestUiRules.formatQuantity(1_080, "minutes", 480))
}
@Test @Test
fun exposesPolishEventActionLabels() { fun exposesPolishEventActionLabels() {
assertEquals("Wniosek złożony", DriverLeaveRequestUiRules.eventActionLabel("submitted")) assertEquals("Wniosek złożony", DriverLeaveRequestUiRules.eventActionLabel("submitted"))
@@ -35,9 +61,78 @@ class DriverLeaveRequestUiRulesTest {
assertEquals("Kierowca poprosił o anulowanie", DriverLeaveRequestUiRules.eventActionLabel("cancellation_requested")) assertEquals("Kierowca poprosił o anulowanie", DriverLeaveRequestUiRules.eventActionLabel("cancellation_requested"))
assertEquals("Anulowanie zatwierdzone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_approved")) assertEquals("Anulowanie zatwierdzone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_approved"))
assertEquals("Anulowanie odrzucone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_rejected")) assertEquals("Anulowanie odrzucone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_rejected"))
assertEquals("Przywrócono urlop wypoczynkowy", DriverLeaveRequestUiRules.eventActionLabel("annual_leave_interrupted"))
assertEquals("Aktualizacja wniosku", DriverLeaveRequestUiRules.eventActionLabel("some_raw_action")) assertEquals("Aktualizacja wniosku", DriverLeaveRequestUiRules.eventActionLabel("some_raw_action"))
} }
@Test
fun formatsStatutoryDayLimitsAsDaysInsteadOfMinutes() {
val dayBasedRequest = DriverLeaveRequestDto(
id = "child-care",
dateFrom = "2026-08-03",
dateTo = "2026-08-03",
type = "CHILD_CARE_14",
requestMode = "days",
requestedQuantity = 1,
quantityUnit = "days",
status = "pending",
)
val legacyDayBasedRequest = dayBasedRequest.copy(
id = "force-majeure",
type = "FORCE_MAJEURE",
quantityUnit = null,
rule = DriverLeaveRuleSnapshotDto(
unit = "work_schedule",
poolCode = "FORCE_MAJEURE",
),
)
assertEquals("days", DriverLeaveRequestUiRules.requestQuantityUnit(dayBasedRequest))
assertEquals("1 dzień", DriverLeaveRequestUiRules.formatQuantity(1, "days"))
assertEquals("days", DriverLeaveRequestUiRules.requestQuantityUnit(legacyDayBasedRequest))
}
@Test
fun exposesOtherStatutoryBalancesSeparatelyFromAnnualLeave() {
val summary = DriverLeaveSummaryDto(
pools = listOf(
annualPool(year = 2026, available = 4_800, reserved = 0, used = 0, granted = 4_800),
DriverLeavePoolDto(
id = 31,
poolCode = "CHILD_CARE_14",
year = 2026,
unit = "days",
availableQuantity = 1,
usedQuantity = 1,
grantedQuantity = 2,
status = "active",
),
DriverLeavePoolDto(
id = 32,
poolCode = "CARE_LEAVE",
year = 2026,
unit = "days",
availableQuantity = 5,
grantedQuantity = 5,
status = "active",
),
),
)
val pools = DriverLeaveRequestUiRules.otherEntitlementPools(summary)
assertEquals(listOf("CHILD_CARE_14", "CARE_LEAVE"), pools.map { it.poolCode })
assertEquals("Urlop opiekuńczy", DriverLeaveRequestUiRules.poolLabel("CARE_LEAVE"))
assertEquals("Rodzina i opieka", DriverLeaveRequestUiRules.categoryLabel("family"))
assertTrue(DriverLeaveRequestUiRules.isUsageVisible(summary, "ANNUAL"))
assertFalse(
DriverLeaveRequestUiRules.isUsageVisible(
summary.copy(visibleUsagePoolCodes = emptyList()),
"ANNUAL",
),
)
}
@Test @Test
fun driverCanCancelPendingAndFutureApprovedRequests() { fun driverCanCancelPendingAndFutureApprovedRequests() {
val tomorrow = LocalDate.now().plusDays(1).toString() val tomorrow = LocalDate.now().plusDays(1).toString()
@@ -48,4 +143,78 @@ class DriverLeaveRequestUiRulesTest {
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = yesterday)) assertFalse(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = yesterday))
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "rejected", dateFrom = tomorrow)) 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",
),
)
private fun annualPool(
year: Int,
available: Int,
reserved: Int,
used: Int,
granted: Int,
): DriverLeavePoolDto =
DriverLeavePoolDto(
id = year,
poolCode = "ANNUAL",
year = year,
unit = "minutes",
availableQuantity = available,
reservedQuantity = reserved,
usedQuantity = used,
grantedQuantity = granted,
status = "active",
)
} }
@@ -1,11 +1,13 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import com.google.gson.Gson
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto 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.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
@@ -15,11 +17,19 @@ class DriverRouteProjectionTest {
fun pendingStartProjectsPlannedRouteAsActiveWithLoadingWeight() { fun pendingStartProjectsPlannedRouteAsActiveWithLoadingWeight() {
val projection = projectDriverRoute( val projection = projectDriverRoute(
route = route(status = "ZAPLANOWANA"), route = route(status = "ZAPLANOWANA"),
actions = listOf(action(RouteActionType.Start, RouteActionStatus.Pending, weight = 12.5)), actions = listOf(
action(
RouteActionType.Start,
RouteActionStatus.Pending,
weight = 12.5,
notes = "Oczekiwanie na dokument WZ.",
),
),
) )
assertEquals("W TRAKCIE", projection.route.status) assertEquals("W TRAKCIE", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight) assertEquals(12.5, projection.route.loadingWeight)
assertEquals("Oczekiwanie na dokument WZ.", projection.route.loadingNotes)
assertEquals("active", projection.route.trackingStatus) assertEquals("active", projection.route.trackingStatus)
assertTrue(projection.loadingWeightPending) assertTrue(projection.loadingWeightPending)
assertFalse(projection.unloadingWeightPending) assertFalse(projection.unloadingWeightPending)
@@ -30,11 +40,20 @@ class DriverRouteProjectionTest {
fun pendingFinishProjectsActiveRouteAsFinishedWithUnloadingWeight() { fun pendingFinishProjectsActiveRouteAsFinishedWithUnloadingWeight() {
val projection = projectDriverRoute( val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5), route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.Syncing, weight = 11.8, occurredAt = "2026-06-30T09:10:00Z")), actions = listOf(
action(
RouteActionType.Finish,
RouteActionStatus.Syncing,
weight = 11.8,
notes = "Uszkodzona plomba.",
occurredAt = "2026-06-30T09:10:00Z",
),
),
) )
assertEquals("ZAKOŃCZONA", projection.route.status) assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight) assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes)
assertEquals("finished", projection.route.trackingStatus) assertEquals("finished", projection.route.trackingStatus)
assertEquals("2026-06-30T09:10:00Z", projection.route.completedAt) assertEquals("2026-06-30T09:10:00Z", projection.route.completedAt)
assertTrue(projection.unloadingWeightPending) assertTrue(projection.unloadingWeightPending)
@@ -74,17 +93,58 @@ class DriverRouteProjectionTest {
} }
@Test @Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() { 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 conflictKeepsLocalRouteValuesButPreservesServerLifecycleStatus() {
val projection = projectDriverRoute( val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5), route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.FailedConflict, weight = 11.8)), actions = listOf(
action(
RouteActionType.Finish,
RouteActionStatus.FailedConflict,
weight = 11.8,
notes = "Uszkodzona plomba.",
),
),
) )
val steps = routeFlowSteps(projection.route, projection.visibleActions) val steps = routeFlowSteps(projection.route, projection.visibleActions)
val callout = routeSyncCallout(projection.visibleActions) val callout = routeSyncCallout(projection.visibleActions)
assertEquals("ZAKOŃCZONA", projection.route.status) assertEquals("W TRAKCIE", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight) assertEquals(11.8, projection.route.unloadingWeight)
assertEquals(RouteFlowStepState.NeedsAttention, steps[2].state) assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes)
assertTrue(projection.unloadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text) assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false) assertTrue(callout?.isConflict ?: false)
} }
@@ -97,9 +157,10 @@ class DriverRouteProjectionTest {
) )
val steps = routeFlowSteps(projection.route, projection.visibleActions) val steps = routeFlowSteps(projection.route, projection.visibleActions)
assertEquals("W TRAKCIE", projection.route.status) assertEquals("ZAPLANOWANA", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight) assertEquals(12.5, projection.route.loadingWeight)
assertEquals(RouteFlowStepState.NeedsAttention, steps[0].state) assertTrue(projection.loadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
} }
@Test @Test
@@ -120,6 +181,7 @@ class DriverRouteProjectionTest {
): DriverRouteDto = ): DriverRouteDto =
DriverRouteDto( DriverRouteDto(
id = "1", id = "1",
routeDate = "2026-06-30",
startsAt = "", startsAt = "",
originName = "Baza", originName = "Baza",
destinationName = "Instalacja", destinationName = "Instalacja",
@@ -139,6 +201,7 @@ class DriverRouteProjectionTest {
type: String, type: String,
status: String, status: String,
weight: Double, weight: Double,
notes: String? = null,
occurredAt: String = "2026-06-30T07:40:00Z", occurredAt: String = "2026-06-30T07:40:00Z",
createdAt: Long = 1000, createdAt: Long = 1000,
): RouteActionEntity = ): RouteActionEntity =
@@ -151,6 +214,7 @@ class DriverRouteProjectionTest {
photoClientRequestIdsJson = "[]", photoClientRequestIdsJson = "[]",
status = status, status = status,
createdAtEpochMillis = createdAt, createdAtEpochMillis = createdAt,
notes = notes,
) )
private companion object { private companion object {
@@ -6,8 +6,14 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DriverRouteStageWorkflowDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDocumentDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowStepDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.TachographReminderTaskDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
@@ -16,6 +22,75 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType
class DriverUiRulesTest { class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30") private val today = LocalDate.parse("2026-06-30")
@Test
fun customWorkflowBlocksUntilConfirmationAndRequiredDocumentArePresent() {
val workflow = DriverRouteWorkflowDto(
version = "workflow-v1",
customized = true,
loading = DriverRouteStageWorkflowDto(
steps = listOf(
DriverRouteWorkflowStepDto(
id = "secure-load",
type = RouteWorkflowStepType.Confirmation,
title = "Sprawdź zabezpieczenie ładunku",
requirement = RouteStageRequirement.Required,
documents = listOf(
DriverRouteWorkflowDocumentDto(
id = "cmr",
label = "List przewozowy CMR",
requirement = RouteStageRequirement.Required,
),
),
),
),
),
unloading = DriverRouteStageWorkflowDto(),
)
assertEquals(
"Potwierdź krok: Sprawdź zabezpieczenie ładunku.",
routeWorkflowSubmitBlocker(workflow, "loading", emptySet(), "", emptyList(), emptyList()),
)
assertEquals(
"Dodaj dokument „List przewozowy CMR” w kroku „Sprawdź zabezpieczenie ładunku”.",
routeWorkflowSubmitBlocker(workflow, "loading", setOf("secure-load"), "", emptyList(), emptyList()),
)
assertEquals(
null,
routeWorkflowSubmitBlocker(
workflow,
"loading",
setOf("secure-load"),
"",
listOf(photo("cmr-photo", "loading").copy(workflowStepId = "secure-load", workflowDocumentId = "cmr")),
emptyList(),
),
)
}
@Test
fun disabledRouteLocationNeverStartsTracking() {
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = true, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = false, hasLocationPermission = true))
}
@Test
fun optionalRouteLocationFollowsDriverChoiceAndPermission() {
assertTrue(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = false, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = false))
}
@Test
fun requiredRouteLocationBlocksStartAndWarnsOnlyWhenNeeded() {
assertTrue(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = false))
assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = true))
assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Optional, hasLocationPermission = false))
assertTrue(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = true, hasLocationPermission = false))
assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = false, hasLocationPermission = false))
assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Optional, hasActiveRoute = true, hasLocationPermission = false))
}
@Test @Test
fun allowsPhotoActionsOnlyForTodayRouteDate() { fun allowsPhotoActionsOnlyForTodayRouteDate() {
assertTrue(canManageRoutePhotos("2026-06-30", today)) assertTrue(canManageRoutePhotos("2026-06-30", today))
@@ -39,6 +114,32 @@ class DriverUiRulesTest {
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today)) 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 @Test
fun showsLiveRouteDayUpdateOnlyForViewedDate() { fun showsLiveRouteDayUpdateOnlyForViewedDate() {
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12")) assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12"))
@@ -56,6 +157,108 @@ class DriverUiRulesTest {
assertEquals("", routeDateChipLabel("", today)) assertEquals("", routeDateChipLabel("", today))
} }
@Test
fun buildsNavigationCandidatesFromGeofenceCoordinatesWithFallbacks() {
val candidates = navigationIntentCandidates(
latitude = 50.0619474,
longitude = 19.9368564,
label = "Brama główna",
)
assertEquals(3, candidates.size)
assertEquals("com.google.android.apps.maps", candidates[0].packageName)
assertEquals("google.navigation:q=50.0619474,19.9368564", candidates[0].uri)
assertEquals(null, candidates[1].packageName)
assertEquals(
"geo:0,0?q=50.0619474,19.9368564(Brama%20g%C5%82%C3%B3wna)",
candidates[1].uri,
)
assertEquals(
"https://www.google.com/maps/dir/?api=1&destination=50.0619474,19.9368564",
candidates[2].uri,
)
}
@Test
fun doesNotBuildNavigationCandidatesForMissingOrInvalidCoordinates() {
assertFalse(hasValidNavigationCoordinates(null, 19.9368564))
assertFalse(hasValidNavigationCoordinates(50.0619474, null))
assertFalse(hasValidNavigationCoordinates(Double.NaN, 19.9368564))
assertFalse(hasValidNavigationCoordinates(91.0, 19.9368564))
assertFalse(hasValidNavigationCoordinates(50.0619474, 181.0))
assertTrue(hasValidNavigationCoordinates(50.0619474, 19.9368564))
assertTrue(navigationIntentCandidates(null, null, "Cel").isEmpty())
}
@Test
fun showsAnOptionalUpdateWhenGooglePlayOrBackendHasANewerVersion() {
val fromPlay = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = null,
latestVersionName = null,
minimumSupportedVersionCode = 0,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = false,
)
val fromBackend = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 0,
playUpdateAvailable = false,
playAvailableVersionCode = null,
updateDownloaded = false,
)
assertEquals(false, fromPlay?.required)
assertEquals("Aktualizuj", fromPlay?.actionLabel)
assertEquals("Dostępna jest nowa wersja aplikacji 1.0.61.", fromBackend?.message)
}
@Test
fun requiresAnUpdateOnlyBelowTheConfiguredMinimumVersion() {
val required = appUpdateBannerUi(
currentVersionCode = 111,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 112,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = false,
)
val current = appUpdateBannerUi(
currentVersionCode = 114,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 112,
playUpdateAvailable = false,
playAvailableVersionCode = null,
updateDownloaded = false,
)
assertEquals(true, required?.required)
assertTrue(required?.message?.contains("nie jest już obsługiwana") == true)
assertEquals(null, current)
}
@Test
fun downloadedUpdateUsesInstallAction() {
val update = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 0,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = true,
)
assertEquals("Zainstaluj", update?.actionLabel)
assertEquals(true, update?.downloaded)
assertTrue(update?.message?.contains("gotowa do instalacji") == true)
}
@Test @Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() { fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0)) assertEquals(0, inlinePhotoGridRows(0))
@@ -179,8 +382,9 @@ class DriverUiRulesTest {
} }
@Test @Test
fun allowsRetryActionOnlyForRetryableFailedUploads() { fun allowsRetryForRetryableAndLegacyCancelledUploads() {
assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE")) assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE"))
assertTrue(canRetryPhotoUpload("FAILED_PERMANENT", "Typ: błąd aplikacji\nKomunikat: Job was cancelled"))
assertFalse(canRetryPhotoUpload("PENDING")) assertFalse(canRetryPhotoUpload("PENDING"))
assertFalse(canRetryPhotoUpload("UPLOADING")) assertFalse(canRetryPhotoUpload("UPLOADING"))
assertFalse(canRetryPhotoUpload("VERIFYING")) assertFalse(canRetryPhotoUpload("VERIFYING"))
@@ -189,6 +393,37 @@ class DriverUiRulesTest {
assertFalse(canRetryPhotoUpload("CANCELLED")) assertFalse(canRetryPhotoUpload("CANCELLED"))
} }
@Test
fun permanentUploadDoesNotSatisfyRouteStageAndCanBeDiscarded() {
val failed = upload(status = "FAILED_PERMANENT")
val pending = upload(status = "PENDING").copy(clientRequestId = "pending-upload")
assertEquals(listOf("pending-upload"), routeStageUsableUploads(listOf(failed, pending)).map { it.clientRequestId })
assertFalse(photoUploadCanSatisfyStage("FAILED_PERMANENT"))
assertTrue(canDiscardLocalPhotoUpload("FAILED_PERMANENT", null))
assertFalse(canDiscardLocalPhotoUpload("CONFIRMED", "42"))
}
@Test
fun serverRefreshLeavesObsoleteRouteStageScreen() {
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.StartRoute, route(status = "W TRAKCIE")),
)
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.LoadingWeight, route(status = "ZAKOŃCZONA")),
)
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.FinishRoute, route(status = "ZAKOŃCZONA")),
)
assertEquals(
DriverScreen.StartRoute,
routeScreenAfterServerRefresh(DriverScreen.StartRoute, route(status = "ZAPLANOWANA")),
)
}
@Test @Test
fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() { fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() {
assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera")) assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera"))
@@ -227,6 +462,57 @@ class DriverUiRulesTest {
assertFalse(shouldShowDispatchSheetReminderCard(null)) assertFalse(shouldShowDispatchSheetReminderCard(null))
} }
@Test
fun showsTachographCardOnlyForCompleteReminderEnabledByBackend() {
assertTrue(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = true,
visible = true,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = false,
visible = true,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = true,
visible = false,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(shouldShowTachographReminderCard(null))
}
@Test
fun preselectsOnlySingleTachographConfirmationTask() {
val driverCard = tachographTask()
val vehicle = TachographReminderTaskDto(
id = "vehicle:12:2026-07-31",
type = "vehicle_tachograph",
label = "Tachograf pojazdu",
)
assertEquals(setOf(driverCard.id), defaultTachographConfirmationSelection(listOf(driverCard)))
assertEquals(emptySet<String>(), defaultTachographConfirmationSelection(listOf(driverCard, vehicle)))
}
@Test @Test
fun explainsOfflineStateWithoutCachedSync() { fun explainsOfflineStateWithoutCachedSync() {
assertEquals( assertEquals(
@@ -235,6 +521,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 @Test
fun explainsStaleCachedDataWhenOnline() { fun explainsStaleCachedDataWhenOnline() {
assertEquals( assertEquals(
@@ -254,11 +566,20 @@ class DriverUiRulesTest {
fun routeStageFormRequiresPositiveWeightAndAtLeastOnePhotoOrQueuedUpload() { fun routeStageFormRequiresPositiveWeightAndAtLeastOnePhotoOrQueuedUpload() {
assertTrue(canSubmitRouteStageForm(weightText = "12,5", serverPhotoCount = 1, localUploadCount = 0)) assertTrue(canSubmitRouteStageForm(weightText = "12,5", serverPhotoCount = 1, localUploadCount = 0))
assertTrue(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 1)) assertTrue(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 1))
assertTrue(canSubmitRouteStageForm(weightText = "23400", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "", serverPhotoCount = 1, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "0", serverPhotoCount = 1, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "0", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 0))
} }
@Test
fun parsesRouteWeightsEnteredInTonsOrKilograms() {
assertEquals(23.4, parseRouteWeightTons("23,4") ?: 0.0, 0.0)
assertEquals(23.4, parseRouteWeightTons("23400") ?: 0.0, 0.0)
assertEquals(23.401, parseRouteWeightTons("23400,5") ?: 0.0, 0.0)
assertEquals("Wpisano kilogramy — zapiszemy 23,400 t.", routeWeightInputHelpText("23400"))
}
@Test @Test
fun filtersRoutePhotosByStageWithOtherFallback() { fun filtersRoutePhotosByStageWithOtherFallback() {
val photos = listOf( val photos = listOf(
@@ -295,14 +616,71 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route, emptyList()) val steps = routeFlowSteps(route, emptyList())
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today) 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[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state) assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].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) assertEquals(RouteLifecycleAction.Start, action?.action)
} }
@Test
fun driverFlowIgnoresOperationalStatusFromGeofence() {
val route = route(
status = "ZAKOŃCZONA",
driverStatus = "ZAPLANOWANA",
operationalStatus = "ZAKOŃCZONA",
)
val steps = routeFlowSteps(route, emptyList())
assertTrue(canStartRouteFromDriverApp(route, "2026-06-30", today))
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(DriverScreen.StartRoute, routeScreenAfterServerRefresh(DriverScreen.StartRoute, route))
}
@Test
fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() {
val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading")))
val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.ServerSaved, steps[0].state)
assertEquals("zapisane na serwerze", steps[0].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
}
@Test
fun freshInstallLabelsBackendLoadingDocumentationAsServerData() {
val route = route(
status = "ZAPLANOWANA",
loadingWeight = 23.76,
photos = listOf(photo("loading-1", stage = "loading")),
)
val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.ServerSaved, steps[0].state)
assertEquals("zapisane na serwerze", steps[0].stateLabel)
assertEquals(RouteFlowStepState.ServerSaved, steps[1].state)
assertEquals("zapisane na serwerze", steps[1].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
}
@Test
fun serverWeightDoesNotMarkMissingLoadingPhotoAsComplete() {
val steps = routeFlowSteps(route(status = "ZAPLANOWANA", loadingWeight = 23.76), emptyList())
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.ServerSaved, steps[1].state)
}
@Test @Test
fun activeRouteShowsTransitStateAndFinishAction() { fun activeRouteShowsTransitStateAndFinishAction() {
val route = route(status = "W TRAKCIE", loadingWeight = 12.5) val route = route(status = "W TRAKCIE", loadingWeight = 12.5)
@@ -311,9 +689,11 @@ class DriverUiRulesTest {
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today) val action = routeLifecyclePrimaryAction(route, "2026-06-30", today)
assertEquals(RouteFlowStepState.Confirmed, steps[0].state) assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.LocalComplete, steps[1].state) assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state) assertEquals(RouteFlowStepState.InProgress, steps[2].state)
assertEquals("Zakończ kurs", action?.label) assertEquals("w trakcie", steps[2].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Rozładuj", action?.label)
assertEquals(RouteLifecycleAction.Finish, action?.action) assertEquals(RouteLifecycleAction.Finish, action?.action)
} }
@@ -327,6 +707,7 @@ class DriverUiRulesTest {
assertEquals(RouteFlowStepState.Confirmed, steps[0].state) assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.Confirmed, steps[1].state) assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.Confirmed, steps[2].state) assertEquals(RouteFlowStepState.Confirmed, steps[2].state)
assertEquals(RouteFlowStepState.Confirmed, steps[3].state)
assertEquals(null, action) assertEquals(null, action)
} }
@@ -337,7 +718,7 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions) val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions)
val callout = routeSyncCallout(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) assertEquals("Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu.", callout?.text)
assertFalse(callout?.isConflict ?: true) assertFalse(callout?.isConflict ?: true)
} }
@@ -349,27 +730,120 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions) val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions)
val callout = routeSyncCallout(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) assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false) 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 @Test
fun routeStageSubmitBlockerExplainsMissingRequirements() { fun routeStageSubmitBlockerExplainsMissingRequirements() {
assertEquals("Podaj wagę.", routeStageSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0)) assertEquals("Podaj tonaż.", routeStageSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Waga musi być większa od zera.", routeStageSubmitBlocker("0", 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("Dodaj co najmniej jedno zdjęcie etapu.", routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 0))
assertEquals(null, routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1)) 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( private fun route(
status: String, status: String = "ZAPLANOWANA",
driverStatus: String? = null,
operationalStatus: String? = null,
loadingWeight: Double? = null, loadingWeight: Double? = null,
unloadingWeight: Double? = null, unloadingWeight: Double? = null,
photos: List<RoutePhotoDto> = emptyList(), photos: List<RoutePhotoDto> = emptyList(),
routeDate: String? = "2026-06-30",
unloadingDate: String? = null,
): DriverRouteDto = ): DriverRouteDto =
DriverRouteDto( DriverRouteDto(
id = "1", id = "1",
routeDate = routeDate,
unloadingDate = unloadingDate,
startsAt = "", startsAt = "",
originName = "Baza", originName = "Baza",
destinationName = "Instalacja", destinationName = "Instalacja",
@@ -378,6 +852,8 @@ class DriverUiRulesTest {
contractCode = "TPP-1", contractCode = "TPP-1",
relationLabel = "Baza -> Instalacja", relationLabel = "Baza -> Instalacja",
status = status, status = status,
driverStatus = driverStatus,
operationalStatus = operationalStatus,
loadingWeight = loadingWeight, loadingWeight = loadingWeight,
unloadingWeight = unloadingWeight, unloadingWeight = unloadingWeight,
distanceKm = 1.0, distanceKm = 1.0,
@@ -444,4 +920,11 @@ class DriverUiRulesTest {
photo = null, photo = null,
canUpload = dueToday, canUpload = dueToday,
) )
private fun tachographTask(): TachographReminderTaskDto =
TachographReminderTaskDto(
id = "driver-card:1:2026-07-31",
type = "driver_card",
label = "Karta kierowcy",
)
} }
@@ -1,8 +1,10 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.IOException
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionEntity
@@ -26,6 +28,70 @@ class DriverUiStateTest {
assertEquals(AppThemeMode.Material3, state.themeMode) 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 silentRefreshCannotReplaceActiveStartupLoad() {
assertFalse(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = false,
startupOfflineOnly = false,
),
)
assertFalse(
shouldStartRoutesLoad(
screen = DriverScreen.Routes,
navigateToRoutes = false,
startupOfflineOnly = false,
startupLoadInProgress = true,
),
)
}
@Test
fun startupAndManualOfflineLoadsRemainAvailable() {
assertTrue(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = true,
startupOfflineOnly = false,
),
)
assertTrue(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = false,
startupOfflineOnly = true,
),
)
}
@Test
fun successfulBootstrapAlwaysLeavesInitializingScreen() {
assertEquals(
DriverScreen.Routes,
screenAfterBootstrap(
currentScreen = DriverScreen.Initializing,
navigateToRoutes = false,
leaveRequestsEnabled = true,
),
)
}
@Test @Test
fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() { fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() {
val state = DriverUiState( val state = DriverUiState(
@@ -55,6 +121,23 @@ class DriverUiStateTest {
assertEquals(1, refreshed.leaveRequests.size) 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 @Test
fun displayRoutesAndSelectedRouteUseLocalRouteActionsProjection() { fun displayRoutesAndSelectedRouteUseLocalRouteActionsProjection() {
val route = route(status = "ZAPLANOWANA") val route = route(status = "ZAPLANOWANA")
@@ -70,18 +153,92 @@ class DriverUiStateTest {
assertEquals(12.5, state.displaySelectedRoute?.loadingWeight) 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( DriverLeaveRequestDto(
id = id, id = id,
dateFrom = "2026-07-10", dateFrom = "2026-07-10",
dateTo = "2026-07-12", dateTo = "2026-07-12",
type = "URLOP", type = "URLOP",
status = status, 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( DriverRouteDto(
id = "1", id = id,
routeDate = "2026-06-30",
ordering = ordering,
dayOrderings = dayOrderings,
startsAt = "", startsAt = "",
originName = "Baza", originName = "Baza",
destinationName = "Instalacja", destinationName = "Instalacja",
@@ -0,0 +1,30 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class LiveVoiceDictationTest {
@Test
fun recognizedTextIsAppendedToExistingTypedNote() {
assertEquals(
"Oczekiwanie na dokumenty kierowca odebrał WZ",
mergeDictationText("Oczekiwanie na dokumenty ", " kierowca odebrał WZ "),
)
}
@Test
fun recognizedTextCanStartAnEmptyNote() {
assertEquals(
"Uszkodzona plomba",
mergeDictationText("", " Uszkodzona plomba "),
)
}
@Test
fun routeNotesAreLimitedToApiMaximum() {
assertEquals(
routeStageNotesMaxLength,
normalizeRouteStageNotes("a".repeat(routeStageNotesMaxLength + 20)).length,
)
}
}
@@ -0,0 +1,48 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class OtpAttemptCoordinatorTest {
@Test
fun buffersSmsReceivedBeforeOtpRequestCompletes() {
val coordinator = OtpAttemptCoordinator()
val attemptId = coordinator.beginAttempt()
assertTrue(coordinator.bufferRetrievedCode(attemptId, "123456"))
assertEquals("123456", coordinator.takeBufferedCode(attemptId))
assertNull(coordinator.takeBufferedCode(attemptId))
}
@Test
fun ignoresConsentResultFromPreviousRequest() {
val coordinator = OtpAttemptCoordinator()
val previousAttempt = coordinator.beginAttempt()
val currentAttempt = coordinator.beginAttempt()
assertFalse(coordinator.bufferRetrievedCode(previousAttempt, "111111"))
assertTrue(coordinator.bufferRetrievedCode(currentAttempt, "222222"))
assertEquals("222222", coordinator.takeBufferedCode(currentAttempt))
}
@Test
fun acceptsOnlyOtpLengthsSupportedByApi() {
val coordinator = OtpAttemptCoordinator()
val attemptId = coordinator.beginAttempt()
assertFalse(coordinator.bufferRetrievedCode(attemptId, "123"))
assertTrue(coordinator.bufferRetrievedCode(attemptId, "1234"))
assertTrue(coordinator.bufferRetrievedCode(attemptId, "1234567890"))
assertFalse(coordinator.bufferRetrievedCode(attemptId, "12345678901"))
}
@Test
fun roundsResendCooldownUpToFullSeconds() {
assertEquals(30, otpResendDelaySeconds(40_000L, 10_000L))
assertEquals(1, otpResendDelaySeconds(10_001L, 10_000L))
assertEquals(0, otpResendDelaySeconds(10_000L, 10_001L))
}
}
@@ -38,4 +38,13 @@ class OtpAutoSubmitPolicyTest {
assertFalse(policy.shouldSubmit("123456", loading = true)) assertFalse(policy.shouldSubmit("123456", loading = true))
assertFalse(policy.shouldSubmit("1234567", loading = false)) assertFalse(policy.shouldSubmit("1234567", loading = false))
} }
@Test
fun submitsCompleteRetrievedCodesAcceptedByApi() {
val policy = OtpAutoSubmitPolicy()
assertTrue(policy.shouldSubmitRetrieved("1234", loading = false))
assertTrue(policy.shouldSubmitRetrieved("1234567890", loading = false))
assertFalse(policy.shouldSubmitRetrieved("123", loading = false))
}
} }
@@ -0,0 +1,43 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteDatePickerBoundsTest {
@Test
fun usesTheSameMinAndMaxDatesAsTheRouteSelector() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-24",
minDate = "2026-07-17",
maxDate = "2026-07-26",
)
assertEquals(LocalDate.parse("2026-07-24"), bounds.selectedDate)
assertEquals(LocalDate.parse("2026-07-17"), bounds.minDate)
assertEquals(LocalDate.parse("2026-07-26"), bounds.maxDate)
}
@Test
fun clampsSelectedDateToAllowedRange() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-30",
minDate = "2026-07-17",
maxDate = "2026-07-26",
)
assertEquals(LocalDate.parse("2026-07-26"), bounds.selectedDate)
}
@Test
fun normalizesAccidentallyReversedServerBounds() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-24",
minDate = "2026-07-26",
maxDate = "2026-07-17",
)
assertEquals(LocalDate.parse("2026-07-17"), bounds.minDate)
assertEquals(LocalDate.parse("2026-07-26"), bounds.maxDate)
}
}
+4 -1
View File
@@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.7.3" agp = "8.10.1"
kotlin = "2.1.10" kotlin = "2.1.10"
composeBom = "2026.03.00" composeBom = "2026.03.00"
activityCompose = "1.9.3" activityCompose = "1.9.3"
@@ -17,6 +17,7 @@ room = "2.7.0"
ksp = "2.1.10-1.0.31" ksp = "2.1.10-1.0.31"
playServicesAuth = "21.6.0" playServicesAuth = "21.6.0"
playServicesAuthApiPhone = "18.3.0" playServicesAuthApiPhone = "18.3.0"
playAppUpdate = "2.1.0"
junit = "4.13.2" junit = "4.13.2"
firebaseBom = "33.7.0" firebaseBom = "33.7.0"
googleServices = "4.4.2" googleServices = "4.4.2"
@@ -52,6 +53,8 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" } play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" }
play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" } play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" }
play-app-update = { group = "com.google.android.play", name = "app-update", version.ref = "playAppUpdate" }
play-app-update-ktx = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "playAppUpdate" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
+824
View File
@@ -0,0 +1,824 @@
#!/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
GOOGLE_PLAY_MODE="ask"
REUSE_CURRENT=0
VERSION_FILE_CHANGED=0
VERSION_COMMITTED=0
VERSION_BACKUP=""
RELEASE_SUCCEEDED=0
GOOGLE_PLAY_ACCESS_TOKEN=""
GOOGLE_PLAY_EDIT_ID=""
GOOGLE_PLAY_TEMP_DIR=""
GOOGLE_PLAY_CURRENT_VERSION_STATE=""
GOOGLE_PLAY_SUMMARY="pominięto"
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 i publikuje go w teście zamkniętym Google Play,
- wysyła zmiany Google Play do weryfikacji,
- 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.
--skip-google-play Nie pytaj i pomiń wysyłanie AAB do Google Play.
--publish-google-play
Nie pytaj i wyślij AAB do Google Play.
--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.
GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS
Opcjonalne nadpisanie automatycznie znalezionego klucza JSON.
Domyślnie skrypt znajduje konto serwisowe w scripts/*.json.
Alternatywnie: GOOGLE_APPLICATION_CREDENTIALS.
GOOGLE_PLAY_PACKAGE_NAME
Identyfikator aplikacji, domyślnie pl.firmatpp.kierowca.
GOOGLE_PLAY_TRACK Nazwa testu zamkniętego, domyślnie alpha.
GOOGLE_PLAY_RELEASE_LANGUAGE
Język informacji o wersji, domyślnie pl-PL.
Wersja początkowa jest zawsze odczytywana z app/build.gradle.kts.
Google Play otrzymuje AAB przez Android Publisher API. Konto serwisowe musi mieć
uprawnienie „Publikowanie aplikacji na ścieżkach testowych”.
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
;;
--skip-google-play)
GOOGLE_PLAY_MODE="skip"
shift
;;
--publish-google-play)
GOOGLE_PLAY_MODE="publish"
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; }
if [[ -n "$NOTES_FILE" && ! -f "$NOTES_FILE" ]]; then
echo "Nie istnieje plik release notes: $NOTES_FILE" >&2
exit 1
fi
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}"
elif [[ "$remote_url" =~ ^ssh://([^/@]+@)?([^/:]+)(:[0-9]+)?/(.+)$ ]]; then
detected_protocol="https"
detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[4]%.git}"
elif [[ "$remote_url" =~ ^([^/@:]+@)?([^/:]+):(.+)$ ]]; then
detected_protocol="https"
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%/}"
if [[ "$gitea_base_url" =~ ^(https?)://([^/]+)$ ]]; then
gitea_api_protocol="${BASH_REMATCH[1]}"
gitea_api_host="${BASH_REMATCH[2]}"
else
echo "Ustaw poprawne GITEA_BASE_URL." >&2
exit 1
fi
[[ "$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' "$gitea_api_protocol" "$gitea_api_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)) && [[ -n "$GOOGLE_PLAY_EDIT_ID" && -n "$GOOGLE_PLAY_ACCESS_TOKEN" ]]; then
package_encoded="$(urlencode "$google_play_package")"
edit_encoded="$(urlencode "$GOOGLE_PLAY_EDIT_ID")"
curl -sS -o /dev/null \
--connect-timeout 20 \
--max-time 60 \
-X DELETE \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$package_encoded/edits/$edit_encoded" || true
fi
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 [[ -n "$GOOGLE_PLAY_TEMP_DIR" && -d "$GOOGLE_PLAY_TEMP_DIR" ]]; then
rm -rf -- "$GOOGLE_PLAY_TEMP_DIR"
fi
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
urlencode() {
jq -rn --arg value "$1" '$value | @uri'
}
base64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
show_google_play_error() {
local response_file="$1"
jq '.error | {code, message, status, details}' "$response_file" >&2 2>/dev/null || true
}
decide_google_play_upload() {
local answer
case "$GOOGLE_PLAY_MODE" in
publish)
return 0
;;
skip)
GOOGLE_PLAY_SUMMARY="pominięto (--skip-google-play)"
return 0
;;
ask)
if [[ ! -t 0 ]]; then
echo "Nie można zapytać o publikację Google Play bez interaktywnego terminala." >&2
echo "Użyj --publish-google-play albo --skip-google-play." >&2
return 1
fi
while true; do
printf "\nBuild %s (%s) zakończony poprawnie. Czy wysłać aktualizację do Google Play? [t/N] " "$next_name" "$next_code"
if ! IFS= read -r answer; then
echo "Nie udało się odczytać odpowiedzi." >&2
return 1
fi
case "${answer,,}" in
t|tak|y|yes)
GOOGLE_PLAY_MODE="publish"
break
;;
""|n|nie|no)
GOOGLE_PLAY_MODE="skip"
GOOGLE_PLAY_SUMMARY="pominięto przez użytkownika"
break
;;
*)
echo "Nie rozpoznano odpowiedzi. Wpisz tak albo nie." >&2
;;
esac
done
;;
*)
echo "Niepoprawny tryb publikacji Google Play: $GOOGLE_PLAY_MODE" >&2
return 1
;;
esac
}
prepare_google_play() {
local credentials_path client_email now expires header payload unsigned_jwt signature assertion previous_umask
local token_response token_status preflight_response preflight_status package_encoded track_encoded
local candidate
local -a credentials_candidates=()
google_play_package="${GOOGLE_PLAY_PACKAGE_NAME:-pl.firmatpp.kierowca}"
google_play_track="${GOOGLE_PLAY_TRACK:-alpha}"
google_play_release_language="${GOOGLE_PLAY_RELEASE_LANGUAGE:-pl-PL}"
credentials_path="${GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS:-${GOOGLE_APPLICATION_CREDENTIALS:-}}"
if [[ -z "$credentials_path" ]]; then
while IFS= read -r -d '' candidate; do
if jq -e '
.type == "service_account"
and (.client_email | type == "string" and length > 0)
and (.private_key | type == "string" and length > 0)
' "$candidate" >/dev/null 2>&1; then
credentials_candidates+=("$candidate")
fi
done < <(find "$ROOT_DIR/scripts" -maxdepth 1 -type f -name '*.json' -print0)
case "${#credentials_candidates[@]}" in
0)
echo "Nie znaleziono klucza konta serwisowego w $ROOT_DIR/scripts/*.json." >&2
echo "Umieść dokładnie jeden poprawny plik JSON konta serwisowego w folderze scripts." >&2
return 1
;;
1)
credentials_path="${credentials_candidates[0]}"
;;
*)
echo "Znaleziono więcej niż jeden klucz konta serwisowego w folderze scripts:" >&2
for candidate in "${credentials_candidates[@]}"; do
printf ' - %s\n' "$(basename "$candidate")" >&2
done
echo "Pozostaw jeden plik albo ustaw GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS." >&2
return 1
;;
esac
fi
command -v openssl >/dev/null 2>&1 || {
echo "Brak wymaganego polecenia: openssl" >&2
return 1
}
[[ "$google_play_package" =~ ^[A-Za-z0-9._]+$ ]] || {
echo "Niepoprawne GOOGLE_PLAY_PACKAGE_NAME: $google_play_package" >&2
return 1
}
[[ -n "$google_play_track" ]] || { echo "GOOGLE_PLAY_TRACK nie może być puste." >&2; return 1; }
[[ "$google_play_release_language" =~ ^[A-Za-z0-9-]+$ ]] || {
echo "Niepoprawne GOOGLE_PLAY_RELEASE_LANGUAGE: $google_play_release_language" >&2
return 1
}
[[ -n "$credentials_path" ]] || {
echo "Nie wskazano ani nie znaleziono klucza konta serwisowego Google Play." >&2
return 1
}
[[ -r "$credentials_path" ]] || {
echo "Nie można odczytać klucza konta serwisowego: $credentials_path" >&2
return 1
}
GOOGLE_PLAY_TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tpp-google-play.XXXXXX")"
chmod 700 "$GOOGLE_PLAY_TEMP_DIR"
client_email="$(jq -er '.client_email | select(type == "string" and length > 0)' "$credentials_path")" || {
echo "Klucz Google Play nie zawiera client_email." >&2
return 1
}
previous_umask="$(umask)"
umask 077
if ! jq -er '.private_key | select(type == "string" and length > 0)' "$credentials_path" > "$GOOGLE_PLAY_TEMP_DIR/private-key.pem"; then
umask "$previous_umask"
echo "Klucz Google Play nie zawiera private_key." >&2
return 1
fi
umask "$previous_umask"
now="$(date +%s)"
expires=$((now + 3600))
header="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | base64url)"
payload="$(jq -cn \
--arg iss "$client_email" \
--arg scope 'https://www.googleapis.com/auth/androidpublisher' \
--arg aud 'https://oauth2.googleapis.com/token' \
--argjson iat "$now" \
--argjson exp "$expires" \
'{iss:$iss,scope:$scope,aud:$aud,iat:$iat,exp:$exp}' | base64url)"
unsigned_jwt="$header.$payload"
signature="$(printf '%s' "$unsigned_jwt" \
| openssl dgst -sha256 -sign "$GOOGLE_PLAY_TEMP_DIR/private-key.pem" \
| base64url)"
assertion="$unsigned_jwt.$signature"
printf '%s' "$assertion" > "$GOOGLE_PLAY_TEMP_DIR/assertion.txt"
chmod 600 "$GOOGLE_PLAY_TEMP_DIR/assertion.txt"
token_response="$GOOGLE_PLAY_TEMP_DIR/token.json"
token_status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$token_response" \
-w '%{http_code}' \
-X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
--data-urlencode "assertion@$GOOGLE_PLAY_TEMP_DIR/assertion.txt" \
'https://oauth2.googleapis.com/token')"
[[ "$token_status" == "200" ]] || {
echo "Nie udało się pobrać tokenu Google Play (HTTP $token_status)." >&2
jq '{error, error_description}' "$token_response" >&2 2>/dev/null || true
return 1
}
GOOGLE_PLAY_ACCESS_TOKEN="$(jq -er '.access_token | select(type == "string" and length > 0)' "$token_response")" || {
echo "Google OAuth nie zwrócił access_token." >&2
return 1
}
printf 'Authorization: Bearer %s\n' "$GOOGLE_PLAY_ACCESS_TOKEN" > "$GOOGLE_PLAY_TEMP_DIR/auth-header.txt"
chmod 600 "$GOOGLE_PLAY_TEMP_DIR/auth-header.txt"
rm -f "$GOOGLE_PLAY_TEMP_DIR/private-key.pem" "$GOOGLE_PLAY_TEMP_DIR/assertion.txt" "$token_response"
package_encoded="$(urlencode "$google_play_package")"
track_encoded="$(urlencode "$google_play_track")"
preflight_response="$GOOGLE_PLAY_TEMP_DIR/preflight.json"
preflight_status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$preflight_response" \
-w '%{http_code}' \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$package_encoded/tracks/$track_encoded/releases")"
[[ "$preflight_status" == "200" ]] || {
echo "Brak dostępu do ścieżki Google Play $google_play_track dla $google_play_package (HTTP $preflight_status)." >&2
show_google_play_error "$preflight_response"
return 1
}
GOOGLE_PLAY_CURRENT_VERSION_STATE="$(jq -r --argjson code "$next_code" '
.releases[]?
| select(any(.activeArtifacts[]?; .versionCode == $code))
| .releaseLifecycleState // empty
' "$preflight_response" | sed -n '1p')"
if [[ -n "$GOOGLE_PLAY_CURRENT_VERSION_STATE" && "$REUSE_CURRENT" == "0" ]]; then
echo "Google Play ma już versionCode $next_code na ścieżce $google_play_track ($GOOGLE_PLAY_CURRENT_VERSION_STATE)." >&2
echo "Zwiększ wersję lokalną albo świadomie użyj --reuse-current." >&2
return 1
fi
if [[ "$GOOGLE_PLAY_CURRENT_VERSION_STATE" == "RELEASE_LIFECYCLE_STATE_NOT_APPROVED" ]]; then
echo "Wersja $next_code została odrzucona w Google Play. Zwiększ versionCode i utwórz nowe wydanie." >&2
return 1
fi
echo "Google Play: używam klucza $(basename "$credentials_path")."
echo "Google Play: zweryfikowano dostęp do $google_play_package, ścieżka $google_play_track."
}
publish_to_google_play() {
local release_notes="$1"
local package_encoded track_encoded edit_encoded api_base
local response status bundle_version_code existing_sha256 local_sha256
local play_notes track_payload release_name
case "$GOOGLE_PLAY_CURRENT_VERSION_STATE" in
RELEASE_LIFECYCLE_STATE_IN_REVIEW|RELEASE_LIFECYCLE_STATE_APPROVED_NOT_PUBLISHED|RELEASE_LIFECYCLE_STATE_PUBLISHED)
GOOGLE_PLAY_SUMMARY="już istnieje: $GOOGLE_PLAY_CURRENT_VERSION_STATE"
echo "Google Play: wersja $next_name ($next_code) ma już stan $GOOGLE_PLAY_CURRENT_VERSION_STATE; pomijam ponowną publikację."
return 0
;;
esac
package_encoded="$(urlencode "$google_play_package")"
track_encoded="$(urlencode "$google_play_track")"
api_base="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$package_encoded"
response="$GOOGLE_PLAY_TEMP_DIR/edit-create.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
-H 'Content-Type: application/json' \
-d '{}' \
"$api_base/edits")"
[[ "$status" == "200" ]] || {
echo "Nie udało się utworzyć edycji Google Play (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
GOOGLE_PLAY_EDIT_ID="$(jq -er '.id | select(type == "string" and length > 0)' "$response")" || {
echo "Google Play nie zwrócił identyfikatora edycji." >&2
return 1
}
edit_encoded="$(urlencode "$GOOGLE_PLAY_EDIT_ID")"
response="$GOOGLE_PLAY_TEMP_DIR/track-get.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded/tracks/$track_encoded")"
[[ "$status" == "200" ]] || {
echo "Nie znaleziono ścieżki Google Play $google_play_track (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
response="$GOOGLE_PLAY_TEMP_DIR/bundles.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded/bundles")"
[[ "$status" == "200" ]] || {
echo "Nie udało się sprawdzić pakietów Google Play (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
existing_sha256="$(jq -r --argjson code "$next_code" '.bundles[]? | select(.versionCode == $code) | .sha256 // empty' "$response" | sed -n '1p')"
local_sha256="$(sha256sum "$artifact_dir/google-play/$play_aab_name" | awk '{print $1}')"
if [[ -n "$existing_sha256" ]]; then
if [[ "${existing_sha256,,}" != "${local_sha256,,}" && "$REUSE_CURRENT" == "0" ]]; then
echo "Google Play ma już versionCode $next_code z inną sumą SHA-256." >&2
return 1
fi
if [[ "${existing_sha256,,}" != "${local_sha256,,}" ]]; then
echo "Uwaga: lokalny AAB ma inną sumę niż istniejący versionCode $next_code; --reuse-current zachowuje pakiet już wysłany do Google Play." >&2
fi
bundle_version_code="$next_code"
echo "Google Play ma już AAB $next_name ($next_code); pomijam ponowny upload."
else
response="$GOOGLE_PLAY_TEMP_DIR/bundle-upload.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 600 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
-H 'Content-Type: application/octet-stream' \
--data-binary "@$artifact_dir/google-play/$play_aab_name" \
"https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/$package_encoded/edits/$edit_encoded/bundles?uploadType=media")"
[[ "$status" == "200" ]] || {
echo "Nie udało się wysłać AAB do Google Play (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
bundle_version_code="$(jq -er '.versionCode' "$response")" || {
echo "Google Play nie zwrócił versionCode przesłanego AAB." >&2
return 1
}
[[ "$bundle_version_code" == "$next_code" ]] || {
echo "Google Play odczytał versionCode $bundle_version_code zamiast $next_code." >&2
return 1
}
fi
play_notes="$(jq -rn --arg notes "$release_notes" '$notes | if length > 500 then .[0:497] + "..." else . end')"
release_name="TPP Kierowca $next_name ($next_code)"
track_payload="$(jq -n \
--arg track "$google_play_track" \
--arg release_name "$release_name" \
--arg version_code "$bundle_version_code" \
--arg language "$google_play_release_language" \
--arg notes "$play_notes" \
'{track:$track,releases:[{name:$release_name,versionCodes:[$version_code],status:"completed",releaseNotes:[{language:$language,text:$notes}]}]}')"
response="$GOOGLE_PLAY_TEMP_DIR/track-update.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-X PUT \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
-H 'Content-Type: application/json' \
-d "$track_payload" \
"$api_base/edits/$edit_encoded/tracks/$track_encoded")"
[[ "$status" == "200" ]] || {
echo "Nie udało się przypisać AAB do ścieżki $google_play_track (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
response="$GOOGLE_PLAY_TEMP_DIR/edit-validate.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 120 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded:validate")"
[[ "$status" == "200" ]] || {
echo "Walidacja edycji Google Play nie powiodła się (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
response="$GOOGLE_PLAY_TEMP_DIR/edit-commit.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 120 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded:commit?changesNotSentForReview=false&changesInReviewBehavior=ERROR_IF_IN_REVIEW")"
[[ "$status" == "200" ]] || {
echo "Nie udało się zatwierdzić i wysłać zmian Google Play do weryfikacji (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
GOOGLE_PLAY_EDIT_ID=""
GOOGLE_PLAY_SUMMARY="wysłano do weryfikacji"
echo "Google Play: $release_name wysłano na ścieżkę $google_play_track i przekazano do weryfikacji."
}
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
)
decide_google_play_upload
if [[ "$GOOGLE_PLAY_MODE" == "publish" ]]; then
prepare_google_play
fi
if ((REUSE_CURRENT == 0)); then
git add "$VERSION_FILE"
git diff --cached --check
git commit -m "Wydaj aplikację kierowcy $next_name ($next_code)"
VERSION_COMMITTED=1
fi
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
}
if [[ -n "$NOTES_FILE" ]]; then
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
if [[ "$GOOGLE_PLAY_MODE" == "publish" ]]; then
publish_to_google_play "$release_notes"
GOOGLE_PLAY_ACCESS_TOKEN=""
rm -f "$GOOGLE_PLAY_TEMP_DIR/auth-header.txt"
fi
git push "$REMOTE_NAME" "$branch"
git push "$REMOTE_NAME" "$tag_name"
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
[[ -n "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP"
if [[ -n "$GOOGLE_PLAY_TEMP_DIR" && -d "$GOOGLE_PLAY_TEMP_DIR" ]]; then
rm -rf -- "$GOOGLE_PLAY_TEMP_DIR"
GOOGLE_PLAY_TEMP_DIR=""
fi
trap - EXIT INT TERM
cat <<EOF
Release zakończony:
Wersja: $next_name ($next_code)
Tag: $tag_name
Gitea: $release_url
APK: $artifact_dir/gitea/$gitea_apk_name
AAB: $artifact_dir/google-play/$play_aab_name
Google Play: $GOOGLE_PLAY_SUMMARY
Play track: ${google_play_track:-pominięto}
Checksumy: $artifact_dir/SHA256SUMS.txt
EOF