Compare commits

..
19 Commits
Author SHA1 Message Date
admin 0bbdbdb4f1 chore: bump Android app to 1.0.37 2026-07-04 12:06:46 +02:00
admin fe2e2e589a feat: add dispatch sheet reminder to driver app 2026-07-04 12:03:34 +02:00
admin e0115e85fc Pin live update toast over routes 2026-07-02 16:29:49 +02:00
admin 0e15d5f9a4 Show live day updates and route date chip 2026-07-02 16:24:06 +02:00
admin 5555676395 Shorten driver Reverb heartbeat 2026-07-02 16:08:24 +02:00
admin ca512f5993 Load Reverb config from mobile bootstrap 2026-07-02 15:33:42 +02:00
admin 64d6f5a41d Add new route push notifications 2026-07-02 15:25:54 +02:00
admin a3d39764e2 Report driver Reverb connection status 2026-07-02 14:58:12 +02:00
admin 705b8904a4 Reconcile confirmed photo uploads with server state 2026-07-02 14:42:52 +02:00
admin 541dda292f Add photo upload failure details 2026-07-01 22:47:13 +02:00
admin fa98451533 Add photo upload retry controls 2026-07-01 22:39:47 +02:00
admin ee9d8cdbaf Update driver app photo handling 2026-07-01 19:26:28 +02:00
admin 76326bbc27 Fix launcher icon clipping for 1.0.22 2026-07-01 14:49:11 +02:00
admin ed02da031b Refine launcher icon for 1.0.21 2026-07-01 14:44:06 +02:00
admin a4e24da717 Fix launcher icon safe area for 1.0.20 2026-07-01 14:20:54 +02:00
admin ab84205bec Update Android app icon for 1.0.18 2026-07-01 12:46:38 +02:00
admin 9ba1b1fa13 Dodaj live sync i kolejkę zdjęć 2026-07-01 10:59:21 +02:00
admin 06557e77b8 Dodaj usuwanie świeżo zapisanych zdjęć 2026-07-01 02:43:52 +02:00
admin a08fe31d3d Popraw obsługę galerii zdjęć kierowcy 2026-07-01 02:37:43 +02:00
80 changed files with 3925 additions and 412 deletions
+4
View File
@@ -1,5 +1,9 @@
.gradle/
build/
local.properties
app/google-services.json
app/google-services.json:Zone.Identifier
*/build/
*.iml
keystore.properties
*.jks
+49 -2
View File
@@ -1,3 +1,6 @@
import java.io.File
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -5,19 +8,57 @@ plugins {
alias(libs.plugins.ksp)
}
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use(::load)
}
}
android {
namespace = "pl.firmatpp.kierowca"
compileSdk = 35
fun configValue(name: String, default: String): String =
providers.gradleProperty(name)
.orElse(providers.environmentVariable(name))
.orElse(default)
.get()
defaultConfig {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 15
versionName = "1.0.14"
versionCode = 38
versionName = "1.0.37"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
buildConfigField("String", "REVERB_APP_KEY", "\"${configValue("REVERB_APP_KEY", "")}\"")
buildConfigField("String", "REVERB_WS_BASE_URL", "\"${configValue("REVERB_WS_BASE_URL", "wss://api-intranet.firmatpp.pl/app/")}\"")
}
signingConfigs {
create("release") {
fun requiredSigningProperty(name: String): String =
keystoreProperties.getProperty(name)
?: throw GradleException("Missing '$name' in keystore.properties")
val configuredStoreFile = requiredSigningProperty("storeFile")
storeFile = if (File(configuredStoreFile).isAbsolute) {
file(configuredStoreFile)
} else {
rootProject.file(configuredStoreFile)
}
storePassword = requiredSigningProperty("storePassword")
keyAlias = requiredSigningProperty("keyAlias")
keyPassword = requiredSigningProperty("keyPassword")
}
}
buildTypes {
@@ -25,7 +66,11 @@ android {
isMinifyEnabled = false
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -77,6 +122,8 @@ dependencies {
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.coroutines.android)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.play.services.auth)
+9
View File
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
@@ -30,5 +31,13 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".sync.DriverFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -1,6 +1,9 @@
package pl.firmatpp.kierowca
import android.os.Bundle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -9,13 +12,26 @@ import pl.firmatpp.kierowca.ui.DriverViewModel
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
setContent {
TppKierowcaTheme {
val viewModel: DriverViewModel = viewModel()
DriverApp(viewModel = viewModel)
DriverApp(viewModel = viewModel, initialRouteId = notificationRouteId)
}
}
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
}
companion object {
const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID"
}
}
@@ -20,6 +20,7 @@ data class ApiError(
val retryable: Boolean,
val code: String? = null,
val statusCode: Int? = null,
val responseBody: String? = null,
)
object ApiErrorMapper {
@@ -48,10 +49,17 @@ object ApiErrorMapper {
code = code,
message = message,
retryable = retryable,
responseBody = body,
)
}
fun mapProblem(statusCode: Int, code: String?, message: String?, retryable: Boolean?): ApiError {
fun mapProblem(
statusCode: Int,
code: String?,
message: String?,
retryable: Boolean?,
responseBody: String? = null,
): ApiError {
val kind = when (statusCode) {
401 -> ApiErrorKind.Auth
403 -> ApiErrorKind.Forbidden
@@ -80,6 +88,7 @@ object ApiErrorMapper {
retryable = resolvedRetryable,
code = code,
statusCode = statusCode,
responseBody = responseBody,
)
}
@@ -12,12 +12,21 @@ 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.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody
import pl.firmatpp.kierowca.data.model.RealtimeStatusBody
import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverRepository(
@@ -42,6 +51,31 @@ class DriverRepository(
suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId)
suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId)
suspend fun updateNotificationPreferences(notifyNewRoutes: Boolean): NotificationPreferencesDto =
api.updateNotificationPreferences(authHeader(requireToken()), NotificationPreferencesBody(notifyNewRoutes))
suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse =
api.syncState(authHeader(requireToken()), date, routeId)
suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse =
api.broadcastAuth(authHeader(requireToken()), BroadcastAuthBody(socketId, channelName))
suspend fun storePushToken(token: String) {
api.storePushToken(authHeader(requireToken()), PushTokenBody(token))
}
suspend fun storeRealtimeStatus(status: String, socketId: String? = null, error: String? = null) {
api.storeRealtimeStatus(authHeader(requireToken()), RealtimeStatusBody(status, socketId, error))
}
suspend fun deletePushToken() {
val token = tokenStore.read() ?: return
runCatching { api.deletePushToken(authHeader(token)) }
}
suspend fun uploadPhoto(
routeId: String,
uri: Uri,
@@ -99,6 +133,32 @@ class DriverRepository(
)
}
suspend fun uploadQueuedDispatchSheetPhoto(upload: DispatchSheetUploadEntity): DispatchSheetUploadResponse {
val file = File(upload.localPath)
val body = file.readBytes().toRequestBody(upload.mimeType.toMediaTypeOrNull())
val photo = MultipartBody.Part.createFormData("photo", file.name, body)
val metadataParts = PhotoUploadMetadata(
takenAt = upload.takenAt,
latitude = upload.latitude,
longitude = upload.longitude,
locationAccuracyMeters = upload.locationAccuracyMeters,
).toMultipartTextParts()
return api.uploadDispatchSheetPhoto(
authHeader(requireToken()),
upload.clientRequestId,
photo,
upload.clientRequestId.toPlainTextBody(),
upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(),
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
metadataParts["locationAccuracyMeters"],
upload.replacePhotoId?.toPlainTextBody(),
)
}
suspend fun deletePhoto(photoId: String) {
api.deletePhoto(authHeader(requireToken()), photoId)
}
@@ -106,6 +166,7 @@ class DriverRepository(
suspend fun logout() {
val token = tokenStore.read()
if (token != null) {
runCatching { api.deletePushToken(authHeader(token)) }
runCatching { api.logout(authHeader(token)) }
}
tokenStore.clear()
@@ -3,10 +3,19 @@ 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.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody
import pl.firmatpp.kierowca.data.model.RealtimeStatusBody
import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.model.VerifyOtpResponse
import retrofit2.http.Body
@@ -15,6 +24,7 @@ import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
@@ -29,6 +39,17 @@ interface MobileDriverApi {
@POST("mobile/driver/auth/logout")
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Boolean>
@GET("mobile/driver/notification-preferences")
suspend fun notificationPreferences(
@Header("Authorization") authorization: String,
): NotificationPreferencesDto
@PUT("mobile/driver/notification-preferences")
suspend fun updateNotificationPreferences(
@Header("Authorization") authorization: String,
@Body body: NotificationPreferencesBody,
): NotificationPreferencesDto
@GET("mobile/driver/bootstrap")
suspend fun bootstrap(
@Header("Authorization") authorization: String,
@@ -41,6 +62,42 @@ interface MobileDriverApi {
@Path("routeId") routeId: String,
): RouteResponse
@POST("mobile/driver/routes/{routeId}/complete")
suspend fun completeRoute(
@Header("Authorization") authorization: String,
@Path("routeId") routeId: String,
): CompleteRouteResponse
@GET("mobile/driver/sync-state")
suspend fun syncState(
@Header("Authorization") authorization: String,
@Query("date") date: String?,
@Query("routeId") routeId: String?,
): SyncStateResponse
@POST("mobile/driver/broadcasting/auth")
suspend fun broadcastAuth(
@Header("Authorization") authorization: String,
@Body body: BroadcastAuthBody,
): BroadcastAuthResponse
@PUT("mobile/driver/push-token")
suspend fun storePushToken(
@Header("Authorization") authorization: String,
@Body body: PushTokenBody,
): Map<String, Boolean>
@PUT("mobile/driver/realtime-status")
suspend fun storeRealtimeStatus(
@Header("Authorization") authorization: String,
@Body body: RealtimeStatusBody,
): Map<String, Boolean>
@DELETE("mobile/driver/push-token")
suspend fun deletePushToken(
@Header("Authorization") authorization: String,
): Map<String, Boolean>
@Multipart
@POST("mobile/driver/routes/{routeId}/photos")
suspend fun uploadPhoto(
@@ -57,6 +114,22 @@ interface MobileDriverApi {
@Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?,
): PhotoUploadResponse
@Multipart
@POST("mobile/driver/dispatch-sheet-photos")
suspend fun uploadDispatchSheetPhoto(
@Header("Authorization") authorization: String,
@Header("Idempotency-Key") idempotencyKey: String,
@Part photo: MultipartBody.Part,
@Part("clientRequestId") clientRequestId: RequestBody,
@Part("contentSha256") contentSha256: RequestBody,
@Part("source") source: RequestBody,
@Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?,
@Part("replacePhotoId") replacePhotoId: RequestBody?,
): DispatchSheetUploadResponse
@DELETE("mobile/driver/photos/{photoId}")
suspend fun deletePhoto(
@Header("Authorization") authorization: String,
@@ -46,6 +46,10 @@ data class BootstrapResponse(
val session: DriverSessionDto,
val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null,
)
data class DriverAppSettingsDto(
@@ -56,6 +60,38 @@ data class DriverAppSettingsDto(
val maxPastDays: Int,
val autoRefreshSeconds: Int,
val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?,
val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null,
)
data class DispatchSheetReminderDto(
val enabled: Boolean = false,
val dueToday: Boolean = false,
val workDate: String? = null,
val reason: String? = null,
val availableUntil: String? = null,
val status: String = "not_required",
val photo: DispatchSheetPhotoDto? = null,
val canUpload: Boolean = false,
)
data class DispatchSheetPhotoDto(
val id: String,
val driverId: String,
val workDate: String?,
val clientRequestId: String? = null,
val contentSha256: String? = null,
val source: String,
val mimeType: String?,
val size: Long,
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
val locationAccuracyMeters: Double?,
val createdAt: String?,
)
data class DriverSessionDto(
@@ -63,16 +99,73 @@ data class DriverSessionDto(
val lastSyncAt: String,
)
data class NotificationPreferencesDto(
val notifyNewRoutes: Boolean = false,
)
data class RealtimeConfigDto(
val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null,
)
data class RoutesBucketDto(
val today: List<DriverRouteDto> = emptyList(),
)
data class RouteResponse(
val route: DriverRouteDto,
val syncState: SyncStateResponse? = null,
)
data class CompleteRouteResponse(
val ok: Boolean,
val alreadyCompleted: Boolean,
val message: String?,
val completedAt: String?,
val route: DriverRouteDto,
)
data class SyncStateResponse(
val scopes: List<SyncScopeDto> = emptyList(),
)
data class SyncScopeDto(
val scope: String,
val date: String? = null,
val routeId: String? = null,
val version: Long,
val checksum: String,
val computedAt: String,
)
data class BroadcastAuthBody(
val socket_id: String,
val channel_name: String,
)
data class BroadcastAuthResponse(
val auth: String,
)
data class PushTokenBody(
val token: String,
val platform: String = "android",
)
data class NotificationPreferencesBody(
val notifyNewRoutes: Boolean,
)
data class RealtimeStatusBody(
val reverbStatus: String,
val socketId: String? = null,
val error: String? = null,
)
data class DriverRouteDto(
val id: String,
val routeDate: String? = null,
val startsAt: String,
val originName: String,
val destinationName: String,
@@ -84,6 +177,8 @@ data class DriverRouteDto(
val distanceKm: Double?,
val notes: String?,
val truck: TruckDto?,
val completedAt: String? = null,
val completedByDriver: DriverDto? = null,
val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null,
val photos: List<RoutePhotoDto> = emptyList(),
@@ -132,3 +227,16 @@ data class PhotoUploadReceiptDto(
val contentSha256: String,
val storedAt: String?,
)
data class DispatchSheetUploadResponse(
val photo: DispatchSheetPhotoDto,
val reminder: DispatchSheetReminderDto,
val receipt: DispatchSheetUploadReceiptDto,
)
data class DispatchSheetUploadReceiptDto(
val clientRequestId: String,
val serverPhotoId: String,
val contentSha256: String,
val storedAt: String?,
)
@@ -0,0 +1,36 @@
package pl.firmatpp.kierowca.data.sync
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface DriverCacheDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertBootstrap(cache: DriverBootstrapCacheEntity)
@Query("SELECT * FROM driver_bootstrap_cache WHERE date = :date LIMIT 1")
suspend fun bootstrap(date: String): DriverBootstrapCacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertRoute(cache: DriverRouteCacheEntity)
@Query("SELECT * FROM driver_route_cache WHERE routeId = :routeId LIMIT 1")
suspend fun route(routeId: String): DriverRouteCacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSyncState(state: DriverSyncStateEntity)
@Query("SELECT * FROM driver_sync_states WHERE scope = :scope AND ifnull(date, '-') = ifnull(:date, '-') AND ifnull(routeId, '-') = ifnull(:routeId, '-') LIMIT 1")
suspend fun syncState(scope: String, date: String?, routeId: String?): DriverSyncStateEntity?
@Query("DELETE FROM driver_bootstrap_cache")
suspend fun clearBootstrap()
@Query("DELETE FROM driver_route_cache")
suspend fun clearRoutes()
@Query("DELETE FROM driver_sync_states")
suspend fun clearSyncStates()
}
@@ -0,0 +1,49 @@
package pl.firmatpp.kierowca.data.sync
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "driver_bootstrap_cache")
data class DriverBootstrapCacheEntity(
@PrimaryKey val date: String,
val payloadJson: String,
val checksum: String?,
val version: Long?,
val syncedAtEpochMillis: Long,
)
@Entity(tableName = "driver_route_cache")
data class DriverRouteCacheEntity(
@PrimaryKey val routeId: String,
val payloadJson: String,
val checksum: String?,
val version: Long?,
val syncedAtEpochMillis: Long,
)
@Entity(
tableName = "driver_sync_states",
indices = [
Index(value = ["scope", "date", "routeId"], unique = true),
],
)
data class DriverSyncStateEntity(
@PrimaryKey val key: String,
val scope: String,
val date: String?,
val routeId: String?,
val checksum: String,
val version: Long,
val computedAt: String,
val syncedAtEpochMillis: Long,
)
data class CachedValue<T>(
val value: T,
val stale: Boolean,
val syncedAtEpochMillis: Long?,
)
fun syncStateKey(scope: String, date: String?, routeId: String?): String =
listOf(scope, date ?: "-", routeId ?: "-").joinToString(":")
@@ -0,0 +1,155 @@
package pl.firmatpp.kierowca.data.sync
import android.content.Context
import com.google.gson.Gson
import java.io.IOException
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncScopeDto
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.upload.DriverDatabase
import retrofit2.HttpException
class DriverSyncRepository(
context: Context,
private val repository: DriverRepository = DriverRepository(context),
private val dao: DriverCacheDao = DriverDatabase.get(context).driverCacheDao(),
private val gson: Gson = Gson(),
) {
suspend fun bootstrap(date: String?): CachedValue<BootstrapResponse> {
val requestedDate = date ?: currentDateFallback()
return runCatching {
val response = repository.bootstrap(date)
val selectedDate = response.driverAppSettings?.selectedDate ?: requestedDate
val routeState = response.syncState?.scopes?.firstOrNull { it.scope == SCOPE_ROUTES }
dao.upsertBootstrap(
DriverBootstrapCacheEntity(
date = selectedDate,
payloadJson = gson.toJson(response),
checksum = routeState?.checksum,
version = routeState?.version,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
cachedBootstrap(requestedDate, throwable)
}
}
suspend fun route(routeId: String): CachedValue<RouteResponse> =
runCatching {
val response = repository.route(routeId)
val routeState = response.syncState?.scopes?.firstOrNull { it.scope == SCOPE_ROUTE_DETAIL }
dao.upsertRoute(
DriverRouteCacheEntity(
routeId = routeId,
payloadJson = gson.toJson(response),
checksum = routeState?.checksum,
version = routeState?.version,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
cachedRoute(routeId, throwable)
}
suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) {
val syncedAt = System.currentTimeMillis()
dao.upsertRoute(
DriverRouteCacheEntity(
routeId = route.id,
payloadJson = gson.toJson(RouteResponse(route = route)),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
val selectedDate = date ?: return
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap(
cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
}
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
repository.syncState(date, routeId)
suspend fun shouldRefresh(scope: SyncScopeDto): Boolean {
val local = dao.syncState(scope.scope, scope.date, scope.routeId)
return local == null || local.checksum != scope.checksum || local.version < scope.version
}
suspend fun saveSyncStates(response: SyncStateResponse?) {
response?.scopes.orEmpty().forEach { scope ->
dao.upsertSyncState(
DriverSyncStateEntity(
key = syncStateKey(scope.scope, scope.date, scope.routeId),
scope = scope.scope,
date = scope.date,
routeId = scope.routeId,
checksum = scope.checksum,
version = scope.version,
computedAt = scope.computedAt,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
}
}
suspend fun clearCache() {
dao.clearBootstrap()
dao.clearRoutes()
dao.clearSyncStates()
}
private suspend fun cachedBootstrap(date: String, throwable: Throwable): CachedValue<BootstrapResponse> {
val cached = dao.bootstrap(date)
if (cached != null && throwable.isReadFallbackAllowed()) {
return CachedValue(
gson.fromJson(cached.payloadJson, BootstrapResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
throw throwable
}
private suspend fun cachedRoute(routeId: String, throwable: Throwable): CachedValue<RouteResponse> {
val cached = dao.route(routeId)
if (cached != null && throwable.isReadFallbackAllowed()) {
return CachedValue(
gson.fromJson(cached.payloadJson, RouteResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
throw throwable
}
private fun Throwable.isReadFallbackAllowed(): Boolean =
this is IOException || (this is HttpException && code() != 401 && code() != 403)
private fun currentDateFallback(): String = java.time.LocalDate.now().toString()
companion object {
const val SCOPE_ROUTES = "routes"
const val SCOPE_ROUTE_DETAIL = "route_detail"
const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
}
}
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.data.sync
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
class NetworkMonitor(context: Context) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val isOnline: Flow<Boolean> = callbackFlow {
fun current(): Boolean = connectivityManager.activeNetwork
?.let(connectivityManager::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(current())
}
override fun onLost(network: Network) {
trySend(current())
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
trySend(current())
}
}
trySend(current())
connectivityManager.registerDefaultNetworkCallback(callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}
@@ -0,0 +1,66 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface DispatchSheetUploadDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(upload: DispatchSheetUploadEntity)
@Query("SELECT * FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId LIMIT 1")
suspend fun find(clientRequestId: String): DispatchSheetUploadEntity?
@Query(
"""
SELECT * FROM dispatch_sheet_uploads
WHERE workDate = :workDate
AND status != 'CANCELLED'
ORDER BY createdAtEpochMillis ASC
""",
)
fun observeVisibleForWorkDate(workDate: String): Flow<List<DispatchSheetUploadEntity>>
@Query(
"""
UPDATE dispatch_sheet_uploads
SET status = :status,
progress = :progress,
lastError = :lastError,
attemptCount = attemptCount + :attemptIncrement,
updatedAtEpochMillis = :updatedAt
WHERE clientRequestId = :clientRequestId
""",
)
suspend fun updateStatus(
clientRequestId: String,
status: String,
progress: Int,
lastError: String?,
attemptIncrement: Int,
updatedAt: Long = System.currentTimeMillis(),
)
@Query(
"""
UPDATE dispatch_sheet_uploads
SET status = 'CONFIRMED',
progress = 100,
lastError = NULL,
serverPhotoId = :serverPhotoId,
updatedAtEpochMillis = :updatedAt
WHERE clientRequestId = :clientRequestId
""",
)
suspend fun markConfirmed(
clientRequestId: String,
serverPhotoId: String,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("DELETE FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String)
}
@@ -0,0 +1,37 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "dispatch_sheet_uploads",
indices = [
Index(value = ["workDate"]),
Index(value = ["status"]),
],
)
data class DispatchSheetUploadEntity(
@PrimaryKey val clientRequestId: String,
val workDate: String,
val replacePhotoId: String?,
val localPath: String,
val source: String,
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
val locationAccuracyMeters: Double?,
val mimeType: String,
val size: Long,
val contentSha256: String,
val status: String = PhotoUploadStatus.Pending.storageValue,
val progress: Int = 0,
val attemptCount: Int = 0,
val lastError: String? = null,
val serverPhotoId: String? = null,
val createdAtEpochMillis: Long = System.currentTimeMillis(),
val updatedAtEpochMillis: Long = System.currentTimeMillis(),
) {
val statusType: PhotoUploadStatus
get() = PhotoUploadStatus.fromStorage(status)
}
@@ -0,0 +1,102 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import android.net.Uri
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import java.io.File
import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.Flow
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
class DispatchSheetUploadOutbox(
private val context: Context,
private val dao: DispatchSheetUploadDao = DriverDatabase.get(context).dispatchSheetUploadDao(),
private val workManager: WorkManager = WorkManager.getInstance(context),
) {
fun observeVisibleForWorkDate(workDate: String): Flow<List<DispatchSheetUploadEntity>> =
dao.observeVisibleForWorkDate(workDate)
suspend fun enqueue(
workDate: String,
replacePhotoId: String?,
uri: Uri,
source: String,
metadata: PhotoUploadMetadata,
): DispatchSheetUploadEntity {
val clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) {
"image/png" -> "png"
"image/webp" -> "webp"
else -> "jpg"
}
val uploadDir = File(context.filesDir, "dispatch-sheet-upload-outbox").apply { mkdirs() }
val localFile = File(uploadDir, "$clientRequestId.$extension")
val sha256 = copyAndHash(uri, localFile)
val upload = DispatchSheetUploadEntity(
clientRequestId = clientRequestId,
workDate = workDate,
replacePhotoId = replacePhotoId,
localPath = localFile.absolutePath,
source = source,
takenAt = metadata.takenAt,
latitude = metadata.latitude,
longitude = metadata.longitude,
locationAccuracyMeters = metadata.locationAccuracyMeters,
mimeType = mimeType,
size = localFile.length(),
contentSha256 = sha256,
)
dao.upsert(upload)
enqueueWorker(clientRequestId)
return upload
}
suspend fun discard(clientRequestId: String) {
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
}
private fun enqueueWorker(clientRequestId: String) {
val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>()
.setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork(
DispatchSheetUploadWorker.uniqueWorkName(clientRequestId),
ExistingWorkPolicy.REPLACE,
request,
)
}
private fun copyAndHash(uri: Uri, target: File): String {
val digest = MessageDigest.getInstance("SHA-256")
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Nie można odczytać zdjęcia karty spedycyjnej." }
target.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read == -1) break
digest.update(buffer, 0, read)
output.write(buffer, 0, read)
}
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,10 @@
package pl.firmatpp.kierowca.data.upload
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadReceiptDto
object DispatchSheetUploadReceiptVerifier {
fun matches(upload: DispatchSheetUploadEntity, receipt: DispatchSheetUploadReceiptDto): Boolean =
upload.clientRequestId == receipt.clientRequestId &&
upload.contentSha256.equals(receipt.contentSha256, ignoreCase = true) &&
receipt.serverPhotoId.isNotBlank()
}
@@ -0,0 +1,86 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
class DispatchSheetUploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val dao = DriverDatabase.get(appContext).dispatchSheetUploadDao()
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure()
val upload = dao.find(clientRequestId) ?: return Result.failure()
val file = File(upload.localPath)
if (!file.exists()) {
val message = "Lokalny plik zdjęcia karty spedycyjnej nie istnieje. Zdjęcie nie zostało zapisane."
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.FailedPermanent.storageValue,
progress = 0,
lastError = PhotoUploadFailureDetails.local(message),
attemptIncrement = 0,
)
return Result.failure()
}
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.Uploading.storageValue,
progress = 10,
lastError = null,
attemptIncrement = 1,
)
setProgress(workDataOf(KEY_PROGRESS to 10))
return runCatching {
setProgress(workDataOf(KEY_PROGRESS to 70))
val response = repository.uploadQueuedDispatchSheetPhoto(upload)
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.Verifying.storageValue,
progress = 90,
lastError = null,
attemptIncrement = 0,
)
setProgress(workDataOf(KEY_PROGRESS to 90))
val receipt = response.receipt
if (!DispatchSheetUploadReceiptVerifier.matches(upload, receipt)) {
error("Serwer nie potwierdził zgodności zdjęcia karty spedycyjnej. Zdjęcie nie zostało zapisane.")
}
dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
val status = if (error.retryable) PhotoUploadStatus.FailedRetryable else PhotoUploadStatus.FailedPermanent
dao.updateStatus(
clientRequestId = clientRequestId,
status = status.storageValue,
progress = 0,
lastError = PhotoUploadFailureDetails.fromApiError(error),
attemptIncrement = 0,
)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
}
companion object {
const val KEY_CLIENT_REQUEST_ID = "clientRequestId"
const val KEY_PROGRESS = "progress"
fun uniqueWorkName(clientRequestId: String): String = "dispatch-sheet-upload-$clientRequestId"
}
}
@@ -1,29 +1,117 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.room.migration.Migration
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import pl.firmatpp.kierowca.data.sync.DriverBootstrapCacheEntity
import pl.firmatpp.kierowca.data.sync.DriverCacheDao
import pl.firmatpp.kierowca.data.sync.DriverRouteCacheEntity
import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
@Database(
entities = [PhotoUploadEntity::class],
version = 1,
entities = [
PhotoUploadEntity::class,
DispatchSheetUploadEntity::class,
DriverBootstrapCacheEntity::class,
DriverRouteCacheEntity::class,
DriverSyncStateEntity::class,
],
version = 3,
exportSchema = false,
)
abstract class DriverDatabase : RoomDatabase() {
abstract fun photoUploadDao(): PhotoUploadDao
abstract fun dispatchSheetUploadDao(): DispatchSheetUploadDao
abstract fun driverCacheDao(): DriverCacheDao
companion object {
@Volatile
private var instance: DriverDatabase? = null
private val migration1To2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS driver_bootstrap_cache (
date TEXT NOT NULL PRIMARY KEY,
payloadJson TEXT NOT NULL,
checksum TEXT,
version INTEGER,
syncedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS driver_route_cache (
routeId TEXT NOT NULL PRIMARY KEY,
payloadJson TEXT NOT NULL,
checksum TEXT,
version INTEGER,
syncedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS driver_sync_states (
key TEXT NOT NULL PRIMARY KEY,
scope TEXT NOT NULL,
date TEXT,
routeId TEXT,
checksum TEXT NOT NULL,
version INTEGER NOT NULL,
computedAt TEXT NOT NULL,
syncedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_driver_sync_states_scope_date_routeId ON driver_sync_states(scope, date, routeId)")
}
}
private val migration2To3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS dispatch_sheet_uploads (
clientRequestId TEXT NOT NULL PRIMARY KEY,
workDate TEXT NOT NULL,
replacePhotoId TEXT,
localPath TEXT NOT NULL,
source TEXT NOT NULL,
takenAt TEXT,
latitude REAL,
longitude REAL,
locationAccuracyMeters REAL,
mimeType TEXT NOT NULL,
size INTEGER NOT NULL,
contentSha256 TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER NOT NULL,
attemptCount INTEGER NOT NULL,
lastError TEXT,
serverPhotoId TEXT,
createdAtEpochMillis INTEGER NOT NULL,
updatedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_dispatch_sheet_uploads_workDate ON dispatch_sheet_uploads(workDate)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_dispatch_sheet_uploads_status ON dispatch_sheet_uploads(status)")
}
}
fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
DriverDatabase::class.java,
"driver-local-outbox.db",
).build().also { instance = it }
).addMigrations(migration1To2, migration2To3).build().also { instance = it }
}
}
}
@@ -27,12 +27,21 @@ interface PhotoUploadDao {
@Query(
"""
SELECT * FROM photo_uploads
WHERE routeId = :routeId
AND status = 'CONFIRMED'
AND clientRequestId IN (:clientRequestIds)
WHERE status != 'CONFIRMED'
AND status != 'CANCELLED'
ORDER BY createdAtEpochMillis ASC
""",
)
suspend fun confirmedForServerPhotos(routeId: String, clientRequestIds: List<String>): List<PhotoUploadEntity>
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>>
@Query(
"""
SELECT * FROM photo_uploads
WHERE routeId = :routeId
AND status = 'CONFIRMED'
""",
)
suspend fun confirmedForRoute(routeId: String): List<PhotoUploadEntity>
@Query(
"""
@@ -0,0 +1,39 @@
package pl.firmatpp.kierowca.data.upload
import pl.firmatpp.kierowca.data.ApiError
import pl.firmatpp.kierowca.data.ApiErrorKind
object PhotoUploadFailureDetails {
fun fromApiError(error: ApiError): String {
val type = when (error.kind) {
ApiErrorKind.Network -> "błąd komunikacji"
ApiErrorKind.Server,
ApiErrorKind.RateLimited,
ApiErrorKind.Validation,
ApiErrorKind.Forbidden,
ApiErrorKind.Auth,
ApiErrorKind.Conflict,
-> "błąd serwera"
ApiErrorKind.Unknown -> "błąd aplikacji"
}
return buildString {
appendLine("Typ: $type")
appendLine("Komunikat: ${error.message}")
error.statusCode?.let { appendLine("HTTP: $it") }
error.code?.takeIf { it.isNotBlank() }?.let { appendLine("Kod: $it") }
error.responseBody
?.takeIf { it.isNotBlank() }
?.let {
appendLine("Odpowiedź serwera:")
append(it.take(MAX_RESPONSE_CHARS))
if (it.length > MAX_RESPONSE_CHARS) append("...")
}
}.trim()
}
fun local(message: String): String =
"Typ: błąd aplikacji\nKomunikat: $message"
private const val MAX_RESPONSE_CHARS = 1200
}
@@ -25,6 +25,9 @@ class PhotoUploadOutbox(
fun observeVisibleForRoute(routeId: String): Flow<List<PhotoUploadEntity>> =
dao.observeVisibleForRoute(routeId)
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> =
dao.observeQueuedUploads()
suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata): PhotoUploadEntity {
val clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
@@ -69,11 +72,15 @@ class PhotoUploadOutbox(
enqueueWorker(clientRequestId)
}
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
val confirmedRequestIds = route.photos.mapNotNull { it.clientRequestId }.distinct()
if (confirmedRequestIds.isEmpty()) return
suspend fun discard(clientRequestId: String) {
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
}
dao.confirmedForServerPhotos(route.id, confirmedRequestIds).forEach { upload ->
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
dao.confirmedForRoute(route.id).forEach { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
@@ -22,11 +22,12 @@ class PhotoUploadWorker(
val file = File(upload.localPath)
if (!file.exists()) {
val message = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane."
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.FailedPermanent.storageValue,
progress = 0,
lastError = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane.",
lastError = PhotoUploadFailureDetails.local(message),
attemptIncrement = 0,
)
return Result.failure()
@@ -68,7 +69,7 @@ class PhotoUploadWorker(
clientRequestId = clientRequestId,
status = status.storageValue,
progress = 0,
lastError = error.message,
lastError = PhotoUploadFailureDetails.fromApiError(error),
attemptIncrement = 0,
)
@@ -0,0 +1,42 @@
package pl.firmatpp.kierowca.sync
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import pl.firmatpp.kierowca.data.DriverRepository
class DriverFirebaseMessagingService : FirebaseMessagingService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onNewToken(token: String) {
scope.launch {
val repository = DriverRepository(applicationContext)
if (repository.hasToken()) {
runCatching { repository.storePushToken(token) }
}
}
}
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
if (data["type"] == "driver_new_route_notification") {
NewRouteNotificationWorker.enqueue(
context = applicationContext,
routeId = data["routeId"],
date = data["date"],
)
return
}
if (data["type"] != "driver_sync_hint") return
DriverSyncWorker.enqueue(
context = applicationContext,
date = data["date"],
routeId = data["routeId"],
)
}
}
@@ -0,0 +1,186 @@
package pl.firmatpp.kierowca.sync
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.Job
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
class DriverLiveSyncClient(
private val repository: DriverRepository,
private val onConnected: () -> Unit,
private val onHint: (DriverSyncHint) -> Unit,
private val client: OkHttpClient = OkHttpClient(),
private val gson: Gson = Gson(),
) {
private companion object {
const val HEARTBEAT_INTERVAL_MS = 10_000L
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val started = AtomicBoolean(false)
private var webSocket: WebSocket? = null
private var driverId: String? = null
private var realtimeConfig: RealtimeConfigDto? = null
private var socketId: String? = null
private var heartbeatJob: Job? = null
fun start(driverId: String, config: RealtimeConfigDto?) {
val appKey = config?.reverbAppKey?.takeIf { it.isNotBlank() } ?: return
val wsBaseUrl = config.reverbWsBaseUrl?.takeIf { it.isNotBlank() } ?: return
if (!config.reverbEnabled) return
realtimeConfig = config
this.driverId = driverId
if (!started.compareAndSet(false, true)) return
val wsUrl = wsBaseUrl.trimEnd('/') +
"/" + appKey +
"?protocol=7&client=android&version=1.0&flash=false"
webSocket = client.newWebSocket(
Request.Builder().url(wsUrl).build(),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
stopHeartbeat()
reportRealtimeStatus("disconnected", reason.takeIf { it.isNotBlank() })
started.set(false)
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
stopHeartbeat()
reportRealtimeStatus("error", t.message ?: response?.message)
started.set(false)
scheduleReconnect()
}
},
)
}
fun stop() {
started.set(false)
stopHeartbeat()
reportRealtimeStatus("disconnected", "client_stop")
webSocket?.close(1000, "logout")
webSocket = null
driverId = null
realtimeConfig = null
socketId = null
}
fun close() {
stop()
scope.cancel()
}
private fun handleMessage(socket: WebSocket, text: String) {
val root = runCatching { JsonParser.parseString(text).asJsonObject }.getOrNull() ?: return
val event = root.string("event") ?: return
when (event) {
"pusher:connection_established" -> {
socketId = root.dataObject()?.string("socket_id") ?: return
subscribe(socket, socketId ?: return)
}
"pusher_internal:subscription_succeeded" -> {
val channel = root.string("channel")
val id = driverId ?: return
if (channel == "private-driver-mobile.$id") {
reportRealtimeStatus("connected")
startHeartbeat()
onConnected()
}
}
"DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint)
}
}
private fun subscribe(socket: WebSocket, socketId: String) {
val id = driverId ?: return
val channel = "private-driver-mobile.$id"
scope.launch {
runCatching {
val auth = repository.broadcastAuth(socketId, channel).auth
val payload = mapOf(
"event" to "pusher:subscribe",
"data" to mapOf(
"channel" to channel,
"auth" to auth,
),
)
socket.send(gson.toJson(payload))
}
}
}
private fun parseHint(data: JsonObject?): DriverSyncHint? {
if (data == null || data.string("type") != "driver_sync_hint") return null
return DriverSyncHint(
scope = data.string("scope") ?: return null,
date = data.string("date"),
routeId = data.string("routeId"),
version = data.string("version")?.toLongOrNull() ?: data.get("version")?.asLong ?: return null,
checksum = data.string("checksum") ?: return null,
)
}
private fun JsonObject.dataObject(): JsonObject? {
val data = get("data") ?: return null
return if (data.isJsonObject) data.asJsonObject else runCatching { JsonParser.parseString(data.asString).asJsonObject }.getOrNull()
}
private fun JsonObject.string(name: String): String? =
get(name)?.takeIf { !it.isJsonNull }?.asString
private fun scheduleReconnect() {
val id = driverId ?: return
scope.launch {
delay(5_000)
if (!started.get() && driverId == id) {
start(id, realtimeConfig)
}
}
}
private fun startHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = scope.launch {
while (started.get()) {
reportRealtimeStatus("connected")
delay(HEARTBEAT_INTERVAL_MS)
}
}
}
private fun stopHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = null
}
private fun reportRealtimeStatus(status: String, error: String? = null) {
scope.launch {
runCatching {
repository.storeRealtimeStatus(status, socketId, error)
}
}
}
}
@@ -0,0 +1,21 @@
package pl.firmatpp.kierowca.sync
import pl.firmatpp.kierowca.data.model.SyncScopeDto
data class DriverSyncHint(
val scope: String,
val date: String?,
val routeId: String?,
val version: Long,
val checksum: String,
) {
fun asScope(): SyncScopeDto =
SyncScopeDto(
scope = scope,
date = date,
routeId = routeId,
version = version,
checksum = checksum,
computedAt = "",
)
}
@@ -0,0 +1,82 @@
package pl.firmatpp.kierowca.sync
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
class DriverSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val syncRepository = DriverSyncRepository(appContext)
override suspend fun doWork(): Result =
runCatching {
val date = inputData.getString(KEY_DATE)
val routeId = inputData.getString(KEY_ROUTE_ID)
val response = syncRepository.fetchSyncState(date, routeId)
var refreshed = false
response.scopes.forEach { scope ->
if (syncRepository.shouldRefresh(scope)) {
when (scope.scope) {
DriverSyncRepository.SCOPE_ROUTES -> {
syncRepository.bootstrap(scope.date ?: date)
refreshed = true
}
DriverSyncRepository.SCOPE_ROUTE_DETAIL -> {
val id = scope.routeId ?: routeId
if (!id.isNullOrBlank()) {
syncRepository.route(id)
refreshed = true
}
}
DriverSyncRepository.SCOPE_SETTINGS,
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> {
syncRepository.bootstrap(scope.date ?: date)
refreshed = true
}
}
}
}
if (!refreshed) {
syncRepository.saveSyncStates(response)
}
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
companion object {
private const val KEY_DATE = "date"
private const val KEY_ROUTE_ID = "routeId"
fun enqueue(context: Context, date: String?, routeId: String?) {
val request = OneTimeWorkRequestBuilder<DriverSyncWorker>()
.setInputData(workDataOf(KEY_DATE to date, KEY_ROUTE_ID to routeId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
listOf("driver-sync", date ?: "-", routeId ?: "-").joinToString("-"),
ExistingWorkPolicy.REPLACE,
request,
)
}
}
}
@@ -0,0 +1,61 @@
package pl.firmatpp.kierowca.sync
import java.time.LocalDate
import java.util.Locale
import java.time.DayOfWeek
import java.time.format.DateTimeFormatter
import pl.firmatpp.kierowca.data.model.DriverRouteDto
object NewRouteNotificationFormatter {
private val polishLocale = Locale("pl", "PL")
private val monthFormatter = DateTimeFormatter.ofPattern("d MMMM", polishLocale)
fun title(routeDate: String?, today: LocalDate = LocalDate.now()): String {
val label = humanDate(routeDate, today) ?: return "Nowy kurs"
return "Nowy kurs: $label"
}
fun collapsedText(route: DriverRouteDto): String =
listOf(route.originName, route.destinationName)
.filter(String::isNotBlank)
.joinToString("")
.ifBlank { route.relationLabel }
.let { relation ->
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode
if (contract.isNullOrBlank()) relation else "$relation · $contract"
}
fun expandedText(route: DriverRouteDto): String {
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode ?: "Brak nazwy kontraktu"
return listOf(
"Skąd: ${route.originName.ifBlank { "Nie podano" }}",
"Dokąd: ${route.destinationName.ifBlank { "Nie podano" }}",
"Kontrakt: $contract",
).joinToString("\n")
}
private fun humanDate(routeDate: String?, today: LocalDate): String? {
val date = runCatching { routeDate?.let(LocalDate::parse) }.getOrNull() ?: return null
return when {
date == today -> "dzisiaj"
date == today.plusDays(1) -> "jutro"
date.isAfter(today.plusDays(1)) && !date.isAfter(today.plusDays(7)) ->
"w ${weekdayAccusative(date.dayOfWeek)}"
else -> date.format(monthFormatter).lowercase(polishLocale)
}
}
private fun weekdayAccusative(dayOfWeek: DayOfWeek): String =
when (dayOfWeek) {
DayOfWeek.MONDAY -> "poniedziałek"
DayOfWeek.TUESDAY -> "wtorek"
DayOfWeek.WEDNESDAY -> "środę"
DayOfWeek.THURSDAY -> "czwartek"
DayOfWeek.FRIDAY -> "piątek"
DayOfWeek.SATURDAY -> "sobotę"
DayOfWeek.SUNDAY -> "niedzielę"
}
}
@@ -0,0 +1,125 @@
package pl.firmatpp.kierowca.sync
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.MainActivity
import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import retrofit2.HttpException
class NewRouteNotificationWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
val routeId = inputData.getString(KEY_ROUTE_ID)?.takeIf(String::isNotBlank) ?: return Result.success()
return runCatching {
val route = repository.route(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 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(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(contentTitle)
.setContentText(contentText)
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedText))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
Result.success()
}.getOrElse { throwable ->
if (throwable is HttpException && throwable.code() == 404) {
Result.success()
} else {
val error = ApiErrorMapper.map(throwable)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.success()
}
}
}
private fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
"Nowe kursy",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Powiadomienia o nowych kursach dodanych przez spedytora."
}
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
}
private fun canShowNotifications(context: Context): Boolean {
val hasRuntimePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
return hasRuntimePermission && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
companion object {
private const val CHANNEL_ID = "new_routes"
private const val KEY_ROUTE_ID = "routeId"
private const val KEY_DATE = "date"
fun enqueue(context: Context, routeId: String?, date: String?) {
if (routeId.isNullOrBlank()) return
val request = OneTimeWorkRequestBuilder<NewRouteNotificationWorker>()
.setInputData(workDataOf(KEY_ROUTE_ID to routeId, KEY_DATE to date))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"driver-new-route-notification-$routeId",
ExistingWorkPolicy.REPLACE,
request,
)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,158 @@
package pl.firmatpp.kierowca.ui
import java.io.File
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
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.upload.PhotoUploadEntity
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
fun canCompleteRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
fun shouldShowRouteDayLiveUpdate(viewedDate: String, hintDate: String?): Boolean =
viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate)
fun routeDateChipLabel(routeDate: String?, today: LocalDate = LocalDate.now()): String {
val date = routeDate
?.takeIf { it.isNotBlank() }
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: return ""
val fallback = date.format(shortDateFormatter)
val days = ChronoUnit.DAYS.between(today, date)
val human = when {
days == 0L -> "dzisiaj"
days == 1L -> "jutro"
days in 2L..7L -> polishWeekdayPhrase(date.dayOfWeek)
else -> null
}
return human?.let { "$it · $fallback" } ?: fallback
}
private fun polishWeekdayPhrase(day: DayOfWeek): String =
when (day) {
DayOfWeek.MONDAY -> "w poniedziałek"
DayOfWeek.TUESDAY -> "we wtorek"
DayOfWeek.WEDNESDAY -> "w środę"
DayOfWeek.THURSDAY -> "w czwartek"
DayOfWeek.FRIDAY -> "w piątek"
DayOfWeek.SATURDAY -> "w sobotę"
DayOfWeek.SUNDAY -> "w niedzielę"
}
fun inlinePhotoGridRows(photoCount: Int): Int =
if (photoCount <= 0) 0 else (photoCount + 1) / 2
fun authBrandBannerHeightDp(screenHeightDp: Int): Int =
when {
screenHeightDp < 640 -> 118
screenHeightDp < 740 -> 150
else -> 190
}
fun appBrandHeaderHeightDp(screenHeightDp: Int): Int =
when {
screenHeightDp < 640 -> 92
screenHeightDp < 740 -> 108
else -> 124
}
fun bottomNavigationHeightDp(screenHeightDp: Int): Int =
when {
screenHeightDp < 640 -> 72
screenHeightDp < 740 -> 78
else -> 82
}
fun screenHorizontalPaddingDp(screenWidthDp: Int): Int =
when {
screenWidthDp < 360 -> 14
screenWidthDp < 420 -> 16
else -> 20
}
fun phonePrefixWidthDp(screenWidthDp: Int): Int =
when {
screenWidthDp < 360 -> 70
screenWidthDp < 420 -> 82
else -> 94
}
fun routeFactColumns(screenWidthDp: Int): Int =
if (screenWidthDp < 360) 1 else 2
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
fun queuedPhotoUploadCount(statuses: List<String>): Int =
statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" }
fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Long): Long =
parseIsoOffsetEpochMillis(createdAt)
?: parseIsoOffsetEpochMillis(takenAt)
?: fallback
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
fun canRetryPhotoUpload(status: String): Boolean =
status == "FAILED_RETRYABLE"
fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean =
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
if (upload.localPath.isBlank()) return null
return RoutePhotoDto(
id = upload.clientRequestId,
routeId = upload.routeId,
clientRequestId = upload.clientRequestId,
contentSha256 = upload.contentSha256,
source = upload.source,
mimeType = upload.mimeType,
size = upload.size,
url = File(upload.localPath).toURI().toString(),
takenAt = upload.takenAt,
latitude = upload.latitude,
longitude = upload.longitude,
locationAccuracyMeters = upload.locationAccuracyMeters,
createdAt = null,
canDelete = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null,
deleteUntil = null,
)
}
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
!requirePreciseLocation || hasFineLocation
fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean =
reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded")
fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String =
when {
hasQueuedUpload -> "Wysyłam zdjęcie..."
reminder?.status == "uploaded" -> "Popraw"
else -> "Zrób zdjęcie"
}
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
}
@@ -4,6 +4,7 @@ import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -11,15 +12,24 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDate
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.ApiErrorKind
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.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox
import pl.firmatpp.kierowca.sync.DriverLiveSyncClient
import pl.firmatpp.kierowca.sync.DriverSyncHint
import pl.firmatpp.kierowca.sync.DriverSyncWorker
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo }
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue }
data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing,
@@ -35,23 +45,64 @@ data class DriverUiState(
val maxRouteDate: String = LocalDate.now().plusDays(1).toString(),
val autoRefreshSeconds: Int = 60,
val allowGalleryUploads: Boolean = true,
val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false,
val notifyNewRoutes: Boolean = false,
val notificationPermissionDenied: Boolean = false,
val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null,
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val deletingPhotoIds: Set<String> = emptySet(),
val completingRoute: Boolean = false,
val imageAuthHeader: String? = null,
val isOnline: Boolean = true,
val isStale: Boolean = false,
val lastSuccessfulSyncAtEpochMillis: Long? = null,
val routeDayLiveUpdateMessage: String? = null,
val feedback: String? = null,
val error: String? = null,
)
class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DriverRepository(application)
private val syncRepository = DriverSyncRepository(application, repository)
private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application)
private val dispatchSheetUploadOutbox = DispatchSheetUploadOutbox(application)
private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
private val liveSyncClient = DriverLiveSyncClient(
repository = repository,
onConnected = { viewModelScope.launch { checkRemoteSyncState() } },
onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } },
)
private var photoUploadsJob: Job? = null
private var dispatchSheetUploadsJob: Job? = null
private var pushTokenRegisteredForDriverId: String? = null
val state: StateFlow<DriverUiState> = _state
init {
viewModelScope.launch {
photoUploadOutbox.observeQueuedUploads().collect { uploads ->
_state.update { it.copy(queuedPhotoUploads = uploads) }
}
}
viewModelScope.launch {
networkMonitor.isOnline.collect { online ->
_state.update {
it.copy(
isOnline = online,
isStale = if (!online && it.lastSuccessfulSyncAtEpochMillis != null) true else it.isStale,
)
}
if (online && repository.hasToken()) {
refreshCurrentScopeFromSyncState()
}
}
}
viewModelScope.launch {
if (repository.hasToken()) {
refreshRoutes()
@@ -94,34 +145,60 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
fun selectRouteDate(date: String) = loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
fun selectRouteDate(date: String) {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
}
fun dismissRouteDayLiveUpdate() {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
}
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null) }
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
runCatching {
val response = repository.bootstrap(date)
val cached = syncRepository.bootstrap(date)
val response = cached.value
val settings = response.driverAppSettings
_state.update {
it.copy(
screen = if (navigateToRoutes) DriverScreen.Routes else it.screen,
driver = response.session.driver,
routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate,
maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds,
allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads,
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos,
notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
)
}
response.session.driver.id.let { driverId ->
liveSyncClient.start(driverId, response.realtime)
registerPushTokenIfAvailable(driverId)
}
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
}.onFailure { throwable ->
_state.update {
val apiError = ApiErrorMapper.map(throwable)
it.copy(
screen = if (it.screen == DriverScreen.Initializing) DriverScreen.Phone else it.screen,
error = throwable.message ?: "Wystapil blad.",
screen = when {
apiError.kind == ApiErrorKind.Auth -> DriverScreen.Phone
it.screen == DriverScreen.Initializing -> DriverScreen.Routes
else -> it.screen
},
isStale = it.lastSuccessfulSyncAtEpochMillis != null,
error = apiError.message,
)
}
}
@@ -133,9 +210,19 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun openRoute(routeId: String) = runLoading {
val response = repository.route(routeId)
val cached = syncRepository.route(routeId)
val response = cached.value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update { it.copy(screen = DriverScreen.Detail, selectedRoute = response.route, error = null) }
_state.update {
it.copy(
screen = DriverScreen.Detail,
selectedRoute = response.route,
feedback = null,
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
)
}
observePhotoUploads(routeId)
}
@@ -143,15 +230,18 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val routeId = _state.value.selectedRoute?.id ?: return
viewModelScope.launch {
_state.update { it.copy(refreshing = true, error = null) }
_state.update { it.copy(refreshing = true, feedback = null, error = null) }
runCatching { repository.route(routeId) }
.onSuccess { response ->
runCatching { syncRepository.route(routeId) }
.onSuccess { cached ->
val response = cached.value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
selectedRoute = response.route,
routes = it.routes.map { route -> if (route.id == routeId) response.route else route },
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
)
}
@@ -168,6 +258,40 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(screen = DriverScreen.Profile, error = null) }
}
fun updateNewRouteNotifications(enabled: Boolean) {
viewModelScope.launch {
_state.update { it.copy(refreshing = true, error = null, notificationPermissionDenied = false) }
runCatching { repository.updateNotificationPreferences(enabled) }
.onSuccess { preferences ->
_state.update {
it.copy(
notifyNewRoutes = preferences.notifyNewRoutes,
feedback = if (preferences.notifyNewRoutes) "Powiadomienia o nowych kursach są włączone." else "Powiadomienia o nowych kursach są wyłączone.",
error = null,
)
}
}
.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(refreshing = false) }
}
}
fun markNotificationPermissionDenied() {
_state.update {
it.copy(
notifyNewRoutes = false,
notificationPermissionDenied = true,
error = "Aby otrzymywać powiadomienia o nowych kursach, włącz powiadomienia w ustawieniach aplikacji.",
)
}
}
fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
}
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val route = _state.value.selectedRoute ?: return
viewModelScope.launch {
@@ -179,6 +303,27 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun uploadDispatchSheetPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val reminder = _state.value.dispatchSheetReminder ?: return
val workDate = reminder.workDate ?: return
if (!reminder.canUpload) return
viewModelScope.launch {
_state.update { it.copy(error = null) }
runCatching {
dispatchSheetUploadOutbox.enqueue(
workDate = workDate,
replacePhotoId = reminder.photo?.id,
uri = uri,
source = source,
metadata = metadata,
)
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
}
}
fun retryPhotoUpload(upload: PhotoUploadEntity) {
viewModelScope.launch {
runCatching { photoUploadOutbox.retry(upload.clientRequestId) }
@@ -194,7 +339,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + photo.id, error = null) }
runCatching {
repository.deletePhoto(photo.id)
val response = repository.route(route.id)
val response = syncRepository.route(route.id).value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
@@ -210,17 +355,79 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun deleteConfirmedUpload(upload: PhotoUploadEntity) {
val route = _state.value.selectedRoute ?: return
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) ?: return
viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) }
runCatching {
repository.deletePhoto(serverPhotoId)
photoUploadOutbox.discard(upload.clientRequestId)
val response = syncRepository.route(route.id).value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
selectedRoute = response.route,
routes = it.routes.map { item -> if (item.id == route.id) response.route else item },
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - serverPhotoId) }
}
}
fun completeSelectedRoute() {
val route = _state.value.selectedRoute ?: return
if (_state.value.completingRoute || !_state.value.isOnline) return
viewModelScope.launch {
_state.update { it.copy(completingRoute = true, feedback = null, error = null) }
runCatching {
val completed = repository.completeRoute(route.id).route
syncRepository.cacheConfirmedRoute(completed, _state.value.selectedDate)
val refreshed = runCatching { syncRepository.route(route.id) }
.getOrNull()
?.takeUnless { it.stale }
?.value
?.route
?: completed
photoUploadOutbox.discardConfirmedServerPhotos(refreshed)
_state.update {
it.copy(
selectedRoute = refreshed,
routes = it.routes.map { item -> if (item.id == route.id) refreshed else item },
feedback = "Kurs zakończony",
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.copy(feedback = null, error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(completingRoute = false) }
}
}
fun openPhoto(photo: RoutePhotoDto) {
_state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
}
fun openRouteFromNotification(routeId: String?) {
if (routeId.isNullOrBlank()) return
openRoute(routeId)
}
fun back() {
_state.update {
when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Detail -> {
photoUploadsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList())
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null)
}
DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone)
@@ -231,7 +438,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun logout() = runLoading {
photoUploadsJob?.cancel()
dispatchSheetUploadsJob?.cancel()
liveSyncClient.stop()
repository.logout()
syncRepository.clearCache()
pushTokenRegisteredForDriverId = null
_state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) }
}
@@ -254,4 +465,92 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
}
private fun observeDispatchSheetUploads(workDate: String?) {
dispatchSheetUploadsJob?.cancel()
if (workDate.isNullOrBlank()) {
_state.update { it.copy(dispatchSheetUploads = emptyList()) }
return
}
dispatchSheetUploadsJob = viewModelScope.launch {
dispatchSheetUploadOutbox.observeVisibleForWorkDate(workDate).collect { uploads ->
_state.update { it.copy(dispatchSheetUploads = uploads) }
}
}
}
private fun refreshCurrentScopeFromSyncState() {
val snapshot = _state.value
if (snapshot.screen == DriverScreen.Routes) {
refreshRoutesSilently()
return
}
if (snapshot.screen == DriverScreen.Detail) {
refreshSelectedRoute()
}
}
private suspend fun checkRemoteSyncState() {
val snapshot = _state.value
val routeId = snapshot.selectedRoute?.id
val remote = runCatching { syncRepository.fetchSyncState(snapshot.selectedDate, routeId) }.getOrNull() ?: return
val needsRefresh = remote.scopes.any { syncRepository.shouldRefresh(it) }
if (needsRefresh) {
refreshCurrentScopeFromSyncState()
} else {
syncRepository.saveSyncStates(remote)
}
}
private suspend fun handleSyncHint(hint: DriverSyncHint) {
if (!syncRepository.shouldRefresh(hint.asScope())) return
val snapshot = _state.value
when (hint.scope) {
DriverSyncRepository.SCOPE_ROUTES -> {
if (shouldShowRouteDayLiveUpdate(snapshot.selectedDate, hint.date)) {
refreshRoutesSilently()
if (snapshot.screen == DriverScreen.Routes) {
_state.update {
it.copy(routeDayLiveUpdateMessage = "Spedytor zaktualizował zlecenia dla tego dnia.")
}
}
} else {
DriverSyncWorker.enqueue(getApplication(), hint.date, null)
}
}
DriverSyncRepository.SCOPE_ROUTE_DETAIL -> {
if (hint.routeId == snapshot.selectedRoute?.id) {
refreshSelectedRoute()
} else {
DriverSyncWorker.enqueue(getApplication(), hint.date, hint.routeId)
}
}
DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently()
}
}
private fun registerPushTokenIfAvailable(driverId: String) {
if (pushTokenRegisteredForDriverId == driverId) return
runCatching {
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
viewModelScope.launch {
runCatching {
repository.storePushToken(token)
pushTokenRegisteredForDriverId = driverId
}
}
}
}
}
override fun onCleared() {
liveSyncClient.close()
dispatchSheetUploadsJob?.cancel()
super.onCleared()
}
}
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.ui
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
object PolishPhoneNumberVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val rawDigits = text.text.filter(Char::isDigit).take(9)
val formatted = rawDigits.chunked(3).joinToString(" ")
return TransformedText(
AnnotatedString(formatted),
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
val safeOffset = offset.coerceIn(0, rawDigits.length)
val spacesBeforeOffset = when {
safeOffset <= 3 -> 0
safeOffset <= 6 -> 1
else -> 2
}
return (safeOffset + spacesBeforeOffset).coerceAtMost(formatted.length)
}
override fun transformedToOriginal(offset: Int): Int {
val safeOffset = offset.coerceIn(0, formatted.length)
return formatted
.take(safeOffset)
.count(Char::isDigit)
.coerceAtMost(rawDigits.length)
}
},
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -1,4 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F7FAF8" />
</shape>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFF"
android:pathData="M0,0h108v108h-108z"/>
</vector>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0B192E</color>
</resources>
@@ -39,4 +39,16 @@ class ApiErrorMapperTest {
assertTrue(error.retryable)
assertEquals("Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie.", error.message)
}
@Test
fun keepsRawServerResponseForTroubleshooting() {
val body = """{"code":"PHOTO_SERVER_UNAVAILABLE","message":"Storage timeout","retryable":true}"""
val error = ApiErrorMapper.mapHttpStatus(503, body)
assertEquals(ApiErrorKind.Server, error.kind)
assertEquals(503, error.statusCode)
assertEquals("PHOTO_SERVER_UNAVAILABLE", error.code)
assertEquals("Storage timeout", error.message)
assertEquals(body, error.responseBody)
}
}
@@ -0,0 +1,65 @@
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.DispatchSheetUploadReceiptDto
class DispatchSheetUploadReceiptVerifierTest {
private val upload = DispatchSheetUploadEntity(
clientRequestId = "6f7a7d10-b7f7-41ab-8f5e-f1afc3e93736",
workDate = "2026-07-03",
replacePhotoId = "12",
localPath = "/tmp/dispatch-sheet.jpg",
source = "camera",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
mimeType = "image/jpeg",
size = 1200,
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
@Test
fun confirmsOnlyMatchingReceipt() {
assertTrue(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = upload.clientRequestId,
serverPhotoId = "55",
contentSha256 = upload.contentSha256.uppercase(),
storedAt = "2026-07-03T17:02:00+02:00",
),
),
)
}
@Test
fun rejectsDifferentRequestOrHash() {
assertFalse(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = "other",
serverPhotoId = "55",
contentSha256 = upload.contentSha256,
storedAt = null,
),
),
)
assertFalse(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = upload.clientRequestId,
serverPhotoId = "55",
contentSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
storedAt = null,
),
),
)
}
}
@@ -0,0 +1,44 @@
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.ApiError
import pl.firmatpp.kierowca.data.ApiErrorKind
class PhotoUploadFailureDetailsTest {
@Test
fun describesServerFailureWithStatusCodeBackendCodeAndResponseBody() {
val details = PhotoUploadFailureDetails.fromApiError(
ApiError(
kind = ApiErrorKind.Server,
message = "Storage timeout",
retryable = true,
code = "PHOTO_STORAGE_FAILED",
statusCode = 503,
responseBody = """{"code":"PHOTO_STORAGE_FAILED","message":"Storage timeout"}""",
),
)
assertTrue(details.contains("Typ: błąd serwera"))
assertTrue(details.contains("HTTP: 503"))
assertTrue(details.contains("Kod: PHOTO_STORAGE_FAILED"))
assertTrue(details.contains("Odpowiedź serwera:"))
assertTrue(details.contains("Storage timeout"))
}
@Test
fun describesNetworkFailureWithoutHttpStatus() {
val details = PhotoUploadFailureDetails.fromApiError(
ApiError(
kind = ApiErrorKind.Network,
message = "Nie udało się połączyć z serwerem.",
retryable = true,
),
)
assertTrue(details.contains("Typ: błąd komunikacji"))
assertTrue(details.contains("Nie udało się połączyć z serwerem."))
assertFalse(details.contains("HTTP:"))
}
}
@@ -0,0 +1,46 @@
package pl.firmatpp.kierowca.sync
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
class NewRouteNotificationFormatterTest {
@Test
fun formatsHumanRouteDates() {
val today = LocalDate.of(2026, 7, 2)
assertEquals("Nowy kurs: dzisiaj", NewRouteNotificationFormatter.title("2026-07-02", today))
assertEquals("Nowy kurs: jutro", NewRouteNotificationFormatter.title("2026-07-03", today))
assertEquals("Nowy kurs: w sobotę", NewRouteNotificationFormatter.title("2026-07-04", today))
assertEquals("Nowy kurs: 12 lipca", NewRouteNotificationFormatter.title("2026-07-12", today))
}
@Test
fun formatsCollapsedAndExpandedRouteText() {
val route = DriverRouteDto(
id = "42",
routeDate = "2026-07-03",
startsAt = "09:15",
originName = "TPP Baza Krakow",
destinationName = "Instalacja Odpadowa Slask",
contractorName = "TPP Baza Krakow",
contractName = "Odpady przemyslowe",
contractCode = "TPP-ODP-24",
relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask",
status = "ZAPLANOWANA",
distanceKm = 91.4,
notes = null,
truck = null,
)
assertEquals(
"TPP Baza Krakow → Instalacja Odpadowa Slask · Odpady przemyslowe",
NewRouteNotificationFormatter.collapsedText(route),
)
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Skąd: TPP Baza Krakow"))
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Dokąd: Instalacja Odpadowa Slask"))
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Kontrakt: Odpady przemyslowe"))
}
}
@@ -5,6 +5,9 @@ import org.junit.Assert.assertEquals
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.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30")
@@ -22,6 +25,33 @@ class DriverUiRulesTest {
assertFalse(canManageRoutePhotos("not-a-date", today))
}
@Test
fun allowsRouteCompletionOnlyForTodayPlannedOrInProgressRoutes() {
assertTrue(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-30", today))
assertTrue(canCompleteRouteFromDriverApp(route(status = "W TRAKCIE"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAKOŃCZONA"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "NIEROZPOZNANA"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-07-01", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
}
@Test
fun showsLiveRouteDayUpdateOnlyForViewedDate() {
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12"))
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = null))
assertFalse(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-13"))
assertFalse(shouldShowRouteDayLiveUpdate(viewedDate = "", hintDate = "2026-03-12"))
}
@Test
fun formatsRouteDateChipHumanFirstWithShortFallback() {
assertEquals("dzisiaj · 30.06", routeDateChipLabel("2026-06-30", today))
assertEquals("jutro · 01.07", routeDateChipLabel("2026-07-01", today))
assertEquals("w poniedziałek · 06.07", routeDateChipLabel("2026-07-06", today))
assertEquals("02.03", routeDateChipLabel("2027-03-02", today))
assertEquals("", routeDateChipLabel("", today))
}
@Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0))
@@ -30,4 +60,194 @@ class DriverUiRulesTest {
assertEquals(2, inlinePhotoGridRows(3))
assertEquals(3, inlinePhotoGridRows(5))
}
@Test
fun usesShorterChromeOnCompactScreens() {
assertEquals(118, authBrandBannerHeightDp(600))
assertEquals(150, authBrandBannerHeightDp(690))
assertEquals(190, authBrandBannerHeightDp(780))
assertEquals(92, appBrandHeaderHeightDp(600))
assertEquals(108, appBrandHeaderHeightDp(690))
assertEquals(124, appBrandHeaderHeightDp(780))
assertEquals(72, bottomNavigationHeightDp(600))
assertEquals(78, bottomNavigationHeightDp(690))
assertEquals(82, bottomNavigationHeightDp(780))
}
@Test
fun reducesHorizontalSpaceOnNarrowScreens() {
assertEquals(14, screenHorizontalPaddingDp(340))
assertEquals(16, screenHorizontalPaddingDp(390))
assertEquals(20, screenHorizontalPaddingDp(440))
assertEquals(70, phonePrefixWidthDp(340))
assertEquals(82, phonePrefixWidthDp(390))
assertEquals(94, phonePrefixWidthDp(440))
}
@Test
fun stacksDenseRouteFactsOnNarrowScreens() {
assertEquals(1, routeFactColumns(340))
assertEquals(2, routeFactColumns(390))
}
@Test
fun countsServerPhotosAndLocalUploadsAsVisibleAttachments() {
assertEquals(0, visiblePhotoAttachmentCount(serverPhotoCount = 0, localUploadCount = 0))
assertEquals(1, visiblePhotoAttachmentCount(serverPhotoCount = 0, localUploadCount = 1))
assertEquals(3, visiblePhotoAttachmentCount(serverPhotoCount = 1, localUploadCount = 2))
}
@Test
fun countsOnlyUnconfirmedNonCancelledUploadsInQueue() {
assertEquals(0, queuedPhotoUploadCount(emptyList()))
assertEquals(
4,
queuedPhotoUploadCount(
listOf(
"PENDING",
"UPLOADING",
"VERIFYING",
"FAILED_RETRYABLE",
"CONFIRMED",
"CANCELLED",
),
),
)
assertEquals(1, queuedPhotoUploadCount(listOf("FAILED_PERMANENT")))
}
@Test
fun usesCreatedAtBeforeTakenAtForNewestFirstPhotoSorting() {
val fallback = 100L
assertEquals(
1_782_892_800_000L,
routePhotoSortEpochMillis(
createdAt = "2026-07-01T10:00:00+02:00",
takenAt = "2026-07-01T08:00:00+02:00",
fallback = fallback,
),
)
assertEquals(
1_782_885_600_000L,
routePhotoSortEpochMillis(
createdAt = null,
takenAt = "2026-07-01T08:00:00+02:00",
fallback = fallback,
),
)
assertEquals(fallback, routePhotoSortEpochMillis(createdAt = null, takenAt = null, fallback = fallback))
}
@Test
fun allowsDeleteIconForConfirmedLocalUploadWithServerPhotoId() {
assertEquals("42", confirmedUploadServerPhotoId("CONFIRMED", "42"))
assertEquals(null, confirmedUploadServerPhotoId("CONFIRMED", ""))
assertEquals(null, confirmedUploadServerPhotoId("UPLOADING", "42"))
assertEquals(null, confirmedUploadServerPhotoId("FAILED_RETRYABLE", "42"))
}
@Test
fun allowsRetryActionOnlyForRetryableFailedUploads() {
assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE"))
assertFalse(canRetryPhotoUpload("PENDING"))
assertFalse(canRetryPhotoUpload("UPLOADING"))
assertFalse(canRetryPhotoUpload("VERIFYING"))
assertFalse(canRetryPhotoUpload("CONFIRMED"))
assertFalse(canRetryPhotoUpload("FAILED_PERMANENT"))
assertFalse(canRetryPhotoUpload("CANCELLED"))
}
@Test
fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() {
assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera"))
assertTrue(canShowPhotoUploadFailureDetails("FAILED_PERMANENT", "Typ: błąd aplikacji"))
assertFalse(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", null))
assertFalse(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", ""))
assertFalse(canShowPhotoUploadFailureDetails("UPLOADING", "Typ: błąd serwera"))
assertFalse(canShowPhotoUploadFailureDetails("CONFIRMED", "Typ: błąd serwera"))
}
@Test
fun createsFullScreenPreviewForLocalUploadBeforeRouteRefresh() {
val photo = localUploadPreviewPhoto(upload(status = "CONFIRMED", serverPhotoId = "55"))
requireNotNull(photo)
assertEquals("local-upload-123", photo.id)
assertEquals("10", photo.routeId)
assertEquals("file:/tmp/photo.jpg", photo.url)
assertEquals("2026-07-01T08:00:00Z", photo.takenAt)
assertEquals(50.1, photo.latitude ?: 0.0, 0.0)
assertEquals(true, photo.canDelete)
}
@Test
fun preciseLocationRequirementAllowsCameraOnlyWithFineLocation() {
assertTrue(canLaunchCameraWithLocationPolicy(requirePreciseLocation = false, hasFineLocation = false))
assertTrue(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = true))
assertFalse(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = false))
}
@Test
fun showsDispatchSheetCardOnlyWhenReminderIsDueToday() {
assertTrue(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "missing", dueToday = true)))
assertTrue(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "uploaded", dueToday = true)))
assertFalse(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "not_required", dueToday = false)))
assertFalse(shouldShowDispatchSheetReminderCard(null))
}
@Test
fun labelsDispatchSheetActionByStatusAndLocalUploadQueue() {
assertEquals("Zrób zdjęcie", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "missing"), hasQueuedUpload = false))
assertEquals("Wysyłam zdjęcie...", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "missing"), hasQueuedUpload = true))
assertEquals("Popraw", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "uploaded"), hasQueuedUpload = false))
}
private fun route(status: String): DriverRouteDto =
DriverRouteDto(
id = "1",
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza -> Instalacja",
status = status,
distanceKm = 1.0,
notes = null,
truck = null,
)
private fun upload(status: String, serverPhotoId: String? = null): PhotoUploadEntity =
PhotoUploadEntity(
clientRequestId = "local-upload-123",
routeId = "10",
localPath = "/tmp/photo.jpg",
source = "camera",
takenAt = "2026-07-01T08:00:00Z",
latitude = 50.1,
longitude = 19.9,
locationAccuracyMeters = 8.0,
mimeType = "image/jpeg",
size = 1200,
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
status = status,
serverPhotoId = serverPhotoId,
)
private fun dispatchReminder(status: String, dueToday: Boolean = true): DispatchSheetReminderDto =
DispatchSheetReminderDto(
enabled = dueToday,
dueToday = dueToday,
workDate = "2026-06-30",
reason = if (dueToday) "friday" else null,
availableUntil = "2026-06-30T23:59:59+02:00",
status = status,
photo = null,
canUpload = dueToday,
)
}
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.ui
import androidx.compose.ui.text.AnnotatedString
import org.junit.Assert.assertEquals
import org.junit.Test
class PolishPhoneNumberVisualTransformationTest {
@Test
fun formatsPhoneDigitsWithoutChangingUnderlyingTextOffsets() {
val transformed = PolishPhoneNumberVisualTransformation.filter(AnnotatedString("1234"))
assertEquals("123 4", transformed.text.text)
assertEquals(5, transformed.offsetMapping.originalToTransformed(4))
assertEquals(4, transformed.offsetMapping.transformedToOriginal(5))
}
@Test
fun mapsOffsetsAcrossBothInsertedSpaces() {
val transformed = PolishPhoneNumberVisualTransformation.filter(AnnotatedString("123456789"))
assertEquals("123 456 789", transformed.text.text)
assertEquals(7, transformed.offsetMapping.originalToTransformed(6))
assertEquals(11, transformed.offsetMapping.originalToTransformed(9))
assertEquals(6, transformed.offsetMapping.transformedToOriginal(7))
assertEquals(9, transformed.offsetMapping.transformedToOriginal(11))
}
}
+1
View File
@@ -2,4 +2,5 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.google.services) apply false
}
+5
View File
@@ -18,6 +18,8 @@ ksp = "2.1.10-1.0.31"
playServicesAuth = "21.6.0"
playServicesAuthApiPhone = "18.3.0"
junit = "4.13.2"
firebaseBom = "33.7.0"
googleServices = "4.4.2"
[libraries]
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -51,9 +53,12 @@ play-services-auth = { group = "com.google.android.gms", name = "play-services-a
play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }