diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java new file mode 100644 index 000000000000..0e6b9561168a --- /dev/null +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java @@ -0,0 +1,398 @@ +// Copyright 2024 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.Context; +import android.util.Log; +import java.io.File; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Enumeration; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * Reads the ELF build ID from libapp.so at runtime. + * + *
The Firebase CLI's {@code crashlytics:symbols:upload} command uses the ELF build ID (from the + * {@code .note.gnu.build-id} section) when uploading symbols. To ensure Crashlytics can match crash + * reports to uploaded symbols, the plugin must report the same ELF build ID rather than the Dart + * VM's internal snapshot build ID (which may differ, especially for AAB + flavor builds). + */ +final class ElfBuildIdReader { + + private static final String TAG = "FLTFirebaseCrashlytics"; + + private static final byte[] ELF_MAGIC = {0x7f, 'E', 'L', 'F'}; + private static final int ELFCLASS64 = 2; + private static final int PT_NOTE = 4; + private static final int NT_GNU_BUILD_ID = 3; + private static final String GNU_NOTE_NAME = "GNU"; + + private ElfBuildIdReader() {} + + /** + * Reads the ELF build ID from libapp.so. + * + *
First checks the native library directory (for devices that extract native libs). If not + * found there, reads libapp.so from inside the APK (for devices with extractNativeLibs=false). + * + * @return the build ID as a lowercase hex string, or {@code null} if it cannot be read. + */ + static String readBuildId(Context context) { + try { + // Try extracted native library first. + String nativeLibDir = context.getApplicationInfo().nativeLibraryDir; + File libApp = new File(nativeLibDir, "libapp.so"); + if (libApp.exists()) { + return readBuildIdFromElf(libApp); + } + + // Fall back to reading from inside the APK (or split APKs for AAB installs). + return readBuildIdFromApk(context); + } catch (Exception e) { + Log.d(TAG, "Could not read ELF build ID from libapp.so", e); + return null; + } + } + + /** + * Reads the ELF build ID from libapp.so stored inside the APK. On newer Android versions, native + * libraries may not be extracted to the filesystem. + */ + private static String readBuildIdFromApk(Context context) throws Exception { + // Check the base APK first. + String result = readBuildIdFromZip(context.getApplicationInfo().sourceDir); + if (result != null) { + return result; + } + + // For AAB installs, libapp.so is in a split APK (e.g., split_config.arm64_v8a.apk). + String[] splitDirs = context.getApplicationInfo().splitSourceDirs; + if (splitDirs != null) { + for (String splitDir : splitDirs) { + result = readBuildIdFromZip(splitDir); + if (result != null) { + return result; + } + } + } + return null; + } + + private static String readBuildIdFromZip(String apkPath) throws Exception { + try (ZipFile zipFile = new ZipFile(apkPath)) { + Enumeration extends ZipEntry> entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry.getName().endsWith("/libapp.so")) { + try (InputStream is = zipFile.getInputStream(entry)) { + byte[] elfData = new byte[(int) entry.getSize()]; + int offset = 0; + while (offset < elfData.length) { + int read = is.read(elfData, offset, elfData.length - offset); + if (read < 0) break; + offset += read; + } + return readBuildIdFromBytes(elfData); + } + } + } + } + return null; + } + + private static String readBuildIdFromElf(File elfFile) throws Exception { + try (RandomAccessFile raf = new RandomAccessFile(elfFile, "r")) { + return readBuildIdFromRaf(raf); + } + } + + private static String readBuildIdFromBytes(byte[] data) { + try { + ByteBuffer buf = ByteBuffer.wrap(data); + + // Verify ELF magic bytes. + for (int i = 0; i < 4; i++) { + if (buf.get() != ELF_MAGIC[i]) { + return null; + } + } + + int elfClass = buf.get() & 0xFF; // 1 = 32-bit, 2 = 64-bit + boolean is64 = elfClass == ELFCLASS64; + + int dataEncoding = buf.get() & 0xFF; // 1 = little-endian, 2 = big-endian + ByteOrder order = dataEncoding == 1 ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN; + buf.order(order); + + if (is64) { + return readBuildIdFromBuffer64(buf); + } else { + return readBuildIdFromBuffer32(buf); + } + } catch (Exception e) { + Log.d(TAG, "Could not parse ELF from APK", e); + return null; + } + } + + private static String readBuildIdFromBuffer64(ByteBuffer buf) { + // e_phoff is at offset 32 in the 64-bit ELF header. + buf.position(32); + long phoff = buf.getLong(); + + // e_phentsize is at offset 54, e_phnum at offset 56. + buf.position(54); + int phentsize = buf.getShort() & 0xFFFF; + int phnum = buf.getShort() & 0xFFFF; + + for (int i = 0; i < phnum; i++) { + int phdr = (int) (phoff + (long) i * phentsize); + buf.position(phdr); + int type = buf.getInt(); + if (type == PT_NOTE) { + // p_offset is at phdr + 8, p_filesz at phdr + 32 for 64-bit. + buf.position(phdr + 8); + long noteOffset = buf.getLong(); + buf.position(phdr + 32); + long noteSize = buf.getLong(); + + String buildId = findGnuBuildIdInBuffer(buf, noteOffset, noteSize); + if (buildId != null) { + return buildId; + } + } + } + return null; + } + + private static String readBuildIdFromBuffer32(ByteBuffer buf) { + // e_phoff is at offset 28 in the 32-bit ELF header. + buf.position(28); + long phoff = buf.getInt() & 0xFFFFFFFFL; + + // e_phentsize is at offset 42, e_phnum at offset 44. + buf.position(42); + int phentsize = buf.getShort() & 0xFFFF; + int phnum = buf.getShort() & 0xFFFF; + + for (int i = 0; i < phnum; i++) { + int phdr = (int) (phoff + (long) i * phentsize); + buf.position(phdr); + int type = buf.getInt(); + if (type == PT_NOTE) { + // p_offset is at phdr + 4, p_filesz at phdr + 16 for 32-bit. + buf.position(phdr + 4); + long noteOffset = buf.getInt() & 0xFFFFFFFFL; + buf.position(phdr + 16); + long noteSize = buf.getInt() & 0xFFFFFFFFL; + + String buildId = findGnuBuildIdInBuffer(buf, noteOffset, noteSize); + if (buildId != null) { + return buildId; + } + } + } + return null; + } + + private static String findGnuBuildIdInBuffer(ByteBuffer buf, long offset, long size) { + long end = offset + size; + long pos = offset; + + while (pos + 12 <= end) { + buf.position((int) pos); + int namesz = buf.getInt(); + int descsz = buf.getInt(); + int type = buf.getInt(); + + if (namesz < 0 || descsz < 0 || namesz > 256) { + break; + } + + int nameAligned = align4(namesz); + long descPos = pos + 12 + nameAligned; + + if (namesz > 0 && type == NT_GNU_BUILD_ID && descPos + descsz <= end) { + byte[] nameBytes = new byte[namesz]; + buf.get(nameBytes); + String name = + new String( + nameBytes, 0, Math.max(0, namesz - 1), java.nio.charset.StandardCharsets.US_ASCII); + + if (GNU_NOTE_NAME.equals(name) && descsz > 0) { + buf.position((int) descPos); + byte[] desc = new byte[descsz]; + buf.get(desc); + return bytesToHex(desc); + } + } + + pos = descPos + align4(descsz); + } + return null; + } + + private static String readBuildIdFromRaf(RandomAccessFile raf) throws Exception { + // Verify ELF magic bytes. + byte[] magic = new byte[4]; + raf.readFully(magic); + for (int i = 0; i < 4; i++) { + if (magic[i] != ELF_MAGIC[i]) { + return null; + } + } + + int elfClass = raf.read(); // 1 = 32-bit, 2 = 64-bit + boolean is64 = elfClass == ELFCLASS64; + + int dataEncoding = raf.read(); // 1 = little-endian, 2 = big-endian + ByteOrder order = dataEncoding == 1 ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN; + + if (is64) { + return readBuildIdFromElf64(raf, order); + } else { + return readBuildIdFromElf32(raf, order); + } + } + + private static String readBuildIdFromElf64(RandomAccessFile raf, ByteOrder order) + throws Exception { + // e_phoff is at offset 32 in the 64-bit ELF header. + raf.seek(32); + long phoff = readLong(raf, order); + + // e_phentsize is at offset 54, e_phnum at offset 56. + raf.seek(54); + int phentsize = readUnsignedShort(raf, order); + int phnum = readUnsignedShort(raf, order); + + for (int i = 0; i < phnum; i++) { + long phdr = phoff + (long) i * phentsize; + raf.seek(phdr); + int type = readInt(raf, order); + if (type == PT_NOTE) { + // p_offset is at phdr + 8, p_filesz at phdr + 32 for 64-bit. + raf.seek(phdr + 8); + long noteOffset = readLong(raf, order); + raf.seek(phdr + 32); + long noteSize = readLong(raf, order); + + String buildId = findGnuBuildId(raf, noteOffset, noteSize, order); + if (buildId != null) { + return buildId; + } + } + } + return null; + } + + private static String readBuildIdFromElf32(RandomAccessFile raf, ByteOrder order) + throws Exception { + // e_phoff is at offset 28 in the 32-bit ELF header. + raf.seek(28); + long phoff = readInt(raf, order) & 0xFFFFFFFFL; + + // e_phentsize is at offset 42, e_phnum at offset 44. + raf.seek(42); + int phentsize = readUnsignedShort(raf, order); + int phnum = readUnsignedShort(raf, order); + + for (int i = 0; i < phnum; i++) { + long phdr = phoff + (long) i * phentsize; + raf.seek(phdr); + int type = readInt(raf, order); + if (type == PT_NOTE) { + // p_offset is at phdr + 4, p_filesz at phdr + 16 for 32-bit. + raf.seek(phdr + 4); + long noteOffset = readInt(raf, order) & 0xFFFFFFFFL; + raf.seek(phdr + 16); + long noteSize = readInt(raf, order) & 0xFFFFFFFFL; + + String buildId = findGnuBuildId(raf, noteOffset, noteSize, order); + if (buildId != null) { + return buildId; + } + } + } + return null; + } + + /** + * Searches a PT_NOTE segment for the GNU build ID note. + * + *
Note format: namesz (4) | descsz (4) | type (4) | name (aligned to 4) | desc (aligned to 4)
+ */
+ private static String findGnuBuildId(
+ RandomAccessFile raf, long offset, long size, ByteOrder order) throws Exception {
+ long end = offset + size;
+ long pos = offset;
+
+ while (pos + 12 <= end) {
+ raf.seek(pos);
+ int namesz = readInt(raf, order);
+ int descsz = readInt(raf, order);
+ int type = readInt(raf, order);
+
+ if (namesz < 0 || descsz < 0 || namesz > 256) {
+ break;
+ }
+
+ int nameAligned = align4(namesz);
+ long descPos = pos + 12 + nameAligned;
+
+ if (namesz > 0 && type == NT_GNU_BUILD_ID && descPos + descsz <= end) {
+ byte[] nameBytes = new byte[namesz];
+ raf.readFully(nameBytes);
+ // Name is null-terminated.
+ String name =
+ namesz > 0 ? new String(nameBytes, 0, Math.max(0, namesz - 1), "US-ASCII") : "";
+
+ if (GNU_NOTE_NAME.equals(name) && descsz > 0) {
+ raf.seek(descPos);
+ byte[] desc = new byte[descsz];
+ raf.readFully(desc);
+ return bytesToHex(desc);
+ }
+ }
+
+ pos = descPos + align4(descsz);
+ }
+ return null;
+ }
+
+ private static int align4(int value) {
+ return (value + 3) & ~3;
+ }
+
+ private static int readInt(RandomAccessFile raf, ByteOrder order) throws Exception {
+ byte[] buf = new byte[4];
+ raf.readFully(buf);
+ return ByteBuffer.wrap(buf).order(order).getInt();
+ }
+
+ private static long readLong(RandomAccessFile raf, ByteOrder order) throws Exception {
+ byte[] buf = new byte[8];
+ raf.readFully(buf);
+ return ByteBuffer.wrap(buf).order(order).getLong();
+ }
+
+ private static int readUnsignedShort(RandomAccessFile raf, ByteOrder order) throws Exception {
+ byte[] buf = new byte[2];
+ raf.readFully(buf);
+ return ByteBuffer.wrap(buf).order(order).getShort() & 0xFFFF;
+ }
+
+ private static String bytesToHex(byte[] bytes) {
+ StringBuilder sb = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ sb.append(String.format("%02x", b & 0xff));
+ }
+ return sb.toString();
+ }
+}
diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.java
index 18a55bd9f530..0ad27c015091 100644
--- a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.java
+++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.java
@@ -40,6 +40,13 @@ public class FlutterFirebaseCrashlyticsPlugin
private MethodChannel channel;
private EventChannel testEventChannel;
private EventChannel.EventSink testEventSink;
+ private Context applicationContext;
+
+ // 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 String elfBuildId;
private static final String FIREBASE_CRASHLYTICS_COLLECTION_ENABLED =
"firebase_crashlytics_collection_enabled";
@@ -56,6 +63,8 @@ private void initInstance(BinaryMessenger messenger) {
@Override
public void onAttachedToEngine(FlutterPluginBinding binding) {
+ applicationContext = binding.getApplicationContext();
+ elfBuildId = ElfBuildIdReader.readBuildId(applicationContext);
initInstance(binding.getBinaryMessenger());
}
@@ -157,14 +166,18 @@ private Task