Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -9,3 +9,5 @@ keystore.properties
|
|||||||
*.jks
|
*.jks
|
||||||
release-artifacts/
|
release-artifacts/
|
||||||
app/src/main/res_bak/
|
app/src/main/res_bak/
|
||||||
|
scripts/*.json
|
||||||
|
scripts/*.json:Zone.Identifier
|
||||||
|
|||||||
@@ -31,9 +31,51 @@ Przed wydaniem zapisz wszystkie zmiany źródłowe w commicie. Następnie urucho
|
|||||||
```
|
```
|
||||||
|
|
||||||
Skrypt automatycznie zwiększa `versionCode` i ostatni człon `versionName`, uruchamia testy,
|
Skrypt automatycznie zwiększa `versionCode` i ostatni człon `versionName`, uruchamia testy,
|
||||||
buduje podpisany APK dla Gitea oraz AAB dla Google Play Console, tworzy commit wersji,
|
buduje podpisany APK oraz AAB, a po poprawnym buildzie pyta, czy wysłać aktualizację do
|
||||||
tag, push i Gitea Release. Artefakty zostają również zapisane lokalnie w
|
Google Play. Po odpowiedzi „tak” publikuje AAB w skonfigurowanym teście zamkniętym i
|
||||||
`release-artifacts/<tag>/`.
|
wysyła zmiany do weryfikacji. Następnie tworzy commit wersji, tag, push i Gitea Release.
|
||||||
|
Artefakty zostają również zapisane lokalnie w `release-artifacts/<tag>/`.
|
||||||
|
|
||||||
|
Do publikacji Google Play konto serwisowe musi mieć dla aplikacji
|
||||||
|
`pl.firmatpp.kierowca` uprawnienia „Wyświetlanie informacji o aplikacji (tylko do odczytu)”
|
||||||
|
oraz „Publikowanie aplikacji na ścieżkach testowych”. Pobrany klucz JSON umieść
|
||||||
|
bezpośrednio w folderze `scripts`, na przykład:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scripts/google-play-service-account.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Skrypt sam znajduje jedyny plik `scripts/*.json`, sprawdza, czy jest prawidłowym kluczem
|
||||||
|
konta serwisowego, i używa go do autoryzacji. Pliki `scripts/*.json` są ignorowane przez
|
||||||
|
Git i nie mogą zostać przypadkowo dodane do repozytorium. Jeśli w folderze znajduje się
|
||||||
|
więcej niż jeden prawidłowy klucz, skrypt przerwie działanie i poprosi o pozostawienie
|
||||||
|
jednego.
|
||||||
|
|
||||||
|
`GOOGLE_PLAY_TRACK` musi być identyfikatorem istniejącego testu zamkniętego. Domyślna
|
||||||
|
wartość to `alpha`. Opcjonalnie można ustawić `GOOGLE_PLAY_PACKAGE_NAME` i
|
||||||
|
`GOOGLE_PLAY_RELEASE_LANGUAGE` (domyślnie `pl-PL`). Zmienna
|
||||||
|
`GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS` pozostaje opcjonalnym sposobem wskazania klucza
|
||||||
|
spoza folderu `scripts`. Skrypt nie zapisuje tokenu ani klucza w repozytorium; prywatny
|
||||||
|
klucz tymczasowy jest usuwany po zakończeniu.
|
||||||
|
|
||||||
|
Przed zatwierdzeniem skrypt waliduje edycję Google Play. Commit edycji jawnie wysyła
|
||||||
|
zmiany do weryfikacji. Jeżeli inne zmiany są już sprawdzane, skrypt nie anuluje ich —
|
||||||
|
kończy się błędem i można go wznowić po zakończeniu poprzedniej weryfikacji.
|
||||||
|
Jeżeli na koncie jest włączone publikowanie zarządzane, po akceptacji Google nadal trzeba
|
||||||
|
wybrać „Opublikuj zmiany” w Play Console; bez publikowania zarządzanego zaakceptowane
|
||||||
|
wydanie zostanie udostępnione testerom automatycznie.
|
||||||
|
|
||||||
|
Awaryjne wydanie wyłącznie do Gitea, bez Google Play:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/release-android.sh --skip-google-play
|
||||||
|
```
|
||||||
|
|
||||||
|
W środowisku bez interaktywnego terminala decyzję trzeba podać jawnie:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/release-android.sh --publish-google-play
|
||||||
|
```
|
||||||
|
|
||||||
Do autoryzacji Gitea używany jest `GITEA_TOKEN` albo dane zapisane w Git credential helper.
|
Do autoryzacji Gitea używany jest `GITEA_TOKEN` albo dane zapisane w Git credential helper.
|
||||||
Jeśli publikacja została przerwana już po utworzeniu commita wersji, można ją wznowić:
|
Jeśli publikacja została przerwana już po utworzeniu commita wersji, można ją wznowić:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ val keystoreProperties = Properties().apply {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "pl.firmatpp.kierowca"
|
namespace = "pl.firmatpp.kierowca"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
fun configValue(name: String, default: String): String =
|
fun configValue(name: String, default: String): String =
|
||||||
providers.gradleProperty(name)
|
providers.gradleProperty(name)
|
||||||
@@ -33,9 +33,9 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "pl.firmatpp.kierowca"
|
applicationId = "pl.firmatpp.kierowca"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = 107
|
versionCode = 122
|
||||||
versionName = "1.0.54"
|
versionName = "1.0.69"
|
||||||
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
|
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -130,6 +130,8 @@ dependencies {
|
|||||||
implementation(libs.okhttp.logging)
|
implementation(libs.okhttp.logging)
|
||||||
implementation(libs.play.services.auth)
|
implementation(libs.play.services.auth)
|
||||||
implementation(libs.play.services.auth.api.phone)
|
implementation(libs.play.services.auth.api.phone)
|
||||||
|
implementation(libs.play.app.update)
|
||||||
|
implementation(libs.play.app.update.ktx)
|
||||||
implementation(libs.retrofit)
|
implementation(libs.retrofit)
|
||||||
implementation(libs.retrofit.gson)
|
implementation(libs.retrofit.gson)
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,23 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.camera"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.speech.RecognitionService" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ class DriverApplication : Application() {
|
|||||||
AppDiagnostics.installFirebaseCrashlytics()
|
AppDiagnostics.installFirebaseCrashlytics()
|
||||||
AppDiagnostics.log("app_started")
|
AppDiagnostics.log("app_started")
|
||||||
applicationScope.launch {
|
applicationScope.launch {
|
||||||
runCatching { OfflineOutboxManager(this@DriverApplication).repairGpsQueue() }
|
val outboxManager = OfflineOutboxManager(this@DriverApplication)
|
||||||
|
runCatching { outboxManager.repairOperationalQueues() }
|
||||||
|
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_operational_queues") }
|
||||||
|
runCatching { outboxManager.repairGpsQueue() }
|
||||||
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") }
|
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -11,17 +12,33 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import pl.firmatpp.kierowca.ui.DriverApp
|
import pl.firmatpp.kierowca.ui.DriverApp
|
||||||
import pl.firmatpp.kierowca.ui.DriverViewModel
|
import pl.firmatpp.kierowca.ui.DriverViewModel
|
||||||
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
|
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
|
||||||
|
import pl.firmatpp.kierowca.update.PlayAppUpdateState
|
||||||
|
import pl.firmatpp.kierowca.update.PlayInAppUpdateController
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private var notificationRouteId by mutableStateOf<String?>(null)
|
private var notificationRouteId by mutableStateOf<String?>(null)
|
||||||
private var notificationRouteTarget by mutableStateOf<String?>(null)
|
private var notificationRouteTarget by mutableStateOf<String?>(null)
|
||||||
private var notificationLeaveRequestId by mutableStateOf<String?>(null)
|
private var notificationLeaveRequestId by mutableStateOf<String?>(null)
|
||||||
|
private var playAppUpdateState by mutableStateOf(PlayAppUpdateState())
|
||||||
|
private lateinit var playInAppUpdates: PlayInAppUpdateController
|
||||||
|
private val appUpdateLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartIntentSenderForResult(),
|
||||||
|
) { result ->
|
||||||
|
if (::playInAppUpdates.isInitialized) {
|
||||||
|
playInAppUpdates.onUpdateFlowResult(result.resultCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
|
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
|
||||||
notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET)
|
notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET)
|
||||||
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
|
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
|
||||||
|
playInAppUpdates = PlayInAppUpdateController(
|
||||||
|
activity = this,
|
||||||
|
launcher = appUpdateLauncher,
|
||||||
|
onStateChanged = { playAppUpdateState = it },
|
||||||
|
)
|
||||||
setContent {
|
setContent {
|
||||||
val viewModel: DriverViewModel = viewModel()
|
val viewModel: DriverViewModel = viewModel()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -32,9 +49,27 @@ class MainActivity : ComponentActivity() {
|
|||||||
initialRouteId = notificationRouteId,
|
initialRouteId = notificationRouteId,
|
||||||
initialRouteTarget = notificationRouteTarget,
|
initialRouteTarget = notificationRouteTarget,
|
||||||
initialLeaveRequestId = notificationLeaveRequestId,
|
initialLeaveRequestId = notificationLeaveRequestId,
|
||||||
|
playAppUpdateState = playAppUpdateState,
|
||||||
|
onStartAppUpdate = playInAppUpdates::startUpdate,
|
||||||
|
onCompleteAppUpdate = playInAppUpdates::completeDownloadedUpdate,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
playInAppUpdates.checkForUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (::playInAppUpdates.isInitialized) {
|
||||||
|
playInAppUpdates.checkForUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
if (::playInAppUpdates.isInitialized) {
|
||||||
|
playInAppUpdates.dispose()
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: android.content.Intent) {
|
override fun onNewIntent(intent: android.content.Intent) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data
|
|||||||
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
|
|
||||||
enum class ApiErrorKind {
|
enum class ApiErrorKind {
|
||||||
@@ -49,7 +50,10 @@ object ApiErrorMapper {
|
|||||||
map(throwable).kind != ApiErrorKind.Network
|
map(throwable).kind != ApiErrorKind.Network
|
||||||
|
|
||||||
fun mapHttpStatus(statusCode: Int, body: String?): ApiError {
|
fun mapHttpStatus(statusCode: Int, body: String?): ApiError {
|
||||||
val code = body?.let { """"code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) }
|
val code = body?.let {
|
||||||
|
""""code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1)
|
||||||
|
?: """"reason"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1)
|
||||||
|
}
|
||||||
val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) }
|
val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) }
|
||||||
val retryable = body?.let { """"retryable"\s*:\s*(true|false)""".toRegex().find(it)?.groupValues?.getOrNull(1)?.toBooleanStrictOrNull() }
|
val retryable = body?.let { """"retryable"\s*:\s*(true|false)""".toRegex().find(it)?.groupValues?.getOrNull(1)?.toBooleanStrictOrNull() }
|
||||||
|
|
||||||
@@ -80,16 +84,21 @@ object ApiErrorMapper {
|
|||||||
}
|
}
|
||||||
val defaultRetryable = statusCode == 429 || statusCode in 500..599
|
val defaultRetryable = statusCode == 429 || statusCode in 500..599
|
||||||
val resolvedRetryable = retryable ?: defaultRetryable
|
val resolvedRetryable = retryable ?: defaultRetryable
|
||||||
val resolvedMessage = message?.takeIf { it.isNotBlank() } ?: when (kind) {
|
val resolvedMessage = when {
|
||||||
|
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.Auth -> "Sesja wygasła. Zaloguj się ponownie."
|
||||||
ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji."
|
ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji."
|
||||||
ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana."
|
ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana."
|
||||||
ApiErrorKind.Conflict -> "Operacja jest w konflikcie z aktualnym stanem danych."
|
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.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.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."
|
ApiErrorKind.Unknown -> "Wystąpił błąd. Operacja nie została potwierdzona."
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ApiError(
|
return ApiError(
|
||||||
kind = kind,
|
kind = kind,
|
||||||
@@ -102,7 +111,15 @@ object ApiErrorMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun mapHttpException(exception: HttpException): ApiError {
|
private fun mapHttpException(exception: HttpException): ApiError {
|
||||||
val body = runCatching { exception.response()?.errorBody()?.string() }.getOrNull()
|
val body = runCatching {
|
||||||
|
val source = exception.response()?.errorBody()?.source() ?: return@runCatching null
|
||||||
|
source.request(Long.MAX_VALUE)
|
||||||
|
source.buffer.clone().readUtf8()
|
||||||
|
}.getOrNull()
|
||||||
return mapHttpStatus(exception.code(), body)
|
return mapHttpStatus(exception.code(), body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Throwable.rethrowIfCancellation() {
|
||||||
|
if (this is CancellationException) throw this
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class AppPreferencesStore(
|
|||||||
|
|
||||||
private val themeModeKey = stringPreferencesKey("theme_mode")
|
private val themeModeKey = stringPreferencesKey("theme_mode")
|
||||||
private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled")
|
private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled")
|
||||||
|
private val routeLocationEnabledKey = booleanPreferencesKey("route_location_enabled")
|
||||||
|
|
||||||
val themeMode: Flow<AppThemeMode> = dataStore.data
|
val themeMode: Flow<AppThemeMode> = dataStore.data
|
||||||
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
|
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
|
||||||
@@ -27,6 +28,9 @@ class AppPreferencesStore(
|
|||||||
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
|
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
|
||||||
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
|
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
|
||||||
|
|
||||||
|
val routeLocationEnabled: Flow<Boolean> = dataStore.data
|
||||||
|
.map { preferences -> preferences[routeLocationEnabledKey] ?: false }
|
||||||
|
|
||||||
suspend fun setThemeMode(themeMode: AppThemeMode) {
|
suspend fun setThemeMode(themeMode: AppThemeMode) {
|
||||||
dataStore.edit { preferences ->
|
dataStore.edit { preferences ->
|
||||||
preferences[themeModeKey] = themeMode.storedValue
|
preferences[themeModeKey] = themeMode.storedValue
|
||||||
@@ -38,4 +42,10 @@ class AppPreferencesStore(
|
|||||||
preferences[routeProgressNotificationEnabledKey] = enabled
|
preferences[routeProgressNotificationEnabledKey] = enabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setRouteLocationEnabled(enabled: Boolean) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[routeLocationEnabledKey] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,17 +22,6 @@ class DeviceInfoProvider(
|
|||||||
androidVersion = Build.VERSION.RELEASE,
|
androidVersion = Build.VERSION.RELEASE,
|
||||||
sdkInt = Build.VERSION.SDK_INT,
|
sdkInt = Build.VERSION.SDK_INT,
|
||||||
appVersion = BuildConfig.VERSION_NAME,
|
appVersion = BuildConfig.VERSION_NAME,
|
||||||
serialNumber = readSerialNumber(),
|
serialNumber = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun readSerialNumber(): String? =
|
|
||||||
runCatching {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
Build.getSerial()
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
Build.SERIAL
|
|
||||||
}
|
|
||||||
}.getOrNull()
|
|
||||||
?.takeUnless { it.isBlank() || it.equals(Build.UNKNOWN, ignoreCase = true) }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package pl.firmatpp.kierowca.data.api
|
package pl.firmatpp.kierowca.data.api
|
||||||
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import pl.firmatpp.kierowca.BuildConfig
|
import pl.firmatpp.kierowca.BuildConfig
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
|
||||||
|
internal const val DRIVER_APP_VERSION_HEADER = "X-Driver-App-Version"
|
||||||
|
|
||||||
|
internal fun Request.withDriverAppVersion(appVersion: String): Request =
|
||||||
|
newBuilder()
|
||||||
|
.header(DRIVER_APP_VERSION_HEADER, appVersion)
|
||||||
|
.build()
|
||||||
|
|
||||||
object ApiFactory {
|
object ApiFactory {
|
||||||
fun mobileDriverApi(): MobileDriverApi {
|
fun mobileDriverApi(): MobileDriverApi {
|
||||||
val logging = HttpLoggingInterceptor().apply {
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
@@ -13,6 +21,9 @@ object ApiFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val client = OkHttpClient.Builder()
|
val client = OkHttpClient.Builder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
chain.proceed(chain.request().withDriverAppVersion(BuildConfig.VERSION_NAME))
|
||||||
|
}
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ data class BootstrapResponse(
|
|||||||
val notificationPreferences: NotificationPreferencesDto? = null,
|
val notificationPreferences: NotificationPreferencesDto? = null,
|
||||||
val realtime: RealtimeConfigDto? = null,
|
val realtime: RealtimeConfigDto? = null,
|
||||||
val syncState: SyncStateResponse? = null,
|
val syncState: SyncStateResponse? = null,
|
||||||
|
val appUpdate: AppUpdateDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AppUpdateDto(
|
||||||
|
val latestVersionCode: Int? = null,
|
||||||
|
val latestVersionName: String? = null,
|
||||||
|
val minimumSupportedVersionCode: Int = 0,
|
||||||
|
val playStoreUrl: String? = null,
|
||||||
|
val track: String? = null,
|
||||||
|
val checkedAt: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DriverAppSettingsDto(
|
data class DriverAppSettingsDto(
|
||||||
@@ -62,6 +72,7 @@ data class DriverAppSettingsDto(
|
|||||||
val allowGalleryUploads: Boolean?,
|
val allowGalleryUploads: Boolean?,
|
||||||
val allowRouteCompletion: Boolean?,
|
val allowRouteCompletion: Boolean?,
|
||||||
val requirePreciseLocationForPhotos: Boolean?,
|
val requirePreciseLocationForPhotos: Boolean?,
|
||||||
|
val routeLocationMode: String? = null,
|
||||||
val loadingPhotoRequirement: String? = null,
|
val loadingPhotoRequirement: String? = null,
|
||||||
val loadingWeightRequirement: String? = null,
|
val loadingWeightRequirement: String? = null,
|
||||||
val unloadingPhotoRequirement: String? = null,
|
val unloadingPhotoRequirement: String? = null,
|
||||||
@@ -117,6 +128,7 @@ data class RealtimeConfigDto(
|
|||||||
val reverbEnabled: Boolean = false,
|
val reverbEnabled: Boolean = false,
|
||||||
val reverbAppKey: String? = null,
|
val reverbAppKey: String? = null,
|
||||||
val reverbWsBaseUrl: String? = null,
|
val reverbWsBaseUrl: String? = null,
|
||||||
|
val reverbOrigin: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoutesBucketDto(
|
data class RoutesBucketDto(
|
||||||
@@ -264,14 +276,19 @@ data class DriverRouteDto(
|
|||||||
val contractCode: String?,
|
val contractCode: String?,
|
||||||
val relationLabel: String,
|
val relationLabel: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
|
val driverStatus: String? = null,
|
||||||
|
val operationalStatus: String? = null,
|
||||||
val loadingWeight: Double? = null,
|
val loadingWeight: Double? = null,
|
||||||
|
val loadingNotes: String? = null,
|
||||||
val unloadingWeight: Double? = null,
|
val unloadingWeight: Double? = null,
|
||||||
|
val unloadingNotes: String? = null,
|
||||||
val trackingStatus: String? = null,
|
val trackingStatus: String? = null,
|
||||||
val arrivedAt: String? = null,
|
val arrivedAt: String? = null,
|
||||||
val distanceKm: Double?,
|
val distanceKm: Double?,
|
||||||
val notes: String?,
|
val notes: String?,
|
||||||
val truck: TruckDto?,
|
val truck: TruckDto?,
|
||||||
val completedAt: String? = null,
|
val completedAt: String? = null,
|
||||||
|
val operationalCompletedAt: String? = null,
|
||||||
val completedByDriver: DriverDto? = null,
|
val completedByDriver: DriverDto? = null,
|
||||||
val originNavigation: NavigationPointDto? = null,
|
val originNavigation: NavigationPointDto? = null,
|
||||||
val destinationNavigation: NavigationPointDto? = null,
|
val destinationNavigation: NavigationPointDto? = null,
|
||||||
@@ -279,6 +296,9 @@ data class DriverRouteDto(
|
|||||||
val photosByStage: RoutePhotosByStageDto? = null,
|
val photosByStage: RoutePhotosByStageDto? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun DriverRouteDto.driverLifecycleStatus(): String =
|
||||||
|
driverStatus?.takeIf { it in setOf("ZAPLANOWANA", "W TRAKCIE", "ZAKOŃCZONA") } ?: status
|
||||||
|
|
||||||
data class RoutePhotosByStageDto(
|
data class RoutePhotosByStageDto(
|
||||||
val loading: List<RoutePhotoDto> = emptyList(),
|
val loading: List<RoutePhotoDto> = emptyList(),
|
||||||
val unloading: List<RoutePhotoDto> = emptyList(),
|
val unloading: List<RoutePhotoDto> = emptyList(),
|
||||||
@@ -335,6 +355,7 @@ data class StartRouteBody(
|
|||||||
val loadingWeight: Double?,
|
val loadingWeight: Double?,
|
||||||
val occurredAt: String,
|
val occurredAt: String,
|
||||||
val photoClientRequestIds: List<String>,
|
val photoClientRequestIds: List<String>,
|
||||||
|
val loadingNotes: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class FinishRouteBody(
|
data class FinishRouteBody(
|
||||||
@@ -342,6 +363,7 @@ data class FinishRouteBody(
|
|||||||
val unloadingWeight: Double?,
|
val unloadingWeight: Double?,
|
||||||
val occurredAt: String,
|
val occurredAt: String,
|
||||||
val photoClientRequestIds: List<String>,
|
val photoClientRequestIds: List<String>,
|
||||||
|
val unloadingNotes: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RouteActionResponse(
|
data class RouteActionResponse(
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ class DriverSyncRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun hasBootstrapCache(date: String?): Boolean =
|
||||||
|
dao.bootstrap(date ?: currentDateFallback()) != null
|
||||||
|
|
||||||
suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? {
|
suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? {
|
||||||
val cached = dao.route(routeId) ?: return null
|
val cached = dao.route(routeId) ?: return null
|
||||||
return CachedValue(
|
return CachedValue(
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package pl.firmatpp.kierowca.data.sync
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import pl.firmatpp.kierowca.domain.StartupSessionPolicy
|
||||||
|
|
||||||
|
class StartupOfflineFallbackLoader(
|
||||||
|
private val timeoutMillis: Long = StartupSessionPolicy.OFFLINE_FALLBACK_MILLIS,
|
||||||
|
) {
|
||||||
|
suspend fun <T> load(
|
||||||
|
onlineLoad: suspend () -> T,
|
||||||
|
offlineLoad: suspend () -> T?,
|
||||||
|
): T {
|
||||||
|
val onlineValue = withTimeoutOrNull(timeoutMillis) {
|
||||||
|
onlineLoad()
|
||||||
|
}
|
||||||
|
if (onlineValue != null) {
|
||||||
|
return onlineValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return offlineLoad()
|
||||||
|
?: throw IOException(
|
||||||
|
"Serwer nie odpowiedział w ciągu ${StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS} sekund " +
|
||||||
|
"i w telefonie nie ma zapisanych danych offline.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,12 +63,26 @@ class DispatchSheetUploadOutbox(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun discard(clientRequestId: String) {
|
suspend fun discard(clientRequestId: String) {
|
||||||
|
workManager.cancelUniqueWork(DispatchSheetUploadWorker.uniqueWorkName(clientRequestId))
|
||||||
dao.find(clientRequestId)?.let { upload ->
|
dao.find(clientRequestId)?.let { upload ->
|
||||||
File(upload.localPath).delete()
|
File(upload.localPath).delete()
|
||||||
dao.delete(upload.clientRequestId)
|
dao.delete(upload.clientRequestId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun retry(clientRequestId: String) {
|
||||||
|
val existing = dao.find(clientRequestId) ?: return
|
||||||
|
dao.upsert(
|
||||||
|
existing.copy(
|
||||||
|
status = PhotoUploadStatus.Pending.storageValue,
|
||||||
|
progress = 0,
|
||||||
|
lastError = null,
|
||||||
|
updatedAtEpochMillis = System.currentTimeMillis(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
enqueueWorker(clientRequestId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun enqueueWorker(clientRequestId: String) {
|
private fun enqueueWorker(clientRequestId: String) {
|
||||||
val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>()
|
val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>()
|
||||||
.setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
|
.setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.io.File
|
|||||||
import pl.firmatpp.kierowca.data.ApiErrorKind
|
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||||
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
||||||
import pl.firmatpp.kierowca.data.DriverRepository
|
import pl.firmatpp.kierowca.data.DriverRepository
|
||||||
|
import pl.firmatpp.kierowca.data.rethrowIfCancellation
|
||||||
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
||||||
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ class DispatchSheetUploadWorker(
|
|||||||
dao.delete(clientRequestId)
|
dao.delete(clientRequestId)
|
||||||
Result.success()
|
Result.success()
|
||||||
}.getOrElse { throwable ->
|
}.getOrElse { throwable ->
|
||||||
|
throwable.rethrowIfCancellation()
|
||||||
val error = ApiErrorMapper.map(throwable)
|
val error = ApiErrorMapper.map(throwable)
|
||||||
AppDiagnostics.reportNonFatal(
|
AppDiagnostics.reportNonFatal(
|
||||||
throwable = throwable,
|
throwable = throwable,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
|
|||||||
DriverRouteCacheEntity::class,
|
DriverRouteCacheEntity::class,
|
||||||
DriverSyncStateEntity::class,
|
DriverSyncStateEntity::class,
|
||||||
],
|
],
|
||||||
version = 6,
|
version = 7,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class DriverDatabase : RoomDatabase() {
|
abstract class DriverDatabase : RoomDatabase() {
|
||||||
@@ -202,13 +202,26 @@ abstract class DriverDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val migration6To7 = object : Migration(6, 7) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE route_actions ADD COLUMN notes TEXT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun get(context: Context): DriverDatabase =
|
fun get(context: Context): DriverDatabase =
|
||||||
instance ?: synchronized(this) {
|
instance ?: synchronized(this) {
|
||||||
instance ?: Room.databaseBuilder(
|
instance ?: Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
DriverDatabase::class.java,
|
DriverDatabase::class.java,
|
||||||
"driver-local-outbox.db",
|
"driver-local-outbox.db",
|
||||||
).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5, migration5To6).build().also { instance = it }
|
).addMigrations(
|
||||||
|
migration1To2,
|
||||||
|
migration2To3,
|
||||||
|
migration3To4,
|
||||||
|
migration4To5,
|
||||||
|
migration5To6,
|
||||||
|
migration6To7,
|
||||||
|
).build().also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,24 @@ class OfflineOutboxManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun repairOperationalQueues() {
|
||||||
|
val photoOutbox = PhotoUploadOutbox(context)
|
||||||
|
val dispatchSheetOutbox = DispatchSheetUploadOutbox(context)
|
||||||
|
val routeActionOutbox = RouteActionOutbox(context)
|
||||||
|
|
||||||
|
database.photoUploadDao().allUnsent()
|
||||||
|
.filter { shouldRepairPhotoUpload(it.status, it.lastError) }
|
||||||
|
.forEach { photoOutbox.retry(it.clientRequestId) }
|
||||||
|
|
||||||
|
database.dispatchSheetUploadDao().allUnsent()
|
||||||
|
.filter { shouldRepairPhotoUpload(it.status, it.lastError) }
|
||||||
|
.forEach { dispatchSheetOutbox.retry(it.clientRequestId) }
|
||||||
|
|
||||||
|
database.routeActionDao().allUnsent()
|
||||||
|
.filter { shouldRepairRouteAction(it.status, it.lastError) }
|
||||||
|
.forEach { routeActionOutbox.retry(it.clientActionId) }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun clearAll() {
|
suspend fun clearAll() {
|
||||||
val photos = database.photoUploadDao().allRecords()
|
val photos = database.photoUploadDao().allRecords()
|
||||||
val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
|
val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
|
||||||
@@ -61,3 +79,31 @@ class OfflineOutboxManager(private val context: Context) {
|
|||||||
database.routePointDao().deleteAll()
|
database.routePointDao().deleteAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun isWorkerCancellationFailure(lastError: String?): Boolean =
|
||||||
|
lastError?.let { error ->
|
||||||
|
error.contains("Job was cancelled", ignoreCase = true) ||
|
||||||
|
error.contains("CancellationException", ignoreCase = true)
|
||||||
|
} == true
|
||||||
|
|
||||||
|
internal fun isPhotoDependencyFailure(lastError: String?): Boolean =
|
||||||
|
lastError?.contains("Wymagane zdjęcie nie zostało wysłane", ignoreCase = true) == true
|
||||||
|
|
||||||
|
internal fun shouldRepairPhotoUpload(status: String, lastError: String?): Boolean =
|
||||||
|
status in setOf(
|
||||||
|
PhotoUploadStatus.Pending.storageValue,
|
||||||
|
PhotoUploadStatus.Uploading.storageValue,
|
||||||
|
PhotoUploadStatus.Verifying.storageValue,
|
||||||
|
PhotoUploadStatus.FailedRetryable.storageValue,
|
||||||
|
) || (status == PhotoUploadStatus.FailedPermanent.storageValue && isWorkerCancellationFailure(lastError))
|
||||||
|
|
||||||
|
internal fun shouldRepairRouteAction(status: String, lastError: String?): Boolean =
|
||||||
|
status in setOf(
|
||||||
|
RouteActionStatus.Pending,
|
||||||
|
RouteActionStatus.WaitingForPhotos,
|
||||||
|
RouteActionStatus.Syncing,
|
||||||
|
RouteActionStatus.FailedRetryable,
|
||||||
|
) || (
|
||||||
|
status == RouteActionStatus.FailedPermanent &&
|
||||||
|
(isWorkerCancellationFailure(lastError) || isPhotoDependencyFailure(lastError))
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import java.util.concurrent.TimeUnit
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
|
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
|
||||||
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||||
|
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
||||||
|
|
||||||
class PhotoUploadOutbox(
|
class PhotoUploadOutbox(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
@@ -74,6 +75,7 @@ class PhotoUploadOutbox(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun discard(clientRequestId: String) {
|
suspend fun discard(clientRequestId: String) {
|
||||||
|
workManager.cancelUniqueWork(PhotoUploadWorker.uniqueWorkName(clientRequestId))
|
||||||
dao.find(clientRequestId)?.let { upload ->
|
dao.find(clientRequestId)?.let { upload ->
|
||||||
File(upload.localPath).delete()
|
File(upload.localPath).delete()
|
||||||
dao.delete(upload.clientRequestId)
|
dao.delete(upload.clientRequestId)
|
||||||
@@ -81,10 +83,13 @@ class PhotoUploadOutbox(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
|
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
|
||||||
dao.confirmedForRoute(route.id).forEach { upload ->
|
dao.allRecords()
|
||||||
File(upload.localPath).delete()
|
.filter { it.routeId == route.id }
|
||||||
dao.delete(upload.clientRequestId)
|
.filter { upload ->
|
||||||
|
upload.status == PhotoUploadStatus.Confirmed.storageValue ||
|
||||||
|
route.photos.orEmpty().any { serverPhoto -> serverPhotoConfirmsUpload(upload, serverPhoto) }
|
||||||
}
|
}
|
||||||
|
.forEach { upload -> discard(upload.clientRequestId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enqueueWorker(clientRequestId: String) {
|
private fun enqueueWorker(clientRequestId: String) {
|
||||||
@@ -118,3 +123,9 @@ class PhotoUploadOutbox(
|
|||||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun serverPhotoConfirmsUpload(upload: PhotoUploadEntity, serverPhoto: RoutePhotoDto): Boolean =
|
||||||
|
serverPhoto.clientRequestId == upload.clientRequestId && (
|
||||||
|
serverPhoto.contentSha256.isNullOrBlank() ||
|
||||||
|
serverPhoto.contentSha256.equals(upload.contentSha256, ignoreCase = true)
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.io.File
|
|||||||
import pl.firmatpp.kierowca.data.ApiErrorKind
|
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||||
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
||||||
import pl.firmatpp.kierowca.data.DriverRepository
|
import pl.firmatpp.kierowca.data.DriverRepository
|
||||||
|
import pl.firmatpp.kierowca.data.rethrowIfCancellation
|
||||||
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
||||||
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ class PhotoUploadWorker(
|
|||||||
enqueueRouteActionsWaitingForPhoto(clientRequestId)
|
enqueueRouteActionsWaitingForPhoto(clientRequestId)
|
||||||
Result.success()
|
Result.success()
|
||||||
}.getOrElse { throwable ->
|
}.getOrElse { throwable ->
|
||||||
|
throwable.rethrowIfCancellation()
|
||||||
val error = ApiErrorMapper.map(throwable)
|
val error = ApiErrorMapper.map(throwable)
|
||||||
AppDiagnostics.reportNonFatal(
|
AppDiagnostics.reportNonFatal(
|
||||||
throwable = throwable,
|
throwable = throwable,
|
||||||
|
|||||||
@@ -14,6 +14,61 @@ interface RouteActionDao {
|
|||||||
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
|
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
|
||||||
suspend fun find(clientActionId: String): RouteActionEntity?
|
suspend fun find(clientActionId: String): RouteActionEntity?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM route_actions
|
||||||
|
WHERE routeId = :routeId
|
||||||
|
AND action = 'start'
|
||||||
|
AND createdAtEpochMillis < :beforeEpochMillis
|
||||||
|
ORDER BY createdAtEpochMillis DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun latestStartBefore(routeId: String, beforeEpochMillis: Long): RouteActionEntity?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM route_actions
|
||||||
|
WHERE routeId = :routeId
|
||||||
|
AND action = 'finish'
|
||||||
|
AND createdAtEpochMillis > :afterEpochMillis
|
||||||
|
AND status != 'CONFIRMED'
|
||||||
|
ORDER BY createdAtEpochMillis ASC
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun unconfirmedFinishesAfter(routeId: String, afterEpochMillis: Long): List<RouteActionEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM route_actions
|
||||||
|
WHERE routeId = :routeId
|
||||||
|
AND action = 'finish'
|
||||||
|
AND status = 'CONFIRMED'
|
||||||
|
AND createdAtEpochMillis > :afterEpochMillis
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun hasConfirmedFinishAfter(routeId: String, afterEpochMillis: Long): Boolean
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE route_actions
|
||||||
|
SET status = 'CONFIRMED',
|
||||||
|
lastError = NULL,
|
||||||
|
updatedAtEpochMillis = :updatedAt
|
||||||
|
WHERE routeId = :routeId
|
||||||
|
AND action = 'finish'
|
||||||
|
AND createdAtEpochMillis < :beforeEpochMillis
|
||||||
|
AND status != 'CONFIRMED'
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun confirmSupersededFinishes(
|
||||||
|
routeId: String,
|
||||||
|
beforeEpochMillis: Long,
|
||||||
|
updatedAt: Long = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
|
||||||
@Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'")
|
@Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'")
|
||||||
suspend fun allUnsent(): List<RouteActionEntity>
|
suspend fun allUnsent(): List<RouteActionEntity>
|
||||||
|
|
||||||
|
|||||||
@@ -38,4 +38,5 @@ data class RouteActionEntity(
|
|||||||
val lastError: String? = null,
|
val lastError: String? = null,
|
||||||
val createdAtEpochMillis: Long = System.currentTimeMillis(),
|
val createdAtEpochMillis: Long = System.currentTimeMillis(),
|
||||||
val updatedAtEpochMillis: Long = System.currentTimeMillis(),
|
val updatedAtEpochMillis: Long = System.currentTimeMillis(),
|
||||||
|
val notes: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,17 +31,40 @@ class RouteActionOutbox(
|
|||||||
dao.delete(clientActionId)
|
dao.delete(clientActionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity =
|
suspend fun retry(clientActionId: String) {
|
||||||
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds)
|
val existing = dao.find(clientActionId) ?: return
|
||||||
|
dao.upsert(
|
||||||
|
existing.copy(
|
||||||
|
status = RouteActionStatus.Pending,
|
||||||
|
lastError = null,
|
||||||
|
updatedAtEpochMillis = System.currentTimeMillis(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
enqueueWorker(clientActionId)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun enqueueFinish(routeId: String, unloadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity =
|
suspend fun enqueueStart(
|
||||||
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds)
|
routeId: String,
|
||||||
|
loadingWeight: Double?,
|
||||||
|
photoClientRequestIds: List<String>,
|
||||||
|
notes: String? = null,
|
||||||
|
): RouteActionEntity =
|
||||||
|
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes)
|
||||||
|
|
||||||
|
suspend fun enqueueFinish(
|
||||||
|
routeId: String,
|
||||||
|
unloadingWeight: Double?,
|
||||||
|
photoClientRequestIds: List<String>,
|
||||||
|
notes: String? = null,
|
||||||
|
): RouteActionEntity =
|
||||||
|
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes)
|
||||||
|
|
||||||
private suspend fun enqueue(
|
private suspend fun enqueue(
|
||||||
action: String,
|
action: String,
|
||||||
routeId: String,
|
routeId: String,
|
||||||
weight: Double?,
|
weight: Double?,
|
||||||
photoClientRequestIds: List<String>,
|
photoClientRequestIds: List<String>,
|
||||||
|
notes: String?,
|
||||||
): RouteActionEntity {
|
): RouteActionEntity {
|
||||||
dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
|
dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
|
||||||
val entity = RouteActionEntity(
|
val entity = RouteActionEntity(
|
||||||
@@ -51,6 +74,7 @@ class RouteActionOutbox(
|
|||||||
weight = weight,
|
weight = weight,
|
||||||
occurredAt = Instant.now().toString(),
|
occurredAt = Instant.now().toString(),
|
||||||
photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()),
|
photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()),
|
||||||
|
notes = notes?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
)
|
)
|
||||||
|
|
||||||
dao.upsert(entity)
|
dao.upsert(entity)
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import com.google.gson.Gson
|
|||||||
import pl.firmatpp.kierowca.data.ApiErrorKind
|
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||||
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
||||||
import pl.firmatpp.kierowca.data.DriverRepository
|
import pl.firmatpp.kierowca.data.DriverRepository
|
||||||
|
import pl.firmatpp.kierowca.data.rethrowIfCancellation
|
||||||
|
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||||
import pl.firmatpp.kierowca.data.model.FinishRouteBody
|
import pl.firmatpp.kierowca.data.model.FinishRouteBody
|
||||||
import pl.firmatpp.kierowca.data.model.RouteActionResponse
|
import pl.firmatpp.kierowca.data.model.RouteActionResponse
|
||||||
import pl.firmatpp.kierowca.data.model.StartRouteBody
|
import pl.firmatpp.kierowca.data.model.StartRouteBody
|
||||||
|
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
|
||||||
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
|
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
|
||||||
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
||||||
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
||||||
@@ -30,6 +33,33 @@ class RouteActionWorker(
|
|||||||
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
|
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
|
||||||
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
|
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
|
||||||
val action = dao.find(clientActionId) ?: return Result.failure()
|
val action = dao.find(clientActionId) ?: return Result.failure()
|
||||||
|
if (action.status == RouteActionStatus.Confirmed) return Result.success()
|
||||||
|
val startPrerequisite = if (action.action == RouteActionType.Finish) {
|
||||||
|
dao.latestStartBefore(action.routeId, action.createdAtEpochMillis)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
when (routeActionPrerequisiteState(action, startPrerequisite)) {
|
||||||
|
RouteActionPrerequisiteState.Waiting -> {
|
||||||
|
dao.updateStatus(
|
||||||
|
clientActionId = clientActionId,
|
||||||
|
status = RouteActionStatus.Pending,
|
||||||
|
lastError = "Czekam na potwierdzenie rozpoczęcia kursu.",
|
||||||
|
attemptIncrement = 0,
|
||||||
|
)
|
||||||
|
return Result.retry()
|
||||||
|
}
|
||||||
|
RouteActionPrerequisiteState.Failed -> {
|
||||||
|
dao.updateStatus(
|
||||||
|
clientActionId = clientActionId,
|
||||||
|
status = RouteActionStatus.FailedConflict,
|
||||||
|
lastError = "Najpierw trzeba potwierdzić rozpoczęcie kursu.",
|
||||||
|
attemptIncrement = 0,
|
||||||
|
)
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
RouteActionPrerequisiteState.Ready -> Unit
|
||||||
|
}
|
||||||
val photoClientRequestIds = photoClientRequestIds(action)
|
val photoClientRequestIds = photoClientRequestIds(action)
|
||||||
|
|
||||||
val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
|
val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
|
||||||
@@ -71,11 +101,25 @@ class RouteActionWorker(
|
|||||||
lastError = null,
|
lastError = null,
|
||||||
attemptIncrement = 0,
|
attemptIncrement = 0,
|
||||||
)
|
)
|
||||||
|
if (action.action == RouteActionType.Finish) {
|
||||||
|
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
|
||||||
|
}
|
||||||
syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate)
|
syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate)
|
||||||
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
|
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
|
||||||
|
if (action.action == RouteActionType.Start) {
|
||||||
|
val outbox = RouteActionOutbox(applicationContext)
|
||||||
|
dao.unconfirmedFinishesAfter(action.routeId, action.createdAtEpochMillis)
|
||||||
|
.forEach { outbox.enqueueWorker(it.clientActionId) }
|
||||||
|
}
|
||||||
Result.success()
|
Result.success()
|
||||||
}.getOrElse { throwable ->
|
}.getOrElse { throwable ->
|
||||||
|
throwable.rethrowIfCancellation()
|
||||||
val error = ApiErrorMapper.map(throwable)
|
val error = ApiErrorMapper.map(throwable)
|
||||||
|
val serverRoute = if (error.kind == ApiErrorKind.Conflict) {
|
||||||
|
runCatching { repository.route(action.routeId).route }.getOrNull()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
AppDiagnostics.reportNonFatal(
|
AppDiagnostics.reportNonFatal(
|
||||||
throwable = throwable,
|
throwable = throwable,
|
||||||
operation = "route_action_worker",
|
operation = "route_action_worker",
|
||||||
@@ -87,8 +131,30 @@ class RouteActionWorker(
|
|||||||
"api_error_code" to error.code,
|
"api_error_code" to error.code,
|
||||||
"api_status_code" to error.statusCode,
|
"api_status_code" to error.statusCode,
|
||||||
"retryable" to error.retryable,
|
"retryable" to error.retryable,
|
||||||
|
"server_route_status" to serverRoute?.driverLifecycleStatus(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
val supersededByConfirmedFinish = action.action == RouteActionType.Finish &&
|
||||||
|
dao.hasConfirmedFinishAfter(action.routeId, action.createdAtEpochMillis)
|
||||||
|
if (serverRoute != null &&
|
||||||
|
(
|
||||||
|
routeActionMatchesServer(action, photoClientRequestIds, serverRoute) ||
|
||||||
|
routeActionWasSuperseded(action, supersededByConfirmedFinish, serverRoute)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
dao.updateStatus(
|
||||||
|
clientActionId = clientActionId,
|
||||||
|
status = RouteActionStatus.Confirmed,
|
||||||
|
lastError = null,
|
||||||
|
attemptIncrement = 0,
|
||||||
|
)
|
||||||
|
if (action.action == RouteActionType.Finish) {
|
||||||
|
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
|
||||||
|
}
|
||||||
|
syncRepository.cacheConfirmedRoute(serverRoute, serverRoute.routeDate)
|
||||||
|
DriverSyncWorker.enqueue(applicationContext, serverRoute.routeDate, serverRoute.id)
|
||||||
|
return@getOrElse Result.success()
|
||||||
|
}
|
||||||
val status = when {
|
val status = when {
|
||||||
error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict
|
error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict
|
||||||
error.retryable -> RouteActionStatus.FailedRetryable
|
error.retryable -> RouteActionStatus.FailedRetryable
|
||||||
@@ -97,7 +163,7 @@ class RouteActionWorker(
|
|||||||
dao.updateStatus(
|
dao.updateStatus(
|
||||||
clientActionId = clientActionId,
|
clientActionId = clientActionId,
|
||||||
status = status,
|
status = status,
|
||||||
lastError = error.message,
|
lastError = routeActionConflictMessage(error.message, serverRoute),
|
||||||
attemptIncrement = 0,
|
attemptIncrement = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,6 +180,7 @@ class RouteActionWorker(
|
|||||||
loadingWeight = action.weight,
|
loadingWeight = action.weight,
|
||||||
occurredAt = action.occurredAt,
|
occurredAt = action.occurredAt,
|
||||||
photoClientRequestIds = photoClientRequestIds,
|
photoClientRequestIds = photoClientRequestIds,
|
||||||
|
loadingNotes = action.notes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
RouteActionType.Finish -> repository.finishRoute(
|
RouteActionType.Finish -> repository.finishRoute(
|
||||||
@@ -123,6 +190,7 @@ class RouteActionWorker(
|
|||||||
unloadingWeight = action.weight,
|
unloadingWeight = action.weight,
|
||||||
occurredAt = action.occurredAt,
|
occurredAt = action.occurredAt,
|
||||||
photoClientRequestIds = photoClientRequestIds,
|
photoClientRequestIds = photoClientRequestIds,
|
||||||
|
unloadingNotes = action.notes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else -> error("Nieznana akcja kursu: ${action.action}")
|
else -> error("Nieznana akcja kursu: ${action.action}")
|
||||||
@@ -142,3 +210,84 @@ class RouteActionWorker(
|
|||||||
fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId"
|
fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun routeActionMatchesServer(
|
||||||
|
action: RouteActionEntity,
|
||||||
|
photoClientRequestIds: List<String>,
|
||||||
|
serverRoute: DriverRouteDto,
|
||||||
|
): Boolean {
|
||||||
|
val lifecycleMatches = when (action.action) {
|
||||||
|
RouteActionType.Start -> serverRoute.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA")
|
||||||
|
RouteActionType.Finish -> serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (!lifecycleMatches) return false
|
||||||
|
|
||||||
|
val serverWeight = when (action.action) {
|
||||||
|
RouteActionType.Start -> serverRoute.loadingWeight
|
||||||
|
RouteActionType.Finish -> serverRoute.unloadingWeight
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val serverNotes = when (action.action) {
|
||||||
|
RouteActionType.Start -> serverRoute.loadingNotes
|
||||||
|
RouteActionType.Finish -> serverRoute.unloadingNotes
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val weightMatches = when {
|
||||||
|
action.weight == null && serverWeight == null -> true
|
||||||
|
action.weight == null || serverWeight == null -> false
|
||||||
|
else -> kotlin.math.abs(action.weight - serverWeight) < 0.0005
|
||||||
|
}
|
||||||
|
val notesMatch = action.notes.normalizedRouteActionNotes() == serverNotes.normalizedRouteActionNotes()
|
||||||
|
val serverPhotoRequestIds = serverRoute.photos
|
||||||
|
.mapNotNull { it.clientRequestId?.trim()?.takeIf(String::isNotBlank) }
|
||||||
|
.toSet()
|
||||||
|
val photosMatch = photoClientRequestIds.all(serverPhotoRequestIds::contains)
|
||||||
|
|
||||||
|
return weightMatches && notesMatch && photosMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun routeActionWasSuperseded(
|
||||||
|
action: RouteActionEntity,
|
||||||
|
hasConfirmedFinishAfter: Boolean,
|
||||||
|
serverRoute: DriverRouteDto,
|
||||||
|
): Boolean =
|
||||||
|
action.action == RouteActionType.Finish &&
|
||||||
|
hasConfirmedFinishAfter &&
|
||||||
|
serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
|
||||||
|
|
||||||
|
internal fun routeActionConflictMessage(message: String, serverRoute: DriverRouteDto?): String {
|
||||||
|
val serverStatus = serverRoute?.driverLifecycleStatus() ?: return message
|
||||||
|
val operationalStatus = serverRoute.operationalStatus
|
||||||
|
?.takeIf(String::isNotBlank)
|
||||||
|
?.let { ", status operacyjny: $it" }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
return "$message Status kursu na serwerze: $serverStatus$operationalStatus. Dane w telefonie pozostają zachowane."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.normalizedRouteActionNotes(): String? =
|
||||||
|
this?.trim()?.takeIf(String::isNotEmpty)
|
||||||
|
|
||||||
|
internal enum class RouteActionPrerequisiteState {
|
||||||
|
Ready,
|
||||||
|
Waiting,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun routeActionPrerequisiteState(
|
||||||
|
action: RouteActionEntity,
|
||||||
|
precedingStart: RouteActionEntity?,
|
||||||
|
): RouteActionPrerequisiteState {
|
||||||
|
if (action.action != RouteActionType.Finish || precedingStart == null) {
|
||||||
|
return RouteActionPrerequisiteState.Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (precedingStart.status) {
|
||||||
|
RouteActionStatus.Confirmed -> RouteActionPrerequisiteState.Ready
|
||||||
|
RouteActionStatus.FailedConflict,
|
||||||
|
RouteActionStatus.FailedPermanent,
|
||||||
|
-> RouteActionPrerequisiteState.Failed
|
||||||
|
else -> RouteActionPrerequisiteState.Waiting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.delay
|
|||||||
import pl.firmatpp.kierowca.data.ApiErrorKind
|
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||||
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
||||||
import pl.firmatpp.kierowca.data.DriverRepository
|
import pl.firmatpp.kierowca.data.DriverRepository
|
||||||
|
import pl.firmatpp.kierowca.data.rethrowIfCancellation
|
||||||
import pl.firmatpp.kierowca.data.model.RoutePointBatchBody
|
import pl.firmatpp.kierowca.data.model.RoutePointBatchBody
|
||||||
import pl.firmatpp.kierowca.data.model.RoutePointDto
|
import pl.firmatpp.kierowca.data.model.RoutePointDto
|
||||||
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
||||||
@@ -52,6 +53,7 @@ class RoutePointWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val throwable = outcome.exceptionOrNull() ?: return Result.retry()
|
val throwable = outcome.exceptionOrNull() ?: return Result.retry()
|
||||||
|
throwable.rethrowIfCancellation()
|
||||||
val error = ApiErrorMapper.map(throwable)
|
val error = ApiErrorMapper.map(throwable)
|
||||||
AppDiagnostics.reportNonFatal(
|
AppDiagnostics.reportNonFatal(
|
||||||
throwable = throwable,
|
throwable = throwable,
|
||||||
@@ -65,9 +67,11 @@ class RoutePointWorker(
|
|||||||
"retryable" to error.retryable,
|
"retryable" to error.retryable,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if (shouldStopRouteTrackingAfterPointError(error.kind, error.code)) {
|
||||||
|
ActiveRouteTrackingService.stop(applicationContext, routeId)
|
||||||
|
}
|
||||||
if (isInactiveRoutePointError(error.code)) {
|
if (isInactiveRoutePointError(error.code)) {
|
||||||
dao.delete(pointIds)
|
dao.delete(pointIds)
|
||||||
ActiveRouteTrackingService.stop(applicationContext, routeId)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,3 +107,6 @@ class RoutePointWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS"
|
internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS"
|
||||||
|
|
||||||
|
internal fun shouldStopRouteTrackingAfterPointError(kind: ApiErrorKind, code: String?): Boolean =
|
||||||
|
kind == ApiErrorKind.Auth || isInactiveRoutePointError(code)
|
||||||
|
|||||||
@@ -229,18 +229,28 @@ class DiagnosticSnapshotProvider(private val context: Context) {
|
|||||||
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
val links = activeNetwork?.let(manager::getLinkProperties)
|
val links = activeNetwork?.let(manager::getLinkProperties)
|
||||||
|
val roaming = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))
|
||||||
|
} else {
|
||||||
|
"niedostępne"
|
||||||
|
}
|
||||||
|
val mtu = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
links?.mtu?.toString() ?: "brak"
|
||||||
|
} else {
|
||||||
|
"niedostępne"
|
||||||
|
}
|
||||||
return listOf(
|
return listOf(
|
||||||
DiagnosticEntry("Transport", transports.joinToString()),
|
DiagnosticEntry("Transport", transports.joinToString()),
|
||||||
DiagnosticEntry("Internet zweryfikowany", yesNo(validated), if (validated) DiagnosticSeverity.Good else DiagnosticSeverity.Error),
|
DiagnosticEntry("Internet zweryfikowany", yesNo(validated), if (validated) DiagnosticSeverity.Good else DiagnosticSeverity.Error),
|
||||||
DiagnosticEntry("NET_CAPABILITY_INTERNET", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET))),
|
DiagnosticEntry("NET_CAPABILITY_INTERNET", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET))),
|
||||||
DiagnosticEntry("NET_CAPABILITY_VALIDATED", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))),
|
DiagnosticEntry("NET_CAPABILITY_VALIDATED", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))),
|
||||||
DiagnosticEntry("Sieć mierzona", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))),
|
DiagnosticEntry("Sieć mierzona", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))),
|
||||||
DiagnosticEntry("Roaming", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))),
|
DiagnosticEntry("Roaming", roaming),
|
||||||
DiagnosticEntry("Pasmo downstream / upstream", "${capabilities.linkDownstreamBandwidthKbps} / ${capabilities.linkUpstreamBandwidthKbps} kb/s"),
|
DiagnosticEntry("Pasmo downstream / upstream", "${capabilities.linkDownstreamBandwidthKbps} / ${capabilities.linkUpstreamBandwidthKbps} kb/s"),
|
||||||
DiagnosticEntry("Interfejs", links?.interfaceName ?: "brak"),
|
DiagnosticEntry("Interfejs", links?.interfaceName ?: "brak"),
|
||||||
DiagnosticEntry("Adresy IP", links?.linkAddresses?.joinToString { it.toString() }?.ifBlank { "brak" } ?: "brak"),
|
DiagnosticEntry("Adresy IP", links?.linkAddresses?.joinToString { it.toString() }?.ifBlank { "brak" } ?: "brak"),
|
||||||
DiagnosticEntry("DNS", links?.dnsServers?.joinToString { it.hostAddress ?: it.toString() }?.ifBlank { "brak" } ?: "brak"),
|
DiagnosticEntry("DNS", links?.dnsServers?.joinToString { it.hostAddress ?: it.toString() }?.ifBlank { "brak" } ?: "brak"),
|
||||||
DiagnosticEntry("MTU", links?.mtu?.toString() ?: "brak"),
|
DiagnosticEntry("MTU", mtu),
|
||||||
DiagnosticEntry("Proxy", links?.httpProxy?.toString() ?: "brak"),
|
DiagnosticEntry("Proxy", links?.httpProxy?.toString() ?: "brak"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.domain
|
|||||||
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||||
|
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
|
||||||
|
|
||||||
data class RouteCardDisplay(
|
data class RouteCardDisplay(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -32,7 +33,7 @@ object RouteDisplayMapper {
|
|||||||
destination = route.destinationName.ifBlank { "Cel" },
|
destination = route.destinationName.ifBlank { "Cel" },
|
||||||
contractLabel = contract,
|
contractLabel = contract,
|
||||||
time = route.startsAt.ifBlank { "--:--" },
|
time = route.startsAt.ifBlank { "--:--" },
|
||||||
status = route.status,
|
status = route.driverLifecycleStatus(),
|
||||||
distanceLabel = distance,
|
distanceLabel = distance,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package pl.firmatpp.kierowca.domain
|
||||||
|
|
||||||
|
data class StartupSessionMessage(
|
||||||
|
val title: String,
|
||||||
|
val detail: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
object StartupSessionPolicy {
|
||||||
|
const val OFFLINE_BUTTON_DELAY_SECONDS = 4
|
||||||
|
const val OFFLINE_FALLBACK_SECONDS = 15
|
||||||
|
const val OFFLINE_FALLBACK_MILLIS = OFFLINE_FALLBACK_SECONDS * 1_000L
|
||||||
|
|
||||||
|
fun remainingSeconds(elapsedSeconds: Int): Int =
|
||||||
|
(OFFLINE_FALLBACK_SECONDS - elapsedSeconds).coerceAtLeast(0)
|
||||||
|
|
||||||
|
fun shouldShowOfflineButton(elapsedSeconds: Int, offlineDataAvailable: Boolean): Boolean =
|
||||||
|
offlineDataAvailable && elapsedSeconds >= OFFLINE_BUTTON_DELAY_SECONDS
|
||||||
|
|
||||||
|
fun message(elapsedSeconds: Int): StartupSessionMessage = when {
|
||||||
|
elapsedSeconds < 3 -> StartupSessionMessage(
|
||||||
|
title = "Sprawdzamy zapisaną sesję",
|
||||||
|
detail = "Odczytujemy bezpieczne logowanie kierowcy.",
|
||||||
|
)
|
||||||
|
elapsedSeconds < 6 -> StartupSessionMessage(
|
||||||
|
title = "Potwierdzamy logowanie",
|
||||||
|
detail = "Łączymy telefon z serwerem.",
|
||||||
|
)
|
||||||
|
elapsedSeconds < 9 -> StartupSessionMessage(
|
||||||
|
title = "Pobieramy aktualne kursy",
|
||||||
|
detail = "Synchronizujemy plan dnia i ustawienia aplikacji.",
|
||||||
|
)
|
||||||
|
elapsedSeconds < 12 -> StartupSessionMessage(
|
||||||
|
title = "To trwa dłużej niż zwykle",
|
||||||
|
detail = "Przy słabym zasięgu skorzystamy z danych zapisanych w telefonie.",
|
||||||
|
)
|
||||||
|
else -> StartupSessionMessage(
|
||||||
|
title = "Przygotowujemy tryb offline",
|
||||||
|
detail = "Za chwilę pokażemy ostatnie zapisane kursy.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun countdownLabel(elapsedSeconds: Int): String {
|
||||||
|
val remaining = remainingSeconds(elapsedSeconds)
|
||||||
|
|
||||||
|
return if (remaining > 0) {
|
||||||
|
"Tryb offline najpóźniej za $remaining s"
|
||||||
|
} else {
|
||||||
|
"Otwieramy zapisane dane offline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,17 +43,17 @@ class DriverRepositoryLiveSyncGateway(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface LiveWebSocketFactory {
|
interface LiveWebSocketFactory {
|
||||||
fun newWebSocket(url: String, listener: WebSocketListener): WebSocket
|
fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
class OkHttpLiveWebSocketFactory(
|
class OkHttpLiveWebSocketFactory(
|
||||||
private val client: OkHttpClient,
|
private val client: OkHttpClient,
|
||||||
) : LiveWebSocketFactory {
|
) : LiveWebSocketFactory {
|
||||||
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket =
|
override fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket =
|
||||||
client.newWebSocket(
|
client.newWebSocket(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Origin", websocketOrigin(url))
|
.header("Origin", origin?.takeIf { it.isNotBlank() } ?: websocketOrigin(url))
|
||||||
.build(),
|
.build(),
|
||||||
listener,
|
listener,
|
||||||
)
|
)
|
||||||
@@ -248,7 +248,7 @@ class DriverLiveSyncClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun connectNow(resetAttempt: Boolean = false) {
|
private fun connectNow(resetAttempt: Boolean = false) {
|
||||||
val wsUrl = synchronized(lock) {
|
val (wsUrl, origin) = synchronized(lock) {
|
||||||
val config = realtimeConfig ?: return
|
val config = realtimeConfig ?: return
|
||||||
val id = driverId ?: return
|
val id = driverId ?: return
|
||||||
if (!desiredActive || !foregroundActive || !networkAvailable || !isConfigUsable(config)) return
|
if (!desiredActive || !foregroundActive || !networkAvailable || !isConfigUsable(config)) return
|
||||||
@@ -262,11 +262,15 @@ class DriverLiveSyncClient(
|
|||||||
|
|
||||||
val appKey = config.reverbAppKey.orEmpty()
|
val appKey = config.reverbAppKey.orEmpty()
|
||||||
val wsBaseUrl = config.reverbWsBaseUrl.orEmpty()
|
val wsBaseUrl = config.reverbWsBaseUrl.orEmpty()
|
||||||
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false"
|
Pair(
|
||||||
|
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false",
|
||||||
|
config.reverbOrigin,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val socket = webSocketFactory.newWebSocket(
|
val socket = webSocketFactory.newWebSocket(
|
||||||
wsUrl,
|
wsUrl,
|
||||||
|
origin,
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
handleMessage(webSocket, text)
|
handleMessage(webSocket, text)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.work.workDataOf
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import pl.firmatpp.kierowca.data.ApiErrorKind
|
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||||
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
||||||
|
import pl.firmatpp.kierowca.data.rethrowIfCancellation
|
||||||
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
|
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
|
||||||
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
||||||
import pl.firmatpp.kierowca.data.model.SyncScopeDto
|
import pl.firmatpp.kierowca.data.model.SyncScopeDto
|
||||||
@@ -79,6 +80,7 @@ class DriverSyncWorker(
|
|||||||
|
|
||||||
Result.success()
|
Result.success()
|
||||||
}.getOrElse { throwable ->
|
}.getOrElse { throwable ->
|
||||||
|
throwable.rethrowIfCancellation()
|
||||||
val error = ApiErrorMapper.map(throwable)
|
val error = ApiErrorMapper.map(throwable)
|
||||||
AppDiagnostics.reportNonFatal(
|
AppDiagnostics.reportNonFatal(
|
||||||
throwable = throwable,
|
throwable = throwable,
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ class LeaveRequestDecisionNotificationWorker(
|
|||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification)
|
NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import pl.firmatpp.kierowca.R
|
|||||||
import pl.firmatpp.kierowca.data.ApiErrorKind
|
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||||
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
||||||
import pl.firmatpp.kierowca.data.DriverRepository
|
import pl.firmatpp.kierowca.data.DriverRepository
|
||||||
|
import pl.firmatpp.kierowca.data.rethrowIfCancellation
|
||||||
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
||||||
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
@@ -72,9 +73,14 @@ class NewRouteNotificationWorker(
|
|||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
|
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
return@runCatching Result.success()
|
||||||
|
}
|
||||||
Result.success()
|
Result.success()
|
||||||
}.getOrElse { throwable ->
|
}.getOrElse { throwable ->
|
||||||
|
throwable.rethrowIfCancellation()
|
||||||
if (throwable is HttpException && throwable.code() == 404) {
|
if (throwable is HttpException && throwable.code() == 404) {
|
||||||
Result.success()
|
Result.success()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package pl.firmatpp.kierowca.tracking
|
package pl.firmatpp.kierowca.tracking
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
@@ -54,6 +55,11 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action != ACTION_STOP && !hasLocationPermission()) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_STOP -> {
|
ACTION_STOP -> {
|
||||||
val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
|
val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
|
||||||
@@ -69,7 +75,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
|||||||
routeId = id
|
routeId = id
|
||||||
arrived = true
|
arrived = true
|
||||||
runCatching { locationManager.removeUpdates(this) }
|
runCatching { locationManager.removeUpdates(this) }
|
||||||
startForegroundCompat(buildNotification())
|
if (!startForegroundCompat(buildNotification())) {
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
@@ -97,7 +105,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
|||||||
?: totalDistanceMeters
|
?: totalDistanceMeters
|
||||||
progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled
|
progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled
|
||||||
if (routeChanged) arrived = false
|
if (routeChanged) arrived = false
|
||||||
startForegroundCompat(buildNotification())
|
if (!startForegroundCompat(buildNotification())) {
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
if (!arrived) requestLocationUpdates()
|
if (!arrived) requestLocationUpdates()
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
@@ -113,9 +123,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
|||||||
if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) {
|
if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) {
|
||||||
arrived = true
|
arrived = true
|
||||||
runCatching { locationManager.removeUpdates(this) }
|
runCatching { locationManager.removeUpdates(this) }
|
||||||
startForegroundCompat(buildNotification())
|
if (!startForegroundCompat(buildNotification())) return
|
||||||
} else {
|
} else {
|
||||||
startForegroundCompat(buildNotification())
|
if (!startForegroundCompat(buildNotification())) return
|
||||||
}
|
}
|
||||||
|
|
||||||
val (battery, charging) = batterySnapshot()
|
val (battery, charging) = batterySnapshot()
|
||||||
@@ -136,6 +146,13 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
runCatching { locationManager.removeUpdates(this) }
|
runCatching { locationManager.removeUpdates(this) }
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
stopForeground(true)
|
||||||
|
}
|
||||||
|
getSystemService(NotificationManager::class.java).cancel(NOTIFICATION_ID)
|
||||||
getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
|
getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
|
||||||
.edit()
|
.edit()
|
||||||
.remove(ACTIVE_ROUTE_ID)
|
.remove(ACTIVE_ROUTE_ID)
|
||||||
@@ -144,6 +161,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
private fun requestLocationUpdates() {
|
private fun requestLocationUpdates() {
|
||||||
if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) {
|
if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) {
|
||||||
return
|
return
|
||||||
@@ -197,12 +215,26 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
|||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startForegroundCompat(notification: Notification) {
|
private fun startForegroundCompat(notification: Notification): Boolean {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && hasLocationPermission()) {
|
if (!hasLocationPermission()) {
|
||||||
|
stopSelf()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
|
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
|
||||||
} else {
|
} else {
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
true
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
stopSelf()
|
||||||
|
false
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
stopSelf()
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureNotificationChannel() {
|
private fun ensureNotificationChannel() {
|
||||||
@@ -213,7 +245,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
|||||||
"Aktywny kurs",
|
"Aktywny kurs",
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
).apply {
|
).apply {
|
||||||
description = "Postęp aktywnego kursu i lokalizacja w tle"
|
description = "Informacje o aktywnym kursie"
|
||||||
}
|
}
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
@@ -292,6 +324,8 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
|||||||
private const val ACTIVE_ROUTE_ID = "active_route_id"
|
private const val ACTIVE_ROUTE_ID = "active_route_id"
|
||||||
|
|
||||||
fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) {
|
fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) {
|
||||||
|
if (!hasLocationPermission(context)) return
|
||||||
|
|
||||||
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
|
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
|
||||||
action = ACTION_START
|
action = ACTION_START
|
||||||
putExtra(EXTRA_ROUTE_ID, route.id)
|
putExtra(EXTRA_ROUTE_ID, route.id)
|
||||||
@@ -302,7 +336,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
|||||||
route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) }
|
route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) }
|
||||||
route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) }
|
route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) }
|
||||||
}
|
}
|
||||||
ContextCompat.startForegroundService(context, intent)
|
runCatching { ContextCompat.startForegroundService(context, intent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(context: Context, routeId: String? = null) {
|
fun stop(context: Context, routeId: String? = null) {
|
||||||
@@ -318,11 +352,20 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun markArrived(context: Context, routeId: String) {
|
fun markArrived(context: Context, routeId: String) {
|
||||||
|
if (!hasLocationPermission(context)) return
|
||||||
|
val activeRouteId = context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.getString(ACTIVE_ROUTE_ID, null)
|
||||||
|
if (activeRouteId != routeId) return
|
||||||
|
|
||||||
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
|
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
|
||||||
action = ACTION_MARK_ARRIVED
|
action = ACTION_MARK_ARRIVED
|
||||||
putExtra(EXTRA_ROUTE_ID, routeId)
|
putExtra(EXTRA_ROUTE_ID, routeId)
|
||||||
}
|
}
|
||||||
ContextCompat.startForegroundService(context, intent)
|
runCatching { ContextCompat.startForegroundService(context, intent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hasLocationPermission(context: Context): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,13 +22,20 @@ internal val routeActionVisibleStatuses = setOf(
|
|||||||
RouteActionStatus.FailedPermanent,
|
RouteActionStatus.FailedPermanent,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
|
private val routeActionDataProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
|
||||||
|
private val routeActionLifecycleProjectionStatuses = setOf(
|
||||||
|
RouteActionStatus.Pending,
|
||||||
|
RouteActionStatus.WaitingForPhotos,
|
||||||
|
RouteActionStatus.Syncing,
|
||||||
|
RouteActionStatus.FailedRetryable,
|
||||||
|
RouteActionStatus.Confirmed,
|
||||||
|
)
|
||||||
|
|
||||||
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
|
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
|
||||||
val visible = actions
|
val visible = actions
|
||||||
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
|
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
|
||||||
val projectable = actions
|
val projectable = actions
|
||||||
.filter { it.routeId == route.id && it.status in routeActionProjectionStatuses }
|
.filter { it.routeId == route.id && it.status in routeActionDataProjectionStatuses }
|
||||||
var projectedRoute = route
|
var projectedRoute = route
|
||||||
var loadingWeightPending = false
|
var loadingWeightPending = false
|
||||||
var unloadingWeightPending = false
|
var unloadingWeightPending = false
|
||||||
@@ -39,21 +46,33 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
|
|||||||
when (action.action) {
|
when (action.action) {
|
||||||
RouteActionType.Start -> {
|
RouteActionType.Start -> {
|
||||||
projectedRoute = projectedRoute.copy(
|
projectedRoute = projectedRoute.copy(
|
||||||
status = "W TRAKCIE",
|
|
||||||
loadingWeight = action.weight,
|
loadingWeight = action.weight,
|
||||||
trackingStatus = "active",
|
loadingNotes = action.notes,
|
||||||
photos = projectedRoute.safePhotos(),
|
photos = projectedRoute.safePhotos(),
|
||||||
)
|
)
|
||||||
|
if (action.status in routeActionLifecycleProjectionStatuses) {
|
||||||
|
projectedRoute = projectedRoute.copy(
|
||||||
|
status = "W TRAKCIE",
|
||||||
|
driverStatus = "W TRAKCIE",
|
||||||
|
trackingStatus = "active",
|
||||||
|
)
|
||||||
|
}
|
||||||
loadingWeightPending = action.status != RouteActionStatus.Confirmed
|
loadingWeightPending = action.status != RouteActionStatus.Confirmed
|
||||||
}
|
}
|
||||||
RouteActionType.Finish -> {
|
RouteActionType.Finish -> {
|
||||||
projectedRoute = projectedRoute.copy(
|
projectedRoute = projectedRoute.copy(
|
||||||
status = "ZAKOŃCZONA",
|
|
||||||
unloadingWeight = action.weight,
|
unloadingWeight = action.weight,
|
||||||
trackingStatus = "finished",
|
unloadingNotes = action.notes,
|
||||||
completedAt = action.occurredAt,
|
|
||||||
photos = projectedRoute.safePhotos(),
|
photos = projectedRoute.safePhotos(),
|
||||||
)
|
)
|
||||||
|
if (action.status in routeActionLifecycleProjectionStatuses) {
|
||||||
|
projectedRoute = projectedRoute.copy(
|
||||||
|
status = "ZAKOŃCZONA",
|
||||||
|
driverStatus = "ZAKOŃCZONA",
|
||||||
|
trackingStatus = "finished",
|
||||||
|
completedAt = action.occurredAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
unloadingWeightPending = action.status != RouteActionStatus.Confirmed
|
unloadingWeightPending = action.status != RouteActionStatus.Confirmed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,150 @@
|
|||||||
package pl.firmatpp.kierowca.ui
|
package pl.firmatpp.kierowca.ui
|
||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
import java.net.URLEncoder
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.abs
|
||||||
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||||
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
|
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
|
||||||
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
||||||
|
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
|
||||||
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
||||||
|
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
|
||||||
|
import pl.firmatpp.kierowca.data.upload.isWorkerCancellationFailure
|
||||||
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
|
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
|
||||||
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
|
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
|
||||||
import pl.firmatpp.kierowca.data.upload.RouteActionType
|
import pl.firmatpp.kierowca.data.upload.RouteActionType
|
||||||
|
|
||||||
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
||||||
|
private val routeWeightKilogramInputThreshold = BigDecimal("1000")
|
||||||
|
private const val routeWeightMaxTons = 999.999
|
||||||
|
const val routeStageNotesMaxLength = 2000
|
||||||
|
private const val googleMapsPackage = "com.google.android.apps.maps"
|
||||||
|
|
||||||
|
data class NavigationIntentCandidate(
|
||||||
|
val uri: String,
|
||||||
|
val packageName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AppUpdateBannerUi(
|
||||||
|
val message: String,
|
||||||
|
val actionLabel: String,
|
||||||
|
val required: Boolean,
|
||||||
|
val downloaded: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
object RouteLocationMode {
|
||||||
|
const val Disabled = "disabled"
|
||||||
|
const val Optional = "optional"
|
||||||
|
const val Required = "required"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalizeRouteLocationMode(value: String?): String = when (value) {
|
||||||
|
RouteLocationMode.Disabled,
|
||||||
|
RouteLocationMode.Required,
|
||||||
|
-> value
|
||||||
|
else -> RouteLocationMode.Optional
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldTrackRouteLocation(
|
||||||
|
mode: String,
|
||||||
|
optionalLocationEnabled: Boolean,
|
||||||
|
hasLocationPermission: Boolean,
|
||||||
|
): Boolean = hasLocationPermission && when (normalizeRouteLocationMode(mode)) {
|
||||||
|
RouteLocationMode.Disabled -> false
|
||||||
|
RouteLocationMode.Required -> true
|
||||||
|
else -> optionalLocationEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fun routeStartRequiresLocationPermission(
|
||||||
|
mode: String,
|
||||||
|
hasLocationPermission: Boolean,
|
||||||
|
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required && !hasLocationPermission
|
||||||
|
|
||||||
|
fun shouldWarnAboutRequiredRouteLocation(
|
||||||
|
mode: String,
|
||||||
|
hasActiveRoute: Boolean,
|
||||||
|
hasLocationPermission: Boolean,
|
||||||
|
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required &&
|
||||||
|
hasActiveRoute &&
|
||||||
|
!hasLocationPermission
|
||||||
|
|
||||||
|
fun appUpdateBannerUi(
|
||||||
|
currentVersionCode: Int,
|
||||||
|
latestVersionCode: Int?,
|
||||||
|
latestVersionName: String?,
|
||||||
|
minimumSupportedVersionCode: Int,
|
||||||
|
playUpdateAvailable: Boolean,
|
||||||
|
playAvailableVersionCode: Int?,
|
||||||
|
updateDownloaded: Boolean,
|
||||||
|
): AppUpdateBannerUi? {
|
||||||
|
val highestAvailableVersion = listOfNotNull(latestVersionCode, playAvailableVersionCode).maxOrNull()
|
||||||
|
val required = minimumSupportedVersionCode > 0 && currentVersionCode < minimumSupportedVersionCode
|
||||||
|
val available = updateDownloaded ||
|
||||||
|
playUpdateAvailable ||
|
||||||
|
highestAvailableVersion?.let { it > currentVersionCode } == true ||
|
||||||
|
required
|
||||||
|
if (!available) return null
|
||||||
|
|
||||||
|
val versionLabel = latestVersionName
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { " $it" }
|
||||||
|
.orEmpty()
|
||||||
|
val message = when {
|
||||||
|
updateDownloaded -> "Aktualizacja$versionLabel jest pobrana i gotowa do instalacji."
|
||||||
|
required -> "Ta wersja aplikacji nie jest już obsługiwana. Zaktualizuj ją, aby kontynuować."
|
||||||
|
else -> "Dostępna jest nowa wersja aplikacji$versionLabel."
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppUpdateBannerUi(
|
||||||
|
message = message,
|
||||||
|
actionLabel = if (updateDownloaded) "Zainstaluj" else "Aktualizuj",
|
||||||
|
required = required,
|
||||||
|
downloaded = updateDownloaded,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasValidNavigationCoordinates(latitude: Double?, longitude: Double?): Boolean =
|
||||||
|
latitude != null &&
|
||||||
|
longitude != null &&
|
||||||
|
latitude.isFinite() &&
|
||||||
|
longitude.isFinite() &&
|
||||||
|
latitude in -90.0..90.0 &&
|
||||||
|
longitude in -180.0..180.0
|
||||||
|
|
||||||
|
fun navigationIntentCandidates(
|
||||||
|
latitude: Double?,
|
||||||
|
longitude: Double?,
|
||||||
|
label: String?,
|
||||||
|
): List<NavigationIntentCandidate> {
|
||||||
|
if (!hasValidNavigationCoordinates(latitude, longitude)) return emptyList()
|
||||||
|
|
||||||
|
val coordinates = "$latitude,$longitude"
|
||||||
|
val encodedLabel = URLEncoder
|
||||||
|
.encode(label?.takeIf { it.isNotBlank() } ?: "Cel", Charsets.UTF_8.name())
|
||||||
|
.replace("+", "%20")
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
NavigationIntentCandidate(
|
||||||
|
uri = "google.navigation:q=$coordinates",
|
||||||
|
packageName = googleMapsPackage,
|
||||||
|
),
|
||||||
|
NavigationIntentCandidate(uri = "geo:0,0?q=$coordinates($encodedLabel)"),
|
||||||
|
NavigationIntentCandidate(uri = "https://www.google.com/maps/dir/?api=1&destination=$coordinates"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
enum class RouteFlowStepState {
|
enum class RouteFlowStepState {
|
||||||
Todo,
|
Todo,
|
||||||
LocalComplete,
|
ServerSaved,
|
||||||
|
InProgress,
|
||||||
Syncing,
|
Syncing,
|
||||||
Confirmed,
|
Confirmed,
|
||||||
NeedsAttention,
|
NeedsAttention,
|
||||||
@@ -97,10 +223,10 @@ fun routeTodayInstruction(
|
|||||||
val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate
|
val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
route.status == "ZAKOŃCZONA" -> "Kurs zakończony."
|
route.driverLifecycleStatus() == "ZAKOŃCZONA" -> "Kurs zakończony."
|
||||||
loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek."
|
loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek."
|
||||||
today == loadingDate -> "Dziś: załadunek."
|
today == loadingDate -> "Dziś: załadunek."
|
||||||
today == unloadingDate && route.status == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj."
|
today == unloadingDate && route.driverLifecycleStatus() == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj."
|
||||||
today == unloadingDate -> "Dziś: rozładunek."
|
today == unloadingDate -> "Dziś: rozładunek."
|
||||||
else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}."
|
else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}."
|
||||||
}
|
}
|
||||||
@@ -113,7 +239,7 @@ fun canCompleteRouteFromDriverApp(
|
|||||||
): Boolean =
|
): Boolean =
|
||||||
runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
|
runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
|
||||||
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
|
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
|
||||||
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
|
&& route.driverLifecycleStatus() in setOf("ZAPLANOWANA", "W TRAKCIE")
|
||||||
|
|
||||||
fun canStartRouteFromDriverApp(
|
fun canStartRouteFromDriverApp(
|
||||||
route: DriverRouteDto,
|
route: DriverRouteDto,
|
||||||
@@ -121,14 +247,14 @@ fun canStartRouteFromDriverApp(
|
|||||||
today: LocalDate = LocalDate.now(),
|
today: LocalDate = LocalDate.now(),
|
||||||
): Boolean =
|
): Boolean =
|
||||||
routeIsActiveToday(route, selectedDate, today)
|
routeIsActiveToday(route, selectedDate, today)
|
||||||
&& route.status == "ZAPLANOWANA"
|
&& route.driverLifecycleStatus() == "ZAPLANOWANA"
|
||||||
|
|
||||||
fun canFinishRouteFromDriverApp(
|
fun canFinishRouteFromDriverApp(
|
||||||
route: DriverRouteDto,
|
route: DriverRouteDto,
|
||||||
selectedDate: String,
|
selectedDate: String,
|
||||||
today: LocalDate = LocalDate.now(),
|
today: LocalDate = LocalDate.now(),
|
||||||
): Boolean =
|
): Boolean =
|
||||||
route.status == "W TRAKCIE"
|
route.driverLifecycleStatus() == "W TRAKCIE"
|
||||||
&& runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
|
&& runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
|
||||||
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
|
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
|
||||||
|
|
||||||
@@ -150,6 +276,33 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean =
|
|||||||
fun routeStageRequirementIsRequired(requirement: String): Boolean =
|
fun routeStageRequirementIsRequired(requirement: String): Boolean =
|
||||||
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
|
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
|
||||||
|
|
||||||
|
fun normalizeRouteStageNotes(value: String): String = value.take(routeStageNotesMaxLength)
|
||||||
|
|
||||||
|
fun parseRouteWeightTons(weightText: String): Double? {
|
||||||
|
val enteredWeight = weightText.trim().replace(',', '.').toBigDecimalOrNull() ?: return null
|
||||||
|
val tons = if (enteredWeight.abs() >= routeWeightKilogramInputThreshold) {
|
||||||
|
enteredWeight.movePointLeft(3)
|
||||||
|
} else {
|
||||||
|
enteredWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
return tons.setScale(3, RoundingMode.HALF_UP).toDouble()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun routeWeightInputHelpText(weightText: String): String {
|
||||||
|
val enteredWeight = weightText.trim().replace(',', '.').toDoubleOrNull()
|
||||||
|
val tons = parseRouteWeightTons(weightText)
|
||||||
|
val enteredAsKilograms = enteredWeight != null &&
|
||||||
|
abs(enteredWeight) >= routeWeightKilogramInputThreshold.toDouble() &&
|
||||||
|
tons != null
|
||||||
|
|
||||||
|
return if (enteredAsKilograms) {
|
||||||
|
"Wpisano kilogramy — zapiszemy ${String.format(Locale("pl", "PL"), "%.3f", tons)} t."
|
||||||
|
} else {
|
||||||
|
"Wpisz tony (np. 23,4). Wartości od 1000 rozpoznajemy jako kilogramy (np. 23400)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun canSubmitRouteStageForm(
|
fun canSubmitRouteStageForm(
|
||||||
weightText: String,
|
weightText: String,
|
||||||
serverPhotoCount: Int,
|
serverPhotoCount: Int,
|
||||||
@@ -188,13 +341,14 @@ fun loadingWeightSubmitBlocker(
|
|||||||
photoRequirement: String = RouteStageRequirement.Required,
|
photoRequirement: String = RouteStageRequirement.Required,
|
||||||
): String? {
|
): String? {
|
||||||
val trimmed = weightText.trim()
|
val trimmed = weightText.trim()
|
||||||
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
|
val normalizedWeight = parseRouteWeightTons(trimmed)
|
||||||
val weightVisible = routeStageRequirementIsVisible(weightRequirement)
|
val weightVisible = routeStageRequirementIsVisible(weightRequirement)
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
|
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
|
||||||
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
|
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
|
||||||
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
|
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
|
||||||
|
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
|
||||||
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
|
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
@@ -208,13 +362,14 @@ fun routeStageSubmitBlocker(
|
|||||||
photoRequirement: String = RouteStageRequirement.Required,
|
photoRequirement: String = RouteStageRequirement.Required,
|
||||||
): String? {
|
): String? {
|
||||||
val trimmed = weightText.trim()
|
val trimmed = weightText.trim()
|
||||||
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
|
val normalizedWeight = parseRouteWeightTons(trimmed)
|
||||||
val weightVisible = routeStageRequirementIsVisible(weightRequirement)
|
val weightVisible = routeStageRequirementIsVisible(weightRequirement)
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
|
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
|
||||||
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
|
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
|
||||||
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
|
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
|
||||||
|
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
|
||||||
routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
|
routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
@@ -264,29 +419,30 @@ fun routeFlowSteps(
|
|||||||
val startOverride = routeActionStepState(actions, RouteActionType.Start)
|
val startOverride = routeActionStepState(actions, RouteActionType.Start)
|
||||||
val finishOverride = routeActionStepState(actions, RouteActionType.Finish)
|
val finishOverride = routeActionStepState(actions, RouteActionType.Finish)
|
||||||
val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size
|
val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size
|
||||||
|
val driverStatus = route.driverLifecycleStatus()
|
||||||
val loadingPhotosState = when {
|
val loadingPhotosState = when {
|
||||||
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
|
startOverride != null -> startOverride
|
||||||
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed
|
driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
|
||||||
route.loadingWeight != null -> RouteFlowStepState.LocalComplete
|
driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
|
||||||
loadingPhotoCount > 0 -> RouteFlowStepState.LocalComplete
|
loadingPhotoCount > 0 -> RouteFlowStepState.ServerSaved
|
||||||
else -> RouteFlowStepState.Todo
|
else -> RouteFlowStepState.Todo
|
||||||
}
|
}
|
||||||
val loadingWeightState = startOverride ?: when {
|
val loadingWeightState = startOverride ?: when {
|
||||||
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
|
driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
|
||||||
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed
|
driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
|
||||||
route.loadingWeight != null -> RouteFlowStepState.LocalComplete
|
route.loadingWeight != null -> RouteFlowStepState.ServerSaved
|
||||||
else -> RouteFlowStepState.Todo
|
else -> RouteFlowStepState.Todo
|
||||||
}
|
}
|
||||||
val transitState = when {
|
val transitState = when {
|
||||||
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
|
finishOverride != null -> RouteFlowStepState.InProgress
|
||||||
finishOverride == RouteFlowStepState.NeedsAttention -> RouteFlowStepState.LocalComplete
|
driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
|
||||||
finishOverride == RouteFlowStepState.Syncing -> RouteFlowStepState.LocalComplete
|
startOverride != null -> startOverride
|
||||||
route.status == "W TRAKCIE" -> RouteFlowStepState.LocalComplete
|
driverStatus == "W TRAKCIE" -> RouteFlowStepState.InProgress
|
||||||
else -> RouteFlowStepState.Todo
|
else -> RouteFlowStepState.Todo
|
||||||
}
|
}
|
||||||
val unloadingState = finishOverride ?: when {
|
val unloadingState = finishOverride ?: when {
|
||||||
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
|
driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
|
||||||
route.unloadingWeight != null -> RouteFlowStepState.LocalComplete
|
route.unloadingWeight != null -> RouteFlowStepState.ServerSaved
|
||||||
else -> RouteFlowStepState.Todo
|
else -> RouteFlowStepState.Todo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +503,8 @@ private fun routeActionStepState(actions: List<RouteActionEntity>, actionType: S
|
|||||||
private fun routeFlowStateLabel(state: RouteFlowStepState): String =
|
private fun routeFlowStateLabel(state: RouteFlowStepState): String =
|
||||||
when (state) {
|
when (state) {
|
||||||
RouteFlowStepState.Todo -> "do zrobienia"
|
RouteFlowStepState.Todo -> "do zrobienia"
|
||||||
RouteFlowStepState.LocalComplete -> "uzupełnione lokalnie"
|
RouteFlowStepState.ServerSaved -> "zapisane na serwerze"
|
||||||
|
RouteFlowStepState.InProgress -> "w trakcie"
|
||||||
RouteFlowStepState.Syncing -> "synchronizacja"
|
RouteFlowStepState.Syncing -> "synchronizacja"
|
||||||
RouteFlowStepState.Confirmed -> "potwierdzone"
|
RouteFlowStepState.Confirmed -> "potwierdzone"
|
||||||
RouteFlowStepState.NeedsAttention -> "wymaga uwagi"
|
RouteFlowStepState.NeedsAttention -> "wymaga uwagi"
|
||||||
@@ -441,6 +598,18 @@ fun routeFactColumns(screenWidthDp: Int): Int =
|
|||||||
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
|
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
|
||||||
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
|
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
|
||||||
|
|
||||||
|
fun photoUploadCanSatisfyStage(status: String): Boolean =
|
||||||
|
status in setOf(
|
||||||
|
PhotoUploadStatus.Pending.storageValue,
|
||||||
|
PhotoUploadStatus.Uploading.storageValue,
|
||||||
|
PhotoUploadStatus.Verifying.storageValue,
|
||||||
|
PhotoUploadStatus.Confirmed.storageValue,
|
||||||
|
PhotoUploadStatus.FailedRetryable.storageValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun routeStageUsableUploads(uploads: List<PhotoUploadEntity>): List<PhotoUploadEntity> =
|
||||||
|
uploads.filter { photoUploadCanSatisfyStage(it.status) }
|
||||||
|
|
||||||
fun queuedPhotoUploadCount(statuses: List<String>): Int =
|
fun queuedPhotoUploadCount(statuses: List<String>): Int =
|
||||||
statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" }
|
statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" }
|
||||||
|
|
||||||
@@ -452,8 +621,15 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo
|
|||||||
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
|
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
|
||||||
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
|
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
|
||||||
|
|
||||||
fun canRetryPhotoUpload(status: String): Boolean =
|
fun canRetryPhotoUpload(status: String, lastError: String? = null): Boolean =
|
||||||
status == "FAILED_RETRYABLE"
|
status == PhotoUploadStatus.FailedRetryable.storageValue ||
|
||||||
|
(status == PhotoUploadStatus.FailedPermanent.storageValue && isWorkerCancellationFailure(lastError))
|
||||||
|
|
||||||
|
fun canDiscardLocalPhotoUpload(status: String, serverPhotoId: String?): Boolean =
|
||||||
|
serverPhotoId.isNullOrBlank() && status in setOf(
|
||||||
|
PhotoUploadStatus.FailedRetryable.storageValue,
|
||||||
|
PhotoUploadStatus.FailedPermanent.storageValue,
|
||||||
|
)
|
||||||
|
|
||||||
fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean =
|
fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean =
|
||||||
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
|
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
|
||||||
@@ -481,6 +657,14 @@ fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun routeScreenAfterServerRefresh(currentScreen: DriverScreen, route: DriverRouteDto): DriverScreen =
|
||||||
|
when {
|
||||||
|
currentScreen in setOf(DriverScreen.StartRoute, DriverScreen.LoadingWeight) &&
|
||||||
|
route.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA") -> DriverScreen.Detail
|
||||||
|
currentScreen == DriverScreen.FinishRoute && route.driverLifecycleStatus() == "ZAKOŃCZONA" -> DriverScreen.Detail
|
||||||
|
else -> currentScreen
|
||||||
|
}
|
||||||
|
|
||||||
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
|
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
|
||||||
!requirePreciseLocation || hasFineLocation
|
!requirePreciseLocation || hasFineLocation
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package pl.firmatpp.kierowca.ui
|
package pl.firmatpp.kierowca.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
@@ -22,12 +25,15 @@ import pl.firmatpp.kierowca.data.DriverRepository
|
|||||||
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
|
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
|
||||||
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
|
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
|
||||||
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
|
||||||
|
import pl.firmatpp.kierowca.data.sync.StartupOfflineFallbackLoader
|
||||||
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
|
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
|
||||||
|
import pl.firmatpp.kierowca.data.model.AppUpdateDto
|
||||||
import pl.firmatpp.kierowca.data.model.DriverDto
|
import pl.firmatpp.kierowca.data.model.DriverDto
|
||||||
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
|
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
|
||||||
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
|
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
|
||||||
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||||
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
||||||
|
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
|
||||||
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
|
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
|
||||||
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
|
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
|
||||||
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
||||||
@@ -39,6 +45,7 @@ import pl.firmatpp.kierowca.data.upload.RouteActionEntity
|
|||||||
import pl.firmatpp.kierowca.data.upload.RouteActionOutbox
|
import pl.firmatpp.kierowca.data.upload.RouteActionOutbox
|
||||||
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
|
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
|
||||||
import pl.firmatpp.kierowca.data.upload.RouteActionType
|
import pl.firmatpp.kierowca.data.upload.RouteActionType
|
||||||
|
import pl.firmatpp.kierowca.data.upload.isWorkerCancellationFailure
|
||||||
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
||||||
import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry
|
import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry
|
||||||
import pl.firmatpp.kierowca.diagnostics.DiagnosticSection
|
import pl.firmatpp.kierowca.diagnostics.DiagnosticSection
|
||||||
@@ -61,8 +68,12 @@ data class DriverUiState(
|
|||||||
val screen: DriverScreen = DriverScreen.Initializing,
|
val screen: DriverScreen = DriverScreen.Initializing,
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val refreshing: Boolean = false,
|
val refreshing: Boolean = false,
|
||||||
|
val startupOfflineAvailable: Boolean = false,
|
||||||
val phone: String = "",
|
val phone: String = "",
|
||||||
val otpCode: String = "",
|
val otpCode: String = "",
|
||||||
|
val otpConsentRequestId: Long = 0L,
|
||||||
|
val otpRequestPending: Boolean = false,
|
||||||
|
val otpResendAvailableAtEpochMillis: Long = 0L,
|
||||||
val maskedPhone: String = "",
|
val maskedPhone: String = "",
|
||||||
val driver: DriverDto? = null,
|
val driver: DriverDto? = null,
|
||||||
val routes: List<DriverRouteDto> = emptyList(),
|
val routes: List<DriverRouteDto> = emptyList(),
|
||||||
@@ -73,6 +84,8 @@ data class DriverUiState(
|
|||||||
val allowGalleryUploads: Boolean = true,
|
val allowGalleryUploads: Boolean = true,
|
||||||
val allowRouteCompletion: Boolean = false,
|
val allowRouteCompletion: Boolean = false,
|
||||||
val requirePreciseLocationForPhotos: Boolean = false,
|
val requirePreciseLocationForPhotos: Boolean = false,
|
||||||
|
val routeLocationMode: String = RouteLocationMode.Optional,
|
||||||
|
val routeLocationEnabled: Boolean = false,
|
||||||
val loadingPhotoRequirement: String = RouteStageRequirement.Required,
|
val loadingPhotoRequirement: String = RouteStageRequirement.Required,
|
||||||
val loadingWeightRequirement: String = RouteStageRequirement.Required,
|
val loadingWeightRequirement: String = RouteStageRequirement.Required,
|
||||||
val unloadingPhotoRequirement: String = RouteStageRequirement.Required,
|
val unloadingPhotoRequirement: String = RouteStageRequirement.Required,
|
||||||
@@ -83,11 +96,13 @@ data class DriverUiState(
|
|||||||
val selectedRoute: DriverRouteDto? = null,
|
val selectedRoute: DriverRouteDto? = null,
|
||||||
val selectedPhoto: RoutePhotoDto? = null,
|
val selectedPhoto: RoutePhotoDto? = null,
|
||||||
val routeStageWeightText: String = "",
|
val routeStageWeightText: String = "",
|
||||||
|
val routeStageNotesText: String = "",
|
||||||
val routeActions: List<RouteActionEntity> = emptyList(),
|
val routeActions: List<RouteActionEntity> = emptyList(),
|
||||||
val visibleRouteActions: List<RouteActionEntity> = emptyList(),
|
val visibleRouteActions: List<RouteActionEntity> = emptyList(),
|
||||||
val photoUploads: List<PhotoUploadEntity> = emptyList(),
|
val photoUploads: List<PhotoUploadEntity> = emptyList(),
|
||||||
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
|
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
|
||||||
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
|
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
|
||||||
|
val appUpdate: AppUpdateDto? = null,
|
||||||
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
|
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
|
||||||
val leaveRequestsConfig: LeaveRequestsConfig? = null,
|
val leaveRequestsConfig: LeaveRequestsConfig? = null,
|
||||||
val leaveRequests: List<DriverLeaveRequestDto> = emptyList(),
|
val leaveRequests: List<DriverLeaveRequestDto> = emptyList(),
|
||||||
@@ -129,7 +144,7 @@ data class DriverUiState(
|
|||||||
val displayRoutes: List<DriverRouteDto>
|
val displayRoutes: List<DriverRouteDto>
|
||||||
get() = projectDriverRoutes(routes, projectionActions)
|
get() = projectDriverRoutes(routes, projectionActions)
|
||||||
.sortedWith(
|
.sortedWith(
|
||||||
compareBy<DriverRouteDto> { route -> if (route.status == "ZAKOŃCZONA") 1 else 0 }
|
compareBy<DriverRouteDto> { route -> if (route.driverLifecycleStatus() == "ZAKOŃCZONA") 1 else 0 }
|
||||||
.thenBy { route -> routeDayOrdering(route, selectedDate) },
|
.thenBy { route -> routeDayOrdering(route, selectedDate) },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -188,6 +203,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
private val appPreferencesStore = AppPreferencesStore(application)
|
private val appPreferencesStore = AppPreferencesStore(application)
|
||||||
private val repository = DriverRepository(application)
|
private val repository = DriverRepository(application)
|
||||||
private val syncRepository = DriverSyncRepository(application, repository)
|
private val syncRepository = DriverSyncRepository(application, repository)
|
||||||
|
private val startupOfflineFallbackLoader = StartupOfflineFallbackLoader()
|
||||||
private val networkMonitor = NetworkMonitor(application)
|
private val networkMonitor = NetworkMonitor(application)
|
||||||
private val photoUploadOutbox = PhotoUploadOutbox(application)
|
private val photoUploadOutbox = PhotoUploadOutbox(application)
|
||||||
private val routeActionOutbox = RouteActionOutbox(application)
|
private val routeActionOutbox = RouteActionOutbox(application)
|
||||||
@@ -196,6 +212,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
private val diagnosticSnapshotProvider = DiagnosticSnapshotProvider(application)
|
private val diagnosticSnapshotProvider = DiagnosticSnapshotProvider(application)
|
||||||
private val _state = MutableStateFlow(DriverUiState(loading = true))
|
private val _state = MutableStateFlow(DriverUiState(loading = true))
|
||||||
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
|
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
|
||||||
|
private val otpAttemptCoordinator = OtpAttemptCoordinator()
|
||||||
private val liveSyncClient = DriverLiveSyncClient(
|
private val liveSyncClient = DriverLiveSyncClient(
|
||||||
repository = repository,
|
repository = repository,
|
||||||
onConnected = { viewModelScope.launch { checkRemoteSyncState() } },
|
onConnected = { viewModelScope.launch { checkRemoteSyncState() } },
|
||||||
@@ -215,6 +232,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
private var leaveCalendarRequestGeneration: Long = 0
|
private var leaveCalendarRequestGeneration: Long = 0
|
||||||
private var routesRequestGeneration: Long = 0
|
private var routesRequestGeneration: Long = 0
|
||||||
private var routeDetailRequestGeneration: Long = 0
|
private var routeDetailRequestGeneration: Long = 0
|
||||||
|
private var routesLoadJob: Job? = null
|
||||||
val state: StateFlow<DriverUiState> = _state
|
val state: StateFlow<DriverUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -228,6 +246,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
_state.update { it.copy(routeProgressNotificationEnabled = enabled) }
|
_state.update { it.copy(routeProgressNotificationEnabled = enabled) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
appPreferencesStore.routeLocationEnabled.collect { enabled ->
|
||||||
|
_state.update { it.copy(routeLocationEnabled = enabled) }
|
||||||
|
reconcileTrackingServiceFromState()
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
photoUploadOutbox.observeQueuedUploads().collect { uploads ->
|
photoUploadOutbox.observeQueuedUploads().collect { uploads ->
|
||||||
_state.update { it.copy(queuedPhotoUploads = uploads) }
|
_state.update { it.copy(queuedPhotoUploads = uploads) }
|
||||||
@@ -295,6 +319,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (repository.hasToken()) {
|
if (repository.hasToken()) {
|
||||||
|
val offlineAvailable = syncRepository.hasBootstrapCache(_state.value.selectedDate)
|
||||||
|
_state.update { it.copy(startupOfflineAvailable = offlineAvailable) }
|
||||||
refreshRoutes()
|
refreshRoutes()
|
||||||
} else {
|
} else {
|
||||||
_state.update { it.copy(screen = DriverScreen.Phone, loading = false) }
|
_state.update { it.copy(screen = DriverScreen.Phone, loading = false) }
|
||||||
@@ -302,19 +328,64 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestOtp(phone: String) = runLoading("request_otp") {
|
fun requestOtp(phone: String) {
|
||||||
if (!_state.value.isOnline) throw IOException("Logowanie wymaga połączenia z internetem.")
|
val requestSnapshot = _state.value
|
||||||
val response = repository.requestOtp(phone)
|
val now = System.currentTimeMillis()
|
||||||
|
val resendDelay = if (requestSnapshot.phone == phone) {
|
||||||
|
otpResendDelaySeconds(requestSnapshot.otpResendAvailableAtEpochMillis, now)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
if (resendDelay > 0) {
|
||||||
|
_state.update { it.copy(error = "Nowy SMS możesz wysłać za $resendDelay s.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isResend = requestSnapshot.screen == DriverScreen.Otp && requestSnapshot.phone == phone
|
||||||
|
val retainedCode = requestSnapshot.otpCode.takeIf { isResend }.orEmpty()
|
||||||
|
val attemptId = otpAttemptCoordinator.beginAttempt()
|
||||||
otpAutoSubmitPolicy.reset()
|
otpAutoSubmitPolicy.reset()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
loading = true,
|
||||||
|
phone = phone,
|
||||||
|
otpCode = retainedCode,
|
||||||
|
otpConsentRequestId = attemptId,
|
||||||
|
otpRequestPending = true,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
kotlinx.coroutines.yield()
|
||||||
|
|
||||||
|
val result = runCatching {
|
||||||
|
if (!_state.value.isOnline) throw IOException("Logowanie wymaga połączenia z internetem.")
|
||||||
|
repository.requestOtp(phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!otpAttemptCoordinator.isCurrent(attemptId)) return@launch
|
||||||
|
|
||||||
|
result.onSuccess { response ->
|
||||||
|
val bufferedCode = otpAttemptCoordinator.takeBufferedCode(attemptId)
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
screen = DriverScreen.Otp,
|
screen = DriverScreen.Otp,
|
||||||
phone = phone,
|
loading = false,
|
||||||
otpCode = "",
|
otpCode = bufferedCode ?: retainedCode,
|
||||||
maskedPhone = response.phoneMasked.orEmpty(),
|
maskedPhone = response.phoneMasked.orEmpty(),
|
||||||
|
otpRequestPending = false,
|
||||||
|
otpResendAvailableAtEpochMillis = System.currentTimeMillis() + OTP_RESEND_COOLDOWN_MILLIS,
|
||||||
error = null,
|
error = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
bufferedCode?.let { applyOtpCode(it, retrievedFromSms = true) }
|
||||||
|
}.onFailure { throwable ->
|
||||||
|
otpAttemptCoordinator.discard(attemptId)
|
||||||
|
reportHandledException("request_otp", throwable)
|
||||||
|
_state.update { it.withApiError(throwable).copy(loading = false, otpRequestPending = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun verifyOtp(code: String) = runLoading("verify_otp") {
|
fun verifyOtp(code: String) = runLoading("verify_otp") {
|
||||||
@@ -326,11 +397,35 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateOtpCode(code: String) {
|
fun updateOtpCode(code: String) {
|
||||||
val sanitized = code.filter(Char::isDigit).take(6)
|
applyOtpCode(sanitizeOtpCode(code), retrievedFromSms = false)
|
||||||
_state.update { it.copy(otpCode = sanitized) }
|
}
|
||||||
|
|
||||||
if (otpAutoSubmitPolicy.shouldSubmit(sanitized, _state.value.loading)) {
|
fun updateOtpCodeFromSms(attemptId: Long, code: String) {
|
||||||
verifyOtp(sanitized)
|
val sanitized = sanitizeOtpCode(code)
|
||||||
|
if (!isRetrievedOtpCode(sanitized)) return
|
||||||
|
|
||||||
|
val snapshot = _state.value
|
||||||
|
if (attemptId != snapshot.otpConsentRequestId || !otpAttemptCoordinator.isCurrent(attemptId)) return
|
||||||
|
|
||||||
|
if (snapshot.otpRequestPending) {
|
||||||
|
otpAttemptCoordinator.bufferRetrievedCode(attemptId, sanitized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (snapshot.screen != DriverScreen.Otp) return
|
||||||
|
|
||||||
|
applyOtpCode(sanitized, retrievedFromSms = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyOtpCode(code: String, retrievedFromSms: Boolean) {
|
||||||
|
_state.update { it.copy(otpCode = code) }
|
||||||
|
|
||||||
|
val shouldSubmit = if (retrievedFromSms) {
|
||||||
|
otpAutoSubmitPolicy.shouldSubmitRetrieved(code, _state.value.loading)
|
||||||
|
} else {
|
||||||
|
otpAutoSubmitPolicy.shouldSubmit(code, _state.value.loading)
|
||||||
|
}
|
||||||
|
if (shouldSubmit) {
|
||||||
|
verifyOtp(code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +433,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
|
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
|
||||||
|
|
||||||
|
fun useOfflineStartupDataNow() {
|
||||||
|
val snapshot = _state.value
|
||||||
|
if (snapshot.screen != DriverScreen.Initializing || !snapshot.startupOfflineAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRoutes(
|
||||||
|
date = snapshot.selectedDate,
|
||||||
|
showLoading = true,
|
||||||
|
navigateToRoutes = true,
|
||||||
|
startupOfflineOnly = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun onAppForegrounded() {
|
fun onAppForegrounded() {
|
||||||
DriverRuntimeSyncState.foreground = true
|
DriverRuntimeSyncState.foreground = true
|
||||||
liveSyncClient.setForeground(true)
|
liveSyncClient.setForeground(true)
|
||||||
@@ -365,17 +474,31 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
|
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
|
private fun loadRoutes(
|
||||||
|
date: String?,
|
||||||
|
showLoading: Boolean,
|
||||||
|
navigateToRoutes: Boolean,
|
||||||
|
startupOfflineOnly: Boolean = false,
|
||||||
|
) {
|
||||||
val generation = ++routesRequestGeneration
|
val generation = ++routesRequestGeneration
|
||||||
viewModelScope.launch {
|
routesLoadJob?.cancel()
|
||||||
|
routesLoadJob = viewModelScope.launch {
|
||||||
|
val isStartup = _state.value.screen == DriverScreen.Initializing
|
||||||
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
|
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
val cached = if (_state.value.isOnline) {
|
val cached = when {
|
||||||
syncRepository.bootstrap(date)
|
startupOfflineOnly -> syncRepository.bootstrapFromCache(date)
|
||||||
} else {
|
|
||||||
syncRepository.bootstrapFromCache(date)
|
|
||||||
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
|
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
|
||||||
|
!_state.value.isOnline -> syncRepository.bootstrapFromCache(date)
|
||||||
|
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
|
||||||
|
isStartup -> {
|
||||||
|
startupOfflineFallbackLoader.load(
|
||||||
|
onlineLoad = { syncRepository.bootstrap(date) },
|
||||||
|
offlineLoad = { syncRepository.bootstrapFromCache(date) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> syncRepository.bootstrap(date)
|
||||||
}
|
}
|
||||||
val response = cached.value
|
val response = cached.value
|
||||||
val settings = response.driverAppSettings
|
val settings = response.driverAppSettings
|
||||||
@@ -386,6 +509,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
driver = response.session.driver,
|
driver = response.session.driver,
|
||||||
routes = response.routes.today,
|
routes = response.routes.today,
|
||||||
dispatchSheetReminder = response.dispatchSheetReminder,
|
dispatchSheetReminder = response.dispatchSheetReminder,
|
||||||
|
appUpdate = response.appUpdate ?: it.appUpdate,
|
||||||
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
|
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
|
||||||
minRouteDate = settings?.minDate ?: it.minRouteDate,
|
minRouteDate = settings?.minDate ?: it.minRouteDate,
|
||||||
maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
|
maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
|
||||||
@@ -394,6 +518,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
|
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
|
||||||
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
|
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
|
||||||
?: it.requirePreciseLocationForPhotos,
|
?: it.requirePreciseLocationForPhotos,
|
||||||
|
routeLocationMode = normalizeRouteLocationMode(settings?.routeLocationMode),
|
||||||
loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement),
|
loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement),
|
||||||
loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement),
|
loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement),
|
||||||
unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement),
|
unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement),
|
||||||
@@ -418,23 +543,31 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId)
|
if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId)
|
||||||
}
|
}
|
||||||
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
|
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
|
||||||
if (_state.value.isOnline && !cached.stale && settings?.selectedDate == settings?.today) {
|
val trackingSnapshot = _state.value
|
||||||
|
if (
|
||||||
|
!shouldTrackRouteLocation(
|
||||||
|
trackingSnapshot.routeLocationMode,
|
||||||