Compare commits

..
26 changed files with 1748 additions and 119 deletions
+4 -4
View File
@@ -22,7 +22,7 @@ val keystoreProperties = Properties().apply {
android {
namespace = "pl.firmatpp.kierowca"
compileSdk = 35
compileSdk = 36
fun configValue(name: String, default: String): String =
providers.gradleProperty(name)
@@ -33,9 +33,9 @@ android {
defaultConfig {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 118
versionName = "1.0.65"
targetSdk = 36
versionCode = 124
versionName = "1.0.71"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+4
View File
@@ -9,6 +9,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />
@@ -20,6 +20,7 @@ class AppPreferencesStore(
private val themeModeKey = stringPreferencesKey("theme_mode")
private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled")
private val routeLocationEnabledKey = booleanPreferencesKey("route_location_enabled")
val themeMode: Flow<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
@@ -27,6 +28,9 @@ class AppPreferencesStore(
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
val routeLocationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeLocationEnabledKey] ?: false }
suspend fun setThemeMode(themeMode: AppThemeMode) {
dataStore.edit { preferences ->
preferences[themeModeKey] = themeMode.storedValue
@@ -38,4 +42,10 @@ class AppPreferencesStore(
preferences[routeProgressNotificationEnabledKey] = enabled
}
}
suspend fun setRouteLocationEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[routeLocationEnabledKey] = enabled
}
}
}
@@ -22,17 +22,6 @@ class DeviceInfoProvider(
androidVersion = Build.VERSION.RELEASE,
sdkInt = Build.VERSION.SDK_INT,
appVersion = BuildConfig.VERSION_NAME,
serialNumber = readSerialNumber(),
serialNumber = null,
)
private fun readSerialNumber(): String? =
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Build.getSerial()
} else {
@Suppress("DEPRECATION")
Build.SERIAL
}
}.getOrNull()
?.takeUnless { it.isBlank() || it.equals(Build.UNKNOWN, ignoreCase = true) }
}
@@ -12,6 +12,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import pl.firmatpp.kierowca.data.api.ApiFactory
import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -33,6 +34,7 @@ import pl.firmatpp.kierowca.data.model.RoutePointsResponse
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -56,6 +58,12 @@ class DriverRepository(
suspend fun bootstrap(date: String? = null): BootstrapResponse =
api.bootstrap(authHeader(requireToken()), date)
suspend fun acknowledgeTachographReminder(taskIds: List<String>): TachographReminderDto =
api.acknowledgeTachographReminder(
authHeader(requireToken()),
AcknowledgeTachographReminderBody(taskIds),
).tachographReminder
suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId)
@@ -1,11 +1,19 @@
package pl.firmatpp.kierowca.data.api
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor
import pl.firmatpp.kierowca.BuildConfig
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
internal const val DRIVER_APP_VERSION_HEADER = "X-Driver-App-Version"
internal fun Request.withDriverAppVersion(appVersion: String): Request =
newBuilder()
.header(DRIVER_APP_VERSION_HEADER, appVersion)
.build()
object ApiFactory {
fun mobileDriverApi(): MobileDriverApi {
val logging = HttpLoggingInterceptor().apply {
@@ -13,6 +21,9 @@ object ApiFactory {
}
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(chain.request().withDriverAppVersion(BuildConfig.VERSION_NAME))
}
.addInterceptor(logging)
.build()
@@ -3,6 +3,8 @@ package pl.firmatpp.kierowca.data.api
import okhttp3.MultipartBody
import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderBody
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -67,6 +69,12 @@ interface MobileDriverApi {
@Query("date") date: String?,
): BootstrapResponse
@POST("mobile/driver/tachograph-reminder/acknowledge")
suspend fun acknowledgeTachographReminder(
@Header("Authorization") authorization: String,
@Body body: AcknowledgeTachographReminderBody,
): AcknowledgeTachographReminderResponse
@GET("mobile/driver/leave-request-types")
suspend fun leaveRequestTypes(
@Header("Authorization") authorization: String,
@@ -47,6 +47,7 @@ data class BootstrapResponse(
val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null,
@@ -72,6 +73,7 @@ data class DriverAppSettingsDto(
val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?,
val routeLocationMode: String? = null,
val loadingPhotoRequirement: String? = null,
val loadingWeightRequirement: String? = null,
val unloadingPhotoRequirement: String? = null,
@@ -79,6 +81,7 @@ data class DriverAppSettingsDto(
val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null,
val tachographRemindersEnabled: Boolean? = null,
val leaveRequests: LeaveRequestsConfigDto? = null,
)
@@ -98,6 +101,30 @@ data class DispatchSheetReminderDto(
val canUpload: Boolean = false,
)
data class TachographReminderDto(
val enabled: Boolean = false,
val visible: Boolean = false,
val title: String? = null,
val message: String? = null,
val confirmationLabel: String? = null,
val tasks: List<TachographReminderTaskDto>? = null,
)
data class TachographReminderTaskDto(
val id: String,
val type: String,
val label: String,
)
data class AcknowledgeTachographReminderBody(
val taskIds: List<String>,
)
data class AcknowledgeTachographReminderResponse(
val ok: Boolean,
val tachographReminder: TachographReminderDto,
)
data class DispatchSheetPhotoDto(
val id: String,
val driverId: String,
@@ -14,6 +14,61 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'start'
AND createdAtEpochMillis < :beforeEpochMillis
ORDER BY createdAtEpochMillis DESC
LIMIT 1
""",
)
suspend fun latestStartBefore(routeId: String, beforeEpochMillis: Long): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis > :afterEpochMillis
AND status != 'CONFIRMED'
ORDER BY createdAtEpochMillis ASC
""",
)
suspend fun unconfirmedFinishesAfter(routeId: String, afterEpochMillis: Long): List<RouteActionEntity>
@Query(
"""
SELECT EXISTS(
SELECT 1 FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND status = 'CONFIRMED'
AND createdAtEpochMillis > :afterEpochMillis
)
""",
)
suspend fun hasConfirmedFinishAfter(routeId: String, afterEpochMillis: Long): Boolean
@Query(
"""
UPDATE route_actions
SET status = 'CONFIRMED',
lastError = NULL,
updatedAtEpochMillis = :updatedAt
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis < :beforeEpochMillis
AND status != 'CONFIRMED'
""",
)
suspend fun confirmSupersededFinishes(
routeId: String,
beforeEpochMillis: Long,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'")
suspend fun allUnsent(): List<RouteActionEntity>
@@ -8,9 +8,11 @@ import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
@@ -31,6 +33,33 @@ class RouteActionWorker(
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
val action = dao.find(clientActionId) ?: return Result.failure()
if (action.status == RouteActionStatus.Confirmed) return Result.success()
val startPrerequisite = if (action.action == RouteActionType.Finish) {
dao.latestStartBefore(action.routeId, action.createdAtEpochMillis)
} else {
null
}
when (routeActionPrerequisiteState(action, startPrerequisite)) {
RouteActionPrerequisiteState.Waiting -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Pending,
lastError = "Czekam na potwierdzenie rozpoczęcia kursu.",
attemptIncrement = 0,
)
return Result.retry()
}
RouteActionPrerequisiteState.Failed -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.FailedConflict,
lastError = "Najpierw trzeba potwierdzić rozpoczęcie kursu.",
attemptIncrement = 0,
)
return Result.failure()
}
RouteActionPrerequisiteState.Ready -> Unit
}
val photoClientRequestIds = photoClientRequestIds(action)
val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
@@ -72,12 +101,25 @@ class RouteActionWorker(
lastError = null,
attemptIncrement = 0,
)
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate)
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
if (action.action == RouteActionType.Start) {
val outbox = RouteActionOutbox(applicationContext)
dao.unconfirmedFinishesAfter(action.routeId, action.createdAtEpochMillis)
.forEach { outbox.enqueueWorker(it.clientActionId) }
}
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
val serverRoute = if (error.kind == ApiErrorKind.Conflict) {
runCatching { repository.route(action.routeId).route }.getOrNull()
} else {
null
}
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "route_action_worker",
@@ -89,8 +131,30 @@ class RouteActionWorker(
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"retryable" to error.retryable,
"server_route_status" to serverRoute?.driverLifecycleStatus(),
),
)
val supersededByConfirmedFinish = action.action == RouteActionType.Finish &&
dao.hasConfirmedFinishAfter(action.routeId, action.createdAtEpochMillis)
if (serverRoute != null &&
(
routeActionMatchesServer(action, photoClientRequestIds, serverRoute) ||
routeActionWasSuperseded(action, supersededByConfirmedFinish, serverRoute)
)
) {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Confirmed,
lastError = null,
attemptIncrement = 0,
)
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(serverRoute, serverRoute.routeDate)
DriverSyncWorker.enqueue(applicationContext, serverRoute.routeDate, serverRoute.id)
return@getOrElse Result.success()
}
val status = when {
error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict
error.retryable -> RouteActionStatus.FailedRetryable
@@ -99,7 +163,7 @@ class RouteActionWorker(
dao.updateStatus(
clientActionId = clientActionId,
status = status,
lastError = error.message,
lastError = routeActionConflictMessage(error.message, serverRoute),
attemptIncrement = 0,
)
@@ -146,3 +210,84 @@ class RouteActionWorker(
fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId"
}
}
internal fun routeActionMatchesServer(
action: RouteActionEntity,
photoClientRequestIds: List<String>,
serverRoute: DriverRouteDto,
): Boolean {
val lifecycleMatches = when (action.action) {
RouteActionType.Start -> serverRoute.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA")
RouteActionType.Finish -> serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
else -> false
}
if (!lifecycleMatches) return false
val serverWeight = when (action.action) {
RouteActionType.Start -> serverRoute.loadingWeight
RouteActionType.Finish -> serverRoute.unloadingWeight
else -> null
}
val serverNotes = when (action.action) {
RouteActionType.Start -> serverRoute.loadingNotes
RouteActionType.Finish -> serverRoute.unloadingNotes
else -> null
}
val weightMatches = when {
action.weight == null && serverWeight == null -> true
action.weight == null || serverWeight == null -> false
else -> kotlin.math.abs(action.weight - serverWeight) < 0.0005
}
val notesMatch = action.notes.normalizedRouteActionNotes() == serverNotes.normalizedRouteActionNotes()
val serverPhotoRequestIds = serverRoute.photos
.mapNotNull { it.clientRequestId?.trim()?.takeIf(String::isNotBlank) }
.toSet()
val photosMatch = photoClientRequestIds.all(serverPhotoRequestIds::contains)
return weightMatches && notesMatch && photosMatch
}
internal fun routeActionWasSuperseded(
action: RouteActionEntity,
hasConfirmedFinishAfter: Boolean,
serverRoute: DriverRouteDto,
): Boolean =
action.action == RouteActionType.Finish &&
hasConfirmedFinishAfter &&
serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
internal fun routeActionConflictMessage(message: String, serverRoute: DriverRouteDto?): String {
val serverStatus = serverRoute?.driverLifecycleStatus() ?: return message
val operationalStatus = serverRoute.operationalStatus
?.takeIf(String::isNotBlank)
?.let { ", status operacyjny: $it" }
.orEmpty()
return "$message Status kursu na serwerze: $serverStatus$operationalStatus. Dane w telefonie pozostają zachowane."
}
private fun String?.normalizedRouteActionNotes(): String? =
this?.trim()?.takeIf(String::isNotEmpty)
internal enum class RouteActionPrerequisiteState {
Ready,
Waiting,
Failed,
}
internal fun routeActionPrerequisiteState(
action: RouteActionEntity,
precedingStart: RouteActionEntity?,
): RouteActionPrerequisiteState {
if (action.action != RouteActionType.Finish || precedingStart == null) {
return RouteActionPrerequisiteState.Ready
}
return when (precedingStart.status) {
RouteActionStatus.Confirmed -> RouteActionPrerequisiteState.Ready
RouteActionStatus.FailedConflict,
RouteActionStatus.FailedPermanent,
-> RouteActionPrerequisiteState.Failed
else -> RouteActionPrerequisiteState.Waiting
}
}
@@ -229,18 +229,28 @@ class DiagnosticSnapshotProvider(private val context: Context) {
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val links = activeNetwork?.let(manager::getLinkProperties)
val roaming = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))
} else {
"niedostępne"
}
val mtu = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
links?.mtu?.toString() ?: "brak"
} else {
"niedostępne"
}
return listOf(
DiagnosticEntry("Transport", transports.joinToString()),
DiagnosticEntry("Internet zweryfikowany", yesNo(validated), if (validated) DiagnosticSeverity.Good else DiagnosticSeverity.Error),
DiagnosticEntry("NET_CAPABILITY_INTERNET", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET))),
DiagnosticEntry("NET_CAPABILITY_VALIDATED", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))),
DiagnosticEntry("Sieć mierzona", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))),
DiagnosticEntry("Roaming", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))),
DiagnosticEntry("Roaming", roaming),
DiagnosticEntry("Pasmo downstream / upstream", "${capabilities.linkDownstreamBandwidthKbps} / ${capabilities.linkUpstreamBandwidthKbps} kb/s"),
DiagnosticEntry("Interfejs", links?.interfaceName ?: "brak"),
DiagnosticEntry("Adresy IP", links?.linkAddresses?.joinToString { it.toString() }?.ifBlank { "brak" } ?: "brak"),
DiagnosticEntry("DNS", links?.dnsServers?.joinToString { it.hostAddress ?: it.toString() }?.ifBlank { "brak" } ?: "brak"),
DiagnosticEntry("MTU", links?.mtu?.toString() ?: "brak"),
DiagnosticEntry("MTU", mtu),
DiagnosticEntry("Proxy", links?.httpProxy?.toString() ?: "brak"),
)
}
@@ -57,7 +57,11 @@ class LeaveRequestDecisionNotificationWorker(
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification)
try {
NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification)
} catch (_: SecurityException) {
return Result.success()
}
return Result.success()
}
@@ -73,7 +73,11 @@ class NewRouteNotificationWorker(
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
try {
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
} catch (_: SecurityException) {
return@runCatching Result.success()
}
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.tracking
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -54,6 +55,11 @@ class ActiveRouteTrackingService : Service(), LocationListener {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != ACTION_STOP && !hasLocationPermission()) {
stopSelf()
return START_NOT_STICKY
}
when (intent?.action) {
ACTION_STOP -> {
val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
@@ -69,7 +75,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
routeId = id
arrived = true
runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification())
if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
}
return START_NOT_STICKY
}
@@ -97,7 +105,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
?: totalDistanceMeters
progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled
if (routeChanged) arrived = false
startForegroundCompat(buildNotification())
if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
if (!arrived) requestLocationUpdates()
return START_NOT_STICKY
}
@@ -113,9 +123,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) {
arrived = true
runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification())
if (!startForegroundCompat(buildNotification())) return
} else {
startForegroundCompat(buildNotification())
if (!startForegroundCompat(buildNotification())) return
}
val (battery, charging) = batterySnapshot()
@@ -151,6 +161,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
super.onDestroy()
}
@SuppressLint("MissingPermission")
private fun requestLocationUpdates() {
if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) {
return
@@ -204,11 +215,25 @@ class ActiveRouteTrackingService : Service(), LocationListener {
return builder.build()
}
private fun startForegroundCompat(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && hasLocationPermission()) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
} else {
startForeground(NOTIFICATION_ID, notification)
private fun startForegroundCompat(notification: Notification): Boolean {
if (!hasLocationPermission()) {
stopSelf()
return false
}
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
} else {
startForeground(NOTIFICATION_ID, notification)
}
true
} catch (_: SecurityException) {
stopSelf()
false
} catch (_: IllegalStateException) {
stopSelf()
false
}
}
@@ -220,7 +245,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
"Aktywny kurs",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Postęp aktywnego kursu i lokalizacja w tle"
description = "Informacje o aktywnym kursie"
}
manager.createNotificationChannel(channel)
}
@@ -299,6 +324,8 @@ class ActiveRouteTrackingService : Service(), LocationListener {
private const val ACTIVE_ROUTE_ID = "active_route_id"
fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) {
if (!hasLocationPermission(context)) return
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_ROUTE_ID, route.id)
@@ -309,7 +336,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) }
route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) }
}
ContextCompat.startForegroundService(context, intent)
runCatching { ContextCompat.startForegroundService(context, intent) }
}
fun stop(context: Context, routeId: String? = null) {
@@ -325,11 +352,20 @@ class ActiveRouteTrackingService : Service(), LocationListener {
}
fun markArrived(context: Context, routeId: String) {
if (!hasLocationPermission(context)) return
val activeRouteId = context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.getString(ACTIVE_ROUTE_ID, null)
if (activeRouteId != routeId) return
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_MARK_ARRIVED
putExtra(EXTRA_ROUTE_ID, routeId)
}
ContextCompat.startForegroundService(context, intent)
runCatching { ContextCompat.startForegroundService(context, intent) }
}
private fun hasLocationPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
}
File diff suppressed because it is too large Load Diff
@@ -22,13 +22,20 @@ internal val routeActionVisibleStatuses = setOf(
RouteActionStatus.FailedPermanent,
)
private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
private val routeActionDataProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
private val routeActionLifecycleProjectionStatuses = setOf(
RouteActionStatus.Pending,
RouteActionStatus.WaitingForPhotos,
RouteActionStatus.Syncing,
RouteActionStatus.FailedRetryable,
RouteActionStatus.Confirmed,
)
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
val projectable = actions
.filter { it.routeId == route.id && it.status in routeActionProjectionStatuses }
.filter { it.routeId == route.id && it.status in routeActionDataProjectionStatuses }
var projectedRoute = route
var loadingWeightPending = false
var unloadingWeightPending = false
@@ -39,25 +46,33 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
when (action.action) {
RouteActionType.Start -> {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = action.weight,
loadingNotes = action.notes,
trackingStatus = "active",
photos = projectedRoute.safePhotos(),
)
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
trackingStatus = "active",
)
}
loadingWeightPending = action.status != RouteActionStatus.Confirmed
}
RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = action.weight,
unloadingNotes = action.notes,
trackingStatus = "finished",
completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(),
)
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
trackingStatus = "finished",
completedAt = action.occurredAt,
)
}
unloadingWeightPending = action.status != RouteActionStatus.Confirmed
}
}
@@ -14,6 +14,8 @@ import kotlin.math.abs
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.TachographReminderTaskDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
@@ -40,6 +42,42 @@ data class AppUpdateBannerUi(
val downloaded: Boolean,
)
object RouteLocationMode {
const val Disabled = "disabled"
const val Optional = "optional"
const val Required = "required"
}
fun normalizeRouteLocationMode(value: String?): String = when (value) {
RouteLocationMode.Disabled,
RouteLocationMode.Required,
-> value
else -> RouteLocationMode.Optional
}
fun shouldTrackRouteLocation(
mode: String,
optionalLocationEnabled: Boolean,
hasLocationPermission: Boolean,
): Boolean = hasLocationPermission && when (normalizeRouteLocationMode(mode)) {
RouteLocationMode.Disabled -> false
RouteLocationMode.Required -> true
else -> optionalLocationEnabled
}
fun routeStartRequiresLocationPermission(
mode: String,
hasLocationPermission: Boolean,
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required && !hasLocationPermission
fun shouldWarnAboutRequiredRouteLocation(
mode: String,
hasActiveRoute: Boolean,
hasLocationPermission: Boolean,
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required &&
hasActiveRoute &&
!hasLocationPermission
fun appUpdateBannerUi(
currentVersionCode: Int,
latestVersionCode: Int?,
@@ -635,6 +673,16 @@ fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLo
fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean =
reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded")
fun shouldShowTachographReminderCard(reminder: TachographReminderDto?): Boolean =
reminder?.enabled == true &&
reminder.visible &&
!reminder.title.isNullOrBlank() &&
!reminder.message.isNullOrBlank() &&
!reminder.tasks.isNullOrEmpty()
fun defaultTachographConfirmationSelection(tasks: List<TachographReminderTaskDto>): Set<String> =
if (tasks.size == 1) setOf(tasks.single().id) else emptySet()
fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String =
when {
hasQueuedUpload -> "Wysyłam zdjęcie..."
@@ -1,7 +1,10 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.app.Application
import android.content.pm.PackageManager
import android.net.Uri
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.messaging.FirebaseMessaging
@@ -30,6 +33,7 @@ import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
@@ -81,6 +85,8 @@ data class DriverUiState(
val allowGalleryUploads: Boolean = true,
val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false,
val routeLocationMode: String = RouteLocationMode.Optional,
val routeLocationEnabled: Boolean = false,
val loadingPhotoRequirement: String = RouteStageRequirement.Required,
val loadingWeightRequirement: String = RouteStageRequirement.Required,
val unloadingPhotoRequirement: String = RouteStageRequirement.Required,
@@ -97,6 +103,8 @@ data class DriverUiState(
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val tachographReminderSubmitting: Boolean = false,
val appUpdate: AppUpdateDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null,
@@ -241,6 +249,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeProgressNotificationEnabled = enabled) }
}
}
viewModelScope.launch {
appPreferencesStore.routeLocationEnabled.collect { enabled ->
_state.update { it.copy(routeLocationEnabled = enabled) }
reconcileTrackingServiceFromState()
}
}
viewModelScope.launch {
photoUploadOutbox.observeQueuedUploads().collect { uploads ->
_state.update { it.copy(queuedPhotoUploads = uploads) }
@@ -459,6 +473,39 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
}
fun acknowledgeTachographReminder(taskIds: List<String>) {
val snapshot = _state.value
if (taskIds.isEmpty() || snapshot.tachographReminderSubmitting) return
if (!snapshot.isOnline) {
_state.update { it.copy(error = "Potwierdzenie odczytu wymaga połączenia z internetem.") }
return
}
viewModelScope.launch {
_state.update {
it.copy(
tachographReminderSubmitting = true,
error = null,
feedback = null,
)
}
runCatching { repository.acknowledgeTachographReminder(taskIds) }
.onSuccess { reminder ->
_state.update {
it.copy(
tachographReminder = reminder,
feedback = "Zapisano potwierdzenie odczytu.",
error = null,
)
}
}
.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(tachographReminderSubmitting = false) }
}
}
fun dismissRouteDayLiveUpdate() {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
}
@@ -498,6 +545,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
driver = response.session.driver,
routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder,
tachographReminder = response.tachographReminder,
appUpdate = response.appUpdate ?: it.appUpdate,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate,
@@ -507,6 +555,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos,
routeLocationMode = normalizeRouteLocationMode(settings?.routeLocationMode),
loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement),
loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement),
unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement),
@@ -531,7 +580,16 @@ 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) {
val trackingSnapshot = _state.value
if (
!shouldTrackRouteLocation(
trackingSnapshot.routeLocationMode,
trackingSnapshot.routeLocationEnabled,
hasLocationPermission(),
)
) {
ActiveRouteTrackingService.stop(getApplication())
} else if (trackingSnapshot.isOnline && !cached.stale && settings?.selectedDate == settings?.today) {
reconcileTrackingService(response.routes.today, response.session.driver)
}
}.onFailure { throwable ->
@@ -700,6 +758,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DiagnosticEntry("Galeria", if (runtime.allowGalleryUploads) "włączona" else "wyłączona"),
DiagnosticEntry("Kończenie kursu", if (runtime.allowRouteCompletion) "włączone" else "wyłączone"),
DiagnosticEntry("Dokładna lokalizacja zdjęć", if (runtime.requirePreciseLocationForPhotos) "wymagana" else "niewymagana"),
DiagnosticEntry("Lokalizacja kursu", runtime.routeLocationMode),
DiagnosticEntry("Wybór kierowcy", if (runtime.routeLocationEnabled) "włączony" else "wyłączony"),
DiagnosticEntry("Załadunek, zdjęcie", runtime.loadingPhotoRequirement),
DiagnosticEntry("Załadunek, tonaż", runtime.loadingWeightRequirement),
DiagnosticEntry("Rozładunek, zdjęcie", runtime.unloadingPhotoRequirement),
@@ -796,6 +856,36 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun setRouteLocationEnabled(enabled: Boolean) {
if (_state.value.routeLocationMode != RouteLocationMode.Optional) return
_state.update { it.copy(routeLocationEnabled = enabled, error = null) }
reconcileTrackingServiceFromState()
viewModelScope.launch {
runCatching { appPreferencesStore.setRouteLocationEnabled(enabled) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun onLocationPermissionGranted() {
val snapshot = _state.value
val driver = snapshot.driver ?: return
if (!shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())) {
return
}
val activeRoute = snapshot.displaySelectedRoute
?.takeIf { it.driverLifecycleStatus() == "W TRAKCIE" }
?: snapshot.displayRoutes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
?: return
ActiveRouteTrackingService.start(
context = getApplication(),
route = activeRoute,
driver = driver,
progressEnabled = snapshot.routeProgressNotificationEnabled,
)
}
fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
}
@@ -1107,6 +1197,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun openStartRoute() {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (routeStartRequiresLocationPermission(snapshot.routeLocationMode, hasLocationPermission())) {
_state.update { it.copy(error = "Lokalizacja jest wymagana, aby rozpocząć kurs.") }
return
}
if (!canStartRouteFromDriverApp(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") }
return
@@ -1211,6 +1305,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun retryConflictedRouteAction() {
val snapshot = _state.value
val action = (snapshot.visibleRouteActions + snapshot.routeActions)
.distinctBy { it.clientActionId }
.filter { it.status == RouteActionStatus.FailedConflict }
.maxByOrNull { it.createdAtEpochMillis }
?: return
viewModelScope.launch {
runCatching {
routeActionOutbox.retry(action.clientActionId)
}
.onSuccess {
_state.update {
it.copy(
feedback = "Ponawiam wysyłanie zapisanych danych.",
error = null,
)
}
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun submitStartRoute() {
submitRouteStage(stage = "loading")
}
@@ -1222,6 +1340,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private fun submitRouteStage(stage: String) {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (stage == "loading" && routeStartRequiresLocationPermission(snapshot.routeLocationMode, hasLocationPermission())) {
_state.update { it.copy(error = "Lokalizacja jest wymagana, aby rozpocząć kurs.") }
return
}
val canSubmitStage = if (stage == "loading") {
canStartRouteFromDriverApp(route, snapshot.selectedDate)
} else {
@@ -1333,7 +1455,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
if (stage == "loading" && driver != null) {
if (
stage == "loading" &&
driver != null &&
shouldTrackRouteLocation(
snapshot.routeLocationMode,
snapshot.routeLocationEnabled,
hasLocationPermission(),
)
) {
ActiveRouteTrackingService.start(
context = getApplication(),
route = updatedRoute,
@@ -1597,8 +1727,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) {
val snapshot = _state.value
val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
if (activeRoute == null) {
if (
activeRoute == null ||
!shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())
) {
ActiveRouteTrackingService.stop(getApplication())
} else {
ActiveRouteTrackingService.start(
@@ -1611,7 +1745,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) {
if (route.driverLifecycleStatus() == "W TRAKCIE") {
val snapshot = _state.value
if (
route.driverLifecycleStatus() == "W TRAKCIE" &&
shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())
) {
if (driver != null) {
ActiveRouteTrackingService.start(
context = getApplication(),
@@ -1625,6 +1763,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
private fun reconcileTrackingServiceFromState() {
val snapshot = _state.value
val driver = snapshot.driver
if (driver == null) {
ActiveRouteTrackingService.stop(getApplication())
return
}
reconcileTrackingService(snapshot.displayRoutes, driver)
}
private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private fun observeRouteActions(routeId: String) {
routeActionsJob?.cancel()
routeActionsJob = viewModelScope.launch {
@@ -0,0 +1,97 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.CancellationSignal
import android.os.Looper
import androidx.core.content.ContextCompat
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
private const val PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS = 5_000L
internal suspend fun currentPhotoLocation(context: Context): Location? {
if (!hasAnyLocationPermission(context)) return null
val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
val hasFineLocation = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
val providers = buildList {
if (hasFineLocation) add(LocationManager.GPS_PROVIDER)
add(LocationManager.NETWORK_PROVIDER)
}.filter { provider -> runCatching { manager.isProviderEnabled(provider) }.getOrDefault(false) }
for (provider in providers) {
val location = withTimeoutOrNull(PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS) {
manager.awaitSingleLocation(context, provider)
}
if (location != null) return location
}
return bestLastKnownLocation(manager)
}
@SuppressLint("MissingPermission")
private suspend fun LocationManager.awaitSingleLocation(
context: Context,
provider: String,
): Location? = suspendCancellableCoroutine { continuation ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val cancellationSignal = CancellationSignal()
continuation.invokeOnCancellation { cancellationSignal.cancel() }
runCatching {
getCurrentLocation(provider, cancellationSignal, context.mainExecutor) { location ->
if (continuation.isActive) continuation.resume(location)
}
}.onFailure {
if (continuation.isActive) continuation.resume(null)
}
return@suspendCancellableCoroutine
}
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
removeUpdates(this)
if (continuation.isActive) continuation.resume(location)
}
}
continuation.invokeOnCancellation { runCatching { removeUpdates(listener) } }
runCatching {
@Suppress("DEPRECATION")
requestSingleUpdate(provider, listener, Looper.getMainLooper())
}.onFailure {
if (continuation.isActive) continuation.resume(null)
}
}
private fun hasAnyLocationPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
@SuppressLint("MissingPermission")
private fun bestLastKnownLocation(manager: LocationManager): Location? {
val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)
return providers.mapNotNull { provider ->
runCatching {
if (manager.isProviderEnabled(provider) || provider == LocationManager.PASSIVE_PROVIDER) {
manager.getLastKnownLocation(provider)
} else {
null
}
}.getOrNull()
}.maxByOrNull { it.time }
}
@@ -0,0 +1,19 @@
package pl.firmatpp.kierowca.data.api
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Test
class ApiFactoryTest {
@Test
fun `request contains current driver app version header`() {
val request = Request.Builder()
.url("https://api.example.test/mobile/driver/bootstrap")
.header(DRIVER_APP_VERSION_HEADER, "old-version")
.build()
val updated = request.withDriverAppVersion("1.0.65")
assertEquals("1.0.65", updated.header(DRIVER_APP_VERSION_HEADER))
}
}
@@ -0,0 +1,61 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteActionPrerequisiteTest {
@Test
fun finishWaitsUntilEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val pendingStart = action(RouteActionType.Start, RouteActionStatus.Syncing, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Waiting,
routeActionPrerequisiteState(finish, pendingStart),
)
}
@Test
fun finishRunsAfterEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val confirmedStart = action(RouteActionType.Start, RouteActionStatus.Confirmed, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, confirmedStart),
)
}
@Test
fun failedStartBlocksFinishInsteadOfSendingItOutOfOrder() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val failedStart = action(RouteActionType.Start, RouteActionStatus.FailedConflict, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Failed,
routeActionPrerequisiteState(finish, failedStart),
)
}
@Test
fun finishWithoutLocalStartCanUseServerLifecycleState() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, null),
)
}
private fun action(type: String, status: String, createdAt: Long): RouteActionEntity =
RouteActionEntity(
clientActionId = "$type-$createdAt",
routeId = "route-1",
action = type,
weight = 12.5,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = "[]",
status = status,
createdAtEpochMillis = createdAt,
)
}
@@ -0,0 +1,135 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class RouteActionServerReconciliationTest {
@Test
fun matchingFinishedRouteConfirmsDuplicateFinishSafely() {
assertTrue(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uszkodzona plomba."),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Uszkodzona plomba.",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun differingNotesKeepConflictVisibleAndPreserveLocalData() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uwagi z telefonu"),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Inna uwaga na serwerze",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun missingServerPhotoDoesNotDiscardLocalFinish() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = null, notes = null),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(status = "ZAKOŃCZONA", weight = null, notes = null),
),
)
}
@Test
fun laterConfirmedFinishSupersedesAnOlderConflictingFinish() {
assertTrue(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Starsza uwaga"),
hasConfirmedFinishAfter = true,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
@Test
fun completedServerRouteAloneDoesNotDiscardConflictingLocalData() {
assertFalse(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Nadal ważna uwaga"),
hasConfirmedFinishAfter = false,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
private fun finish(weight: Double?, notes: String?): RouteActionEntity =
RouteActionEntity(
clientActionId = "finish-1",
routeId = "route-1",
action = RouteActionType.Finish,
weight = weight,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = """["photo-1"]""",
notes = notes,
)
private fun route(
status: String,
weight: Double?,
notes: String?,
photoClientRequestIds: List<String> = emptyList(),
): DriverRouteDto =
DriverRouteDto(
id = "route-1",
routeDate = "2026-07-23",
startsAt = "",
originName = "Baza",
destinationName = "Cel",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza → Cel",
status = status,
driverStatus = status,
unloadingWeight = weight,
unloadingNotes = notes,
distanceKm = 1.0,
notes = null,
truck = null,
photos = photoClientRequestIds.mapIndexed { index, requestId ->
RoutePhotoDto(
id = "photo-$index",
routeId = "route-1",
clientRequestId = requestId,
source = "camera",
stage = "unloading",
mimeType = "image/jpeg",
size = 1,
url = "https://example.test/photo-$index",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
createdAt = null,
)
},
)
}
@@ -125,16 +125,25 @@ class DriverRouteProjectionTest {
}
@Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() {
fun conflictKeepsLocalRouteValuesButPreservesServerLifecycleStatus() {
val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.FailedConflict, weight = 11.8)),
actions = listOf(
action(
RouteActionType.Finish,
RouteActionStatus.FailedConflict,
weight = 11.8,
notes = "Uszkodzona plomba.",
),
),
)
val steps = routeFlowSteps(projection.route, projection.visibleActions)
val callout = routeSyncCallout(projection.visibleActions)
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes)
assertTrue(projection.unloadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false)
@@ -148,8 +157,9 @@ class DriverRouteProjectionTest {
)
val steps = routeFlowSteps(projection.route, projection.visibleActions)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals("ZAPLANOWANA", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight)
assertTrue(projection.loadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
}
@@ -8,6 +8,8 @@ import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.TachographReminderTaskDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
@@ -16,6 +18,29 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType
class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30")
@Test
fun disabledRouteLocationNeverStartsTracking() {
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = true, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = false, hasLocationPermission = true))
}
@Test
fun optionalRouteLocationFollowsDriverChoiceAndPermission() {
assertTrue(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = false, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = false))
}
@Test
fun requiredRouteLocationBlocksStartAndWarnsOnlyWhenNeeded() {
assertTrue(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = false))
assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = true))
assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Optional, hasLocationPermission = false))
assertTrue(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = true, hasLocationPermission = false))
assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = false, hasLocationPermission = false))
assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Optional, hasActiveRoute = true, hasLocationPermission = false))
}
@Test
fun allowsPhotoActionsOnlyForTodayRouteDate() {
assertTrue(canManageRoutePhotos("2026-06-30", today))
@@ -387,6 +412,57 @@ class DriverUiRulesTest {
assertFalse(shouldShowDispatchSheetReminderCard(null))
}
@Test
fun showsTachographCardOnlyForCompleteReminderEnabledByBackend() {
assertTrue(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = true,
visible = true,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = false,
visible = true,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = true,
visible = false,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(shouldShowTachographReminderCard(null))
}
@Test
fun preselectsOnlySingleTachographConfirmationTask() {
val driverCard = tachographTask()
val vehicle = TachographReminderTaskDto(
id = "vehicle:12:2026-07-31",
type = "vehicle_tachograph",
label = "Tachograf pojazdu",
)
assertEquals(setOf(driverCard.id), defaultTachographConfirmationSelection(listOf(driverCard)))
assertEquals(emptySet<String>(), defaultTachographConfirmationSelection(listOf(driverCard, vehicle)))
}
@Test
fun explainsOfflineStateWithoutCachedSync() {
assertEquals(
@@ -794,4 +870,11 @@ class DriverUiRulesTest {
photo = null,
canUpload = dueToday,
)
private fun tachographTask(): TachographReminderTaskDto =
TachographReminderTaskDto(
id = "driver-card:1:2026-07-31",
type = "driver_card",
label = "Karta kierowcy",
)
}
@@ -0,0 +1,43 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteDatePickerBoundsTest {
@Test
fun usesTheSameMinAndMaxDatesAsTheRouteSelector() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-24",
minDate = "2026-07-17",
maxDate = "2026-07-26",
)
assertEquals(LocalDate.parse("2026-07-24"), bounds.selectedDate)
assertEquals(LocalDate.parse("2026-07-17"), bounds.minDate)
assertEquals(LocalDate.parse("2026-07-26"), bounds.maxDate)
}
@Test
fun clampsSelectedDateToAllowedRange() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-30",
minDate = "2026-07-17",
maxDate = "2026-07-26",
)
assertEquals(LocalDate.parse("2026-07-26"), bounds.selectedDate)
}
@Test
fun normalizesAccidentallyReversedServerBounds() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-24",
minDate = "2026-07-26",
maxDate = "2026-07-17",
)
assertEquals(LocalDate.parse("2026-07-17"), bounds.minDate)
assertEquals(LocalDate.parse("2026-07-26"), bounds.maxDate)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
[versions]
agp = "8.7.3"
agp = "8.10.1"
kotlin = "2.1.10"
composeBom = "2026.03.00"
activityCompose = "1.9.3"