Coverage Summary for Class: CrashReportingKt (com.georgeci.moneysurfer.domain.telemetry)

Class Method, % Branch, % Line, % Instruction, %
CrashReportingKt 100% (4/4) 100% (6/6) 100% (14/14) 100% (62/62)
CrashReportingKt$bindCrashReportingUser$1 100% (1/1) 100% (1/1) 100% (10/10)
Total 100% (5/5) 100% (6/6) 100% (15/15) 100% (72/72)


 package com.georgeci.moneysurfer.domain.telemetry
 
 import co.touchlab.kermit.Logger
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 
 /**
  * Wires [crashReporter] into the app: collection is enabled for release builds only, and
  * Kermit gains a [CrashReportingLogWriter] so Warn+ logs become breadcrumbs and logged
  * throwables become non-fatals — no call site has to know Crashlytics exists.
  *
  * When [userIds] is given (the session's Firebase uid, null when signed out) the
  * Crashlytics user id is kept in step with it for the lifetime of the process, so a
  * crash report can be tied back to the account that produced it. The uid is an opaque
  * Firebase identifier, not PII in the sense of issue #154 — unlike emails it never
  * reaches logcat, only the crash backend the account already lives in.
  *
  * Called once per process from `initKoin`, after Koin has started. Debug builds keep
  * collection off so local crashes never pollute the production Crashlytics dashboard.
  *
  * Idempotent: a second call is a no-op. Repeated `initKoin` calls — instrumented tests,
  * previews — therefore stack neither duplicate log writers nor duplicate uid collectors,
  * and the collector's scope is created at most once per process.
  */
 fun installCrashReporting(
     crashReporter: CrashReporter,
     isDebug: Boolean,
     userIds: Flow<String?>? = null,
 ) {
     if (isInstalled) return
     isInstalled = true
 
     crashReporter.setCollectionEnabled(!isDebug)
     Logger.addLogWriter(CrashReportingLogWriter(crashReporter))
 
     if (userIds != null) {
         userBinding = bindCrashReportingUser(crashReporter, userIds, processScope)
     }
 }
 
 /**
  * Mirrors [userIds] onto the Crashlytics user id on [scope]. Exposed separately from
  * [installCrashReporting] so the binding can be driven by a caller-owned scope in tests;
  * production goes through [installCrashReporting], which owns a single process scope.
  */
 fun bindCrashReportingUser(
     crashReporter: CrashReporter,
     userIds: Flow<String?>,
     scope: CoroutineScope,
 ): Job = userIds
     .onEach { uid -> crashReporter.setUserId(uid) }
     .launchIn(scope)
 
 private var isInstalled = false
 private var userBinding: Job? = null
 
 // Process-scoped on purpose: the uid collector lives as long as the app does, and there
 // is nothing above `initKoin` to own it. `installCrashReporting` is latched, so this is
 // created at most once.
 private val processScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
 
 /** Test-only reset of the [installCrashReporting] latch and its uid collector. */
 internal fun resetCrashReportingForTest() {
     userBinding?.cancel()
     userBinding = null
     isInstalled = false
 }