Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
905711d8de | ||
|
|
7c9cb3fb0d | ||
|
|
ae0d1b2d3d | ||
|
|
c5b3c5dae9 | ||
|
|
3fa3b4eac1 | ||
|
|
197bcc6f5e | ||
|
|
f4f5cec2be | ||
|
|
5a630f8401 | ||
|
|
54a3c58a22 | ||
|
|
1c6a044c37 | ||
|
|
fcc022db64 | ||
|
|
a908a2da18 | ||
|
|
2c25647fa5 | ||
|
|
956f33140e | ||
|
|
7bba4dc878 |
@@ -7,3 +7,5 @@ app/google-services.json:Zone.Identifier
|
||||
*.iml
|
||||
keystore.properties
|
||||
*.jks
|
||||
release-artifacts/
|
||||
app/src/main/res_bak/
|
||||
|
||||
@@ -21,3 +21,23 @@ Natywna aplikacja Android Jetpack Compose dla kierowcow TPP.
|
||||
```bash
|
||||
./gradlew :app:testDebugUnitTest
|
||||
```
|
||||
|
||||
## Release Gitea i Google Play
|
||||
|
||||
Przed wydaniem zapisz wszystkie zmiany źródłowe w commicie. Następnie uruchom:
|
||||
|
||||
```bash
|
||||
./scripts/release-android.sh
|
||||
```
|
||||
|
||||
Skrypt automatycznie zwiększa `versionCode` i ostatni człon `versionName`, uruchamia testy,
|
||||
buduje podpisany APK 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>/`.
|
||||
|
||||
Do autoryzacji Gitea używany jest `GITEA_TOKEN` albo dane zapisane w Git credential helper.
|
||||
Jeśli publikacja została przerwana już po utworzeniu commita wersji, można ją wznowić:
|
||||
|
||||
```bash
|
||||
./scripts/release-android.sh --reuse-current
|
||||
```
|
||||
|
||||
@@ -34,8 +34,8 @@ android {
|
||||
applicationId = "pl.firmatpp.kierowca"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 105
|
||||
versionName = "1.0.52"
|
||||
versionCode = 111
|
||||
versionName = "1.0.58"
|
||||
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
package pl.firmatpp.kierowca
|
||||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import pl.firmatpp.kierowca.data.upload.OfflineOutboxManager
|
||||
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
|
||||
|
||||
class DriverApplication : Application() {
|
||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AppDiagnostics.installFirebaseCrashlytics()
|
||||
AppDiagnostics.log("app_started")
|
||||
applicationScope.launch {
|
||||
runCatching { OfflineOutboxManager(this@DriverApplication).repairGpsQueue() }
|
||||
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ data class RealtimeConfigDto(
|
||||
val reverbEnabled: Boolean = false,
|
||||
val reverbAppKey: String? = null,
|
||||
val reverbWsBaseUrl: String? = null,
|
||||
val reverbOrigin: String? = null,
|
||||
)
|
||||
|
||||
data class RoutesBucketDto(
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@ package pl.firmatpp.kierowca.data.upload
|
||||
import android.content.Context
|
||||
import androidx.work.WorkManager
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class OfflineOutboxManager(context: Context) {
|
||||
class OfflineOutboxManager(private val context: Context) {
|
||||
private val database = DriverDatabase.get(context)
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
|
||||
@@ -14,6 +15,27 @@ class OfflineOutboxManager(context: Context) {
|
||||
database.routeActionDao().allUnsent().size +
|
||||
database.routePointDao().allUnsent().size
|
||||
|
||||
suspend fun repairGpsQueue() {
|
||||
val retryableStatuses = setOf(
|
||||
RoutePointStatus.Pending,
|
||||
RoutePointStatus.Syncing,
|
||||
RoutePointStatus.FailedRetryable,
|
||||
)
|
||||
val routeIds = database.routePointDao().allUnsent()
|
||||
.filter { it.status in retryableStatuses }
|
||||
.map { it.routeId }
|
||||
.distinct()
|
||||
|
||||
routeIds.forEach { routeId ->
|
||||
runCatching {
|
||||
workManager.cancelUniqueWork(RoutePointWorker.uniqueWorkName(routeId))
|
||||
.result
|
||||
.get(5, TimeUnit.SECONDS)
|
||||
}
|
||||
RoutePointOutbox(context).enqueueWorker(routeId, delaySeconds = 0L)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
val photos = database.photoUploadDao().allRecords()
|
||||
val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
|
||||
|
||||
@@ -37,6 +37,9 @@ interface RoutePointDao {
|
||||
@Query("SELECT COUNT(*) FROM route_points WHERE status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')")
|
||||
fun observeUnsentCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT MIN(createdAtEpochMillis) FROM route_points WHERE status IN ('PENDING', 'SYNCING', 'FAILED_RETRYABLE')")
|
||||
fun observeOldestUnsentAt(): Flow<Long?>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE route_points
|
||||
|
||||
@@ -54,7 +54,7 @@ class RoutePointOutbox(
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
RoutePointWorker.uniqueWorkName(routeId),
|
||||
ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.delay
|
||||
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
||||
import pl.firmatpp.kierowca.data.DriverRepository
|
||||
@@ -23,31 +24,34 @@ class RoutePointWorker(
|
||||
override suspend fun doWork(): Result {
|
||||
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
|
||||
val routeId = inputData.getString(KEY_ROUTE_ID) ?: return Result.failure()
|
||||
val points = dao.pendingForRoute(routeId, BATCH_LIMIT)
|
||||
if (points.isEmpty()) return Result.success()
|
||||
while (!isStopped) {
|
||||
val points = dao.pendingForRoute(routeId, BATCH_LIMIT)
|
||||
if (points.isEmpty()) return Result.success()
|
||||
|
||||
val pointIds = points.map { it.clientPointId }
|
||||
dao.updateStatus(pointIds, RoutePointStatus.Syncing, null, attemptIncrement = 1)
|
||||
val pointIds = points.map { it.clientPointId }
|
||||
dao.updateStatus(pointIds, RoutePointStatus.Syncing, null, attemptIncrement = 1)
|
||||
|
||||
return runCatching {
|
||||
val response = repository.storeRoutePoints(
|
||||
routeId = routeId,
|
||||
body = RoutePointBatchBody(
|
||||
clientBatchId = UUID.randomUUID().toString(),
|
||||
points = points.map { it.toDto() },
|
||||
),
|
||||
)
|
||||
dao.delete(pointIds)
|
||||
|
||||
if (response.arrived) {
|
||||
ActiveRouteTrackingService.markArrived(applicationContext, routeId)
|
||||
val outcome = runCatching {
|
||||
repository.storeRoutePoints(
|
||||
routeId = routeId,
|
||||
body = RoutePointBatchBody(
|
||||
clientBatchId = UUID.randomUUID().toString(),
|
||||
points = points.map { it.toDto() },
|
||||
),
|
||||
)
|
||||
}
|
||||
if (dao.pendingCount(routeId) > 0) {
|
||||
RoutePointOutbox(applicationContext).enqueueWorker(routeId, delaySeconds = 1L)
|
||||
val response = outcome.getOrNull()
|
||||
if (response != null) {
|
||||
dao.delete(pointIds)
|
||||
if (response.arrived) ActiveRouteTrackingService.markArrived(applicationContext, routeId)
|
||||
if (dao.pendingCount(routeId) == 0) {
|
||||
delay(750L)
|
||||
if (dao.pendingCount(routeId) == 0) return Result.success()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
Result.success()
|
||||
}.getOrElse { throwable ->
|
||||
val throwable = outcome.exceptionOrNull() ?: return Result.retry()
|
||||
val error = ApiErrorMapper.map(throwable)
|
||||
AppDiagnostics.reportNonFatal(
|
||||
throwable = throwable,
|
||||
@@ -61,11 +65,18 @@ class RoutePointWorker(
|
||||
"retryable" to error.retryable,
|
||||
),
|
||||
)
|
||||
if (isInactiveRoutePointError(error.code)) {
|
||||
dao.delete(pointIds)
|
||||
ActiveRouteTrackingService.stop(applicationContext, routeId)
|
||||
continue
|
||||
}
|
||||
|
||||
val status = if (error.retryable) RoutePointStatus.FailedRetryable else RoutePointStatus.FailedPermanent
|
||||
dao.updateStatus(pointIds, status, error.message, attemptIncrement = 0)
|
||||
|
||||
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
|
||||
return if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
|
||||
}
|
||||
|
||||
return Result.retry()
|
||||
}
|
||||
|
||||
private fun RoutePointEntity.toDto(): RoutePointDto =
|
||||
@@ -90,3 +101,5 @@ class RoutePointWorker(
|
||||
fun uniqueWorkName(routeId: String): String = "route-point-sync-$routeId"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -56,6 +56,10 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
|
||||
if (!requestedRouteId.isNullOrBlank() && routeId != requestedRouteId) {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
@@ -64,13 +68,25 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
||||
if (!id.isNullOrBlank() && (routeId == null || id == routeId)) {
|
||||
routeId = id
|
||||
arrived = true
|
||||
runCatching { locationManager.removeUpdates(this) }
|
||||
startForegroundCompat(buildNotification())
|
||||
}
|
||||
return START_STICKY
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
else -> {
|
||||
routeId = intent?.getStringExtra(EXTRA_ROUTE_ID) ?: routeId
|
||||
driverId = intent?.getStringExtra(EXTRA_DRIVER_ID) ?: driverId
|
||||
val nextRouteId = intent?.getStringExtra(EXTRA_ROUTE_ID) ?: routeId
|
||||
val nextDriverId = intent?.getStringExtra(EXTRA_DRIVER_ID) ?: driverId
|
||||
if (nextRouteId.isNullOrBlank() || nextDriverId.isNullOrBlank()) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
val routeChanged = nextRouteId != routeId
|
||||
routeId = nextRouteId
|
||||
driverId = nextDriverId
|
||||
getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(ACTIVE_ROUTE_ID, nextRouteId)
|
||||
.apply()
|
||||
routeLabel = intent?.getStringExtra(EXTRA_ROUTE_LABEL) ?: routeLabel
|
||||
destinationLat = intent?.takeIf { it.hasExtra(EXTRA_DESTINATION_LAT) }?.getDoubleExtra(EXTRA_DESTINATION_LAT, 0.0)
|
||||
?: destinationLat
|
||||
@@ -80,10 +96,10 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
||||
?.takeIf { it > 0.0 }
|
||||
?: totalDistanceMeters
|
||||
progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled
|
||||
arrived = false
|
||||
if (routeChanged) arrived = false
|
||||
startForegroundCompat(buildNotification())
|
||||
requestLocationUpdates()
|
||||
return START_STICKY
|
||||
if (!arrived) requestLocationUpdates()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +112,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
||||
|
||||
if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) {
|
||||
arrived = true
|
||||
runCatching { locationManager.removeUpdates(this) }
|
||||
startForegroundCompat(buildNotification())
|
||||
} else {
|
||||
startForegroundCompat(buildNotification())
|
||||
@@ -119,14 +136,19 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
||||
|
||||
override fun onDestroy() {
|
||||
runCatching { locationManager.removeUpdates(this) }
|
||||
getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(ACTIVE_ROUTE_ID)
|
||||
.apply()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun requestLocationUpdates() {
|
||||
if (!hasLocationPermission()) {
|
||||
if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) {
|
||||
return
|
||||
}
|
||||
runCatching { locationManager.removeUpdates(this) }
|
||||
runCatching {
|
||||
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, LOCATION_INTERVAL_MS, 0f, this)
|
||||
}
|
||||
@@ -266,6 +288,8 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
||||
private const val EXTRA_DESTINATION_LON = "destinationLon"
|
||||
private const val EXTRA_TOTAL_DISTANCE_METERS = "totalDistanceMeters"
|
||||
private const val EXTRA_PROGRESS_ENABLED = "progressEnabled"
|
||||
private const val TRACKING_PREFERENCES = "active_route_tracking"
|
||||
private const val ACTIVE_ROUTE_ID = "active_route_id"
|
||||
|
||||
fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) {
|
||||
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
|
||||
@@ -281,12 +305,16 @@ class ActiveRouteTrackingService : Service(), LocationListener {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.startService(
|
||||
Intent(context, ActiveRouteTrackingService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
},
|
||||
)
|
||||
fun stop(context: Context, routeId: String? = null) {
|
||||
val activeRouteId = context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
|
||||
.getString(ACTIVE_ROUTE_ID, null)
|
||||
if (routeId == null || routeId == activeRouteId) {
|
||||
context.stopService(Intent(context, ActiveRouteTrackingService::class.java))
|
||||
context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(ACTIVE_ROUTE_ID)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun markArrived(context: Context, routeId: String) {
|
||||
|
||||
@@ -12,9 +12,12 @@ import android.location.LocationManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
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
|
||||
@@ -115,6 +118,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
|
||||
@@ -159,6 +163,7 @@ 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
|
||||
|
||||
@@ -228,10 +233,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(
|
||||
@@ -376,7 +400,21 @@ fun DriverApp(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartupScreen() {
|
||||
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 +439,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2238,13 +2363,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))
|
||||
@@ -2725,16 +2855,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),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package pl.firmatpp.kierowca.ui
|
||||
|
||||
import java.io.File
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
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
|
||||
@@ -15,6 +19,8 @@ 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
|
||||
|
||||
enum class RouteFlowStepState {
|
||||
Todo,
|
||||
@@ -150,6 +156,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 +219,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 +240,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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ 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.DriverDto
|
||||
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
|
||||
@@ -61,6 +62,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 = "",
|
||||
@@ -118,6 +120,7 @@ data class DriverUiState(
|
||||
val pendingLogoutItems: Int = 0,
|
||||
val pendingDispatchUploads: Int = 0,
|
||||
val pendingGpsPoints: Int = 0,
|
||||
val oldestPendingGpsAtEpochMillis: Long? = null,
|
||||
val diagnostics: DiagnosticSnapshot? = null,
|
||||
val diagnosticsLoading: Boolean = false,
|
||||
val diagnosticsError: String? = null,
|
||||
@@ -138,13 +141,20 @@ data class DriverUiState(
|
||||
val displaySelectedRoute: DriverRouteDto?
|
||||
get() = selectedRouteProjection?.route
|
||||
|
||||
val pendingOperationalItems: Int
|
||||
private val pendingUserOperationalItems: Int
|
||||
get() = queuedPhotoUploads.count { it.status != PhotoUploadStatus.Confirmed.storageValue && it.status != PhotoUploadStatus.Cancelled.storageValue } +
|
||||
visibleRouteActions.count { it.status != RouteActionStatus.Confirmed } + pendingDispatchUploads + pendingGpsPoints
|
||||
visibleRouteActions.count { it.status != RouteActionStatus.Confirmed } + pendingDispatchUploads
|
||||
|
||||
val gpsQueueNeedsAttention: Boolean
|
||||
get() = isOnline && oldestPendingGpsAtEpochMillis?.let { System.currentTimeMillis() - it >= 2 * 60 * 1000L } == true
|
||||
|
||||
val pendingOperationalItems: Int
|
||||
get() = pendingUserOperationalItems + if (!isOnline || serverConnectionState == ServerConnectionState.Degraded || gpsQueueNeedsAttention) pendingGpsPoints else 0
|
||||
|
||||
val operationalQueueNeedsAttention: Boolean
|
||||
get() = queuedPhotoUploads.any { it.status == PhotoUploadStatus.FailedPermanent.storageValue } ||
|
||||
visibleRouteActions.any { it.status == RouteActionStatus.FailedPermanent || it.status == RouteActionStatus.FailedConflict }
|
||||
visibleRouteActions.any { it.status == RouteActionStatus.FailedPermanent || it.status == RouteActionStatus.FailedConflict } ||
|
||||
gpsQueueNeedsAttention
|
||||
}
|
||||
|
||||
internal fun DriverUiState.withApiError(throwable: Throwable): DriverUiState {
|
||||
@@ -180,6 +190,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)
|
||||
@@ -207,6 +218,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 {
|
||||
@@ -240,6 +252,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
_state.update { it.copy(pendingGpsPoints = count) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
DriverDatabase.get(application).routePointDao().observeOldestUnsentAt().collect { oldest ->
|
||||
_state.update { it.copy(oldestPendingGpsAtEpochMillis = oldest) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
liveSyncClient.connectionState.collect { connectionState ->
|
||||
DriverRuntimeSyncState.realtimeConnected = connectionState == LiveSyncConnectionState.Connected
|
||||
@@ -282,6 +299,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) }
|
||||
@@ -325,6 +344,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)
|
||||
@@ -352,17 +385,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
|
||||
@@ -405,6 +452,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId)
|
||||
}
|
||||
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
|
||||
if (_state.value.isOnline && !cached.stale && settings?.selectedDate == settings?.today) {
|
||||
reconcileTrackingService(response.routes.today, response.session.driver)
|
||||
}
|
||||
}.onFailure { throwable ->
|
||||
if (generation != routesRequestGeneration) return@onFailure
|
||||
reportHandledException("load_routes", throwable, mapOf("date" to date))
|
||||
@@ -457,6 +507,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
observePhotoUploads(routeId)
|
||||
observeRouteActions(routeId)
|
||||
if (_state.value.isOnline && !cached.stale) {
|
||||
reconcileTrackingServiceForRoute(response.route, _state.value.driver)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSelectedRoute() {
|
||||
@@ -484,6 +537,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
if (_state.value.isOnline && !cached.stale) {
|
||||
reconcileTrackingServiceForRoute(response.route, _state.value.driver)
|
||||
}
|
||||
}
|
||||
.onFailure { throwable ->
|
||||
if (generation == routeDetailRequestGeneration) _state.update { it.withApiError(throwable) }
|
||||
@@ -617,6 +673,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 {
|
||||
@@ -1063,7 +1137,7 @@ 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
|
||||
}
|
||||
@@ -1389,6 +1463,35 @@ 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" }
|
||||
if (activeRoute == null) {
|
||||
ActiveRouteTrackingService.stop(getApplication())
|
||||
} else {
|
||||
ActiveRouteTrackingService.start(
|
||||
context = getApplication(),
|
||||
route = activeRoute,
|
||||
driver = driver,
|
||||
progressEnabled = _state.value.routeProgressNotificationEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) {
|
||||
if (route.status == "W TRAKCIE" || route.trackingStatus == "active") {
|
||||
if (driver != null) {
|
||||
ActiveRouteTrackingService.start(
|
||||
context = getApplication(),
|
||||
route = route,
|
||||
driver = driver,
|
||||
progressEnabled = _state.value.routeProgressNotificationEnabled,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ActiveRouteTrackingService.stop(getApplication(), route.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeRouteActions(routeId: String) {
|
||||
routeActionsJob?.cancel()
|
||||
routeActionsJob = viewModelScope.launch {
|
||||
|
||||
@@ -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,18 @@
|
||||
package pl.firmatpp.kierowca.data.upload
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RoutePointWorkerRulesTest {
|
||||
@Test
|
||||
fun inactiveRouteConflictDiscardsObsoleteGpsPoints() {
|
||||
assertTrue(isInactiveRoutePointError("ROUTE_POINTS_INVALID_STATUS"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun temporaryServerFailureDoesNotDiscardGpsPoints() {
|
||||
assertFalse(isInactiveRoutePointError("SERVER_UNAVAILABLE"))
|
||||
assertFalse(isInactiveRoutePointError(null))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,11 +306,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(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package pl.firmatpp.kierowca.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
@@ -130,6 +131,44 @@ class DriverUiStateTest {
|
||||
assertEquals(listOf("above-first", "above-second", "span"), state.displayRoutes.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshGpsPointsDoNotShowSynchronizationBannerWhenOnlineIsHealthy() {
|
||||
val state = DriverUiState(
|
||||
isOnline = true,
|
||||
serverConnectionState = ServerConnectionState.Reachable,
|
||||
pendingGpsPoints = 48,
|
||||
oldestPendingGpsAtEpochMillis = System.currentTimeMillis() - 30_000L,
|
||||
)
|
||||
|
||||
assertEquals(0, state.pendingOperationalItems)
|
||||
assertFalse(state.operationalQueueNeedsAttention)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleGpsBacklogRequiresAttentionWhenOnline() {
|
||||
val state = DriverUiState(
|
||||
isOnline = true,
|
||||
serverConnectionState = ServerConnectionState.Reachable,
|
||||
pendingGpsPoints = 48,
|
||||
oldestPendingGpsAtEpochMillis = System.currentTimeMillis() - 3 * 60_000L,
|
||||
)
|
||||
|
||||
assertEquals(48, state.pendingOperationalItems)
|
||||
assertTrue(state.operationalQueueNeedsAttention)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineGpsPointsAreShownAsSavedLocallyNotAsFailure() {
|
||||
val state = DriverUiState(
|
||||
isOnline = false,
|
||||
pendingGpsPoints = 48,
|
||||
oldestPendingGpsAtEpochMillis = System.currentTimeMillis() - 3 * 60_000L,
|
||||
)
|
||||
|
||||
assertEquals(48, state.pendingOperationalItems)
|
||||
assertFalse(state.operationalQueueNeedsAttention)
|
||||
}
|
||||
|
||||
private fun leaveRequest(id: String, status: String, version: Long = 1): DriverLeaveRequestDto =
|
||||
DriverLeaveRequestDto(
|
||||
id = id,
|
||||
|
||||
Executable
+363
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VERSION_FILE="$ROOT_DIR/app/build.gradle.kts"
|
||||
REMOTE_NAME="${GIT_REMOTE:-origin}"
|
||||
NOTES_FILE=""
|
||||
SKIP_TESTS=0
|
||||
REUSE_CURRENT=0
|
||||
VERSION_FILE_CHANGED=0
|
||||
VERSION_COMMITTED=0
|
||||
VERSION_BACKUP=""
|
||||
RELEASE_SUCCEEDED=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Użycie: scripts/release-android.sh [opcje]
|
||||
|
||||
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,
|
||||
- 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.
|
||||
--reuse-current Nie zwiększaj wersji. Wznów publikację wersji zapisanej
|
||||
obecnie w app/build.gradle.kts.
|
||||
-h, --help Pokaż pomoc.
|
||||
|
||||
Zmienne środowiskowe:
|
||||
GITEA_TOKEN Token API Gitea. Jeśli go brak, używany jest git credential helper.
|
||||
GITEA_BASE_URL Nadpisuje adres Gitea, np. https://gitea.example.com.
|
||||
GITEA_REPOSITORY Nadpisuje owner/repository.
|
||||
GIT_REMOTE Remote Git, domyślnie origin.
|
||||
|
||||
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.
|
||||
EOF
|
||||
}
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--notes-file)
|
||||
NOTES_FILE="${2:-}"
|
||||
[[ -n "$NOTES_FILE" ]] || { echo "Brak wartości dla --notes-file." >&2; exit 2; }
|
||||
shift 2
|
||||
;;
|
||||
--skip-tests)
|
||||
SKIP_TESTS=1
|
||||
shift
|
||||
;;
|
||||
--reuse-current)
|
||||
REUSE_CURRENT=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Nieznana opcja: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
for command in git curl jq perl sha256sum jarsigner; do
|
||||
command -v "$command" >/dev/null 2>&1 || {
|
||||
echo "Brak wymaganego polecenia: $command" >&2
|
||||
exit 1
|
||||
}
|
||||
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; }
|
||||
|
||||
branch="$(git branch --show-current)"
|
||||
[[ -n "$branch" ]] || { echo "Release nie może być wykonany z detached HEAD." >&2; exit 1; }
|
||||
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "Repozytorium ma niezapisane zmiany śledzonych plików. Najpierw wykonaj commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
relevant_untracked="$(git status --porcelain --untracked-files=all -- app gradle scripts build.gradle.kts settings.gradle.kts gradle.properties | awk '$1 == "??" { print }')"
|
||||
if [[ -n "$relevant_untracked" ]]; then
|
||||
echo "Repozytorium ma nieśledzone pliki źródłowe. Najpierw zdecyduj, czy mają wejść do release:" >&2
|
||||
printf '%s\n' "$relevant_untracked" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --tags "$REMOTE_NAME"
|
||||
|
||||
remote_url="$(git remote get-url "$REMOTE_NAME")"
|
||||
sanitized_remote="$(printf '%s' "$remote_url" | sed -E 's#^(https?://)[^/@]+@#\1#')"
|
||||
if [[ "$sanitized_remote" =~ ^(https?)://([^/]+)/(.+)$ ]]; then
|
||||
detected_protocol="${BASH_REMATCH[1]}"
|
||||
detected_host="${BASH_REMATCH[2]}"
|
||||
detected_repository="${BASH_REMATCH[3]%.git}"
|
||||
else
|
||||
detected_protocol="https"
|
||||
detected_host=""
|
||||
detected_repository=""
|
||||
fi
|
||||
|
||||
gitea_base_url="${GITEA_BASE_URL:-${detected_protocol}://${detected_host}}"
|
||||
gitea_repository="${GITEA_REPOSITORY:-$detected_repository}"
|
||||
gitea_base_url="${gitea_base_url%/}"
|
||||
[[ "$gitea_base_url" =~ ^https?://[^/]+$ ]] || { echo "Ustaw poprawne GITEA_BASE_URL." >&2; exit 1; }
|
||||
[[ "$gitea_repository" =~ ^[^/]+/[^/]+$ ]] || { echo "Ustaw poprawne GITEA_REPOSITORY=owner/repository." >&2; exit 1; }
|
||||
|
||||
auth_args=()
|
||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||
auth_args=(-H "Authorization: token $GITEA_TOKEN")
|
||||
else
|
||||
credential="$(printf 'protocol=%s\nhost=%s\n\n' "$detected_protocol" "$detected_host" | git credential fill)"
|
||||
gitea_username="$(printf '%s\n' "$credential" | sed -n 's/^username=//p')"
|
||||
gitea_password="$(printf '%s\n' "$credential" | sed -n 's/^password=//p')"
|
||||
[[ -n "$gitea_username" && -n "$gitea_password" ]] || {
|
||||
echo "Brak danych Gitea. Ustaw GITEA_TOKEN albo skonfiguruj git credential helper." >&2
|
||||
exit 1
|
||||
}
|
||||
auth_args=(-u "$gitea_username:$gitea_password")
|
||||
fi
|
||||
|
||||
api_base="$gitea_base_url/api/v1/repos/$gitea_repository"
|
||||
preflight_status="$(curl -sS "${auth_args[@]}" -o /dev/null -w '%{http_code}' "$api_base")"
|
||||
[[ "$preflight_status" == "200" ]] || {
|
||||
echo "Brak dostępu do repozytorium Gitea $gitea_repository (HTTP $preflight_status)." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
read_version() {
|
||||
local code name
|
||||
code="$(sed -nE 's/^[[:space:]]*versionCode = ([0-9]+).*$/\1/p' "$VERSION_FILE" | sed -n '1p')"
|
||||
name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)".*$/\1/p' "$VERSION_FILE" | sed -n '1p')"
|
||||
[[ "$code" =~ ^[0-9]+$ ]] || { echo "Nie można odczytać versionCode." >&2; return 1; }
|
||||
[[ "$name" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "versionName musi mieć format major.minor.patch." >&2; return 1; }
|
||||
printf '%s %s\n' "$code" "$name"
|
||||
}
|
||||
|
||||
read -r current_code current_name < <(read_version)
|
||||
if ((REUSE_CURRENT == 1)); then
|
||||
next_code="$current_code"
|
||||
next_name="$current_name"
|
||||
else
|
||||
IFS=. read -r version_major version_minor version_patch <<<"$current_name"
|
||||
next_code=$((current_code + 1))
|
||||
next_name="${version_major}.${version_minor}.$((version_patch + 1))"
|
||||
fi
|
||||
|
||||
tag_name="v${next_name}-${next_code}"
|
||||
artifact_dir="$ROOT_DIR/release-artifacts/$tag_name"
|
||||
gitea_apk_name="TPP-Kierowca-${next_name}-${next_code}.apk"
|
||||
play_aab_name="TPP-Kierowca-${next_name}-${next_code}.aab"
|
||||
|
||||
cleanup_on_exit() {
|
||||
local exit_code=$?
|
||||
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 ((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
|
||||
trap - EXIT INT TERM
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap cleanup_on_exit EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION_BACKUP="$(mktemp)"
|
||||
cp "$VERSION_FILE" "$VERSION_BACKUP"
|
||||
perl -0pi -e "s/versionCode = \\d+/versionCode = $next_code/" "$VERSION_FILE"
|
||||
perl -0pi -e "s/versionName = \"[^\"]+\"/versionName = \"$next_name\"/" "$VERSION_FILE"
|
||||
VERSION_FILE_CHANGED=1
|
||||
|
||||
read -r written_code written_name < <(read_version)
|
||||
[[ "$written_code" == "$next_code" && "$written_name" == "$next_name" ]] || {
|
||||
echo "Nie udało się jednoznacznie zapisać nowej wersji." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
echo "Buduję TPP Kierowca $next_name ($next_code)..."
|
||||
gradle_tasks=(assembleRelease bundleRelease)
|
||||
if ((SKIP_TESTS == 0)); then
|
||||
gradle_tasks=(testDebugUnitTest "${gradle_tasks[@]}")
|
||||
fi
|
||||
./gradlew "${gradle_tasks[@]}"
|
||||
|
||||
apk_source="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name '*.apk' -print -quit)"
|
||||
aab_source="$(find app/build/outputs/bundle/release -maxdepth 1 -type f -name '*.aab' -print -quit)"
|
||||
[[ -n "$apk_source" && -f "$apk_source" ]] || { echo "Nie znaleziono release APK." >&2; exit 1; }
|
||||
[[ -n "$aab_source" && -f "$aab_source" ]] || { echo "Nie znaleziono release AAB." >&2; exit 1; }
|
||||
|
||||
sdk_dir="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||
if [[ -z "$sdk_dir" && -f local.properties ]]; then
|
||||
sdk_dir="$(sed -n 's/^sdk.dir=//p' local.properties | sed -n '1p')"
|
||||
fi
|
||||
apksigner="$(find "$sdk_dir/build-tools" -type f -name apksigner 2>/dev/null | sort -V | tail -n 1)"
|
||||
aapt="$(find "$sdk_dir/build-tools" -type f -name aapt 2>/dev/null | sort -V | tail -n 1)"
|
||||
[[ -x "$apksigner" && -x "$aapt" ]] || { echo "Nie znaleziono apksigner/aapt w Android SDK." >&2; exit 1; }
|
||||
"$apksigner" verify --verbose "$apk_source"
|
||||
apk_badging="$("$aapt" dump badging "$apk_source" | sed -n '1p')"
|
||||
[[ "$apk_badging" == *"versionCode='$next_code'"* && "$apk_badging" == *"versionName='$next_name'"* ]] || {
|
||||
echo "APK ma inny numer wersji niż oczekiwany $next_name ($next_code)." >&2
|
||||
exit 1
|
||||
}
|
||||
jarsigner -verify "$aab_source" >/dev/null
|
||||
|
||||
rm -rf "$artifact_dir"
|
||||
mkdir -p "$artifact_dir/gitea" "$artifact_dir/google-play"
|
||||
cp "$apk_source" "$artifact_dir/gitea/$gitea_apk_name"
|
||||
cp "$aab_source" "$artifact_dir/google-play/$play_aab_name"
|
||||
(
|
||||
cd "$artifact_dir"
|
||||
sha256sum "gitea/$gitea_apk_name" "google-play/$play_aab_name" > SHA256SUMS.txt
|
||||
)
|
||||
|
||||
if ((REUSE_CURRENT == 0)); then
|
||||
git add "$VERSION_FILE"
|
||||
git diff --cached --check
|
||||
git commit -m "chore: release android driver $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
|
||||
tag_commit="$(git rev-list -n 1 "$tag_name")"
|
||||
head_commit="$(git rev-parse HEAD)"
|
||||
[[ "$tag_commit" == "$head_commit" ]] || {
|
||||
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)"
|
||||
if [[ -n "$previous_tag" ]]; then
|
||||
changes="$(git log --format='- %s (%h)' "$previous_tag..HEAD")"
|
||||
else
|
||||
changes="$(git log -20 --format='- %s (%h)')"
|
||||
fi
|
||||
release_notes="TPP Kierowca $next_name ($next_code)
|
||||
|
||||
Zmiany:
|
||||
${changes:-'- Wydanie techniczne.'}
|
||||
|
||||
Artefakt Google Play Console (AAB) znajduje się lokalnie w:
|
||||
release-artifacts/$tag_name/google-play/$play_aab_name"
|
||||
fi
|
||||
|
||||
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
|
||||
release_id="$(jq -r '.id' "$release_response")"
|
||||
elif [[ "$release_status" == "404" ]]; then
|
||||
release_payload="$(jq -n \
|
||||
--arg tag "$tag_name" \
|
||||
--arg target "$branch" \
|
||||
--arg name "TPP Kierowca $next_name ($next_code)" \
|
||||
--arg body "$release_notes" \
|
||||
'{tag_name:$tag,target_commitish:$target,name:$name,body:$body,draft:false,prerelease:false}')"
|
||||
release_status="$(curl -sS "${auth_args[@]}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-o "$release_response" \
|
||||
-w '%{http_code}' \
|
||||
-X POST \
|
||||
-d "$release_payload" \
|
||||
"$api_base/releases")"
|
||||
[[ "$release_status" == "201" ]] || {
|
||||
echo "Nie udało się utworzyć Gitea Release (HTTP $release_status)." >&2
|
||||
jq '{message,errors}' "$release_response" >&2 || true
|
||||
exit 1
|
||||
}
|
||||
release_id="$(jq -r '.id' "$release_response")"
|
||||
else
|
||||
echo "Nie udało się sprawdzić Gitea Release (HTTP $release_status)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
upload_asset() {
|
||||
local file_path="$1"
|
||||
local asset_name="$2"
|
||||
local mime_type="$3"
|
||||
local assets_response existing_asset_id upload_response upload_status
|
||||
assets_response="$(mktemp)"
|
||||
curl -fsS "${auth_args[@]}" -o "$assets_response" "$api_base/releases/$release_id/assets"
|
||||
existing_asset_id="$(jq -r --arg name "$asset_name" '.[] | select(.name == $name) | .id' "$assets_response" | sed -n '1p')"
|
||||
if [[ -n "$existing_asset_id" ]]; then
|
||||
curl -fsS "${auth_args[@]}" -X DELETE "$api_base/releases/$release_id/assets/$existing_asset_id" >/dev/null
|
||||
fi
|
||||
upload_response="$(mktemp)"
|
||||
upload_status="$(curl -sS "${auth_args[@]}" \
|
||||
-o "$upload_response" \
|
||||
-w '%{http_code}' \
|
||||
-X POST \
|
||||
-F "attachment=@$file_path;type=$mime_type" \
|
||||
"$api_base/releases/$release_id/assets?name=$asset_name")"
|
||||
[[ "$upload_status" == "201" ]] || {
|
||||
echo "Nie udało się wysłać $asset_name (HTTP $upload_status)." >&2
|
||||
jq '{message,errors}' "$upload_response" >&2 || true
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
upload_asset "$artifact_dir/gitea/$gitea_apk_name" "$gitea_apk_name" "application/vnd.android.package-archive"
|
||||
upload_asset "$artifact_dir/SHA256SUMS.txt" "SHA256SUMS.txt" "text/plain"
|
||||
|
||||
assets_response="$(mktemp)"
|
||||
curl -fsS "${auth_args[@]}" -o "$assets_response" "$api_base/releases/$release_id/assets"
|
||||
published_apk_url="$(jq -r --arg name "$gitea_apk_name" '.[] | select(.name == $name) | .browser_download_url' "$assets_response" | sed -n '1p')"
|
||||
[[ -n "$published_apk_url" && "$published_apk_url" != "null" ]] || {
|
||||
echo "Nie znaleziono opublikowanego APK w Gitea Release." >&2
|
||||
exit 1
|
||||
}
|
||||
downloaded_apk="$(mktemp)"
|
||||
curl -fsSL "${auth_args[@]}" -o "$downloaded_apk" "$published_apk_url"
|
||||
local_apk_hash="$(sha256sum "$artifact_dir/gitea/$gitea_apk_name" | awk '{print $1}')"
|
||||
published_apk_hash="$(sha256sum "$downloaded_apk" | awk '{print $1}')"
|
||||
[[ "$local_apk_hash" == "$published_apk_hash" ]] || {
|
||||
echo "Checksum opublikowanego APK nie zgadza się z lokalnym plikiem." >&2
|
||||
exit 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"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Release zakończony:
|
||||
Wersja: $next_name ($next_code)
|
||||
Tag: $tag_name
|
||||
Gitea: $release_url
|
||||
APK: $artifact_dir/gitea/$gitea_apk_name
|
||||
Google Play: $artifact_dir/google-play/$play_aab_name
|
||||
Checksumy: $artifact_dir/SHA256SUMS.txt
|
||||
EOF
|
||||
Reference in New Issue
Block a user