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
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String>, findLibrary: (String) -> String?): List<String> =
names.filter { name ->
try {
findLibrary(name) == null
} catch (_: Throwable) {
true
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,11 +44,12 @@ class TraceInitializer: Initializer<Unit> {
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
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading