From e9e8a75bf19629c69162e8b15c97c564bb2ba2b6 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 7 Oct 2026 12:51:44 +0200 Subject: [PATCH] =?UTF-8?q?Obs=C5=82u=C5=BC=20publikacj=C4=99=20dni=20i=20?= =?UTF-8?q?ukrywanie=20kurs=C3=B3w=20kierowcy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kierowca/data/model/DriverModels.kt | 3 ++ .../kierowca/data/sync/DriverCacheDao.kt | 3 ++ .../data/sync/DriverSyncRepository.kt | 8 ++++ .../sync/DriverFirebaseMessagingService.kt | 10 ++++ .../sync/NewRouteNotificationWorker.kt | 48 ++++++++++++++++--- .../PublishedRoutesNotificationFormatter.kt | 22 +++++++++ .../ui/DriverDayPublicationUiRules.kt | 14 ++++++ .../firmatpp/kierowca/ui/DriverViewModel.kt | 20 ++++++++ ...ublishedRoutesNotificationFormatterTest.kt | 20 ++++++++ .../ui/DriverDayPublicationUiRulesTest.kt | 38 +++++++++++++++ 10 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/pl/firmatpp/kierowca/sync/PublishedRoutesNotificationFormatter.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/ui/DriverDayPublicationUiRules.kt create mode 100644 app/src/test/java/pl/firmatpp/kierowca/sync/PublishedRoutesNotificationFormatterTest.kt create mode 100644 app/src/test/java/pl/firmatpp/kierowca/ui/DriverDayPublicationUiRulesTest.kt diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index dc6e230..ac76744 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -42,7 +42,10 @@ data class DriverDto( val anonymousLabel: String?, ) +data class DayPublicationDto(val date: String, val isVisible: Boolean, val version: Long) + data class BootstrapResponse( + val dayPublication: DayPublicationDto? = null, val session: DriverSessionDto, val routes: RoutesBucketDto, val driverAppSettings: DriverAppSettingsDto?, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheDao.kt index 1258ef3..a8bed76 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheDao.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverCacheDao.kt @@ -37,6 +37,9 @@ interface DriverCacheDao { @Query("DELETE FROM driver_bootstrap_cache") suspend fun clearBootstrap() + @Query("DELETE FROM driver_route_cache WHERE routeId = :routeId") + suspend fun deleteRoute(routeId: String) + @Query("DELETE FROM driver_route_cache") suspend fun clearRoutes() diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt index 523049e..c918722 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt @@ -27,6 +27,13 @@ class DriverSyncRepository( val response = repository.bootstrap(date) val selectedDate = response.driverAppSettings?.selectedDate ?: requestedDate val routeState = response.syncState?.scopes?.firstOrNull { it.scope == SCOPE_ROUTES } + if (response.dayPublication?.isVisible == false) { + // Previously downloaded details must not survive withdrawing a day. + dao.allRouteCaches().forEach { cached -> + val route = gson.fromJson(cached.payloadJson, RouteResponse::class.java).route + if (selectedDate in routeVisibleDates(route)) dao.deleteRoute(route.id) + } + } dao.upsertBootstrap( DriverBootstrapCacheEntity( date = selectedDate, @@ -61,6 +68,7 @@ class DriverSyncRepository( CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis()) }.getOrElse { throwable -> throwable.rethrowIfCancellation() + if (throwable is HttpException && throwable.code() == 404) dao.deleteRoute(routeId) cachedRoute(routeId, throwable) } diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt index 7006901..73d0768 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt @@ -23,6 +23,16 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { val data = message.data + if (data["type"] == "driver_routes_published") { + NewRouteNotificationWorker.enqueuePublication( + context = applicationContext, + date = data["date"], + version = data["publicationVersion"]?.toLongOrNull(), + driverId = data["driverId"], + ) + DriverSyncWorker.enqueue(applicationContext, data["date"], null) + return + } if (data["type"] == "driver_new_route_notification") { NewRouteNotificationWorker.enqueue( context = applicationContext, diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt index 65c49a0..d4adea9 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt @@ -27,6 +27,7 @@ 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.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import retrofit2.HttpException @@ -39,25 +40,41 @@ class NewRouteNotificationWorker( override suspend fun doWork(): Result { if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry() - val routeId = inputData.getString(KEY_ROUTE_ID)?.takeIf(String::isNotBlank) ?: return Result.success() + val routeId = inputData.getString(KEY_ROUTE_ID)?.takeIf(String::isNotBlank) + val publicationDate = inputData.getString(KEY_DATE) + val publicationVersion = inputData.getLong(KEY_PUBLICATION_VERSION, -1L) + val isPublication = publicationVersion >= 0L + if (!isPublication && routeId == null) return Result.success() return runCatching { - val route = repository.route(routeId).route + val route = if (isPublication) { + if (publicationDate.isNullOrBlank()) return@runCatching Result.success() + val bootstrap = DriverSyncRepository(applicationContext).bootstrap(publicationDate) + if (bootstrap.stale) return@runCatching Result.retry() + val publication = bootstrap.value.dayPublication + if (publication?.isVisible != true || publication.version != publicationVersion || + bootstrap.value.notificationPreferences?.notifyNewRoutes != true || + bootstrap.value.session.driver.id != inputData.getString(KEY_DRIVER_ID)) { + return@runCatching Result.success() + } + bootstrap.value.routes.today.firstOrNull() ?: return@runCatching Result.success() + } else repository.route(requireNotNull(routeId)).route if (!canShowNotifications(applicationContext)) { return@runCatching Result.success() } createChannel(applicationContext) - val contentTitle = NewRouteNotificationFormatter.title(route.routeDate ?: inputData.getString(KEY_DATE)) - val contentText = NewRouteNotificationFormatter.collapsedText(route) - val expandedText = NewRouteNotificationFormatter.expandedText(route) + val contentTitle = if (isPublication) "Opublikowano kursy" else NewRouteNotificationFormatter.title(route.routeDate ?: publicationDate) + val contentText = if (isPublication) PublishedRoutesNotificationFormatter.text(publicationDate) else NewRouteNotificationFormatter.collapsedText(route) + val expandedText = if (isPublication) contentText else NewRouteNotificationFormatter.expandedText(route) + val notificationId = if (isPublication) "published-$publicationDate".hashCode() else route.id.hashCode() val intent = Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP putExtra(MainActivity.EXTRA_ROUTE_ID, route.id) } val pendingIntent = PendingIntent.getActivity( applicationContext, - route.id.hashCode(), + notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) @@ -69,12 +86,13 @@ class NewRouteNotificationWorker( .setStyle(NotificationCompat.BigTextStyle().bigText(expandedText)) .setContentIntent(pendingIntent) .setAutoCancel(true) + .setOnlyAlertOnce(true) .setCategory(NotificationCompat.CATEGORY_REMINDER) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .build() try { - NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification) + NotificationManagerCompat.from(applicationContext).notify(notificationId, notification) } catch (_: SecurityException) { return@runCatching Result.success() } @@ -125,6 +143,22 @@ class NewRouteNotificationWorker( private const val CHANNEL_ID = "new_routes" private const val KEY_ROUTE_ID = "routeId" private const val KEY_DATE = "date" + private const val KEY_PUBLICATION_VERSION = "publicationVersion" + private const val KEY_DRIVER_ID = "driverId" + + fun enqueuePublication(context: Context, date: String?, version: Long?, driverId: String?) { + if (date.isNullOrBlank() || version == null || version < 1L || driverId.isNullOrBlank()) return + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(KEY_DATE to date, KEY_PUBLICATION_VERSION to version, KEY_DRIVER_ID to driverId)) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + "driver-day-publication-$date", + ExistingWorkPolicy.REPLACE, + request, + ) + } fun enqueue(context: Context, routeId: String?, date: String?) { if (routeId.isNullOrBlank()) return diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/PublishedRoutesNotificationFormatter.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/PublishedRoutesNotificationFormatter.kt new file mode 100644 index 0000000..b08bbf2 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/PublishedRoutesNotificationFormatter.kt @@ -0,0 +1,22 @@ +package pl.firmatpp.kierowca.sync + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +object PublishedRoutesNotificationFormatter { + fun text(value: String?): String { + val date = runCatching { LocalDate.parse(value) }.getOrNull() + ?: return "Opublikowano twoje kursy. Otwórz aplikację, aby je zobaczyć." + val weekday = when (date.dayOfWeek) { + DayOfWeek.MONDAY -> "poniedziałek" + DayOfWeek.TUESDAY -> "wtorek" + DayOfWeek.WEDNESDAY -> "środę" + DayOfWeek.THURSDAY -> "czwartek" + DayOfWeek.FRIDAY -> "piątek" + DayOfWeek.SATURDAY -> "sobotę" + DayOfWeek.SUNDAY -> "niedzielę" + } + return "Opublikowano twoje kursy na $weekday (${date.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))})." + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverDayPublicationUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverDayPublicationUiRules.kt new file mode 100644 index 0000000..1e018e8 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverDayPublicationUiRules.kt @@ -0,0 +1,14 @@ +package pl.firmatpp.kierowca.ui + +import java.time.LocalDate +import pl.firmatpp.kierowca.data.model.DriverRouteDto + +fun routeIncludesPublicationDate(route: DriverRouteDto?, date: String?): Boolean { + if (route == null || date == null) return false + return runCatching { + val day = LocalDate.parse(date) + val start = LocalDate.parse(route.routeDate) + val end = LocalDate.parse(route.unloadingDate ?: route.routeDate) + !day.isBefore(start) && !day.isAfter(end) + }.getOrDefault(false) +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index 80585df..a754b80 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -644,6 +644,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) error = null, ) } + if (!cached.stale && response.dayPublication?.isVisible == false && + routeIncludesPublicationDate(_state.value.selectedRoute, response.dayPublication.date)) { + _state.value.selectedRoute?.id?.let(::closeUnavailableRoute) + } response.session.driver.id.let { driverId -> liveSyncClient.start(driverId, response.realtime) if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId) @@ -765,6 +769,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) if (generation != routeDetailRequestGeneration) return@onFailure if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) { handleExpiredSession() + } else if (ApiErrorMapper.map(throwable).statusCode == 404) { + closeUnavailableRoute(routeId) } else { _state.update { it.withApiError(throwable) } } @@ -774,6 +780,19 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } + private fun closeUnavailableRoute(routeId: String) { + routeDetailRequestGeneration++ + _state.update { current -> + if (current.selectedRoute?.id != routeId) current else current.copy( + selectedRoute = null, + selectedPhoto = null, + routes = current.routes.filterNot { it.id == routeId }, + refreshing = false, + screen = if (current.screen in setOf(DriverScreen.Detail, DriverScreen.StartRoute, DriverScreen.LoadingWeight, DriverScreen.FinishRoute, DriverScreen.Photo)) DriverScreen.Routes else current.screen, + ) + } + } + fun openProfile() { _state.update { it.copy(screen = DriverScreen.Profile, error = null) } if ( @@ -2280,6 +2299,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) val snapshot = _state.value when (hint.scope) { DriverSyncRepository.SCOPE_ROUTES -> { + if (routeIncludesPublicationDate(snapshot.selectedRoute, hint.date)) refreshSelectedRoute() if (shouldShowRouteDayLiveUpdate(snapshot.selectedDate, hint.date)) { refreshRoutesSilently() if (snapshot.screen == DriverScreen.Routes) { diff --git a/app/src/test/java/pl/firmatpp/kierowca/sync/PublishedRoutesNotificationFormatterTest.kt b/app/src/test/java/pl/firmatpp/kierowca/sync/PublishedRoutesNotificationFormatterTest.kt new file mode 100644 index 0000000..b50c5b1 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/sync/PublishedRoutesNotificationFormatterTest.kt @@ -0,0 +1,20 @@ +package pl.firmatpp.kierowca.sync + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PublishedRoutesNotificationFormatterTest { + @Test + fun formatsPublicationWithPolishWeekdayAndUnambiguousDate() { + assertEquals("Opublikowano twoje kursy na poniedziałek (28.09.2026).", PublishedRoutesNotificationFormatter.text("2026-09-28")) + assertEquals("Opublikowano twoje kursy na środę (30.09.2026).", PublishedRoutesNotificationFormatter.text("2026-09-30")) + assertEquals("Opublikowano twoje kursy na niedzielę (04.10.2026).", PublishedRoutesNotificationFormatter.text("2026-10-04")) + } + + @Test + fun handlesMissingAndInvalidDates() { + val fallback = "Opublikowano twoje kursy. Otwórz aplikację, aby je zobaczyć." + assertEquals(fallback, PublishedRoutesNotificationFormatter.text(null)) + assertEquals(fallback, PublishedRoutesNotificationFormatter.text("invalid")) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverDayPublicationUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverDayPublicationUiRulesTest.kt new file mode 100644 index 0000000..97182a5 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverDayPublicationUiRulesTest.kt @@ -0,0 +1,38 @@ +package pl.firmatpp.kierowca.ui + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import pl.firmatpp.kierowca.data.model.DriverRouteDto + +class DriverDayPublicationUiRulesTest { + private val route = DriverRouteDto( + id = "42", routeDate = "2026-09-28", unloadingDate = "2026-09-30", + startsAt = "09:00", originName = "Baza", destinationName = "Zakład", + contractorName = "Kontrahent", contractName = null, contractCode = null, relationLabel = "Baza → Zakład", + status = "ZAPLANOWANA", distanceKm = 20.0, notes = null, truck = null, + ) + + @Test + fun hidingAnyScheduledDayCanInvalidateOpenDetails() { + assertTrue(routeIncludesPublicationDate(route, "2026-09-28")) + assertTrue(routeIncludesPublicationDate(route, "2026-09-29")) + assertTrue(routeIncludesPublicationDate(route, "2026-09-30")) + } + + @Test + fun unrelatedOrInvalidDatesMustNotCloseTheOpenRoute() { + assertFalse(routeIncludesPublicationDate(route, "2026-09-27")) + assertFalse(routeIncludesPublicationDate(route, "2026-10-01")) + assertFalse(routeIncludesPublicationDate(route, "invalid")) + assertFalse(routeIncludesPublicationDate(null, "2026-09-28")) + assertFalse(routeIncludesPublicationDate(route, null)) + } + + @Test + fun singleDayRoutesDoNotInheritTheNextDaysPublication() { + val singleDay = route.copy(unloadingDate = null) + assertTrue(routeIncludesPublicationDate(singleDay, "2026-09-28")) + assertFalse(routeIncludesPublicationDate(singleDay, "2026-09-29")) + } +}