Compare commits

...
4 Commits
Author SHA1 Message Date
admin 32d45f6430 Bump driver app version to 51 2026-07-08 02:03:33 +02:00
admin 4a06e9da8b Retry route actions after photo upload 2026-07-08 02:01:37 +02:00
admin d6b36a1d3a Fix driver route crash on null photos 2026-07-08 00:02:15 +02:00
admin e811b4bc43 Limit confirmed route actions in driver projection 2026-07-07 23:18:10 +02:00
10 changed files with 120 additions and 8 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 48
versionName = "1.0.44"
versionCode = 51
versionName = "1.0.45"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -14,7 +14,10 @@ class PhotoUploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val dao = DriverDatabase.get(appContext).photoUploadDao()
private val database = DriverDatabase.get(appContext)
private val dao = database.photoUploadDao()
private val routeActionDao = database.routeActionDao()
private val routeActionOutbox = RouteActionOutbox(appContext)
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
@@ -62,6 +65,7 @@ class PhotoUploadWorker(
}
dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
enqueueRouteActionsWaitingForPhoto(clientRequestId)
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
@@ -90,6 +94,12 @@ class PhotoUploadWorker(
}
}
private suspend fun enqueueRouteActionsWaitingForPhoto(clientRequestId: String) {
routeActionDao.waitingForPhotosActions()
.filter { routeActionReferencesPhotoClientRequest(it, clientRequestId) }
.forEach { routeActionOutbox.enqueueWorker(it.clientActionId) }
}
companion object {
const val KEY_CLIENT_REQUEST_ID = "clientRequestId"
const val KEY_PROGRESS = "progress"
@@ -14,6 +14,9 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity?
@Query("SELECT * FROM route_actions WHERE status = 'WAITING_FOR_PHOTOS'")
suspend fun waitingForPhotosActions(): List<RouteActionEntity>
@Query(
"""
SELECT * FROM route_actions
@@ -27,7 +30,15 @@ interface RouteActionDao {
@Query(
"""
SELECT * FROM route_actions
WHERE status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'CONFIRMED', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT')
WHERE status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT')
OR clientActionId IN (
SELECT confirmed.clientActionId
FROM route_actions AS confirmed
WHERE confirmed.status = 'CONFIRMED'
AND confirmed.routeId = route_actions.routeId
ORDER BY confirmed.createdAtEpochMillis DESC
LIMIT 2
)
ORDER BY createdAtEpochMillis DESC
""",
)
@@ -53,7 +53,7 @@ class RouteActionOutbox(
return entity
}
private fun enqueueWorker(clientActionId: String) {
fun enqueueWorker(clientActionId: String) {
val request = OneTimeWorkRequestBuilder<RouteActionWorker>()
.setInputData(workDataOf(RouteActionWorker.KEY_CLIENT_ACTION_ID to clientActionId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
@@ -0,0 +1,17 @@
package pl.firmatpp.kierowca.data.upload
import com.google.gson.Gson
private val routeActionPhotoDependencyGson = Gson()
fun routeActionReferencesPhotoClientRequest(action: RouteActionEntity, clientRequestId: String): Boolean {
val needle = clientRequestId.trim()
if (needle.isBlank()) return false
return runCatching {
routeActionPhotoDependencyGson
.fromJson(action.photoClientRequestIdsJson, Array<String>::class.java)
?.any { it.trim() == needle }
?: false
}.getOrDefault(false)
}
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.ui
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType
@@ -41,6 +42,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
status = "W TRAKCIE",
loadingWeight = action.weight,
trackingStatus = "active",
photos = projectedRoute.safePhotos(),
)
loadingWeightPending = action.status != RouteActionStatus.Confirmed
}
@@ -50,6 +52,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
unloadingWeight = action.weight,
trackingStatus = "finished",
completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(),
)
unloadingWeightPending = action.status != RouteActionStatus.Confirmed
}
@@ -71,3 +74,5 @@ fun projectDriverRoutes(routes: List<DriverRouteDto>, actions: List<RouteActionE
fun routeActionNeedsAttention(status: String): Boolean =
status == RouteActionStatus.FailedConflict || status == RouteActionStatus.FailedPermanent
internal fun DriverRouteDto.safePhotos(): List<RoutePhotoDto> = photos.orEmpty()
@@ -114,8 +114,10 @@ fun routeSyncCallout(actions: List<RouteActionEntity>): RouteSyncCalloutUi? {
} else {
when (unresolved.status) {
RouteActionStatus.WaitingForPhotos -> "Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu."
RouteActionStatus.Pending,
RouteActionStatus.Syncing -> "Trwa wysyłanie zmiany statusu kursu."
RouteActionStatus.FailedRetryable -> "Nie udało się wysłać. Aplikacja spróbuje ponownie."
else -> "Zmiana zapisana w telefonie. Wyślemypo odzyskaniu internetu."
else -> "Zmiana zapisana w telefonie. Aplikacja wyśle ją automatycznie."
}
},
isConflict = isConflict,
@@ -151,9 +153,9 @@ fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): Lis
)
}
fun routePhotosForStage(photos: List<RoutePhotoDto>, stage: String): List<RoutePhotoDto> {
fun routePhotosForStage(photos: List<RoutePhotoDto>?, stage: String): List<RoutePhotoDto> {
val normalized = normalizedRoutePhotoStage(stage)
return photos.filter { normalizedRoutePhotoStage(it.stage) == normalized }
return photos.orEmpty().filter { normalizedRoutePhotoStage(it.stage) == normalized }
}
fun normalizedRoutePhotoStage(stage: String?): String =
@@ -617,6 +617,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
status = "W TRAKCIE",
loadingWeight = weight,
trackingStatus = "active",
photos = route.safePhotos(),
)
}
"unloading" -> {
@@ -626,6 +627,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
unloadingWeight = weight,
trackingStatus = "finished",
completedAt = Instant.now().toString(),
photos = route.safePhotos(),
)
}
else -> route
@@ -0,0 +1,31 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RouteActionPhotoDependenciesTest {
@Test
fun detectsRouteActionWaitingForConfirmedPhotoClientRequest() {
val action = action("""["photo-a","photo-b"]""")
assertTrue(routeActionReferencesPhotoClientRequest(action, "photo-b"))
assertFalse(routeActionReferencesPhotoClientRequest(action, "photo-c"))
}
@Test
fun malformedPhotoRequestJsonDoesNotMatch() {
assertFalse(routeActionReferencesPhotoClientRequest(action("not-json"), "photo-a"))
}
private fun action(photoClientRequestIdsJson: String): RouteActionEntity =
RouteActionEntity(
clientActionId = "action-1",
routeId = "route-1",
action = RouteActionType.Start,
weight = 12.5,
occurredAt = "2026-06-30T07:40:00Z",
photoClientRequestIdsJson = photoClientRequestIdsJson,
status = RouteActionStatus.WaitingForPhotos,
)
}
@@ -1,11 +1,13 @@
package pl.firmatpp.kierowca.ui
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType
@@ -73,6 +75,38 @@ class DriverRouteProjectionTest {
assertEquals(emptyList<RouteActionEntity>(), projection.visibleActions)
}
@Test
fun routeProjectionNormalizesNullPhotosFromCachedJsonBeforeCopy() {
val route = Gson().fromJson(
"""
{
"id": "1",
"startsAt": "",
"originName": "Baza",
"destinationName": "Instalacja",
"contractorName": "TPP",
"contractName": "Kontrakt",
"contractCode": "TPP-1",
"relationLabel": "Baza -> Instalacja",
"status": "ZAPLANOWANA",
"distanceKm": 1.0,
"notes": null,
"truck": null,
"photos": null
}
""".trimIndent(),
DriverRouteDto::class.java,
)
val projection = projectDriverRoute(
route = route,
actions = listOf(action(RouteActionType.Start, RouteActionStatus.Confirmed, weight = 12.5)),
)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(emptyList<RoutePhotoDto>(), projection.route.photos)
}
@Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() {
val projection = projectDriverRoute(