Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72db88b25c |
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Any?> = 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,10 +706,35 @@ 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)
|
||||
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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user