Android Question R8 obfuscated app & Crashlytics

Jerryk

Active Member
Licensed User
Longtime User
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:
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)
There are two separate layers of renaming and neither is undone by Crashlytics by itself:
  • 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's mapping.txt for 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 (_vvvvvvvvvvvvvvvv6 above). That is a second layer written to Objects\ObfuscatorMap.txt, and Crashlytics knows nothing about it.
So the console alone does not tell you which sub crashed, and you have to remember which map belongs to which release.

2. The solution: upload the mapping, keep both maps per release, read the reports through MCP
  1. 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.
  2. After the build, R8's mapping.txt is uploaded to Firebase with the Firebase CLI. The console then shows the real class names and line numbers for the R8 layer.
  3. B4A's ObfuscatorMap.txt is 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.
  4. 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.
I put steps 1-3 into one script that runs after the release is decided: new id, build, a check that the id is really inside the bundle, copy of both maps into 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.
Why it matters for an obfuscated app: the assistant reads the trace and has the project, so it can look the renamed sub up in the 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
(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 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)
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:
B4X:
#If AAB
#R8Optimizer: True
#End If
Firebase finds its components by the class names in the manifest <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
If 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
The generated file contains the string 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
The 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.
 
Top