Compare commits

..
7 Commits
29 changed files with 1924 additions and 424 deletions
+4 -2
View File
@@ -10,6 +10,7 @@ plugins {
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics")
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -33,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 39
versionName = "1.0.38"
versionCode = 46
versionName = "1.0.44"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -123,6 +124,7 @@ dependencies {
implementation(libs.compose.ui.tooling.preview)
implementation(libs.coroutines.android)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.messaging)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
+1
View File
@@ -10,6 +10,7 @@
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:name=".DriverApplication"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TppKierowca">
@@ -0,0 +1,12 @@
package pl.firmatpp.kierowca
import android.app.Application
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverApplication : Application() {
override fun onCreate() {
super.onCreate()
AppDiagnostics.installFirebaseCrashlytics()
AppDiagnostics.log("app_started")
}
}
@@ -1,11 +1,12 @@
package pl.firmatpp.kierowca
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import pl.firmatpp.kierowca.ui.DriverApp
import pl.firmatpp.kierowca.ui.DriverViewModel
@@ -20,8 +21,10 @@ class MainActivity : ComponentActivity() {
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
setContent {
TppKierowcaTheme {
val viewModel: DriverViewModel = viewModel()
val viewModel: DriverViewModel = viewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
TppKierowcaTheme(themeMode = state.themeMode) {
DriverApp(
viewModel = viewModel,
initialRouteId = notificationRouteId,
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.data
import java.io.IOException
import java.net.UnknownHostException
import retrofit2.HttpException
enum class ApiErrorKind {
@@ -26,6 +27,11 @@ data class ApiError(
object ApiErrorMapper {
fun map(throwable: Throwable): ApiError =
when (throwable) {
is UnknownHostException -> ApiError(
kind = ApiErrorKind.Network,
message = "Brak internetu lub połączenia z serwerem. Sprawdź zasięg i spróbuj ponownie.",
retryable = true,
)
is IOException -> ApiError(
kind = ApiErrorKind.Network,
message = "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.",
@@ -39,6 +45,9 @@ object ApiErrorMapper {
)
}
fun shouldReportNonFatal(throwable: Throwable): Boolean =
map(throwable).kind != ApiErrorKind.Network
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) }
@@ -0,0 +1,30 @@
package pl.firmatpp.kierowca.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
private val Context.driverAppPreferencesDataStore by preferencesDataStore(name = "driver_app_preferences")
class AppPreferencesStore(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.driverAppPreferencesDataStore)
private val themeModeKey = stringPreferencesKey("theme_mode")
val themeMode: Flow<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
suspend fun setThemeMode(themeMode: AppThemeMode) {
dataStore.edit { preferences ->
preferences[themeModeKey] = themeMode.storedValue
}
}
}
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DispatchSheetUploadWorker(
appContext: Context,
@@ -64,6 +65,17 @@ class DispatchSheetUploadWorker(
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "dispatch_sheet_upload_worker",
keys = mapOf(
"client_request_id" to clientRequestId,
"api_error_kind" to error.kind.name,
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"retryable" to error.retryable,
),
)
val status = if (error.retryable) PhotoUploadStatus.FailedRetryable else PhotoUploadStatus.FailedPermanent
dao.updateStatus(
clientRequestId = clientRequestId,
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class PhotoUploadWorker(
appContext: Context,
@@ -64,6 +65,18 @@ class PhotoUploadWorker(
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "photo_upload_worker",
keys = mapOf(
"client_request_id" to clientRequestId,
"route_id" to upload.routeId,
"api_error_kind" to error.kind.name,
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"retryable" to error.retryable,
),
)
val status = if (error.retryable) PhotoUploadStatus.FailedRetryable else PhotoUploadStatus.FailedPermanent
dao.updateStatus(
clientRequestId = clientRequestId,
@@ -0,0 +1,75 @@
package pl.firmatpp.kierowca.diagnostics
import com.google.firebase.crashlytics.FirebaseCrashlytics
interface DiagnosticsSink {
fun log(message: String)
fun setUserId(userId: String)
fun setCustomKey(key: String, value: String)
fun recordException(throwable: Throwable)
}
object NoOpDiagnosticsSink : DiagnosticsSink {
override fun log(message: String) = Unit
override fun setUserId(userId: String) = Unit
override fun setCustomKey(key: String, value: String) = Unit
override fun recordException(throwable: Throwable) = Unit
}
class FirebaseCrashlyticsDiagnosticsSink(
private val crashlytics: FirebaseCrashlytics = FirebaseCrashlytics.getInstance(),
) : DiagnosticsSink {
override fun log(message: String) {
crashlytics.log(message)
}
override fun setUserId(userId: String) {
crashlytics.setUserId(userId)
}
override fun setCustomKey(key: String, value: String) {
crashlytics.setCustomKey(key, value)
}
override fun recordException(throwable: Throwable) {
crashlytics.recordException(throwable)
}
}
object AppDiagnostics {
@Volatile
private var sink: DiagnosticsSink = NoOpDiagnosticsSink
fun installFirebaseCrashlytics() {
installSink(FirebaseCrashlyticsDiagnosticsSink())
}
fun installSink(nextSink: DiagnosticsSink) {
sink = nextSink
}
fun setDriverId(driverId: String) {
sink.setUserId(driverId)
}
fun clearDriverId() {
sink.setUserId("")
}
fun log(message: String) {
sink.log(message)
}
fun reportNonFatal(
throwable: Throwable,
operation: String,
keys: Map<String, Any?> = emptyMap(),
) {
sink.setCustomKey("operation", operation)
keys.forEach { (key, value) ->
sink.setCustomKey(key, value?.toString().orEmpty())
}
sink.log("non_fatal: $operation: ${throwable.message ?: throwable::class.java.simpleName}")
sink.recordException(throwable)
}
}
@@ -3,10 +3,10 @@ package pl.firmatpp.kierowca.sync
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.Job
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
@@ -16,73 +16,155 @@ import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
interface DriverLiveSyncGateway {
suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse
suspend fun storeRealtimeStatus(status: String, socketId: String?, error: String?)
}
class DriverRepositoryLiveSyncGateway(
private val repository: DriverRepository,
) : DriverLiveSyncGateway {
override suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse =
repository.broadcastAuth(socketId, channelName)
override suspend fun storeRealtimeStatus(status: String, socketId: String?, error: String?) {
repository.storeRealtimeStatus(status, socketId, error)
}
}
interface LiveWebSocketFactory {
fun newWebSocket(url: String, listener: WebSocketListener): WebSocket
}
class OkHttpLiveWebSocketFactory(
private val client: OkHttpClient,
) : LiveWebSocketFactory {
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket =
client.newWebSocket(Request.Builder().url(url).build(), listener)
}
private enum class LiveSyncConnectionState {
Stopped,
WaitingForNetwork,
Disconnected,
Connecting,
Subscribing,
Connected,
}
class DriverLiveSyncClient(
private val repository: DriverRepository,
private val gateway: DriverLiveSyncGateway,
private val onConnected: () -> Unit,
private val onHint: (DriverSyncHint) -> Unit,
private val client: OkHttpClient = OkHttpClient(),
private val webSocketFactory: LiveWebSocketFactory = OkHttpLiveWebSocketFactory(defaultOkHttpClient()),
private val gson: Gson = Gson(),
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
private val reconnectDelaysMs: List<Long> = DEFAULT_RECONNECT_DELAYS_MS,
private val staleTimeoutMs: Long = STALE_TIMEOUT_MS,
) {
constructor(
repository: DriverRepository,
onConnected: () -> Unit,
onHint: (DriverSyncHint) -> Unit,
client: OkHttpClient = defaultOkHttpClient(),
gson: Gson = Gson(),
) : this(
gateway = DriverRepositoryLiveSyncGateway(repository),
onConnected = onConnected,
onHint = onHint,
webSocketFactory = OkHttpLiveWebSocketFactory(client),
gson = gson,
)
private companion object {
const val HEARTBEAT_INTERVAL_MS = 10_000L
const val STALE_TIMEOUT_MS = 30_000L
val DEFAULT_RECONNECT_DELAYS_MS = listOf(1_000L, 2_000L, 5_000L, 10_000L, 30_000L)
fun defaultOkHttpClient(): OkHttpClient =
OkHttpClient.Builder()
.pingInterval(15, TimeUnit.SECONDS)
.build()
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val started = AtomicBoolean(false)
private val lock = Any()
private var desiredActive: Boolean = false
private var networkAvailable: Boolean = true
private var state: LiveSyncConnectionState = LiveSyncConnectionState.Stopped
private var webSocket: WebSocket? = null
private var driverId: String? = null
private var realtimeConfig: RealtimeConfigDto? = null
private var socketId: String? = null
private var reconnectJob: Job? = null
private var heartbeatJob: Job? = null
private var staleWatchdogJob: Job? = null
private var reconnectAttempt: Int = 0
private var messageVersion: Long = 0
fun start(driverId: String, config: RealtimeConfigDto?) {
val appKey = config?.reverbAppKey?.takeIf { it.isNotBlank() } ?: return
val wsBaseUrl = config.reverbWsBaseUrl?.takeIf { it.isNotBlank() } ?: return
if (!config.reverbEnabled) return
if (!isConfigUsable(config)) return
realtimeConfig = config
this.driverId = driverId
if (!started.compareAndSet(false, true)) return
synchronized(lock) {
realtimeConfig = config
this.driverId = driverId
desiredActive = true
}
ensureConnected()
}
val wsUrl = wsBaseUrl.trimEnd('/') +
"/" + appKey +
"?protocol=7&client=android&version=1.0&flash=false"
fun ensureConnected() {
val shouldConnect = synchronized(lock) {
desiredActive &&
networkAvailable &&
state !in setOf(
LiveSyncConnectionState.Connecting,
LiveSyncConnectionState.Subscribing,
LiveSyncConnectionState.Connected,
)
}
if (shouldConnect) connectNow()
}
webSocket = client.newWebSocket(
Request.Builder().url(wsUrl).build(),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text)
}
fun onNetworkAvailable(available: Boolean) {
val shouldReconnect = synchronized(lock) {
networkAvailable = available
if (!available) {
reconnectJob?.cancel()
reconnectJob = null
closeSocketLocked("network_lost")
state = if (desiredActive) LiveSyncConnectionState.WaitingForNetwork else LiveSyncConnectionState.Stopped
false
} else {
desiredActive && state == LiveSyncConnectionState.WaitingForNetwork
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
stopHeartbeat()
reportRealtimeStatus("disconnected", reason.takeIf { it.isNotBlank() })
started.set(false)
scheduleReconnect()
}
if (available) {
reportRealtimeStatus("reconnecting", "network_available")
} else {
reportRealtimeStatus("disconnected", "network_lost")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
stopHeartbeat()
reportRealtimeStatus("error", t.message ?: response?.message)
started.set(false)
scheduleReconnect()
}
},
)
if (shouldReconnect) connectNow(resetAttempt = true)
}
fun stop() {
started.set(false)
stopHeartbeat()
synchronized(lock) {
desiredActive = false
reconnectJob?.cancel()
reconnectJob = null
closeSocketLocked("client_stop")
driverId = null
realtimeConfig = null
socketId = null
reconnectAttempt = 0
state = LiveSyncConnectionState.Stopped
}
reportRealtimeStatus("disconnected", "client_stop")
webSocket?.close(1000, "logout")
webSocket = null
driverId = null
realtimeConfig = null
socketId = null
}
fun close() {
@@ -90,35 +172,112 @@ class DriverLiveSyncClient(
scope.cancel()
}
private fun connectNow(resetAttempt: Boolean = false) {
val wsUrl = synchronized(lock) {
val config = realtimeConfig ?: return
val id = driverId ?: return
if (!desiredActive || !networkAvailable || !isConfigUsable(config)) return
if (state == LiveSyncConnectionState.Connecting || state == LiveSyncConnectionState.Subscribing || state == LiveSyncConnectionState.Connected) return
if (resetAttempt) reconnectAttempt = 0
reconnectJob?.cancel()
reconnectJob = null
socketId = null
state = LiveSyncConnectionState.Connecting
val appKey = config.reverbAppKey.orEmpty()
val wsBaseUrl = config.reverbWsBaseUrl.orEmpty()
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false"
}
val socket = webSocketFactory.newWebSocket(
wsUrl,
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
handleDisconnect(
status = "disconnected",
reason = reason.takeIf { it.isNotBlank() } ?: "closed",
socket = webSocket,
)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
AppDiagnostics.log("realtime_error: ${t.message ?: response?.message ?: "unknown"}")
handleDisconnect(
status = "error",
reason = t.message ?: response?.message ?: "failure",
socket = webSocket,
)
}
},
)
synchronized(lock) {
webSocket = socket
}
}
private fun handleMessage(socket: WebSocket, text: String) {
val isCurrentSocket = synchronized(lock) {
if (webSocket !== socket) {
false
} else {
messageVersion += 1
true
}
}
if (!isCurrentSocket) return
val root = runCatching { JsonParser.parseString(text).asJsonObject }.getOrNull() ?: return
val event = root.string("event") ?: return
when (event) {
"pusher:ping" -> {
socket.send("""{"event":"pusher:pong","data":{}}""")
restartStaleWatchdogIfConnected()
}
"pusher:connection_established" -> {
socketId = root.dataObject()?.string("socket_id") ?: return
subscribe(socket, socketId ?: return)
val nextSocketId = root.dataObject()?.string("socket_id") ?: return
synchronized(lock) {
socketId = nextSocketId
state = LiveSyncConnectionState.Subscribing
}
subscribe(socket, nextSocketId)
}
"pusher_internal:subscription_succeeded" -> {
val channel = root.string("channel")
val id = driverId ?: return
val id = synchronized(lock) { driverId } ?: return
if (channel == "private-driver-mobile.$id") {
synchronized(lock) {
reconnectAttempt = 0
state = LiveSyncConnectionState.Connected
}
reportRealtimeStatus("connected")
startHeartbeat()
startStaleWatchdog()
onConnected()
}
}
"DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint)
"DriverMobileSyncHint" -> {
restartStaleWatchdogIfConnected()
parseHint(root.dataObject())?.let(onHint)
}
else -> restartStaleWatchdogIfConnected()
}
}
private fun subscribe(socket: WebSocket, socketId: String) {
val id = driverId ?: return
val id = synchronized(lock) { driverId } ?: return
val channel = "private-driver-mobile.$id"
scope.launch {
runCatching {
val auth = repository.broadcastAuth(socketId, channel).auth
val auth = gateway.broadcastAuth(socketId, channel).auth
if (!isCurrentSocket(socket)) return@launch
val payload = mapOf(
"event" to "pusher:subscribe",
"data" to mapOf(
@@ -126,11 +285,129 @@ class DriverLiveSyncClient(
"auth" to auth,
),
)
socket.send(gson.toJson(payload))
check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" }
}.onFailure { throwable ->
AppDiagnostics.log("realtime_subscription_error: ${throwable.message ?: throwable::class.java.simpleName}")
socket.close(1000, "subscription_failed")
handleDisconnect("error", "subscription_failed", socket)
}
}
}
private fun handleDisconnect(status: String, reason: String?, socket: WebSocket? = null) {
val shouldSchedule = synchronized(lock) {
if (socket != null && webSocket !== socket) return
stopHeartbeatLocked()
stopStaleWatchdogLocked()
webSocket = null
socketId = null
if (!desiredActive) {
state = LiveSyncConnectionState.Stopped
false
} else if (!networkAvailable) {
state = LiveSyncConnectionState.WaitingForNetwork
false
} else {
state = LiveSyncConnectionState.Disconnected
true
}
}
reportRealtimeStatus(status, reason)
if (shouldSchedule) scheduleReconnect()
}
private fun scheduleReconnect() {
val delayMs = synchronized(lock) {
if (!desiredActive || !networkAvailable || state == LiveSyncConnectionState.Stopped) return
if (reconnectJob?.isActive == true) return
val delay = reconnectDelaysMs.getOrElse(reconnectAttempt) { reconnectDelaysMs.last() }
reconnectAttempt += 1
delay
}
reconnectJob = scope.launch {
delay(delayMs)
connectNow()
}
}
private fun startHeartbeat() {
synchronized(lock) {
heartbeatJob?.cancel()
heartbeatJob = scope.launch {
while (true) {
reportRealtimeStatus("connected")
delay(HEARTBEAT_INTERVAL_MS)
}
}
}
}
private fun startStaleWatchdog() {
synchronized(lock) {
stopStaleWatchdogLocked()
val observedVersion = messageVersion
staleWatchdogJob = scope.launch {
delay(staleTimeoutMs)
val socketToClose = synchronized(lock) {
if (state == LiveSyncConnectionState.Connected && messageVersion == observedVersion) {
webSocket
} else {
null
}
}
if (socketToClose != null) {
socketToClose.close(1001, "stale_connection")
handleDisconnect("error", "stale_connection", socketToClose)
ensureConnected()
}
}
}
}
private fun restartStaleWatchdogIfConnected() {
val connected = synchronized(lock) { state == LiveSyncConnectionState.Connected }
if (connected) startStaleWatchdog()
}
private fun isCurrentSocket(socket: WebSocket): Boolean =
synchronized(lock) { webSocket === socket }
private fun closeSocketLocked(reason: String) {
stopHeartbeatLocked()
stopStaleWatchdogLocked()
webSocket?.close(1000, reason)
webSocket = null
socketId = null
}
private fun stopHeartbeatLocked() {
heartbeatJob?.cancel()
heartbeatJob = null
}
private fun stopStaleWatchdogLocked() {
staleWatchdogJob?.cancel()
staleWatchdogJob = null
}
private fun reportRealtimeStatus(status: String, error: String? = null) {
scope.launch {
runCatching {
gateway.storeRealtimeStatus(status, socketId, error)
}
}
}
private fun isConfigUsable(config: RealtimeConfigDto?): Boolean =
config?.reverbEnabled == true &&
!config.reverbAppKey.isNullOrBlank() &&
!config.reverbWsBaseUrl.isNullOrBlank()
private fun parseHint(data: JsonObject?): DriverSyncHint? {
if (data == null || data.string("type") != "driver_sync_hint") return null
@@ -150,37 +427,4 @@ class DriverLiveSyncClient(
private fun JsonObject.string(name: String): String? =
get(name)?.takeIf { !it.isJsonNull }?.asString
private fun scheduleReconnect() {
val id = driverId ?: return
scope.launch {
delay(5_000)
if (!started.get() && driverId == id) {
start(id, realtimeConfig)
}
}
}
private fun startHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = scope.launch {
while (started.get()) {
reportRealtimeStatus("connected")
delay(HEARTBEAT_INTERVAL_MS)
}
}
}
private fun stopHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = null
}
private fun reportRealtimeStatus(status: String, error: String? = null) {
scope.launch {
runCatching {
repository.storeRealtimeStatus(status, socketId, error)
}
}
}
}
@@ -14,6 +14,7 @@ import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverSyncWorker(
appContext: Context,
@@ -62,6 +63,18 @@ class DriverSyncWorker(
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "driver_sync_worker",
keys = mapOf(
"date" to inputData.getString(KEY_DATE),
"route_id" to inputData.getString(KEY_ROUTE_ID),
"api_error_kind" to error.kind.name,
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"retryable" to error.retryable,
),
)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
@@ -26,6 +26,7 @@ import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import retrofit2.HttpException
class NewRouteNotificationWorker(
@@ -76,6 +77,17 @@ class NewRouteNotificationWorker(
Result.success()
} else {
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "new_route_notification_worker",
keys = mapOf(
"route_id" to routeId,
"api_error_kind" to error.kind.name,
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"retryable" to error.retryable,
),
)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.success()
}
}
File diff suppressed because it is too large Load Diff
@@ -19,7 +19,20 @@ object DriverLeaveRequestUiRules {
"cancel_requested" -> "Anulowanie do decyzji"
"cancelled" -> "Anulowany"
"revoked" -> "Cofnięto decyzję"
else -> status
else -> "Nieznany status"
}
fun eventActionLabel(action: String): String =
when (action) {
"submitted" -> "Wniosek złożony"
"approved" -> "Wniosek zatwierdzony"
"rejected" -> "Wniosek odrzucony"
"revoked" -> "Decyzja cofnięta"
"cancelled_by_driver" -> "Wniosek anulowany przez kierowcę"
"cancellation_requested" -> "Kierowca poprosił o anulowanie"
"cancellation_approved" -> "Anulowanie zatwierdzone"
"cancellation_rejected" -> "Anulowanie odrzucone"
else -> "Aktualizacja wniosku"
}
fun typeLabel(type: String): String =
@@ -59,6 +59,18 @@ private fun polishWeekdayPhrase(day: DayOfWeek): String =
fun inlinePhotoGridRows(photoCount: Int): Int =
if (photoCount <= 0) 0 else (photoCount + 1) / 2
fun shouldShowPhotoPreviewLoadingState(hasPhoto: Boolean, isLoading: Boolean, isError: Boolean): Boolean =
hasPhoto && isLoading && !isError
fun shouldShowPhotoPreviewErrorState(hasPhoto: Boolean, isError: Boolean): Boolean =
hasPhoto && isError
fun shouldShowPhotoTileLoadingState(hasPhoto: Boolean, isLoading: Boolean, isError: Boolean): Boolean =
hasPhoto && isLoading && !isError
fun shouldShowPhotoTileErrorState(hasPhoto: Boolean, isError: Boolean): Boolean =
hasPhoto && isError
fun authBrandBannerHeightDp(screenHeightDp: Int): Int =
when {
screenHeightDp < 640 -> 118
@@ -152,6 +164,15 @@ fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueu
else -> "Zrób zdjęcie"
}
fun offlineStaleBannerMessage(isOnline: Boolean, isStale: Boolean, syncLabel: String): String? =
when {
isOnline && !isStale -> null
!isOnline && syncLabel == "brak zapisanej synchronizacji" ->
"Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie."
!isOnline -> "Brak połączenia z serwerem. Dane z $syncLabel mogą być nieaktualne."
else -> "Dane mogą być nieaktualne. Ostatnia synchronizacja: $syncLabel."
}
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDate
import pl.firmatpp.kierowca.data.AppPreferencesStore
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.DriverRepository
@@ -26,9 +27,11 @@ import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.sync.DriverLiveSyncClient
import pl.firmatpp.kierowca.sync.DriverSyncHint
import pl.firmatpp.kierowca.sync.DriverSyncWorker
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, AddLeaveRequest }
@@ -70,11 +73,37 @@ data class DriverUiState(
val isStale: Boolean = false,
val lastSuccessfulSyncAtEpochMillis: Long? = null,
val routeDayLiveUpdateMessage: String? = null,
val themeMode: AppThemeMode = AppThemeMode.Default,
val feedback: String? = null,
val error: String? = null,
)
private fun DriverUiState.withApiError(throwable: Throwable): DriverUiState {
val apiError = ApiErrorMapper.map(throwable)
return copy(
isOnline = if (apiError.kind == ApiErrorKind.Network) false else isOnline,
isStale = if (apiError.kind == ApiErrorKind.Network && lastSuccessfulSyncAtEpochMillis != null) true else isStale,
error = apiError.message,
)
}
internal fun DriverUiState.withLoadedLeaveRequests(
requests: List<DriverLeaveRequestDto>,
navigateToList: Boolean,
): DriverUiState {
val refreshedSelectedRequest = selectedLeaveRequest?.let { selected ->
requests.firstOrNull { request -> request.id == selected.id } ?: selected
}
return copy(
screen = if (navigateToList) DriverScreen.LeaveRequests else screen,
leaveRequests = requests,
selectedLeaveRequest = refreshedSelectedRequest,
error = null,
)
}
class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val appPreferencesStore = AppPreferencesStore(application)
private val repository = DriverRepository(application)
private val syncRepository = DriverSyncRepository(application, repository)
private val networkMonitor = NetworkMonitor(application)
@@ -93,6 +122,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val state: StateFlow<DriverUiState> = _state
init {
viewModelScope.launch {
appPreferencesStore.themeMode.collect { themeMode ->
_state.update { it.copy(themeMode = themeMode) }
}
}
viewModelScope.launch {
photoUploadOutbox.observeQueuedUploads().collect { uploads ->
_state.update { it.copy(queuedPhotoUploads = uploads) }
@@ -100,6 +134,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
viewModelScope.launch {
networkMonitor.isOnline.collect { online ->
liveSyncClient.onNetworkAvailable(online)
_state.update {
it.copy(
isOnline = online,
@@ -107,6 +142,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
if (online && repository.hasToken()) {
liveSyncClient.ensureConnected()
refreshCurrentScopeFromSyncState()
}
}
@@ -120,7 +156,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun requestOtp(phone: String) = runLoading {
fun requestOtp(phone: String) = runLoading("request_otp") {
val response = repository.requestOtp(phone)
otpAutoSubmitPolicy.reset()
_state.update {
@@ -134,8 +170,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun verifyOtp(code: String) = runLoading {
fun verifyOtp(code: String) = runLoading("verify_otp") {
val driver = repository.verifyOtp(_state.value.phone, code, android.os.Build.MODEL ?: "Android")
AppDiagnostics.setDriverId(driver.id)
_state.update { it.copy(driver = driver, imageAuthHeader = repository.imageAuthHeader()) }
refreshRoutes()
}
@@ -153,6 +190,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
fun onAppForegrounded() {
liveSyncClient.ensureConnected()
viewModelScope.launch { checkRemoteSyncState() }
if (_state.value.screen == DriverScreen.Routes) {
refreshRoutesSilently()
}
}
fun selectRouteDate(date: String) {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
@@ -170,6 +215,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val cached = syncRepository.bootstrap(date)
val response = cached.value
val settings = response.driverAppSettings
AppDiagnostics.setDriverId(response.session.driver.id)
_state.update {
it.copy(
driver = response.session.driver,
@@ -203,16 +249,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
}.onFailure { throwable ->
reportHandledException("load_routes", throwable, mapOf("date" to date))
_state.update {
val apiError = ApiErrorMapper.map(throwable)
it.copy(
it.withApiError(throwable).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,
)
}
}
@@ -223,7 +268,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun openRoute(routeId: String) = runLoading {
fun openRoute(routeId: String) = runLoading("open_route", mapOf("route_id" to routeId)) {
val cached = syncRepository.route(routeId)
val response = cached.value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
@@ -261,7 +306,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
.onFailure { throwable ->
_state.update { it.copy(error = throwable.message ?: "Wystapil blad.") }
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(refreshing = false) }
@@ -286,7 +331,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(refreshing = false) }
}
@@ -302,35 +347,36 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun setThemeMode(themeMode: AppThemeMode) {
_state.update { it.copy(themeMode = themeMode) }
viewModelScope.launch {
runCatching { appPreferencesStore.setThemeMode(themeMode) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
}
fun openLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = true)
loadLeaveRequests(showLoading = true, navigateToList = true)
}
fun refreshLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = false)
loadLeaveRequests(showLoading = false, navigateToList = false)
}
private fun loadLeaveRequests(showLoading: Boolean) {
private fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean) {
viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) }
runCatching { repository.leaveRequests() }
.onSuccess { requests ->
_state.update {
it.copy(
screen = DriverScreen.LeaveRequests,
leaveRequests = requests,
selectedLeaveRequest = requests.firstOrNull { request -> request.id == it.selectedLeaveRequest?.id } ?: it.selectedLeaveRequest,
error = null,
)
}
_state.update { it.withLoadedLeaveRequests(requests, navigateToList) }
}
.onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(loading = false, refreshing = false) }
}
}
@@ -343,7 +389,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
.onSuccess { request ->
_state.update { it.copy(screen = DriverScreen.LeaveRequestDetail, selectedLeaveRequest = request, error = null) }
}
.onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(loading = false) }
}
}
@@ -407,7 +453,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(loading = false) }
}
@@ -430,7 +476,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
}
.onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(loading = false) }
}
}
@@ -441,7 +487,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_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) }
_state.update { it.withApiError(throwable) }
}
}
}
@@ -462,7 +508,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
metadata = metadata,
)
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
_state.update { it.withApiError(throwable) }
}
}
}
@@ -471,7 +517,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
runCatching { photoUploadOutbox.retry(upload.clientRequestId) }
.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
_state.update { it.withApiError(throwable) }
}
}
}
@@ -492,7 +538,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - photo.id) }
}
@@ -517,7 +563,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - serverPhotoId) }
}
@@ -548,7 +594,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
}.onFailure { throwable ->
_state.update { it.copy(feedback = null, error = ApiErrorMapper.map(throwable).message) }
_state.update { it.withApiError(throwable).copy(feedback = null) }
}
_state.update { it.copy(completingRoute = false) }
}
@@ -582,22 +628,28 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun logout() = runLoading {
fun logout() = runLoading("logout") {
photoUploadsJob?.cancel()
dispatchSheetUploadsJob?.cancel()
liveSyncClient.stop()
repository.logout()
syncRepository.clearCache()
pushTokenRegisteredForDriverId = null
AppDiagnostics.clearDriverId()
_state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) }
}
private fun runLoading(block: suspend () -> Unit) {
private fun runLoading(
operation: String,
keys: Map<String, Any?> = emptyMap(),
block: suspend () -> Unit,
) {
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) }
runCatching { block() }
.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
reportHandledException(operation, throwable, keys)
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(loading = false) }
}
@@ -696,12 +748,41 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
runCatching {
repository.storePushToken(token)
pushTokenRegisteredForDriverId = driverId
}.onFailure { throwable ->
reportHandledException("store_push_token", throwable, mapOf("driver_id" to driverId))
}
}
}.addOnFailureListener { throwable ->
reportHandledException("get_push_token", throwable, mapOf("driver_id" to driverId))
}
}.onFailure { throwable ->
reportHandledException("register_push_token", throwable, mapOf("driver_id" to driverId))
}
}
private fun reportHandledException(
operation: String,
throwable: Throwable,
keys: Map<String, Any?> = emptyMap(),
) {
val apiError = ApiErrorMapper.map(throwable)
if (!ApiErrorMapper.shouldReportNonFatal(throwable)) {
AppDiagnostics.log("network_failure: $operation: ${throwable.message ?: throwable::class.java.simpleName}")
return
}
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = operation,
keys = mapOf(
"screen" to _state.value.screen.name,
"api_error_kind" to apiError.kind.name,
"api_error_code" to apiError.code,
"api_status_code" to apiError.statusCode,
"retryable" to apiError.retryable,
) + keys,
)
}
override fun onCleared() {
liveSyncClient.close()
dispatchSheetUploadsJob?.cancel()
@@ -0,0 +1,13 @@
package pl.firmatpp.kierowca.ui.theme
enum class AppThemeMode(val storedValue: String) {
Current("current"),
Material3("material3");
companion object {
val Default: AppThemeMode = Material3
fun fromStoredValue(value: String?): AppThemeMode =
entries.firstOrNull { it.storedValue == value } ?: Default
}
}
@@ -1,9 +1,20 @@
package pl.firmatpp.kierowca.ui.theme
import android.app.Activity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
object TppColors {
val Forest = Color(0xFF154212)
@@ -18,7 +29,71 @@ object TppColors {
val Error = Color(0xFFBA1A1A)
}
private val TppScheme = lightColorScheme(
@Immutable
data class TppColorTokens(
val forest: Color,
val containerGreen: Color,
val logoLeaf: Color,
val navy: Color,
val surface: Color,
val panel: Color,
val ink: Color,
val muted: Color,
val outline: Color,
val error: Color,
val card: Color,
val successContainer: Color,
val successOutline: Color,
val warningContainer: Color,
val warningOutline: Color,
)
object TppTheme {
val colors: TppColorTokens
@Composable
@ReadOnlyComposable
get() = LocalTppColorTokens.current
}
private val CurrentColorTokens = TppColorTokens(
forest = TppColors.Forest,
containerGreen = TppColors.ContainerGreen,
logoLeaf = TppColors.LogoLeaf,
navy = TppColors.Navy,
surface = TppColors.Surface,
panel = TppColors.Panel,
ink = TppColors.Ink,
muted = TppColors.Muted,
outline = TppColors.Outline,
error = TppColors.Error,
card = Color.White,
successContainer = Color(0xFFEAF7EF),
successOutline = Color(0xFF9BD1AD),
warningContainer = Color(0xFFFFF7E6),
warningOutline = Color(0xFFE6B85C),
)
private val Material3ColorTokens = TppColorTokens(
forest = Color(0xFF226C2C),
containerGreen = Color(0xFF2F7D32),
logoLeaf = Color(0xFF5FA642),
navy = Color(0xFF225A86),
surface = Color(0xFFF8FBF6),
panel = Color(0xFFEAF1E6),
ink = Color(0xFF172018),
muted = Color(0xFF566252),
outline = Color(0xFFBAC8B5),
error = Color(0xFFBA1A1A),
card = Color(0xFFFFFFFF),
successContainer = Color(0xFFDFF4E2),
successOutline = Color(0xFF8EC69A),
warningContainer = Color(0xFFFFF1D6),
warningOutline = Color(0xFFE4B45B),
)
private val LocalTppColorTokens = staticCompositionLocalOf { CurrentColorTokens }
private val CurrentScheme = lightColorScheme(
primary = TppColors.Forest,
onPrimary = Color.White,
primaryContainer = TppColors.ContainerGreen,
@@ -33,10 +108,71 @@ private val TppScheme = lightColorScheme(
error = TppColors.Error,
)
private val Material3Scheme = lightColorScheme(
primary = Material3ColorTokens.forest,
onPrimary = Color.White,
primaryContainer = Material3ColorTokens.containerGreen,
onPrimaryContainer = Color.White,
secondary = Material3ColorTokens.navy,
onSecondary = Color.White,
secondaryContainer = Color(0xFFD2E7F7),
onSecondaryContainer = Color(0xFF071E30),
tertiary = Color(0xFF7A5D00),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFFFE08A),
background = Material3ColorTokens.surface,
onBackground = Material3ColorTokens.ink,
surface = Material3ColorTokens.surface,
onSurface = Material3ColorTokens.ink,
surfaceVariant = Material3ColorTokens.panel,
onSurfaceVariant = Material3ColorTokens.muted,
outline = Material3ColorTokens.outline,
outlineVariant = Color(0xFFD5DED1),
error = Material3ColorTokens.error,
)
private val CurrentShapes = Shapes(
extraSmall = androidx.compose.foundation.shape.RoundedCornerShape(2.dp),
small = androidx.compose.foundation.shape.RoundedCornerShape(4.dp),
medium = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
large = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
extraLarge = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
)
private val Material3Shapes = Shapes(
extraSmall = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
small = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
medium = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
large = androidx.compose.foundation.shape.RoundedCornerShape(28.dp),
extraLarge = androidx.compose.foundation.shape.RoundedCornerShape(32.dp),
)
@Composable
fun TppKierowcaTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = TppScheme,
content = content,
)
@Suppress("DEPRECATION")
fun TppKierowcaTheme(
themeMode: AppThemeMode = AppThemeMode.Default,
content: @Composable () -> Unit,
) {
val colorTokens = if (themeMode == AppThemeMode.Material3) Material3ColorTokens else CurrentColorTokens
val colorScheme = if (themeMode == AppThemeMode.Material3) Material3Scheme else CurrentScheme
val shapes = if (themeMode == AppThemeMode.Material3) Material3Shapes else CurrentShapes
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
window.statusBarColor = colorTokens.surface.toArgb()
window.navigationBarColor = colorTokens.surface.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true
WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = true
}
}
CompositionLocalProvider(LocalTppColorTokens provides colorTokens) {
MaterialTheme(
colorScheme = colorScheme,
shapes = shapes,
content = content,
)
}
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.ITSTransport" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.data
import java.io.IOException
import java.net.UnknownHostException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -16,6 +17,23 @@ class ApiErrorMapperTest {
assertEquals("Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.", error.message)
}
@Test
fun mapsUnknownHostToActionableOfflineMessage() {
val error = ApiErrorMapper.map(UnknownHostException("api-intranet.firmatpp.pl"))
assertEquals(ApiErrorKind.Network, error.kind)
assertTrue(error.retryable)
assertEquals(
"Brak internetu lub połączenia z serwerem. Sprawdź zasięg i spróbuj ponownie.",
error.message,
)
}
@Test
fun doesNotReportExpectedNetworkFailuresAsNonFatal() {
assertFalse(ApiErrorMapper.shouldReportNonFatal(UnknownHostException("api-intranet.firmatpp.pl")))
}
@Test
fun mapsMobileApiProblemCodes() {
val error = ApiErrorMapper.mapProblem(
@@ -0,0 +1,44 @@
package pl.firmatpp.kierowca.data
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import java.io.File
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
@OptIn(ExperimentalCoroutinesApi::class)
class AppPreferencesStoreTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test
fun startsWithMaterial3ThemeMode() = runTest {
val store = preferencesStore("default.preferences_pb")
assertEquals(AppThemeMode.Material3, store.themeMode.first())
}
@Test
fun persistsSelectedThemeMode() = runTest {
val file = temporaryFolder.newFile("theme.preferences_pb")
val store = preferencesStore(file)
store.setThemeMode(AppThemeMode.Material3)
assertEquals(AppThemeMode.Material3, store.themeMode.first())
}
private fun TestScope.preferencesStore(fileName: String): AppPreferencesStore =
preferencesStore(temporaryFolder.newFile(fileName))
private fun TestScope.preferencesStore(file: File): AppPreferencesStore {
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
return AppPreferencesStore(dataStore)
}
}
@@ -0,0 +1,71 @@
package pl.firmatpp.kierowca.diagnostics
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
class AppDiagnosticsTest {
private val sink = RecordingDiagnosticsSink()
@After
fun tearDown() {
AppDiagnostics.installSink(NoOpDiagnosticsSink)
}
@Test
fun reportNonFatalAddsOperationContextBeforeRecordingException() {
AppDiagnostics.installSink(sink)
val throwable = IllegalStateException("upload failed")
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "photo_upload",
keys = mapOf("route_id" to "R-42", "retryable" to true),
)
assertEquals(
listOf(
"key:operation=photo_upload",
"key:route_id=R-42",
"key:retryable=true",
"log:non_fatal: photo_upload: upload failed",
"exception:IllegalStateException",
),
sink.events,
)
assertSame(throwable, sink.throwables.single())
}
@Test
fun clearDriverIdClearsPreviouslySetUserIdentifier() {
AppDiagnostics.installSink(sink)
AppDiagnostics.setDriverId("driver-7")
AppDiagnostics.clearDriverId()
assertEquals(listOf("user:driver-7", "user:"), sink.events)
}
private class RecordingDiagnosticsSink : DiagnosticsSink {
val events = mutableListOf<String>()
val throwables = mutableListOf<Throwable>()
override fun log(message: String) {
events += "log:$message"
}
override fun setUserId(userId: String) {
events += "user:$userId"
}
override fun setCustomKey(key: String, value: String) {
events += "key:$key=$value"
}
override fun recordException(throwable: Throwable) {
events += "exception:${throwable::class.simpleName}"
throwables += throwable
}
}
}
@@ -0,0 +1,217 @@
package pl.firmatpp.kierowca.sync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.runCurrent
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
@OptIn(ExperimentalCoroutinesApi::class)
class DriverLiveSyncClientTest {
private val config = RealtimeConfigDto(
reverbEnabled = true,
reverbAppKey = "app-key",
reverbWsBaseUrl = "wss://example.test/app",
)
@Test
fun reconnectsWithBackoffAfterSocketFailure() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
factory.sockets.single().fail()
advanceTimeBy(999)
assertEquals(1, factory.sockets.size)
advanceTimeBy(1)
runCurrent()
assertEquals(2, factory.sockets.size)
factory.sockets.last().fail()
advanceTimeBy(1_999)
assertEquals(2, factory.sockets.size)
advanceTimeBy(1)
runCurrent()
assertEquals(3, factory.sockets.size)
}
@Test
fun pausesReconnectWhileOfflineAndReconnectsImmediatelyWhenNetworkReturns() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
client.onNetworkAvailable(false)
factory.sockets.single().fail()
advanceTimeBy(10_000)
assertEquals(1, factory.sockets.size)
assertTrue(factory.sockets.single().closed)
client.onNetworkAvailable(true)
advanceUntilIdle()
assertEquals(2, factory.sockets.size)
}
@Test
fun foregroundEnsureConnectedRestartsWhenSocketIsDisconnected() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
factory.sockets.single().fail()
client.ensureConnected()
advanceUntilIdle()
assertEquals(2, factory.sockets.size)
}
@Test
fun retriesWhenPrivateChannelSubscriptionAuthFails() = runTest {
val gateway = FakeLiveSyncGateway(authFailure = RuntimeException("offline"))
val factory = FakeWebSocketFactory()
val client = liveClient(gateway = gateway, factory = factory, scope = backgroundScope)
client.start("driver-1", config)
factory.sockets.single().message(connectionEstablished("socket-1"))
advanceUntilIdle()
advanceTimeBy(1_000)
runCurrent()
assertEquals(2, factory.sockets.size)
}
@Test
fun respondsToPusherPingWithPong() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
factory.sockets.single().message("""{"event":"pusher:ping"}""")
assertTrue(factory.sockets.single().sent.any { it.contains("pusher:pong") })
}
@Test
fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
factory.sockets.single().message(connectionEstablished("socket-1"))
advanceUntilIdle()
factory.sockets.single().message("""{"event":"pusher_internal:subscription_succeeded","channel":"private-driver-mobile.driver-1"}""")
advanceTimeBy(30_000)
runCurrent()
assertTrue(factory.sockets.first().closed)
assertEquals(2, factory.sockets.size)
}
@Test
fun ignoresCallbacksFromOldSocketAfterReconnect() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
val oldSocket = factory.sockets.single()
oldSocket.fail()
client.ensureConnected()
assertEquals(2, factory.sockets.size)
oldSocket.fail()
client.ensureConnected()
assertEquals(2, factory.sockets.size)
}
private fun liveClient(
gateway: FakeLiveSyncGateway = FakeLiveSyncGateway(),
factory: FakeWebSocketFactory,
scope: CoroutineScope,
): DriverLiveSyncClient =
DriverLiveSyncClient(
gateway = gateway,
onConnected = {},
onHint = {},
webSocketFactory = factory,
scope = scope,
reconnectDelaysMs = listOf(1_000L, 2_000L),
staleTimeoutMs = 30_000L,
)
private fun connectionEstablished(socketId: String): String =
"""{"event":"pusher:connection_established","data":"{\"socket_id\":\"$socketId\"}"}"""
private class FakeLiveSyncGateway(
private val authFailure: Throwable? = null,
) : DriverLiveSyncGateway {
override suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse {
authFailure?.let { throw it }
return BroadcastAuthResponse(auth = "auth-token")
}
override suspend fun storeRealtimeStatus(status: String, socketId: String?, error: String?) = Unit
}
private class FakeWebSocketFactory : LiveWebSocketFactory {
val sockets = mutableListOf<FakeWebSocket>()
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket {
val socket = FakeWebSocket(listener)
sockets += socket
return socket
}
}
private class FakeWebSocket(
private val listener: WebSocketListener,
) : WebSocket {
val sent = mutableListOf<String>()
var closed = false
fun fail() {
listener.onFailure(this, RuntimeException("boom"), null)
}
fun message(text: String) {
listener.onMessage(this, text)
}
override fun request(): Request = Request.Builder().url("wss://example.test/app").build()
override fun queueSize(): Long = 0L
override fun send(text: String): Boolean {
sent += text
return true
}
override fun send(bytes: ByteString): Boolean = true
override fun close(code: Int, reason: String?): Boolean {
closed = true
return true
}
override fun cancel() {
closed = true
}
}
}
@@ -17,8 +17,25 @@ class DriverLeaveRequestUiRulesTest {
@Test
fun exposesPolishStatusLabels() {
assertEquals("Oczekuje", DriverLeaveRequestUiRules.statusLabel("pending"))
assertEquals("Zatwierdzony", DriverLeaveRequestUiRules.statusLabel("approved"))
assertEquals("Odrzucony", DriverLeaveRequestUiRules.statusLabel("rejected"))
assertEquals("Anulowanie do decyzji", DriverLeaveRequestUiRules.statusLabel("cancel_requested"))
assertEquals("Anulowany", DriverLeaveRequestUiRules.statusLabel("cancelled"))
assertEquals("Cofnięto decyzję", DriverLeaveRequestUiRules.statusLabel("revoked"))
assertEquals("Nieznany status", DriverLeaveRequestUiRules.statusLabel("some_raw_status"))
}
@Test
fun exposesPolishEventActionLabels() {
assertEquals("Wniosek złożony", DriverLeaveRequestUiRules.eventActionLabel("submitted"))
assertEquals("Wniosek zatwierdzony", DriverLeaveRequestUiRules.eventActionLabel("approved"))
assertEquals("Wniosek odrzucony", DriverLeaveRequestUiRules.eventActionLabel("rejected"))
assertEquals("Decyzja cofnięta", DriverLeaveRequestUiRules.eventActionLabel("revoked"))
assertEquals("Wniosek anulowany przez kierowcę", DriverLeaveRequestUiRules.eventActionLabel("cancelled_by_driver"))
assertEquals("Kierowca poprosił o anulowanie", DriverLeaveRequestUiRules.eventActionLabel("cancellation_requested"))
assertEquals("Anulowanie zatwierdzone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_approved"))
assertEquals("Anulowanie odrzucone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_rejected"))
assertEquals("Aktualizacja wniosku", DriverLeaveRequestUiRules.eventActionLabel("some_raw_action"))
}
@Test
@@ -61,6 +61,30 @@ class DriverUiRulesTest {
assertEquals(3, inlinePhotoGridRows(5))
}
@Test
fun keepsPhotoPreviewFeedbackVisibleUntilImageLoadsOrFails() {
assertTrue(shouldShowPhotoPreviewLoadingState(hasPhoto = true, isLoading = true, isError = false))
assertFalse(shouldShowPhotoPreviewLoadingState(hasPhoto = true, isLoading = false, isError = false))
assertFalse(shouldShowPhotoPreviewLoadingState(hasPhoto = true, isLoading = true, isError = true))
assertFalse(shouldShowPhotoPreviewLoadingState(hasPhoto = false, isLoading = true, isError = false))
assertTrue(shouldShowPhotoPreviewErrorState(hasPhoto = true, isError = true))
assertFalse(shouldShowPhotoPreviewErrorState(hasPhoto = true, isError = false))
assertFalse(shouldShowPhotoPreviewErrorState(hasPhoto = false, isError = true))
}
@Test
fun keepsPhotoTileFeedbackVisibleUntilThumbnailLoadsOrFails() {
assertTrue(shouldShowPhotoTileLoadingState(hasPhoto = true, isLoading = true, isError = false))
assertFalse(shouldShowPhotoTileLoadingState(hasPhoto = true, isLoading = false, isError = false))
assertFalse(shouldShowPhotoTileLoadingState(hasPhoto = true, isLoading = true, isError = true))
assertFalse(shouldShowPhotoTileLoadingState(hasPhoto = false, isLoading = true, isError = false))
assertTrue(shouldShowPhotoTileErrorState(hasPhoto = true, isError = true))
assertFalse(shouldShowPhotoTileErrorState(hasPhoto = true, isError = false))
assertFalse(shouldShowPhotoTileErrorState(hasPhoto = false, isError = true))
}
@Test
fun usesShorterChromeOnCompactScreens() {
assertEquals(118, authBrandBannerHeightDp(600))
@@ -199,6 +223,22 @@ class DriverUiRulesTest {
assertFalse(shouldShowDispatchSheetReminderCard(null))
}
@Test
fun explainsOfflineStateWithoutCachedSync() {
assertEquals(
"Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie.",
offlineStaleBannerMessage(isOnline = false, isStale = false, syncLabel = "brak zapisanej synchronizacji"),
)
}
@Test
fun explainsStaleCachedDataWhenOnline() {
assertEquals(
"Dane mogą być nieaktualne. Ostatnia synchronizacja: 12:30.",
offlineStaleBannerMessage(isOnline = true, isStale = true, syncLabel = "12:30"),
)
}
@Test
fun labelsDispatchSheetActionByStatusAndLocalUploadQueue() {
assertEquals("Zrób zdjęcie", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "missing"), hasQueuedUpload = false))
@@ -3,6 +3,8 @@ package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
class DriverUiStateTest {
@Test
@@ -12,4 +14,49 @@ class DriverUiStateTest {
assertEquals(DriverScreen.Initializing, state.screen)
assertTrue(state.loading)
}
@Test
fun startsWithMaterial3ThemeMode() {
val state = DriverUiState()
assertEquals(AppThemeMode.Material3, state.themeMode)
}
@Test
fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() {
val state = DriverUiState(
screen = DriverScreen.LeaveRequestDetail,
selectedLeaveRequest = leaveRequest(id = "leave-1", status = "pending"),
)
val refreshed = state.withLoadedLeaveRequests(
requests = listOf(leaveRequest(id = "leave-1", status = "approved")),
navigateToList = false,
)
assertEquals(DriverScreen.LeaveRequestDetail, refreshed.screen)
assertEquals("approved", refreshed.selectedLeaveRequest?.status)
}
@Test
fun openingLeaveRequestsNavigatesToList() {
val state = DriverUiState(screen = DriverScreen.Routes)
val refreshed = state.withLoadedLeaveRequests(
requests = listOf(leaveRequest(id = "leave-1", status = "pending")),
navigateToList = true,
)
assertEquals(DriverScreen.LeaveRequests, refreshed.screen)
assertEquals(1, refreshed.leaveRequests.size)
}
private fun leaveRequest(id: String, status: String): DriverLeaveRequestDto =
DriverLeaveRequestDto(
id = id,
dateFrom = "2026-07-10",
dateTo = "2026-07-12",
type = "URLOP",
status = status,
)
}
@@ -0,0 +1,23 @@
package pl.firmatpp.kierowca.ui.theme
import org.junit.Assert.assertEquals
import org.junit.Test
class AppThemeModeTest {
@Test
fun parsesStoredMaterial3Value() {
assertEquals(AppThemeMode.Material3, AppThemeMode.fromStoredValue("material3"))
}
@Test
fun fallsBackToCurrentForUnknownStoredValue() {
assertEquals(AppThemeMode.Material3, AppThemeMode.fromStoredValue("future-theme"))
assertEquals(AppThemeMode.Material3, AppThemeMode.fromStoredValue(null))
}
@Test
fun storesStablePreferenceValues() {
assertEquals("current", AppThemeMode.Current.storedValue)
assertEquals("material3", AppThemeMode.Material3.storedValue)
}
}
+1
View File
@@ -3,4 +3,5 @@ plugins {
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.google.services) apply false
alias(libs.plugins.firebase.crashlytics) apply false
}
+3
View File
@@ -20,6 +20,7 @@ playServicesAuthApiPhone = "18.3.0"
junit = "4.13.2"
firebaseBom = "33.7.0"
googleServices = "4.4.2"
firebaseCrashlyticsPlugin = "3.0.7"
[libraries]
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -55,6 +56,7 @@ retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref =
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" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@@ -62,3 +64,4 @@ 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" }
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" }