Reading Crashlytics reports of an obfuscated B4A app - with mapping files and the Firebase MCP server
My app is published on Google Play as an obfuscated bundle (R8 + B4A's obfuscator). There is no B4A library for Crashlytics, so I use it through JavaObject. This post describes the problem I ran into when reading the reports, the way I solved it, why the Firebase MCP server helps, and exactly how to set it up. It works for me with B4A 14.20 and the command line builder.
1. The problem: Crashlytics reports of an obfuscated app are unreadable
A crash arrives in the console, but the stack trace looks like this:
There are two separate layers of renaming and neither is undone by Crashlytics by itself:
2. The solution: upload the mapping, keep both maps per release, read the reports through MCP
3. The advantage of MCP: reading the errors
The Firebase CLI has an experimental MCP server limited to Crashlytics. After it is registered in the assistant, the assistant can:
Privacy by design: because my reports carry only a fixed tag and the exception class name (never the message, which may hold a host or a title), reading them in a conversation puts no user data into it.
Limits: the server is experimental, Crashlytics only (Analytics events are not readable through it), it uses the Firebase CLI login, and the report window is the last 90 days.
4. How to do it - technical description
4.1 Libraries
(Crashlytics 19.4.4 and Analytics 22.5.0 from B4A's local Maven cache; Analytics comes from the FirebaseAnalytics library.)
4.2 Manifest
I do not use the library's
Without Analytics you can drop the three measurement entries and the Analytics registrar.
4.3 R8 and the keep rules
R8 only for the bundle configuration:
Firebase finds its components by the class names in the manifest
4.4 The code (JavaObject)
Crashlytics starts itself through
If
4.5 The release routine (mapping id, build, upload)
The generated file contains the string
4.6 The MCP server
Register it in the assistant (I use Claude Code). On Windows, from Git Bash (PowerShell lost the
The
Status
Crashes and non-fatals from an obfuscated build reach the console and the MCP server reads them. What I have not seen yet is a trace from a version downloaded from Google Play translated end to end, because that needs a real crash of a published release. I will update this post when I have one. If someone has found a way to hand B4A's
My app is published on Google Play as an obfuscated bundle (R8 + B4A's obfuscator). There is no B4A library for Crashlytics, so I use it through JavaObject. This post describes the problem I ran into when reading the reports, the way I solved it, why the Firebase MCP server helps, and exactly how to set it up. It works for me with B4A 14.20 and the command line builder.
1. The problem: Crashlytics reports of an obfuscated app are unreadable
A crash arrives in the console, but the stack trace looks like this:
B4X:
java.lang.RuntimeException: Object should first be initialized (List).
at anywheresoftware.b4a.objects.collections.List.Add (SourceFile:48)
at cz.mojeodkazy.telemetry._vvvvvvvvvvvvvvvv6 (SourceFile:68)
at cz.mojeodkazy.main._activity_create (SourceFile:21)
- R8 shrinks and renames classes and members and removes the line numbers (everything is
SourceFile:n). Crashlytics can translate that, but only if it receives R8'smapping.txtfor exactly that build. The gradle plugin does it for you; a B4A build has no gradle. - B4A's own obfuscator renames your subs and variables (
_vvvvvvvvvvvvvvvv6above). That is a second layer written toObjects\ObfuscatorMap.txt, and Crashlytics knows nothing about it.
2. The solution: upload the mapping, keep both maps per release, read the reports through MCP
- Every release gets a new
mapping_file_id(a resource built into the app) so that Crashlytics can match a crash to a mapping file. It must exist before the build and be different each time. - After the build, R8's
mapping.txtis uploaded to Firebase with the Firebase CLI. The console then shows the real class names and line numbers for the R8 layer. - B4A's
ObfuscatorMap.txtis kept per release (a folder named after version and version code), next to the AAB and the mapping. It is small and can be committed. - The reports are read through the Firebase MCP server (Crashlytics tools), so an AI coding assistant can fetch the issues and sample stack traces, translate the B4A names with the matching
ObfuscatorMap.txt, and go straight to the code.
releases\<version>-<code>, upload of the mapping. Nothing is forgotten, and a crash from an old version can still be translated months later.3. The advantage of MCP: reading the errors
The Firebase CLI has an experimental MCP server limited to Crashlytics. After it is registered in the assistant, the assistant can:
- list the top issues of a period (fatal, non-fatal, ANR) with counts and users,
- open one issue and fetch sample events with the full stack trace, thread list, device, OS and app version,
- filter by version, device or operating system, and change an issue's state or add a note.
ObfuscatorMap.txt of that version and open the source line in one go - the second layer is no longer a manual job. Rule I follow: match the version of the report to the folder of the release first, because the names change in every build.Privacy by design: because my reports carry only a fixed tag and the exception class name (never the message, which may hold a host or a title), reading them in a conversation puts no user data into it.
Limits: the server is experimental, Crashlytics only (Analytics events are not readable through it), it uses the Firebase CLI login, and the report window is the last 90 days.
4. How to do it - technical description
4.1 Libraries
B4X:
#AdditionalJar: com.google.firebase:firebase-crashlytics
#AdditionalJar: com.google.android.datatransport:transport-runtime
#AdditionalJar: com.google.android.datatransport:transport-backend-cct
4.2 Manifest
I do not use the library's
FirebaseAnalytics.Firebase macro: it adds FCM, biometric and install-referrer permissions, an exported FCM receiver and registrars for libraries that are not there. My own block plus the Crashlytics macro; google-services.json is in the B4A folder:
B4X:
CreateResourceFromFile("google-services", "google-services.json")
AddPermission(android.permission.INTERNET)
AddPermission(android.permission.WAKE_LOCK)
AddApplicationText(
<receiver android:name="com.google.android.gms.measurement.AppMeasurementReceiver" android:enabled="true" android:exported="false" />
<service android:name="com.google.android.gms.measurement.AppMeasurementService" android:enabled="true" android:exported="false" />
<service android:name="com.google.android.gms.measurement.AppMeasurementJobService" android:enabled="true" android:exported="false" android:permission="android.permission.BIND_JOB_SERVICE" />
<service android:name="com.google.firebase.components.ComponentDiscoveryService" android:directBootAware="true" android:exported="false">
<meta-data android:name="com.google.firebase.components:com.google.firebase.analytics.connector.internal.AnalyticsConnectorRegistrar" android:value="com.google.firebase.components.ComponentRegistrar" />
<meta-data android:name="com.google.firebase.components:com.google.firebase.installations.FirebaseInstallationsRegistrar" android:value="com.google.firebase.components.ComponentRegistrar" />
<meta-data android:name="com.google.firebase.components:com.google.firebase.crashlytics.CrashlyticsRegistrar" android:value="com.google.firebase.components.ComponentRegistrar" />
<meta-data android:name="com.google.firebase.components:com.google.firebase.FirebaseCommonKtxRegistrar" android:value="com.google.firebase.components.ComponentRegistrar" />
<meta-data android:name="com.google.firebase.components:com.google.firebase.ktx.FirebaseCommonLegacyRegistrar" android:value="com.google.firebase.components.ComponentRegistrar" />
</service>
<provider android:name="com.google.firebase.provider.FirebaseInitProvider" android:authorities="$PACKAGE$.firebaseinitprovider" android:directBootAware="true" android:exported="false" android:initOrder="100" />)
CreateResourceFromFile(Macro, FirebaseAnalytics.Crashlytics)
4.3 R8 and the keep rules
R8 only for the bundle configuration:
B4X:
#If AAB
#R8Optimizer: True
#End If
<meta-data>, and the Google classes called through JavaObject are reached by name, so R8 needs keep rules (AddProguardText in the manifest editor):
B4X:
AddProguardText(
-keep class * implements com.google.firebase.components.ComponentRegistrar { *; }
-keep class com.google.firebase.crashlytics.FirebaseCrashlytics { *; }
-keep class com.google.firebase.analytics.FirebaseAnalytics { *; }
-keep class com.google.android.datatransport.cct.** { *; }
-keep class com.google.android.datatransport.runtime.backends.TransportBackendDiscovery { *; })
4.4 The code (JavaObject)
Crashlytics starts itself through
FirebaseInitProvider. A small module records caught exceptions. B4A's Exception is the java.lang.Throwable itself, not a wrapper, so Ex.As(JavaObject) works directly. Only a tag and the class name are sent, the original frames are kept so that the report points at the line:
B4X:
Private Sub Crashlytics As JavaObject
Dim c As JavaObject
c.InitializeStatic("com.google.firebase.crashlytics.FirebaseCrashlytics")
Return c.RunMethod("getInstance", Null)
End Sub
Public Sub NonFatal (Tag As String, Ex As Exception)
Try
Dim t As JavaObject = Ex.As(JavaObject)
Dim safe As JavaObject
safe.InitializeNewInstance("java.lang.Exception", Array(Tag & ": " & GetType(Ex)))
safe.RunMethod("setStackTrace", Array(t.RunMethod("getStackTrace", Null)))
Crashlytics.RunMethod("recordException", Array(safe))
Catch
End Try
End Sub
Application_Error returns True, the process goes on and Crashlytics' own crash handler never sees the error, so call NonFatal("unhandled", Error) there. A real crash (in my test, an error in Activity_Create that B4A rethrows after Application_Error) is reported as Fatal with its threads.4.5 The release routine (mapping id, build, upload)
B4X:
1. firebase crashlytics:mappingfile:generateid --resource-file=IconRes\values\crashlytics_mapping_id.xml
(the folder is included with #AdditionalRes: ..\IconRes - it must be there before the build)
2. B4ABuilder.exe -task=BuildBundle -obfuscate=True -Project=<project>.b4a -Configuration=Default
3. check that the id is inside the bundle: it is in base/resources.pb of the .aab (a zip)
4. keep Objects\mapping.txt and Objects\ObfuscatorMap.txt in releases\<version>-<code>\ together with the .aab
5. firebase crashlytics:mappingfile:upload --app=<Firebase App ID> --resource-file=<the xml of step 1> Objects\mapping.txt
com.google.firebase.crashlytics.mapping_file_id; do not commit it by hand, it changes with every release. Google Play also needs a higher #VersionCode for every upload.4.6 The MCP server
Register it in the assistant (I use Claude Code). On Windows, from Git Bash (PowerShell lost the
-- and failed with unknown option '-y'):
B4X:
claude mcp add firebase -- cmd /c npx -y firebase-tools@latest mcp --only crashlytics
cmd /c is needed because npx is a .cmd on Windows. It uses the Firebase CLI login (firebase login once). In a session: set the active Firebase project, find the app id, then ask for the top issues and open a sample event of one of them - the tools are crashlytics_get_report (with topIssues), crashlytics_get_issue, crashlytics_list_events and crashlytics_batch_get_events. Then translate the renamed subs with the ObfuscatorMap.txt of the release that crashed.Status
Crashes and non-fatals from an obfuscated build reach the console and the MCP server reads them. What I have not seen yet is a trace from a version downloaded from Google Play translated end to end, because that needs a real crash of a published release. I will update this post when I have one. If someone has found a way to hand B4A's
ObfuscatorMap.txt to Crashlytics too, I would like to know.