Compare commits

...
10 Commits
56 changed files with 2512 additions and 82 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
+55 -2
View File
@@ -1,22 +1,64 @@
import java.io.File
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
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 = 11
versionName = "1.0.10"
versionCode = 21
versionName = "1.0.20"
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 {
@@ -24,6 +66,7 @@ android {
isMinifyEnabled = false
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -45,6 +88,10 @@ android {
compose = true
buildConfig = true
}
lint {
disable += "NullSafeMutableLiveData"
}
}
dependencies {
@@ -57,6 +104,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)
@@ -70,6 +119,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)
@@ -81,4 +132,6 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
ksp(libs.androidx.room.compiler)
}
+10
View File
@@ -7,7 +7,9 @@
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TppKierowca">
<activity
@@ -28,5 +30,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>
@@ -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)
}
}
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.data
import android.content.Context
import android.os.Build
import android.provider.Settings
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.model.DriverDeviceInfo
class DeviceInfoProvider(
private val context: Context,
private val tokenStore: TokenStore,
) {
suspend fun currentDeviceInfo(): DriverDeviceInfo =
DriverDeviceInfo(
deviceId = tokenStore.deviceId(),
androidId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID),
brand = Build.BRAND,
manufacturer = Build.MANUFACTURER,
model = Build.MODEL,
device = Build.DEVICE,
product = Build.PRODUCT,
androidVersion = Build.VERSION.RELEASE,
sdkInt = Build.VERSION.SDK_INT,
appVersion = BuildConfig.VERSION_NAME,
serialNumber = readSerialNumber(),
)
private fun readSerialNumber(): String? =
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Build.getSerial()
} else {
@Suppress("DEPRECATION")
Build.SERIAL
}
}.getOrNull()
?.takeUnless { it.isBlank() || it.equals(Build.UNKNOWN, ignoreCase = true) }
}
@@ -2,29 +2,39 @@ 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
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.DriverDto
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.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.PhotoUploadEntity
class DriverRepository(
private val context: Context,
private val api: MobileDriverApi = ApiFactory.mobileDriverApi(),
private val tokenStore: TokenStore = TokenStore(context),
private val deviceInfoProvider: DeviceInfoProvider = DeviceInfoProvider(context, tokenStore),
) {
suspend fun requestOtp(phoneNumber: String): OtpResponse =
api.requestOtp(RequestOtpBody(phoneNumber))
suspend fun verifyOtp(phoneNumber: String, code: String, deviceName: String): DriverDto {
val response = api.verifyOtp(VerifyOtpBody(phoneNumber, code, deviceName))
val response = api.verifyOtp(VerifyOtpBody(phoneNumber, code, deviceName, deviceInfoProvider.currentDeviceInfo()))
val token = response.accessToken ?: error("Brak tokenu sesji.")
tokenStore.save(token)
return response.driver ?: error("Brak danych kierowcy.")
@@ -36,6 +46,21 @@ class DriverRepository(
suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId)
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 deletePushToken() {
val token = tokenStore.read() ?: return
runCatching { api.deletePushToken(authHeader(token)) }
}
suspend fun uploadPhoto(
routeId: String,
uri: Uri,
@@ -45,6 +70,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())
@@ -52,8 +79,11 @@ class DriverRepository(
return api.uploadPhoto(
authHeader(requireToken()),
clientRequestId,
routeId,
photo,
clientRequestId.toPlainTextBody(),
contentSha256.toPlainTextBody(),
sourceBody,
metadataParts["takenAt"],
metadataParts["latitude"],
@@ -62,6 +92,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)
}
@@ -69,6 +125,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()
@@ -81,4 +138,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) }
}
@@ -6,11 +6,13 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.util.UUID
private val Context.driverDataStore by preferencesDataStore(name = "driver_session")
class TokenStore(private val context: Context) {
private val tokenKey = stringPreferencesKey("access_token")
private val deviceIdKey = stringPreferencesKey("device_id")
suspend fun save(token: String) {
context.driverDataStore.edit { preferences ->
@@ -22,6 +24,21 @@ class TokenStore(private val context: Context) {
.map { it[tokenKey] }
.first()
suspend fun deviceId(): String {
val existing = context.driverDataStore.data
.map { it[deviceIdKey] }
.first()
if (!existing.isNullOrBlank()) return existing
val generated = UUID.randomUUID().toString()
context.driverDataStore.edit { preferences ->
preferences[deviceIdKey] = generated
}
return generated
}
suspend fun clear() {
context.driverDataStore.edit { it.remove(tokenKey) }
}
@@ -3,10 +3,14 @@ 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.OtpResponse
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody
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 +19,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
@@ -41,12 +46,39 @@ interface MobileDriverApi {
@Path("routeId") routeId: String,
): RouteResponse
@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>
@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(
@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?,
@@ -6,6 +6,21 @@ data class VerifyOtpBody(
val phoneNumber: String,
val code: String,
val deviceName: String,
val device: DriverDeviceInfo,
)
data class DriverDeviceInfo(
val deviceId: String,
val androidId: String?,
val brand: String?,
val manufacturer: String?,
val model: String?,
val device: String?,
val product: String?,
val androidVersion: String?,
val sdkInt: Int,
val appVersion: String?,
val serialNumber: String?,
)
data class OtpResponse(
@@ -31,6 +46,7 @@ data class BootstrapResponse(
val session: DriverSessionDto,
val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?,
val syncState: SyncStateResponse? = null,
)
data class DriverAppSettingsDto(
@@ -54,6 +70,34 @@ data class RoutesBucketDto(
data class RouteResponse(
val route: DriverRouteDto,
val syncState: SyncStateResponse? = null,
)
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 DriverRouteDto(
@@ -91,6 +135,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,
@@ -106,4 +152,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?,
)
@@ -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,127 @@
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.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 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"
}
}
@@ -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,83 @@
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,
DriverBootstrapCacheEntity::class,
DriverRouteCacheEntity::class,
DriverSyncStateEntity::class,
],
version = 2,
exportSchema = false,
)
abstract class DriverDatabase : RoomDatabase() {
abstract fun photoUploadDao(): PhotoUploadDao
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)")
}
}
fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
DriverDatabase::class.java,
"driver-local-outbox.db",
).addMigrations(migration1To2).build().also { instance = it }
}
}
}
@@ -0,0 +1,86 @@
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<List<PhotoUploadEntity>>
@Query(
"""
SELECT * FROM photo_uploads
WHERE status != 'CONFIRMED'
AND status != 'CANCELLED'
ORDER BY createdAtEpochMillis ASC
""",
)
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>>
@Query(
"""
SELECT * FROM photo_uploads
WHERE routeId = :routeId
AND status = 'CONFIRMED'
AND clientRequestId IN (:clientRequestIds)
""",
)
suspend fun confirmedForServerPhotos(routeId: String, clientRequestIds: List<String>): List<PhotoUploadEntity>
@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)
}
@@ -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)
}
@@ -0,0 +1,122 @@
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<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"
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 discard(clientRequestId: String) {
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.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<PhotoUploadWorker>()
.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) }
}
}
@@ -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()
}
@@ -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
}
}
@@ -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"
}
}
@@ -0,0 +1,33 @@
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_sync_hint") return
DriverSyncWorker.enqueue(
context = applicationContext,
date = data["date"],
routeId = data["routeId"],
)
}
}
@@ -0,0 +1,135 @@
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.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.BuildConfig
import pl.firmatpp.kierowca.data.DriverRepository
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 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val started = AtomicBoolean(false)
private var webSocket: WebSocket? = null
private var driverId: String? = null
fun start(driverId: String) {
if (BuildConfig.REVERB_APP_KEY.isBlank()) return
this.driverId = driverId
if (!started.compareAndSet(false, true)) return
val wsUrl = BuildConfig.REVERB_WS_BASE_URL.trimEnd('/') +
"/" + BuildConfig.REVERB_APP_KEY +
"?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) {
started.set(false)
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
started.set(false)
scheduleReconnect()
}
},
)
}
fun stop() {
started.set(false)
webSocket?.close(1000, "logout")
webSocket = null
driverId = 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" -> {
val socketId = root.dataObject()?.string("socket_id") ?: return
subscribe(socket, socketId)
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)
}
}
}
}
@@ -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,77 @@
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
}
}
}
}
}
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,
)
}
}
}
@@ -18,6 +18,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -40,17 +41,17 @@ 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
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddPhotoAlternate
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.Factory
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.LocationOn
@@ -62,6 +63,7 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -75,6 +77,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
@@ -92,11 +95,14 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.verticalScroll
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle
@@ -110,8 +116,8 @@ import com.google.android.gms.common.api.Status
import java.io.File
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.math.ceil
import kotlinx.coroutines.delay
import java.util.Locale
import pl.firmatpp.kierowca.R
@@ -119,6 +125,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
@@ -129,7 +137,7 @@ fun DriverApp(viewModel: DriverViewModel) {
val lifecycleOwner = LocalLifecycleOwner.current
var appInForeground by remember { mutableStateOf(true) }
BackHandler(enabled = state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
viewModel.back()
}
DisposableEffect(lifecycleOwner, state.screen) {
@@ -162,6 +170,7 @@ fun DriverApp(viewModel: DriverViewModel) {
Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
when (state.screen) {
DriverScreen.Initializing -> StartupScreen()
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back)
DriverScreen.Routes -> RoutesScreen(
@@ -169,14 +178,29 @@ fun DriverApp(viewModel: DriverViewModel) {
onRefresh = viewModel::refreshRoutesSilently,
onDate = viewModel::selectRouteDate,
onProfile = viewModel::openProfile,
onPhotoQueue = viewModel::openPhotoQueue,
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::deleteConfirmedUpload,
viewModel::retryPhotoUpload,
viewModel::refreshSelectedRoute,
)
DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
DriverScreen.PhotoQueue -> PhotoQueueScreen(
state = state,
onBack = viewModel::back,
onRetryUpload = viewModel::retryPhotoUpload,
)
}
if (state.loading) {
if (state.loading && state.screen != DriverScreen.Initializing) {
Box(Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.42f)), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = TppColors.Forest)
}
@@ -185,21 +209,166 @@ fun DriverApp(viewModel: DriverViewModel) {
}
@Composable
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
var phone by remember { mutableStateOf(state.phone) }
AuthShell(title = "TPP Kierowca", subtitle = "Zaloguj sie numerem telefonu kierowcy.") {
OutlinedTextField(
value = phone,
onValueChange = { phone = it },
label = { Text("Numer telefonu") },
leadingIcon = { Icon(Icons.Outlined.Phone, contentDescription = null) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
private fun StartupScreen() {
Column(
Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Image(
painter = painterResource(R.drawable.tpp5),
contentDescription = "Firma TPP",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
)
Spacer(Modifier.height(32.dp))
CircularProgressIndicator(color = TppColors.Forest)
Spacer(Modifier.height(20.dp))
Text(
"Sprawdzanie sesji",
color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
Box(
Modifier
.fillMaxSize()
.background(TppColors.Surface)
.statusBarsPadding()
.navigationBarsPadding()
.padding(24.dp),
) {
Card(
modifier = Modifier.fillMaxSize(),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(4.dp),
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 28.dp, vertical = 36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.tpp5),
contentDescription = "Firma TPP",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.height(132.dp),
)
Spacer(Modifier.height(42.dp))
Text(
"Zaloguj się",
style = MaterialTheme.typography.headlineLarge,
color = TppColors.Muted,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(76.dp))
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
Text(
"Numer Telefonu",
color = Color.Black,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 23.sp,
)
PhoneNumberField(
digits = phoneDigits,
onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
)
Text(
"Otrzymasz kod SMS do weryfikacji.",
color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
)
ErrorText(state.error)
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 22.dp else 8.dp))
Button(
onClick = { onSubmit("+48$phoneDigits") },
modifier = Modifier
.fillMaxWidth()
.height(72.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen),
shape = RoundedCornerShape(4.dp),
contentPadding = PaddingValues(horizontal = 20.dp),
) {
Text(
"Wyślij kod OTP",
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
)
}
}
}
}
}
}
@Composable
private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(82.dp)
.background(Color(0xFFFEFFFC), RoundedCornerShape(2.dp))
.border(BorderStroke(1.dp, TppColors.Outline), RoundedCornerShape(2.dp)),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.width(94.dp)
.fillMaxHeight()
.background(TppColors.Panel),
contentAlignment = Alignment.Center,
) {
Text("+48", color = TppColors.Ink, fontSize = 26.sp)
}
Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline))
BasicTextField(
value = formatPolishPhoneDigits(digits),
onValueChange = { onDigitsChange(it) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
textStyle = TextStyle(
color = TppColors.Muted,
fontSize = 28.sp,
fontWeight = FontWeight.Medium,
),
modifier = Modifier
.weight(1f)
.padding(horizontal = 24.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (digits.isBlank()) {
Text(
"000 000 000",
color = TppColors.Muted.copy(alpha = 0.92f),
fontSize = 28.sp,
fontWeight = FontWeight.Medium,
)
}
innerTextField()
}
},
)
ErrorText(state.error)
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 18.dp else 8.dp))
PrimaryButton("Wyslij kod") { onSubmit(phone) }
}
}
@@ -245,6 +414,7 @@ private fun RoutesScreen(
onRefresh: () -> Unit,
onDate: (String) -> Unit,
onProfile: () -> Unit,
onPhotoQueue: () -> Unit,
onRoute: (String) -> Unit,
) {
Scaffold(
@@ -288,6 +458,8 @@ private fun RoutesScreen(
onDate = onDate,
)
}
item { OfflineStaleBanner(state) }
item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) }
if (state.routes.isEmpty()) {
item { EmptyState("Brak kursow na wybrany dzien") }
} else {
@@ -306,6 +478,129 @@ private fun RoutesScreen(
}
}
@Composable
private fun PhotoQueueBanner(uploads: List<PhotoUploadEntity>, onPhotoQueue: () -> Unit) {
val count = queuedPhotoUploadCount(uploads.map { it.status })
if (count <= 0) return
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(42.dp).background(TppColors.ContainerGreen, RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.CloudUpload, contentDescription = null, tint = TppColors.Forest)
}
Text(
"W kolejce do wysłania jest $count ${photoCountLabel(count)}.",
color = TppColors.Ink,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Button(
onClick = onPhotoQueue,
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
shape = RoundedCornerShape(4.dp),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
) {
Text("Kolejka", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
private fun PhotoQueueScreen(
state: DriverUiState,
onBack: () -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
) {
val uploads = state.queuedPhotoUploads.sortedBy { it.createdAtEpochMillis }
Scaffold(
topBar = { DetailHeader(onBack, title = "Kolejka zdjęć") },
containerColor = TppColors.Surface,
) { padding ->
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { OfflineStaleBanner(state) }
if (uploads.isEmpty()) {
item { EmptyState("Brak zdjęć oczekujących na przesłanie") }
} else {
item {
Text(
"Zdjęcia do przesłania (${uploads.size})",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = TppColors.Ink,
)
}
items(uploads, key = { it.clientRequestId }) { upload ->
PhotoQueueItem(upload, onRetryUpload)
}
}
}
}
}
@Composable
private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploadEntity) -> Unit) {
val status = upload.statusType
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = File(upload.localPath),
contentDescription = "Zdjęcie z kolejki",
contentScale = ContentScale.Crop,
modifier = Modifier.size(76.dp).background(TppColors.Panel, RoundedCornerShape(6.dp)),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Kurs #${upload.routeId}", color = TppColors.Ink, fontWeight = FontWeight.Bold)
Text(
upload.lastError?.takeIf { status == PhotoUploadStatus.FailedPermanent } ?: status.label,
color = if (status == PhotoUploadStatus.FailedPermanent) TppColors.Error else TppColors.Muted,
fontFamily = FontFamily.Monospace,
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) {
TextButton(onClick = { onRetryUpload(upload) }) {
Text("Ponów", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
}
}
}
}
@Composable
private fun StitchHeader() {
Column(Modifier.fillMaxWidth().background(Color.White).statusBarsPadding()) {
@@ -586,6 +881,8 @@ private fun DetailScreen(
onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
onRefresh: () -> Unit,
) {
val context = LocalContext.current
@@ -621,14 +918,19 @@ private fun DetailScreen(
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) }
item { OfflineStaleBanner(state) }
item {
CargoDocumentationSection(
photos = route.photos,
uploads = state.photoUploads,
deletingPhotoIds = state.deletingPhotoIds,
imageAuthHeader = state.imageAuthHeader,
allowGalleryUploads = state.allowGalleryUploads,
canManagePhotos = canManagePhotos,
onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
onCamera = {
val newUri = createCameraUri(context)
cameraUri = newUri
@@ -658,7 +960,7 @@ private fun DetailScreen(
}
@Composable
private fun DetailHeader(onBack: () -> Unit) {
private fun DetailHeader(onBack: () -> Unit, title: String = "Szczegóły Trasy") {
Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) {
Row(
Modifier.fillMaxWidth().height(64.dp).padding(start = 8.dp, end = 16.dp),
@@ -668,7 +970,7 @@ private fun DetailHeader(onBack: () -> Unit) {
Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz", tint = TppColors.Ink)
}
Text(
"Szczegóły Trasy",
title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = TppColors.Ink,
@@ -679,6 +981,13 @@ private fun DetailHeader(onBack: () -> Unit) {
}
}
private fun photoCountLabel(count: Int): String =
when {
count == 1 -> "zdjęcie"
count in 2..4 -> "zdjęcia"
else -> "zdjęć"
}
@Composable
private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status)
@@ -812,11 +1121,15 @@ private fun RouteFact(label: String, value: String, modifier: Modifier = Modifie
@Composable
private fun CargoDocumentationSection(
photos: List<RoutePhotoDto>,
uploads: List<PhotoUploadEntity>,
deletingPhotoIds: Set<String>,
imageAuthHeader: String?,
allowGalleryUploads: Boolean,
canManagePhotos: Boolean,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
onCamera: () -> Unit,
onGallery: () -> Unit,
) {
@@ -838,7 +1151,7 @@ private fun CargoDocumentationSection(
}
}
}
PhotoGrid(photos, imageAuthHeader, onPhoto, onDeletePhoto)
PhotoGrid(photos, uploads, deletingPhotoIds, imageAuthHeader, onPhoto, onDeletePhoto, onDeleteUpload, onRetryUpload)
}
}
@@ -859,12 +1172,18 @@ private fun CargoActionButton(label: String, icon: ImageVector, color: Color, on
@Composable
private fun PhotoGrid(
photos: List<RoutePhotoDto>,
uploads: List<PhotoUploadEntity>,
deletingPhotoIds: Set<String>,
imageAuthHeader: String?,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
) {
val rows = ceil((photos.size.coerceAtLeast(1) / 2f).toDouble()).toInt()
val gridHeight = (rows * 172).coerceIn(172, 520).dp
var pendingDelete by remember { mutableStateOf<PhotoDeleteTarget?>(null) }
val items = (uploads.map { PhotoGridItem.Upload(it) } + photos.map { PhotoGridItem.Server(it) })
.sortedByDescending { it.sortEpochMillis }
val visibleAttachmentCount = visiblePhotoAttachmentCount(photos.size, uploads.size)
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
@@ -873,51 +1192,210 @@ private fun PhotoGrid(
) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"Załączone zdjęcia (${photos.size})",
"Załączone zdjęcia ($visibleAttachmentCount)",
color = TppColors.Muted,
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 = { pendingDelete = PhotoDeleteTarget.Server(it) },
modifier = Modifier.weight(1f),
)
is PhotoGridItem.Upload -> PendingPhotoTile(
upload = item.upload,
isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true,
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
onRetry = onRetryUpload,
modifier = Modifier.weight(1f),
)
}
}
if (rowItems.size == 1) {
Spacer(Modifier.weight(1f))
}
}
}
}
}
}
}
pendingDelete?.let { target ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Usunąć zdjęcie?", color = TppColors.Ink, fontWeight = FontWeight.Bold) },
text = { Text("Czy na pewno chcesz usunąć to zdjęcie?", color = TppColors.Muted) },
confirmButton = {
TextButton(
onClick = {
pendingDelete = null
when (target) {
is PhotoDeleteTarget.Server -> onDeletePhoto(target.photo)
is PhotoDeleteTarget.Upload -> onDeleteUpload(target.upload)
}
},
) {
Text("Usuń", color = TppColors.Error, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text("Anuluj", color = TppColors.Muted)
}
},
)
}
}
private sealed class PhotoGridItem(open val sortEpochMillis: Long) {
data class Server(val photo: RoutePhotoDto) : PhotoGridItem(
routePhotoSortEpochMillis(photo.createdAt, photo.takenAt, fallback = 0L),
)
data class Upload(val upload: PhotoUploadEntity) : PhotoGridItem(upload.createdAtEpochMillis)
}
private sealed class PhotoDeleteTarget {
data class Server(val photo: RoutePhotoDto) : PhotoDeleteTarget()
data class Upload(val upload: PhotoUploadEntity) : PhotoDeleteTarget()
}
@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,
isDeleting: Boolean,
onDelete: (PhotoUploadEntity) -> Unit,
onRetry: (PhotoUploadEntity) -> Unit,
modifier: Modifier = Modifier,
) {
val status = upload.statusType
val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null
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)))
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 (canDeleteServerPhoto) {
IconButton(
onClick = { onDelete(upload) },
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),
)
}
}
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
@@ -998,6 +1476,46 @@ private fun ErrorText(error: String?) {
if (!error.isNullOrBlank()) Text(error, color = TppColors.Error, modifier = Modifier.padding(vertical = 8.dp))
}
@Composable
private fun OfflineStaleBanner(state: DriverUiState) {
if (state.isOnline && !state.isStale) return
val syncLabel = state.lastSuccessfulSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji"
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF7E6)),
border = BorderStroke(1.dp, Color(0xFFE6B85C)),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = if (state.isOnline) {
"Dane mogą być nieaktualne. Ostatnia synchronizacja: $syncLabel."
} else {
"Brak połączenia z serwerem. Dane z $syncLabel mogą być nieaktualne."
},
color = Color(0xFF5F4200),
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(12.dp),
)
}
}
private fun formatSyncTime(epochMillis: Long): String =
runCatching {
DateTimeFormatter.ofPattern("HH:mm", Locale("pl", "PL"))
.format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()))
}.getOrDefault("nieznana godzina")
private fun polishPhoneDigits(value: String): String {
val digits = value.filter(Char::isDigit)
val withoutCountryCode = if (digits.length > 9 && digits.startsWith("48")) digits.drop(2) else digits
return withoutCountryCode.take(9)
}
private fun formatPolishPhoneDigits(value: String): String =
value.chunked(3).joinToString(" ")
private fun createCameraUri(context: Context): Uri {
val dir = File(context.cacheDir, "camera").apply { mkdirs() }
val file = File.createTempFile("ladunek-", ".jpg", dir)
@@ -1,6 +1,29 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import java.time.OffsetDateTime
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
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() }
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
}
@@ -4,22 +4,33 @@ 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
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.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
import pl.firmatpp.kierowca.sync.DriverLiveSyncClient
import pl.firmatpp.kierowca.sync.DriverSyncHint
import pl.firmatpp.kierowca.sync.DriverSyncWorker
enum class DriverScreen { Phone, Otp, Routes, Profile, Detail, Photo }
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue }
data class DriverUiState(
val screen: DriverScreen = DriverScreen.Phone,
val loading: Boolean = false,
val screen: DriverScreen = DriverScreen.Initializing,
val loading: Boolean = true,
val refreshing: Boolean = false,
val phone: String = "",
val otpCode: String = "",
@@ -33,23 +44,63 @@ data class DriverUiState(
val allowGalleryUploads: Boolean = true,
val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null,
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val deletingPhotoIds: Set<String> = emptySet(),
val imageAuthHeader: String? = null,
val isOnline: Boolean = true,
val isStale: Boolean = false,
val lastSuccessfulSyncAtEpochMillis: Long? = 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 _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 pushTokenRegisteredForDriverId: String? = null
val state: StateFlow<DriverUiState> = _state
init {
viewModelScope.launch {
if (repository.hasToken()) refreshRoutes() else _state.update { it.copy(loading = false) }
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()
} else {
_state.update { it.copy(screen = DriverScreen.Phone, loading = false) }
}
}
}
fun requestOtp(phone: String) = runLoading {
val response = repository.requestOtp(phone)
otpAutoSubmitPolicy.reset()
_state.update {
it.copy(
screen = DriverScreen.Otp,
@@ -68,7 +119,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun updateOtpCode(code: String) {
_state.update { it.copy(otpCode = code.filter(Char::isDigit).take(10)) }
val sanitized = code.filter(Char::isDigit).take(6)
_state.update { it.copy(otpCode = sanitized) }
if (otpAutoSubmitPolicy.shouldSubmit(sanitized, _state.value.loading)) {
verifyOtp(sanitized)
}
}
fun refreshRoutes() = loadRoutes(date = _state.value.selectedDate, showLoading = true, navigateToRoutes = true)
@@ -82,7 +138,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, 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(
@@ -95,11 +152,28 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds,
allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads,
imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
)
}
response.session.driver.id.let { driverId ->
liveSyncClient.start(driverId)
registerPushTokenIfAvailable(driverId)
}
}.onFailure { throwable ->
_state.update { it.copy(error = throwable.message ?: "Wystapil blad.") }
_state.update {
val apiError = ApiErrorMapper.map(throwable)
it.copy(
screen = when {
apiError.kind == ApiErrorKind.Auth -> DriverScreen.Phone
it.screen == DriverScreen.Initializing -> DriverScreen.Routes
else -> it.screen
},
isStale = it.lastSuccessfulSyncAtEpochMillis != null,
error = apiError.message,
)
}
}
_state.update {
@@ -109,8 +183,19 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun openRoute(routeId: String) = runLoading {
val response = repository.route(routeId)
_state.update { it.copy(screen = DriverScreen.Detail, selectedRoute = response.route, error = null) }
val cached = syncRepository.route(routeId)
val response = cached.value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
screen = DriverScreen.Detail,
selectedRoute = response.route,
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
)
}
observePhotoUploads(routeId)
}
fun refreshSelectedRoute() {
@@ -119,12 +204,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
_state.update { it.copy(refreshing = true, 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,
)
}
@@ -141,16 +230,75 @@ 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 openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
}
fun deletePhoto(photo: RoutePhotoDto) = runLoading {
val route = _state.value.selectedRoute ?: return@runLoading
repository.deletePhoto(photo.id)
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 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 = 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 - photo.id) }
}
}
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 openPhoto(photo: RoutePhotoDto) {
@@ -161,7 +309,11 @@ 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.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
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
@@ -170,8 +322,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun logout() = runLoading {
photoUploadsJob?.cancel()
liveSyncClient.stop()
repository.logout()
_state.update { DriverUiState(screen = DriverScreen.Phone) }
syncRepository.clearCache()
pushTokenRegisteredForDriverId = null
_state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) }
}
private fun runLoading(block: suspend () -> Unit) {
@@ -179,9 +335,85 @@ 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) }
}
}
}
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 (hint.date == null || hint.date == snapshot.selectedDate) {
refreshRoutesSilently()
} 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()
}
}
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()
super.onCleared()
}
}
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.ui
class OtpAutoSubmitPolicy {
private var lastSubmittedCode: String? = null
fun shouldSubmit(code: String, loading: Boolean): Boolean {
if (code.length < OTP_LENGTH) {
lastSubmittedCode = null
return false
}
if (loading || code.length != OTP_LENGTH || code == lastSubmittedCode) {
return false
}
lastSubmittedCode = code
return true
}
fun reset() {
lastSubmittedCode = null
}
private companion object {
const val OTP_LENGTH = 6
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#07172B" />
</shape>
@@ -0,0 +1,5 @@
<?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>
@@ -0,0 +1,5 @@
<?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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TPP Kierowca</string>
<string name="app_name">Firma TPP - Kierowca</string>
</resources>
@@ -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)
}
}
@@ -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,
),
),
)
}
}
@@ -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)
}
}
@@ -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,70 @@ 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))
}
@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"))
}
}
@@ -0,0 +1,15 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class DriverUiStateTest {
@Test
fun startsOnInitializingScreenBeforeTokenCheckCompletes() {
val state = DriverUiState()
assertEquals(DriverScreen.Initializing, state.screen)
assertTrue(state.loading)
}
}
@@ -0,0 +1,41 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OtpAutoSubmitPolicyTest {
@Test
fun submitsWhenSixthDigitIsEntered() {
val policy = OtpAutoSubmitPolicy()
assertFalse(policy.shouldSubmit("12345", loading = false))
assertTrue(policy.shouldSubmit("123456", loading = false))
}
@Test
fun submitsSmsRetrieverCodeOnlyOnceWhileItStaysTheSame() {
val policy = OtpAutoSubmitPolicy()
assertTrue(policy.shouldSubmit("987654", loading = false))
assertFalse(policy.shouldSubmit("987654", loading = false))
}
@Test
fun allowsRetryAfterCodeIsEditedBelowSixDigits() {
val policy = OtpAutoSubmitPolicy()
assertTrue(policy.shouldSubmit("111111", loading = false))
assertFalse(policy.shouldSubmit("111111", loading = false))
assertFalse(policy.shouldSubmit("11111", loading = false))
assertTrue(policy.shouldSubmit("111111", loading = false))
}
@Test
fun doesNotSubmitWhileLoadingOrForLongerCodes() {
val policy = OtpAutoSubmitPolicy()
assertFalse(policy.shouldSubmit("123456", loading = true))
assertFalse(policy.shouldSubmit("1234567", loading = false))
}
}
+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
}
+11
View File
@@ -13,9 +13,13 @@ 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"
firebaseBom = "33.7.0"
googleServices = "4.4.2"
[libraries]
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -25,6 +29,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" }
@@ -46,8 +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" }
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
function readSource(path) {
return readFileSync(resolve(root, path), 'utf8');
}
test('driver app sends Android device metadata during OTP verification', () => {
const models = readSource('app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt');
const repository = readSource('app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt');
const deviceInfo = readSource('app/src/main/java/pl/firmatpp/kierowca/data/DeviceInfoProvider.kt');
assert.match(models, /data class DriverDeviceInfo/);
assert.match(models, /val device: DriverDeviceInfo/);
assert.match(repository, /DeviceInfoProvider/);
assert.match(repository, /deviceInfoProvider\.currentDeviceInfo/);
assert.match(deviceInfo, /Settings\.Secure\.ANDROID_ID/);
assert.match(deviceInfo, /Build\.MANUFACTURER/);
assert.match(deviceInfo, /Build\.MODEL/);
assert.match(deviceInfo, /BuildConfig\.VERSION_NAME/);
});