Compare commits

..
Author SHA1 Message Date
admin e9e8a75bf1 Obsłuż publikację dni i ukrywanie kursów kierowcy 2026-10-07 12:51:44 +02:00
admin c212a6a488 Wydaj aplikację kierowcy 1.0.80 (133) 2026-08-10 11:07:31 +02:00
admin 08fcb25a2a Napraw aparat po obrocie telefonu 2026-08-10 11:06:13 +02:00
admin b8b135ee63 Wydaj aplikację kierowcy 1.0.79 (132) 2026-08-01 00:40:26 +02:00
admin 6066527dc0 Przyspiesz wyświetlanie dodawanych zdjęć 2026-08-01 00:32:05 +02:00
admin 1f39f27618 Wydaj aplikację kierowcy 1.0.78 (131) 2026-07-31 23:14:01 +02:00
admin 956be6a6de Obsłuż konfigurowalne kroki i dokumenty trasy 2026-07-31 23:11:24 +02:00
admin 90ca9775ef Wydaj aplikację kierowcy 1.0.77 (130) 2026-07-30 13:30:22 +02:00
admin 0e33564b95 Popraw zapis i usuwanie zdjęć kierowcy 2026-07-30 13:24:19 +02:00
admin 321b260e3d Wydaj aplikację kierowcy 1.0.76 (129) 2026-07-30 12:08:10 +02:00
admin be3c885988 Dodaj seryjne zdjęcia załadunku i rozładunku 2026-07-30 12:07:05 +02:00
admin 802393962e Wydaj aplikację kierowcy 1.0.75 (128) 2026-07-30 02:04:03 +02:00
admin ac31308443 Ukrywaj wybrane salda w aplikacji kierowcy 2026-07-30 02:01:46 +02:00
admin af832dee93 Wydaj aplikację kierowcy 1.0.74 (127) 2026-07-30 01:44:37 +02:00
admin 8ca5f20a98 Rozszerz mobilną obsługę urlopów i nieobecności 2026-07-30 01:43:26 +02:00
admin 26b17ece26 Wydaj aplikację kierowcy 1.0.73 (126) 2026-07-30 00:26:21 +02:00
admin 2e7eb7e47b Uprość obsługę urlopów w aplikacji kierowcy 2026-07-30 00:24:39 +02:00
admin 062415a75d Wydaj aplikację kierowcy 1.0.72 (125) 2026-07-29 18:07:48 +02:00
admin ab68be4c1e Napraw pętlę uruchamiania aplikacji kierowcy 2026-07-29 18:06:24 +02:00
admin 513c1785ef Wydaj aplikację kierowcy 1.0.71 (124) 2026-07-24 13:40:46 +02:00
admin e70a53eca7 Dodaj potwierdzanie odczytów tachografu 2026-07-24 13:33:27 +02:00
admin c3004a3d2e Dodaj kartę przypomnienia o tachografie 2026-07-24 13:11:16 +02:00
admin fda4b462dc Wydaj aplikację kierowcy 1.0.70 (123) 2026-07-24 12:55:12 +02:00
admin 6a1a15ade9 Add route date calendar picker 2026-07-24 12:49:32 +02:00
admin ea44cf5d2e Wydaj aplikację kierowcy 1.0.69 (122) 2026-07-24 01:30:07 +02:00
admin 984feb170a Wydaj aplikację kierowcy 1.0.68 (121) 2026-07-23 15:14:50 +02:00
admin 5691c87e91 Dostosuj aplikację do Androida 16 2026-07-23 15:14:05 +02:00
admin 227e08a671 Wydaj aplikację kierowcy 1.0.67 (120) 2026-07-23 15:03:18 +02:00
admin 3bfb5f9788 Usuń nieaktualny konflikt zakończenia kursu 2026-07-23 15:02:21 +02:00
admin bf1cf3865a Napraw synchronizację statusu kursu kierowcy 2026-07-23 14:15:34 +02:00
admin 0c3bed6213 Dostosuj lokalizację kursu do ustawień 2026-07-23 12:51:26 +02:00
admin f1cb2d618d Wydaj aplikację kierowcy 1.0.66 (119) 2026-07-23 11:35:47 +02:00
admin 8c9a3b5b37 Aktualizuj wersję aplikacji w sesji kierowcy 2026-07-23 11:33:47 +02:00
admin de8510bc79 Wydaj aplikację kierowcy 1.0.65 (118) 2026-07-23 11:33:17 +02:00
admin 34d01b312e Obsłuż adres SSH w skrypcie wydania Androida 2026-07-23 11:19:10 +02:00
admin f89332b28c Dodaj uwagi i dyktowanie na etapach trasy 2026-07-23 11:05:49 +02:00
admin 8f12bf32bc Wydaj aplikację kierowcy 1.0.64 (117) 2026-07-18 17:37:53 +02:00
admin b9ae128842 Napraw obsługę kodów OTP podczas logowania 2026-07-18 17:33:40 +02:00
admin a86dca50b8 Wydaj aplikację kierowcy 1.0.63 (116) 2026-07-18 16:34:32 +02:00
admin fc49eae512 Zatrzymaj aktywny kurs po wygaśnięciu sesji 2026-07-18 16:29:34 +02:00
admin ffbae9edfd Wydaj aplikację kierowcy 1.0.62 (115) 2026-07-18 02:57:39 +02:00
admin 2268c64250 Zautomatyzuj publikację aplikacji w Google Play 2026-07-18 02:57:03 +02:00
admin c954fd9bb8 chore: release android driver 1.0.61 (114) 2026-07-18 02:34:10 +02:00
admin b9757348f2 Dodaj aktualizacje aplikacji kierowcy przez Google Play 2026-07-18 02:32:31 +02:00
admin 130f8bbecc chore: release android driver 1.0.60 (113) 2026-07-17 14:10:03 +02:00
admin 4c00d46a14 Napraw otwieranie nawigacji do geofence 2026-07-17 14:05:11 +02:00
admin 07fc4021e1 chore: release android driver 1.0.59 (112) 2026-07-17 13:33:46 +02:00
admin 431436d0db Napraw synchronizację kursu w aplikacji kierowcy 2026-07-17 13:28:49 +02:00
admin 905711d8de chore: release android driver 1.0.58 (111) 2026-07-16 11:06:24 +02:00
admin 7c9cb3fb0d Dodaj timeout i ręczny tryb offline przy uruchamianiu 2026-07-16 11:04:36 +02:00
admin ae0d1b2d3d chore: release android driver 1.0.57 (110) 2026-07-16 10:31:11 +02:00
admin c5b3c5dae9 Przeliczaj wagi kierowcy z kilogramów na tony 2026-07-16 10:28:30 +02:00
admin 3fa3b4eac1 chore: release android driver 1.0.56 (109) 2026-07-12 22:34:20 +02:00
admin 197bcc6f5e Przypomnij o zgodzie na powiadomienia Android 2026-07-12 22:33:52 +02:00
admin f4f5cec2be chore: release android driver 1.0.55 (108) 2026-07-12 22:04:46 +02:00
admin 5a630f8401 Użyj origin realtime z bootstrapu 2026-07-12 21:59:46 +02:00
admin 54a3c58a22 chore: release android driver 1.0.54 (107) 2026-07-12 21:57:39 +02:00
admin 1c6a044c37 Dodaj Origin dla WebSocket Reverb 2026-07-12 21:55:01 +02:00
admin fcc022db64 chore: release android driver 1.0.53 (106) 2026-07-11 00:35:40 +02:00
admin a908a2da18 fix: avoid release verification pipe failure 2026-07-11 00:35:21 +02:00
admin 2c25647fa5 chore: ignore backup Android resources 2026-07-11 00:29:47 +02:00
admin 956f33140e chore: automate Android release builds 2026-07-11 00:27:54 +02:00
admin 7bba4dc878 fix: stop orphaned GPS synchronization 2026-07-11 00:27:52 +02:00
admin 89235fbb46 feat: harden offline sync and add diagnostics 2026-07-10 23:58:07 +02:00
admin 0564101e9e chore: release android driver 1.0.51 2026-07-10 22:35:04 +02:00
admin 2baa544b32 fix: reconcile leave requests in realtime 2026-07-10 22:32:13 +02:00
admin f9c3a2e305 Sortuj kursy kierowcy według dnia 2026-07-10 21:31:17 +02:00
83 changed files with 10681 additions and 692 deletions
+5
View File
@@ -1,4 +1,5 @@
.gradle/
.kotlin/
build/
local.properties
app/google-services.json
@@ -7,3 +8,7 @@ app/google-services.json:Zone.Identifier
*.iml
keystore.properties
*.jks
release-artifacts/
app/src/main/res_bak/
scripts/*.json
scripts/*.json:Zone.Identifier
+62
View File
@@ -21,3 +21,65 @@ Natywna aplikacja Android Jetpack Compose dla kierowcow TPP.
```bash
./gradlew :app:testDebugUnitTest
```
## Release Gitea i Google Play
Przed wydaniem zapisz wszystkie zmiany źródłowe w commicie. Następnie uruchom:
```bash
./scripts/release-android.sh
```
Skrypt automatycznie zwiększa `versionCode` i ostatni człon `versionName`, uruchamia testy,
buduje podpisany APK oraz AAB, a po poprawnym buildzie pyta, czy wysłać aktualizację do
Google Play. Po odpowiedzi „tak” publikuje AAB w skonfigurowanym teście zamkniętym i
wysyła zmiany do weryfikacji. Następnie tworzy commit wersji, tag, push i Gitea Release.
Artefakty zostają również zapisane lokalnie w `release-artifacts/<tag>/`.
Do publikacji Google Play konto serwisowe musi mieć dla aplikacji
`pl.firmatpp.kierowca` uprawnienia „Wyświetlanie informacji o aplikacji (tylko do odczytu)”
oraz „Publikowanie aplikacji na ścieżkach testowych”. Pobrany klucz JSON umieść
bezpośrednio w folderze `scripts`, na przykład:
```text
scripts/google-play-service-account.json
```
Skrypt sam znajduje jedyny plik `scripts/*.json`, sprawdza, czy jest prawidłowym kluczem
konta serwisowego, i używa go do autoryzacji. Pliki `scripts/*.json` są ignorowane przez
Git i nie mogą zostać przypadkowo dodane do repozytorium. Jeśli w folderze znajduje się
więcej niż jeden prawidłowy klucz, skrypt przerwie działanie i poprosi o pozostawienie
jednego.
`GOOGLE_PLAY_TRACK` musi być identyfikatorem istniejącego testu zamkniętego. Domyślna
wartość to `alpha`. Opcjonalnie można ustawić `GOOGLE_PLAY_PACKAGE_NAME` i
`GOOGLE_PLAY_RELEASE_LANGUAGE` (domyślnie `pl-PL`). Zmienna
`GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS` pozostaje opcjonalnym sposobem wskazania klucza
spoza folderu `scripts`. Skrypt nie zapisuje tokenu ani klucza w repozytorium; prywatny
klucz tymczasowy jest usuwany po zakończeniu.
Przed zatwierdzeniem skrypt waliduje edycję Google Play. Commit edycji jawnie wysyła
zmiany do weryfikacji. Jeżeli inne zmiany są już sprawdzane, skrypt nie anuluje ich —
kończy się błędem i można go wznowić po zakończeniu poprzedniej weryfikacji.
Jeżeli na koncie jest włączone publikowanie zarządzane, po akceptacji Google nadal trzeba
wybrać „Opublikuj zmiany” w Play Console; bez publikowania zarządzanego zaakceptowane
wydanie zostanie udostępnione testerom automatycznie.
Awaryjne wydanie wyłącznie do Gitea, bez Google Play:
```bash
./scripts/release-android.sh --skip-google-play
```
W środowisku bez interaktywnego terminala decyzję trzeba podać jawnie:
```bash
./scripts/release-android.sh --publish-google-play
```
Do autoryzacji Gitea używany jest `GITEA_TOKEN` albo dane zapisane w Git credential helper.
Jeśli publikacja została przerwana już po utworzeniu commita wersji, można ją wznowić:
```bash
./scripts/release-android.sh --reuse-current
```
+6 -4
View File
@@ -22,7 +22,7 @@ val keystoreProperties = Properties().apply {
android {
namespace = "pl.firmatpp.kierowca"
compileSdk = 35
compileSdk = 36
fun configValue(name: String, default: String): String =
providers.gradleProperty(name)
@@ -33,9 +33,9 @@ android {
defaultConfig {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 103
versionName = "1.0.50"
targetSdk = 36
versionCode = 133
versionName = "1.0.80"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -130,6 +130,8 @@ dependencies {
implementation(libs.okhttp.logging)
implementation(libs.play.services.auth)
implementation(libs.play.services.auth.api.phone)
implementation(libs.play.app.update)
implementation(libs.play.app.update.ktx)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
+11
View File
@@ -2,12 +2,23 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<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_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<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
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
@@ -1,12 +1,26 @@
package pl.firmatpp.kierowca
import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import pl.firmatpp.kierowca.data.upload.OfflineOutboxManager
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverApplication : Application() {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
AppDiagnostics.installFirebaseCrashlytics()
AppDiagnostics.log("app_started")
applicationScope.launch {
val outboxManager = OfflineOutboxManager(this@DriverApplication)
runCatching { outboxManager.repairOperationalQueues() }
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_operational_queues") }
runCatching { outboxManager.repairGpsQueue() }
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") }
}
}
}
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.DriverViewModel
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
import pl.firmatpp.kierowca.update.PlayAppUpdateState
import pl.firmatpp.kierowca.update.PlayInAppUpdateController
class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null)
private var notificationRouteTarget 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?) {
super.onCreate(savedInstanceState)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
playInAppUpdates = PlayInAppUpdateController(
activity = this,
launcher = appUpdateLauncher,
onStateChanged = { playAppUpdateState = it },
)
setContent {
val viewModel: DriverViewModel = viewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -32,9 +49,27 @@ class MainActivity : ComponentActivity() {
initialRouteId = notificationRouteId,
initialRouteTarget = notificationRouteTarget,
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) {
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data
import java.io.IOException
import java.net.UnknownHostException
import kotlin.coroutines.cancellation.CancellationException
import retrofit2.HttpException
enum class ApiErrorKind {
@@ -49,7 +50,10 @@ object ApiErrorMapper {
map(throwable).kind != ApiErrorKind.Network
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 retryable = body?.let { """"retryable"\s*:\s*(true|false)""".toRegex().find(it)?.groupValues?.getOrNull(1)?.toBooleanStrictOrNull() }
@@ -80,16 +84,21 @@ object ApiErrorMapper {
}
val defaultRetryable = statusCode == 429 || statusCode in 500..599
val resolvedRetryable = retryable ?: defaultRetryable
val resolvedMessage = message?.takeIf { it.isNotBlank() } ?: when (kind) {
val resolvedMessage = when {
code == "INVALID_OTP" -> "Kod jest nieprawidłowy lub wygasł. Użyj kodu z najnowszego SMS-a."
kind == ApiErrorKind.RateLimited -> "Za dużo prób. Odczekaj chwilę i spróbuj ponownie."
!message.isNullOrBlank() -> message
else -> when (kind) {
ApiErrorKind.Auth -> "Sesja wygasła. Zaloguj się ponownie."
ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji."
ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana."
ApiErrorKind.Conflict -> "Operacja jest w konflikcie z aktualnym stanem danych."
ApiErrorKind.RateLimited -> "Za dużo prób. Aplikacja spróbuje ponownie później."
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(
kind = kind,
@@ -102,7 +111,15 @@ object ApiErrorMapper {
}
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)
}
}
fun Throwable.rethrowIfCancellation() {
if (this is CancellationException) throw this
}
@@ -20,6 +20,7 @@ class AppPreferencesStore(
private val themeModeKey = stringPreferencesKey("theme_mode")
private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled")
private val routeLocationEnabledKey = booleanPreferencesKey("route_location_enabled")
val themeMode: Flow<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
@@ -27,6 +28,9 @@ class AppPreferencesStore(
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
val routeLocationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeLocationEnabledKey] ?: false }
suspend fun setThemeMode(themeMode: AppThemeMode) {
dataStore.edit { preferences ->
preferences[themeModeKey] = themeMode.storedValue
@@ -38,4 +42,10 @@ class AppPreferencesStore(
preferences[routeProgressNotificationEnabledKey] = enabled
}
}
suspend fun setRouteLocationEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[routeLocationEnabledKey] = enabled
}
}
}
@@ -22,17 +22,6 @@ class DeviceInfoProvider(
androidVersion = Build.VERSION.RELEASE,
sdkInt = Build.VERSION.SDK_INT,
appVersion = BuildConfig.VERSION_NAME,
serialNumber = readSerialNumber(),
serialNumber = null,
)
private fun readSerialNumber(): String? =
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Build.getSerial()
} else {
@Suppress("DEPRECATION")
Build.SERIAL
}
}.getOrNull()
?.takeUnless { it.isBlank() || it.equals(Build.UNKNOWN, ignoreCase = true) }
}
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import java.io.File
import java.security.MessageDigest
import java.util.UUID
@@ -12,6 +13,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import pl.firmatpp.kierowca.data.api.ApiFactory
import pl.firmatpp.kierowca.data.api.MobileDriverApi
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.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -19,8 +21,12 @@ import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveDocumentDto
import pl.firmatpp.kierowca.data.model.DriverLeaveForecastDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveForecastBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse
@@ -33,6 +39,7 @@ import pl.firmatpp.kierowca.data.model.RoutePointsResponse
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
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.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -56,23 +63,104 @@ class DriverRepository(
suspend fun bootstrap(date: String? = null): BootstrapResponse =
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 =
api.route(authHeader(requireToken()), routeId)
suspend fun leaveRequests(): List<DriverLeaveRequestDto> =
api.leaveRequests(authHeader(requireToken())).data
suspend fun leaveSummary(): DriverLeaveSummaryDto =
api.leaveSummary(authHeader(requireToken())).data
suspend fun leaveForecast(
dateFrom: String,
dateTo: String,
type: String,
requestMode: String,
requestedQuantity: Int?,
details: Map<String, String>,
): DriverLeaveForecastDto =
api.leaveForecast(
authHeader(requireToken()),
LeaveForecastBody(type, dateFrom, dateTo, requestMode, requestedQuantity, details),
).data
suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> =
api.leaveCalendar(authHeader(requireToken()), from, to).data
suspend fun leaveRequest(id: String): DriverLeaveRequestDto =
api.leaveRequest(authHeader(requireToken()), id).data
suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto =
api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data
suspend fun createLeaveRequest(
dateFrom: String,
dateTo: String,
type: String,
requestMode: String,
requestedQuantity: Int?,
details: Map<String, String>,
idempotencyKey: String,
note: String?,
): DriverLeaveRequestDto =
api.createLeaveRequest(
authHeader(requireToken()),
CreateLeaveRequestBody(
dateFrom = dateFrom,
dateTo = dateTo,
type = type,
requestMode = requestMode,
requestedQuantity = requestedQuantity,
details = details,
idempotencyKey = idempotencyKey,
note = note,
),
).data
suspend fun cancelLeaveRequest(id: String, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment)).data
suspend fun cancelLeaveRequest(id: String, expectedVersion: Long, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment, expectedVersion)).data
suspend fun leaveDocuments(id: String): List<DriverLeaveDocumentDto> =
api.leaveDocuments(authHeader(requireToken()), id).data
suspend fun uploadLeaveDocument(id: String, documentType: String, uri: Uri): DriverLeaveDocumentDto {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "application/octet-stream"
val fileMetadata = resolver.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
null,
null,
null,
)?.use { cursor ->
if (!cursor.moveToFirst()) {
null
} else {
cursor.getString(0) to cursor.getLong(1)
}
}
if ((fileMetadata?.second ?: 0L) > 20L * 1024L * 1024L) {
error("Dokument jest za duży. Maksymalny rozmiar pliku to 20 MB.")
}
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Nie można odczytać wybranego dokumentu.")
val originalName = fileMetadata?.first
?.takeIf { it.isNotBlank() }
?: "dokument"
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val file = MultipartBody.Part.createFormData("file", originalName, body)
return api.uploadLeaveDocument(
authHeader(requireToken()),
id,
file,
documentType.toPlainTextBody(),
).data
}
suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId)
@@ -114,6 +202,9 @@ class DriverRepository(
source: String,
stage: String = "other",
metadata: PhotoUploadMetadata = PhotoUploadMetadata(),
workflowVersion: String? = null,
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): PhotoUploadResponse {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "image/jpeg"
@@ -134,6 +225,9 @@ class DriverRepository(
contentSha256.toPlainTextBody(),
sourceBody,
stage.toPlainTextBody(),
workflowVersion?.toPlainTextBody(),
workflowStepId?.toPlainTextBody(),
workflowDocumentId?.toPlainTextBody(),
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
@@ -161,6 +255,9 @@ class DriverRepository(
upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(),
upload.stage.toPlainTextBody(),
upload.workflowVersion?.toPlainTextBody(),
upload.workflowStepId?.toPlainTextBody(),
upload.workflowDocumentId?.toPlainTextBody(),
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
@@ -198,9 +295,9 @@ class DriverRepository(
api.deletePhoto(authHeader(requireToken()), photoId)
}
suspend fun logout() {
suspend fun logout(notifyServer: Boolean = true) {
val token = tokenStore.read()
if (token != null) {
if (token != null && notifyServer) {
runCatching { api.deletePushToken(authHeader(token)) }
runCatching { api.logout(authHeader(token)) }
}
@@ -1,5 +1,6 @@
package pl.firmatpp.kierowca.data
import java.io.Serializable
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
@@ -9,7 +10,7 @@ data class PhotoUploadMetadata(
val latitude: Double? = null,
val longitude: Double? = null,
val locationAccuracyMeters: Double? = null,
)
) : Serializable
fun PhotoUploadMetadata.toMultipartTextParts(): Map<String, RequestBody> = buildMap {
takenAt?.let { put("takenAt", it.toPlainTextBody()) }
@@ -1,11 +1,19 @@
package pl.firmatpp.kierowca.data.api
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor
import pl.firmatpp.kierowca.BuildConfig
import retrofit2.Retrofit
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 {
fun mobileDriverApi(): MobileDriverApi {
val logging = HttpLoggingInterceptor().apply {
@@ -13,6 +21,9 @@ object ApiFactory {
}
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(chain.request().withDriverAppVersion(BuildConfig.VERSION_NAME))
}
.addInterceptor(logging)
.build()
@@ -3,6 +3,8 @@ package pl.firmatpp.kierowca.data.api
import okhttp3.MultipartBody
import okhttp3.RequestBody
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.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -11,9 +13,14 @@ import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveCalendarResponse
import pl.firmatpp.kierowca.data.model.LeaveDocumentListResponse
import pl.firmatpp.kierowca.data.model.LeaveDocumentResponse
import pl.firmatpp.kierowca.data.model.LeaveForecastBody
import pl.firmatpp.kierowca.data.model.LeaveForecastResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse
import pl.firmatpp.kierowca.data.model.LeaveSummaryResponse
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
@@ -67,11 +74,28 @@ interface MobileDriverApi {
@Query("date") date: String?,
): BootstrapResponse
@POST("mobile/driver/tachograph-reminder/acknowledge")
suspend fun acknowledgeTachographReminder(
@Header("Authorization") authorization: String,
@Body body: AcknowledgeTachographReminderBody,
): AcknowledgeTachographReminderResponse
@GET("mobile/driver/leave-request-types")
suspend fun leaveRequestTypes(
@Header("Authorization") authorization: String,
): LeaveRequestTypesResponse
@GET("mobile/driver/leave-summary")
suspend fun leaveSummary(
@Header("Authorization") authorization: String,
): LeaveSummaryResponse
@POST("mobile/driver/leave-forecast")
suspend fun leaveForecast(
@Header("Authorization") authorization: String,
@Body body: LeaveForecastBody,
): LeaveForecastResponse
@GET("mobile/driver/leave-requests")
suspend fun leaveRequests(
@Header("Authorization") authorization: String,
@@ -96,6 +120,21 @@ interface MobileDriverApi {
@Path("id") id: String,
): LeaveRequestResponse
@GET("mobile/driver/leave-requests/{id}/documents")
suspend fun leaveDocuments(
@Header("Authorization") authorization: String,
@Path("id") id: String,
): LeaveDocumentListResponse
@Multipart
@POST("mobile/driver/leave-requests/{id}/documents")
suspend fun uploadLeaveDocument(
@Header("Authorization") authorization: String,
@Path("id") id: String,
@Part file: MultipartBody.Part,
@Part("documentType") documentType: RequestBody,
): LeaveDocumentResponse
@POST("mobile/driver/leave-requests/{id}/cancel")
suspend fun cancelLeaveRequest(
@Header("Authorization") authorization: String,
@@ -177,6 +216,9 @@ interface MobileDriverApi {
@Part("contentSha256") contentSha256: RequestBody,
@Part("source") source: RequestBody,
@Part("stage") stage: RequestBody?,
@Part("workflowVersion") workflowVersion: RequestBody?,
@Part("workflowStepId") workflowStepId: RequestBody?,
@Part("workflowDocumentId") workflowDocumentId: RequestBody?,
@Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@@ -42,14 +42,28 @@ data class DriverDto(
val anonymousLabel: String?,
)
data class DayPublicationDto(val date: String, val isVisible: Boolean, val version: Long)
data class BootstrapResponse(
val dayPublication: DayPublicationDto? = null,
val session: DriverSessionDto,
val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = 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(
@@ -62,15 +76,56 @@ data class DriverAppSettingsDto(
val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?,
val routeLocationMode: String? = null,
val loadingPhotoRequirement: String? = null,
val loadingWeightRequirement: String? = null,
val unloadingPhotoRequirement: String? = null,
val unloadingWeightRequirement: String? = null,
val routeWorkflow: DriverRouteWorkflowDto? = null,
val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null,
val tachographRemindersEnabled: Boolean? = null,
val leaveRequests: LeaveRequestsConfigDto? = null,
)
data class DriverRouteWorkflowDto(
val version: String,
val customized: Boolean = false,
val loading: DriverRouteStageWorkflowDto,
val unloading: DriverRouteStageWorkflowDto,
)
data class DriverRouteStageWorkflowDto(
val steps: List<DriverRouteWorkflowStepDto> = emptyList(),
)
data class DriverRouteWorkflowStepDto(
val id: String,
val type: String,
val title: String,
val description: String = "",
val requirement: String = "required",
val documents: List<DriverRouteWorkflowDocumentDto> = emptyList(),
)
data class DriverRouteWorkflowDocumentDto(
val id: String,
val label: String,
val description: String = "",
val requirement: String = "required",
)
data class LeaveRequestsConfigDto(
val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"),
val usagePoolCodes: List<String> = listOf(
"ANNUAL",
"ANNUAL_DISABILITY",
"FORCE_MAJEURE",
"CHILD_CARE_14",
"CARE_LEAVE",
),
)
data class DispatchSheetReminderDto(
@@ -84,6 +139,30 @@ data class DispatchSheetReminderDto(
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(
val id: String,
val driverId: String,
@@ -113,6 +192,7 @@ data class RealtimeConfigDto(
val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null,
val reverbOrigin: String? = null,
)
data class RoutesBucketDto(
@@ -181,18 +261,117 @@ data class LeaveRequestTypesResponse(
data class LeaveRequestTypeDto(
val value: String,
val code: String? = null,
val ruleCode: String? = null,
val label: String,
val category: String? = null,
val unit: String? = null,
val poolCode: String? = null,
val requestModes: List<String> = listOf("days"),
val requiredFields: List<String> = emptyList(),
val requiredDocuments: List<String> = emptyList(),
val fieldOptions: Map<String, Map<String, String>> = emptyMap(),
val description: String? = null,
val decisionMode: String? = null,
val payrollMode: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
val legalBasis: String? = null,
)
data class LeaveSummaryResponse(
val data: DriverLeaveSummaryDto,
)
data class DriverLeaveSummaryDto(
val pools: List<DriverLeavePoolDto> = emptyList(),
val dailyNormMinutes: Int = 480,
val balanceConfigured: Boolean = false,
val overdueQuantity: Int = 0,
val visibleUsagePoolCodes: List<String> = listOf(
"ANNUAL",
"ANNUAL_DISABILITY",
"FORCE_MAJEURE",
"CHILD_CARE_14",
"CARE_LEAVE",
),
val availableTypes: List<LeaveRequestTypeDto> = emptyList(),
val reminders: List<DriverLeaveReminderDto> = emptyList(),
)
data class DriverLeavePoolDto(
val id: Int,
val poolCode: String,
val year: Int,
val unit: String,
val calculatedQuantity: Int = 0,
val confirmedQuantity: Int? = null,
val availableQuantity: Int = 0,
val reservedQuantity: Int = 0,
val usedQuantity: Int = 0,
val grantedQuantity: Int = 0,
val overdue: Boolean = false,
val status: String,
)
data class DriverLeaveReminderDto(
val code: String,
val message: String,
val poolId: Int? = null,
)
data class LeaveForecastBody(
val type: String,
val dateFrom: String,
val dateTo: String,
val requestMode: String,
val requestedQuantity: Int? = null,
val details: Map<String, String> = emptyMap(),
)
data class LeaveForecastResponse(
val data: DriverLeaveForecastDto,
)
data class DriverLeaveForecastDto(
val ruleCode: String,
val payrollMode: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
val quantityUnit: String,
val days: List<DriverLeaveForecastDayDto> = emptyList(),
val workingDaysCount: Int = 0,
val requestedQuantity: Int = 0,
val availableQuantity: Int = 0,
val balanceConfigured: Boolean = false,
val sufficientBalance: Boolean = true,
val pools: List<DriverLeavePoolDto> = emptyList(),
)
data class DriverLeaveForecastDayDto(
val date: String,
val quantity: Int,
val source: String? = null,
)
data class CreateLeaveRequestBody(
val dateFrom: String,
val dateTo: String,
val type: String,
val requestMode: String = "days",
val requestedQuantity: Int? = null,
val details: Map<String, String> = emptyMap(),
val idempotencyKey: String? = null,
val note: String?,
)
data class CancelLeaveRequestBody(
val comment: String? = null,
val expectedVersion: Long? = null,
)
data class DriverLeaveRequestDto(
@@ -201,13 +380,82 @@ data class DriverLeaveRequestDto(
val dateFrom: String?,
val dateTo: String?,
val type: String,
val ruleCode: String? = null,
val typeLabel: String? = null,
val requestMode: String = "days",
val requestedQuantity: Int? = null,
val quantityUnit: String? = null,
val rule: DriverLeaveRuleSnapshotDto? = null,
val details: Map<String, String> = emptyMap(),
val note: String? = null,
val status: String,
val version: Long = 1,
val updatedAt: String? = null,
val allowedDecisions: List<String> = emptyList(),
val canCancel: Boolean = false,
val submittedAt: String? = null,
val decidedAt: String? = null,
val decisionComment: String? = null,
val events: List<DriverLeaveRequestEventDto> = emptyList(),
val allocations: List<DriverLeaveAllocationDto> = emptyList(),
val balanceConfigurationRequired: Boolean = false,
val missingDocumentTypes: List<String> = emptyList(),
val payrollMode: String? = null,
val payrollItemTypes: List<String> = emptyList(),
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresLegalVerification: Boolean = false,
val legalVerificationChecks: List<String> = emptyList(),
)
data class DriverLeaveRuleSnapshotDto(
val code: String? = null,
val label: String? = null,
val unit: String? = null,
val decisionMode: String? = null,
val payrollMode: String? = null,
val poolCode: String? = null,
val rule: DriverLeaveRuleRequirementsDto? = null,
val legalBasis: String? = null,
)
data class DriverLeaveRuleRequirementsDto(
val requestModes: List<String> = listOf("days"),
val requiredFields: List<String> = emptyList(),
val requiredDocuments: List<String> = emptyList(),
val fieldOptions: Map<String, Map<String, String>> = emptyMap(),
val description: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
)
data class DriverLeaveAllocationDto(
val id: Int,
val date: String?,
val quantity: Int,
val status: String,
val poolId: Int? = null,
val sourceYear: Int? = null,
)
data class LeaveDocumentListResponse(
val data: List<DriverLeaveDocumentDto> = emptyList(),
)
data class LeaveDocumentResponse(
val data: DriverLeaveDocumentDto,
)
data class DriverLeaveDocumentDto(
val id: Int,
val documentType: String,
val originalName: String,
val mimeType: String? = null,
val sizeBytes: Long = 0,
val uploadedAt: String? = null,
)
data class DriverLeaveCalendarEntryDto(
@@ -245,6 +493,8 @@ data class DriverRouteDto(
val id: String,
val routeDate: String? = null,
val unloadingDate: String? = null,
val ordering: Int? = null,
val dayOrderings: Map<String, Int>? = emptyMap(),
val startsAt: String,
val originName: String,
val destinationName: String,
@@ -253,14 +503,19 @@ data class DriverRouteDto(
val contractCode: String?,
val relationLabel: String,
val status: String,
val driverStatus: String? = null,
val operationalStatus: String? = null,
val loadingWeight: Double? = null,
val loadingNotes: String? = null,
val unloadingWeight: Double? = null,
val unloadingNotes: String? = null,
val trackingStatus: String? = null,
val arrivedAt: String? = null,
val distanceKm: Double?,
val notes: String?,
val truck: TruckDto?,
val completedAt: String? = null,
val operationalCompletedAt: String? = null,
val completedByDriver: DriverDto? = null,
val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null,
@@ -268,6 +523,9 @@ data class DriverRouteDto(
val photosByStage: RoutePhotosByStageDto? = null,
)
fun DriverRouteDto.driverLifecycleStatus(): String =
driverStatus?.takeIf { it in setOf("ZAPLANOWANA", "W TRAKCIE", "ZAKOŃCZONA") } ?: status
data class RoutePhotosByStageDto(
val loading: List<RoutePhotoDto> = emptyList(),
val unloading: List<RoutePhotoDto> = emptyList(),
@@ -295,6 +553,10 @@ data class RoutePhotoDto(
val contentSha256: String? = null,
val source: String,
val stage: String = "other",
val workflowVersion: String? = null,
val workflowStepId: String? = null,
val workflowDocumentId: String? = null,
val originalName: String? = null,
val mimeType: String?,
val size: Long,
val url: String,
@@ -321,16 +583,22 @@ data class PhotoUploadReceiptDto(
data class StartRouteBody(
val clientActionId: String,
val loadingWeight: Double,
val loadingWeight: Double?,
val occurredAt: String,
val photoClientRequestIds: List<String>,
val loadingNotes: String? = null,
val workflowVersion: String? = null,
val completedStepIds: List<String> = emptyList(),
)
data class FinishRouteBody(
val clientActionId: String,
val unloadingWeight: Double,
val unloadingWeight: Double?,
val occurredAt: String,
val photoClientRequestIds: List<String>,
val unloadingNotes: String? = null,
val workflowVersion: String? = null,
val completedStepIds: List<String> = emptyList(),
)
data class RouteActionResponse(
@@ -13,21 +13,33 @@ interface DriverCacheDao {
@Query("SELECT * FROM driver_bootstrap_cache WHERE date = :date LIMIT 1")
suspend fun bootstrap(date: String): DriverBootstrapCacheEntity?
@Query("SELECT * FROM driver_bootstrap_cache ORDER BY syncedAtEpochMillis DESC")
suspend fun allBootstrapCaches(): List<DriverBootstrapCacheEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertRoute(cache: DriverRouteCacheEntity)
@Query("SELECT * FROM driver_route_cache WHERE routeId = :routeId LIMIT 1")
suspend fun route(routeId: String): DriverRouteCacheEntity?
@Query("SELECT * FROM driver_route_cache ORDER BY syncedAtEpochMillis DESC")
suspend fun allRouteCaches(): List<DriverRouteCacheEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSyncState(state: DriverSyncStateEntity)
@Query("SELECT * FROM driver_sync_states WHERE scope = :scope AND ifnull(date, '-') = ifnull(:date, '-') AND ifnull(routeId, '-') = ifnull(:routeId, '-') LIMIT 1")
suspend fun syncState(scope: String, date: String?, routeId: String?): DriverSyncStateEntity?
@Query("SELECT * FROM driver_sync_states ORDER BY syncedAtEpochMillis DESC")
suspend fun allSyncStates(): List<DriverSyncStateEntity>
@Query("DELETE FROM driver_bootstrap_cache")
suspend fun clearBootstrap()
@Query("DELETE FROM driver_route_cache WHERE routeId = :routeId")
suspend fun deleteRoute(routeId: String)
@Query("DELETE FROM driver_route_cache")
suspend fun clearRoutes()
@@ -5,6 +5,7 @@ import com.google.gson.Gson
import java.io.IOException
import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RouteResponse
@@ -26,6 +27,13 @@ class DriverSyncRepository(
val response = repository.bootstrap(date)
val selectedDate = response.driverAppSettings?.selectedDate ?: requestedDate
val routeState = response.syncState?.scopes?.firstOrNull { it.scope == SCOPE_ROUTES }
if (response.dayPublication?.isVisible == false) {
// Previously downloaded details must not survive withdrawing a day.
dao.allRouteCaches().forEach { cached ->
val route = gson.fromJson(cached.payloadJson, RouteResponse::class.java).route
if (selectedDate in routeVisibleDates(route)) dao.deleteRoute(route.id)
}
}
dao.upsertBootstrap(
DriverBootstrapCacheEntity(
date = selectedDate,
@@ -38,6 +46,7 @@ class DriverSyncRepository(
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
cachedBootstrap(requestedDate, throwable)
}
}
@@ -58,9 +67,32 @@ class DriverSyncRepository(
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
if (throwable is HttpException && throwable.code() == 404) dao.deleteRoute(routeId)
cachedRoute(routeId, throwable)
}
suspend fun bootstrapFromCache(date: String?): CachedValue<BootstrapResponse>? {
val cached = dao.bootstrap(date ?: currentDateFallback()) ?: return null
return CachedValue(
value = gson.fromJson(cached.payloadJson, BootstrapResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
suspend fun hasBootstrapCache(date: String?): Boolean =
dao.bootstrap(date ?: currentDateFallback()) != null
suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? {
val cached = dao.route(routeId) ?: return null
return CachedValue(
value = gson.fromJson(cached.payloadJson, RouteResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) {
val syncedAt = System.currentTimeMillis()
dao.upsertRoute(
@@ -98,6 +130,11 @@ class DriverSyncRepository(
suspend fun saveSyncStates(response: SyncStateResponse?) {
response?.scopes.orEmpty().forEach { scope ->
saveSyncScope(scope)
}
}
suspend fun saveSyncScope(scope: SyncScopeDto) {
dao.upsertSyncState(
DriverSyncStateEntity(
key = syncStateKey(scope.scope, scope.date, scope.routeId),
@@ -111,7 +148,6 @@ class DriverSyncRepository(
),
)
}
}
suspend fun clearCache() {
dao.clearBootstrap()
@@ -144,7 +180,7 @@ class DriverSyncRepository(
}
private fun Throwable.isReadFallbackAllowed(): Boolean =
this is IOException || (this is HttpException && code() != 401 && code() != 403)
this is IOException || (this is HttpException && code() in 500..599)
private fun currentDateFallback(): String = java.time.LocalDate.now().toString()
@@ -166,5 +202,7 @@ class DriverSyncRepository(
const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
const val SCOPE_LEAVE_REQUESTS = "leave_requests"
const val SCOPE_LEAVE_CALENDAR = "leave_calendar"
const val SCOPE_SESSION = "session"
}
}
@@ -13,9 +13,7 @@ class NetworkMonitor(context: Context) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val isOnline: Flow<Boolean> = callbackFlow {
fun current(): Boolean = connectivityManager.activeNetwork
?.let(connectivityManager::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
fun current(): Boolean = isCurrentlyValidated()
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
@@ -35,4 +33,17 @@ class NetworkMonitor(context: Context) {
connectivityManager.registerDefaultNetworkCallback(callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
fun isCurrentlyValidated(): Boolean = connectivityManager.activeNetwork
?.let(connectivityManager::getNetworkCapabilities)
?.let(::hasValidatedInternet) == true
}
internal fun hasValidatedInternet(capabilities: NetworkCapabilities): Boolean =
hasValidatedInternetCapabilities(
hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
hasValidated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
)
internal fun hasValidatedInternetCapabilities(hasInternet: Boolean, hasValidated: Boolean): Boolean =
hasInternet && hasValidated
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.withTimeoutOrNull
import pl.firmatpp.kierowca.domain.StartupSessionPolicy
class StartupOfflineFallbackLoader(
private val timeoutMillis: Long = StartupSessionPolicy.OFFLINE_FALLBACK_MILLIS,
) {
suspend fun <T> load(
onlineLoad: suspend () -> T,
offlineLoad: suspend () -> T?,
): T {
val onlineValue = withTimeoutOrNull(timeoutMillis) {
onlineLoad()
}
if (onlineValue != null) {
return onlineValue
}
return offlineLoad()
?: throw IOException(
"Serwer nie odpowiedział w ciągu ${StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS} sekund " +
"i w telefonie nie ma zapisanych danych offline.",
)
}
}
@@ -14,6 +14,15 @@ interface DispatchSheetUploadDao {
@Query("SELECT * FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId LIMIT 1")
suspend fun find(clientRequestId: String): DispatchSheetUploadEntity?
@Query("SELECT * FROM dispatch_sheet_uploads WHERE status != 'CONFIRMED' AND status != 'CANCELLED'")
suspend fun allUnsent(): List<DispatchSheetUploadEntity>
@Query("SELECT * FROM dispatch_sheet_uploads")
suspend fun allRecords(): List<DispatchSheetUploadEntity>
@Query("SELECT COUNT(*) FROM dispatch_sheet_uploads WHERE status != 'CONFIRMED' AND status != 'CANCELLED'")
fun observeUnsentCount(): Flow<Int>
@Query(
"""
SELECT * FROM dispatch_sheet_uploads
@@ -63,4 +72,7 @@ interface DispatchSheetUploadDao {
@Query("DELETE FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String)
@Query("DELETE FROM dispatch_sheet_uploads")
suspend fun deleteAll()
}
@@ -63,12 +63,26 @@ class DispatchSheetUploadOutbox(
}
suspend fun discard(clientRequestId: String) {
workManager.cancelUniqueWork(DispatchSheetUploadWorker.uniqueWorkName(clientRequestId))
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
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) {
val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>()
.setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
@@ -8,7 +8,9 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
class DispatchSheetUploadWorker(
appContext: Context,
@@ -18,6 +20,7 @@ class DispatchSheetUploadWorker(
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure()
val upload = dao.find(clientRequestId) ?: return Result.failure()
val file = File(upload.localPath)
@@ -62,8 +65,11 @@ class DispatchSheetUploadWorker(
}
dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
file.delete()
dao.delete(clientRequestId)
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
@@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
DriverRouteCacheEntity::class,
DriverSyncStateEntity::class,
],
version = 5,
version = 8,
exportSchema = false,
)
abstract class DriverDatabase : RoomDatabase() {
@@ -164,13 +164,75 @@ abstract class DriverDatabase : RoomDatabase() {
}
}
private val migration5To6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE route_actions_new (
clientActionId TEXT NOT NULL PRIMARY KEY,
routeId TEXT NOT NULL,
action TEXT NOT NULL,
weight REAL,
occurredAt TEXT NOT NULL,
photoClientRequestIdsJson TEXT NOT NULL,
status TEXT NOT NULL,
attemptCount INTEGER NOT NULL,
lastError TEXT,
createdAtEpochMillis INTEGER NOT NULL,
updatedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
INSERT INTO route_actions_new (
clientActionId, routeId, action, weight, occurredAt, photoClientRequestIdsJson,
status, attemptCount, lastError, createdAtEpochMillis, updatedAtEpochMillis
)
SELECT
clientActionId, routeId, action, weight, occurredAt, photoClientRequestIdsJson,
status, attemptCount, lastError, createdAtEpochMillis, updatedAtEpochMillis
FROM route_actions
""".trimIndent(),
)
db.execSQL("DROP TABLE route_actions")
db.execSQL("ALTER TABLE route_actions_new RENAME TO route_actions")
db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_routeId ON route_actions(routeId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_status ON route_actions(status)")
}
}
private val migration6To7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE route_actions ADD COLUMN notes TEXT")
}
}
private val migration7To8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowVersion TEXT")
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowStepId TEXT")
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowDocumentId TEXT")
db.execSQL("ALTER TABLE route_actions ADD COLUMN workflowVersion TEXT")
db.execSQL("ALTER TABLE route_actions ADD COLUMN completedStepIdsJson TEXT NOT NULL DEFAULT '[]'")
}
}
fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
DriverDatabase::class.java,
"driver-local-outbox.db",
).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5).build().also { instance = it }
).addMigrations(
migration1To2,
migration2To3,
migration3To4,
migration4To5,
migration5To6,
migration6To7,
migration7To8,
).build().also { instance = it }
}
}
}
@@ -0,0 +1,109 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.work.WorkManager
import java.io.File
import java.util.concurrent.TimeUnit
class OfflineOutboxManager(private val context: Context) {
private val database = DriverDatabase.get(context)
private val workManager = WorkManager.getInstance(context)
suspend fun pendingCount(): Int =
database.photoUploadDao().allUnsent().size +
database.dispatchSheetUploadDao().allUnsent().size +
database.routeActionDao().allUnsent().size +
database.routePointDao().allUnsent().size
suspend fun repairGpsQueue() {
val retryableStatuses = setOf(
RoutePointStatus.Pending,
RoutePointStatus.Syncing,
RoutePointStatus.FailedRetryable,
)
val routeIds = database.routePointDao().allUnsent()
.filter { it.status in retryableStatuses }
.map { it.routeId }
.distinct()
routeIds.forEach { routeId ->
runCatching {
workManager.cancelUniqueWork(RoutePointWorker.uniqueWorkName(routeId))
.result
.get(5, TimeUnit.SECONDS)
}
RoutePointOutbox(context).enqueueWorker(routeId, delaySeconds = 0L)
}
}
suspend fun repairOperationalQueues() {
val photoOutbox = PhotoUploadOutbox(context)
val dispatchSheetOutbox = DispatchSheetUploadOutbox(context)
val routeActionOutbox = RouteActionOutbox(context)
database.photoUploadDao().allUnsent()
.filter { shouldRepairPhotoUpload(it.status, it.lastError) }
.forEach { photoOutbox.retry(it.clientRequestId) }
database.dispatchSheetUploadDao().allUnsent()
.filter { shouldRepairPhotoUpload(it.status, it.lastError) }
.forEach { dispatchSheetOutbox.retry(it.clientRequestId) }
database.routeActionDao().allUnsent()
.filter { shouldRepairRouteAction(it.status, it.lastError) }
.forEach { routeActionOutbox.retry(it.clientActionId) }
}
suspend fun clearAll() {
val photos = database.photoUploadDao().allRecords()
val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
val actions = database.routeActionDao().allUnsent()
val points = database.routePointDao().allUnsent()
photos.forEach {
workManager.cancelUniqueWork(PhotoUploadWorker.uniqueWorkName(it.clientRequestId))
File(it.localPath).delete()
}
dispatchSheets.forEach {
workManager.cancelUniqueWork(DispatchSheetUploadWorker.uniqueWorkName(it.clientRequestId))
File(it.localPath).delete()
}
actions.forEach { workManager.cancelUniqueWork(RouteActionWorker.uniqueWorkName(it.clientActionId)) }
points.map { it.routeId }.distinct().forEach {
workManager.cancelUniqueWork(RoutePointWorker.uniqueWorkName(it))
}
database.photoUploadDao().deleteAll()
database.dispatchSheetUploadDao().deleteAll()
database.routeActionDao().deleteAll()
database.routePointDao().deleteAll()
}
}
internal fun isWorkerCancellationFailure(lastError: String?): Boolean =
lastError?.let { error ->
error.contains("Job was cancelled", ignoreCase = true) ||
error.contains("CancellationException", ignoreCase = true)
} == true
internal fun isPhotoDependencyFailure(lastError: String?): Boolean =
lastError?.contains("Wymagane zdjęcie nie zostało wysłane", ignoreCase = true) == true
internal fun shouldRepairPhotoUpload(status: String, lastError: String?): Boolean =
status in setOf(
PhotoUploadStatus.Pending.storageValue,
PhotoUploadStatus.Uploading.storageValue,
PhotoUploadStatus.Verifying.storageValue,
PhotoUploadStatus.FailedRetryable.storageValue,
) || (status == PhotoUploadStatus.FailedPermanent.storageValue && isWorkerCancellationFailure(lastError))
internal fun shouldRepairRouteAction(status: String, lastError: String?): Boolean =
status in setOf(
RouteActionStatus.Pending,
RouteActionStatus.WaitingForPhotos,
RouteActionStatus.Syncing,
RouteActionStatus.FailedRetryable,
) || (
status == RouteActionStatus.FailedPermanent &&
(isWorkerCancellationFailure(lastError) || isPhotoDependencyFailure(lastError))
)
@@ -43,6 +43,12 @@ interface PhotoUploadDao {
)
suspend fun confirmedForRoute(routeId: String): List<PhotoUploadEntity>
@Query("SELECT * FROM photo_uploads WHERE status != 'CONFIRMED' AND status != 'CANCELLED'")
suspend fun allUnsent(): List<PhotoUploadEntity>
@Query("SELECT * FROM photo_uploads")
suspend fun allRecords(): List<PhotoUploadEntity>
@Query(
"""
UPDATE photo_uploads
@@ -82,4 +88,7 @@ interface PhotoUploadDao {
@Query("DELETE FROM photo_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String)
@Query("DELETE FROM photo_uploads")
suspend fun deleteAll()
}
@@ -17,6 +17,9 @@ data class PhotoUploadEntity(
val localPath: String,
val source: String,
val stage: String = "other",
val workflowVersion: String? = null,
val workflowStepId: String? = null,
val workflowDocumentId: String? = null,
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
@@ -13,9 +13,12 @@ import java.io.File
import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class PhotoUploadOutbox(
private val context: Context,
@@ -28,12 +31,22 @@ class PhotoUploadOutbox(
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> =
dao.observeQueuedUploads()
suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata, stage: String = "other"): PhotoUploadEntity {
suspend fun enqueue(
routeId: String,
uri: Uri,
source: String,
metadata: PhotoUploadMetadata,
stage: String = "other",
workflowVersion: String? = null,
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): PhotoUploadEntity = withContext(Dispatchers.IO) {
val clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) {
"image/png" -> "png"
"image/webp" -> "webp"
"application/pdf" -> "pdf"
else -> "jpg"
}
val uploadDir = File(context.filesDir, "photo-upload-outbox").apply { mkdirs() }
@@ -45,6 +58,9 @@ class PhotoUploadOutbox(
localPath = localFile.absolutePath,
source = source,
stage = stage,
workflowVersion = workflowVersion,
workflowStepId = workflowStepId,
workflowDocumentId = workflowDocumentId,
takenAt = metadata.takenAt,
latitude = metadata.latitude,
longitude = metadata.longitude,
@@ -57,7 +73,7 @@ class PhotoUploadOutbox(
dao.upsert(upload)
enqueueWorker(clientRequestId)
return upload
upload
}
suspend fun retry(clientRequestId: String) {
@@ -74,6 +90,7 @@ class PhotoUploadOutbox(
}
suspend fun discard(clientRequestId: String) {
workManager.cancelUniqueWork(PhotoUploadWorker.uniqueWorkName(clientRequestId))
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
@@ -81,10 +98,13 @@ class PhotoUploadOutbox(
}
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
dao.confirmedForRoute(route.id).forEach { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
dao.allRecords()
.filter { it.routeId == route.id }
.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) {
@@ -118,3 +138,9 @@ class PhotoUploadOutbox(
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,7 +8,9 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
class PhotoUploadWorker(
appContext: Context,
@@ -21,6 +23,7 @@ class PhotoUploadWorker(
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure()
val upload = dao.find(clientRequestId) ?: return Result.failure()
val file = File(upload.localPath)
@@ -68,6 +71,7 @@ class PhotoUploadWorker(
enqueueRouteActionsWaitingForPhoto(clientRequestId)
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
@@ -14,6 +14,73 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'start'
AND createdAtEpochMillis < :beforeEpochMillis
ORDER BY createdAtEpochMillis DESC
LIMIT 1
""",
)
suspend fun latestStartBefore(routeId: String, beforeEpochMillis: Long): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis > :afterEpochMillis
AND status != 'CONFIRMED'
ORDER BY createdAtEpochMillis ASC
""",
)
suspend fun unconfirmedFinishesAfter(routeId: String, afterEpochMillis: Long): List<RouteActionEntity>
@Query(
"""
SELECT EXISTS(
SELECT 1 FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND status = 'CONFIRMED'
AND createdAtEpochMillis > :afterEpochMillis
)
""",
)
suspend fun hasConfirmedFinishAfter(routeId: String, afterEpochMillis: Long): Boolean
@Query(
"""
UPDATE route_actions
SET status = 'CONFIRMED',
lastError = NULL,
updatedAtEpochMillis = :updatedAt
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis < :beforeEpochMillis
AND status != 'CONFIRMED'
""",
)
suspend fun confirmSupersededFinishes(
routeId: String,
beforeEpochMillis: Long,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'")
suspend fun allUnsent(): List<RouteActionEntity>
@Query("DELETE FROM route_actions WHERE clientActionId = :clientActionId")
suspend fun delete(clientActionId: String)
@Query("DELETE FROM route_actions")
suspend fun deleteAll()
@Query("DELETE FROM route_actions WHERE status = 'CONFIRMED' AND updatedAtEpochMillis < :beforeEpochMillis")
suspend fun pruneConfirmed(beforeEpochMillis: Long)
@Query("SELECT * FROM route_actions WHERE status = 'WAITING_FOR_PHOTOS'")
suspend fun waitingForPhotosActions(): List<RouteActionEntity>
@@ -30,12 +30,15 @@ data class RouteActionEntity(
@PrimaryKey val clientActionId: String,
val routeId: String,
val action: String,
val weight: Double,
val weight: Double?,
val occurredAt: String,
val photoClientRequestIdsJson: String,
val workflowVersion: String? = null,
val completedStepIdsJson: String = "[]",
val status: String = RouteActionStatus.Pending,
val attemptCount: Int = 0,
val lastError: String? = null,
val createdAtEpochMillis: Long = System.currentTimeMillis(),
val updatedAtEpochMillis: Long = System.currentTimeMillis(),
val notes: String? = null,
)
@@ -26,18 +26,53 @@ class RouteActionOutbox(
fun observeVisibleActions(): Flow<List<RouteActionEntity>> =
dao.observeVisibleActions()
suspend fun enqueueStart(routeId: String, loadingWeight: Double, photoClientRequestIds: List<String>): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds)
suspend fun discard(clientActionId: String) {
workManager.cancelUniqueWork(RouteActionWorker.uniqueWorkName(clientActionId))
dao.delete(clientActionId)
}
suspend fun enqueueFinish(routeId: String, unloadingWeight: Double, photoClientRequestIds: List<String>): RouteActionEntity =
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds)
suspend fun retry(clientActionId: String) {
val existing = dao.find(clientActionId) ?: return
dao.upsert(
existing.copy(
status = RouteActionStatus.Pending,
lastError = null,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)
enqueueWorker(clientActionId)
}
suspend fun enqueueStart(
routeId: String,
loadingWeight: Double?,
photoClientRequestIds: List<String>,
notes: String? = null,
workflowVersion: String? = null,
completedStepIds: List<String> = emptyList(),
): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes, workflowVersion, completedStepIds)
suspend fun enqueueFinish(
routeId: String,
unloadingWeight: Double?,
photoClientRequestIds: List<String>,
notes: String? = null,
workflowVersion: String? = null,
completedStepIds: List<String> = emptyList(),
): RouteActionEntity =
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes, workflowVersion, completedStepIds)
private suspend fun enqueue(
action: String,
routeId: String,
weight: Double,
weight: Double?,
photoClientRequestIds: List<String>,
notes: String?,
workflowVersion: String?,
completedStepIds: List<String>,
): RouteActionEntity {
dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
val entity = RouteActionEntity(
clientActionId = UUID.randomUUID().toString(),
routeId = routeId,
@@ -45,6 +80,9 @@ class RouteActionOutbox(
weight = weight,
occurredAt = Instant.now().toString(),
photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()),
workflowVersion = workflowVersion,
completedStepIdsJson = gson.toJson(completedStepIds.distinct()),
notes = notes?.trim()?.takeIf { it.isNotEmpty() },
)
dao.upsert(entity)
@@ -7,10 +7,14 @@ import com.google.gson.Gson
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
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.RouteActionResponse
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.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.sync.DriverSyncWorker
@@ -26,13 +30,51 @@ class RouteActionWorker(
private val gson = Gson()
override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
val action = dao.find(clientActionId) ?: return Result.failure()
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 waitingForPhotos = photoClientRequestIds
.mapNotNull { photoDao.find(it) }
.any { it.status != PhotoUploadStatus.Confirmed.storageValue }
val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
val permanentlyFailedPhoto = photoDependencies.firstOrNull { it.status == PhotoUploadStatus.FailedPermanent.storageValue }
if (permanentlyFailedPhoto != null) {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.FailedPermanent,
lastError = "Wymagane zdjęcie nie zostało wysłane. Popraw zdjęcie i ponów potwierdzenie etapu.",
attemptIncrement = 0,
)
return Result.failure()
}
val waitingForPhotos = photoDependencies.any { it.status != PhotoUploadStatus.Confirmed.storageValue }
if (waitingForPhotos) {
dao.updateStatus(
@@ -59,11 +101,25 @@ class RouteActionWorker(
lastError = null,
attemptIncrement = 0,
)
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate)
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()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
val serverRoute = if (error.kind == ApiErrorKind.Conflict) {
runCatching { repository.route(action.routeId).route }.getOrNull()
} else {
null
}
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "route_action_worker",
@@ -75,8 +131,30 @@ class RouteActionWorker(
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"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 {
error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict
error.retryable -> RouteActionStatus.FailedRetryable
@@ -85,7 +163,7 @@ class RouteActionWorker(
dao.updateStatus(
clientActionId = clientActionId,
status = status,
lastError = error.message,
lastError = routeActionConflictMessage(error.message, serverRoute),
attemptIncrement = 0,
)
@@ -102,6 +180,9 @@ class RouteActionWorker(
loadingWeight = action.weight,
occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds,
loadingNotes = action.notes,
workflowVersion = action.workflowVersion,
completedStepIds = completedStepIds(action),
),
)
RouteActionType.Finish -> repository.finishRoute(
@@ -111,6 +192,9 @@ class RouteActionWorker(
unloadingWeight = action.weight,
occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds,
unloadingNotes = action.notes,
workflowVersion = action.workflowVersion,
completedStepIds = completedStepIds(action),
),
)
else -> error("Nieznana akcja kursu: ${action.action}")
@@ -124,9 +208,98 @@ class RouteActionWorker(
.filter { it.isNotBlank() }
.distinct()
private fun completedStepIds(action: RouteActionEntity): List<String> =
runCatching {
gson.fromJson(action.completedStepIdsJson, Array<String>::class.java)?.toList().orEmpty()
}.getOrDefault(emptyList())
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
companion object {
const val KEY_CLIENT_ACTION_ID = "clientActionId"
fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId"
}
}
internal fun routeActionMatchesServer(
action: RouteActionEntity,
photoClientRequestIds: List<String>,
serverRoute: DriverRouteDto,
): Boolean {
val lifecycleMatches = when (action.action) {
RouteActionType.Start -> serverRoute.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA")
RouteActionType.Finish -> serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
else -> false
}
if (!lifecycleMatches) return false
val serverWeight = when (action.action) {
RouteActionType.Start -> serverRoute.loadingWeight
RouteActionType.Finish -> serverRoute.unloadingWeight
else -> null
}
val serverNotes = when (action.action) {
RouteActionType.Start -> serverRoute.loadingNotes
RouteActionType.Finish -> serverRoute.unloadingNotes
else -> null
}
val weightMatches = when {
action.weight == null && serverWeight == null -> true
action.weight == null || serverWeight == null -> false
else -> kotlin.math.abs(action.weight - serverWeight) < 0.0005
}
val notesMatch = action.notes.normalizedRouteActionNotes() == serverNotes.normalizedRouteActionNotes()
val serverPhotoRequestIds = serverRoute.photos
.mapNotNull { it.clientRequestId?.trim()?.takeIf(String::isNotBlank) }
.toSet()
val photosMatch = photoClientRequestIds.all(serverPhotoRequestIds::contains)
return weightMatches && notesMatch && photosMatch
}
internal fun routeActionWasSuperseded(
action: RouteActionEntity,
hasConfirmedFinishAfter: Boolean,
serverRoute: DriverRouteDto,
): Boolean =
action.action == RouteActionType.Finish &&
hasConfirmedFinishAfter &&
serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
internal fun routeActionConflictMessage(message: String, serverRoute: DriverRouteDto?): String {
val serverStatus = serverRoute?.driverLifecycleStatus() ?: return message
val operationalStatus = serverRoute.operationalStatus
?.takeIf(String::isNotBlank)
?.let { ", status operacyjny: $it" }
.orEmpty()
return "$message Status kursu na serwerze: $serverStatus$operationalStatus. Dane w telefonie pozostają zachowane."
}
private fun String?.normalizedRouteActionNotes(): String? =
this?.trim()?.takeIf(String::isNotEmpty)
internal enum class RouteActionPrerequisiteState {
Ready,
Waiting,
Failed,
}
internal fun routeActionPrerequisiteState(
action: RouteActionEntity,
precedingStart: RouteActionEntity?,
): RouteActionPrerequisiteState {
if (action.action != RouteActionType.Finish || precedingStart == null) {
return RouteActionPrerequisiteState.Ready
}
return when (precedingStart.status) {
RouteActionStatus.Confirmed -> RouteActionPrerequisiteState.Ready
RouteActionStatus.FailedConflict,
RouteActionStatus.FailedPermanent,
-> RouteActionPrerequisiteState.Failed
else -> RouteActionPrerequisiteState.Waiting
}
}
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface RoutePointDao {
@@ -14,7 +15,7 @@ interface RoutePointDao {
"""
SELECT * FROM route_points
WHERE routeId = :routeId
AND status != 'CONFIRMED'
AND status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')
ORDER BY timestamp ASC
LIMIT :limit
""",
@@ -25,11 +26,20 @@ interface RoutePointDao {
"""
SELECT COUNT(*) FROM route_points
WHERE routeId = :routeId
AND status != 'CONFIRMED'
AND status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')
""",
)
suspend fun pendingCount(routeId: String): Int
@Query("SELECT * FROM route_points WHERE status != 'CONFIRMED'")
suspend fun allUnsent(): List<RoutePointEntity>
@Query("SELECT COUNT(*) FROM route_points WHERE status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')")
fun observeUnsentCount(): Flow<Int>
@Query("SELECT MIN(createdAtEpochMillis) FROM route_points WHERE status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')")
fun observeOldestUnsentAt(): Flow<Long?>
@Query(
"""
UPDATE route_points
@@ -47,4 +57,10 @@ interface RoutePointDao {
attemptIncrement: Int,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("DELETE FROM route_points WHERE clientPointId IN (:clientPointIds)")
suspend fun delete(clientPointIds: List<String>)
@Query("DELETE FROM route_points")
suspend fun deleteAll()
}
@@ -4,12 +4,15 @@ import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import java.util.UUID
import kotlinx.coroutines.delay
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.RoutePointBatchBody
import pl.firmatpp.kierowca.data.model.RoutePointDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService
class RoutePointWorker(
@@ -20,32 +23,37 @@ class RoutePointWorker(
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val routeId = inputData.getString(KEY_ROUTE_ID) ?: return Result.failure()
while (!isStopped) {
val points = dao.pendingForRoute(routeId, BATCH_LIMIT)
if (points.isEmpty()) return Result.success()
val pointIds = points.map { it.clientPointId }
dao.updateStatus(pointIds, RoutePointStatus.Syncing, null, attemptIncrement = 1)
return runCatching {
val response = repository.storeRoutePoints(
val outcome = runCatching {
repository.storeRoutePoints(
routeId = routeId,
body = RoutePointBatchBody(
clientBatchId = UUID.randomUUID().toString(),
points = points.map { it.toDto() },
),
)
dao.updateStatus(pointIds, RoutePointStatus.Confirmed, null, attemptIncrement = 0)
if (response.arrived) {
ActiveRouteTrackingService.markArrived(applicationContext, routeId)
}
if (dao.pendingCount(routeId) > 0) {
RoutePointOutbox(applicationContext).enqueueWorker(routeId, delaySeconds = 1L)
val response = outcome.getOrNull()
if (response != null) {
dao.delete(pointIds)
if (response.arrived) ActiveRouteTrackingService.markArrived(applicationContext, routeId)
if (dao.pendingCount(routeId) == 0) {
delay(750L)
if (dao.pendingCount(routeId) == 0) return Result.success()
}
continue
}
Result.success()
}.getOrElse { throwable ->
val throwable = outcome.exceptionOrNull() ?: return Result.retry()
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
@@ -59,11 +67,20 @@ class RoutePointWorker(
"retryable" to error.retryable,
),
)
if (shouldStopRouteTrackingAfterPointError(error.kind, error.code)) {
ActiveRouteTrackingService.stop(applicationContext, routeId)
}
if (isInactiveRoutePointError(error.code)) {
dao.delete(pointIds)
continue
}
val status = if (error.retryable) RoutePointStatus.FailedRetryable else RoutePointStatus.FailedPermanent
dao.updateStatus(pointIds, status, error.message, attemptIncrement = 0)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
return if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
return Result.retry()
}
private fun RoutePointEntity.toDto(): RoutePointDto =
@@ -88,3 +105,8 @@ class RoutePointWorker(
fun uniqueWorkName(routeId: String): String = "route-point-sync-$routeId"
}
}
internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS"
internal fun shouldStopRouteTrackingAfterPointError(kind: ApiErrorKind, code: String?): Boolean =
kind == ApiErrorKind.Auth || isInactiveRoutePointError(code)
@@ -0,0 +1,359 @@
package pl.firmatpp.kierowca.diagnostics
import android.Manifest
import android.app.ActivityManager
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.os.BatteryManager
import android.os.Environment
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging
import java.io.File
import java.security.MessageDigest
import java.time.Instant
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.DeviceInfoProvider
import pl.firmatpp.kierowca.data.TokenStore
import pl.firmatpp.kierowca.data.upload.DriverDatabase
data class DiagnosticEntry(
val label: String,
val value: String,
val severity: DiagnosticSeverity = DiagnosticSeverity.Neutral,
)
enum class DiagnosticSeverity { Neutral, Good, Warning, Error }
data class DiagnosticSection(
val title: String,
val entries: List<DiagnosticEntry>,
)
data class DiagnosticSnapshot(
val generatedAtEpochMillis: Long,
val sections: List<DiagnosticSection>,
) {
fun asPlainText(): String = buildString {
appendLine("TPP Kierowca, raport diagnostyczny")
appendLine("Wygenerowano: ${Instant.ofEpochMilli(generatedAtEpochMillis)}")
sections.forEach { section ->
appendLine()
appendLine("[${section.title}]")
section.entries.forEach { entry -> appendLine("${entry.label}: ${entry.value}") }
}
appendLine()
append("Tokeny są celowo przedstawione jako fingerprinty, raport nie zawiera danych pozwalających przejąć sesję.")
}
}
class DiagnosticSnapshotProvider(private val context: Context) {
private val database = DriverDatabase.get(context)
private val tokenStore = TokenStore(context)
suspend fun capture(runtimeSections: List<DiagnosticSection>): DiagnosticSnapshot = withContext(Dispatchers.IO) {
val token = tokenStore.read()
val fcmToken = withTimeoutOrNull(5_000L) { readFirebaseToken() }
?: Result.failure(IllegalStateException("timeout po 5 s"))
val device = DeviceInfoProvider(context, tokenStore).currentDeviceInfo()
val network = currentNetworkEntries()
val photos = database.photoUploadDao().allRecords()
val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
val actions = database.routeActionDao().allUnsent()
val points = database.routePointDao().allUnsent()
val cacheDao = database.driverCacheDao()
val bootstrapCaches = cacheDao.allBootstrapCaches()
val routeCaches = cacheDao.allRouteCaches()
val syncStates = cacheDao.allSyncStates()
val queuedFiles = (photos.map { it.localPath } + dispatchSheets.map { it.localPath }).map(::File)
val activeWork = activeWorkInfo()
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val firebaseOptions = FirebaseApp.getApps(context).firstOrNull()?.options
val databaseFile = context.getDatabasePath("driver-local-outbox.db")
val sections = buildList {
add(
DiagnosticSection(
"Aplikacja",
listOf(
DiagnosticEntry("Pakiet", BuildConfig.APPLICATION_ID),
DiagnosticEntry("Wersja", "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"),
DiagnosticEntry("Typ buildu", BuildConfig.BUILD_TYPE),
DiagnosticEntry("Debug", yesNo(BuildConfig.DEBUG)),
DiagnosticEntry("API", BuildConfig.API_BASE_URL),
DiagnosticEntry("Reverb URL", BuildConfig.REVERB_WS_BASE_URL),
DiagnosticEntry("Reverb app key", secureIdentifierSummary(BuildConfig.REVERB_APP_KEY)),
DiagnosticEntry("Firebase App ID", firebaseOptions?.applicationId ?: "brak"),
DiagnosticEntry("Firebase Project ID", firebaseOptions?.projectId ?: "brak"),
DiagnosticEntry("Firebase Sender ID", firebaseOptions?.gcmSenderId ?: "brak"),
DiagnosticEntry("Pierwsza instalacja", Instant.ofEpochMilli(packageInfo.firstInstallTime).toString()),
DiagnosticEntry("Ostatnia aktualizacja", Instant.ofEpochMilli(packageInfo.lastUpdateTime).toString()),
),
),
)
add(
DiagnosticSection(
"Sesja i identyfikatory",
listOf(
DiagnosticEntry("Token sesji", secureIdentifierSummary(token), if (token == null) DiagnosticSeverity.Error else DiagnosticSeverity.Good),
DiagnosticEntry("Token FCM", fcmToken.fold(::secureIdentifierSummary) { "Błąd odczytu: ${it.message ?: it::class.java.simpleName}" }),
DiagnosticEntry("Device ID", secureIdentifierSummary(device.deviceId)),
DiagnosticEntry("Android ID", secureIdentifierSummary(device.androidId)),
DiagnosticEntry("Numer seryjny", secureIdentifierSummary(device.serialNumber)),
),
),
)
add(
DiagnosticSection(
"Urządzenie",
listOf(
DiagnosticEntry("Producent", device.manufacturer.orEmpty().ifBlank { "brak" }),
DiagnosticEntry("Marka / model", listOfNotNull(device.brand, device.model).joinToString(" ").ifBlank { "brak" }),
DiagnosticEntry("Device / product", listOfNotNull(device.device, device.product).joinToString(" / ").ifBlank { "brak" }),
DiagnosticEntry("Android", "${device.androidVersion ?: "?"}, API ${device.sdkInt}"),
DiagnosticEntry("Build fingerprint", Build.FINGERPRINT),
DiagnosticEntry("Sprzęt / bootloader", "${Build.HARDWARE} / ${Build.BOOTLOADER}"),
DiagnosticEntry("ABI", Build.SUPPORTED_ABIS.joinToString()),
DiagnosticEntry("Język", context.resources.configuration.locales.toLanguageTags().ifBlank { "brak" }),
DiagnosticEntry("Strefa czasowa", java.util.TimeZone.getDefault().id),
DiagnosticEntry("Pamięć aplikacji", storageSummary(context.filesDir)),
DiagnosticEntry("Pamięć współdzielona", storageSummary(Environment.getExternalStorageDirectory())),
DiagnosticEntry("RAM", memorySummary()),
DiagnosticEntry("Bateria", batterySummary()),
),
),
)
add(DiagnosticSection("Sieć urządzenia", network))
add(
DiagnosticSection(
"Uprawnienia i system",
listOf(
permissionEntry("Aparat", Manifest.permission.CAMERA),
permissionEntry("Lokalizacja dokładna", Manifest.permission.ACCESS_FINE_LOCATION),
permissionEntry("Lokalizacja przybliżona", Manifest.permission.ACCESS_COARSE_LOCATION),
notificationPermissionEntry(),
DiagnosticEntry("Powiadomienia systemowe", yesNo(NotificationManagerCompat.from(context).areNotificationsEnabled())),
DiagnosticEntry("Optymalizacja baterii wyłączona", yesNo(isIgnoringBatteryOptimizations())),
DiagnosticEntry("Kanały powiadomień", notificationChannelSummary()),
),
),
)
addAll(runtimeSections)
add(
DiagnosticSection(
"WorkManager",
buildList {
add(DiagnosticEntry("Aktywne zadania", activeWork.size.toString(), if (activeWork.isEmpty()) DiagnosticSeverity.Good else DiagnosticSeverity.Warning))
activeWork.forEach { work ->
add(
DiagnosticEntry(
work.tags.map { it.substringAfterLast('.') }.sorted().joinToString().ifBlank { work.id.toString() },
"${work.state}, próba ${work.runAttemptCount}, id ${work.id}",
),
)
}
},
),
)
add(
DiagnosticSection(
"Lokalna kolejka",
buildList {
add(DiagnosticEntry("Zdjęcia", statusSummary(photos.map { it.status }), queueSeverity(photos.map { it.status })))
add(DiagnosticEntry("Karty spedycyjne", statusSummary(dispatchSheets.map { it.status }), queueSeverity(dispatchSheets.map { it.status })))
add(DiagnosticEntry("Akcje kursu", statusSummary(actions.map { it.status }), queueSeverity(actions.map { it.status })))
add(DiagnosticEntry("Punkty GPS", statusSummary(points.map { it.status }), queueSeverity(points.map { it.status })))
add(DiagnosticEntry("Pliki lokalne", "${queuedFiles.count(File::exists)}/${queuedFiles.size} obecnych, ${formatBytes(queuedFiles.filter(File::exists).sumOf(File::length))}"))
add(DiagnosticEntry("Brakujące pliki", queuedFiles.filterNot(File::exists).joinToString { it.name }.ifBlank { "brak" }, if (queuedFiles.any { !it.exists() }) DiagnosticSeverity.Error else DiagnosticSeverity.Good))
val errors = (photos.mapNotNull { it.lastError } + dispatchSheets.mapNotNull { it.lastError } + actions.mapNotNull { it.lastError } + points.mapNotNull { it.lastError }).distinct()
add(DiagnosticEntry("Błędy kolejki", errors.joinToString(" | ").ifBlank { "brak" }, if (errors.isEmpty()) DiagnosticSeverity.Good else DiagnosticSeverity.Warning))
},
),
)
add(
DiagnosticSection(
"Cache i rewizje",
buildList {
add(DiagnosticEntry("Wersja schematu Room", databaseSchemaVersion().toString()))
add(DiagnosticEntry("Plik bazy", "${if (databaseFile.exists()) formatBytes(databaseFile.length()) else "brak"}, ${databaseFile.absolutePath}"))
add(DiagnosticEntry("Snapshoty list tras", "${bootstrapCaches.size}: ${bootstrapCaches.joinToString { "${it.date}@v${it.version ?: "?"}" }.ifBlank { "brak" }}"))
add(DiagnosticEntry("Snapshoty tras", "${routeCaches.size}: ${routeCaches.joinToString { "${it.routeId}@v${it.version ?: "?"}" }.ifBlank { "brak" }}"))
add(DiagnosticEntry("Scope synchronizacji", syncStates.size.toString()))
syncStates.forEach { sync ->
add(
DiagnosticEntry(
sync.scope,
listOfNotNull(sync.date, sync.routeId).joinToString(" / ").let { target ->
"${target.ifBlank { "globalny" }}, v${sync.version}, checksum ${shortFingerprint(sync.checksum)}, zapis ${Instant.ofEpochMilli(sync.syncedAtEpochMillis)}"
},
),
)
}
},
),
)
}
DiagnosticSnapshot(System.currentTimeMillis(), sections)
}
private fun currentNetworkEntries(): List<DiagnosticEntry> {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = manager.activeNetwork
val capabilities = activeNetwork?.let(manager::getNetworkCapabilities)
if (capabilities == null) {
return listOf(DiagnosticEntry("Aktywna sieć", "brak", DiagnosticSeverity.Error))
}
val transports = buildList {
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) add("Wi-Fi")
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) add("sieć komórkowa")
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) add("Ethernet")
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) add("VPN")
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) add("Bluetooth")
}.ifEmpty { listOf("inny") }
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val links = activeNetwork?.let(manager::getLinkProperties)
val roaming = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))
} else {
"niedostępne"
}
val mtu = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
links?.mtu?.toString() ?: "brak"
} else {
"niedostępne"
}
return listOf(
DiagnosticEntry("Transport", transports.joinToString()),
DiagnosticEntry("Internet zweryfikowany", yesNo(validated), if (validated) DiagnosticSeverity.Good else DiagnosticSeverity.Error),
DiagnosticEntry("NET_CAPABILITY_INTERNET", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET))),
DiagnosticEntry("NET_CAPABILITY_VALIDATED", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))),
DiagnosticEntry("Sieć mierzona", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))),
DiagnosticEntry("Roaming", roaming),
DiagnosticEntry("Pasmo downstream / upstream", "${capabilities.linkDownstreamBandwidthKbps} / ${capabilities.linkUpstreamBandwidthKbps} kb/s"),
DiagnosticEntry("Interfejs", links?.interfaceName ?: "brak"),
DiagnosticEntry("Adresy IP", links?.linkAddresses?.joinToString { it.toString() }?.ifBlank { "brak" } ?: "brak"),
DiagnosticEntry("DNS", links?.dnsServers?.joinToString { it.hostAddress ?: it.toString() }?.ifBlank { "brak" } ?: "brak"),
DiagnosticEntry("MTU", mtu),
DiagnosticEntry("Proxy", links?.httpProxy?.toString() ?: "brak"),
)
}
private fun activeWorkInfo(): List<WorkInfo> = runCatching {
val query = WorkQuery.Builder.fromStates(
listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED),
).build()
WorkManager.getInstance(context).getWorkInfos(query).get(3, TimeUnit.SECONDS)
}.getOrDefault(emptyList())
private fun databaseSchemaVersion(): Int = runCatching {
database.openHelper.readableDatabase.query("PRAGMA user_version").use { cursor ->
if (cursor.moveToFirst()) cursor.getInt(0) else -1
}
}.getOrDefault(-1)
private fun memorySummary(): String {
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val info = ActivityManager.MemoryInfo()
manager.getMemoryInfo(info)
return "wolne ${formatBytes(info.availMem)} z ${formatBytes(info.totalMem)}, niski stan: ${yesNo(info.lowMemory)}"
}
private fun batterySummary(): String {
val manager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).takeIf { it >= 0 }?.let { "$it%" } ?: "brak"
return "$level, ładowanie: ${yesNo(manager.isCharging)}"
}
private fun notificationChannelSummary(): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return "niewspierane"
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channels = manager.notificationChannels
return if (channels.isEmpty()) "0" else channels.joinToString { "${it.id}:${it.importance}" }
}
private fun permissionEntry(label: String, permission: String): DiagnosticEntry {
val granted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
return DiagnosticEntry(label, if (granted) "przyznane" else "brak", if (granted) DiagnosticSeverity.Good else DiagnosticSeverity.Warning)
}
private fun notificationPermissionEntry(): DiagnosticEntry =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
DiagnosticEntry("Uprawnienie powiadomień", "niewymagane na tej wersji Androida")
} else {
permissionEntry("Uprawnienie powiadomień", Manifest.permission.POST_NOTIFICATIONS)
}
private fun isIgnoringBatteryOptimizations(): Boolean {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(context.packageName)
}
private suspend fun readFirebaseToken(): Result<String?> = suspendCancellableCoroutine { continuation ->
runCatching { FirebaseMessaging.getInstance().token }
.onFailure { continuation.resume(Result.failure(it)) }
.onSuccess { task ->
task.addOnCompleteListener { completed ->
if (!continuation.isActive) return@addOnCompleteListener
if (completed.isSuccessful) continuation.resume(Result.success(completed.result))
else continuation.resume(Result.failure(completed.exception ?: IllegalStateException("Nieznany błąd FCM")))
}
}
}
}
internal fun secureIdentifierSummary(value: String?): String {
if (value.isNullOrBlank()) return "brak"
val fingerprint = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray())
.joinToString("") { "%02x".format(it) }
.take(12)
val suffix = value.takeLast(4)
return "obecny, długość ${value.length}, SHA-256 $fingerprint, końcówka …$suffix"
}
private fun shortFingerprint(value: String): String =
MessageDigest.getInstance("SHA-256").digest(value.toByteArray()).joinToString("") { "%02x".format(it) }.take(10)
private fun statusSummary(statuses: List<String>): String =
if (statuses.isEmpty()) "0" else statuses.groupingBy { it }.eachCount().entries.sortedBy { it.key }.joinToString { "${it.key}: ${it.value}" }
private fun queueSeverity(statuses: List<String>): DiagnosticSeverity = when {
statuses.any { it.contains("PERMANENT") || it.contains("CONFLICT") } -> DiagnosticSeverity.Error
statuses.any { it.contains("FAILED") } -> DiagnosticSeverity.Warning
statuses.isNotEmpty() -> DiagnosticSeverity.Warning
else -> DiagnosticSeverity.Good
}
private fun storageSummary(directory: File): String =
"wolne ${formatBytes(directory.usableSpace)} z ${formatBytes(directory.totalSpace)}"
private fun formatBytes(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = listOf("KB", "MB", "GB", "TB")
var value = bytes.toDouble()
var unit = -1
while (value >= 1024 && unit < units.lastIndex) {
value /= 1024
unit++
}
return "%.1f %s".format(java.util.Locale.US, value, units[unit])
}
private fun yesNo(value: Boolean): String = if (value) "tak" else "nie"
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.domain
import kotlin.math.roundToInt
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
data class RouteCardDisplay(
val id: String,
@@ -32,7 +33,7 @@ object RouteDisplayMapper {
destination = route.destinationName.ifBlank { "Cel" },
contractLabel = contract,
time = route.startsAt.ifBlank { "--:--" },
status = route.status,
status = route.driverLifecycleStatus(),
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.",
)
}