Compare commits

...
1 Commits
Author SHA1 Message Date
admin 72db88b25c Add Firebase Crashlytics diagnostics 2026-07-05 00:06:47 +02:00
13 changed files with 259 additions and 7 deletions
+4 -2
View File
@@ -10,6 +10,7 @@ plugins {
if (file("google-services.json").exists()) { if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services") apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics")
} }
val keystorePropertiesFile = rootProject.file("keystore.properties") val keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -33,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 41 versionCode = 42
versionName = "1.0.40" versionName = "1.0.41"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -123,6 +124,7 @@ dependencies {
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
implementation(libs.coroutines.android) implementation(libs.coroutines.android)
implementation(platform(libs.firebase.bom)) implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.messaging) implementation(libs.firebase.messaging)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
+1
View File
@@ -10,6 +10,7 @@
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:name=".DriverApplication"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.TppKierowca"> 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")
}
}
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DispatchSheetUploadWorker( class DispatchSheetUploadWorker(
appContext: Context, appContext: Context,
@@ -64,6 +65,17 @@ class DispatchSheetUploadWorker(
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
val error = ApiErrorMapper.map(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 val status = if (error.retryable) PhotoUploadStatus.FailedRetryable else PhotoUploadStatus.FailedPermanent
dao.updateStatus( dao.updateStatus(
clientRequestId = clientRequestId, clientRequestId = clientRequestId,
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class PhotoUploadWorker( class PhotoUploadWorker(
appContext: Context, appContext: Context,
@@ -64,6 +65,18 @@ class PhotoUploadWorker(
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
val error = ApiErrorMapper.map(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 val status = if (error.retryable) PhotoUploadStatus.FailedRetryable else PhotoUploadStatus.FailedPermanent
dao.updateStatus( dao.updateStatus(
clientRequestId = clientRequestId, 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)
}
}
@@ -17,6 +17,7 @@ import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverLiveSyncClient( class DriverLiveSyncClient(
private val repository: DriverRepository, private val repository: DriverRepository,
@@ -66,6 +67,7 @@ class DriverLiveSyncClient(
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
stopHeartbeat() stopHeartbeat()
AppDiagnostics.log("realtime_error: ${t.message ?: response?.message ?: "unknown"}")
reportRealtimeStatus("error", t.message ?: response?.message) reportRealtimeStatus("error", t.message ?: response?.message)
started.set(false) started.set(false)
scheduleReconnect() scheduleReconnect()
@@ -14,6 +14,7 @@ import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverSyncWorker( class DriverSyncWorker(
appContext: Context, appContext: Context,
@@ -62,6 +63,18 @@ class DriverSyncWorker(
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
val error = ApiErrorMapper.map(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() 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.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import retrofit2.HttpException import retrofit2.HttpException
class NewRouteNotificationWorker( class NewRouteNotificationWorker(
@@ -76,6 +77,17 @@ class NewRouteNotificationWorker(
Result.success() Result.success()
} else { } else {
val error = ApiErrorMapper.map(throwable) 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() if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.success()
} }
} }
@@ -26,6 +26,7 @@ import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.sync.DriverLiveSyncClient import pl.firmatpp.kierowca.sync.DriverLiveSyncClient
import pl.firmatpp.kierowca.sync.DriverSyncHint import pl.firmatpp.kierowca.sync.DriverSyncHint
import pl.firmatpp.kierowca.sync.DriverSyncWorker import pl.firmatpp.kierowca.sync.DriverSyncWorker
@@ -120,7 +121,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun requestOtp(phone: String) = runLoading { fun requestOtp(phone: String) = runLoading("request_otp") {
val response = repository.requestOtp(phone) val response = repository.requestOtp(phone)
otpAutoSubmitPolicy.reset() otpAutoSubmitPolicy.reset()
_state.update { _state.update {
@@ -134,8 +135,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") 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()) } _state.update { it.copy(driver = driver, imageAuthHeader = repository.imageAuthHeader()) }
refreshRoutes() refreshRoutes()
} }
@@ -170,6 +172,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val cached = syncRepository.bootstrap(date) val cached = syncRepository.bootstrap(date)
val response = cached.value val response = cached.value
val settings = response.driverAppSettings val settings = response.driverAppSettings
AppDiagnostics.setDriverId(response.session.driver.id)
_state.update { _state.update {
it.copy( it.copy(
driver = response.session.driver, driver = response.session.driver,
@@ -203,6 +206,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate) observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
}.onFailure { throwable -> }.onFailure { throwable ->
reportHandledException("load_routes", throwable, mapOf("date" to date))
_state.update { _state.update {
val apiError = ApiErrorMapper.map(throwable) val apiError = ApiErrorMapper.map(throwable)
it.copy( it.copy(
@@ -223,7 +227,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 cached = syncRepository.route(routeId)
val response = cached.value val response = cached.value
photoUploadOutbox.discardConfirmedServerPhotos(response.route) photoUploadOutbox.discardConfirmedServerPhotos(response.route)
@@ -582,21 +586,27 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun logout() = runLoading { fun logout() = runLoading("logout") {
photoUploadsJob?.cancel() photoUploadsJob?.cancel()
dispatchSheetUploadsJob?.cancel() dispatchSheetUploadsJob?.cancel()
liveSyncClient.stop() liveSyncClient.stop()
repository.logout() repository.logout()
syncRepository.clearCache() syncRepository.clearCache()
pushTokenRegisteredForDriverId = null pushTokenRegisteredForDriverId = null
AppDiagnostics.clearDriverId()
_state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) } _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 { viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) } _state.update { it.copy(loading = true, error = null) }
runCatching { block() } runCatching { block() }
.onFailure { throwable -> .onFailure { throwable ->
reportHandledException(operation, throwable, keys)
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
} }
_state.update { it.copy(loading = false) } _state.update { it.copy(loading = false) }
@@ -696,12 +706,37 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
runCatching { runCatching {
repository.storePushToken(token) repository.storePushToken(token)
pushTokenRegisteredForDriverId = driverId 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)
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() { override fun onCleared() {
liveSyncClient.close() liveSyncClient.close()
dispatchSheetUploadsJob?.cancel() dispatchSheetUploadsJob?.cancel()
@@ -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
}
}
}
+1
View File
@@ -3,4 +3,5 @@ plugins {
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.google.services) 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" junit = "4.13.2"
firebaseBom = "33.7.0" firebaseBom = "33.7.0"
googleServices = "4.4.2" googleServices = "4.4.2"
firebaseCrashlyticsPlugin = "3.0.7"
[libraries] [libraries]
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } 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" } 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-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } 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" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" }