From 060896d12bbec1cf52e29257655aa06255844603 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 1 Jul 2026 02:28:44 +0200 Subject: [PATCH] =?UTF-8?q?Dodaj=20pewny=20outbox=20uploadu=20zdj=C4=99?= =?UTF-8?q?=C4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 9 +- .../firmatpp/kierowca/data/ApiErrorMapper.kt | 90 +++++++++ .../kierowca/data/DriverRepository.kt | 43 +++++ .../kierowca/data/api/MobileDriverApi.kt | 3 + .../kierowca/data/model/DriverModels.kt | 10 + .../kierowca/data/upload/DriverDatabase.kt | 29 +++ .../kierowca/data/upload/PhotoUploadDao.kt | 76 ++++++++ .../kierowca/data/upload/PhotoUploadEntity.kt | 36 ++++ .../kierowca/data/upload/PhotoUploadOutbox.kt | 112 +++++++++++ .../data/upload/PhotoUploadReceiptVerifier.kt | 9 + .../kierowca/data/upload/PhotoUploadStatus.kt | 20 ++ .../kierowca/data/upload/PhotoUploadWorker.kt | 85 +++++++++ .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 174 ++++++++++++++---- .../pl/firmatpp/kierowca/ui/DriverUiRules.kt | 3 + .../firmatpp/kierowca/ui/DriverViewModel.kt | 74 +++++++- .../kierowca/data/ApiErrorMapperTest.kt | 42 +++++ .../upload/PhotoUploadReceiptVerifierTest.kt | 64 +++++++ .../data/upload/PhotoUploadStatusTest.kt | 29 +++ .../firmatpp/kierowca/ui/DriverUiRulesTest.kt | 10 + gradle/libs.versions.toml | 6 + 20 files changed, 875 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadDao.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReceiptVerifier.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadStatus.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt create mode 100644 app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt create mode 100644 app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReceiptVerifierTest.kt create mode 100644 app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadStatusTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 00c67f4..d3b6ad8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) } android { @@ -12,8 +13,8 @@ android { applicationId = "pl.firmatpp.kierowca" minSdk = 26 targetSdk = 35 - versionCode = 14 - versionName = "1.0.13" + versionCode = 15 + versionName = "1.0.14" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"") @@ -61,6 +62,8 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.room.runtime) implementation(libs.androidx.work.runtime.ktx) implementation(libs.camera.camera2) implementation(libs.camera.core) @@ -85,4 +88,6 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.coroutines.test) + + ksp(libs.androidx.room.compiler) } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt b/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt new file mode 100644 index 0000000..4468d5a --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt @@ -0,0 +1,90 @@ +package pl.firmatpp.kierowca.data + +import java.io.IOException +import retrofit2.HttpException + +enum class ApiErrorKind { + Network, + Auth, + Forbidden, + Validation, + Conflict, + RateLimited, + Server, + Unknown, +} + +data class ApiError( + val kind: ApiErrorKind, + val message: String, + val retryable: Boolean, + val code: String? = null, + val statusCode: Int? = null, +) + +object ApiErrorMapper { + fun map(throwable: Throwable): ApiError = + when (throwable) { + is IOException -> ApiError( + kind = ApiErrorKind.Network, + message = "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.", + retryable = true, + ) + is HttpException -> mapHttpException(throwable) + else -> ApiError( + kind = ApiErrorKind.Unknown, + message = throwable.message ?: "Wystąpił błąd. Operacja nie została potwierdzona.", + retryable = false, + ) + } + + fun mapHttpStatus(statusCode: Int, body: String?): ApiError { + val code = body?.let { """"code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) } + val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) } + val retryable = body?.let { """"retryable"\s*:\s*(true|false)""".toRegex().find(it)?.groupValues?.getOrNull(1)?.toBooleanStrictOrNull() } + + return mapProblem( + statusCode = statusCode, + code = code, + message = message, + retryable = retryable, + ) + } + + fun mapProblem(statusCode: Int, code: String?, message: String?, retryable: Boolean?): ApiError { + val kind = when (statusCode) { + 401 -> ApiErrorKind.Auth + 403 -> ApiErrorKind.Forbidden + 400, 422 -> ApiErrorKind.Validation + 409 -> ApiErrorKind.Conflict + 429 -> ApiErrorKind.RateLimited + in 500..599 -> ApiErrorKind.Server + else -> ApiErrorKind.Unknown + } + val defaultRetryable = statusCode == 429 || statusCode in 500..599 + val resolvedRetryable = retryable ?: defaultRetryable + val resolvedMessage = message?.takeIf { it.isNotBlank() } ?: when (kind) { + ApiErrorKind.Auth -> "Sesja wygasła. Zaloguj się ponownie." + ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji." + ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana." + ApiErrorKind.Conflict -> "Operacja jest w konflikcie z aktualnym stanem danych." + ApiErrorKind.RateLimited -> "Za dużo prób. Aplikacja spróbuje ponownie później." + ApiErrorKind.Server -> "Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie." + ApiErrorKind.Network -> "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona." + ApiErrorKind.Unknown -> "Wystąpił błąd. Operacja nie została potwierdzona." + } + + return ApiError( + kind = kind, + message = resolvedMessage, + retryable = resolvedRetryable, + code = code, + statusCode = statusCode, + ) + } + + private fun mapHttpException(exception: HttpException): ApiError { + val body = runCatching { exception.response()?.errorBody()?.string() }.getOrNull() + return mapHttpStatus(exception.code(), body) + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt index 169cb8b..67e9453 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -2,8 +2,12 @@ package pl.firmatpp.kierowca.data import android.content.Context import android.net.Uri +import java.io.File +import java.security.MessageDigest +import java.util.UUID import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody +import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import pl.firmatpp.kierowca.data.api.ApiFactory import pl.firmatpp.kierowca.data.api.MobileDriverApi @@ -14,6 +18,7 @@ import pl.firmatpp.kierowca.data.model.PhotoUploadResponse import pl.firmatpp.kierowca.data.model.RequestOtpBody import pl.firmatpp.kierowca.data.model.RouteResponse import pl.firmatpp.kierowca.data.model.VerifyOtpBody +import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity class DriverRepository( private val context: Context, @@ -46,6 +51,8 @@ class DriverRepository( val resolver = context.contentResolver val mimeType = resolver.getType(uri) ?: "image/jpeg" val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: error("Nie mozna odczytac zdjecia.") + val clientRequestId = UUID.randomUUID().toString() + val contentSha256 = sha256(bytes) val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) val photo = MultipartBody.Part.createFormData("photo", "ladunek.jpg", body) val sourceBody = source.toRequestBody("text/plain".toMediaTypeOrNull()) @@ -53,8 +60,11 @@ class DriverRepository( return api.uploadPhoto( authHeader(requireToken()), + clientRequestId, routeId, photo, + clientRequestId.toPlainTextBody(), + contentSha256.toPlainTextBody(), sourceBody, metadataParts["takenAt"], metadataParts["latitude"], @@ -63,6 +73,32 @@ class DriverRepository( ) } + suspend fun uploadQueuedPhoto(upload: PhotoUploadEntity): PhotoUploadResponse { + 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.uploadPhoto( + authHeader(requireToken()), + upload.clientRequestId, + upload.routeId, + photo, + upload.clientRequestId.toPlainTextBody(), + upload.contentSha256.toPlainTextBody(), + upload.source.toPlainTextBody(), + metadataParts["takenAt"], + metadataParts["latitude"], + metadataParts["longitude"], + metadataParts["locationAccuracyMeters"], + ) + } + suspend fun deletePhoto(photoId: String) { api.deletePhoto(authHeader(requireToken()), photoId) } @@ -82,4 +118,11 @@ class DriverRepository( private suspend fun requireToken(): String = tokenStore.read() ?: error("Brak aktywnej sesji.") private fun authHeader(token: String): String = "Bearer $token" + + private fun String.toPlainTextBody(): RequestBody = toRequestBody("text/plain".toMediaTypeOrNull()) + + private fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt index 969285e..503a075 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt @@ -45,8 +45,11 @@ interface MobileDriverApi { @POST("mobile/driver/routes/{routeId}/photos") suspend fun uploadPhoto( @Header("Authorization") authorization: String, + @Header("Idempotency-Key") idempotencyKey: String, @Path("routeId") routeId: 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?, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index c38520e..bd0919f 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -106,6 +106,8 @@ data class NavigationPointDto( data class RoutePhotoDto( val id: String, val routeId: String, + val clientRequestId: String? = null, + val contentSha256: String? = null, val source: String, val mimeType: String?, val size: Long, @@ -121,4 +123,12 @@ data class RoutePhotoDto( data class PhotoUploadResponse( val photo: RoutePhotoDto, + val receipt: PhotoUploadReceiptDto, +) + +data class PhotoUploadReceiptDto( + val clientRequestId: String, + val serverPhotoId: String, + val contentSha256: String, + val storedAt: String?, ) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt new file mode 100644 index 0000000..9781984 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt @@ -0,0 +1,29 @@ +package pl.firmatpp.kierowca.data.upload + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database( + entities = [PhotoUploadEntity::class], + version = 1, + exportSchema = false, +) +abstract class DriverDatabase : RoomDatabase() { + abstract fun photoUploadDao(): PhotoUploadDao + + companion object { + @Volatile + private var instance: DriverDatabase? = null + + fun get(context: Context): DriverDatabase = + instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + DriverDatabase::class.java, + "driver-local-outbox.db", + ).build().also { instance = it } + } + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadDao.kt new file mode 100644 index 0000000..41329ba --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadDao.kt @@ -0,0 +1,76 @@ +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 PhotoUploadDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(upload: PhotoUploadEntity) + + @Query("SELECT * FROM photo_uploads WHERE clientRequestId = :clientRequestId LIMIT 1") + suspend fun find(clientRequestId: String): PhotoUploadEntity? + + @Query( + """ + SELECT * FROM photo_uploads + WHERE routeId = :routeId + AND status != 'CANCELLED' + ORDER BY createdAtEpochMillis ASC + """, + ) + fun observeVisibleForRoute(routeId: String): Flow> + + @Query( + """ + SELECT * FROM photo_uploads + WHERE routeId = :routeId + AND status = 'CONFIRMED' + AND clientRequestId IN (:clientRequestIds) + """, + ) + suspend fun confirmedForServerPhotos(routeId: String, clientRequestIds: List): List + + @Query( + """ + UPDATE photo_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 photo_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 photo_uploads WHERE clientRequestId = :clientRequestId") + suspend fun delete(clientRequestId: String) +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt new file mode 100644 index 0000000..639aedc --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt @@ -0,0 +1,36 @@ +package pl.firmatpp.kierowca.data.upload + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "photo_uploads", + indices = [ + Index(value = ["routeId"]), + Index(value = ["status"]), + ], +) +data class PhotoUploadEntity( + @PrimaryKey val clientRequestId: String, + val routeId: 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) +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt new file mode 100644 index 0000000..30a8aaa --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt @@ -0,0 +1,112 @@ +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 +import pl.firmatpp.kierowca.data.model.DriverRouteDto + +class PhotoUploadOutbox( + private val context: Context, + private val dao: PhotoUploadDao = DriverDatabase.get(context).photoUploadDao(), + private val workManager: WorkManager = WorkManager.getInstance(context), +) { + fun observeVisibleForRoute(routeId: String): Flow> = + dao.observeVisibleForRoute(routeId) + + 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" + val extension = when (mimeType) { + "image/png" -> "png" + "image/webp" -> "webp" + else -> "jpg" + } + val uploadDir = File(context.filesDir, "photo-upload-outbox").apply { mkdirs() } + val localFile = File(uploadDir, "$clientRequestId.$extension") + val sha256 = copyAndHash(uri, localFile) + val upload = PhotoUploadEntity( + clientRequestId = clientRequestId, + routeId = routeId, + 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 retry(clientRequestId: String) { + val existing = dao.find(clientRequestId) ?: return + dao.upsert( + existing.copy( + status = PhotoUploadStatus.Pending.storageValue, + progress = 0, + lastError = null, + updatedAtEpochMillis = System.currentTimeMillis(), + ), + ) + enqueueWorker(clientRequestId) + } + + suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) { + val confirmedRequestIds = route.photos.mapNotNull { it.clientRequestId }.distinct() + if (confirmedRequestIds.isEmpty()) return + + dao.confirmedForServerPhotos(route.id, confirmedRequestIds).forEach { upload -> + File(upload.localPath).delete() + dao.delete(upload.clientRequestId) + } + } + + private fun enqueueWorker(clientRequestId: String) { + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(PhotoUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId)) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + + workManager.enqueueUniqueWork( + PhotoUploadWorker.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." } + 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) } + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReceiptVerifier.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReceiptVerifier.kt new file mode 100644 index 0000000..ff9a4e8 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReceiptVerifier.kt @@ -0,0 +1,9 @@ +package pl.firmatpp.kierowca.data.upload + +import pl.firmatpp.kierowca.data.model.PhotoUploadReceiptDto + +object PhotoUploadReceiptVerifier { + fun matches(upload: PhotoUploadEntity, receipt: PhotoUploadReceiptDto): Boolean = + receipt.clientRequestId == upload.clientRequestId && + receipt.contentSha256.lowercase() == upload.contentSha256.lowercase() +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadStatus.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadStatus.kt new file mode 100644 index 0000000..d5cb730 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadStatus.kt @@ -0,0 +1,20 @@ +package pl.firmatpp.kierowca.data.upload + +enum class PhotoUploadStatus( + val storageValue: String, + val label: String, + val isStoredOnServer: Boolean, +) { + Pending("PENDING", "Czeka na wysłanie", false), + Uploading("UPLOADING", "Wysyłam zdjęcie", false), + Verifying("VERIFYING", "Sprawdzam zapis", false), + Confirmed("CONFIRMED", "Zapisane", true), + FailedRetryable("FAILED_RETRYABLE", "Nie wysłano, ponów", false), + FailedPermanent("FAILED_PERMANENT", "Zdjęcie nie zostało zapisane", false), + Cancelled("CANCELLED", "Anulowano", false); + + companion object { + fun fromStorage(value: String): PhotoUploadStatus = + entries.firstOrNull { it.storageValue == value } ?: Pending + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt new file mode 100644 index 0000000..25b5c52 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt @@ -0,0 +1,85 @@ +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 PhotoUploadWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + private val dao = DriverDatabase.get(appContext).photoUploadDao() + 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()) { + dao.updateStatus( + clientRequestId = clientRequestId, + status = PhotoUploadStatus.FailedPermanent.storageValue, + progress = 0, + lastError = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane.", + 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.uploadQueuedPhoto(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 (!PhotoUploadReceiptVerifier.matches(upload, receipt)) { + error("Serwer nie potwierdził zgodności zdjęcia. 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 = error.message, + 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 = "photo-upload-$clientRequestId" + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index 6ec3d82..b9e1e00 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -41,9 +41,6 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -117,7 +114,6 @@ import java.io.File import java.time.Instant import java.time.LocalDate import java.time.format.DateTimeFormatter -import kotlin.math.ceil import kotlinx.coroutines.delay import java.util.Locale import pl.firmatpp.kierowca.R @@ -125,6 +121,8 @@ import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.NavigationPointDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto +import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity +import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus import pl.firmatpp.kierowca.domain.OtpCodeExtractor import pl.firmatpp.kierowca.domain.RouteDisplayMapper import pl.firmatpp.kierowca.ui.theme.TppColors @@ -179,7 +177,7 @@ fun DriverApp(viewModel: DriverViewModel) { onRoute = viewModel::openRoute, ) DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout) - DriverScreen.Detail -> DetailScreen(state, viewModel::back, viewModel::uploadPhoto, viewModel::openPhoto, viewModel::deletePhoto, viewModel::refreshSelectedRoute) + DriverScreen.Detail -> DetailScreen(state, viewModel::back, viewModel::uploadPhoto, viewModel::openPhoto, viewModel::deletePhoto, viewModel::retryPhotoUpload, viewModel::refreshSelectedRoute) DriverScreen.Photo -> PhotoScreen(state, viewModel::back) } @@ -738,6 +736,7 @@ private fun DetailScreen( onUpload: (Uri, String, PhotoUploadMetadata) -> Unit, onPhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit, + onRetryUpload: (PhotoUploadEntity) -> Unit, onRefresh: () -> Unit, ) { val context = LocalContext.current @@ -776,11 +775,14 @@ private fun DetailScreen( item { CargoDocumentationSection( photos = route.photos, + uploads = state.photoUploads, + deletingPhotoIds = state.deletingPhotoIds, imageAuthHeader = state.imageAuthHeader, allowGalleryUploads = state.allowGalleryUploads, canManagePhotos = canManagePhotos, onPhoto = onPhoto, onDeletePhoto = onDeletePhoto, + onRetryUpload = onRetryUpload, onCamera = { val newUri = createCameraUri(context) cameraUri = newUri @@ -964,11 +966,14 @@ private fun RouteFact(label: String, value: String, modifier: Modifier = Modifie @Composable private fun CargoDocumentationSection( photos: List, + uploads: List, + deletingPhotoIds: Set, imageAuthHeader: String?, allowGalleryUploads: Boolean, canManagePhotos: Boolean, onPhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit, + onRetryUpload: (PhotoUploadEntity) -> Unit, onCamera: () -> Unit, onGallery: () -> Unit, ) { @@ -990,7 +995,7 @@ private fun CargoDocumentationSection( } } } - PhotoGrid(photos, imageAuthHeader, onPhoto, onDeletePhoto) + PhotoGrid(photos, uploads, deletingPhotoIds, imageAuthHeader, onPhoto, onDeletePhoto, onRetryUpload) } } @@ -1011,12 +1016,14 @@ private fun CargoActionButton(label: String, icon: ImageVector, color: Color, on @Composable private fun PhotoGrid( photos: List, + uploads: List, + deletingPhotoIds: Set, imageAuthHeader: String?, onPhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit, + onRetryUpload: (PhotoUploadEntity) -> Unit, ) { - val rows = ceil((photos.size.coerceAtLeast(1) / 2f).toDouble()).toInt() - val gridHeight = (rows * 172).coerceIn(172, 520).dp + val items = uploads.map { PhotoGridItem.Upload(it) } + photos.map { PhotoGridItem.Server(it) } Card( colors = CardDefaults.cardColors(containerColor = Color.White), @@ -1030,40 +1037,34 @@ private fun PhotoGrid( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold, ) - if (photos.isEmpty()) { + if (items.isEmpty()) { EmptyPhotoState() } else { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.height(gridHeight), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(photos, key = { it.id }) { photo -> - Box(Modifier.aspectRatio(1f)) { - AsyncImage( - model = imageRequest(photo.url, imageAuthHeader), - contentDescription = "Zdjecie ladunku", - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().clickable { onPhoto(photo) }, - ) - if (photo.canDelete) { - IconButton( - onClick = { onDeletePhoto(photo) }, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(6.dp) - .size(34.dp) - .background(TppColors.Error, RoundedCornerShape(17.dp)), - ) { - Icon( - Icons.Outlined.Delete, - contentDescription = "Usuń zdjęcie", - tint = Color.White, - modifier = Modifier.size(18.dp), + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + repeat(inlinePhotoGridRows(items.size)) { rowIndex -> + val startIndex = rowIndex * 2 + val rowItems = items.subList(startIndex, minOf(startIndex + 2, items.size)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + rowItems.forEach { item -> + when (item) { + is PhotoGridItem.Server -> PhotoTile( + photo = item.photo, + imageAuthHeader = imageAuthHeader, + isDeleting = item.photo.id in deletingPhotoIds, + onPhoto = onPhoto, + onDeletePhoto = onDeletePhoto, + modifier = Modifier.weight(1f), + ) + is PhotoGridItem.Upload -> PendingPhotoTile( + upload = item.upload, + onRetry = onRetryUpload, + modifier = Modifier.weight(1f), ) } } + if (rowItems.size == 1) { + Spacer(Modifier.weight(1f)) + } } } } @@ -1072,6 +1073,105 @@ private fun PhotoGrid( } } +private sealed class PhotoGridItem { + data class Server(val photo: RoutePhotoDto) : PhotoGridItem() + data class Upload(val upload: PhotoUploadEntity) : PhotoGridItem() +} + +@Composable +private fun PhotoTile( + photo: RoutePhotoDto, + imageAuthHeader: String?, + isDeleting: Boolean, + onPhoto: (RoutePhotoDto) -> Unit, + onDeletePhoto: (RoutePhotoDto) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier.aspectRatio(1f)) { + AsyncImage( + model = imageRequest(photo.url, imageAuthHeader), + contentDescription = "Zdjecie ladunku", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clickable { onPhoto(photo) }, + ) + if (isDeleting) { + Box( + Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)), + contentAlignment = Alignment.Center, + ) { + Text("Usuwam", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) + } + } + if (photo.canDelete) { + IconButton( + onClick = { onDeletePhoto(photo) }, + enabled = !isDeleting, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(40.dp) + .background(TppColors.Surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) + .border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), + ) { + Icon( + Icons.Outlined.Delete, + contentDescription = "Usuń zdjęcie", + tint = TppColors.Error, + modifier = Modifier.size(19.dp), + ) + } + } + } +} + +@Composable +private fun PendingPhotoTile( + upload: PhotoUploadEntity, + onRetry: (PhotoUploadEntity) -> Unit, + modifier: Modifier = Modifier, +) { + val status = upload.statusType + Box(modifier.aspectRatio(1f).background(TppColors.Panel, RoundedCornerShape(4.dp))) { + AsyncImage( + model = File(upload.localPath), + contentDescription = "Zdjęcie oczekujące na zapis", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.34f))) + Column( + Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Color.White.copy(alpha = 0.94f)).padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + upload.lastError?.takeIf { status == PhotoUploadStatus.FailedPermanent } ?: status.label, + color = if (status == PhotoUploadStatus.FailedPermanent) TppColors.Error else TppColors.Ink, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelMedium, + ) + if (status == PhotoUploadStatus.Uploading || status == PhotoUploadStatus.Verifying) { + androidx.compose.material3.LinearProgressIndicator( + progress = { (upload.progress.coerceIn(0, 100) / 100f) }, + modifier = Modifier.fillMaxWidth(), + color = TppColors.Forest, + trackColor = TppColors.Outline.copy(alpha = 0.4f), + ) + } + if (status == PhotoUploadStatus.FailedRetryable) { + Button( + onClick = { onRetry(upload) }, + modifier = Modifier.fillMaxWidth().height(40.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), + shape = RoundedCornerShape(4.dp), + ) { + Text("Ponów", color = Color.White, fontWeight = FontWeight.Bold) + } + } + } + } +} + @Composable private fun EmptyPhotoState() { Box( diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt index 5edbad8..cf2b6f3 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -4,3 +4,6 @@ import java.time.LocalDate fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean = runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false) + +fun inlinePhotoGridRows(photoCount: Int): Int = + if (photoCount <= 0) 0 else (photoCount + 1) / 2 diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index c276518..197f2f6 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -4,16 +4,20 @@ import android.app.Application import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate +import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.PhotoUploadMetadata 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.PhotoUploadEntity +import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo } @@ -33,14 +37,18 @@ data class DriverUiState( val allowGalleryUploads: Boolean = true, val selectedRoute: DriverRouteDto? = null, val selectedPhoto: RoutePhotoDto? = null, + val photoUploads: List = emptyList(), + val deletingPhotoIds: Set = emptySet(), val imageAuthHeader: String? = null, val error: String? = null, ) class DriverViewModel(application: Application) : AndroidViewModel(application) { private val repository = DriverRepository(application) + private val photoUploadOutbox = PhotoUploadOutbox(application) private val _state = MutableStateFlow(DriverUiState(loading = true)) private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy() + private var photoUploadsJob: Job? = null val state: StateFlow = _state init { @@ -126,7 +134,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun openRoute(routeId: String) = runLoading { val response = repository.route(routeId) + photoUploadOutbox.discardConfirmedServerPhotos(response.route) _state.update { it.copy(screen = DriverScreen.Detail, selectedRoute = response.route, error = null) } + observePhotoUploads(routeId) } fun refreshSelectedRoute() { @@ -137,6 +147,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) runCatching { repository.route(routeId) } .onSuccess { response -> + photoUploadOutbox.discardConfirmedServerPhotos(response.route) _state.update { it.copy( selectedRoute = response.route, @@ -157,16 +168,46 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(screen = DriverScreen.Profile, error = null) } } - fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) = runLoading { - val route = _state.value.selectedRoute ?: return@runLoading - repository.uploadPhoto(route.id, uri, source, metadata) - openRoute(route.id) + fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) { + val route = _state.value.selectedRoute ?: return + viewModelScope.launch { + _state.update { it.copy(error = null) } + runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata) } + .onFailure { throwable -> + _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } + } + } } - fun deletePhoto(photo: RoutePhotoDto) = runLoading { - val route = _state.value.selectedRoute ?: return@runLoading - repository.deletePhoto(photo.id) - openRoute(route.id) + fun retryPhotoUpload(upload: PhotoUploadEntity) { + viewModelScope.launch { + runCatching { photoUploadOutbox.retry(upload.clientRequestId) } + .onFailure { throwable -> + _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } + } + } + } + + fun deletePhoto(photo: RoutePhotoDto) { + val route = _state.value.selectedRoute ?: return + viewModelScope.launch { + _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + photo.id, error = null) } + runCatching { + repository.deletePhoto(photo.id) + val response = repository.route(route.id) + 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 - photo.id) } + } } fun openPhoto(photo: RoutePhotoDto) { @@ -177,7 +218,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { when (it.screen) { DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) - DriverScreen.Detail -> it.copy(screen = DriverScreen.Routes, selectedRoute = null) + DriverScreen.Detail -> { + photoUploadsJob?.cancel() + it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList()) + } DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes) DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone) else -> it @@ -186,6 +230,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } fun logout() = runLoading { + photoUploadsJob?.cancel() repository.logout() _state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) } } @@ -195,9 +240,18 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(loading = true, error = null) } runCatching { block() } .onFailure { throwable -> - _state.update { it.copy(error = throwable.message ?: "Wystapil blad.") } + _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } } _state.update { it.copy(loading = false) } } } + + private fun observePhotoUploads(routeId: String) { + photoUploadsJob?.cancel() + photoUploadsJob = viewModelScope.launch { + photoUploadOutbox.observeVisibleForRoute(routeId).collect { uploads -> + _state.update { it.copy(photoUploads = uploads) } + } + } + } } diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt new file mode 100644 index 0000000..74e1f3f --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt @@ -0,0 +1,42 @@ +package pl.firmatpp.kierowca.data + +import java.io.IOException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ApiErrorMapperTest { + @Test + fun mapsNetworkFailuresToRetryableOfflineMessage() { + val error = ApiErrorMapper.map(IOException("timeout")) + + assertEquals(ApiErrorKind.Network, error.kind) + assertTrue(error.retryable) + assertEquals("Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.", error.message) + } + + @Test + fun mapsMobileApiProblemCodes() { + val error = ApiErrorMapper.mapProblem( + statusCode = 422, + code = "PHOTO_IDEMPOTENCY_CONFLICT", + message = "Ten upload został już użyty dla innego zdjęcia.", + retryable = false, + ) + + assertEquals(ApiErrorKind.Validation, error.kind) + assertFalse(error.retryable) + assertEquals("PHOTO_IDEMPOTENCY_CONFLICT", error.code) + assertEquals("Ten upload został już użyty dla innego zdjęcia.", error.message) + } + + @Test + fun mapsServerErrorsToRetryableUnconfirmedMessage() { + val error = ApiErrorMapper.mapHttpStatus(503, null) + + assertEquals(ApiErrorKind.Server, error.kind) + assertTrue(error.retryable) + assertEquals("Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie.", error.message) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReceiptVerifierTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReceiptVerifierTest.kt new file mode 100644 index 0000000..944432e --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReceiptVerifierTest.kt @@ -0,0 +1,64 @@ +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.PhotoUploadReceiptDto + +class PhotoUploadReceiptVerifierTest { + private val upload = PhotoUploadEntity( + clientRequestId = "6f7a7d10-b7f7-41ab-8f5e-f1afc3e93736", + routeId = "10", + localPath = "/tmp/photo.jpg", + source = "camera", + takenAt = null, + latitude = null, + longitude = null, + locationAccuracyMeters = null, + mimeType = "image/jpeg", + size = 1200, + contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + + @Test + fun confirmsOnlyMatchingReceipt() { + assertTrue( + PhotoUploadReceiptVerifier.matches( + upload, + PhotoUploadReceiptDto( + clientRequestId = upload.clientRequestId, + serverPhotoId = "55", + contentSha256 = upload.contentSha256.uppercase(), + storedAt = "2026-07-01T10:00:00+02:00", + ), + ), + ) + } + + @Test + fun rejectsDifferentRequestOrHash() { + assertFalse( + PhotoUploadReceiptVerifier.matches( + upload, + PhotoUploadReceiptDto( + clientRequestId = "other", + serverPhotoId = "55", + contentSha256 = upload.contentSha256, + storedAt = null, + ), + ), + ) + + assertFalse( + PhotoUploadReceiptVerifier.matches( + upload, + PhotoUploadReceiptDto( + clientRequestId = upload.clientRequestId, + serverPhotoId = "55", + contentSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + storedAt = null, + ), + ), + ) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadStatusTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadStatusTest.kt new file mode 100644 index 0000000..e40b970 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadStatusTest.kt @@ -0,0 +1,29 @@ +package pl.firmatpp.kierowca.data.upload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PhotoUploadStatusTest { + @Test + fun onlyConfirmedStatusIsStoredOnServer() { + assertFalse(PhotoUploadStatus.Pending.isStoredOnServer) + assertFalse(PhotoUploadStatus.Uploading.isStoredOnServer) + assertFalse(PhotoUploadStatus.Verifying.isStoredOnServer) + assertFalse(PhotoUploadStatus.FailedRetryable.isStoredOnServer) + assertFalse(PhotoUploadStatus.FailedPermanent.isStoredOnServer) + assertFalse(PhotoUploadStatus.Cancelled.isStoredOnServer) + assertTrue(PhotoUploadStatus.Confirmed.isStoredOnServer) + } + + @Test + fun statusLabelsMakeUnconfirmedStateExplicit() { + assertEquals("Czeka na wysłanie", PhotoUploadStatus.Pending.label) + assertEquals("Wysyłam zdjęcie", PhotoUploadStatus.Uploading.label) + assertEquals("Sprawdzam zapis", PhotoUploadStatus.Verifying.label) + assertEquals("Nie wysłano, ponów", PhotoUploadStatus.FailedRetryable.label) + assertEquals("Zdjęcie nie zostało zapisane", PhotoUploadStatus.FailedPermanent.label) + assertEquals("Zapisane", PhotoUploadStatus.Confirmed.label) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt index e4bfead..3cf8981 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -1,6 +1,7 @@ package pl.firmatpp.kierowca.ui import java.time.LocalDate +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -20,4 +21,13 @@ class DriverUiRulesTest { assertFalse(canManageRoutePhotos("", today)) assertFalse(canManageRoutePhotos("not-a-date", today)) } + + @Test + fun calculatesPhotoGridRowsForTwoColumnInlineGallery() { + assertEquals(0, inlinePhotoGridRows(0)) + assertEquals(1, inlinePhotoGridRows(1)) + assertEquals(1, inlinePhotoGridRows(2)) + assertEquals(2, inlinePhotoGridRows(3)) + assertEquals(3, inlinePhotoGridRows(5)) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 21ee39b..02695a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,8 @@ datastore = "1.1.1" coil = "2.7.0" camerax = "1.4.1" work = "2.10.0" +room = "2.7.0" +ksp = "2.1.10-1.0.31" playServicesAuth = "21.6.0" playServicesAuthApiPhone = "18.3.0" junit = "4.13.2" @@ -25,6 +27,9 @@ androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lif androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } @@ -51,3 +56,4 @@ retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", ver 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" }