From 1d3ba82646589150da03f90bd18cb72b9bb660cc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 08:42:01 -0400 Subject: [PATCH] fix(bugsnag): file Test Lab devices under their own stage Play's pre-launch reports run every uploaded build on Firebase Test Lab, and their events arrived under the track's stage. Build 4804 alerted on Internal with a NoSuchMethodError from Play Billing and an NPE in streamRates, both from an x86_64 "Pixel 8 Pro" with 2 GB of memory. Devices with the firebase.test.lab setting now report the TestLab stage, which keeps the events searchable but outside the Internal and Production alerts. Events from an install missing its native libraries are tagged device.nativeLibsMissing rather than dropped. 4804 also crashed at startup on a Pixel 6 Pro that could not load libjnidispatch.so or Bugsnag's own NDK plugin, and a real user can end up with the same incomplete install. --- .../debug/FlipcashBugsnagErrorCallback.kt | 6 ++ .../app/internal/debug/NativeLibraries.kt | 43 ++++++++++++++ .../flipcash/app/internal/debug/TestLab.kt | 38 +++++++++++++ .../app/internal/startup/TraceInitializer.kt | 7 ++- .../app/internal/debug/NativeLibrariesTest.kt | 38 +++++++++++++ .../app/internal/debug/TestLabTest.kt | 57 +++++++++++++++++++ 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/NativeLibraries.kt create mode 100644 apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/TestLab.kt create mode 100644 apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/debug/NativeLibrariesTest.kt create mode 100644 apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/debug/TestLabTest.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/FlipcashBugsnagErrorCallback.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/FlipcashBugsnagErrorCallback.kt index 210471ec73..544f3d3ee0 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/FlipcashBugsnagErrorCallback.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/FlipcashBugsnagErrorCallback.kt @@ -25,6 +25,12 @@ internal val FlipcashErrorCallback = OnErrorCallback onError@{ event -> event.addMetadata("device", "apiLevelHonest", false) } + // An install missing its ABI split fails every native library load. Tagged for the same reason. + val missingLibraries = NativeLibraries.missing + if (missingLibraries.isNotEmpty()) { + event.addMetadata("device", "nativeLibsMissing", missingLibraries.joinToString()) + } + // Attach recent logs val logFile = TraceManager.getLogFile(includeHeader = false) if (logFile != null && logFile.exists()) { diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/NativeLibraries.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/NativeLibraries.kt new file mode 100644 index 0000000000..95f76dc29b --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/NativeLibraries.kt @@ -0,0 +1,43 @@ +package com.flipcash.app.internal.debug + +import dalvik.system.BaseDexClassLoader + +/** + * Whether the install carries the native libraries the APK was built with. + * + * An install missing its ABI split has none of them, so the first library to load one crashes: + * build 4804 died in `LibSodiumInitializer` with `UnsatisfiedLinkError` for `libjnidispatch.so` on + * a Pixel 6 Pro (`6aafa68958142669ea5db3c7`), and Bugsnag reported its own NDK and ANR plugins + * failing to link from the same device. Nothing in the app can fix the install, but a real user + * can end up with one, so the event is tagged rather than discarded. + */ +internal object NativeLibraries { + + /** + * One library from a dependency we always load at startup, and one from Bugsnag: if both are + * missing, the install is missing all of them. + */ + private val Probes = listOf("jnidispatch", "bugsnag-ndk") + + /** Probed once per process. */ + val missing: List by lazy { + val loader = NativeLibraries::class.java.classLoader as? BaseDexClassLoader + if (loader == null) emptyList() else missingLibraries(Probes, loader::findLibrary) + } +} + +/** + * The [names] that [findLibrary] cannot resolve. + * + * `findLibrary` resolves a library from the extracted lib directory or straight from the APK, and + * loads nothing, so probing has no side effects. It is injected so this stays a pure function + * under unit test. + */ +internal fun missingLibraries(names: List, findLibrary: (String) -> String?): List = + names.filter { name -> + try { + findLibrary(name) == null + } catch (_: Throwable) { + true + } + } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/TestLab.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/TestLab.kt new file mode 100644 index 0000000000..579cf46a60 --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/TestLab.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.internal.debug + +import android.content.Context +import android.provider.Settings +import com.flipcash.app.updates.ReleaseStage + +/** + * Whether this process runs on a Firebase Test Lab device, which is where Play's pre-launch + * reports run every build uploaded to a track. + * + * Those robots crawl the app on emulators and cloud phones, some of them broken in ways no user's + * device is: the internal build 4804 sent Bugsnag a `NoSuchMethodError` from Play Billing and a + * null `getClass()` in `OpenCodeExchange.streamRates` from an x86_64 "Pixel 8 Pro" with 2 GB of + * memory. Reported under the track's stage, they alert as if a tester had crashed. + */ +internal object TestLab { + + private const val SETTING = "firebase.test.lab" + + /** Test Lab sets this system setting to `"true"` on every device it runs. */ + fun isRunning(context: Context): Boolean = try { + Settings.System.getString(context.contentResolver, SETTING) == "true" + } catch (_: Throwable) { + false + } +} + +/** The stage Bugsnag files an event under when the device belongs to Test Lab. */ +internal const val TestLabStage = "TestLab" + +/** + * The release stage to report. A Test Lab device reports [TestLabStage] whatever track the build + * is on, so its events stay searchable but fall outside the Internal and Production alerts. + */ +internal fun bugsnagReleaseStage(stage: ReleaseStage?, onTestLab: Boolean): String = when { + onTestLab -> TestLabStage + else -> stage?.name ?: "Unknown" +} 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 8ba33809ca..10165c2cb1 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 @@ -7,6 +7,8 @@ import com.bugsnag.android.Configuration import com.flipcash.app.android.BuildConfig import com.flipcash.app.internal.debug.FlipcashDebugTree import com.flipcash.app.internal.debug.FlipcashErrorCallback +import com.flipcash.app.internal.debug.TestLab +import com.flipcash.app.internal.debug.bugsnagReleaseStage import com.flipcash.app.updates.ReleaseStage import com.flipcash.app.updates.ReleaseStageProvider import com.getcode.utils.ErrorUtils @@ -42,11 +44,12 @@ class TraceInitializer: Initializer { TraceManager.includeRpcBodies = true } + val onTestLab = TestLab.isRunning(context) val config = Configuration.load(context).apply { - releaseStage = initialStage?.name ?: "Unknown" + releaseStage = bugsnagReleaseStage(initialStage, onTestLab) addOnError(FlipcashErrorCallback) addOnSend { event -> - event.app.releaseStage = stageProvider.resolvedStage?.name ?: "Unknown" + event.app.releaseStage = bugsnagReleaseStage(stageProvider.resolvedStage, onTestLab) true } } diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/debug/NativeLibrariesTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/debug/NativeLibrariesTest.kt new file mode 100644 index 0000000000..b2bda57158 --- /dev/null +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/debug/NativeLibrariesTest.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.internal.debug + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NativeLibrariesTest { + + private val names = listOf("jnidispatch", "bugsnag-ndk") + + @Test + fun `an install with every library is missing none`() { + val missing = missingLibraries(names) { "/data/app/base.apk!/lib/arm64-v8a/lib$it.so" } + + assertTrue(missing.isEmpty()) + } + + @Test + fun `an install without its ABI split is missing all of them`() { + val missing = missingLibraries(names) { null } + + assertEquals(names, missing) + } + + @Test + fun `only the libraries that do not resolve are missing`() { + val missing = missingLibraries(names) { if (it == "jnidispatch") null else "/lib$it.so" } + + assertEquals(listOf("jnidispatch"), missing) + } + + @Test + fun `a lookup that throws counts as missing`() { + val missing = missingLibraries(names) { throw UnsatisfiedLinkError(it) } + + assertEquals(names, missing) + } +} diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/debug/TestLabTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/debug/TestLabTest.kt new file mode 100644 index 0000000000..212f954149 --- /dev/null +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/debug/TestLabTest.kt @@ -0,0 +1,57 @@ +package com.flipcash.app.internal.debug + +import android.provider.Settings +import com.flipcash.app.updates.ReleaseStage +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = android.app.Application::class) +class TestLabTest { + + private val context = RuntimeEnvironment.getApplication() + + @Test + fun `a Test Lab device reports the TestLab stage whatever the track`() { + ReleaseStage.entries.forEach { stage -> + assertEquals(TestLabStage, bugsnagReleaseStage(stage, onTestLab = true)) + } + assertEquals(TestLabStage, bugsnagReleaseStage(null, onTestLab = true)) + } + + @Test + fun `any other device reports its track`() { + assertEquals("Internal", bugsnagReleaseStage(ReleaseStage.Internal, onTestLab = false)) + assertEquals("Production", bugsnagReleaseStage(ReleaseStage.Production, onTestLab = false)) + } + + @Test + fun `an unresolved track on any other device is Unknown`() { + assertEquals("Unknown", bugsnagReleaseStage(null, onTestLab = false)) + } + + @Test + fun `the Test Lab setting marks the device`() { + Settings.System.putString(context.contentResolver, "firebase.test.lab", "true") + + assertTrue(TestLab.isRunning(context)) + } + + @Test + fun `a device without the setting is not Test Lab`() { + assertFalse(TestLab.isRunning(context)) + } + + @Test + fun `a setting other than true is not Test Lab`() { + Settings.System.putString(context.contentResolver, "firebase.test.lab", "false") + + assertFalse(TestLab.isRunning(context)) + } +}