Compare commits

..
Author SHA1 Message Date
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
60 changed files with 4522 additions and 295 deletions
+2
View File
@@ -9,3 +9,5 @@ keystore.properties
*.jks *.jks
release-artifacts/ release-artifacts/
app/src/main/res_bak/ app/src/main/res_bak/
scripts/*.json
scripts/*.json:Zone.Identifier
+45 -3
View File
@@ -31,9 +31,51 @@ Przed wydaniem zapisz wszystkie zmiany źródłowe w commicie. Następnie urucho
``` ```
Skrypt automatycznie zwiększa `versionCode` i ostatni człon `versionName`, uruchamia testy, Skrypt automatycznie zwiększa `versionCode` i ostatni człon `versionName`, uruchamia testy,
buduje podpisany APK dla Gitea oraz AAB dla Google Play Console, tworzy commit wersji, buduje podpisany APK oraz AAB, a po poprawnym buildzie pyta, czy wysłać aktualizację do
tag, push i Gitea Release. Artefakty zostają również zapisane lokalnie w Google Play. Po odpowiedzi „tak” publikuje AAB w skonfigurowanym teście zamkniętym i
`release-artifacts/<tag>/`. 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. 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ć: Jeśli publikacja została przerwana już po utworzeniu commita wersji, można ją wznowić:
+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 = 108 versionCode = 124
versionName = "1.0.55" versionName = "1.0.71"
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"
@@ -16,7 +16,10 @@ class DriverApplication : Application() {
AppDiagnostics.installFirebaseCrashlytics() AppDiagnostics.installFirebaseCrashlytics()
AppDiagnostics.log("app_started") AppDiagnostics.log("app_started")
applicationScope.launch { applicationScope.launch {
runCatching { OfflineOutboxManager(this@DriverApplication).repairGpsQueue() } 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") } .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) }
} }
@@ -12,6 +12,7 @@ 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
@@ -33,6 +34,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
@@ -56,6 +58,12 @@ 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)
@@ -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
@@ -67,6 +69,12 @@ 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,
@@ -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,6 +73,7 @@ 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 loadingPhotoRequirement: String? = null,
val loadingWeightRequirement: String? = null, val loadingWeightRequirement: String? = null,
val unloadingPhotoRequirement: String? = null, val unloadingPhotoRequirement: String? = null,
@@ -69,6 +81,7 @@ data class DriverAppSettingsDto(
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,
) )
@@ -88,6 +101,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,
@@ -265,14 +302,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,
@@ -280,6 +322,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(),
@@ -336,6 +381,7 @@ data class StartRouteBody(
val loadingWeight: Double?, val loadingWeight: Double?,
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val loadingNotes: String? = null,
) )
data class FinishRouteBody( data class FinishRouteBody(
@@ -343,6 +389,7 @@ data class FinishRouteBody(
val unloadingWeight: Double?, val unloadingWeight: Double?,
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val unloadingNotes: String? = null,
) )
data class RouteActionResponse( data class RouteActionResponse(
@@ -70,6 +70,9 @@ class DriverSyncRepository(
) )
} }
suspend fun hasBootstrapCache(date: String?): Boolean =
dao.bootstrap(date ?: currentDateFallback()) != null
suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? { suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? {
val cached = dao.route(routeId) ?: return null val cached = dao.route(routeId) ?: return null
return CachedValue( return CachedValue(
@@ -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.",
)
}
}
@@ -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,6 +8,7 @@ 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 import pl.firmatpp.kierowca.data.sync.NetworkMonitor
@@ -68,6 +69,7 @@ class DispatchSheetUploadWorker(
dao.delete(clientRequestId) 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 = 6, version = 7,
exportSchema = false, exportSchema = false,
) )
abstract class DriverDatabase : RoomDatabase() { abstract class DriverDatabase : RoomDatabase() {
@@ -202,13 +202,26 @@ abstract class DriverDatabase : RoomDatabase() {
} }
} }
private val migration6To7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE route_actions ADD COLUMN notes TEXT")
}
}
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, migration5To6).build().also { instance = it } ).addMigrations(
migration1To2,
migration2To3,
migration3To4,
migration4To5,
migration5To6,
migration6To7,
).build().also { instance = it }
} }
} }
} }
@@ -36,6 +36,24 @@ class OfflineOutboxManager(private val context: Context) {
} }
} }
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() { suspend fun clearAll() {
val photos = database.photoUploadDao().allRecords() val photos = database.photoUploadDao().allRecords()
val dispatchSheets = database.dispatchSheetUploadDao().allRecords() val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
@@ -61,3 +79,31 @@ class OfflineOutboxManager(private val context: Context) {
database.routePointDao().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))
)
@@ -16,6 +16,7 @@ import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
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,
@@ -74,6 +75,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 +83,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 +123,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,6 +8,7 @@ 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 import pl.firmatpp.kierowca.data.sync.NetworkMonitor
@@ -70,6 +71,7 @@ class PhotoUploadWorker(
enqueueRouteActionsWaitingForPhoto(clientRequestId) 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,
@@ -14,6 +14,61 @@ 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'") @Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'")
suspend fun allUnsent(): List<RouteActionEntity> suspend fun allUnsent(): List<RouteActionEntity>
@@ -38,4 +38,5 @@ data class RouteActionEntity(
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,
) )
@@ -31,17 +31,40 @@ class RouteActionOutbox(
dao.delete(clientActionId) dao.delete(clientActionId)
} }
suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity = suspend fun retry(clientActionId: String) {
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds) val existing = dao.find(clientActionId) ?: return
dao.upsert(
existing.copy(
status = RouteActionStatus.Pending,
lastError = null,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)
enqueueWorker(clientActionId)
}
suspend fun enqueueFinish(routeId: String, unloadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity = suspend fun enqueueStart(
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds) routeId: String,
loadingWeight: Double?,
photoClientRequestIds: List<String>,
notes: String? = null,
): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes)
suspend fun enqueueFinish(
routeId: String,
unloadingWeight: Double?,
photoClientRequestIds: List<String>,
notes: String? = null,
): RouteActionEntity =
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes)
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?,
): RouteActionEntity { ): RouteActionEntity {
dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)) dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
val entity = RouteActionEntity( val entity = RouteActionEntity(
@@ -51,6 +74,7 @@ class RouteActionOutbox(
weight = weight, weight = weight,
occurredAt = Instant.now().toString(), occurredAt = Instant.now().toString(),
photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()), photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()),
notes = notes?.trim()?.takeIf { it.isNotEmpty() },
) )
dao.upsert(entity) dao.upsert(entity)
@@ -7,9 +7,12 @@ 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.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
@@ -30,6 +33,33 @@ class RouteActionWorker(
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry() 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 photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) } val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
@@ -71,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",
@@ -87,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
@@ -97,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,
) )
@@ -114,6 +180,7 @@ class RouteActionWorker(
loadingWeight = action.weight, loadingWeight = action.weight,
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
loadingNotes = action.notes,
), ),
) )
RouteActionType.Finish -> repository.finishRoute( RouteActionType.Finish -> repository.finishRoute(
@@ -123,6 +190,7 @@ class RouteActionWorker(
unloadingWeight = action.weight, unloadingWeight = action.weight,
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
unloadingNotes = action.notes,
), ),
) )
else -> error("Nieznana akcja kursu: ${action.action}") else -> error("Nieznana akcja kursu: ${action.action}")
@@ -142,3 +210,84 @@ class RouteActionWorker(
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
}
}
@@ -8,6 +8,7 @@ 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
@@ -52,6 +53,7 @@ class RoutePointWorker(
} }
val throwable = outcome.exceptionOrNull() ?: return Result.retry() val throwable = outcome.exceptionOrNull() ?: return Result.retry()
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -65,9 +67,11 @@ class RoutePointWorker(
"retryable" to error.retryable, "retryable" to error.retryable,
), ),
) )
if (shouldStopRouteTrackingAfterPointError(error.kind, error.code)) {
ActiveRouteTrackingService.stop(applicationContext, routeId)
}
if (isInactiveRoutePointError(error.code)) { if (isInactiveRoutePointError(error.code)) {
dao.delete(pointIds) dao.delete(pointIds)
ActiveRouteTrackingService.stop(applicationContext, routeId)
continue continue
} }
@@ -103,3 +107,6 @@ class RoutePointWorker(
} }
internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS" internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS"
internal fun shouldStopRouteTrackingAfterPointError(kind: ApiErrorKind, code: String?): Boolean =
kind == ApiErrorKind.Auth || isInactiveRoutePointError(code)
@@ -229,18 +229,28 @@ class DiagnosticSnapshotProvider(private val context: Context) {
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val links = activeNetwork?.let(manager::getLinkProperties) 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( return listOf(
DiagnosticEntry("Transport", transports.joinToString()), DiagnosticEntry("Transport", transports.joinToString()),
DiagnosticEntry("Internet zweryfikowany", yesNo(validated), if (validated) DiagnosticSeverity.Good else DiagnosticSeverity.Error), 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_INTERNET", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET))),
DiagnosticEntry("NET_CAPABILITY_VALIDATED", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))), DiagnosticEntry("NET_CAPABILITY_VALIDATED", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))),
DiagnosticEntry("Sieć mierzona", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))), DiagnosticEntry("Sieć mierzona", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))),
DiagnosticEntry("Roaming", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))), DiagnosticEntry("Roaming", roaming),
DiagnosticEntry("Pasmo downstream / upstream", "${capabilities.linkDownstreamBandwidthKbps} / ${capabilities.linkUpstreamBandwidthKbps} kb/s"), DiagnosticEntry("Pasmo downstream / upstream", "${capabilities.linkDownstreamBandwidthKbps} / ${capabilities.linkUpstreamBandwidthKbps} kb/s"),
DiagnosticEntry("Interfejs", links?.interfaceName ?: "brak"), DiagnosticEntry("Interfejs", links?.interfaceName ?: "brak"),
DiagnosticEntry("Adresy IP", links?.linkAddresses?.joinToString { it.toString() }?.ifBlank { "brak" } ?: "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("DNS", links?.dnsServers?.joinToString { it.hostAddress ?: it.toString() }?.ifBlank { "brak" } ?: "brak"),
DiagnosticEntry("MTU", links?.mtu?.toString() ?: "brak"), DiagnosticEntry("MTU", mtu),
DiagnosticEntry("Proxy", links?.httpProxy?.toString() ?: "brak"), DiagnosticEntry("Proxy", links?.httpProxy?.toString() ?: "brak"),
) )
} }
@@ -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"
}
}
}
@@ -13,6 +13,7 @@ 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.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.model.SyncScopeDto import pl.firmatpp.kierowca.data.model.SyncScopeDto
@@ -79,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,
@@ -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,7 @@ 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.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import retrofit2.HttpException import retrofit2.HttpException
@@ -72,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,6 +55,11 @@ 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) val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
@@ -69,7 +75,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
routeId = id routeId = id
arrived = true arrived = true
runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
} }
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -97,7 +105,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
?: totalDistanceMeters ?: totalDistanceMeters
progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled
if (routeChanged) arrived = false if (routeChanged) arrived = false
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
if (!arrived) requestLocationUpdates() if (!arrived) requestLocationUpdates()
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -113,9 +123,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) { if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) {
arrived = true arrived = true
runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) return
} else { } else {
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) return
} }
val (battery, charging) = batterySnapshot() val (battery, charging) = batterySnapshot()
@@ -136,6 +146,13 @@ 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) getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.edit() .edit()
.remove(ACTIVE_ROUTE_ID) .remove(ACTIVE_ROUTE_ID)
@@ -144,6 +161,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
super.onDestroy() super.onDestroy()
} }
@SuppressLint("MissingPermission")
private fun requestLocationUpdates() { private fun requestLocationUpdates() {
if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) { if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) {
return return
@@ -197,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
} }
} }
@@ -213,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)
} }
@@ -292,6 +324,8 @@ class ActiveRouteTrackingService : Service(), LocationListener {
private const val ACTIVE_ROUTE_ID = "active_route_id" 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)
@@ -302,7 +336,7 @@ 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, routeId: String? = null) { fun stop(context: Context, routeId: String? = null) {
@@ -318,11 +352,20 @@ class ActiveRouteTrackingService : Service(), LocationListener {
} }
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
@@ -22,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
@@ -39,21 +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(), 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(), 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
} }
} }
@@ -1,24 +1,152 @@
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.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,
@@ -97,10 +225,10 @@ fun routeTodayInstruction(
val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate
return when { return when {
route.status == "ZAKOŃCZONA" -> "Kurs zakończony." route.driverLifecycleStatus() == "ZAKOŃCZONA" -> "Kurs zakończony."
loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek." loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek."
today == loadingDate -> "Dziś: załadunek." today == loadingDate -> "Dziś: załadunek."
today == unloadingDate && route.status == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj." today == unloadingDate && route.driverLifecycleStatus() == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj."
today == unloadingDate -> "Dziś: rozładunek." today == unloadingDate -> "Dziś: rozładunek."
else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}." else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}."
} }
@@ -113,7 +241,7 @@ fun canCompleteRouteFromDriverApp(
): Boolean = ): Boolean =
runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today && routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE") && route.driverLifecycleStatus() in setOf("ZAPLANOWANA", "W TRAKCIE")
fun canStartRouteFromDriverApp( fun canStartRouteFromDriverApp(
route: DriverRouteDto, route: DriverRouteDto,
@@ -121,14 +249,14 @@ fun canStartRouteFromDriverApp(
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): Boolean = ): Boolean =
routeIsActiveToday(route, 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 =
route.status == "W TRAKCIE" route.driverLifecycleStatus() == "W TRAKCIE"
&& runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today && runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today && routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
@@ -150,6 +278,33 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean =
fun routeStageRequirementIsRequired(requirement: String): Boolean = fun routeStageRequirementIsRequired(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
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( fun canSubmitRouteStageForm(
weightText: String, weightText: String,
serverPhotoCount: Int, serverPhotoCount: Int,
@@ -188,13 +343,14 @@ fun loadingWeightSubmitBlocker(
photoRequirement: String = RouteStageRequirement.Required, photoRequirement: String = RouteStageRequirement.Required,
): String? { ): String? {
val trimmed = weightText.trim() val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() val normalizedWeight = parseRouteWeightTons(trimmed)
val weightVisible = routeStageRequirementIsVisible(weightRequirement) val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when { return when {
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż." weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny 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 <= 0.0 -> "Tonaż musi być większy od zera."
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku." loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
else -> null else -> null
} }
@@ -208,13 +364,14 @@ fun routeStageSubmitBlocker(
photoRequirement: String = RouteStageRequirement.Required, photoRequirement: String = RouteStageRequirement.Required,
): String? { ): String? {
val trimmed = weightText.trim() val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() val normalizedWeight = parseRouteWeightTons(trimmed)
val weightVisible = routeStageRequirementIsVisible(weightRequirement) val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when { return when {
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż." weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny 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 <= 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." routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
else -> null else -> null
} }
@@ -264,29 +421,30 @@ fun routeFlowSteps(
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 loadingPhotoCount = routePhotosForStage(route.photos, "loading").size val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size
val driverStatus = route.driverLifecycleStatus()
val loadingPhotosState = when { val loadingPhotosState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed startOverride != null -> startOverride
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
loadingPhotoCount > 0 -> RouteFlowStepState.LocalComplete loadingPhotoCount > 0 -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
val loadingWeightState = startOverride ?: when { val loadingWeightState = startOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete 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
} }
@@ -347,7 +505,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"
@@ -441,6 +600,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" }
@@ -452,8 +623,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()
@@ -481,12 +659,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..."
@@ -1,7 +1,10 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import android.Manifest
import android.app.Application import android.app.Application
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
@@ -22,12 +25,16 @@ import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
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.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.sync.StartupOfflineFallbackLoader
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.AppUpdateDto
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.DriverLeaveCalendarEntryDto
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.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -39,6 +46,7 @@ import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionOutbox import pl.firmatpp.kierowca.data.upload.RouteActionOutbox
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
import pl.firmatpp.kierowca.data.upload.isWorkerCancellationFailure
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry
import pl.firmatpp.kierowca.diagnostics.DiagnosticSection import pl.firmatpp.kierowca.diagnostics.DiagnosticSection
@@ -61,8 +69,12 @@ data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing, val screen: DriverScreen = DriverScreen.Initializing,
val loading: Boolean = true, val loading: Boolean = true,
val refreshing: Boolean = false, val refreshing: Boolean = false,
val startupOfflineAvailable: Boolean = false,
val phone: String = "", val phone: String = "",
val otpCode: String = "", val otpCode: String = "",
val otpConsentRequestId: Long = 0L,
val otpRequestPending: Boolean = false,
val otpResendAvailableAtEpochMillis: Long = 0L,
val maskedPhone: String = "", val maskedPhone: String = "",
val driver: DriverDto? = null, val driver: DriverDto? = null,
val routes: List<DriverRouteDto> = emptyList(), val routes: List<DriverRouteDto> = emptyList(),
@@ -73,6 +85,8 @@ data class DriverUiState(
val allowGalleryUploads: Boolean = true, val allowGalleryUploads: Boolean = true,
val allowRouteCompletion: Boolean = false, val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false, val requirePreciseLocationForPhotos: Boolean = false,
val routeLocationMode: String = RouteLocationMode.Optional,
val routeLocationEnabled: Boolean = false,
val loadingPhotoRequirement: String = RouteStageRequirement.Required, val loadingPhotoRequirement: String = RouteStageRequirement.Required,
val loadingWeightRequirement: String = RouteStageRequirement.Required, val loadingWeightRequirement: String = RouteStageRequirement.Required,
val unloadingPhotoRequirement: String = RouteStageRequirement.Required, val unloadingPhotoRequirement: String = RouteStageRequirement.Required,
@@ -83,11 +97,15 @@ data class DriverUiState(
val selectedRoute: DriverRouteDto? = null, val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null, val selectedPhoto: RoutePhotoDto? = null,
val routeStageWeightText: String = "", val routeStageWeightText: String = "",
val routeStageNotesText: String = "",
val routeActions: List<RouteActionEntity> = emptyList(), val routeActions: List<RouteActionEntity> = emptyList(),
val visibleRouteActions: List<RouteActionEntity> = emptyList(), val visibleRouteActions: List<RouteActionEntity> = emptyList(),
val photoUploads: List<PhotoUploadEntity> = emptyList(), val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(), val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val tachographReminderSubmitting: Boolean = false,
val appUpdate: AppUpdateDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(), val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null, val leaveRequestsConfig: LeaveRequestsConfig? = null,
val leaveRequests: List<DriverLeaveRequestDto> = emptyList(), val leaveRequests: List<DriverLeaveRequestDto> = emptyList(),
@@ -129,7 +147,7 @@ data class DriverUiState(
val displayRoutes: List<DriverRouteDto> val displayRoutes: List<DriverRouteDto>
get() = projectDriverRoutes(routes, projectionActions) get() = projectDriverRoutes(routes, projectionActions)
.sortedWith( .sortedWith(
compareBy<DriverRouteDto> { route -> if (route.status == "ZAKOŃCZONA") 1 else 0 } compareBy<DriverRouteDto> { route -> if (route.driverLifecycleStatus() == "ZAKOŃCZONA") 1 else 0 }
.thenBy { route -> routeDayOrdering(route, selectedDate) }, .thenBy { route -> routeDayOrdering(route, selectedDate) },
) )
@@ -188,6 +206,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val appPreferencesStore = AppPreferencesStore(application) private val appPreferencesStore = AppPreferencesStore(application)
private val repository = DriverRepository(application) private val repository = DriverRepository(application)
private val syncRepository = DriverSyncRepository(application, repository) private val syncRepository = DriverSyncRepository(application, repository)
private val startupOfflineFallbackLoader = StartupOfflineFallbackLoader()
private val networkMonitor = NetworkMonitor(application) private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application) private val photoUploadOutbox = PhotoUploadOutbox(application)
private val routeActionOutbox = RouteActionOutbox(application) private val routeActionOutbox = RouteActionOutbox(application)
@@ -196,6 +215,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val diagnosticSnapshotProvider = DiagnosticSnapshotProvider(application) private val diagnosticSnapshotProvider = DiagnosticSnapshotProvider(application)
private val _state = MutableStateFlow(DriverUiState(loading = true)) private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy() private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
private val otpAttemptCoordinator = OtpAttemptCoordinator()
private val liveSyncClient = DriverLiveSyncClient( private val liveSyncClient = DriverLiveSyncClient(
repository = repository, repository = repository,
onConnected = { viewModelScope.launch { checkRemoteSyncState() } }, onConnected = { viewModelScope.launch { checkRemoteSyncState() } },
@@ -215,6 +235,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private var leaveCalendarRequestGeneration: Long = 0 private var leaveCalendarRequestGeneration: Long = 0
private var routesRequestGeneration: Long = 0 private var routesRequestGeneration: Long = 0
private var routeDetailRequestGeneration: Long = 0 private var routeDetailRequestGeneration: Long = 0
private var routesLoadJob: Job? = null
val state: StateFlow<DriverUiState> = _state val state: StateFlow<DriverUiState> = _state
init { init {
@@ -228,6 +249,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeProgressNotificationEnabled = enabled) } _state.update { it.copy(routeProgressNotificationEnabled = enabled) }
} }
} }
viewModelScope.launch {
appPreferencesStore.routeLocationEnabled.collect { enabled ->
_state.update { it.copy(routeLocationEnabled = enabled) }
reconcileTrackingServiceFromState()
}
}
viewModelScope.launch { viewModelScope.launch {
photoUploadOutbox.observeQueuedUploads().collect { uploads -> photoUploadOutbox.observeQueuedUploads().collect { uploads ->
_state.update { it.copy(queuedPhotoUploads = uploads) } _state.update { it.copy(queuedPhotoUploads = uploads) }
@@ -295,6 +322,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
viewModelScope.launch { viewModelScope.launch {
if (repository.hasToken()) { if (repository.hasToken()) {
val offlineAvailable = syncRepository.hasBootstrapCache(_state.value.selectedDate)
_state.update { it.copy(startupOfflineAvailable = offlineAvailable) }
refreshRoutes() refreshRoutes()
} else { } else {
_state.update { it.copy(screen = DriverScreen.Phone, loading = false) } _state.update { it.copy(screen = DriverScreen.Phone, loading = false) }
@@ -302,18 +331,63 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun requestOtp(phone: String) = runLoading("request_otp") { fun requestOtp(phone: String) {
if (!_state.value.isOnline) throw IOException("Logowanie wymaga połączenia z internetem.") val requestSnapshot = _state.value
val response = repository.requestOtp(phone) val now = System.currentTimeMillis()
val resendDelay = if (requestSnapshot.phone == phone) {
otpResendDelaySeconds(requestSnapshot.otpResendAvailableAtEpochMillis, now)
} else {
0
}
if (resendDelay > 0) {
_state.update { it.copy(error = "Nowy SMS możesz wysłać za $resendDelay s.") }
return
}
val isResend = requestSnapshot.screen == DriverScreen.Otp && requestSnapshot.phone == phone
val retainedCode = requestSnapshot.otpCode.takeIf { isResend }.orEmpty()
val attemptId = otpAttemptCoordinator.beginAttempt()
otpAutoSubmitPolicy.reset() otpAutoSubmitPolicy.reset()
_state.update {
it.copy( viewModelScope.launch {
screen = DriverScreen.Otp, _state.update {
phone = phone, it.copy(
otpCode = "", loading = true,
maskedPhone = response.phoneMasked.orEmpty(), phone = phone,
error = null, otpCode = retainedCode,
) otpConsentRequestId = attemptId,
otpRequestPending = true,
error = null,
)
}
kotlinx.coroutines.yield()
val result = runCatching {
if (!_state.value.isOnline) throw IOException("Logowanie wymaga połączenia z internetem.")
repository.requestOtp(phone)
}
if (!otpAttemptCoordinator.isCurrent(attemptId)) return@launch
result.onSuccess { response ->
val bufferedCode = otpAttemptCoordinator.takeBufferedCode(attemptId)
_state.update {
it.copy(
screen = DriverScreen.Otp,
loading = false,
otpCode = bufferedCode ?: retainedCode,
maskedPhone = response.phoneMasked.orEmpty(),
otpRequestPending = false,
otpResendAvailableAtEpochMillis = System.currentTimeMillis() + OTP_RESEND_COOLDOWN_MILLIS,
error = null,
)
}
bufferedCode?.let { applyOtpCode(it, retrievedFromSms = true) }
}.onFailure { throwable ->
otpAttemptCoordinator.discard(attemptId)
reportHandledException("request_otp", throwable)
_state.update { it.withApiError(throwable).copy(loading = false, otpRequestPending = false) }
}
} }
} }
@@ -326,11 +400,35 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
fun updateOtpCode(code: String) { fun updateOtpCode(code: String) {
val sanitized = code.filter(Char::isDigit).take(6) applyOtpCode(sanitizeOtpCode(code), retrievedFromSms = false)
_state.update { it.copy(otpCode = sanitized) } }
if (otpAutoSubmitPolicy.shouldSubmit(sanitized, _state.value.loading)) { fun updateOtpCodeFromSms(attemptId: Long, code: String) {
verifyOtp(sanitized) val sanitized = sanitizeOtpCode(code)
if (!isRetrievedOtpCode(sanitized)) return
val snapshot = _state.value
if (attemptId != snapshot.otpConsentRequestId || !otpAttemptCoordinator.isCurrent(attemptId)) return
if (snapshot.otpRequestPending) {
otpAttemptCoordinator.bufferRetrievedCode(attemptId, sanitized)
return
}
if (snapshot.screen != DriverScreen.Otp) return
applyOtpCode(sanitized, retrievedFromSms = true)
}
private fun applyOtpCode(code: String, retrievedFromSms: Boolean) {
_state.update { it.copy(otpCode = code) }
val shouldSubmit = if (retrievedFromSms) {
otpAutoSubmitPolicy.shouldSubmitRetrieved(code, _state.value.loading)
} else {
otpAutoSubmitPolicy.shouldSubmit(code, _state.value.loading)
}
if (shouldSubmit) {
verifyOtp(code)
} }
} }
@@ -338,6 +436,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false) fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
fun useOfflineStartupDataNow() {
val snapshot = _state.value
if (snapshot.screen != DriverScreen.Initializing || !snapshot.startupOfflineAvailable) {
return
}
loadRoutes(
date = snapshot.selectedDate,
showLoading = true,
navigateToRoutes = true,
startupOfflineOnly = true,
)
}
fun onAppForegrounded() { fun onAppForegrounded() {
DriverRuntimeSyncState.foreground = true DriverRuntimeSyncState.foreground = true
liveSyncClient.setForeground(true) liveSyncClient.setForeground(true)
@@ -361,21 +473,68 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
loadRoutes(date = date, showLoading = true, navigateToRoutes = true) loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
} }
fun acknowledgeTachographReminder(taskIds: List<String>) {
val snapshot = _state.value
if (taskIds.isEmpty() || snapshot.tachographReminderSubmitting) return
if (!snapshot.isOnline) {
_state.update { it.copy(error = "Potwierdzenie odczytu wymaga połączenia z internetem.") }
return
}
viewModelScope.launch {
_state.update {
it.copy(
tachographReminderSubmitting = true,
error = null,
feedback = null,
)
}
runCatching { repository.acknowledgeTachographReminder(taskIds) }
.onSuccess { reminder ->
_state.update {
it.copy(
tachographReminder = reminder,
feedback = "Zapisano potwierdzenie odczytu.",
error = null,
)
}
}
.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(tachographReminderSubmitting = false) }
}
}
fun dismissRouteDayLiveUpdate() { fun dismissRouteDayLiveUpdate() {
_state.update { it.copy(routeDayLiveUpdateMessage = null) } _state.update { it.copy(routeDayLiveUpdateMessage = null) }
} }
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) { private fun loadRoutes(
date: String?,
showLoading: Boolean,
navigateToRoutes: Boolean,
startupOfflineOnly: Boolean = false,
) {
val generation = ++routesRequestGeneration val generation = ++routesRequestGeneration
viewModelScope.launch { routesLoadJob?.cancel()
routesLoadJob = viewModelScope.launch {
val isStartup = _state.value.screen == DriverScreen.Initializing
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) } _state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
runCatching { runCatching {
val cached = if (_state.value.isOnline) { val cached = when {
syncRepository.bootstrap(date) startupOfflineOnly -> syncRepository.bootstrapFromCache(date)
} else {
syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.") ?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
!_state.value.isOnline -> syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
isStartup -> {
startupOfflineFallbackLoader.load(
onlineLoad = { syncRepository.bootstrap(date) },
offlineLoad = { syncRepository.bootstrapFromCache(date) },
)
}
else -> syncRepository.bootstrap(date)
} }
val response = cached.value val response = cached.value
val settings = response.driverAppSettings val settings = response.driverAppSettings
@@ -386,6 +545,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
driver = response.session.driver, driver = response.session.driver,
routes = response.routes.today, routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder, dispatchSheetReminder = response.dispatchSheetReminder,
tachographReminder = response.tachographReminder,
appUpdate = response.appUpdate ?: it.appUpdate,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate, selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate, minRouteDate = settings?.minDate ?: it.minRouteDate,
maxRouteDate = settings?.maxDate ?: it.maxRouteDate, maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
@@ -394,6 +555,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion, allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos, ?: it.requirePreciseLocationForPhotos,
routeLocationMode = normalizeRouteLocationMode(settings?.routeLocationMode),
loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement), loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement),
loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement), loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement),
unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement), unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement),
@@ -418,21 +580,29 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId) if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId)
} }
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate) observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
if (_state.value.isOnline && !cached.stale && settings?.selectedDate == settings?.today) { val trackingSnapshot = _state.value
if (
!shouldTrackRouteLocation(
trackingSnapshot.routeLocationMode,
trackingSnapshot.routeLocationEnabled,
hasLocationPermission(),
)
) {
ActiveRouteTrackingService.stop(getApplication())
} else if (trackingSnapshot.isOnline && !cached.stale && settings?.selectedDate == settings?.today) {
reconcileTrackingService(response.routes.today, response.session.driver) reconcileTrackingService(response.routes.today, response.session.driver)
} }
}.onFailure { throwable -> }.onFailure { throwable ->
if (generation != routesRequestGeneration) return@onFailure if (generation != routesRequestGeneration) return@onFailure
reportHandledException("load_routes", throwable, mapOf("date" to date)) reportHandledException("load_routes", throwable, mapOf("date" to date))
_state.update { if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) {
val apiError = ApiErrorMapper.map(throwable) handleExpiredSession()
it.withApiError(throwable).copy( } else {
screen = when { _state.update {
apiError.kind == ApiErrorKind.Auth -> DriverScreen.Phone it.withApiError(throwable).copy(
it.screen == DriverScreen.Initializing -> DriverScreen.Routes screen = if (it.screen == DriverScreen.Initializing) DriverScreen.Routes else it.screen,
else -> it.screen )
}, }
)
} }
} }
@@ -464,6 +634,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty() DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty()
else -> it.routeStageWeightText else -> it.routeStageWeightText
}, },
routeStageNotesText = when (targetScreen) {
DriverScreen.LoadingWeight -> response.route.loadingNotes.orEmpty()
DriverScreen.FinishRoute -> response.route.unloadingNotes.orEmpty()
else -> it.routeStageNotesText
},
feedback = null, feedback = null,
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
lastServerSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastServerSyncAtEpochMillis, lastServerSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastServerSyncAtEpochMillis,
@@ -495,6 +670,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
photoUploadOutbox.discardConfirmedServerPhotos(response.route) photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update { _state.update {
it.copy( it.copy(
screen = routeScreenAfterServerRefresh(it.screen, response.route),
selectedRoute = response.route, selectedRoute = response.route,
routes = it.routes.map { route -> if (route.id == routeId) response.route else route }, routes = it.routes.map { route -> if (route.id == routeId) response.route else route },
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
@@ -508,7 +684,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
.onFailure { throwable -> .onFailure { throwable ->
if (generation == routeDetailRequestGeneration) _state.update { it.withApiError(throwable) } if (generation != routeDetailRequestGeneration) return@onFailure
if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) {
handleExpiredSession()
} else {
_state.update { it.withApiError(throwable) }
}
} }
if (generation == routeDetailRequestGeneration) _state.update { it.copy(refreshing = false) } if (generation == routeDetailRequestGeneration) _state.update { it.copy(refreshing = false) }
@@ -577,6 +758,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DiagnosticEntry("Galeria", if (runtime.allowGalleryUploads) "włączona" else "wyłączona"), DiagnosticEntry("Galeria", if (runtime.allowGalleryUploads) "włączona" else "wyłączona"),
DiagnosticEntry("Kończenie kursu", if (runtime.allowRouteCompletion) "włączone" else "wyłączone"), DiagnosticEntry("Kończenie kursu", if (runtime.allowRouteCompletion) "włączone" else "wyłączone"),
DiagnosticEntry("Dokładna lokalizacja zdjęć", if (runtime.requirePreciseLocationForPhotos) "wymagana" else "niewymagana"), DiagnosticEntry("Dokładna lokalizacja zdjęć", if (runtime.requirePreciseLocationForPhotos) "wymagana" else "niewymagana"),
DiagnosticEntry("Lokalizacja kursu", runtime.routeLocationMode),
DiagnosticEntry("Wybór kierowcy", if (runtime.routeLocationEnabled) "włączony" else "wyłączony"),
DiagnosticEntry("Załadunek, zdjęcie", runtime.loadingPhotoRequirement), DiagnosticEntry("Załadunek, zdjęcie", runtime.loadingPhotoRequirement),
DiagnosticEntry("Załadunek, tonaż", runtime.loadingWeightRequirement), DiagnosticEntry("Załadunek, tonaż", runtime.loadingWeightRequirement),
DiagnosticEntry("Rozładunek, zdjęcie", runtime.unloadingPhotoRequirement), DiagnosticEntry("Rozładunek, zdjęcie", runtime.unloadingPhotoRequirement),
@@ -639,6 +822,24 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun markNotificationPermissionBlocked() {
_state.update {
it.copy(
notificationPermissionDenied = true,
error = "Powiadomienia są włączone w aplikacji, ale Android blokuje ich wyświetlanie. Włącz powiadomienia w ustawieniach aplikacji.",
)
}
}
fun clearNotificationPermissionWarning() {
_state.update {
it.copy(
notificationPermissionDenied = false,
error = if (it.error?.contains("powiadom", ignoreCase = true) == true) null else it.error,
)
}
}
fun setThemeMode(themeMode: AppThemeMode) { fun setThemeMode(themeMode: AppThemeMode) {
_state.update { it.copy(themeMode = themeMode) } _state.update { it.copy(themeMode = themeMode) }
viewModelScope.launch { viewModelScope.launch {
@@ -655,6 +856,36 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun setRouteLocationEnabled(enabled: Boolean) {
if (_state.value.routeLocationMode != RouteLocationMode.Optional) return
_state.update { it.copy(routeLocationEnabled = enabled, error = null) }
reconcileTrackingServiceFromState()
viewModelScope.launch {
runCatching { appPreferencesStore.setRouteLocationEnabled(enabled) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun onLocationPermissionGranted() {
val snapshot = _state.value
val driver = snapshot.driver ?: return
if (!shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())) {
return
}
val activeRoute = snapshot.displaySelectedRoute
?.takeIf { it.driverLifecycleStatus() == "W TRAKCIE" }
?: snapshot.displayRoutes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
?: return
ActiveRouteTrackingService.start(
context = getApplication(),
route = activeRoute,
driver = driver,
progressEnabled = snapshot.routeProgressNotificationEnabled,
)
}
fun openPhotoQueue() { fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } _state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
} }
@@ -966,6 +1197,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun openStartRoute() { fun openStartRoute() {
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return val route = snapshot.selectedRoute ?: return
if (routeStartRequiresLocationPermission(snapshot.routeLocationMode, hasLocationPermission())) {
_state.update { it.copy(error = "Lokalizacja jest wymagana, aby rozpocząć kurs.") }
return
}
if (!canStartRouteFromDriverApp(route, snapshot.selectedDate)) { if (!canStartRouteFromDriverApp(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") } _state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") }
return return
@@ -978,6 +1213,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy( it.copy(
screen = targetScreen, screen = targetScreen,
routeStageWeightText = "", routeStageWeightText = "",
routeStageNotesText = route.loadingNotes.orEmpty(),
feedback = null, feedback = null,
error = null, error = null,
) )
@@ -988,7 +1224,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return val route = snapshot.selectedRoute ?: return
val stagePhotos = routePhotosForStage(route.photos, "loading") val stagePhotos = routePhotosForStage(route.photos, "loading")
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" } val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" },
)
val blocker = loadingPhotosSubmitBlocker( val blocker = loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size, localUploadCount = stageUploads.size,
@@ -1020,6 +1258,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy( it.copy(
screen = DriverScreen.FinishRoute, screen = DriverScreen.FinishRoute,
routeStageWeightText = route.unloadingWeight?.toString().orEmpty(), routeStageWeightText = route.unloadingWeight?.toString().orEmpty(),
routeStageNotesText = route.unloadingNotes.orEmpty(),
feedback = null, feedback = null,
error = null, error = null,
) )
@@ -1031,6 +1270,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeStageWeightText = normalized) } _state.update { it.copy(routeStageWeightText = normalized) }
} }
fun updateRouteStageNotes(value: String) {
_state.update { it.copy(routeStageNotesText = normalizeRouteStageNotes(value)) }
}
fun correctFailedRouteAction() { fun correctFailedRouteAction() {
val snapshot = _state.value val snapshot = _state.value
val action = (snapshot.visibleRouteActions + snapshot.routeActions) val action = (snapshot.visibleRouteActions + snapshot.routeActions)
@@ -1040,16 +1283,47 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
?: return ?: return
viewModelScope.launch { viewModelScope.launch {
runCatching { routeActionOutbox.discard(action.clientActionId) } val interrupted = isWorkerCancellationFailure(action.lastError)
runCatching {
if (interrupted) routeActionOutbox.retry(action.clientActionId)
else routeActionOutbox.discard(action.clientActionId)
}
.onSuccess { .onSuccess {
_state.update { _state.update {
it.copy( it.copy(
routeActions = it.routeActions.filterNot { item -> item.clientActionId == action.clientActionId }, routeActions = if (interrupted) it.routeActions else it.routeActions.filterNot { item -> item.clientActionId == action.clientActionId },
visibleRouteActions = it.visibleRouteActions.filterNot { item -> item.clientActionId == action.clientActionId }, visibleRouteActions = if (interrupted) it.visibleRouteActions else it.visibleRouteActions.filterNot { item -> item.clientActionId == action.clientActionId },
feedback = if (interrupted) "Ponawiam potwierdzenie z tym samym identyfikatorem operacji." else it.feedback,
error = null,
)
}
if (!interrupted) {
if (action.action == RouteActionType.Start) openStartRoute() else openFinishRoute()
}
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun retryConflictedRouteAction() {
val snapshot = _state.value
val action = (snapshot.visibleRouteActions + snapshot.routeActions)
.distinctBy { it.clientActionId }
.filter { it.status == RouteActionStatus.FailedConflict }
.maxByOrNull { it.createdAtEpochMillis }
?: return
viewModelScope.launch {
runCatching {
routeActionOutbox.retry(action.clientActionId)
}
.onSuccess {
_state.update {
it.copy(
feedback = "Ponawiam wysyłanie zapisanych danych.",
error = null, error = null,
) )
} }
if (action.action == RouteActionType.Start) openStartRoute() else openFinishRoute()
} }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } } .onFailure { throwable -> _state.update { it.withApiError(throwable) } }
} }
@@ -1066,6 +1340,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private fun submitRouteStage(stage: String) { private fun submitRouteStage(stage: String) {
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return val route = snapshot.selectedRoute ?: return
if (stage == "loading" && routeStartRequiresLocationPermission(snapshot.routeLocationMode, hasLocationPermission())) {
_state.update { it.copy(error = "Lokalizacja jest wymagana, aby rozpocząć kurs.") }
return
}
val canSubmitStage = if (stage == "loading") { val canSubmitStage = if (stage == "loading") {
canStartRouteFromDriverApp(route, snapshot.selectedDate) canStartRouteFromDriverApp(route, snapshot.selectedDate)
} else { } else {
@@ -1085,12 +1363,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val photoRequirement = if (stage == "loading") snapshot.loadingPhotoRequirement else snapshot.unloadingPhotoRequirement val photoRequirement = if (stage == "loading") snapshot.loadingPhotoRequirement else snapshot.unloadingPhotoRequirement
val weightRequirement = if (stage == "loading") snapshot.loadingWeightRequirement else snapshot.unloadingWeightRequirement val weightRequirement = if (stage == "loading") snapshot.loadingWeightRequirement else snapshot.unloadingWeightRequirement
val weight = if (routeStageRequirementIsVisible(weightRequirement)) { val weight = if (routeStageRequirementIsVisible(weightRequirement)) {
snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull() parseRouteWeightTons(snapshot.routeStageWeightText)
} else { } else {
null null
} }
val notes = normalizeRouteStageNotes(snapshot.routeStageNotesText).trim().takeIf { it.isNotBlank() }
val stagePhotos = routePhotosForStage(route.photos, stage) val stagePhotos = routePhotosForStage(route.photos, stage)
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage },
)
val submitBlocker = if (stage == "loading") { val submitBlocker = if (stage == "loading") {
loadingWeightSubmitBlocker( loadingWeightSubmitBlocker(
weightText = snapshot.routeStageWeightText, weightText = snapshot.routeStageWeightText,
@@ -1133,19 +1414,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
runCatching { runCatching {
val updatedRoute = when (stage) { val updatedRoute = when (stage) {
"loading" -> { "loading" -> {
routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds) routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds, notes)
route.copy( route.copy(
status = "W TRAKCIE", status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = weight, loadingWeight = weight,
loadingNotes = notes,
trackingStatus = "active", trackingStatus = "active",
photos = route.safePhotos(), photos = route.safePhotos(),
) )
} }
"unloading" -> { "unloading" -> {
routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds) routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds, notes)
route.copy( route.copy(
status = "ZAKOŃCZONA", status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = weight, unloadingWeight = weight,
unloadingNotes = notes,
trackingStatus = "finished", trackingStatus = "finished",
completedAt = Instant.now().toString(), completedAt = Instant.now().toString(),
photos = route.safePhotos(), photos = route.safePhotos(),
@@ -1160,6 +1445,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
selectedRoute = updatedRoute, selectedRoute = updatedRoute,
routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item }, routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item },
routeStageWeightText = "", routeStageWeightText = "",
routeStageNotesText = "",
feedback = if (stage == "loading") { feedback = if (stage == "loading") {
if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu." if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu."
} else { } else {
@@ -1169,7 +1455,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
) )
} }
if (stage == "loading" && driver != null) { if (
stage == "loading" &&
driver != null &&
shouldTrackRouteLocation(
snapshot.routeLocationMode,
snapshot.routeLocationEnabled,
hasLocationPermission(),
)
) {
ActiveRouteTrackingService.start( ActiveRouteTrackingService.start(
context = getApplication(), context = getApplication(),
route = updatedRoute, route = updatedRoute,
@@ -1244,13 +1538,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun deleteConfirmedUpload(upload: PhotoUploadEntity) { fun deletePhotoUpload(upload: PhotoUploadEntity) {
val route = _state.value.selectedRoute ?: return val route = _state.value.selectedRoute ?: return
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId)
if (serverPhotoId == null) {
if (!canDiscardLocalPhotoUpload(upload.status, upload.serverPhotoId)) return
viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + upload.clientRequestId, error = null) }
runCatching { photoUploadOutbox.discard(upload.clientRequestId) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - upload.clientRequestId) }
}
return
}
if (!_state.value.isOnline) { if (!_state.value.isOnline) {
_state.update { it.copy(error = "Usunięcie wysłanego zdjęcia wymaga połączenia z internetem.") } _state.update { it.copy(error = "Usunięcie wysłanego zdjęcia wymaga połączenia z internetem.") }
return return
} }
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) ?: return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) } _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) }
@@ -1328,9 +1632,19 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
when (it.screen) { when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.StartRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null) DriverScreen.StartRoute -> it.copy(
screen = DriverScreen.Detail,
routeStageWeightText = "",
routeStageNotesText = "",
feedback = null,
)
DriverScreen.LoadingWeight -> it.copy(screen = DriverScreen.StartRoute, feedback = null) DriverScreen.LoadingWeight -> it.copy(screen = DriverScreen.StartRoute, feedback = null)
DriverScreen.FinishRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null) DriverScreen.FinishRoute -> it.copy(
screen = DriverScreen.Detail,
routeStageWeightText = "",
routeStageNotesText = "",
feedback = null,
)
DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes) DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null) DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null)
DriverScreen.LeaveCalendar -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null) DriverScreen.LeaveCalendar -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
@@ -1374,6 +1688,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
dispatchSheetUploadsJob?.cancel() dispatchSheetUploadsJob?.cancel()
realtimeBannerJob?.cancel() realtimeBannerJob?.cancel()
liveSyncClient.stop() liveSyncClient.stop()
ActiveRouteTrackingService.stop(getApplication())
offlineOutboxManager.clearAll() offlineOutboxManager.clearAll()
repository.logout(notifyServer = _state.value.isOnline) repository.logout(notifyServer = _state.value.isOnline)
syncRepository.clearCache() syncRepository.clearCache()
@@ -1412,8 +1727,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) { private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) {
val activeRoute = routes.firstOrNull { it.status == "W TRAKCIE" || it.trackingStatus == "active" } val snapshot = _state.value
if (activeRoute == null) { val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
if (
activeRoute == null ||
!shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())
) {
ActiveRouteTrackingService.stop(getApplication()) ActiveRouteTrackingService.stop(getApplication())
} else { } else {
ActiveRouteTrackingService.start( ActiveRouteTrackingService.start(
@@ -1426,7 +1745,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) { private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) {
if (route.status == "W TRAKCIE" || route.trackingStatus == "active") { val snapshot = _state.value
if (
route.driverLifecycleStatus() == "W TRAKCIE" &&
shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())
) {
if (driver != null) { if (driver != null) {
ActiveRouteTrackingService.start( ActiveRouteTrackingService.start(
context = getApplication(), context = getApplication(),
@@ -1440,6 +1763,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
private fun reconcileTrackingServiceFromState() {
val snapshot = _state.value
val driver = snapshot.driver
if (driver == null) {
ActiveRouteTrackingService.stop(getApplication())
return
}
reconcileTrackingService(snapshot.displayRoutes, driver)
}
private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private fun observeRouteActions(routeId: String) { private fun observeRouteActions(routeId: String) {
routeActionsJob?.cancel() routeActionsJob?.cancel()
routeActionsJob = viewModelScope.launch { routeActionsJob = viewModelScope.launch {
@@ -1584,6 +1921,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private suspend fun handleExpiredSession() { private suspend fun handleExpiredSession() {
liveSyncClient.stop() liveSyncClient.stop()
ActiveRouteTrackingService.stop(getApplication())
offlineOutboxManager.clearAll() offlineOutboxManager.clearAll()
repository.logout(notifyServer = false) repository.logout(notifyServer = false)
syncRepository.clearCache() syncRepository.clearCache()
@@ -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,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,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,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,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,
)
},
)
}
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.data.upload
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.ApiErrorKind
class RoutePointWorkerRulesTest { class RoutePointWorkerRulesTest {
@Test @Test
@@ -15,4 +16,14 @@ class RoutePointWorkerRulesTest {
assertFalse(isInactiveRoutePointError("SERVER_UNAVAILABLE")) assertFalse(isInactiveRoutePointError("SERVER_UNAVAILABLE"))
assertFalse(isInactiveRoutePointError(null)) assertFalse(isInactiveRoutePointError(null))
} }
@Test
fun revokedSessionStopsRouteTracking() {
assertTrue(shouldStopRouteTrackingAfterPointError(ApiErrorKind.Auth, null))
}
@Test
fun temporaryFailureKeepsRouteTrackingActive() {
assertFalse(shouldStopRouteTrackingAfterPointError(ApiErrorKind.Server, "SERVER_UNAVAILABLE"))
}
} }
@@ -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))
}
}
@@ -17,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)
@@ -32,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)
@@ -108,16 +125,25 @@ class DriverRouteProjectionTest {
} }
@Test @Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() { 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("Uszkodzona plomba.", projection.route.unloadingNotes)
assertTrue(projection.unloadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].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)
@@ -131,8 +157,9 @@ 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)
assertTrue(projection.loadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state) assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
} }
@@ -174,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 =
@@ -186,6 +214,7 @@ class DriverRouteProjectionTest {
photoClientRequestIdsJson = "[]", photoClientRequestIdsJson = "[]",
status = status, status = status,
createdAtEpochMillis = createdAt, createdAtEpochMillis = createdAt,
notes = notes,
) )
private companion object { private companion object {
@@ -8,6 +8,8 @@ import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
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 +18,29 @@ 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 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))
@@ -82,6 +107,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))
@@ -205,8 +332,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"))
@@ -215,6 +343,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"))
@@ -253,6 +412,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(
@@ -306,11 +516,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(
@@ -356,18 +575,62 @@ class DriverUiRulesTest {
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 @Test
fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() { fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() {
val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading"))) val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading")))
val steps = routeFlowSteps(route, emptyList()) val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.LocalComplete, steps[0].state) assertEquals(RouteFlowStepState.ServerSaved, steps[0].state)
assertEquals("zapisane na serwerze", steps[0].stateLabel)
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(RouteFlowStepState.Todo, steps[3].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)
@@ -377,7 +640,8 @@ 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.LocalComplete, steps[2].state) assertEquals(RouteFlowStepState.InProgress, steps[2].state)
assertEquals("w trakcie", steps[2].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[3].state) assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Rozładuj", action?.label) assertEquals("Rozładuj", action?.label)
assertEquals(RouteLifecycleAction.Finish, action?.action) assertEquals(RouteLifecycleAction.Finish, action?.action)
@@ -518,6 +782,8 @@ class DriverUiRulesTest {
private fun route( private fun route(
status: String = "ZAPLANOWANA", 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(),
@@ -536,6 +802,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,
@@ -602,4 +870,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",
)
} }
@@ -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" }
+472 -11
View File
@@ -7,11 +7,17 @@ VERSION_FILE="$ROOT_DIR/app/build.gradle.kts"
REMOTE_NAME="${GIT_REMOTE:-origin}" REMOTE_NAME="${GIT_REMOTE:-origin}"
NOTES_FILE="" NOTES_FILE=""
SKIP_TESTS=0 SKIP_TESTS=0
GOOGLE_PLAY_MODE="ask"
REUSE_CURRENT=0 REUSE_CURRENT=0
VERSION_FILE_CHANGED=0 VERSION_FILE_CHANGED=0
VERSION_COMMITTED=0 VERSION_COMMITTED=0
VERSION_BACKUP="" VERSION_BACKUP=""
RELEASE_SUCCEEDED=0 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() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -21,13 +27,17 @@ Buduje i publikuje kolejną wersję aplikacji kierowcy:
- automatycznie zwiększa versionCode o 1 i patch versionName o 1, - automatycznie zwiększa versionCode o 1 i patch versionName o 1,
- uruchamia testy jednostkowe, - uruchamia testy jednostkowe,
- buduje podpisany APK dla Gitea Release, - buduje podpisany APK dla Gitea Release,
- buduje podpisany AAB dla Google Play Console, - 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, - zapisuje zmianę wersji w commicie, tworzy tag i wykonuje push,
- tworzy lub aktualizuje Gitea Release i wysyła APK oraz SHA256SUMS. - tworzy lub aktualizuje Gitea Release i wysyła APK oraz SHA256SUMS.
Opcje: Opcje:
--notes-file PLIK Treść release notes z podanego pliku. --notes-file PLIK Treść release notes z podanego pliku.
--skip-tests Pomiń testDebugUnitTest. --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 --reuse-current Nie zwiększaj wersji. Wznów publikację wersji zapisanej
obecnie w app/build.gradle.kts. obecnie w app/build.gradle.kts.
-h, --help Pokaż pomoc. -h, --help Pokaż pomoc.
@@ -37,9 +47,19 @@ Zmienne środowiskowe:
GITEA_BASE_URL Nadpisuje adres Gitea, np. https://gitea.example.com. GITEA_BASE_URL Nadpisuje adres Gitea, np. https://gitea.example.com.
GITEA_REPOSITORY Nadpisuje owner/repository. GITEA_REPOSITORY Nadpisuje owner/repository.
GIT_REMOTE Remote Git, domyślnie origin. 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. Wersja początkowa jest zawsze odczytywana z app/build.gradle.kts.
Google Play Console otrzymuje plik AAB, ponieważ jest to właściwy format publikacyjny. Google Play otrzymuje AAB przez Android Publisher API. Konto serwisowe musi mieć
uprawnienie „Publikowanie aplikacji na ścieżkach testowych”.
EOF EOF
} }
@@ -54,6 +74,14 @@ while (($# > 0)); do
SKIP_TESTS=1 SKIP_TESTS=1
shift shift
;; ;;
--skip-google-play)
GOOGLE_PLAY_MODE="skip"
shift
;;
--publish-google-play)
GOOGLE_PLAY_MODE="publish"
shift
;;
--reuse-current) --reuse-current)
REUSE_CURRENT=1 REUSE_CURRENT=1
shift shift
@@ -82,6 +110,10 @@ done
[[ -x ./gradlew ]] || { echo "Brak wykonywalnego ./gradlew." >&2; exit 1; } [[ -x ./gradlew ]] || { echo "Brak wykonywalnego ./gradlew." >&2; exit 1; }
[[ -f keystore.properties ]] || { echo "Brak keystore.properties wymaganego do podpisania release." >&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; } [[ -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)" branch="$(git branch --show-current)"
[[ -n "$branch" ]] || { echo "Release nie może być wykonany z detached HEAD." >&2; exit 1; } [[ -n "$branch" ]] || { echo "Release nie może być wykonany z detached HEAD." >&2; exit 1; }
@@ -106,6 +138,14 @@ if [[ "$sanitized_remote" =~ ^(https?)://([^/]+)/(.+)$ ]]; then
detected_protocol="${BASH_REMATCH[1]}" detected_protocol="${BASH_REMATCH[1]}"
detected_host="${BASH_REMATCH[2]}" detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[3]%.git}" 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 else
detected_protocol="https" detected_protocol="https"
detected_host="" detected_host=""
@@ -115,14 +155,20 @@ fi
gitea_base_url="${GITEA_BASE_URL:-${detected_protocol}://${detected_host}}" gitea_base_url="${GITEA_BASE_URL:-${detected_protocol}://${detected_host}}"
gitea_repository="${GITEA_REPOSITORY:-$detected_repository}" gitea_repository="${GITEA_REPOSITORY:-$detected_repository}"
gitea_base_url="${gitea_base_url%/}" gitea_base_url="${gitea_base_url%/}"
[[ "$gitea_base_url" =~ ^https?://[^/]+$ ]] || { echo "Ustaw poprawne GITEA_BASE_URL." >&2; exit 1; } 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; } [[ "$gitea_repository" =~ ^[^/]+/[^/]+$ ]] || { echo "Ustaw poprawne GITEA_REPOSITORY=owner/repository." >&2; exit 1; }
auth_args=() auth_args=()
if [[ -n "${GITEA_TOKEN:-}" ]]; then if [[ -n "${GITEA_TOKEN:-}" ]]; then
auth_args=(-H "Authorization: token $GITEA_TOKEN") auth_args=(-H "Authorization: token $GITEA_TOKEN")
else else
credential="$(printf 'protocol=%s\nhost=%s\n\n' "$detected_protocol" "$detected_host" | git credential fill)" 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_username="$(printf '%s\n' "$credential" | sed -n 's/^username=//p')"
gitea_password="$(printf '%s\n' "$credential" | sed -n 's/^password=//p')" gitea_password="$(printf '%s\n' "$credential" | sed -n 's/^password=//p')"
[[ -n "$gitea_username" && -n "$gitea_password" ]] || { [[ -n "$gitea_username" && -n "$gitea_password" ]] || {
@@ -165,11 +211,24 @@ play_aab_name="TPP-Kierowca-${next_name}-${next_code}.aab"
cleanup_on_exit() { cleanup_on_exit() {
local exit_code=$? 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 if ((RELEASE_SUCCEEDED == 0 && VERSION_FILE_CHANGED == 1 && VERSION_COMMITTED == 0)) && [[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]]; then
cp "$VERSION_BACKUP" "$VERSION_FILE" cp "$VERSION_BACKUP" "$VERSION_FILE"
echo "Przywrócono poprzednią wersję w app/build.gradle.kts." >&2 echo "Przywrócono poprzednią wersję w app/build.gradle.kts." >&2
fi fi
[[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP" [[ -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 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 echo "Release nie został ukończony. Po usunięciu przyczyny użyj --reuse-current, jeśli commit wersji już powstał." >&2
fi fi
@@ -180,6 +239,391 @@ trap cleanup_on_exit EXIT
trap 'exit 130' INT trap 'exit 130' INT
trap 'exit 143' TERM 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 ((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 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 echo "Tag $tag_name już istnieje. Użyj --reuse-current albo zwiększ wersję ręcznie." >&2
@@ -235,14 +679,18 @@ cp "$aab_source" "$artifact_dir/google-play/$play_aab_name"
sha256sum "gitea/$gitea_apk_name" "google-play/$play_aab_name" > SHA256SUMS.txt 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 if ((REUSE_CURRENT == 0)); then
git add "$VERSION_FILE" git add "$VERSION_FILE"
git diff --cached --check git diff --cached --check
git commit -m "chore: release android driver $next_name ($next_code)" git commit -m "Wydaj aplikację kierowcy $next_name ($next_code)"
VERSION_COMMITTED=1 VERSION_COMMITTED=1
fi fi
git push "$REMOTE_NAME" "$branch"
if ! git show-ref --verify --quiet "refs/tags/$tag_name"; then if ! git show-ref --verify --quiet "refs/tags/$tag_name"; then
git tag -a "$tag_name" -m "TPP Kierowca $next_name ($next_code)" git tag -a "$tag_name" -m "TPP Kierowca $next_name ($next_code)"
fi fi
@@ -252,10 +700,8 @@ head_commit="$(git rev-parse HEAD)"
echo "Tag $tag_name nie wskazuje aktualnego commita ($head_commit)." >&2 echo "Tag $tag_name nie wskazuje aktualnego commita ($head_commit)." >&2
exit 1 exit 1
} }
git push "$REMOTE_NAME" "$tag_name"
if [[ -n "$NOTES_FILE" ]]; then if [[ -n "$NOTES_FILE" ]]; then
[[ -f "$NOTES_FILE" ]] || { echo "Nie istnieje plik release notes: $NOTES_FILE" >&2; exit 1; }
release_notes="$(<"$NOTES_FILE")" release_notes="$(<"$NOTES_FILE")"
else else
previous_tag="$(git tag --sort=-version:refname | grep -Fxv "$tag_name" | sed -n '1p' || true)" previous_tag="$(git tag --sort=-version:refname | grep -Fxv "$tag_name" | sed -n '1p' || true)"
@@ -264,7 +710,7 @@ else
else else
changes="$(git log -20 --format='- %s (%h)')" changes="$(git log -20 --format='- %s (%h)')"
fi fi
release_notes="TPP Kierowca $next_name ($next_code) release_notes="TPP Kierowca $next_name ($next_code)
Zmiany: Zmiany:
${changes:-'- Wydanie techniczne.'} ${changes:-'- Wydanie techniczne.'}
@@ -273,6 +719,15 @@ Artefakt Google Play Console (AAB) znajduje się lokalnie w:
release-artifacts/$tag_name/google-play/$play_aab_name" release-artifacts/$tag_name/google-play/$play_aab_name"
fi 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_response="$(mktemp)"
release_status="$(curl -sS "${auth_args[@]}" -o "$release_response" -w '%{http_code}' "$api_base/releases/tags/$tag_name")" release_status="$(curl -sS "${auth_args[@]}" -o "$release_response" -w '%{http_code}' "$api_base/releases/tags/$tag_name")"
if [[ "$release_status" == "200" ]]; then if [[ "$release_status" == "200" ]]; then
@@ -348,8 +803,12 @@ published_apk_hash="$(sha256sum "$downloaded_apk" | awk '{print $1}')"
release_url="$gitea_base_url/$gitea_repository/releases/tag/$tag_name" release_url="$gitea_base_url/$gitea_repository/releases/tag/$tag_name"
RELEASE_SUCCEEDED=1 RELEASE_SUCCEEDED=1
trap - EXIT INT TERM
[[ -n "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP" [[ -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 cat <<EOF
@@ -358,6 +817,8 @@ Release zakończony:
Tag: $tag_name Tag: $tag_name
Gitea: $release_url Gitea: $release_url
APK: $artifact_dir/gitea/$gitea_apk_name APK: $artifact_dir/gitea/$gitea_apk_name
Google Play: $artifact_dir/google-play/$play_aab_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 Checksumy: $artifact_dir/SHA256SUMS.txt
EOF EOF