From 444d2923a90c88adecb758603d68c352cc61e6e3 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 13:21:51 +0200 Subject: [PATCH 1/3] fix(crashlytics,android): read the ELF build ID off the main thread, once per process onAttachedToEngine read libapp.so's build ID on the main thread for every engine. With minSdk >= 24 the native libraries are not extracted, so that opened base.apk and its splits as zip files during startup, which is jank and a StrictMode disk-read violation, repeated for each background engine. Read it on first use instead, in recordError, which already runs on the cached thread pool, and share the result across engines. --- .../firebase/crashlytics/ElfBuildIdReader.kt | 34 ++++++ .../FlutterFirebaseCrashlyticsPlugin.kt | 12 +-- .../crashlytics/ElfBuildIdReaderTest.java | 4 +- .../FlutterFirebaseCrashlyticsPluginTest.java | 101 ++++++++++++++++++ 4 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.java 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..c17cb6f3a197 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,39 @@ 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 on the first call only: libapp.so does not change while the + * process runs, so every engine shares the result. + * + * 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) { + cachedBuildIdValue = readBuildId(context) + isCachedBuildIdRead = true + } + } + } + return cachedBuildIdValue + } + + @JvmStatic + @VisibleForTesting + fun clearCachedBuildId() { + synchronized(cachedBuildIdLock) { + cachedBuildIdValue = null + isCachedBuildIdRead = false + } + } + /** * Reads the ELF build ID from libapp.so. * 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..4984f6fd523b 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 @@ -36,12 +36,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 @@ -52,7 +46,6 @@ class FlutterFirebaseCrashlyticsPlugin : override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { applicationContext = binding.applicationContext - elfBuildId = ElfBuildIdReader.readBuildId(applicationContext) initInstance(binding.binaryMessenger) } @@ -114,8 +107,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) } 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/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.java new file mode 100644 index 000000000000..573c09e649d4 --- /dev/null +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.java @@ -0,0 +1,101 @@ +// 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +import android.content.ContextWrapper; +import android.content.pm.ApplicationInfo; +import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding; +import io.flutter.plugin.common.BinaryMessenger; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class FlutterFirebaseCrashlyticsPluginTest { + @Rule public final TemporaryFolder nativeLibraryDir = new TemporaryFolder(); + + /** + * Records the threads that look up the application info, which every libapp.so read starts with. + */ + private static final class RecordingContext extends ContextWrapper { + final List applicationInfoThreads = new CopyOnWriteArrayList<>(); + private final ApplicationInfo applicationInfo = new ApplicationInfo(); + + RecordingContext(File nativeLibraryDir) { + super(null); + applicationInfo.nativeLibraryDir = nativeLibraryDir.getPath(); + } + + @Override + public ApplicationInfo getApplicationInfo() { + applicationInfoThreads.add(Thread.currentThread()); + return applicationInfo; + } + } + + private static final BinaryMessenger MESSENGER = + new BinaryMessenger() { + @Override + public void send(String channel, ByteBuffer message) {} + + @Override + public void send(String channel, ByteBuffer message, BinaryReply callback) {} + + @Override + public void setMessageHandler(String channel, BinaryMessageHandler handler) {} + }; + + @Before + public void setUp() throws IOException { + ElfBuildIdReader.clearCachedBuildId(); + try (FileOutputStream libapp = + new FileOutputStream(new File(nativeLibraryDir.getRoot(), "libapp.so"))) { + libapp.write(ElfBuildIdReaderTest.elfWithBuildId((byte) 0x42)); + } + } + + @After + public void tearDown() { + ElfBuildIdReader.clearCachedBuildId(); + } + + @Test + public void onAttachedToEngine_doesNotReadLibappOnTheMainThread() { + // Every engine attaches its plugins on the main thread, which this test thread stands in for. + for (int engine = 0; engine < 2; engine++) { + RecordingContext context = new RecordingContext(nativeLibraryDir.getRoot()); + + new FlutterFirebaseCrashlyticsPlugin() + .onAttachedToEngine( + new FlutterPluginBinding(context, null, MESSENGER, null, null, null, null)); + + assertFalse(context.applicationInfoThreads.contains(Thread.currentThread())); + } + } + + @Test + public void cachedBuildId_readsLibappOncePerProcess() { + RecordingContext firstEngine = new RecordingContext(nativeLibraryDir.getRoot()); + RecordingContext secondEngine = new RecordingContext(nativeLibraryDir.getRoot()); + + String expected = ElfBuildIdReaderTest.hexWithFirstByte((byte) 0x42); + assertEquals(expected, ElfBuildIdReader.cachedBuildId(firstEngine)); + assertEquals(expected, ElfBuildIdReader.cachedBuildId(firstEngine)); + assertEquals(expected, ElfBuildIdReader.cachedBuildId(secondEngine)); + + assertEquals( + 1, firstEngine.applicationInfoThreads.size() + secondEngine.applicationInfoThreads.size()); + } +} From 624c0d857b23fe66358e405422b0e12de1e87064 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 15:15:18 +0200 Subject: [PATCH 2/3] fix(crashlytics,android): read the build ID at startup in the background Reading it on the first `recordError` meant the read could happen while the app is short of memory or file descriptors, often the reason it reports an error, and a failed read was cached for the whole process. It is now read once when the plugin attaches, on the thread pool, so the first report doesn't wait for it either. --- .../firebase/crashlytics/ElfBuildIdReader.kt | 1 + .../FlutterFirebaseCrashlyticsPlugin.kt | 12 ++++++++- .../FlutterFirebaseCrashlyticsPluginTest.java | 26 ++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) 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 c17cb6f3a197..296a46db38ea 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 @@ -128,6 +128,7 @@ 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") 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 4984f6fd523b..7339feacf1de 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 : @@ -45,8 +47,13 @@ class FlutterFirebaseCrashlyticsPlugin : } override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - applicationContext = binding.applicationContext + val context = binding.applicationContext + applicationContext = context initInstance(binding.binaryMessenger) + // Read once at startup, off the main thread: the first report then 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, and would leave every report without the ID. + buildIdExecutor.execute { ElfBuildIdReader.cachedBuildId(context) } } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -282,6 +289,9 @@ class FlutterFirebaseCrashlyticsPlugin : private const val FIREBASE_CRASHLYTICS_COLLECTION_ENABLED = "firebase_crashlytics_collection_enabled" + /** Where the ELF build ID is read at startup. */ + @VisibleForTesting @JvmStatic 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/FlutterFirebaseCrashlyticsPluginTest.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.java index 573c09e649d4..c89f86518c61 100644 --- a/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.java +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.java @@ -17,6 +17,7 @@ import java.nio.ByteBuffer; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; import org.junit.After; import org.junit.Before; import org.junit.Rule; @@ -57,9 +58,24 @@ public void send(String channel, ByteBuffer message, BinaryReply callback) {} public void setMessageHandler(String channel, BinaryMessageHandler handler) {} }; + private Executor defaultBuildIdExecutor; + @Before public void setUp() throws IOException { ElfBuildIdReader.clearCachedBuildId(); + // Runs the startup read on another thread, like the thread pool does, and waits for it so no + // read outlives the test. + defaultBuildIdExecutor = FlutterFirebaseCrashlyticsPlugin.getBuildIdExecutor(); + FlutterFirebaseCrashlyticsPlugin.setBuildIdExecutor( + runnable -> { + Thread thread = new Thread(runnable); + thread.start(); + try { + thread.join(); + } catch (InterruptedException e) { + throw new AssertionError(e); + } + }); try (FileOutputStream libapp = new FileOutputStream(new File(nativeLibraryDir.getRoot(), "libapp.so"))) { libapp.write(ElfBuildIdReaderTest.elfWithBuildId((byte) 0x42)); @@ -68,12 +84,14 @@ public void setUp() throws IOException { @After public void tearDown() { + FlutterFirebaseCrashlyticsPlugin.setBuildIdExecutor(defaultBuildIdExecutor); ElfBuildIdReader.clearCachedBuildId(); } @Test - public void onAttachedToEngine_doesNotReadLibappOnTheMainThread() { + public void onAttachedToEngine_readsLibappOnceInTheBackground() { // Every engine attaches its plugins on the main thread, which this test thread stands in for. + int reads = 0; for (int engine = 0; engine < 2; engine++) { RecordingContext context = new RecordingContext(nativeLibraryDir.getRoot()); @@ -82,7 +100,13 @@ public void onAttachedToEngine_doesNotReadLibappOnTheMainThread() { new FlutterPluginBinding(context, null, MESSENGER, null, null, null, null)); 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((byte) 0x42), + ElfBuildIdReader.cachedBuildId(new RecordingContext(nativeLibraryDir.getRoot()))); } @Test From 60f558ecc47289aa291704919ec53600dc0d6597 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 22 Oct 2026 12:49:09 +0200 Subject: [PATCH 3/3] fix(crashlytics,android): retry a failed build ID read, keep the test seam internal - A read of libapp.so that fails (memory or file descriptor pressure) is no longer cached, so a later report retries it. A library without a build ID (debug builds) is still read only once. - `buildIdExecutor` is internal instead of a public `setBuildIdExecutor`, and the plugin test moves to Kotlin to use it. --- .../firebase/crashlytics/ElfBuildIdReader.kt | 34 ++-- .../FlutterFirebaseCrashlyticsPlugin.kt | 16 +- .../FlutterFirebaseCrashlyticsPluginTest.java | 125 --------------- .../FlutterFirebaseCrashlyticsPluginTest.kt | 150 ++++++++++++++++++ 4 files changed, 184 insertions(+), 141 deletions(-) delete mode 100644 packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.java create mode 100644 packages/firebase_crashlytics/firebase_crashlytics/android/src/test/kotlin/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.kt 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 296a46db38ea..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 @@ -91,8 +91,9 @@ internal class ElfBuildIdReader private constructor() { private var cachedBuildIdValue: String? = null /** - * Returns [readBuildId], reading it on the first call only: libapp.so does not change while the - * process runs, so every engine shares the result. + * 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. */ @@ -102,8 +103,16 @@ internal class ElfBuildIdReader private constructor() { if (!isCachedBuildIdRead) { synchronized(cachedBuildIdLock) { if (!isCachedBuildIdRead) { - cachedBuildIdValue = readBuildId(context) - isCachedBuildIdRead = true + 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 + } } } } @@ -131,12 +140,7 @@ internal class ElfBuildIdReader private constructor() { @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 @@ -146,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 7339feacf1de..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 @@ -47,12 +47,16 @@ class FlutterFirebaseCrashlyticsPlugin : } override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - val context = binding.applicationContext + attachToEngine(binding.applicationContext, binding.binaryMessenger) + } + + @VisibleForTesting + internal fun attachToEngine(context: Context, messenger: BinaryMessenger) { applicationContext = context - initInstance(binding.binaryMessenger) - // Read once at startup, off the main thread: the first report then 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, and would leave every report without the ID. + 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) } } @@ -290,7 +294,7 @@ class FlutterFirebaseCrashlyticsPlugin : "firebase_crashlytics_collection_enabled" /** Where the ELF build ID is read at startup. */ - @VisibleForTesting @JvmStatic var buildIdExecutor: Executor = cachedThreadPool + @VisibleForTesting internal var buildIdExecutor: Executor = cachedThreadPool private fun readCrashlyticsDataCollectionEnabledFromManifest( applicationContext: Context diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.java deleted file mode 100644 index c89f86518c61..000000000000 --- a/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPluginTest.java +++ /dev/null @@ -1,125 +0,0 @@ -// 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 static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; - -import android.content.ContextWrapper; -import android.content.pm.ApplicationInfo; -import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding; -import io.flutter.plugin.common.BinaryMessenger; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.Executor; -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -public class FlutterFirebaseCrashlyticsPluginTest { - @Rule public final TemporaryFolder nativeLibraryDir = new TemporaryFolder(); - - /** - * Records the threads that look up the application info, which every libapp.so read starts with. - */ - private static final class RecordingContext extends ContextWrapper { - final List applicationInfoThreads = new CopyOnWriteArrayList<>(); - private final ApplicationInfo applicationInfo = new ApplicationInfo(); - - RecordingContext(File nativeLibraryDir) { - super(null); - applicationInfo.nativeLibraryDir = nativeLibraryDir.getPath(); - } - - @Override - public ApplicationInfo getApplicationInfo() { - applicationInfoThreads.add(Thread.currentThread()); - return applicationInfo; - } - } - - private static final BinaryMessenger MESSENGER = - new BinaryMessenger() { - @Override - public void send(String channel, ByteBuffer message) {} - - @Override - public void send(String channel, ByteBuffer message, BinaryReply callback) {} - - @Override - public void setMessageHandler(String channel, BinaryMessageHandler handler) {} - }; - - private Executor defaultBuildIdExecutor; - - @Before - public void setUp() throws IOException { - ElfBuildIdReader.clearCachedBuildId(); - // Runs the startup read on another thread, like the thread pool does, and waits for it so no - // read outlives the test. - defaultBuildIdExecutor = FlutterFirebaseCrashlyticsPlugin.getBuildIdExecutor(); - FlutterFirebaseCrashlyticsPlugin.setBuildIdExecutor( - runnable -> { - Thread thread = new Thread(runnable); - thread.start(); - try { - thread.join(); - } catch (InterruptedException e) { - throw new AssertionError(e); - } - }); - try (FileOutputStream libapp = - new FileOutputStream(new File(nativeLibraryDir.getRoot(), "libapp.so"))) { - libapp.write(ElfBuildIdReaderTest.elfWithBuildId((byte) 0x42)); - } - } - - @After - public void tearDown() { - FlutterFirebaseCrashlyticsPlugin.setBuildIdExecutor(defaultBuildIdExecutor); - ElfBuildIdReader.clearCachedBuildId(); - } - - @Test - public void onAttachedToEngine_readsLibappOnceInTheBackground() { - // Every engine attaches its plugins on the main thread, which this test thread stands in for. - int reads = 0; - for (int engine = 0; engine < 2; engine++) { - RecordingContext context = new RecordingContext(nativeLibraryDir.getRoot()); - - new FlutterFirebaseCrashlyticsPlugin() - .onAttachedToEngine( - new FlutterPluginBinding(context, null, MESSENGER, null, null, null, null)); - - 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((byte) 0x42), - ElfBuildIdReader.cachedBuildId(new RecordingContext(nativeLibraryDir.getRoot()))); - } - - @Test - public void cachedBuildId_readsLibappOncePerProcess() { - RecordingContext firstEngine = new RecordingContext(nativeLibraryDir.getRoot()); - RecordingContext secondEngine = new RecordingContext(nativeLibraryDir.getRoot()); - - String expected = ElfBuildIdReaderTest.hexWithFirstByte((byte) 0x42); - assertEquals(expected, ElfBuildIdReader.cachedBuildId(firstEngine)); - assertEquals(expected, ElfBuildIdReader.cachedBuildId(firstEngine)); - assertEquals(expected, ElfBuildIdReader.cachedBuildId(secondEngine)); - - assertEquals( - 1, firstEngine.applicationInfoThreads.size() + secondEngine.applicationInfoThreads.size()); - } -} 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) + } +}