Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/flipcash/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -328,4 +328,5 @@ dependencies {

testImplementation(libs.junit)
testImplementation(libs.kotlin.test.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit> {
override fun create(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
launchBestEffort(tag = "camerax") {
ProcessCameraProvider.getInstance(context)
}
}

override fun dependencies(): List<Class<out Initializer<*>?>?> {
return emptyList()
return listOf(TraceInitializer::class.java)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,11 +19,11 @@ import kotlinx.coroutines.launch
class ClockDriftInitializer : Initializer<Unit> {

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit> {
override fun create(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
launchBestEffort(tag = "curves") {
Curves.initialize(context)
}
}

override fun dependencies(): List<Class<out Initializer<*>?>?> {
return emptyList()
return listOf(TraceInitializer::class.java)
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit> {
Expand All @@ -35,7 +32,7 @@ class TraceInitializer: Initializer<Unit> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pair<String, Throwable>>()

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<NoSuchMethodError>(failures.single().second)
}

@Test
fun `does not report cancellation as a failure`() = runTest {
val failures = mutableListOf<Throwable>()

val job = launchBestEffort(
tag = "camerax",
dispatcher = UnconfinedTestDispatcher(testScheduler),
onFailure = { _, error -> failures += error },
) { awaitCancellation() }
job.cancel()
job.join()

assertTrue(job.isCancelled)
assertTrue(failures.isEmpty())
}
}
Loading