diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/kotlin/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.kt b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/kotlin/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.kt index c808e84feb80..99e6387a2e1b 100644 --- a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/kotlin/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.kt +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/kotlin/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.kt @@ -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 @@ -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. * @@ -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 @@ -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 diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/kotlin/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.kt b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/kotlin/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.kt index 256c6645dbbb..d8acdd108de9 100644 --- a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/kotlin/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.kt +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/kotlin/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.kt @@ -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 @@ -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 : @@ -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 @@ -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) { @@ -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) } @@ -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 { diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReaderTest.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReaderTest.java index dbc84251e83c..7c001b02f9ed 100644 --- a/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReaderTest.java +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReaderTest.java @@ -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]; @@ -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); } diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/kotlin/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.kt b/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/kotlin/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.kt new file mode 100644 index 000000000000..783c8cc83919 --- /dev/null +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/kotlin/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.kt @@ -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 = 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) + } +}