From 72db88b25c5575c7a6f600ec94a9d2fb04395216 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 5 Jul 2026 00:06:47 +0200 Subject: [PATCH] Add Firebase Crashlytics diagnostics --- app/build.gradle.kts | 6 +- app/src/main/AndroidManifest.xml | 1 + .../pl/firmatpp/kierowca/DriverApplication.kt | 12 +++ .../data/upload/DispatchSheetUploadWorker.kt | 12 +++ .../kierowca/data/upload/PhotoUploadWorker.kt | 13 ++++ .../kierowca/diagnostics/AppDiagnostics.kt | 75 +++++++++++++++++++ .../kierowca/sync/DriverLiveSyncClient.kt | 2 + .../kierowca/sync/DriverSyncWorker.kt | 13 ++++ .../sync/NewRouteNotificationWorker.kt | 12 +++ .../firmatpp/kierowca/ui/DriverViewModel.kt | 45 +++++++++-- .../diagnostics/AppDiagnosticsTest.kt | 71 ++++++++++++++++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 3 + 13 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/diagnostics/AppDiagnostics.kt create mode 100644 app/src/test/java/pl/firmatpp/kierowca/diagnostics/AppDiagnosticsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 249197f..e46a567 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 = 41 - versionName = "1.0.40" + versionCode = 42 + versionName = "1.0.41" 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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ce5f6fa..f09c3dc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> diff --git a/app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt b/app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt new file mode 100644 index 0000000..0457716 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt @@ -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") + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt index c0e30a0..d271d9f 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt @@ -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, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt index fdf1295..13e1e0b 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt @@ -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, diff --git a/app/src/main/java/pl/firmatpp/kierowca/diagnostics/AppDiagnostics.kt b/app/src/main/java/pl/firmatpp/kierowca/diagnostics/AppDiagnostics.kt new file mode 100644 index 0000000..39e03b5 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/diagnostics/AppDiagnostics.kt @@ -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 = 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) + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt index ea93612..0ecbca1 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt @@ -17,6 +17,7 @@ import okhttp3.WebSocket import okhttp3.WebSocketListener import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.model.RealtimeConfigDto +import pl.firmatpp.kierowca.diagnostics.AppDiagnostics class DriverLiveSyncClient( private val repository: DriverRepository, @@ -66,6 +67,7 @@ class DriverLiveSyncClient( override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { stopHeartbeat() + AppDiagnostics.log("realtime_error: ${t.message ?: response?.message ?: "unknown"}") reportRealtimeStatus("error", t.message ?: response?.message) started.set(false) scheduleReconnect() diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt index 0e41fc8..7ed74b7 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt @@ -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() } diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt index 577129f..0c77b1a 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt @@ -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() } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index 63929ea..9547ddf 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -26,6 +26,7 @@ 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 @@ -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) otpAutoSubmitPolicy.reset() _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") + AppDiagnostics.setDriverId(driver.id) _state.update { it.copy(driver = driver, imageAuthHeader = repository.imageAuthHeader()) } refreshRoutes() } @@ -170,6 +172,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,6 +206,7 @@ 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( @@ -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 response = cached.value photoUploadOutbox.discardConfirmedServerPhotos(response.route) @@ -582,21 +586,27 @@ 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 = emptyMap(), + block: suspend () -> Unit, + ) { viewModelScope.launch { _state.update { it.copy(loading = true, error = null) } runCatching { block() } .onFailure { throwable -> + reportHandledException(operation, throwable, keys) _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } } _state.update { it.copy(loading = false) } @@ -696,12 +706,37 @@ 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 = 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() { liveSyncClient.close() dispatchSheetUploadsJob?.cancel() diff --git a/app/src/test/java/pl/firmatpp/kierowca/diagnostics/AppDiagnosticsTest.kt b/app/src/test/java/pl/firmatpp/kierowca/diagnostics/AppDiagnosticsTest.kt new file mode 100644 index 0000000..7efcc93 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/diagnostics/AppDiagnosticsTest.kt @@ -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() + val throwables = mutableListOf() + + 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 + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 7ea37b1..bef58d8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f4fba62..1dedf13 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }