Compare commits

..
26 changed files with 938 additions and 75 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 115
versionName = "1.0.62"
versionCode = 119
versionName = "1.0.66"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+7
View File
@@ -2,12 +2,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
@@ -50,7 +50,10 @@ object ApiErrorMapper {
map(throwable).kind != ApiErrorKind.Network
fun mapHttpStatus(statusCode: Int, body: String?): ApiError {
val code = body?.let { """"code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) }
val code = body?.let {
""""code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1)
?: """"reason"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1)
}
val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) }
val retryable = body?.let { """"retryable"\s*:\s*(true|false)""".toRegex().find(it)?.groupValues?.getOrNull(1)?.toBooleanStrictOrNull() }
@@ -81,16 +84,21 @@ object ApiErrorMapper {
}
val defaultRetryable = statusCode == 429 || statusCode in 500..599
val resolvedRetryable = retryable ?: defaultRetryable
val resolvedMessage = message?.takeIf { it.isNotBlank() } ?: when (kind) {
val resolvedMessage = when {
code == "INVALID_OTP" -> "Kod jest nieprawidłowy lub wygasł. Użyj kodu z najnowszego SMS-a."
kind == ApiErrorKind.RateLimited -> "Za dużo prób. Odczekaj chwilę i spróbuj ponownie."
!message.isNullOrBlank() -> message
else -> when (kind) {
ApiErrorKind.Auth -> "Sesja wygasła. Zaloguj się ponownie."
ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji."
ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana."
ApiErrorKind.Conflict -> "Operacja jest w konflikcie z aktualnym stanem danych."
ApiErrorKind.RateLimited -> "Za dużo prób. Aplikacja spróbuje ponownie później."
ApiErrorKind.RateLimited -> "Za dużo prób. Odczekaj chwilę i spróbuj ponownie."
ApiErrorKind.Server -> "Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie."
ApiErrorKind.Network -> "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona."
ApiErrorKind.Unknown -> "Wystąpił błąd. Operacja nie została potwierdzona."
}
}
return ApiError(
kind = kind,
@@ -103,7 +111,11 @@ object ApiErrorMapper {
}
private fun mapHttpException(exception: HttpException): ApiError {
val body = runCatching { exception.response()?.errorBody()?.string() }.getOrNull()
val body = runCatching {
val source = exception.response()?.errorBody()?.source() ?: return@runCatching null
source.request(Long.MAX_VALUE)
source.buffer.clone().readUtf8()
}.getOrNull()
return mapHttpStatus(exception.code(), body)
}
}
@@ -1,11 +1,19 @@
package pl.firmatpp.kierowca.data.api
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor
import pl.firmatpp.kierowca.BuildConfig
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
internal const val DRIVER_APP_VERSION_HEADER = "X-Driver-App-Version"
internal fun Request.withDriverAppVersion(appVersion: String): Request =
newBuilder()
.header(DRIVER_APP_VERSION_HEADER, appVersion)
.build()
object ApiFactory {
fun mobileDriverApi(): MobileDriverApi {
val logging = HttpLoggingInterceptor().apply {
@@ -13,6 +21,9 @@ object ApiFactory {
}
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(chain.request().withDriverAppVersion(BuildConfig.VERSION_NAME))
}
.addInterceptor(logging)
.build()
@@ -278,7 +278,9 @@ data class DriverRouteDto(
val driverStatus: String? = null,
val operationalStatus: String? = null,
val loadingWeight: Double? = null,
val loadingNotes: String? = null,
val unloadingWeight: Double? = null,
val unloadingNotes: String? = null,
val trackingStatus: String? = null,
val arrivedAt: String? = null,
val distanceKm: Double?,
@@ -352,6 +354,7 @@ data class StartRouteBody(
val loadingWeight: Double?,
val occurredAt: String,
val photoClientRequestIds: List<String>,
val loadingNotes: String? = null,
)
data class FinishRouteBody(
@@ -359,6 +362,7 @@ data class FinishRouteBody(
val unloadingWeight: Double?,
val occurredAt: String,
val photoClientRequestIds: List<String>,
val unloadingNotes: String? = null,
)
data class RouteActionResponse(
@@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
DriverRouteCacheEntity::class,
DriverSyncStateEntity::class,
],
version = 6,
version = 7,
exportSchema = false,
)
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 =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
DriverDatabase::class.java,
"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 }
}
}
}
@@ -38,4 +38,5 @@ data class RouteActionEntity(
val lastError: String? = null,
val createdAtEpochMillis: Long = System.currentTimeMillis(),
val updatedAtEpochMillis: Long = System.currentTimeMillis(),
val notes: String? = null,
)
@@ -43,17 +43,28 @@ class RouteActionOutbox(
enqueueWorker(clientActionId)
}
suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds)
suspend fun enqueueStart(
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>): RouteActionEntity =
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds)
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(
action: String,
routeId: String,
weight: Double?,
photoClientRequestIds: List<String>,
notes: String?,
): RouteActionEntity {
dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
val entity = RouteActionEntity(
@@ -63,6 +74,7 @@ class RouteActionOutbox(
weight = weight,
occurredAt = Instant.now().toString(),
photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()),
notes = notes?.trim()?.takeIf { it.isNotEmpty() },
)
dao.upsert(entity)
@@ -116,6 +116,7 @@ class RouteActionWorker(
loadingWeight = action.weight,
occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds,
loadingNotes = action.notes,
),
)
RouteActionType.Finish -> repository.finishRoute(
@@ -125,6 +126,7 @@ class RouteActionWorker(
unloadingWeight = action.weight,
occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds,
unloadingNotes = action.notes,
),
)
else -> error("Nieznana akcja kursu: ${action.action}")
@@ -67,9 +67,11 @@ class RoutePointWorker(
"retryable" to error.retryable,
),
)
if (shouldStopRouteTrackingAfterPointError(error.kind, error.code)) {
ActiveRouteTrackingService.stop(applicationContext, routeId)
}
if (isInactiveRoutePointError(error.code)) {
dao.delete(pointIds)
ActiveRouteTrackingService.stop(applicationContext, routeId)
continue
}
@@ -105,3 +107,6 @@ class RoutePointWorker(
}
internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS"
internal fun shouldStopRouteTrackingAfterPointError(kind: ApiErrorKind, code: String?): Boolean =
kind == ApiErrorKind.Auth || isInactiveRoutePointError(code)
@@ -136,6 +136,13 @@ class ActiveRouteTrackingService : Service(), LocationListener {
override fun onDestroy() {
runCatching { locationManager.removeUpdates(this) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
getSystemService(NotificationManager::class.java).cancel(NOTIFICATION_ID)
getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.edit()
.remove(ACTIVE_ROUTE_ID)
@@ -247,8 +247,10 @@ fun DriverApp(
}
}
SmsUserConsentEffect(
enabled = state.screen == DriverScreen.Phone || state.screen == DriverScreen.Otp,
onCode = viewModel::updateOtpCode,
enabled = state.otpRequestPending ||
(state.screen == DriverScreen.Otp && !state.loading),
requestId = state.otpConsentRequestId,
onCode = viewModel::updateOtpCodeFromSms,
)
LaunchedEffect(initialRouteId, initialRouteTarget) {
viewModel.openRouteFromNotification(initialRouteId, initialRouteTarget)
@@ -282,7 +284,13 @@ fun DriverApp(
onUseOfflineNow = viewModel::useOfflineStartupDataNow,
)
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back)
DriverScreen.Otp -> OtpScreen(
state = state,
onCodeChange = viewModel::updateOtpCode,
onSubmit = viewModel::verifyOtp,
onResend = { viewModel.requestOtp(state.phone) },
onBack = viewModel::back,
)
DriverScreen.Routes -> RoutesScreen(
state = state,
onRefresh = viewModel::refreshRoutesSilently,
@@ -338,10 +346,12 @@ fun DriverApp(
weightLabel = "Tonaż załadunku",
submitLabel = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) "Dalej: tonaż" else "Potwierdź załadunek",
showWeightInput = false,
showNotesInput = !routeStageRequirementIsVisible(state.loadingWeightRequirement),
showPhotoActions = routeStageRequirementIsVisible(state.loadingPhotoRequirement),
showPhotoGrid = routeStageRequirementIsVisible(state.loadingPhotoRequirement),
onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight,
onNotesChange = viewModel::updateRouteStageNotes,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") },
onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deletePhotoUpload,
@@ -355,10 +365,12 @@ fun DriverApp(
weightLabel = "Tonaż załadunku",
submitLabel = "Potwierdź załadunek",
showWeightInput = routeStageRequirementIsVisible(state.loadingWeightRequirement),
showNotesInput = true,
showPhotoActions = false,
showPhotoGrid = false,
onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight,
onNotesChange = viewModel::updateRouteStageNotes,
onUpload = { _, _, _ -> },
onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deletePhotoUpload,
@@ -372,10 +384,12 @@ fun DriverApp(
weightLabel = "Tonaż rozładunku",
submitLabel = "Potwierdź rozładunek",
showWeightInput = routeStageRequirementIsVisible(state.unloadingWeightRequirement),
showNotesInput = true,
showPhotoActions = routeStageRequirementIsVisible(state.unloadingPhotoRequirement),
showPhotoGrid = routeStageRequirementIsVisible(state.unloadingPhotoRequirement),
onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight,
onNotesChange = viewModel::updateRouteStageNotes,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") },
onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deletePhotoUpload,
@@ -840,8 +854,19 @@ private fun OtpScreen(
state: DriverUiState,
onCodeChange: (String) -> Unit,
onSubmit: (String) -> Unit,
onResend: () -> Unit,
onBack: () -> Unit,
) {
var nowEpochMillis by remember(state.otpResendAvailableAtEpochMillis) { mutableStateOf(System.currentTimeMillis()) }
val resendDelaySeconds = otpResendDelaySeconds(state.otpResendAvailableAtEpochMillis, nowEpochMillis)
LaunchedEffect(state.otpResendAvailableAtEpochMillis) {
while (nowEpochMillis < state.otpResendAvailableAtEpochMillis) {
delay(1_000L)
nowEpochMillis = System.currentTimeMillis()
}
}
AuthShell(title = "Kod SMS", subtitle = "Wpisz kod wyslany na ${state.maskedPhone}.", onBack = onBack) {
OutlinedTextField(
value = state.otpCode,
@@ -853,7 +878,20 @@ private fun OtpScreen(
)
ErrorText(state.error)
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 18.dp else 8.dp))
PrimaryButton("Zaloguj") { onSubmit(state.otpCode) }
PrimaryButton(
label = "Zaloguj",
enabled = isRetrievedOtpCode(state.otpCode) && !state.loading,
) { onSubmit(state.otpCode) }
TextButton(
onClick = onResend,
enabled = resendDelaySeconds == 0 && !state.loading,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
if (resendDelaySeconds > 0) "Wyślij kod ponownie za $resendDelaySeconds s"
else "Wyślij kod ponownie",
)
}
}
}
@@ -2835,10 +2873,12 @@ private fun RouteStageScreen(
weightLabel: String,
submitLabel: String,
showWeightInput: Boolean = true,
showNotesInput: Boolean = true,
showPhotoActions: Boolean = true,
showPhotoGrid: Boolean = true,
onBack: () -> Unit,
onWeightChange: (String) -> Unit,
onNotesChange: (String) -> Unit,
onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
onPhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
@@ -2848,6 +2888,7 @@ private fun RouteStageScreen(
val context = LocalContext.current
var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
var voiceDictationActive by remember(stage) { mutableStateOf(false) }
val route = state.displaySelectedRoute
val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty()
val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
@@ -2876,6 +2917,11 @@ private fun RouteStageScreen(
photoRequirement = photoRequirement,
)
}
val effectiveSubmitBlocker = if (voiceDictationActive) {
"Zakończ dyktowanie przed zatwierdzeniem."
} else {
submitBlocker
}
val weightBlocker = submitBlocker?.takeIf { it.contains("wag", ignoreCase = true) || it.contains("popraw", ignoreCase = true) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
@@ -2915,7 +2961,7 @@ private fun RouteStageScreen(
bottomBar = {
RouteStageSubmitBar(
submitLabel = submitLabel,
submitBlocker = submitBlocker,
submitBlocker = effectiveSubmitBlocker,
onSubmit = onSubmit,
)
},
@@ -2972,6 +3018,18 @@ private fun RouteStageScreen(
)
}
}
if (showNotesInput) {
item {
VoiceNotesField(
value = state.routeStageNotesText,
onValueChange = onNotesChange,
stageLabel = if (stage == "loading") "załadunku" else "rozładunku",
enabled = !state.loading,
onListeningChanged = { voiceDictationActive = it },
modifier = Modifier.fillMaxWidth(),
)
}
}
if (showPhotoActions) {
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -3736,6 +3794,7 @@ private fun CargoDocumentationSection(
RouteStageDocumentationBlock(
title = "Załadunek",
weight = route.loadingWeight,
notes = route.loadingNotes,
photos = loadingPhotos,
uploads = loadingUploads,
deletingPhotoIds = deletingPhotoIds,
@@ -3746,6 +3805,7 @@ private fun CargoDocumentationSection(
RouteStageDocumentationBlock(
title = "Rozładunek",
weight = route.unloadingWeight,
notes = route.unloadingNotes,
photos = unloadingPhotos,
uploads = unloadingUploads,
deletingPhotoIds = deletingPhotoIds,
@@ -3757,6 +3817,7 @@ private fun CargoDocumentationSection(
RouteStageDocumentationBlock(
title = "Pozostałe",
weight = null,
notes = null,
photos = otherPhotos,
uploads = otherUploads,
deletingPhotoIds = deletingPhotoIds,
@@ -3772,6 +3833,7 @@ private fun CargoDocumentationSection(
private fun RouteStageDocumentationBlock(
title: String,
weight: Double?,
notes: String?,
photos: List<RoutePhotoDto>,
uploads: List<PhotoUploadEntity>,
deletingPhotoIds: Set<String>,
@@ -3805,6 +3867,38 @@ private fun RouteStageDocumentationBlock(
}
}
}
if (!notes.isNullOrBlank()) {
Column(
Modifier
.fillMaxWidth()
.background(TppTheme.colors.panel, RoundedCornerShape(4.dp))
.border(1.dp, TppTheme.colors.outline.copy(alpha = 0.55f), RoundedCornerShape(4.dp))
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
Text(
"UWAGA KIEROWCY",
color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelSmall,
)
Text(
notes,
color = TppTheme.colors.ink,
fontWeight = FontWeight.SemiBold,
)
if (weightPending) {
Text(
"uwaga czeka na wysłanie",
color = TppTheme.colors.containerGreen,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
if (count == 0) {
Box(
Modifier
@@ -4390,9 +4484,10 @@ private fun PhotoPreviewErrorOverlay() {
}
@Composable
private fun PrimaryButton(label: String, onClick: () -> Unit) {
private fun PrimaryButton(label: String, enabled: Boolean = true, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.small,
@@ -4544,28 +4639,48 @@ private fun bestLastKnownLocation(context: Context): Location? {
}
@Composable
private fun SmsUserConsentEffect(enabled: Boolean, onCode: (String) -> Unit) {
private fun SmsUserConsentEffect(enabled: Boolean, requestId: Long, onCode: (Long, String) -> Unit) {
val context = LocalContext.current
var listenGeneration by remember(requestId) { mutableStateOf(0) }
var launchedRequestId by remember { mutableStateOf<Long?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val completedRequestId = launchedRequestId
var codeDelivered = false
if (result.resultCode == Activity.RESULT_OK) {
val message = result.data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE).orEmpty()
OtpCodeExtractor.extract(message)?.let(onCode)
if (completedRequestId != null) {
OtpCodeExtractor.extract(message)?.let { code ->
codeDelivered = true
onCode(completedRequestId, code)
}
}
}
if (!codeDelivered && enabled && completedRequestId == requestId) {
listenGeneration += 1
}
}
DisposableEffect(enabled, context) {
if (!enabled) return@DisposableEffect onDispose { }
LaunchedEffect(enabled, requestId, listenGeneration) {
if (enabled && requestId > 0L) {
SmsRetriever.getClient(context).startSmsUserConsent(null)
}
}
DisposableEffect(enabled, context, requestId) {
if (!enabled) return@DisposableEffect onDispose { }
val receiver = object : BroadcastReceiver() {
override fun onReceive(receiverContext: Context?, intent: Intent?) {
if (intent?.action != SmsRetriever.SMS_RETRIEVED_ACTION) return
val status = intent.smsRetrieverStatus() ?: return
if (status.statusCode != CommonStatusCodes.SUCCESS) return
intent.smsConsentIntent()?.let(launcher::launch)
when (status.statusCode) {
CommonStatusCodes.SUCCESS -> intent.smsConsentIntent()?.let { consentIntent ->
launchedRequestId = requestId
launcher.launch(consentIntent)
}
CommonStatusCodes.TIMEOUT -> listenGeneration += 1
}
}
}
@@ -42,6 +42,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = action.weight,
loadingNotes = action.notes,
trackingStatus = "active",
photos = projectedRoute.safePhotos(),
)
@@ -52,6 +53,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = action.weight,
unloadingNotes = action.notes,
trackingStatus = "finished",
completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(),
@@ -25,6 +25,7 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType
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(
@@ -239,6 +240,8 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean =
fun routeStageRequirementIsRequired(requirement: String): Boolean =
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) {
@@ -68,6 +68,9 @@ data class DriverUiState(
val startupOfflineAvailable: Boolean = false,
val phone: String = "",
val otpCode: String = "",
val otpConsentRequestId: Long = 0L,
val otpRequestPending: Boolean = false,
val otpResendAvailableAtEpochMillis: Long = 0L,
val maskedPhone: String = "",
val driver: DriverDto? = null,
val routes: List<DriverRouteDto> = emptyList(),
@@ -88,6 +91,7 @@ data class DriverUiState(
val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null,
val routeStageWeightText: String = "",
val routeStageNotesText: String = "",
val routeActions: List<RouteActionEntity> = emptyList(),
val visibleRouteActions: List<RouteActionEntity> = emptyList(),
val photoUploads: List<PhotoUploadEntity> = emptyList(),
@@ -203,6 +207,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val diagnosticSnapshotProvider = DiagnosticSnapshotProvider(application)
private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
private val otpAttemptCoordinator = OtpAttemptCoordinator()
private val liveSyncClient = DriverLiveSyncClient(
repository = repository,
onConnected = { viewModelScope.launch { checkRemoteSyncState() } },
@@ -312,19 +317,64 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun requestOtp(phone: String) = runLoading("request_otp") {
if (!_state.value.isOnline) throw IOException("Logowanie wymaga połączenia z internetem.")
val response = repository.requestOtp(phone)
fun requestOtp(phone: String) {
val requestSnapshot = _state.value
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()
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 {
it.copy(
screen = DriverScreen.Otp,
phone = phone,
otpCode = "",
loading = false,
otpCode = bufferedCode ?: retainedCode,
maskedPhone = response.phoneMasked.orEmpty(),
otpRequestPending = false,
otpResendAvailableAtEpochMillis = System.currentTimeMillis() + OTP_RESEND_COOLDOWN_MILLIS,
error = null,
)
}
bufferedCode?.let { applyOtpCode(it, retrievedFromSms = true) }
}.onFailure { throwable ->
otpAttemptCoordinator.discard(attemptId)
reportHandledException("request_otp", throwable)
_state.update { it.withApiError(throwable).copy(loading = false, otpRequestPending = false) }
}
}
}
fun verifyOtp(code: String) = runLoading("verify_otp") {
@@ -336,11 +386,35 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun updateOtpCode(code: String) {
val sanitized = code.filter(Char::isDigit).take(6)
_state.update { it.copy(otpCode = sanitized) }
applyOtpCode(sanitizeOtpCode(code), retrievedFromSms = false)
}
if (otpAutoSubmitPolicy.shouldSubmit(sanitized, _state.value.loading)) {
verifyOtp(sanitized)
fun updateOtpCodeFromSms(attemptId: Long, code: String) {
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)
}
}
@@ -463,17 +537,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}.onFailure { throwable ->
if (generation != routesRequestGeneration) return@onFailure
reportHandledException("load_routes", throwable, mapOf("date" to date))
if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) {
handleExpiredSession()
} else {
_state.update {
val apiError = ApiErrorMapper.map(throwable)
it.withApiError(throwable).copy(
screen = when {
apiError.kind == ApiErrorKind.Auth -> DriverScreen.Phone
it.screen == DriverScreen.Initializing -> DriverScreen.Routes
else -> it.screen
},
screen = if (it.screen == DriverScreen.Initializing) DriverScreen.Routes else it.screen,
)
}
}
}
if (generation == routesRequestGeneration) _state.update {
it.copy(loading = false, refreshing = false)
@@ -503,6 +576,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty()
else -> it.routeStageWeightText
},
routeStageNotesText = when (targetScreen) {
DriverScreen.LoadingWeight -> response.route.loadingNotes.orEmpty()
DriverScreen.FinishRoute -> response.route.unloadingNotes.orEmpty()
else -> it.routeStageNotesText
},
feedback = null,
isStale = cached.stale || !it.isOnline,
lastServerSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastServerSyncAtEpochMillis,
@@ -548,7 +626,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
.onFailure { throwable ->
if (generation == routeDetailRequestGeneration) _state.update { it.withApiError(throwable) }
if (generation != routeDetailRequestGeneration) return@onFailure
if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) {
handleExpiredSession()
} else {
_state.update { it.withApiError(throwable) }
}
}
if (generation == routeDetailRequestGeneration) _state.update { it.copy(refreshing = false) }
@@ -1036,6 +1119,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy(
screen = targetScreen,
routeStageWeightText = "",
routeStageNotesText = route.loadingNotes.orEmpty(),
feedback = null,
error = null,
)
@@ -1080,6 +1164,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy(
screen = DriverScreen.FinishRoute,
routeStageWeightText = route.unloadingWeight?.toString().orEmpty(),
routeStageNotesText = route.unloadingNotes.orEmpty(),
feedback = null,
error = null,
)
@@ -1091,6 +1176,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeStageWeightText = normalized) }
}
fun updateRouteStageNotes(value: String) {
_state.update { it.copy(routeStageNotesText = normalizeRouteStageNotes(value)) }
}
fun correctFailedRouteAction() {
val snapshot = _state.value
val action = (snapshot.visibleRouteActions + snapshot.routeActions)
@@ -1156,6 +1245,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} else {
null
}
val notes = normalizeRouteStageNotes(snapshot.routeStageNotesText).trim().takeIf { it.isNotBlank() }
val stagePhotos = routePhotosForStage(route.photos, stage)
val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage },
@@ -1202,21 +1292,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
runCatching {
val updatedRoute = when (stage) {
"loading" -> {
routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds)
routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds, notes)
route.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = weight,
loadingNotes = notes,
trackingStatus = "active",
photos = route.safePhotos(),
)
}
"unloading" -> {
routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds)
routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds, notes)
route.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = weight,
unloadingNotes = notes,
trackingStatus = "finished",
completedAt = Instant.now().toString(),
photos = route.safePhotos(),
@@ -1231,6 +1323,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
selectedRoute = updatedRoute,
routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item },
routeStageWeightText = "",
routeStageNotesText = "",
feedback = if (stage == "loading") {
if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu."
} else {
@@ -1409,9 +1502,19 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.StartRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null)
DriverScreen.StartRoute -> it.copy(
screen = DriverScreen.Detail,
routeStageWeightText = "",
routeStageNotesText = "",
feedback = null,
)
DriverScreen.LoadingWeight -> it.copy(screen = DriverScreen.StartRoute, feedback = null)
DriverScreen.FinishRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null)
DriverScreen.FinishRoute -> it.copy(
screen = DriverScreen.Detail,
routeStageWeightText = "",
routeStageNotesText = "",
feedback = null,
)
DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null)
DriverScreen.LeaveCalendar -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
@@ -1455,6 +1558,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
dispatchSheetUploadsJob?.cancel()
realtimeBannerJob?.cancel()
liveSyncClient.stop()
ActiveRouteTrackingService.stop(getApplication())
offlineOutboxManager.clearAll()
repository.logout(notifyServer = _state.value.isOnline)
syncRepository.clearCache()
@@ -1665,6 +1769,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private suspend fun handleExpiredSession() {
liveSyncClient.stop()
ActiveRouteTrackingService.stop(getApplication())
offlineOutboxManager.clearAll()
repository.logout(notifyServer = false)
syncRepository.clearCache()
@@ -0,0 +1,337 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.StopCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import pl.firmatpp.kierowca.ui.theme.TppTheme
internal data class VoiceDictationUiState(
val isListening: Boolean = false,
val message: String = "Możesz wpisać uwagę albo podyktować ją po polsku.",
val isError: Boolean = false,
)
internal fun mergeDictationText(existingText: String, recognizedText: String): String {
val existing = existingText.trimEnd()
val recognized = recognizedText.trim()
if (recognized.isBlank()) return normalizeRouteStageNotes(existingText)
return normalizeRouteStageNotes(
if (existing.isBlank()) recognized else "$existing $recognized",
)
}
@Composable
internal fun VoiceNotesField(
value: String,
onValueChange: (String) -> Unit,
stageLabel: String,
enabled: Boolean,
onListeningChanged: (Boolean) -> Unit = {},
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var dictationState by remember { mutableStateOf(VoiceDictationUiState()) }
val currentValue by rememberUpdatedState(value)
val currentOnValueChange by rememberUpdatedState(onValueChange)
val controller = remember(context) { AndroidLiveVoiceDictation(context.applicationContext) }
val startDictation = {
controller.start(
existingText = currentValue,
onText = currentOnValueChange,
onState = { dictationState = it },
)
}
val microphonePermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
startDictation()
} else {
dictationState = VoiceDictationUiState(
message = "Brak dostępu do mikrofonu. Możesz nadal wpisać uwagę ręcznie.",
isError = true,
)
}
}
LaunchedEffect(dictationState.isListening) {
onListeningChanged(dictationState.isListening)
}
DisposableEffect(controller) {
onDispose {
onListeningChanged(false)
controller.destroy()
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedTextField(
value = value,
onValueChange = { currentOnValueChange(normalizeRouteStageNotes(it)) },
label = { Text("Uwagi do $stageLabel (opcjonalnie)") },
placeholder = { Text("Np. oczekiwanie, uszkodzenie, problem z dokumentami…") },
supportingText = {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Zapisujemy tylko tekst uwagi.")
Text("${value.length}/$routeStageNotesMaxLength")
}
},
readOnly = dictationState.isListening,
enabled = enabled,
minLines = 3,
maxLines = 6,
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = {
if (dictationState.isListening) {
controller.stop()
} else if (
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
) {
startDictation()
} else {
microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
enabled = enabled,
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp),
) {
Icon(
imageVector = if (dictationState.isListening) Icons.Outlined.StopCircle else Icons.Outlined.Mic,
contentDescription = null,
)
Text(
text = if (dictationState.isListening) "Zakończ dyktowanie" else "Dyktuj uwagę",
modifier = Modifier.padding(start = 10.dp),
fontWeight = FontWeight.Bold,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (dictationState.isListening) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = TppTheme.colors.forest,
)
}
Text(
text = dictationState.message,
color = if (dictationState.isError) Color(0xFFB42318) else TppTheme.colors.muted,
)
}
}
}
private class AndroidLiveVoiceDictation(
private val context: Context,
) : RecognitionListener {
private var recognizer: SpeechRecognizer? = null
private var prefix: String = ""
private var onText: ((String) -> Unit)? = null
private var onState: ((VoiceDictationUiState) -> Unit)? = null
private var listening = false
fun start(
existingText: String,
onText: (String) -> Unit,
onState: (VoiceDictationUiState) -> Unit,
) {
if (! SpeechRecognizer.isRecognitionAvailable(context)) {
onState(
VoiceDictationUiState(
message = "Na tym telefonie nie ma usługi rozpoznawania mowy. Wpisz uwagę ręcznie.",
isError = true,
),
)
return
}
this.prefix = existingText
this.onText = onText
this.onState = onState
if (recognizer == null) {
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also {
it.setRecognitionListener(this)
}
}
listening = true
emitState("Uruchamiam mikrofon…")
runCatching {
recognizer?.startListening(recognitionIntent())
}.onFailure {
listening = false
emitState("Nie udało się uruchomić dyktowania. Spróbuj ponownie.", isError = true)
}
}
fun stop() {
if (! listening) return
emitState("Kończę i sprawdzam rozpoznany tekst…")
recognizer?.stopListening()
}
fun destroy() {
listening = false
onText = null
onState = null
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
override fun onReadyForSpeech(params: Bundle?) {
emitState("Słucham… Mów normalnie. Rozpoznany tekst pojawia się wyżej na żywo.")
}
override fun onBeginningOfSpeech() {
emitState("Rozpoznaję mowę na żywo…")
}
override fun onRmsChanged(rmsdB: Float) = Unit
override fun onBufferReceived(buffer: ByteArray?) = Unit
override fun onEndOfSpeech() {
emitState("Sprawdzam rozpoznany tekst…")
}
override fun onError(error: Int) {
listening = false
emitState(speechRecognitionErrorMessage(error), isError = true)
}
override fun onResults(results: Bundle?) {
val recognized = bestRecognitionResult(results)
if (recognized.isNotBlank()) {
onText?.invoke(mergeDictationText(prefix, recognized))
}
listening = false
emitState(
if (recognized.isBlank()) {
"Nie wykryto tekstu. Naciśnij „Dyktuj uwagę” i spróbuj ponownie."
} else {
"Rozpoznano: „${recognized.take(160)}”. Możesz teraz poprawić tekst."
},
isError = recognized.isBlank(),
)
}
override fun onPartialResults(partialResults: Bundle?) {
val recognized = bestRecognitionResult(partialResults)
if (recognized.isBlank()) return
onText?.invoke(mergeDictationText(prefix, recognized))
emitState("Na żywo: „${recognized.take(160)}")
}
override fun onEvent(eventType: Int, params: Bundle?) = Unit
private fun recognitionIntent(): Intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "pl-PL")
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, false)
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
putExtra(
RecognizerIntent.EXTRA_ENABLE_FORMATTING,
RecognizerIntent.FORMATTING_OPTIMIZE_LATENCY,
)
putExtra(RecognizerIntent.EXTRA_HIDE_PARTIAL_TRAILING_PUNCTUATION, true)
}
}
private fun bestRecognitionResult(bundle: Bundle?): String =
bundle
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
?.firstOrNull()
.orEmpty()
.trim()
private fun emitState(message: String, isError: Boolean = false) {
onState?.invoke(
VoiceDictationUiState(
isListening = listening,
message = message,
isError = isError,
),
)
}
}
internal fun speechRecognitionErrorMessage(error: Int): String = when (error) {
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ->
"Brak dostępu do mikrofonu. Możesz wpisać uwagę ręcznie."
SpeechRecognizer.ERROR_SPEECH_TIMEOUT ->
"Nie usłyszałem mowy. Naciśnij „Dyktuj uwagę” i spróbuj ponownie."
SpeechRecognizer.ERROR_NO_MATCH ->
"Nie rozpoznano słów. Mów wyraźnie i spróbuj ponownie."
SpeechRecognizer.ERROR_NETWORK,
SpeechRecognizer.ERROR_NETWORK_TIMEOUT ->
"Rozpoznawanie mowy nie ma teraz połączenia. Spróbuj ponownie lub wpisz uwagę."
SpeechRecognizer.ERROR_RECOGNIZER_BUSY ->
"Mikrofon jest teraz zajęty. Odczekaj chwilę i spróbuj ponownie."
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED,
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE ->
"Polskie rozpoznawanie mowy nie jest dostępne na tym telefonie."
else ->
"Nie udało się rozpoznać mowy. Spróbuj ponownie lub wpisz uwagę ręcznie."
}
@@ -0,0 +1,45 @@
package pl.firmatpp.kierowca.ui
class OtpAttemptCoordinator {
private var currentAttemptId: Long = 0L
private var bufferedCode: String? = null
fun beginAttempt(): Long {
currentAttemptId += 1L
bufferedCode = null
return currentAttemptId
}
fun isCurrent(attemptId: Long): Boolean = attemptId == currentAttemptId
fun bufferRetrievedCode(attemptId: Long, code: String): Boolean {
if (!isCurrent(attemptId) || !isRetrievedOtpCode(code)) return false
bufferedCode = code
return true
}
fun takeBufferedCode(attemptId: Long): String? {
if (!isCurrent(attemptId)) return null
return bufferedCode.also { bufferedCode = null }
}
fun discard(attemptId: Long) {
if (isCurrent(attemptId)) bufferedCode = null
}
}
internal fun sanitizeOtpCode(code: String): String =
code.filter(Char::isDigit).take(OTP_MAX_LENGTH)
internal fun isRetrievedOtpCode(code: String): Boolean =
code.length in OTP_MIN_LENGTH..OTP_MAX_LENGTH && code.all(Char::isDigit)
internal fun otpResendDelaySeconds(availableAtEpochMillis: Long, nowEpochMillis: Long): Int {
val remainingMillis = (availableAtEpochMillis - nowEpochMillis).coerceAtLeast(0L)
return ((remainingMillis + 999L) / 1_000L).toInt()
}
internal const val OTP_MIN_LENGTH = 4
internal const val OTP_DEFAULT_LENGTH = 6
internal const val OTP_MAX_LENGTH = 10
internal const val OTP_RESEND_COOLDOWN_MILLIS = 30_000L
@@ -4,14 +4,20 @@ class OtpAutoSubmitPolicy {
private var lastSubmittedCode: String? = null
fun shouldSubmit(code: String, loading: Boolean): Boolean {
if (code.length < OTP_LENGTH) {
return shouldSubmitCompleteCode(code, loading, code.length == OTP_DEFAULT_LENGTH)
}
fun shouldSubmitRetrieved(code: String, loading: Boolean): Boolean {
return shouldSubmitCompleteCode(code, loading, isRetrievedOtpCode(code))
}
private fun shouldSubmitCompleteCode(code: String, loading: Boolean, complete: Boolean): Boolean {
if (!complete) {
lastSubmittedCode = null
return false
}
if (loading || code.length != OTP_LENGTH || code == lastSubmittedCode) {
return false
}
if (loading || code == lastSubmittedCode) return false
lastSubmittedCode = code
return true
@@ -20,8 +26,4 @@ class OtpAutoSubmitPolicy {
fun reset() {
lastSubmittedCode = null
}
private companion object {
const val OTP_LENGTH = 6
}
}
@@ -3,12 +3,16 @@ package pl.firmatpp.kierowca.data
import java.io.IOException
import java.net.UnknownHostException
import kotlin.coroutines.cancellation.CancellationException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
class ApiErrorMapperTest {
@Test
@@ -84,4 +88,30 @@ class ApiErrorMapperTest {
assertEquals("Storage timeout", error.message)
assertEquals(body, error.responseBody)
}
@Test
fun mapsInvalidOtpReasonToActionableMessage() {
val error = ApiErrorMapper.mapHttpStatus(422, """{"ok":false,"reason":"INVALID_OTP"}""")
assertEquals("INVALID_OTP", error.code)
assertEquals("Kod jest nieprawidłowy lub wygasł. Użyj kodu z najnowszego SMS-a.", error.message)
}
@Test
fun translatesRateLimitResponses() {
val error = ApiErrorMapper.mapHttpStatus(429, """{"message":"Too Many Attempts."}""")
assertEquals(ApiErrorKind.RateLimited, error.kind)
assertEquals("Za dużo prób. Odczekaj chwilę i spróbuj ponownie.", error.message)
}
@Test
fun preservesApiProblemWhenSameExceptionIsMappedMoreThanOnce() {
val body = """{"ok":false,"reason":"INVALID_OTP"}"""
.toResponseBody("application/json".toMediaType())
val exception = HttpException(Response.error<Any>(422, body))
assertEquals("INVALID_OTP", ApiErrorMapper.map(exception).code)
assertEquals("INVALID_OTP", ApiErrorMapper.map(exception).code)
}
}
@@ -0,0 +1,19 @@
package pl.firmatpp.kierowca.data.api
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Test
class ApiFactoryTest {
@Test
fun `request contains current driver app version header`() {
val request = Request.Builder()
.url("https://api.example.test/mobile/driver/bootstrap")
.header(DRIVER_APP_VERSION_HEADER, "old-version")
.build()
val updated = request.withDriverAppVersion("1.0.65")
assertEquals("1.0.65", updated.header(DRIVER_APP_VERSION_HEADER))
}
}
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.ApiErrorKind
class RoutePointWorkerRulesTest {
@Test
@@ -15,4 +16,14 @@ class RoutePointWorkerRulesTest {
assertFalse(isInactiveRoutePointError("SERVER_UNAVAILABLE"))
assertFalse(isInactiveRoutePointError(null))
}
@Test
fun revokedSessionStopsRouteTracking() {
assertTrue(shouldStopRouteTrackingAfterPointError(ApiErrorKind.Auth, null))
}
@Test
fun temporaryFailureKeepsRouteTrackingActive() {
assertFalse(shouldStopRouteTrackingAfterPointError(ApiErrorKind.Server, "SERVER_UNAVAILABLE"))
}
}
@@ -17,11 +17,19 @@ class DriverRouteProjectionTest {
fun pendingStartProjectsPlannedRouteAsActiveWithLoadingWeight() {
val projection = projectDriverRoute(
route = route(status = "ZAPLANOWANA"),
actions = listOf(action(RouteActionType.Start, RouteActionStatus.Pending, weight = 12.5)),
actions = listOf(
action(
RouteActionType.Start,
RouteActionStatus.Pending,
weight = 12.5,
notes = "Oczekiwanie na dokument WZ.",
),
),
)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight)
assertEquals("Oczekiwanie na dokument WZ.", projection.route.loadingNotes)
assertEquals("active", projection.route.trackingStatus)
assertTrue(projection.loadingWeightPending)
assertFalse(projection.unloadingWeightPending)
@@ -32,11 +40,20 @@ class DriverRouteProjectionTest {
fun pendingFinishProjectsActiveRouteAsFinishedWithUnloadingWeight() {
val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.Syncing, weight = 11.8, occurredAt = "2026-06-30T09:10:00Z")),
actions = listOf(
action(
RouteActionType.Finish,
RouteActionStatus.Syncing,
weight = 11.8,
notes = "Uszkodzona plomba.",
occurredAt = "2026-06-30T09:10:00Z",
),
),
)
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes)
assertEquals("finished", projection.route.trackingStatus)
assertEquals("2026-06-30T09:10:00Z", projection.route.completedAt)
assertTrue(projection.unloadingWeightPending)
@@ -174,6 +191,7 @@ class DriverRouteProjectionTest {
type: String,
status: String,
weight: Double,
notes: String? = null,
occurredAt: String = "2026-06-30T07:40:00Z",
createdAt: Long = 1000,
): RouteActionEntity =
@@ -186,6 +204,7 @@ class DriverRouteProjectionTest {
photoClientRequestIdsJson = "[]",
status = status,
createdAtEpochMillis = createdAt,
notes = notes,
)
private companion object {
@@ -0,0 +1,30 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class LiveVoiceDictationTest {
@Test
fun recognizedTextIsAppendedToExistingTypedNote() {
assertEquals(
"Oczekiwanie na dokumenty kierowca odebrał WZ",
mergeDictationText("Oczekiwanie na dokumenty ", " kierowca odebrał WZ "),
)
}
@Test
fun recognizedTextCanStartAnEmptyNote() {
assertEquals(
"Uszkodzona plomba",
mergeDictationText("", " Uszkodzona plomba "),
)
}
@Test
fun routeNotesAreLimitedToApiMaximum() {
assertEquals(
routeStageNotesMaxLength,
normalizeRouteStageNotes("a".repeat(routeStageNotesMaxLength + 20)).length,
)
}
}
@@ -0,0 +1,48 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class OtpAttemptCoordinatorTest {
@Test
fun buffersSmsReceivedBeforeOtpRequestCompletes() {
val coordinator = OtpAttemptCoordinator()
val attemptId = coordinator.beginAttempt()
assertTrue(coordinator.bufferRetrievedCode(attemptId, "123456"))
assertEquals("123456", coordinator.takeBufferedCode(attemptId))
assertNull(coordinator.takeBufferedCode(attemptId))
}
@Test
fun ignoresConsentResultFromPreviousRequest() {
val coordinator = OtpAttemptCoordinator()
val previousAttempt = coordinator.beginAttempt()
val currentAttempt = coordinator.beginAttempt()
assertFalse(coordinator.bufferRetrievedCode(previousAttempt, "111111"))
assertTrue(coordinator.bufferRetrievedCode(currentAttempt, "222222"))
assertEquals("222222", coordinator.takeBufferedCode(currentAttempt))
}
@Test
fun acceptsOnlyOtpLengthsSupportedByApi() {
val coordinator = OtpAttemptCoordinator()
val attemptId = coordinator.beginAttempt()
assertFalse(coordinator.bufferRetrievedCode(attemptId, "123"))
assertTrue(coordinator.bufferRetrievedCode(attemptId, "1234"))
assertTrue(coordinator.bufferRetrievedCode(attemptId, "1234567890"))
assertFalse(coordinator.bufferRetrievedCode(attemptId, "12345678901"))
}
@Test
fun roundsResendCooldownUpToFullSeconds() {
assertEquals(30, otpResendDelaySeconds(40_000L, 10_000L))
assertEquals(1, otpResendDelaySeconds(10_001L, 10_000L))
assertEquals(0, otpResendDelaySeconds(10_000L, 10_001L))
}
}
@@ -38,4 +38,13 @@ class OtpAutoSubmitPolicyTest {
assertFalse(policy.shouldSubmit("123456", loading = true))
assertFalse(policy.shouldSubmit("1234567", loading = false))
}
@Test
fun submitsCompleteRetrievedCodesAcceptedByApi() {
val policy = OtpAutoSubmitPolicy()
assertTrue(policy.shouldSubmitRetrieved("1234", loading = false))
assertTrue(policy.shouldSubmitRetrieved("1234567890", loading = false))
assertFalse(policy.shouldSubmitRetrieved("123", loading = false))
}
}
+16 -2
View File
@@ -138,6 +138,14 @@ if [[ "$sanitized_remote" =~ ^(https?)://([^/]+)/(.+)$ ]]; then
detected_protocol="${BASH_REMATCH[1]}"
detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[3]%.git}"
elif [[ "$remote_url" =~ ^ssh://([^/@]+@)?([^/:]+)(:[0-9]+)?/(.+)$ ]]; then
detected_protocol="https"
detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[4]%.git}"
elif [[ "$remote_url" =~ ^([^/@:]+@)?([^/:]+):(.+)$ ]]; then
detected_protocol="https"
detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[3]%.git}"
else
detected_protocol="https"
detected_host=""
@@ -147,14 +155,20 @@ fi
gitea_base_url="${GITEA_BASE_URL:-${detected_protocol}://${detected_host}}"
gitea_repository="${GITEA_REPOSITORY:-$detected_repository}"
gitea_base_url="${gitea_base_url%/}"
[[ "$gitea_base_url" =~ ^https?://[^/]+$ ]] || { echo "Ustaw poprawne GITEA_BASE_URL." >&2; exit 1; }
if [[ "$gitea_base_url" =~ ^(https?)://([^/]+)$ ]]; then
gitea_api_protocol="${BASH_REMATCH[1]}"
gitea_api_host="${BASH_REMATCH[2]}"
else
echo "Ustaw poprawne GITEA_BASE_URL." >&2
exit 1
fi
[[ "$gitea_repository" =~ ^[^/]+/[^/]+$ ]] || { echo "Ustaw poprawne GITEA_REPOSITORY=owner/repository." >&2; exit 1; }
auth_args=()
if [[ -n "${GITEA_TOKEN:-}" ]]; then
auth_args=(-H "Authorization: token $GITEA_TOKEN")
else
credential="$(printf 'protocol=%s\nhost=%s\n\n' "$detected_protocol" "$detected_host" | git credential fill)"
credential="$(printf 'protocol=%s\nhost=%s\n\n' "$gitea_api_protocol" "$gitea_api_host" | git credential fill)"
gitea_username="$(printf '%s\n' "$credential" | sed -n 's/^username=//p')"
gitea_password="$(printf '%s\n' "$credential" | sed -n 's/^password=//p')"
[[ -n "$gitea_username" && -n "$gitea_password" ]] || {