From 6eb17ddd9cd07c0df3c11d90c5b247576d5a13f9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 16:33:55 -0400 Subject: [PATCH 1/5] feat(startup): add launchBestEffort for fire-and-forget initializer work --- apps/flipcash/app/build.gradle.kts | 1 + .../app/internal/startup/LaunchBestEffort.kt | 56 ++++++++++++++++ .../internal/startup/LaunchBestEffortTest.kt | 66 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/LaunchBestEffort.kt create mode 100644 apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/startup/LaunchBestEffortTest.kt diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index eeb55d19de..e2866fc80a 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -328,4 +328,5 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/LaunchBestEffort.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/LaunchBestEffort.kt new file mode 100644 index 0000000000..cfa629ab1b --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/LaunchBestEffort.kt @@ -0,0 +1,56 @@ +package com.flipcash.app.internal.startup + +import com.getcode.utils.trace +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Runs [block] off the startup thread as a best-effort side task: if it throws, the failure is + * reported through [onFailure] and the process survives. + * + * `androidx.startup` initializers run during `ContentProvider` creation, before any UI exists. A + * bare `CoroutineScope(Dispatchers.IO).launch { }` there has no exception handler, so anything the + * block throws reaches the default uncaught-exception handler and kills a cold start outright — see + * Bugsnag `6a8f47a7b5ee91bed8ac6cac`, where CameraX's `Context.getDeviceId()` call threw + * `NoSuchMethodError` on a runtime that misreports its API level. + * + * Catches [Throwable], not [Exception]: `NoSuchMethodError` and the rest of `LinkageError` extend + * `Error`. [CancellationException] is re-thrown so structured cancellation still works. + * + * @param tag short trace tag identifying the startup task, e.g. `"camerax"`. + * @param dispatcher the dispatcher to run on; overridable for tests. + * @param onFailure what to do with a non-cancellation throwable. The default traces it, which also + * files it as a handled error via `ErrorUtils`. + */ +internal fun launchBestEffort( + tag: String, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + onFailure: (String, Throwable) -> Unit = ::traceStartupFailure, + block: suspend CoroutineScope.() -> Unit, +): Job = CoroutineScope(dispatcher).launch { + try { + block() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (throwable: Throwable) { + onFailure(tag, throwable) + } +} + +/** + * Default [launchBestEffort] failure sink. + * + * Note that [trace] no-ops until `TraceManager.initialize` has run, which is why the initializers + * that use [launchBestEffort] declare `TraceInitializer` as a dependency. + */ +internal fun traceStartupFailure(tag: String, error: Throwable) { + trace( + message = "Startup task failed", + tag = tag, + error = error, + ) +} diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/startup/LaunchBestEffortTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/startup/LaunchBestEffortTest.kt new file mode 100644 index 0000000000..1135ae3dd5 --- /dev/null +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/startup/LaunchBestEffortTest.kt @@ -0,0 +1,66 @@ +package com.flipcash.app.internal.startup + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class LaunchBestEffortTest { + + @Test + fun `runs the block`() = runTest { + var ran = false + + val job = launchBestEffort( + tag = "camerax", + dispatcher = UnconfinedTestDispatcher(testScheduler), + ) { ran = true } + job.join() + + assertTrue(ran) + assertFalse(job.isCancelled) + } + + @Test + fun `reports a LinkageError instead of failing the job`() = runTest { + val failures = mutableListOf>() + + val job = launchBestEffort( + tag = "camerax", + dispatcher = UnconfinedTestDispatcher(testScheduler), + onFailure = { tag, error -> failures += tag to error }, + ) { + // The real Bugsnag crash: CameraX calls Context.getDeviceId() on a runtime + // that reports API 34 but has no such method. + throw NoSuchMethodError("No virtual method getDeviceId()I") + } + job.join() + + assertFalse(job.isCancelled) + assertEquals(1, failures.size) + assertEquals("camerax", failures.single().first) + assertIs(failures.single().second) + } + + @Test + fun `does not report cancellation as a failure`() = runTest { + val failures = mutableListOf() + + val job = launchBestEffort( + tag = "camerax", + dispatcher = UnconfinedTestDispatcher(testScheduler), + onFailure = { _, error -> failures += error }, + ) { awaitCancellation() } + job.cancel() + job.join() + + assertTrue(job.isCancelled) + assertTrue(failures.isEmpty()) + } +} From 00a1332da8391365fa2dd7fc801b0a73ffc3edbc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 16:34:25 -0400 Subject: [PATCH 2/5] fix(startup): stop a failed CameraX warm-up from crashing launch --- .../internal/startup/CameraXInitializer.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/CameraXInitializer.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/CameraXInitializer.kt index 37b54b7409..08beb426cc 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/CameraXInitializer.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/CameraXInitializer.kt @@ -3,18 +3,25 @@ package com.flipcash.app.internal.startup import android.content.Context import androidx.camera.lifecycle.ProcessCameraProvider import androidx.startup.Initializer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +/** + * Warms up the CameraX provider so the scanner opens faster on first use. + * + * Purely an optimisation — a failure costs a cold camera open, nothing more, so it must not be able + * to take down launch. `ProcessCameraProvider.getInstance` reaches `Context.getDeviceId()` on + * API 34+, which throws `NoSuchMethodError` on runtimes that misreport their API level + * (Bugsnag `6a8f47a7b5ee91bed8ac6cac`). + * + * Depends on [TraceInitializer] so a failure here is actually traceable. + */ class CameraXInitializer: Initializer { override fun create(context: Context) { - CoroutineScope(Dispatchers.IO).launch { + launchBestEffort(tag = "camerax") { ProcessCameraProvider.getInstance(context) } } override fun dependencies(): List?>?> { - return emptyList() + return listOf(TraceInitializer::class.java) } -} \ No newline at end of file +} From 7d254be4289638253a16ae200401306e6a429549 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 16:34:48 -0400 Subject: [PATCH 3/5] fix(startup): guard the bonding curve preload against startup failures --- .../startup/DiscreteBondingCurveInitializer.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/DiscreteBondingCurveInitializer.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/DiscreteBondingCurveInitializer.kt index d10c0d2141..f030a36544 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/DiscreteBondingCurveInitializer.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/DiscreteBondingCurveInitializer.kt @@ -3,18 +3,20 @@ package com.flipcash.app.internal.startup import android.content.Context import androidx.startup.Initializer import com.flipcash.libs.currency.math.Curves -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +/** + * Preloads the discrete bonding curve tables. + * + * Depends on [TraceInitializer] so a failure here is actually traceable. + */ class DiscreteBondingCurveInitializer : Initializer { override fun create(context: Context) { - CoroutineScope(Dispatchers.IO).launch { + launchBestEffort(tag = "curves") { Curves.initialize(context) } } override fun dependencies(): List?>?> { - return emptyList() + return listOf(TraceInitializer::class.java) } -} \ No newline at end of file +} From 614aeafd7ed9985f2339fd8d195ff690697482fa Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 16:35:14 -0400 Subject: [PATCH 4/5] fix(startup): guard the SNTP clock drift probe against startup failures --- .../flipcash/app/internal/startup/ClockDriftInitializer.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/ClockDriftInitializer.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/ClockDriftInitializer.kt index 9f7795a7c3..0781c54c1e 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/ClockDriftInitializer.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/ClockDriftInitializer.kt @@ -6,9 +6,6 @@ import com.flipcash.app.internal.time.SntpClient import com.getcode.utils.ClockSource import com.getcode.utils.TraceManager import com.getcode.utils.trace -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch /** * Best-effort SNTP measurement of how far the device clock has drifted from real time, taken once at @@ -22,11 +19,11 @@ import kotlinx.coroutines.launch class ClockDriftInitializer : Initializer { override fun create(context: Context) { - CoroutineScope(Dispatchers.IO).launch { + launchBestEffort(tag = "clock") { val offsetMillis = SntpClient.queryClockOffsetMillis() if (offsetMillis == null) { trace(tag = "clock", message = "Clock drift on launch: unavailable (SNTP query failed)") - return@launch + return@launchBestEffort } // drift = device time - true time = negative of the NTP offset (true - device). val driftMillis = -offsetMillis From 13e9fcebd77245b3e75450389b2ba2959795a0ba Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 16:35:34 -0400 Subject: [PATCH 5/5] fix(startup): guard the Bugsnag bootstrap against startup failures --- .../com/flipcash/app/internal/startup/TraceInitializer.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/TraceInitializer.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/TraceInitializer.kt index 6a4d0b7d0b..8ba33809ca 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/TraceInitializer.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/TraceInitializer.kt @@ -15,9 +15,6 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import timber.log.Timber class TraceInitializer: Initializer { @@ -35,7 +32,7 @@ class TraceInitializer: Initializer { Timber.plant(FlipcashDebugTree) TraceManager.includeRpcBodies = true } else { - CoroutineScope(Dispatchers.IO).launch { + launchBestEffort(tag = "trace") { val entryPoint = EntryPointAccessors.fromApplication(context, TraceEntryPoint::class.java) val stageProvider = entryPoint.releaseStageProvider() val versionCode = BuildConfig.VERSION_CODE