Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9e8a75bf1 | ||
|
|
c212a6a488 | ||
|
|
08fcb25a2a | ||
|
|
b8b135ee63 | ||
|
|
6066527dc0 | ||
|
|
1f39f27618 | ||
|
|
956be6a6de | ||
|
|
90ca9775ef | ||
|
|
0e33564b95 | ||
|
|
321b260e3d | ||
|
|
be3c885988 | ||
|
|
802393962e | ||
|
|
ac31308443 | ||
|
|
af832dee93 | ||
|
|
8ca5f20a98 | ||
|
|
26b17ece26 | ||
|
|
2e7eb7e47b | ||
|
|
062415a75d | ||
|
|
ab68be4c1e | ||
|
|
513c1785ef | ||
|
|
e70a53eca7 | ||
|
|
c3004a3d2e | ||
|
|
fda4b462dc | ||
|
|
6a1a15ade9 | ||
|
|
ea44cf5d2e | ||
|
|
984feb170a | ||
|
|
5691c87e91 | ||
|
|
227e08a671 | ||
|
|
3bfb5f9788 | ||
|
|
bf1cf3865a | ||
|
|
0c3bed6213 | ||
|
|
f1cb2d618d | ||
|
|
8c9a3b5b37 | ||
|
|
de8510bc79 | ||
|
|
34d01b312e | ||
|
|
f89332b28c | ||
|
|
8f12bf32bc | ||
|
|
b9ae128842 | ||
|
|
a86dca50b8 | ||
|
|
fc49eae512 | ||
|
|
ffbae9edfd | ||
|
|
2268c64250 | ||
|
|
c954fd9bb8 | ||
|
|
b9757348f2 | ||
|
|
130f8bbecc | ||
|
|
4c00d46a14 | ||
|
|
07fc4021e1 | ||
|
|
431436d0db | ||
|
|
905711d8de | ||
|
|
7c9cb3fb0d | ||
|
|
ae0d1b2d3d | ||
|
|
c5b3c5dae9 | ||
|
|
3fa3b4eac1 | ||
|
|
197bcc6f5e | ||
|
|
f4f5cec2be | ||
|
|
5a630f8401 | ||
|
|
54a3c58a22 | ||
|
|
1c6a044c37 | ||
|
|
fcc022db64 | ||
|
|
a908a2da18 | ||
|
|
2c25647fa5 | ||
|
|
956f33140e | ||
|
|
7bba4dc878 | ||
|
|
89235fbb46 | ||
|
|
0564101e9e | ||
|
|
2baa544b32 | ||
|
|
f9c3a2e305 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||