Skip to content

Commit eda1bcc

Browse files
authored
fix(bugsnag): file Test Lab devices under their own stage (#1759)
1 parent ed54a32 commit eda1bcc

6 files changed

Lines changed: 187 additions & 2 deletions

File tree

‎apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/debug/FlipcashBugsnagErrorCallback.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ internal val FlipcashErrorCallback = OnErrorCallback onError@{ event ->
2525
event.addMetadata("device", "apiLevelHonest", false)
2626
}
2727

28+
// An install missing its ABI split fails every native library load. Tagged for the same reason.
29+
val missingLibraries = NativeLibraries.missing
30+
if (missingLibraries.isNotEmpty()) {
31+
event.addMetadata("device", "nativeLibsMissing", missingLibraries.joinToString())
32+
}
33+
2834
// Attach recent logs
2935
val logFile = TraceManager.getLogFile(includeHeader = false)
3036
if (logFile != null && logFile.exists()) {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.flipcash.app.internal.debug
2+
3+
import dalvik.system.BaseDexClassLoader
4+
5+
/**
6+
* Whether the install carries the native libraries the APK was built with.
7+
*
8+
* An install missing its ABI split has none of them, so the first library to load one crashes:
9+
* build 4804 died in `LibSodiumInitializer` with `UnsatisfiedLinkError` for `libjnidispatch.so` on
10+
* a Pixel 6 Pro (`6aafa68958142669ea5db3c7`), and Bugsnag reported its own NDK and ANR plugins
11+
* failing to link from the same device. Nothing in the app can fix the install, but a real user
12+
* can end up with one, so the event is tagged rather than discarded.
13+
*/
14+
internal object NativeLibraries {
15+
16+
/**
17+
* One library from a dependency we always load at startup, and one from Bugsnag: if both are
18+
* missing, the install is missing all of them.
19+
*/
20+
private val Probes = listOf("jnidispatch", "bugsnag-ndk")
21+
22+
/** Probed once per process. */
23+
val missing: List<String> by lazy {
24+
val loader = NativeLibraries::class.java.classLoader as? BaseDexClassLoader
25+
if (loader == null) emptyList() else missingLibraries(Probes, loader::findLibrary)
26+
}
27+
}
28+
29+
/**
30+
* The [names] that [findLibrary] cannot resolve.
31+
*
32+
* `findLibrary` resolves a library from the extracted lib directory or straight from the APK, and
33+
* loads nothing, so probing has no side effects. It is injected so this stays a pure function
34+
* under unit test.
35+
*/
36+
internal fun missingLibraries(names: List<String>, findLibrary: (String) -> String?): List<String> =
37+
names.filter { name ->
38+
try {
39+
findLibrary(name) == null
40+
} catch (_: Throwable) {
41+
true
42+
}
43+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.flipcash.app.internal.debug
2+
3+
import android.content.Context
4+
import android.provider.Settings
5+
import com.flipcash.app.updates.ReleaseStage
6+
7+
/**
8+
* Whether this process runs on a Firebase Test Lab device, which is where Play's pre-launch
9+
* reports run every build uploaded to a track.
10+
*
11+
* Those robots crawl the app on emulators and cloud phones, some of them broken in ways no user's
12+
* device is: the internal build 4804 sent Bugsnag a `NoSuchMethodError` from Play Billing and a
13+
* null `getClass()` in `OpenCodeExchange.streamRates` from an x86_64 "Pixel 8 Pro" with 2 GB of
14+
* memory. Reported under the track's stage, they alert as if a tester had crashed.
15+
*/
16+
internal object TestLab {
17+
18+
private const val SETTING = "firebase.test.lab"
19+
20+
/** Test Lab sets this system setting to `"true"` on every device it runs. */
21+
fun isRunning(context: Context): Boolean = try {
22+
Settings.System.getString(context.contentResolver, SETTING) == "true"
23+
} catch (_: Throwable) {
24+
false
25+
}
26+
}
27+
28+
/** The stage Bugsnag files an event under when the device belongs to Test Lab. */
29+
internal const val TestLabStage = "TestLab"
30+
31+
/**
32+
* The release stage to report. A Test Lab device reports [TestLabStage] whatever track the build
33+
* is on, so its events stay searchable but fall outside the Internal and Production alerts.
34+
*/
35+
internal fun bugsnagReleaseStage(stage: ReleaseStage?, onTestLab: Boolean): String = when {
36+
onTestLab -> TestLabStage
37+
else -> stage?.name ?: "Unknown"
38+
}

‎apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/startup/TraceInitializer.kt‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import com.bugsnag.android.Configuration
77
import com.flipcash.app.android.BuildConfig
88
import com.flipcash.app.internal.debug.FlipcashDebugTree
99
import com.flipcash.app.internal.debug.FlipcashErrorCallback
10+
import com.flipcash.app.internal.debug.TestLab
11+
import com.flipcash.app.internal.debug.bugsnagReleaseStage
1012
import com.flipcash.app.updates.ReleaseStage
1113
import com.flipcash.app.updates.ReleaseStageProvider
1214
import com.getcode.utils.ErrorUtils
@@ -42,11 +44,12 @@ class TraceInitializer: Initializer<Unit> {
4244
TraceManager.includeRpcBodies = true
4345
}
4446

47+
val onTestLab = TestLab.isRunning(context)
4548
val config = Configuration.load(context).apply {
46-
releaseStage = initialStage?.name ?: "Unknown"
49+
releaseStage = bugsnagReleaseStage(initialStage, onTestLab)
4750
addOnError(FlipcashErrorCallback)
4851
addOnSend { event ->
49-
event.app.releaseStage = stageProvider.resolvedStage?.name ?: "Unknown"
52+
event.app.releaseStage = bugsnagReleaseStage(stageProvider.resolvedStage, onTestLab)
5053
true
5154
}
5255
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.flipcash.app.internal.debug
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertTrue
6+
7+
class NativeLibrariesTest {
8+
9+
private val names = listOf("jnidispatch", "bugsnag-ndk")
10+
11+
@Test
12+
fun `an install with every library is missing none`() {
13+
val missing = missingLibraries(names) { "/data/app/base.apk!/lib/arm64-v8a/lib$it.so" }
14+
15+
assertTrue(missing.isEmpty())
16+
}
17+
18+
@Test
19+
fun `an install without its ABI split is missing all of them`() {
20+
val missing = missingLibraries(names) { null }
21+
22+
assertEquals(names, missing)
23+
}
24+
25+
@Test
26+
fun `only the libraries that do not resolve are missing`() {
27+
val missing = missingLibraries(names) { if (it == "jnidispatch") null else "/lib$it.so" }
28+
29+
assertEquals(listOf("jnidispatch"), missing)
30+
}
31+
32+
@Test
33+
fun `a lookup that throws counts as missing`() {
34+
val missing = missingLibraries(names) { throw UnsatisfiedLinkError(it) }
35+
36+
assertEquals(names, missing)
37+
}
38+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.flipcash.app.internal.debug
2+
3+
import android.provider.Settings
4+
import com.flipcash.app.updates.ReleaseStage
5+
import org.junit.runner.RunWith
6+
import org.robolectric.RobolectricTestRunner
7+
import org.robolectric.RuntimeEnvironment
8+
import org.robolectric.annotation.Config
9+
import kotlin.test.Test
10+
import kotlin.test.assertEquals
11+
import kotlin.test.assertFalse
12+
import kotlin.test.assertTrue
13+
14+
@RunWith(RobolectricTestRunner::class)
15+
@Config(manifest = Config.NONE, application = android.app.Application::class)
16+
class TestLabTest {
17+
18+
private val context = RuntimeEnvironment.getApplication()
19+
20+
@Test
21+
fun `a Test Lab device reports the TestLab stage whatever the track`() {
22+
ReleaseStage.entries.forEach { stage ->
23+
assertEquals(TestLabStage, bugsnagReleaseStage(stage, onTestLab = true))
24+
}
25+
assertEquals(TestLabStage, bugsnagReleaseStage(null, onTestLab = true))
26+
}
27+
28+
@Test
29+
fun `any other device reports its track`() {
30+
assertEquals("Internal", bugsnagReleaseStage(ReleaseStage.Internal, onTestLab = false))
31+
assertEquals("Production", bugsnagReleaseStage(ReleaseStage.Production, onTestLab = false))
32+
}
33+
34+
@Test
35+
fun `an unresolved track on any other device is Unknown`() {
36+
assertEquals("Unknown", bugsnagReleaseStage(null, onTestLab = false))
37+
}
38+
39+
@Test
40+
fun `the Test Lab setting marks the device`() {
41+
Settings.System.putString(context.contentResolver, "firebase.test.lab", "true")
42+
43+
assertTrue(TestLab.isRunning(context))
44+
}
45+
46+
@Test
47+
fun `a device without the setting is not Test Lab`() {
48+
assertFalse(TestLab.isRunning(context))
49+
}
50+
51+
@Test
52+
fun `a setting other than true is not Test Lab`() {
53+
Settings.System.putString(context.contentResolver, "firebase.test.lab", "false")
54+
55+
assertFalse(TestLab.isRunning(context))
56+
}
57+
}

0 commit comments

Comments
 (0)