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 @@ -9,6 +9,7 @@ import android.os.Build
import android.os.Process
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import java.io.File
import java.io.FileInputStream
import java.io.IOException
Expand Down Expand Up @@ -85,6 +86,48 @@ internal class ElfBuildIdReader private constructor() {
private const val ELF64_PHDR_MIN_BYTES = 56
private const val NOTE_HEADER_BYTES = 12

private val cachedBuildIdLock = Any()
@Volatile private var isCachedBuildIdRead = false
private var cachedBuildIdValue: String? = null

/**
* Returns [readBuildId], reading it once: libapp.so does not change while the process runs, so
* every engine shares the result. A read that fails is not kept, so a later call retries: a
* transient failure (memory or file descriptors) must not leave every report without the ID.
*
* Without extracted native libraries this opens the APK, so call it off the main thread.
*/
@JvmStatic
@WorkerThread
fun cachedBuildId(context: Context): String? {
if (!isCachedBuildIdRead) {
synchronized(cachedBuildIdLock) {
if (!isCachedBuildIdRead) {
try {
cachedBuildIdValue = readBuildIdOrThrow(context)
isCachedBuildIdRead = true
} catch (exception: Exception) {
Log.d(TAG, "Could not read ELF build ID from libapp.so", exception)
return null
} catch (error: OutOfMemoryError) {
Log.d(TAG, "Could not read ELF build ID from libapp.so", error)
return null
}
}
}
}
return cachedBuildIdValue
}

@JvmStatic
@VisibleForTesting
fun clearCachedBuildId() {
synchronized(cachedBuildIdLock) {
cachedBuildIdValue = null
isCachedBuildIdRead = false
}
}

/**
* Reads the ELF build ID from libapp.so.
*
Expand All @@ -94,14 +137,10 @@ internal class ElfBuildIdReader private constructor() {
* @return the build ID as a lowercase hex string, or `null` if it cannot be read.
*/
@JvmStatic
@WorkerThread
fun readBuildId(context: Context): String? {
return try {
val libApp = File(context.applicationInfo.nativeLibraryDir, "libapp.so")
if (libApp.exists()) {
readBuildIdFromFile(libApp)
} else {
readBuildIdFromApk(context)
}
readBuildIdOrThrow(context)
} catch (exception: Exception) {
Log.d(TAG, "Could not read ELF build ID from libapp.so", exception)
null
Expand All @@ -111,6 +150,16 @@ internal class ElfBuildIdReader private constructor() {
}
}

/** [readBuildId] that throws when libapp.so cannot be read, instead of returning `null`. */
private fun readBuildIdOrThrow(context: Context): String? {
val libApp = File(context.applicationInfo.nativeLibraryDir, "libapp.so")
return if (libApp.exists()) {
readBuildIdFromFile(libApp)
} else {
readBuildIdFromApk(context)
}
}

private fun readBuildIdFromApk(context: Context): String? {
readBuildIdFromZip(context.applicationInfo.sourceDir)?.let {
return it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.TaskCompletionSource
import com.google.android.gms.tasks.Tasks
Expand All @@ -27,6 +28,7 @@ import io.flutter.plugins.firebase.crashlytics.generated.CrashlyticsStackFrame
import io.flutter.plugins.firebase.crashlytics.generated.FirebaseCrashlyticsHostApi
import io.flutter.plugins.firebase.crashlytics.generated.FlutterError as PigeonFlutterError
import io.flutter.plugins.firebase.crashlytics.generated.RecordErrorRequest
import java.util.concurrent.Executor

/** FlutterFirebaseCrashlyticsPlugin */
class FlutterFirebaseCrashlyticsPlugin :
Expand All @@ -36,12 +38,6 @@ class FlutterFirebaseCrashlyticsPlugin :
private var testEventSink: EventChannel.EventSink? = null
private lateinit var applicationContext: Context

// Cached ELF build ID read from libapp.so at startup. This is the build ID that the
// firebase-crashlytics-buildtools JAR extracts from .symbols files during upload, so using
// it ensures crash reports match uploaded symbols (even when the Dart VM's internal snapshot
// build ID differs, which happens with AAB + flavor + obfuscation builds).
private var elfBuildId: String? = null

private fun initInstance(messenger: BinaryMessenger) {
FlutterFirebasePluginRegistry.registerPlugin(CHANNEL_NAME, this)
binaryMessenger = messenger
Expand All @@ -51,9 +47,17 @@ class FlutterFirebaseCrashlyticsPlugin :
}

override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = binding.applicationContext
elfBuildId = ElfBuildIdReader.readBuildId(applicationContext)
initInstance(binding.binaryMessenger)
attachToEngine(binding.applicationContext, binding.binaryMessenger)
}

@VisibleForTesting
internal fun attachToEngine(context: Context, messenger: BinaryMessenger) {
applicationContext = context
initInstance(messenger)
// Read once at startup, off the main thread, so the first report doesn't have to, and the read
// doesn't happen while the app is short of memory or file descriptors, which is often why it
// reports an error.
buildIdExecutor.execute { ElfBuildIdReader.cachedBuildId(context) }
}

override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
Expand Down Expand Up @@ -114,8 +118,9 @@ class FlutterFirebaseCrashlyticsPlugin :

// Prefer the ELF build ID from libapp.so over the Dart VM's snapshot build ID.
// The firebase-crashlytics-buildtools JAR uses the ELF build ID when uploading symbols,
// so we must report the same ID for Crashlytics to match them.
val effectiveBuildId = elfBuildId ?: dartBuildId
// so we must report the same ID for Crashlytics to match them (even when the snapshot
// build ID differs, which happens with AAB + flavor + obfuscation builds).
val effectiveBuildId = ElfBuildIdReader.cachedBuildId(applicationContext) ?: dartBuildId
if (effectiveBuildId.isNotEmpty()) {
FlutterFirebaseCrashlyticsInternal.setFlutterBuildId(effectiveBuildId)
}
Expand Down Expand Up @@ -288,6 +293,9 @@ class FlutterFirebaseCrashlyticsPlugin :
private const val FIREBASE_CRASHLYTICS_COLLECTION_ENABLED =
"firebase_crashlytics_collection_enabled"

/** Where the ELF build ID is read at startup. */
@VisibleForTesting internal var buildIdExecutor: Executor = cachedThreadPool

private fun readCrashlyticsDataCollectionEnabledFromManifest(
applicationContext: Context
): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ private static ElfBuildIdReader.StreamSource sourceOf(byte[] image) {
return () -> new ByteArrayInputStream(image);
}

private static byte[] elfWithBuildId(byte first) {
static byte[] elfWithBuildId(byte first) {
byte[] buildId = BUILD_ID.clone();
buildId[0] = first;
byte[] image = new byte[512];
Expand All @@ -411,7 +411,7 @@ private static byte[] elfWithBuildId(byte first) {
return image;
}

private static String hexWithFirstByte(byte first) {
static String hexWithFirstByte(byte first) {
return String.format("%02x", first & 0xff) + BUILD_ID_HEX.substring(2);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright 2026 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package io.flutter.plugins.firebase.crashlytics

import android.content.ContextWrapper
import android.content.pm.ApplicationInfo
import io.flutter.plugin.common.BinaryMessenger
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executor
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder

class FlutterFirebaseCrashlyticsPluginTest {
@get:Rule val folder = TemporaryFolder()

/**
* Records the threads that look up the application info, which every libapp.so read starts with,
* and fails the first [failures] lookups like a process short of memory or file descriptors.
*/
private class RecordingContext(nativeLibraryDir: File, private var failures: Int = 0) :
ContextWrapper(null) {
val applicationInfoThreads: MutableList<Thread> = CopyOnWriteArrayList()
private val info = ApplicationInfo().apply { this.nativeLibraryDir = nativeLibraryDir.path }

override fun getApplicationInfo(): ApplicationInfo {
applicationInfoThreads.add(Thread.currentThread())
if (failures > 0) {
failures--
throw IllegalStateException("Too many open files")
}
return info
}

fun withApk(apk: File): RecordingContext = apply { info.sourceDir = apk.path }
}

private object NoOpMessenger : BinaryMessenger {
override fun send(channel: String, message: ByteBuffer?) {}

override fun send(
channel: String,
message: ByteBuffer?,
callback: BinaryMessenger.BinaryReply?
) {}

override fun setMessageHandler(
channel: String,
handler: BinaryMessenger.BinaryMessageHandler?
) {}
}

private lateinit var nativeLibraryDir: File
private lateinit var defaultBuildIdExecutor: Executor

@Before
fun setUp() {
ElfBuildIdReader.clearCachedBuildId()
nativeLibraryDir = folder.newFolder("lib")
FileOutputStream(File(nativeLibraryDir, "libapp.so")).use {
it.write(ElfBuildIdReaderTest.elfWithBuildId(0x42.toByte()))
}
// Runs the startup read on another thread, like the thread pool does, and waits for it so no
// read outlives the test.
defaultBuildIdExecutor = FlutterFirebaseCrashlyticsPlugin.buildIdExecutor
FlutterFirebaseCrashlyticsPlugin.buildIdExecutor = Executor { runnable ->
Thread(runnable).apply {
start()
join()
}
}
}

@After
fun tearDown() {
FlutterFirebaseCrashlyticsPlugin.buildIdExecutor = defaultBuildIdExecutor
ElfBuildIdReader.clearCachedBuildId()
}

@Test
fun onAttachedToEngine_readsLibappOnceInTheBackground() {
// Every engine attaches its plugins on the main thread, which this test thread stands in for.
var reads = 0
repeat(2) {
val context = RecordingContext(nativeLibraryDir)

FlutterFirebaseCrashlyticsPlugin().attachToEngine(context, NoOpMessenger)

assertFalse(context.applicationInfoThreads.contains(Thread.currentThread()))
reads += context.applicationInfoThreads.size
}
// Read at the first attach, so a first error report doesn't have to.
assertEquals(1, reads)
assertEquals(
ElfBuildIdReaderTest.hexWithFirstByte(0x42.toByte()),
ElfBuildIdReader.cachedBuildId(RecordingContext(nativeLibraryDir)))
}

@Test
fun cachedBuildId_readsLibappOncePerProcess() {
val firstEngine = RecordingContext(nativeLibraryDir)
val secondEngine = RecordingContext(nativeLibraryDir)

val expected = ElfBuildIdReaderTest.hexWithFirstByte(0x42.toByte())
assertEquals(expected, ElfBuildIdReader.cachedBuildId(firstEngine))
assertEquals(expected, ElfBuildIdReader.cachedBuildId(firstEngine))
assertEquals(expected, ElfBuildIdReader.cachedBuildId(secondEngine))

assertEquals(
1, firstEngine.applicationInfoThreads.size + secondEngine.applicationInfoThreads.size)
}

@Test
fun cachedBuildId_readsAgainAfterAFailedRead() {
val context = RecordingContext(nativeLibraryDir, failures = 1)

assertNull(ElfBuildIdReader.cachedBuildId(context))
assertEquals(
ElfBuildIdReaderTest.hexWithFirstByte(0x42.toByte()),
ElfBuildIdReader.cachedBuildId(context))
}

@Test
fun cachedBuildId_keepsALibraryWithoutBuildId() {
// Debug (JIT) builds have no libapp.so: that answer does not change, so it is kept.
val apk = folder.newFile("base.apk")
ZipOutputStream(FileOutputStream(apk)).use {
it.putNextEntry(ZipEntry("AndroidManifest.xml"))
it.closeEntry()
}
val context = RecordingContext(folder.newFolder("empty")).withApk(apk)

assertNull(ElfBuildIdReader.cachedBuildId(context))
val lookups = context.applicationInfoThreads.size
assertNull(ElfBuildIdReader.cachedBuildId(context))

assertEquals(lookups, context.applicationInfoThreads.size)
}
}
Loading