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)) + } +}