Compare commits

..
Author SHA1 Message Date
admin ffbae9edfd Wydaj aplikację kierowcy 1.0.62 (115) 2026-07-18 02:57:39 +02:00
admin 2268c64250 Zautomatyzuj publikację aplikacji w Google Play 2026-07-18 02:57:03 +02:00
admin c954fd9bb8 chore: release android driver 1.0.61 (114) 2026-07-18 02:34:10 +02:00
admin b9757348f2 Dodaj aktualizacje aplikacji kierowcy przez Google Play 2026-07-18 02:32:31 +02:00
admin 130f8bbecc chore: release android driver 1.0.60 (113) 2026-07-17 14:10:03 +02:00
admin 4c00d46a14 Napraw otwieranie nawigacji do geofence 2026-07-17 14:05:11 +02:00
admin 07fc4021e1 chore: release android driver 1.0.59 (112) 2026-07-17 13:33:46 +02:00
admin 431436d0db Napraw synchronizację kursu w aplikacji kierowcy 2026-07-17 13:28:49 +02:00
admin 905711d8de chore: release android driver 1.0.58 (111) 2026-07-16 11:06:24 +02:00
admin 7c9cb3fb0d Dodaj timeout i ręczny tryb offline przy uruchamianiu 2026-07-16 11:04:36 +02:00
admin ae0d1b2d3d chore: release android driver 1.0.57 (110) 2026-07-16 10:31:11 +02:00
admin c5b3c5dae9 Przeliczaj wagi kierowcy z kilogramów na tony 2026-07-16 10:28:30 +02:00
admin 3fa3b4eac1 chore: release android driver 1.0.56 (109) 2026-07-12 22:34:20 +02:00
admin 197bcc6f5e Przypomnij o zgodzie na powiadomienia Android 2026-07-12 22:33:52 +02:00
admin f4f5cec2be chore: release android driver 1.0.55 (108) 2026-07-12 22:04:46 +02:00
admin 5a630f8401 Użyj origin realtime z bootstrapu 2026-07-12 21:59:46 +02:00
admin 54a3c58a22 chore: release android driver 1.0.54 (107) 2026-07-12 21:57:39 +02:00
admin 1c6a044c37 Dodaj Origin dla WebSocket Reverb 2026-07-12 21:55:01 +02:00
37 changed files with 1928 additions and 120 deletions
+2
View File
@@ -9,3 +9,5 @@ keystore.properties
*.jks
release-artifacts/
app/src/main/res_bak/
scripts/*.json
scripts/*.json:Zone.Identifier
+45 -3
View File
@@ -31,9 +31,51 @@ Przed wydaniem zapisz wszystkie zmiany źródłowe w commicie. Następnie urucho
```
Skrypt automatycznie zwiększa `versionCode` i ostatni człon `versionName`, uruchamia testy,
buduje podpisany APK dla Gitea oraz AAB dla Google Play Console, tworzy commit wersji,
tag, push i Gitea Release. Artefakty zostają również zapisane lokalnie w
`release-artifacts/<tag>/`.
buduje podpisany APK oraz AAB, a po poprawnym buildzie pyta, czy wysłać aktualizację do
Google Play. Po odpowiedzi „tak” publikuje AAB w skonfigurowanym teście zamkniętym i
wysyła zmiany do weryfikacji. Następnie tworzy commit wersji, tag, push i Gitea Release.
Artefakty zostają również zapisane lokalnie w `release-artifacts/<tag>/`.
Do publikacji Google Play konto serwisowe musi mieć dla aplikacji
`pl.firmatpp.kierowca` uprawnienia „Wyświetlanie informacji o aplikacji (tylko do odczytu)”
oraz „Publikowanie aplikacji na ścieżkach testowych”. Pobrany klucz JSON umieść
bezpośrednio w folderze `scripts`, na przykład:
```text
scripts/google-play-service-account.json
```
Skrypt sam znajduje jedyny plik `scripts/*.json`, sprawdza, czy jest prawidłowym kluczem
konta serwisowego, i używa go do autoryzacji. Pliki `scripts/*.json` są ignorowane przez
Git i nie mogą zostać przypadkowo dodane do repozytorium. Jeśli w folderze znajduje się
więcej niż jeden prawidłowy klucz, skrypt przerwie działanie i poprosi o pozostawienie
jednego.
`GOOGLE_PLAY_TRACK` musi być identyfikatorem istniejącego testu zamkniętego. Domyślna
wartość to `alpha`. Opcjonalnie można ustawić `GOOGLE_PLAY_PACKAGE_NAME` i
`GOOGLE_PLAY_RELEASE_LANGUAGE` (domyślnie `pl-PL`). Zmienna
`GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS` pozostaje opcjonalnym sposobem wskazania klucza
spoza folderu `scripts`. Skrypt nie zapisuje tokenu ani klucza w repozytorium; prywatny
klucz tymczasowy jest usuwany po zakończeniu.
Przed zatwierdzeniem skrypt waliduje edycję Google Play. Commit edycji jawnie wysyła
zmiany do weryfikacji. Jeżeli inne zmiany są już sprawdzane, skrypt nie anuluje ich —
kończy się błędem i można go wznowić po zakończeniu poprzedniej weryfikacji.
Jeżeli na koncie jest włączone publikowanie zarządzane, po akceptacji Google nadal trzeba
wybrać „Opublikuj zmiany” w Play Console; bez publikowania zarządzanego zaakceptowane
wydanie zostanie udostępnione testerom automatycznie.
Awaryjne wydanie wyłącznie do Gitea, bez Google Play:
```bash
./scripts/release-android.sh --skip-google-play
```
W środowisku bez interaktywnego terminala decyzję trzeba podać jawnie:
```bash
./scripts/release-android.sh --publish-google-play
```
Do autoryzacji Gitea używany jest `GITEA_TOKEN` albo dane zapisane w Git credential helper.
Jeśli publikacja została przerwana już po utworzeniu commita wersji, można ją wznowić:
+4 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 106
versionName = "1.0.53"
versionCode = 115
versionName = "1.0.62"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -130,6 +130,8 @@ dependencies {
implementation(libs.okhttp.logging)
implementation(libs.play.services.auth)
implementation(libs.play.services.auth.api.phone)
implementation(libs.play.app.update)
implementation(libs.play.app.update.ktx)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
@@ -16,7 +16,10 @@ class DriverApplication : Application() {
AppDiagnostics.installFirebaseCrashlytics()
AppDiagnostics.log("app_started")
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") }
}
}
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -11,17 +12,33 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import pl.firmatpp.kierowca.ui.DriverApp
import pl.firmatpp.kierowca.ui.DriverViewModel
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
import pl.firmatpp.kierowca.update.PlayAppUpdateState
import pl.firmatpp.kierowca.update.PlayInAppUpdateController
class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null)
private var notificationRouteTarget by mutableStateOf<String?>(null)
private var notificationLeaveRequestId by mutableStateOf<String?>(null)
private var playAppUpdateState by mutableStateOf(PlayAppUpdateState())
private lateinit var playInAppUpdates: PlayInAppUpdateController
private val appUpdateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult(),
) { result ->
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.onUpdateFlowResult(result.resultCode)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
playInAppUpdates = PlayInAppUpdateController(
activity = this,
launcher = appUpdateLauncher,
onStateChanged = { playAppUpdateState = it },
)
setContent {
val viewModel: DriverViewModel = viewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -32,9 +49,27 @@ class MainActivity : ComponentActivity() {
initialRouteId = notificationRouteId,
initialRouteTarget = notificationRouteTarget,
initialLeaveRequestId = notificationLeaveRequestId,
playAppUpdateState = playAppUpdateState,
onStartAppUpdate = playInAppUpdates::startUpdate,
onCompleteAppUpdate = playInAppUpdates::completeDownloadedUpdate,
)
}
}
playInAppUpdates.checkForUpdate()
}
override fun onResume() {
super.onResume()
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.checkForUpdate()
}
}
override fun onDestroy() {
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.dispose()
}
super.onDestroy()
}
override fun onNewIntent(intent: android.content.Intent) {
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data
import java.io.IOException
import java.net.UnknownHostException
import kotlin.coroutines.cancellation.CancellationException
import retrofit2.HttpException
enum class ApiErrorKind {
@@ -106,3 +107,7 @@ object ApiErrorMapper {
return mapHttpStatus(exception.code(), body)
}
}
fun Throwable.rethrowIfCancellation() {
if (this is CancellationException) throw this
}
@@ -50,6 +50,16 @@ data class BootstrapResponse(
val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null,
val appUpdate: AppUpdateDto? = null,
)
data class AppUpdateDto(
val latestVersionCode: Int? = null,
val latestVersionName: String? = null,
val minimumSupportedVersionCode: Int = 0,
val playStoreUrl: String? = null,
val track: String? = null,
val checkedAt: String? = null,
)
data class DriverAppSettingsDto(
@@ -117,6 +127,7 @@ data class RealtimeConfigDto(
val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null,
val reverbOrigin: String? = null,
)
data class RoutesBucketDto(
@@ -264,6 +275,8 @@ data class DriverRouteDto(
val contractCode: String?,
val relationLabel: String,
val status: String,
val driverStatus: String? = null,
val operationalStatus: String? = null,
val loadingWeight: Double? = null,
val unloadingWeight: Double? = null,
val trackingStatus: String? = null,
@@ -272,6 +285,7 @@ data class DriverRouteDto(
val notes: String?,
val truck: TruckDto?,
val completedAt: String? = null,
val operationalCompletedAt: String? = null,
val completedByDriver: DriverDto? = null,
val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null,
@@ -279,6 +293,9 @@ data class DriverRouteDto(
val photosByStage: RoutePhotosByStageDto? = null,
)
fun DriverRouteDto.driverLifecycleStatus(): String =
driverStatus?.takeIf { it in setOf("ZAPLANOWANA", "W TRAKCIE", "ZAKOŃCZONA") } ?: status
data class RoutePhotosByStageDto(
val loading: List<RoutePhotoDto> = emptyList(),
val unloading: List<RoutePhotoDto> = emptyList(),
@@ -70,6 +70,9 @@ class DriverSyncRepository(
)
}
suspend fun hasBootstrapCache(date: String?): Boolean =
dao.bootstrap(date ?: currentDateFallback()) != null
suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? {
val cached = dao.route(routeId) ?: return null
return CachedValue(
@@ -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) {
workManager.cancelUniqueWork(DispatchSheetUploadWorker.uniqueWorkName(clientRequestId))
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
}
suspend fun retry(clientRequestId: String) {
val existing = dao.find(clientRequestId) ?: return
dao.upsert(
existing.copy(
status = PhotoUploadStatus.Pending.storageValue,
progress = 0,
lastError = null,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)
enqueueWorker(clientRequestId)
}
private fun enqueueWorker(clientRequestId: String) {
val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>()
.setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
@@ -68,6 +69,7 @@ class DispatchSheetUploadWorker(
dao.delete(clientRequestId)
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
@@ -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() {
val photos = database.photoUploadDao().allRecords()
val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
@@ -61,3 +79,31 @@ class OfflineOutboxManager(private val context: Context) {
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 pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class PhotoUploadOutbox(
private val context: Context,
@@ -74,6 +75,7 @@ class PhotoUploadOutbox(
}
suspend fun discard(clientRequestId: String) {
workManager.cancelUniqueWork(PhotoUploadWorker.uniqueWorkName(clientRequestId))
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
@@ -81,10 +83,13 @@ class PhotoUploadOutbox(
}
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
dao.confirmedForRoute(route.id).forEach { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
dao.allRecords()
.filter { it.routeId == route.id }
.filter { upload ->
upload.status == PhotoUploadStatus.Confirmed.storageValue ||
route.photos.orEmpty().any { serverPhoto -> serverPhotoConfirmsUpload(upload, serverPhoto) }
}
.forEach { upload -> discard(upload.clientRequestId) }
}
private fun enqueueWorker(clientRequestId: String) {
@@ -118,3 +123,9 @@ class PhotoUploadOutbox(
return digest.digest().joinToString("") { "%02x".format(it) }
}
}
internal fun serverPhotoConfirmsUpload(upload: PhotoUploadEntity, serverPhoto: RoutePhotoDto): Boolean =
serverPhoto.clientRequestId == upload.clientRequestId && (
serverPhoto.contentSha256.isNullOrBlank() ||
serverPhoto.contentSha256.equals(upload.contentSha256, ignoreCase = true)
)
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
@@ -70,6 +71,7 @@ class PhotoUploadWorker(
enqueueRouteActionsWaitingForPhoto(clientRequestId)
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
@@ -31,6 +31,18 @@ class RouteActionOutbox(
dao.delete(clientActionId)
}
suspend fun retry(clientActionId: String) {
val existing = dao.find(clientActionId) ?: return
dao.upsert(
existing.copy(
status = RouteActionStatus.Pending,
lastError = null,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)
enqueueWorker(clientActionId)
}
suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds)
@@ -7,6 +7,7 @@ import com.google.gson.Gson
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
@@ -75,6 +76,7 @@ class RouteActionWorker(
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
@@ -8,6 +8,7 @@ import kotlinx.coroutines.delay
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.RoutePointBatchBody
import pl.firmatpp.kierowca.data.model.RoutePointDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
@@ -52,6 +53,7 @@ class RoutePointWorker(
}
val throwable = outcome.exceptionOrNull() ?: return Result.retry()
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.domain
import kotlin.math.roundToInt
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
data class RouteCardDisplay(
val id: String,
@@ -32,7 +33,7 @@ object RouteDisplayMapper {
destination = route.destinationName.ifBlank { "Cel" },
contractLabel = contract,
time = route.startsAt.ifBlank { "--:--" },
status = route.status,
status = route.driverLifecycleStatus(),
distanceLabel = distance,
)
}
@@ -0,0 +1,51 @@
package pl.firmatpp.kierowca.domain
data class StartupSessionMessage(
val title: String,
val detail: String,
)
object StartupSessionPolicy {
const val OFFLINE_BUTTON_DELAY_SECONDS = 4
const val OFFLINE_FALLBACK_SECONDS = 15
const val OFFLINE_FALLBACK_MILLIS = OFFLINE_FALLBACK_SECONDS * 1_000L
fun remainingSeconds(elapsedSeconds: Int): Int =
(OFFLINE_FALLBACK_SECONDS - elapsedSeconds).coerceAtLeast(0)
fun shouldShowOfflineButton(elapsedSeconds: Int, offlineDataAvailable: Boolean): Boolean =
offlineDataAvailable && elapsedSeconds >= OFFLINE_BUTTON_DELAY_SECONDS
fun message(elapsedSeconds: Int): StartupSessionMessage = when {
elapsedSeconds < 3 -> StartupSessionMessage(
title = "Sprawdzamy zapisaną sesję",
detail = "Odczytujemy bezpieczne logowanie kierowcy.",
)
elapsedSeconds < 6 -> StartupSessionMessage(
title = "Potwierdzamy logowanie",
detail = "Łączymy telefon z serwerem.",
)
elapsedSeconds < 9 -> StartupSessionMessage(
title = "Pobieramy aktualne kursy",
detail = "Synchronizujemy plan dnia i ustawienia aplikacji.",
)
elapsedSeconds < 12 -> StartupSessionMessage(
title = "To trwa dłużej niż zwykle",
detail = "Przy słabym zasięgu skorzystamy z danych zapisanych w telefonie.",
)
else -> StartupSessionMessage(
title = "Przygotowujemy tryb offline",
detail = "Za chwilę pokażemy ostatnie zapisane kursy.",
)
}
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"
}
}
}
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.sync
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.net.URI
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -42,16 +43,33 @@ class DriverRepositoryLiveSyncGateway(
}
interface LiveWebSocketFactory {
fun newWebSocket(url: String, listener: WebSocketListener): WebSocket
fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket
}
class OkHttpLiveWebSocketFactory(
private val client: OkHttpClient,
) : LiveWebSocketFactory {
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket =
client.newWebSocket(Request.Builder().url(url).build(), listener)
override fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket =
client.newWebSocket(
Request.Builder()
.url(url)
.header("Origin", origin?.takeIf { it.isNotBlank() } ?: websocketOrigin(url))
.build(),
listener,
)
}
internal fun websocketOrigin(url: String): String =
URI(
url
.replaceFirst("wss://", "https://")
.replaceFirst("ws://", "http://")
).let { uri ->
val defaultPort = (uri.scheme == "https" && uri.port == 443) || (uri.scheme == "http" && uri.port == 80)
val port = if (uri.port > 0 && !defaultPort) ":${uri.port}" else ""
"${uri.scheme}://${uri.host}$port"
}
enum class LiveSyncConnectionState {
Stopped,
WaitingForNetwork,
@@ -230,7 +248,7 @@ class DriverLiveSyncClient(
}
private fun connectNow(resetAttempt: Boolean = false) {
val wsUrl = synchronized(lock) {
val (wsUrl, origin) = synchronized(lock) {
val config = realtimeConfig ?: return
val id = driverId ?: return
if (!desiredActive || !foregroundActive || !networkAvailable || !isConfigUsable(config)) return
@@ -244,11 +262,15 @@ class DriverLiveSyncClient(
val appKey = config.reverbAppKey.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(
wsUrl,
origin,
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text)
@@ -13,6 +13,7 @@ import androidx.work.workDataOf
import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.data.ApiErrorKind
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.NetworkMonitor
import pl.firmatpp.kierowca.data.model.SyncScopeDto
@@ -79,6 +80,7 @@ class DriverSyncWorker(
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
@@ -26,6 +26,7 @@ import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import retrofit2.HttpException
@@ -75,6 +76,7 @@ class NewRouteNotificationWorker(
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
if (throwable is HttpException && throwable.code() == 404) {
Result.success()
} else {
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.ui
import android.Manifest
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -12,9 +13,13 @@ import android.location.LocationManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
@@ -69,6 +74,7 @@ import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
@@ -115,6 +121,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.Dp
@@ -143,6 +150,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import java.util.Locale
import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
@@ -150,6 +158,7 @@ import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.NavigationPointDto
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.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
@@ -159,8 +168,10 @@ import pl.firmatpp.kierowca.diagnostics.DiagnosticSeverity
import pl.firmatpp.kierowca.sync.LiveSyncConnectionState
import pl.firmatpp.kierowca.domain.OtpCodeExtractor
import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.domain.StartupSessionPolicy
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
import pl.firmatpp.kierowca.ui.theme.TppTheme
import pl.firmatpp.kierowca.update.PlayAppUpdateState
@Composable
fun DriverApp(
@@ -168,8 +179,27 @@ fun DriverApp(
initialRouteId: String? = null,
initialRouteTarget: String? = null,
initialLeaveRequestId: String? = null,
playAppUpdateState: PlayAppUpdateState = PlayAppUpdateState(),
onStartAppUpdate: (required: Boolean, playStoreUrl: String?) -> Unit = { _, _ -> },
onCompleteAppUpdate: () -> Unit = {},
) {
val state by viewModel.state.collectAsState()
val updateUi = appUpdateBannerUi(
currentVersionCode = BuildConfig.VERSION_CODE,
latestVersionCode = state.appUpdate?.latestVersionCode,
latestVersionName = state.appUpdate?.latestVersionName,
minimumSupportedVersionCode = state.appUpdate?.minimumSupportedVersionCode ?: 0,
playUpdateAvailable = playAppUpdateState.updateAvailable,
playAvailableVersionCode = playAppUpdateState.availableVersionCode,
updateDownloaded = playAppUpdateState.downloaded,
)
val runUpdate = {
if (updateUi?.downloaded == true) {
onCompleteAppUpdate()
} else {
onStartAppUpdate(updateUi?.required == true, state.appUpdate?.playStoreUrl)
}
}
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
var appInForeground by remember { mutableStateOf(true) }
@@ -228,10 +258,29 @@ fun DriverApp(
viewModel.openLeaveRequest(initialLeaveRequestId)
}
}
LaunchedEffect(state.screen, state.notifyNewRoutes, state.notificationPermissionDenied, appInForeground) {
if (!appInForeground || state.screen != DriverScreen.Profile || !state.notifyNewRoutes) return@LaunchedEffect
if (canPostNotifications(context)) {
if (state.notificationPermissionDenied) {
viewModel.clearNotificationPermissionWarning()
}
return@LaunchedEffect
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasPostNotificationsRuntimePermission(context)) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
viewModel.markNotificationPermissionBlocked()
}
}
Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) {
when (state.screen) {
DriverScreen.Initializing -> StartupScreen()
DriverScreen.Initializing -> StartupScreen(
offlineDataAvailable = state.startupOfflineAvailable,
onUseOfflineNow = viewModel::useOfflineStartupDataNow,
)
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back)
DriverScreen.Routes -> RoutesScreen(
@@ -295,7 +344,7 @@ fun DriverApp(
onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") },
onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deleteConfirmedUpload,
onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute,
)
@@ -312,7 +361,7 @@ fun DriverApp(
onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { _, _, _ -> },
onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deleteConfirmedUpload,
onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitStartRoute,
)
@@ -329,7 +378,7 @@ fun DriverApp(
onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") },
onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deleteConfirmedUpload,
onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitFinishRoute,
)
@@ -372,11 +421,99 @@ fun DriverApp(
CircularProgressIndicator(color = TppTheme.colors.forest)
}
}
if (updateUi != null && !updateUi.required) {
AppUpdateBanner(
update = updateUi,
onUpdate = runUpdate,
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
if (updateUi?.required == true) {
AlertDialog(
onDismissRequest = {},
title = {
Text(
"Wymagana aktualizacja",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
)
},
text = { Text(updateUi.message, color = TppTheme.colors.muted) },
confirmButton = {
Button(
onClick = runUpdate,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
) {
Text(updateUi.actionLabel, color = Color.White, fontWeight = FontWeight.Bold)
}
},
)
}
}
@Composable
private fun StartupScreen() {
private fun AppUpdateBanner(
update: AppUpdateBannerUi,
onUpdate: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.warningContainer),
border = BorderStroke(1.dp, TppTheme.colors.warningOutline),
shape = MaterialTheme.shapes.medium,
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(40.dp).background(Color.White, RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.SystemUpdate, contentDescription = null, tint = TppTheme.colors.forest)
}
Text(
update.message,
color = Color(0xFF5F4200),
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Button(
onClick = onUpdate,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.small,
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
) {
Text(update.actionLabel, color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
private fun StartupScreen(
offlineDataAvailable: Boolean,
onUseOfflineNow: () -> Unit,
) {
var elapsedSeconds by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
while (elapsedSeconds < StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS) {
delay(1_000L)
elapsedSeconds += 1
}
}
val sessionMessage = StartupSessionPolicy.message(elapsedSeconds)
val countdownLabel = StartupSessionPolicy.countdownLabel(elapsedSeconds)
val showOfflineButton = StartupSessionPolicy.shouldShowOfflineButton(elapsedSeconds, offlineDataAvailable)
BoxWithConstraints(
Modifier
.fillMaxSize()
@@ -401,13 +538,100 @@ private fun StartupScreen() {
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(color = TppTheme.colors.forest)
Spacer(Modifier.height(20.dp))
Spacer(Modifier.height(22.dp))
Text(
"Sprawdzanie sesji",
color = TppTheme.colors.muted,
"URUCHAMIAMY APLIKACJĘ",
color = TppTheme.colors.forest,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
Spacer(Modifier.height(10.dp))
AnimatedContent(
targetState = sessionMessage,
transitionSpec = {
fadeIn(animationSpec = tween(260)) togetherWith
fadeOut(animationSpec = tween(220))
},
label = "startup-session-message",
) { message ->
Column(
modifier = Modifier.heightIn(min = 72.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
Text(
message.title,
modifier = Modifier.fillMaxWidth(),
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(6.dp))
Text(
message.detail,
modifier = Modifier.fillMaxWidth(),
color = TppTheme.colors.muted,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
Spacer(Modifier.height(14.dp))
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.panel),
border = BorderStroke(1.dp, TppTheme.colors.outline.copy(alpha = 0.72f)),
shape = RoundedCornerShape(6.dp),
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AnimatedContent(
targetState = countdownLabel,
transitionSpec = {
fadeIn(animationSpec = tween(180)) togetherWith
fadeOut(animationSpec = tween(140))
},
label = "startup-offline-countdown",
) { label ->
Text(
label,
color = TppTheme.colors.forest,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(4.dp))
Text(
"Jeśli kursy były wcześniej zapisane w telefonie.",
color = TppTheme.colors.muted,
fontSize = 12.sp,
)
}
}
AnimatedVisibility(
visible = showOfflineButton,
enter = fadeIn(animationSpec = tween(260)),
exit = fadeOut(animationSpec = tween(160)),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onUseOfflineNow,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
border = BorderStroke(1.dp, TppTheme.colors.forest),
colors = ButtonDefaults.outlinedButtonColors(contentColor = TppTheme.colors.forest),
shape = RoundedCornerShape(6.dp),
) {
Text(
"Przejdź do trybu offline teraz",
fontWeight = FontWeight.Bold,
)
}
}
}
}
}
}
@@ -1866,7 +2090,7 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
)
}
}
if (hasFailureDetails || canRetryPhotoUpload(upload.status)) {
if (hasFailureDetails || canRetryPhotoUpload(upload.status, upload.lastError)) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
if (hasFailureDetails) {
IconButton(
@@ -1884,7 +2108,7 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
)
}
}
if (canRetryPhotoUpload(upload.status)) {
if (canRetryPhotoUpload(upload.status, upload.lastError)) {
Button(
onClick = { onRetryUpload(upload) },
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
@@ -2021,7 +2245,8 @@ private fun routeListTitle(selectedDate: String): String {
@Composable
private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
val display = RouteDisplayMapper.toCard(route)
val stripColor = routeStatusColor(route.status)
val driverStatus = route.driverLifecycleStatus()
val stripColor = routeStatusColor(driverStatus)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
@@ -2043,7 +2268,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
Column(Modifier.padding(start = contentPadding, top = contentPadding, end = contentPadding, bottom = 20.dp)) {
if (compactWidth) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
StatusChip(route.status, stripColor)
StatusChip(driverStatus, stripColor)
Text(
"Kontrakt: #${display.contractLabel.trimStart('#')}",
fontFamily = FontFamily.Monospace,
@@ -2057,7 +2282,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
StatusChip(route.status, stripColor)
StatusChip(driverStatus, stripColor)
Text(
"Kontrakt: #${display.contractLabel.trimStart('#')}",
fontFamily = FontFamily.Monospace,
@@ -2238,13 +2463,18 @@ private fun ProfileScreen(
Text("Zmiana powiadomień wymaga połączenia z internetem.", color = TppTheme.colors.muted, style = MaterialTheme.typography.bodySmall)
}
if (state.notificationPermissionDenied) {
Text(
"Android blokuje powiadomienia dla tej aplikacji. Token FCM może być zarejestrowany, ale powiadomienie nie pojawi się na telefonie, dopóki nie włączysz zgody systemowej.",
color = TppTheme.colors.error,
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = onOpenNotificationSettings,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy),
shape = MaterialTheme.shapes.small,
) {
Text("Przejdź do ustawień", fontWeight = FontWeight.Bold)
Text("Otwórz ustawienia powiadomień", fontWeight = FontWeight.Bold)
}
}
HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f))
@@ -2621,26 +2851,27 @@ private fun RouteStageScreen(
val route = state.displaySelectedRoute
val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty()
val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size)
val usableStageUploads = routeStageUsableUploads(stageUploads)
val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, usableStageUploads.size)
val photoRequirement = if (stage == "loading") state.loadingPhotoRequirement else state.unloadingPhotoRequirement
val weightRequirement = if (stage == "loading") state.loadingWeightRequirement else state.unloadingWeightRequirement
val submitBlocker = when {
!showWeightInput -> loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
localUploadCount = usableStageUploads.size,
photoRequirement = photoRequirement,
)
stage == "loading" -> loadingWeightSubmitBlocker(
weightText = state.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
localUploadCount = usableStageUploads.size,
weightRequirement = weightRequirement,
photoRequirement = photoRequirement,
)
else -> routeStageSubmitBlocker(
weightText = state.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
localUploadCount = usableStageUploads.size,
weightRequirement = weightRequirement,
photoRequirement = photoRequirement,
)
@@ -2725,16 +2956,14 @@ private fun RouteStageScreen(
label = { Text(weightLabel) },
trailingIcon = {
Text(
"t",
"t / kg",
color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
},
supportingText = {
if (weightBlocker != null) {
Text(weightBlocker)
}
Text(weightBlocker ?: routeWeightInputHelpText(state.routeStageWeightText))
},
isError = weightBlocker != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
@@ -3124,7 +3353,7 @@ private fun RouteLifecycleSection(
)
}
if (route.status == "W TRAKCIE" && !routeProgressNotificationEnabled) {
if (route.driverLifecycleStatus() == "W TRAKCIE" && !routeProgressNotificationEnabled) {
Text(
"Powiadomienie działa w trybie minimalnym.",
color = TppTheme.colors.muted,
@@ -3235,7 +3464,8 @@ private fun RouteFlowStepItem(step: RouteFlowStepUi, modifier: Modifier = Modifi
private fun routeFlowStepColor(state: RouteFlowStepState): Color =
when (state) {
RouteFlowStepState.Todo -> TppTheme.colors.muted
RouteFlowStepState.LocalComplete -> TppTheme.colors.navy
RouteFlowStepState.ServerSaved,
RouteFlowStepState.InProgress -> TppTheme.colors.navy
RouteFlowStepState.Syncing -> TppTheme.colors.containerGreen
RouteFlowStepState.Confirmed -> TppTheme.colors.forest
RouteFlowStepState.NeedsAttention -> TppTheme.colors.error
@@ -3245,7 +3475,8 @@ private fun routeFlowStepColor(state: RouteFlowStepState): Color =
private fun routeFlowStepBackground(state: RouteFlowStepState): Color =
when (state) {
RouteFlowStepState.Todo -> TppTheme.colors.panel.copy(alpha = 0.62f)
RouteFlowStepState.LocalComplete -> Color(0xFFE9F2FF)
RouteFlowStepState.ServerSaved,
RouteFlowStepState.InProgress -> Color(0xFFE9F2FF)
RouteFlowStepState.Syncing -> TppTheme.colors.successContainer.copy(alpha = 0.75f)
RouteFlowStepState.Confirmed -> TppTheme.colors.successContainer
RouteFlowStepState.NeedsAttention -> TppTheme.colors.warningContainer
@@ -3254,7 +3485,8 @@ private fun routeFlowStepBackground(state: RouteFlowStepState): Color =
private fun routeFlowStepIcon(state: RouteFlowStepState): ImageVector =
when (state) {
RouteFlowStepState.Todo -> Icons.Outlined.Info
RouteFlowStepState.LocalComplete -> Icons.Outlined.LocalShipping
RouteFlowStepState.ServerSaved -> Icons.Outlined.CheckCircle
RouteFlowStepState.InProgress -> Icons.Outlined.LocalShipping
RouteFlowStepState.Syncing -> Icons.Outlined.CloudUpload
RouteFlowStepState.Confirmed -> Icons.Outlined.CheckCircle
RouteFlowStepState.NeedsAttention -> Icons.Outlined.Info
@@ -3304,7 +3536,8 @@ private fun leaveConflictCountLabel(count: Int): String =
@Composable
private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status)
val driverStatus = route.driverLifecycleStatus()
val stripColor = routeStatusColor(driverStatus)
val routeDate = route.routeDate ?: selectedDate
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
@@ -3319,7 +3552,7 @@ private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavig
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Top,
) {
StatusChip(route.status, stripColor)
StatusChip(driverStatus, stripColor)
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
RouteDateStatusChip(routeDate)
}
@@ -3430,7 +3663,10 @@ private fun RoutePointBlock(
Text(subtitle, color = TppTheme.colors.muted, style = MaterialTheme.typography.bodyLarge)
}
}
if (navigationPoint?.hasCoordinates() == true) {
if (
navigationPoint != null &&
hasValidNavigationCoordinates(navigationPoint.latitude, navigationPoint.longitude)
) {
IconButton(
onClick = { onNavigate(navigationPoint) },
modifier = Modifier.size(42.dp).background(TppTheme.colors.forest, RoundedCornerShape(21.dp)),
@@ -3673,7 +3909,7 @@ private fun PhotoGrid(
)
is PhotoGridItem.Upload -> PendingPhotoTile(
upload = item.upload,
isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true,
isDeleting = (item.upload.serverPhotoId ?: item.upload.clientRequestId) in deletingPhotoIds,
onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) },
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
onRetry = onRetryUpload,
@@ -3823,6 +4059,7 @@ private fun PendingPhotoTile(
) {
val status = upload.statusType
val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null
val canDeleteUpload = canDeleteServerPhoto || canDiscardLocalPhotoUpload(upload.status, upload.serverPhotoId)
val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError)
var showFailureDetails by remember { mutableStateOf(false) }
if (showFailureDetails) {
@@ -3878,7 +4115,7 @@ private fun PendingPhotoTile(
Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
}
}
if (!readOnly && canDeleteServerPhoto) {
if (!readOnly && canDeleteUpload) {
IconButton(
onClick = { onDelete(upload) },
enabled = !isDeleting,
@@ -3916,13 +4153,13 @@ private fun PendingPhotoTile(
)
}
}
if (!readOnly && canRetryPhotoUpload(upload.status)) {
if (!readOnly && canRetryPhotoUpload(upload.status, upload.lastError)) {
IconButton(
onClick = { onRetry(upload) },
enabled = !isDeleting,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.padding(top = if (canDeleteUpload) 56.dp else 8.dp, end = 8.dp)
.size(40.dp)
.background(TppTheme.colors.surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp))
.border(1.dp, TppTheme.colors.forest.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
@@ -4220,24 +4457,29 @@ private fun createCameraUri(context: Context): Uri {
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
private fun NavigationPointDto.hasCoordinates(): Boolean = latitude != null && longitude != null
private fun openNavigation(context: Context, point: NavigationPointDto) {
val latitude = point.latitude ?: return
val longitude = point.longitude ?: return
val googleMapsIntent = Intent(Intent.ACTION_VIEW, Uri.parse("google.navigation:q=$latitude,$longitude"))
.setPackage("com.google.android.apps.maps")
val candidates = navigationIntentCandidates(point.latitude, point.longitude, point.label)
if (googleMapsIntent.resolveActivity(context.packageManager) != null) {
context.startActivity(googleMapsIntent)
return
for (candidate in candidates) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(candidate.uri)).apply {
candidate.packageName?.let(::setPackage)
}
try {
context.startActivity(intent)
return
} catch (_: ActivityNotFoundException) {
// Try the next navigation provider.
} catch (_: SecurityException) {
// The provider is unavailable to this app; use the next fallback.
}
}
val label = Uri.encode(point.label?.takeIf { it.isNotBlank() } ?: "Cel")
val geoIntent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$latitude,$longitude($label)"))
if (geoIntent.resolveActivity(context.packageManager) != null) {
context.startActivity(geoIntent)
}
Toast.makeText(
context,
"Nie znaleziono aplikacji, która może otworzyć nawigację.",
Toast.LENGTH_LONG,
).show()
}
private fun cameraCapturePermissions(context: Context, requirePreciseLocation: Boolean): Array<String> = buildList {
@@ -40,6 +40,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
RouteActionType.Start -> {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = action.weight,
trackingStatus = "active",
photos = projectedRoute.safePhotos(),
@@ -49,6 +50,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = action.weight,
trackingStatus = "finished",
completedAt = action.occurredAt,
@@ -1,24 +1,113 @@
package pl.firmatpp.kierowca.ui
import java.io.File
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
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.DispatchSheetReminderDto
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.PhotoUploadStatus
import pl.firmatpp.kierowca.data.upload.isWorkerCancellationFailure
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
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
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,
)
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 {
Todo,
LocalComplete,
ServerSaved,
InProgress,
Syncing,
Confirmed,
NeedsAttention,
@@ -97,10 +186,10 @@ fun routeTodayInstruction(
val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate
return when {
route.status == "ZAKOŃCZONA" -> "Kurs zakończony."
route.driverLifecycleStatus() == "ZAKOŃCZONA" -> "Kurs zakończony."
loadingDate == unloadingDate -> "Dziś: załadunek i rozł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."
else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}."
}
@@ -113,7 +202,7 @@ fun canCompleteRouteFromDriverApp(
): Boolean =
runCatching { LocalDate.parse(selectedDate) }.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(
route: DriverRouteDto,
@@ -121,14 +210,14 @@ fun canStartRouteFromDriverApp(
today: LocalDate = LocalDate.now(),
): Boolean =
routeIsActiveToday(route, selectedDate, today)
&& route.status == "ZAPLANOWANA"
&& route.driverLifecycleStatus() == "ZAPLANOWANA"
fun canFinishRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
route.status == "W TRAKCIE"
route.driverLifecycleStatus() == "W TRAKCIE"
&& runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
@@ -150,6 +239,31 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean =
fun routeStageRequirementIsRequired(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
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(
weightText: String,
serverPhotoCount: Int,
@@ -188,13 +302,14 @@ fun loadingWeightSubmitBlocker(
photoRequirement: String = RouteStageRequirement.Required,
): String? {
val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
val normalizedWeight = parseRouteWeightTons(trimmed)
val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when {
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj 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 > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
else -> null
}
@@ -208,13 +323,14 @@ fun routeStageSubmitBlocker(
photoRequirement: String = RouteStageRequirement.Required,
): String? {
val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
val normalizedWeight = parseRouteWeightTons(trimmed)
val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when {
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj 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 > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
else -> null
}
@@ -264,29 +380,30 @@ fun routeFlowSteps(
val startOverride = routeActionStepState(actions, RouteActionType.Start)
val finishOverride = routeActionStepState(actions, RouteActionType.Finish)
val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size
val driverStatus = route.driverLifecycleStatus()
val loadingPhotosState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete
loadingPhotoCount > 0 -> RouteFlowStepState.LocalComplete
startOverride != null -> startOverride
driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
loadingPhotoCount > 0 -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo
}
val loadingWeightState = startOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete
driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo
}
val transitState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
finishOverride == RouteFlowStepState.NeedsAttention -> RouteFlowStepState.LocalComplete
finishOverride == RouteFlowStepState.Syncing -> RouteFlowStepState.LocalComplete
route.status == "W TRAKCIE" -> RouteFlowStepState.LocalComplete
finishOverride != null -> RouteFlowStepState.InProgress
driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
startOverride != null -> startOverride
driverStatus == "W TRAKCIE" -> RouteFlowStepState.InProgress
else -> RouteFlowStepState.Todo
}
val unloadingState = finishOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.unloadingWeight != null -> RouteFlowStepState.LocalComplete
driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.unloadingWeight != null -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo
}
@@ -347,7 +464,8 @@ private fun routeActionStepState(actions: List<RouteActionEntity>, actionType: S
private fun routeFlowStateLabel(state: RouteFlowStepState): String =
when (state) {
RouteFlowStepState.Todo -> "do zrobienia"
RouteFlowStepState.LocalComplete -> "uzupełnione lokalnie"
RouteFlowStepState.ServerSaved -> "zapisane na serwerze"
RouteFlowStepState.InProgress -> "w trakcie"
RouteFlowStepState.Syncing -> "synchronizacja"
RouteFlowStepState.Confirmed -> "potwierdzone"
RouteFlowStepState.NeedsAttention -> "wymaga uwagi"
@@ -441,6 +559,18 @@ fun routeFactColumns(screenWidthDp: Int): Int =
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
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 =
statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" }
@@ -452,8 +582,15 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
fun canRetryPhotoUpload(status: String): Boolean =
status == "FAILED_RETRYABLE"
fun canRetryPhotoUpload(status: String, lastError: String? = null): Boolean =
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 =
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
@@ -481,6 +618,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 =
!requirePreciseLocation || hasFineLocation
@@ -22,12 +22,15 @@ import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
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.AppUpdateDto
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
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.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -39,6 +42,7 @@ import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionOutbox
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
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.DiagnosticEntry
import pl.firmatpp.kierowca.diagnostics.DiagnosticSection
@@ -61,6 +65,7 @@ data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing,
val loading: Boolean = true,
val refreshing: Boolean = false,
val startupOfflineAvailable: Boolean = false,
val phone: String = "",
val otpCode: String = "",
val maskedPhone: String = "",
@@ -88,6 +93,7 @@ data class DriverUiState(
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val appUpdate: AppUpdateDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null,
val leaveRequests: List<DriverLeaveRequestDto> = emptyList(),
@@ -129,7 +135,7 @@ data class DriverUiState(
val displayRoutes: List<DriverRouteDto>
get() = projectDriverRoutes(routes, projectionActions)
.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) },
)
@@ -188,6 +194,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val appPreferencesStore = AppPreferencesStore(application)
private val repository = DriverRepository(application)
private val syncRepository = DriverSyncRepository(application, repository)
private val startupOfflineFallbackLoader = StartupOfflineFallbackLoader()
private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application)
private val routeActionOutbox = RouteActionOutbox(application)
@@ -215,6 +222,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private var leaveCalendarRequestGeneration: Long = 0
private var routesRequestGeneration: Long = 0
private var routeDetailRequestGeneration: Long = 0
private var routesLoadJob: Job? = null
val state: StateFlow<DriverUiState> = _state
init {
@@ -295,6 +303,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
viewModelScope.launch {
if (repository.hasToken()) {
val offlineAvailable = syncRepository.hasBootstrapCache(_state.value.selectedDate)
_state.update { it.copy(startupOfflineAvailable = offlineAvailable) }
refreshRoutes()
} else {
_state.update { it.copy(screen = DriverScreen.Phone, loading = false) }
@@ -338,6 +348,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
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() {
DriverRuntimeSyncState.foreground = true
liveSyncClient.setForeground(true)
@@ -365,17 +389,31 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_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
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) }
runCatching {
val cached = if (_state.value.isOnline) {
syncRepository.bootstrap(date)
} else {
syncRepository.bootstrapFromCache(date)
val cached = when {
startupOfflineOnly -> syncRepository.bootstrapFromCache(date)
?: 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 settings = response.driverAppSettings
@@ -386,6 +424,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
driver = response.session.driver,
routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder,
appUpdate = response.appUpdate ?: it.appUpdate,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate,
maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
@@ -495,6 +534,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
screen = routeScreenAfterServerRefresh(it.screen, response.route),
selectedRoute = response.route,
routes = it.routes.map { route -> if (route.id == routeId) response.route else route },
isStale = cached.stale || !it.isOnline,
@@ -639,6 +679,24 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun markNotificationPermissionBlocked() {
_state.update {
it.copy(
notificationPermissionDenied = true,
error = "Powiadomienia są włączone w aplikacji, ale Android blokuje ich wyświetlanie. Włącz powiadomienia w ustawieniach aplikacji.",
)
}
}
fun clearNotificationPermissionWarning() {
_state.update {
it.copy(
notificationPermissionDenied = false,
error = if (it.error?.contains("powiadom", ignoreCase = true) == true) null else it.error,
)
}
}
fun setThemeMode(themeMode: AppThemeMode) {
_state.update { it.copy(themeMode = themeMode) }
viewModelScope.launch {
@@ -988,7 +1046,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
val stagePhotos = routePhotosForStage(route.photos, "loading")
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" }
val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" },
)
val blocker = loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
@@ -1040,16 +1100,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
?: return
viewModelScope.launch {
runCatching { routeActionOutbox.discard(action.clientActionId) }
val interrupted = isWorkerCancellationFailure(action.lastError)
runCatching {
if (interrupted) routeActionOutbox.retry(action.clientActionId)
else routeActionOutbox.discard(action.clientActionId)
}
.onSuccess {
_state.update {
it.copy(
routeActions = it.routeActions.filterNot { item -> item.clientActionId == action.clientActionId },
visibleRouteActions = it.visibleRouteActions.filterNot { item -> item.clientActionId == action.clientActionId },
routeActions = if (interrupted) it.routeActions else it.routeActions.filterNot { item -> item.clientActionId == action.clientActionId },
visibleRouteActions = if (interrupted) it.visibleRouteActions else it.visibleRouteActions.filterNot { item -> item.clientActionId == action.clientActionId },
feedback = if (interrupted) "Ponawiam potwierdzenie z tym samym identyfikatorem operacji." else it.feedback,
error = null,
)
}
if (action.action == RouteActionType.Start) openStartRoute() else openFinishRoute()
if (!interrupted) {
if (action.action == RouteActionType.Start) openStartRoute() else openFinishRoute()
}
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
@@ -1085,12 +1152,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val photoRequirement = if (stage == "loading") snapshot.loadingPhotoRequirement else snapshot.unloadingPhotoRequirement
val weightRequirement = if (stage == "loading") snapshot.loadingWeightRequirement else snapshot.unloadingWeightRequirement
val weight = if (routeStageRequirementIsVisible(weightRequirement)) {
snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull()
parseRouteWeightTons(snapshot.routeStageWeightText)
} else {
null
}
val stagePhotos = routePhotosForStage(route.photos, stage)
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage },
)
val submitBlocker = if (stage == "loading") {
loadingWeightSubmitBlocker(
weightText = snapshot.routeStageWeightText,
@@ -1136,6 +1205,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds)
route.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = weight,
trackingStatus = "active",
photos = route.safePhotos(),
@@ -1145,6 +1215,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds)
route.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = weight,
trackingStatus = "finished",
completedAt = Instant.now().toString(),
@@ -1244,13 +1315,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun deleteConfirmedUpload(upload: PhotoUploadEntity) {
fun deletePhotoUpload(upload: PhotoUploadEntity) {
val route = _state.value.selectedRoute ?: return
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId)
if (serverPhotoId == null) {
if (!canDiscardLocalPhotoUpload(upload.status, upload.serverPhotoId)) return
viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + upload.clientRequestId, error = null) }
runCatching { photoUploadOutbox.discard(upload.clientRequestId) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - upload.clientRequestId) }
}
return
}
if (!_state.value.isOnline) {
_state.update { it.copy(error = "Usunięcie wysłanego zdjęcia wymaga połączenia z internetem.") }
return
}
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) ?: return
viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) }
@@ -1412,7 +1493,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) {
val activeRoute = routes.firstOrNull { it.status == "W TRAKCIE" || it.trackingStatus == "active" }
val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
if (activeRoute == null) {
ActiveRouteTrackingService.stop(getApplication())
} else {
@@ -1426,7 +1507,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) {
if (route.status == "W TRAKCIE" || route.trackingStatus == "active") {
if (route.driverLifecycleStatus() == "W TRAKCIE") {
if (driver != null) {
ActiveRouteTrackingService.start(
context = getApplication(),
@@ -0,0 +1,146 @@
package pl.firmatpp.kierowca.update
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.InstallStateUpdatedListener
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
data class PlayAppUpdateState(
val updateAvailable: Boolean = false,
val availableVersionCode: Int? = null,
val downloaded: Boolean = false,
val checking: Boolean = false,
)
class PlayInAppUpdateController(
private val activity: ComponentActivity,
private val launcher: ActivityResultLauncher<IntentSenderRequest>,
private val onStateChanged: (PlayAppUpdateState) -> Unit,
private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(activity),
) {
private var state = PlayAppUpdateState()
private val installStateListener = InstallStateUpdatedListener { installState ->
when (installState.installStatus()) {
InstallStatus.DOWNLOADED -> updateState(state.copy(downloaded = true, updateAvailable = true))
InstallStatus.INSTALLED -> updateState(PlayAppUpdateState())
else -> Unit
}
}
init {
appUpdateManager.registerListener(installStateListener)
}
fun checkForUpdate() {
updateState(state.copy(checking = true))
appUpdateManager.appUpdateInfo
.addOnSuccessListener(::handleUpdateInfo)
.addOnFailureListener { updateState(state.copy(checking = false)) }
}
fun startUpdate(required: Boolean, playStoreUrl: String?) {
appUpdateManager.appUpdateInfo
.addOnSuccessListener { info ->
if (info.updateAvailability() != UpdateAvailability.UPDATE_AVAILABLE) {
openPlayStore(playStoreUrl)
return@addOnSuccessListener
}
val preferredType = if (required) AppUpdateType.IMMEDIATE else AppUpdateType.FLEXIBLE
val updateType = when {
info.isUpdateTypeAllowed(preferredType) -> preferredType
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> AppUpdateType.FLEXIBLE
info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) -> AppUpdateType.IMMEDIATE
else -> null
}
if (updateType == null || !launchUpdate(info, updateType)) {
openPlayStore(playStoreUrl)
}
}
.addOnFailureListener { openPlayStore(playStoreUrl) }
}
fun completeDownloadedUpdate() {
appUpdateManager.completeUpdate()
}
fun onUpdateFlowResult(resultCode: Int) {
if (resultCode != Activity.RESULT_OK) {
checkForUpdate()
}
}
fun dispose() {
appUpdateManager.unregisterListener(installStateListener)
}
private fun handleUpdateInfo(info: AppUpdateInfo) {
val downloaded = info.installStatus() == InstallStatus.DOWNLOADED
val available = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE ||
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS ||
downloaded
updateState(
PlayAppUpdateState(
updateAvailable = available,
availableVersionCode = info.availableVersionCode().takeIf { it > 0 },
downloaded = downloaded,
checking = false,
),
)
if (
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS &&
info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
) {
launchUpdate(info, AppUpdateType.IMMEDIATE)
}
}
private fun launchUpdate(info: AppUpdateInfo, updateType: Int): Boolean =
appUpdateManager.startUpdateFlowForResult(
info,
launcher,
AppUpdateOptions.newBuilder(updateType).build(),
)
private fun openPlayStore(playStoreUrl: String?) {
val packageName = activity.packageName
val candidates = listOf(
Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")).setPackage("com.android.vending"),
Intent(
Intent.ACTION_VIEW,
Uri.parse(playStoreUrl?.takeIf { it.isNotBlank() }
?: "https://play.google.com/store/apps/details?id=$packageName"),
),
)
for (intent in candidates) {
try {
activity.startActivity(intent)
return
} catch (_: ActivityNotFoundException) {
// Try the browser fallback.
} catch (_: SecurityException) {
// Try the browser fallback.
}
}
}
private fun updateState(next: PlayAppUpdateState) {
state = next
onStateChanged(next)
}
}
@@ -2,12 +2,27 @@ package pl.firmatpp.kierowca.data
import java.io.IOException
import java.net.UnknownHostException
import kotlin.coroutines.cancellation.CancellationException
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
class ApiErrorMapperTest {
@Test
fun rethrowsWorkerCancellationInsteadOfMappingItAsApplicationFailure() {
val cancellation = CancellationException("Job was cancelled")
try {
cancellation.rethrowIfCancellation()
fail("CancellationException should be rethrown")
} catch (thrown: CancellationException) {
assertSame(cancellation, thrown)
}
}
@Test
fun mapsNetworkFailuresToRetryableOfflineMessage() {
val error = ApiErrorMapper.map(IOException("timeout"))
@@ -0,0 +1,70 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class StartupOfflineFallbackLoaderTest {
@Test
fun returnsServerDataBeforeTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = { "server" },
offlineLoad = { "cache" },
)
assertEquals("server", result)
}
@Test
fun returnsCachedDataWhenServerExceedsTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { "cache" },
)
assertEquals("cache", result)
}
@Test
fun reportsMissingOfflineDataAfterTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val error = runCatching {
loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { null },
)
}.exceptionOrNull()
assertTrue(error is IOException)
}
@Test
fun doesNotHideAuthenticationFailures() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val authenticationFailure = IllegalStateException("401")
val error = runCatching {
loader.load(
onlineLoad = { throw authenticationFailure },
offlineLoad = { "cache" },
)
}.exceptionOrNull()
assertTrue(error is IllegalStateException)
assertEquals(authenticationFailure.message, error?.message)
}
}
@@ -0,0 +1,43 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OfflineOutboxManagerTest {
@Test
fun recognizesLegacyCancellationFailureDetails() {
assertTrue(isWorkerCancellationFailure("Typ: błąd aplikacji\nKomunikat: Job was cancelled"))
assertTrue(isWorkerCancellationFailure("kotlinx.coroutines.JobCancellationException"))
assertFalse(isWorkerCancellationFailure("HTTP 422: Niepoprawne zdjęcie"))
assertFalse(isWorkerCancellationFailure(null))
}
@Test
fun repairsInterruptedAndRetryablePhotoUploadsButNotRealPermanentFailures() {
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Pending.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Uploading.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Verifying.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.FailedRetryable.storageValue, "timeout"))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.FailedPermanent.storageValue, "Job was cancelled"))
assertFalse(shouldRepairPhotoUpload(PhotoUploadStatus.FailedPermanent.storageValue, "HTTP 422"))
assertFalse(shouldRepairPhotoUpload(PhotoUploadStatus.Cancelled.storageValue, null))
}
@Test
fun repairsInterruptedRouteActionWithItsOriginalIdempotencyKey() {
assertTrue(shouldRepairRouteAction(RouteActionStatus.Pending, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.WaitingForPhotos, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.Syncing, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.FailedRetryable, "timeout"))
assertTrue(shouldRepairRouteAction(RouteActionStatus.FailedPermanent, "Job was cancelled"))
assertTrue(
shouldRepairRouteAction(
RouteActionStatus.FailedPermanent,
"Wymagane zdjęcie nie zostało wysłane. Popraw zdjęcie i ponów potwierdzenie etapu.",
),
)
assertFalse(shouldRepairRouteAction(RouteActionStatus.FailedPermanent, "Brakuje zdjęcia"))
assertFalse(shouldRepairRouteAction(RouteActionStatus.FailedConflict, "Kurs już rozpoczęty"))
}
}
@@ -0,0 +1,62 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class PhotoUploadReconciliationTest {
@Test
fun serverReceiptIdentityClearsFalseLocalFailure() {
val upload = upload(
clientRequestId = "request-1",
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
assertTrue(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", upload.contentSha256.uppercase())))
assertFalse(serverPhotoConfirmsUpload(upload, serverPhoto("request-2", upload.contentSha256)))
assertFalse(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")))
}
@Test
fun legacyServerPhotoWithoutHashStillConfirmsMatchingRequestId() {
val upload = upload("request-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
assertTrue(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", null)))
}
private fun upload(clientRequestId: String, contentSha256: String): PhotoUploadEntity =
PhotoUploadEntity(
clientRequestId = clientRequestId,
routeId = "10",
localPath = "/tmp/$clientRequestId.jpg",
source = "camera",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
mimeType = "image/jpeg",
size = 100,
contentSha256 = contentSha256,
status = PhotoUploadStatus.FailedPermanent.storageValue,
lastError = "Typ: błąd aplikacji\nKomunikat: Job was cancelled",
)
private fun serverPhoto(clientRequestId: String, contentSha256: String?): RoutePhotoDto =
RoutePhotoDto(
id = "42",
routeId = "10",
clientRequestId = clientRequestId,
contentSha256 = contentSha256,
source = "camera",
stage = "loading",
mimeType = "image/jpeg",
size = 100,
url = "https://example.test/photo/42",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
createdAt = null,
)
}
@@ -16,7 +16,9 @@ class RouteDisplayMapperTest {
contractName = "Odpady przemyslowe",
contractCode = "TPP-ODP-24",
relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask",
status = "ZAPLANOWANA",
status = "ZAKOŃCZONA",
driverStatus = "ZAPLANOWANA",
operationalStatus = "ZAKOŃCZONA",
distanceKm = 91.4,
notes = null,
truck = null,
@@ -29,6 +31,7 @@ class RouteDisplayMapperTest {
assertEquals("Instalacja Odpadowa Slask", display.destination)
assertEquals("TPP-ODP-24", display.contractLabel)
assertEquals("09:15", display.time)
assertEquals("ZAPLANOWANA", display.status)
assertEquals("91 km", display.distanceLabel)
}
}
@@ -0,0 +1,32 @@
package pl.firmatpp.kierowca.domain
import org.junit.Assert.assertEquals
import org.junit.Test
class StartupSessionPolicyTest {
@Test
fun rotatesStartupMessagesAsWaitingTimeIncreases() {
assertEquals("Sprawdzamy zapisaną sesję", StartupSessionPolicy.message(0).title)
assertEquals("Potwierdzamy logowanie", StartupSessionPolicy.message(3).title)
assertEquals("Pobieramy aktualne kursy", StartupSessionPolicy.message(6).title)
assertEquals("To trwa dłużej niż zwykle", StartupSessionPolicy.message(9).title)
assertEquals("Przygotowujemy tryb offline", StartupSessionPolicy.message(12).title)
}
@Test
fun countsDownToOfflineFallbackWithoutGoingBelowZero() {
assertEquals(15, StartupSessionPolicy.remainingSeconds(0))
assertEquals(7, StartupSessionPolicy.remainingSeconds(8))
assertEquals(0, StartupSessionPolicy.remainingSeconds(15))
assertEquals(0, StartupSessionPolicy.remainingSeconds(30))
assertEquals("Tryb offline najpóźniej za 5 s", StartupSessionPolicy.countdownLabel(10))
assertEquals("Otwieramy zapisane dane offline", StartupSessionPolicy.countdownLabel(15))
}
@Test
fun showsManualOfflineButtonAfterFourSecondsOnlyWhenCacheExists() {
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(3, offlineDataAvailable = true))
assertEquals(true, StartupSessionPolicy.shouldShowOfflineButton(4, offlineDataAvailable = true))
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(10, offlineDataAvailable = false))
}
}
@@ -23,8 +23,26 @@ class DriverLiveSyncClientTest {
reverbEnabled = true,
reverbAppKey = "app-key",
reverbWsBaseUrl = "wss://example.test/app",
reverbOrigin = "https://bootstrap-origin.test",
)
@Test
fun derivesBrowserCompatibleOriginFromWebSocketUrl() {
assertEquals("https://api-intranet.firmatpp.pl", websocketOrigin("wss://api-intranet.firmatpp.pl/ws/app/app-key"))
assertEquals("https://example.test:8443", websocketOrigin("wss://example.test:8443/app/app-key"))
assertEquals("http://localhost:8080", websocketOrigin("ws://localhost:8080/app/app-key"))
}
@Test
fun passesBootstrapOriginToWebSocketFactory() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
assertEquals("https://bootstrap-origin.test", factory.origins.single())
}
@Test
fun reconnectsWithBackoffAfterSocketFailure() = runTest {
val factory = FakeWebSocketFactory()
@@ -204,10 +222,12 @@ class DriverLiveSyncClientTest {
private class FakeWebSocketFactory : LiveWebSocketFactory {
val sockets = mutableListOf<FakeWebSocket>()
val origins = mutableListOf<String?>()
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket {
override fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket {
val socket = FakeWebSocket(listener)
sockets += socket
origins += origin
return socket
}
}
@@ -82,6 +82,108 @@ class DriverUiRulesTest {
assertEquals("", routeDateChipLabel("", today))
}
@Test
fun buildsNavigationCandidatesFromGeofenceCoordinatesWithFallbacks() {
val candidates = navigationIntentCandidates(
latitude = 50.0619474,
longitude = 19.9368564,
label = "Brama główna",
)
assertEquals(3, candidates.size)
assertEquals("com.google.android.apps.maps", candidates[0].packageName)
assertEquals("google.navigation:q=50.0619474,19.9368564", candidates[0].uri)
assertEquals(null, candidates[1].packageName)
assertEquals(
"geo:0,0?q=50.0619474,19.9368564(Brama%20g%C5%82%C3%B3wna)",
candidates[1].uri,
)
assertEquals(
"https://www.google.com/maps/dir/?api=1&destination=50.0619474,19.9368564",
candidates[2].uri,
)
}
@Test
fun doesNotBuildNavigationCandidatesForMissingOrInvalidCoordinates() {
assertFalse(hasValidNavigationCoordinates(null, 19.9368564))
assertFalse(hasValidNavigationCoordinates(50.0619474, null))
assertFalse(hasValidNavigationCoordinates(Double.NaN, 19.9368564))
assertFalse(hasValidNavigationCoordinates(91.0, 19.9368564))
assertFalse(hasValidNavigationCoordinates(50.0619474, 181.0))
assertTrue(hasValidNavigationCoordinates(50.0619474, 19.9368564))
assertTrue(navigationIntentCandidates(null, null, "Cel").isEmpty())
}
@Test
fun showsAnOptionalUpdateWhenGooglePlayOrBackendHasANewerVersion() {
val fromPlay = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = null,
latestVersionName = null,
minimumSupportedVersionCode = 0,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = false,
)
val fromBackend = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 0,
playUpdateAvailable = false,
playAvailableVersionCode = null,
updateDownloaded = false,
)
assertEquals(false, fromPlay?.required)
assertEquals("Aktualizuj", fromPlay?.actionLabel)
assertEquals("Dostępna jest nowa wersja aplikacji 1.0.61.", fromBackend?.message)
}
@Test
fun requiresAnUpdateOnlyBelowTheConfiguredMinimumVersion() {
val required = appUpdateBannerUi(
currentVersionCode = 111,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 112,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = false,
)
val current = appUpdateBannerUi(
currentVersionCode = 114,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 112,
playUpdateAvailable = false,
playAvailableVersionCode = null,
updateDownloaded = false,
)
assertEquals(true, required?.required)
assertTrue(required?.message?.contains("nie jest już obsługiwana") == true)
assertEquals(null, current)
}
@Test
fun downloadedUpdateUsesInstallAction() {
val update = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 0,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = true,
)
assertEquals("Zainstaluj", update?.actionLabel)
assertEquals(true, update?.downloaded)
assertTrue(update?.message?.contains("gotowa do instalacji") == true)
}
@Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0))
@@ -205,8 +307,9 @@ class DriverUiRulesTest {
}
@Test
fun allowsRetryActionOnlyForRetryableFailedUploads() {
fun allowsRetryForRetryableAndLegacyCancelledUploads() {
assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE"))
assertTrue(canRetryPhotoUpload("FAILED_PERMANENT", "Typ: błąd aplikacji\nKomunikat: Job was cancelled"))
assertFalse(canRetryPhotoUpload("PENDING"))
assertFalse(canRetryPhotoUpload("UPLOADING"))
assertFalse(canRetryPhotoUpload("VERIFYING"))
@@ -215,6 +318,37 @@ class DriverUiRulesTest {
assertFalse(canRetryPhotoUpload("CANCELLED"))
}
@Test
fun permanentUploadDoesNotSatisfyRouteStageAndCanBeDiscarded() {
val failed = upload(status = "FAILED_PERMANENT")
val pending = upload(status = "PENDING").copy(clientRequestId = "pending-upload")
assertEquals(listOf("pending-upload"), routeStageUsableUploads(listOf(failed, pending)).map { it.clientRequestId })
assertFalse(photoUploadCanSatisfyStage("FAILED_PERMANENT"))
assertTrue(canDiscardLocalPhotoUpload("FAILED_PERMANENT", null))
assertFalse(canDiscardLocalPhotoUpload("CONFIRMED", "42"))
}
@Test
fun serverRefreshLeavesObsoleteRouteStageScreen() {
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.StartRoute, route(status = "W TRAKCIE")),
)
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.LoadingWeight, route(status = "ZAKOŃCZONA")),
)
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.FinishRoute, route(status = "ZAKOŃCZONA")),
)
assertEquals(
DriverScreen.StartRoute,
routeScreenAfterServerRefresh(DriverScreen.StartRoute, route(status = "ZAPLANOWANA")),
)
}
@Test
fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() {
assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera"))
@@ -306,11 +440,20 @@ class DriverUiRulesTest {
fun routeStageFormRequiresPositiveWeightAndAtLeastOnePhotoOrQueuedUpload() {
assertTrue(canSubmitRouteStageForm(weightText = "12,5", serverPhotoCount = 1, localUploadCount = 0))
assertTrue(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 1))
assertTrue(canSubmitRouteStageForm(weightText = "23400", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "0", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 0))
}
@Test
fun parsesRouteWeightsEnteredInTonsOrKilograms() {
assertEquals(23.4, parseRouteWeightTons("23,4") ?: 0.0, 0.0)
assertEquals(23.4, parseRouteWeightTons("23400") ?: 0.0, 0.0)
assertEquals(23.401, parseRouteWeightTons("23400,5") ?: 0.0, 0.0)
assertEquals("Wpisano kilogramy — zapiszemy 23,400 t.", routeWeightInputHelpText("23400"))
}
@Test
fun filtersRoutePhotosByStageWithOtherFallback() {
val photos = listOf(
@@ -356,18 +499,62 @@ class DriverUiRulesTest {
assertEquals(RouteLifecycleAction.Start, action?.action)
}
@Test
fun driverFlowIgnoresOperationalStatusFromGeofence() {
val route = route(
status = "ZAKOŃCZONA",
driverStatus = "ZAPLANOWANA",
operationalStatus = "ZAKOŃCZONA",
)
val steps = routeFlowSteps(route, emptyList())
assertTrue(canStartRouteFromDriverApp(route, "2026-06-30", today))
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(DriverScreen.StartRoute, routeScreenAfterServerRefresh(DriverScreen.StartRoute, route))
}
@Test
fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() {
val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading")))
val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.LocalComplete, steps[0].state)
assertEquals(RouteFlowStepState.ServerSaved, steps[0].state)
assertEquals("zapisane na serwerze", steps[0].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
}
@Test
fun freshInstallLabelsBackendLoadingDocumentationAsServerData() {
val route = route(
status = "ZAPLANOWANA",
loadingWeight = 23.76,
photos = listOf(photo("loading-1", stage = "loading")),
)
val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.ServerSaved, steps[0].state)
assertEquals("zapisane na serwerze", steps[0].stateLabel)
assertEquals(RouteFlowStepState.ServerSaved, steps[1].state)
assertEquals("zapisane na serwerze", steps[1].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
}
@Test
fun serverWeightDoesNotMarkMissingLoadingPhotoAsComplete() {
val steps = routeFlowSteps(route(status = "ZAPLANOWANA", loadingWeight = 23.76), emptyList())
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.ServerSaved, steps[1].state)
}
@Test
fun activeRouteShowsTransitStateAndFinishAction() {
val route = route(status = "W TRAKCIE", loadingWeight = 12.5)
@@ -377,7 +564,8 @@ class DriverUiRulesTest {
assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.LocalComplete, steps[2].state)
assertEquals(RouteFlowStepState.InProgress, steps[2].state)
assertEquals("w trakcie", steps[2].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Rozładuj", action?.label)
assertEquals(RouteLifecycleAction.Finish, action?.action)
@@ -518,6 +706,8 @@ class DriverUiRulesTest {
private fun route(
status: String = "ZAPLANOWANA",
driverStatus: String? = null,
operationalStatus: String? = null,
loadingWeight: Double? = null,
unloadingWeight: Double? = null,
photos: List<RoutePhotoDto> = emptyList(),
@@ -536,6 +726,8 @@ class DriverUiRulesTest {
contractCode = "TPP-1",
relationLabel = "Baza -> Instalacja",
status = status,
driverStatus = driverStatus,
operationalStatus = operationalStatus,
loadingWeight = loadingWeight,
unloadingWeight = unloadingWeight,
distanceKm = 1.0,
+3
View File
@@ -17,6 +17,7 @@ room = "2.7.0"
ksp = "2.1.10-1.0.31"
playServicesAuth = "21.6.0"
playServicesAuthApiPhone = "18.3.0"
playAppUpdate = "2.1.0"
junit = "4.13.2"
firebaseBom = "33.7.0"
googleServices = "4.4.2"
@@ -52,6 +53,8 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" }
play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" }
play-app-update = { group = "com.google.android.play", name = "app-update", version.ref = "playAppUpdate" }
play-app-update-ktx = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "playAppUpdate" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
+456 -9
View File
@@ -7,11 +7,17 @@ VERSION_FILE="$ROOT_DIR/app/build.gradle.kts"
REMOTE_NAME="${GIT_REMOTE:-origin}"
NOTES_FILE=""
SKIP_TESTS=0
GOOGLE_PLAY_MODE="ask"
REUSE_CURRENT=0
VERSION_FILE_CHANGED=0
VERSION_COMMITTED=0
VERSION_BACKUP=""
RELEASE_SUCCEEDED=0
GOOGLE_PLAY_ACCESS_TOKEN=""
GOOGLE_PLAY_EDIT_ID=""
GOOGLE_PLAY_TEMP_DIR=""
GOOGLE_PLAY_CURRENT_VERSION_STATE=""
GOOGLE_PLAY_SUMMARY="pominięto"
usage() {
cat <<'EOF'
@@ -21,13 +27,17 @@ Buduje i publikuje kolejną wersję aplikacji kierowcy:
- automatycznie zwiększa versionCode o 1 i patch versionName o 1,
- uruchamia testy jednostkowe,
- buduje podpisany APK dla Gitea Release,
- buduje podpisany AAB dla Google Play Console,
- buduje podpisany AAB i publikuje go w teście zamkniętym Google Play,
- wysyła zmiany Google Play do weryfikacji,
- zapisuje zmianę wersji w commicie, tworzy tag i wykonuje push,
- tworzy lub aktualizuje Gitea Release i wysyła APK oraz SHA256SUMS.
Opcje:
--notes-file PLIK Treść release notes z podanego pliku.
--skip-tests Pomiń testDebugUnitTest.
--skip-google-play Nie pytaj i pomiń wysyłanie AAB do Google Play.
--publish-google-play
Nie pytaj i wyślij AAB do Google Play.
--reuse-current Nie zwiększaj wersji. Wznów publikację wersji zapisanej
obecnie w app/build.gradle.kts.
-h, --help Pokaż pomoc.
@@ -37,9 +47,19 @@ Zmienne środowiskowe:
GITEA_BASE_URL Nadpisuje adres Gitea, np. https://gitea.example.com.
GITEA_REPOSITORY Nadpisuje owner/repository.
GIT_REMOTE Remote Git, domyślnie origin.
GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS
Opcjonalne nadpisanie automatycznie znalezionego klucza JSON.
Domyślnie skrypt znajduje konto serwisowe w scripts/*.json.
Alternatywnie: GOOGLE_APPLICATION_CREDENTIALS.
GOOGLE_PLAY_PACKAGE_NAME
Identyfikator aplikacji, domyślnie pl.firmatpp.kierowca.
GOOGLE_PLAY_TRACK Nazwa testu zamkniętego, domyślnie alpha.
GOOGLE_PLAY_RELEASE_LANGUAGE
Język informacji o wersji, domyślnie pl-PL.
Wersja początkowa jest zawsze odczytywana z app/build.gradle.kts.
Google Play Console otrzymuje plik AAB, ponieważ jest to właściwy format publikacyjny.
Google Play otrzymuje AAB przez Android Publisher API. Konto serwisowe musi mieć
uprawnienie „Publikowanie aplikacji na ścieżkach testowych”.
EOF
}
@@ -54,6 +74,14 @@ while (($# > 0)); do
SKIP_TESTS=1
shift
;;
--skip-google-play)
GOOGLE_PLAY_MODE="skip"
shift
;;
--publish-google-play)
GOOGLE_PLAY_MODE="publish"
shift
;;
--reuse-current)
REUSE_CURRENT=1
shift
@@ -82,6 +110,10 @@ done
[[ -x ./gradlew ]] || { echo "Brak wykonywalnego ./gradlew." >&2; exit 1; }
[[ -f keystore.properties ]] || { echo "Brak keystore.properties wymaganego do podpisania release." >&2; exit 1; }
[[ -f "$VERSION_FILE" ]] || { echo "Brak $VERSION_FILE." >&2; exit 1; }
if [[ -n "$NOTES_FILE" && ! -f "$NOTES_FILE" ]]; then
echo "Nie istnieje plik release notes: $NOTES_FILE" >&2
exit 1
fi
branch="$(git branch --show-current)"
[[ -n "$branch" ]] || { echo "Release nie może być wykonany z detached HEAD." >&2; exit 1; }
@@ -165,11 +197,24 @@ play_aab_name="TPP-Kierowca-${next_name}-${next_code}.aab"
cleanup_on_exit() {
local exit_code=$?
if ((RELEASE_SUCCEEDED == 0)) && [[ -n "$GOOGLE_PLAY_EDIT_ID" && -n "$GOOGLE_PLAY_ACCESS_TOKEN" ]]; then
package_encoded="$(urlencode "$google_play_package")"
edit_encoded="$(urlencode "$GOOGLE_PLAY_EDIT_ID")"
curl -sS -o /dev/null \
--connect-timeout 20 \
--max-time 60 \
-X DELETE \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$package_encoded/edits/$edit_encoded" || true
fi
if ((RELEASE_SUCCEEDED == 0 && VERSION_FILE_CHANGED == 1 && VERSION_COMMITTED == 0)) && [[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]]; then
cp "$VERSION_BACKUP" "$VERSION_FILE"
echo "Przywrócono poprzednią wersję w app/build.gradle.kts." >&2
fi
[[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP"
if [[ -n "$GOOGLE_PLAY_TEMP_DIR" && -d "$GOOGLE_PLAY_TEMP_DIR" ]]; then
rm -rf -- "$GOOGLE_PLAY_TEMP_DIR"
fi
if ((RELEASE_SUCCEEDED == 0)); then
echo "Release nie został ukończony. Po usunięciu przyczyny użyj --reuse-current, jeśli commit wersji już powstał." >&2
fi
@@ -180,6 +225,391 @@ trap cleanup_on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
urlencode() {
jq -rn --arg value "$1" '$value | @uri'
}
base64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
show_google_play_error() {
local response_file="$1"
jq '.error | {code, message, status, details}' "$response_file" >&2 2>/dev/null || true
}
decide_google_play_upload() {
local answer
case "$GOOGLE_PLAY_MODE" in
publish)
return 0
;;
skip)
GOOGLE_PLAY_SUMMARY="pominięto (--skip-google-play)"
return 0
;;
ask)
if [[ ! -t 0 ]]; then
echo "Nie można zapytać o publikację Google Play bez interaktywnego terminala." >&2
echo "Użyj --publish-google-play albo --skip-google-play." >&2
return 1
fi
while true; do
printf "\nBuild %s (%s) zakończony poprawnie. Czy wysłać aktualizację do Google Play? [t/N] " "$next_name" "$next_code"
if ! IFS= read -r answer; then
echo "Nie udało się odczytać odpowiedzi." >&2
return 1
fi
case "${answer,,}" in
t|tak|y|yes)
GOOGLE_PLAY_MODE="publish"
break
;;
""|n|nie|no)
GOOGLE_PLAY_MODE="skip"
GOOGLE_PLAY_SUMMARY="pominięto przez użytkownika"
break
;;
*)
echo "Nie rozpoznano odpowiedzi. Wpisz tak albo nie." >&2
;;
esac
done
;;
*)
echo "Niepoprawny tryb publikacji Google Play: $GOOGLE_PLAY_MODE" >&2
return 1
;;
esac
}
prepare_google_play() {
local credentials_path client_email now expires header payload unsigned_jwt signature assertion previous_umask
local token_response token_status preflight_response preflight_status package_encoded track_encoded
local candidate
local -a credentials_candidates=()
google_play_package="${GOOGLE_PLAY_PACKAGE_NAME:-pl.firmatpp.kierowca}"
google_play_track="${GOOGLE_PLAY_TRACK:-alpha}"
google_play_release_language="${GOOGLE_PLAY_RELEASE_LANGUAGE:-pl-PL}"
credentials_path="${GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS:-${GOOGLE_APPLICATION_CREDENTIALS:-}}"
if [[ -z "$credentials_path" ]]; then
while IFS= read -r -d '' candidate; do
if jq -e '
.type == "service_account"
and (.client_email | type == "string" and length > 0)
and (.private_key | type == "string" and length > 0)
' "$candidate" >/dev/null 2>&1; then
credentials_candidates+=("$candidate")
fi
done < <(find "$ROOT_DIR/scripts" -maxdepth 1 -type f -name '*.json' -print0)
case "${#credentials_candidates[@]}" in
0)
echo "Nie znaleziono klucza konta serwisowego w $ROOT_DIR/scripts/*.json." >&2
echo "Umieść dokładnie jeden poprawny plik JSON konta serwisowego w folderze scripts." >&2
return 1
;;
1)
credentials_path="${credentials_candidates[0]}"
;;
*)
echo "Znaleziono więcej niż jeden klucz konta serwisowego w folderze scripts:" >&2
for candidate in "${credentials_candidates[@]}"; do
printf ' - %s\n' "$(basename "$candidate")" >&2
done
echo "Pozostaw jeden plik albo ustaw GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS." >&2
return 1
;;
esac
fi
command -v openssl >/dev/null 2>&1 || {
echo "Brak wymaganego polecenia: openssl" >&2
return 1
}
[[ "$google_play_package" =~ ^[A-Za-z0-9._]+$ ]] || {
echo "Niepoprawne GOOGLE_PLAY_PACKAGE_NAME: $google_play_package" >&2
return 1
}
[[ -n "$google_play_track" ]] || { echo "GOOGLE_PLAY_TRACK nie może być puste." >&2; return 1; }
[[ "$google_play_release_language" =~ ^[A-Za-z0-9-]+$ ]] || {
echo "Niepoprawne GOOGLE_PLAY_RELEASE_LANGUAGE: $google_play_release_language" >&2
return 1
}
[[ -n "$credentials_path" ]] || {
echo "Nie wskazano ani nie znaleziono klucza konta serwisowego Google Play." >&2
return 1
}
[[ -r "$credentials_path" ]] || {
echo "Nie można odczytać klucza konta serwisowego: $credentials_path" >&2
return 1
}
GOOGLE_PLAY_TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tpp-google-play.XXXXXX")"
chmod 700 "$GOOGLE_PLAY_TEMP_DIR"
client_email="$(jq -er '.client_email | select(type == "string" and length > 0)' "$credentials_path")" || {
echo "Klucz Google Play nie zawiera client_email." >&2
return 1
}
previous_umask="$(umask)"
umask 077
if ! jq -er '.private_key | select(type == "string" and length > 0)' "$credentials_path" > "$GOOGLE_PLAY_TEMP_DIR/private-key.pem"; then
umask "$previous_umask"
echo "Klucz Google Play nie zawiera private_key." >&2
return 1
fi
umask "$previous_umask"
now="$(date +%s)"
expires=$((now + 3600))
header="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | base64url)"
payload="$(jq -cn \
--arg iss "$client_email" \
--arg scope 'https://www.googleapis.com/auth/androidpublisher' \
--arg aud 'https://oauth2.googleapis.com/token' \
--argjson iat "$now" \
--argjson exp "$expires" \
'{iss:$iss,scope:$scope,aud:$aud,iat:$iat,exp:$exp}' | base64url)"
unsigned_jwt="$header.$payload"
signature="$(printf '%s' "$unsigned_jwt" \
| openssl dgst -sha256 -sign "$GOOGLE_PLAY_TEMP_DIR/private-key.pem" \
| base64url)"
assertion="$unsigned_jwt.$signature"
printf '%s' "$assertion" > "$GOOGLE_PLAY_TEMP_DIR/assertion.txt"
chmod 600 "$GOOGLE_PLAY_TEMP_DIR/assertion.txt"
token_response="$GOOGLE_PLAY_TEMP_DIR/token.json"
token_status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$token_response" \
-w '%{http_code}' \
-X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
--data-urlencode "assertion@$GOOGLE_PLAY_TEMP_DIR/assertion.txt" \
'https://oauth2.googleapis.com/token')"
[[ "$token_status" == "200" ]] || {
echo "Nie udało się pobrać tokenu Google Play (HTTP $token_status)." >&2
jq '{error, error_description}' "$token_response" >&2 2>/dev/null || true
return 1
}
GOOGLE_PLAY_ACCESS_TOKEN="$(jq -er '.access_token | select(type == "string" and length > 0)' "$token_response")" || {
echo "Google OAuth nie zwrócił access_token." >&2
return 1
}
printf 'Authorization: Bearer %s\n' "$GOOGLE_PLAY_ACCESS_TOKEN" > "$GOOGLE_PLAY_TEMP_DIR/auth-header.txt"
chmod 600 "$GOOGLE_PLAY_TEMP_DIR/auth-header.txt"
rm -f "$GOOGLE_PLAY_TEMP_DIR/private-key.pem" "$GOOGLE_PLAY_TEMP_DIR/assertion.txt" "$token_response"
package_encoded="$(urlencode "$google_play_package")"
track_encoded="$(urlencode "$google_play_track")"
preflight_response="$GOOGLE_PLAY_TEMP_DIR/preflight.json"
preflight_status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$preflight_response" \
-w '%{http_code}' \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$package_encoded/tracks/$track_encoded/releases")"
[[ "$preflight_status" == "200" ]] || {
echo "Brak dostępu do ścieżki Google Play $google_play_track dla $google_play_package (HTTP $preflight_status)." >&2
show_google_play_error "$preflight_response"
return 1
}
GOOGLE_PLAY_CURRENT_VERSION_STATE="$(jq -r --argjson code "$next_code" '
.releases[]?
| select(any(.activeArtifacts[]?; .versionCode == $code))
| .releaseLifecycleState // empty
' "$preflight_response" | sed -n '1p')"
if [[ -n "$GOOGLE_PLAY_CURRENT_VERSION_STATE" && "$REUSE_CURRENT" == "0" ]]; then
echo "Google Play ma już versionCode $next_code na ścieżce $google_play_track ($GOOGLE_PLAY_CURRENT_VERSION_STATE)." >&2
echo "Zwiększ wersję lokalną albo świadomie użyj --reuse-current." >&2
return 1
fi
if [[ "$GOOGLE_PLAY_CURRENT_VERSION_STATE" == "RELEASE_LIFECYCLE_STATE_NOT_APPROVED" ]]; then
echo "Wersja $next_code została odrzucona w Google Play. Zwiększ versionCode i utwórz nowe wydanie." >&2
return 1
fi
echo "Google Play: używam klucza $(basename "$credentials_path")."
echo "Google Play: zweryfikowano dostęp do $google_play_package, ścieżka $google_play_track."
}
publish_to_google_play() {
local release_notes="$1"
local package_encoded track_encoded edit_encoded api_base
local response status bundle_version_code existing_sha256 local_sha256
local play_notes track_payload release_name
case "$GOOGLE_PLAY_CURRENT_VERSION_STATE" in
RELEASE_LIFECYCLE_STATE_IN_REVIEW|RELEASE_LIFECYCLE_STATE_APPROVED_NOT_PUBLISHED|RELEASE_LIFECYCLE_STATE_PUBLISHED)
GOOGLE_PLAY_SUMMARY="już istnieje: $GOOGLE_PLAY_CURRENT_VERSION_STATE"
echo "Google Play: wersja $next_name ($next_code) ma już stan $GOOGLE_PLAY_CURRENT_VERSION_STATE; pomijam ponowną publikację."
return 0
;;
esac
package_encoded="$(urlencode "$google_play_package")"
track_encoded="$(urlencode "$google_play_track")"
api_base="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$package_encoded"
response="$GOOGLE_PLAY_TEMP_DIR/edit-create.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
-H 'Content-Type: application/json' \
-d '{}' \
"$api_base/edits")"
[[ "$status" == "200" ]] || {
echo "Nie udało się utworzyć edycji Google Play (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
GOOGLE_PLAY_EDIT_ID="$(jq -er '.id | select(type == "string" and length > 0)' "$response")" || {
echo "Google Play nie zwrócił identyfikatora edycji." >&2
return 1
}
edit_encoded="$(urlencode "$GOOGLE_PLAY_EDIT_ID")"
response="$GOOGLE_PLAY_TEMP_DIR/track-get.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded/tracks/$track_encoded")"
[[ "$status" == "200" ]] || {
echo "Nie znaleziono ścieżki Google Play $google_play_track (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
response="$GOOGLE_PLAY_TEMP_DIR/bundles.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded/bundles")"
[[ "$status" == "200" ]] || {
echo "Nie udało się sprawdzić pakietów Google Play (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
existing_sha256="$(jq -r --argjson code "$next_code" '.bundles[]? | select(.versionCode == $code) | .sha256 // empty' "$response" | sed -n '1p')"
local_sha256="$(sha256sum "$artifact_dir/google-play/$play_aab_name" | awk '{print $1}')"
if [[ -n "$existing_sha256" ]]; then
if [[ "${existing_sha256,,}" != "${local_sha256,,}" && "$REUSE_CURRENT" == "0" ]]; then
echo "Google Play ma już versionCode $next_code z inną sumą SHA-256." >&2
return 1
fi
if [[ "${existing_sha256,,}" != "${local_sha256,,}" ]]; then
echo "Uwaga: lokalny AAB ma inną sumę niż istniejący versionCode $next_code; --reuse-current zachowuje pakiet już wysłany do Google Play." >&2
fi
bundle_version_code="$next_code"
echo "Google Play ma już AAB $next_name ($next_code); pomijam ponowny upload."
else
response="$GOOGLE_PLAY_TEMP_DIR/bundle-upload.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 600 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
-H 'Content-Type: application/octet-stream' \
--data-binary "@$artifact_dir/google-play/$play_aab_name" \
"https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/$package_encoded/edits/$edit_encoded/bundles?uploadType=media")"
[[ "$status" == "200" ]] || {
echo "Nie udało się wysłać AAB do Google Play (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
bundle_version_code="$(jq -er '.versionCode' "$response")" || {
echo "Google Play nie zwrócił versionCode przesłanego AAB." >&2
return 1
}
[[ "$bundle_version_code" == "$next_code" ]] || {
echo "Google Play odczytał versionCode $bundle_version_code zamiast $next_code." >&2
return 1
}
fi
play_notes="$(jq -rn --arg notes "$release_notes" '$notes | if length > 500 then .[0:497] + "..." else . end')"
release_name="TPP Kierowca $next_name ($next_code)"
track_payload="$(jq -n \
--arg track "$google_play_track" \
--arg release_name "$release_name" \
--arg version_code "$bundle_version_code" \
--arg language "$google_play_release_language" \
--arg notes "$play_notes" \
'{track:$track,releases:[{name:$release_name,versionCodes:[$version_code],status:"completed",releaseNotes:[{language:$language,text:$notes}]}]}')"
response="$GOOGLE_PLAY_TEMP_DIR/track-update.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-X PUT \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
-H 'Content-Type: application/json' \
-d "$track_payload" \
"$api_base/edits/$edit_encoded/tracks/$track_encoded")"
[[ "$status" == "200" ]] || {
echo "Nie udało się przypisać AAB do ścieżki $google_play_track (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
response="$GOOGLE_PLAY_TEMP_DIR/edit-validate.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 120 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded:validate")"
[[ "$status" == "200" ]] || {
echo "Walidacja edycji Google Play nie powiodła się (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
response="$GOOGLE_PLAY_TEMP_DIR/edit-commit.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 120 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded:commit?changesNotSentForReview=false&changesInReviewBehavior=ERROR_IF_IN_REVIEW")"
[[ "$status" == "200" ]] || {
echo "Nie udało się zatwierdzić i wysłać zmian Google Play do weryfikacji (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
GOOGLE_PLAY_EDIT_ID=""
GOOGLE_PLAY_SUMMARY="wysłano do weryfikacji"
echo "Google Play: $release_name wysłano na ścieżkę $google_play_track i przekazano do weryfikacji."
}
if ((REUSE_CURRENT == 0)); then
if git show-ref --verify --quiet "refs/tags/$tag_name" || git ls-remote --exit-code --tags "$REMOTE_NAME" "refs/tags/$tag_name" >/dev/null 2>&1; then
echo "Tag $tag_name już istnieje. Użyj --reuse-current albo zwiększ wersję ręcznie." >&2
@@ -235,14 +665,18 @@ cp "$aab_source" "$artifact_dir/google-play/$play_aab_name"
sha256sum "gitea/$gitea_apk_name" "google-play/$play_aab_name" > SHA256SUMS.txt
)
decide_google_play_upload
if [[ "$GOOGLE_PLAY_MODE" == "publish" ]]; then
prepare_google_play
fi
if ((REUSE_CURRENT == 0)); then
git add "$VERSION_FILE"
git diff --cached --check
git commit -m "chore: release android driver $next_name ($next_code)"
git commit -m "Wydaj aplikację kierowcy $next_name ($next_code)"
VERSION_COMMITTED=1
fi
git push "$REMOTE_NAME" "$branch"
if ! git show-ref --verify --quiet "refs/tags/$tag_name"; then
git tag -a "$tag_name" -m "TPP Kierowca $next_name ($next_code)"
fi
@@ -252,10 +686,8 @@ head_commit="$(git rev-parse HEAD)"
echo "Tag $tag_name nie wskazuje aktualnego commita ($head_commit)." >&2
exit 1
}
git push "$REMOTE_NAME" "$tag_name"
if [[ -n "$NOTES_FILE" ]]; then
[[ -f "$NOTES_FILE" ]] || { echo "Nie istnieje plik release notes: $NOTES_FILE" >&2; exit 1; }
release_notes="$(<"$NOTES_FILE")"
else
previous_tag="$(git tag --sort=-version:refname | grep -Fxv "$tag_name" | sed -n '1p' || true)"
@@ -264,7 +696,7 @@ else
else
changes="$(git log -20 --format='- %s (%h)')"
fi
release_notes="TPP Kierowca $next_name ($next_code)
release_notes="TPP Kierowca $next_name ($next_code)
Zmiany:
${changes:-'- Wydanie techniczne.'}
@@ -273,6 +705,15 @@ Artefakt Google Play Console (AAB) znajduje się lokalnie w:
release-artifacts/$tag_name/google-play/$play_aab_name"
fi
if [[ "$GOOGLE_PLAY_MODE" == "publish" ]]; then
publish_to_google_play "$release_notes"
GOOGLE_PLAY_ACCESS_TOKEN=""
rm -f "$GOOGLE_PLAY_TEMP_DIR/auth-header.txt"
fi
git push "$REMOTE_NAME" "$branch"
git push "$REMOTE_NAME" "$tag_name"
release_response="$(mktemp)"
release_status="$(curl -sS "${auth_args[@]}" -o "$release_response" -w '%{http_code}' "$api_base/releases/tags/$tag_name")"
if [[ "$release_status" == "200" ]]; then
@@ -348,8 +789,12 @@ published_apk_hash="$(sha256sum "$downloaded_apk" | awk '{print $1}')"
release_url="$gitea_base_url/$gitea_repository/releases/tag/$tag_name"
RELEASE_SUCCEEDED=1
trap - EXIT INT TERM
[[ -n "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP"
if [[ -n "$GOOGLE_PLAY_TEMP_DIR" && -d "$GOOGLE_PLAY_TEMP_DIR" ]]; then
rm -rf -- "$GOOGLE_PLAY_TEMP_DIR"
GOOGLE_PLAY_TEMP_DIR=""
fi
trap - EXIT INT TERM
cat <<EOF
@@ -358,6 +803,8 @@ Release zakończony:
Tag: $tag_name
Gitea: $release_url
APK: $artifact_dir/gitea/$gitea_apk_name
Google Play: $artifact_dir/google-play/$play_aab_name
AAB: $artifact_dir/google-play/$play_aab_name
Google Play: $GOOGLE_PLAY_SUMMARY
Play track: ${google_play_track:-pominięto}
Checksumy: $artifact_dir/SHA256SUMS.txt
EOF