6 minute read

In this article we share the story of an interesting investigation we had to perform in order to track down a misleading stack trace we found in our Crashlytics reports.

We had received a crash report whose call stack did not match the source code of our application. Both in Crashlytics and the Google Play console we saw something like this:

Fatal Exception: java.lang.IllegalArgumentException: 
        Parameter specified as non-null is null: 
        method com.example.feature.c.a, parameter $this$extensionB
    at com.example.extensions.ExtentionsA.extensionA(ExtentionsA.java:29)
    at com.example.service.FcmService.onMessageReceived(FcmService.java:92)
    ...

And in the actual source code of the project we had the following:

FcmService.java:

package com.example.service;

class FcmService {
    ...
    void onMessageReceived() {
        ExtensionsB.extensionB(someNullableString, param);
    }
    ...
}

ExtensionsB.kt:

package com.example.extensions

fun String.extensionB(param: Int) {
    ...
}

As you can see the stacktrace indicates that FcmService.onMessageReceived calls ExtensionsA.extensionA, even though it was actually calling ExtensionsB.extensionB.

Having carefully studied the stacktrace we asked ourselves a question: how did it happen that ExtensionsA and ExtensionsB have a package called com.example.extensions but there is another package com.example.feature.c in the crash message? In search of the answer we started to look into our application’s obfuscation configuration.

R8

Our Android application was minified and obfuscated with the R8 just like all other apps are before publishing, meaning the stacktrace of the production crash contained the modified class names. If there used to be a class called class ExtensionA, then after R8 was applied, it transformed into class c. In the resulting stacktrace it would be called com.example.extensions.c. We load a special mapping.txt file to Crashlytics along with the new release of our application in order to deobfuscate such stacktraces. mapping.txt contains a one-to-one matching for names of classes/methods from the source code and names of classes/methods from release binaries. Then Crashlytics run the script ./retrace after receiving a crash report:

$ ./retrace mapping.txt stacktrace.txt > result_stacktrace.txt

and turns an unreadable stacktrace into a readable one. That is how com.example.extensions.c became com.example.extensions.ExtensionsA.

There was an error somewhere in this process and incorrect class and method names were used in the resulting stacktrace.

Let’s take a look at our mapping.txt and find class ExtensionsA and its methods:

com.example.feature.R$style -> com.example.feature.c:
    1:1:void com.example.ExtensionsA.extensionA(java.lang.String,java.lang.Integer,java.lang.Integer):29:30 -> a
    ...
    9:9:android.view.View com.example.feature.extensions.AndroidExtensionsKt.inflate(android.view.ViewGroup,int,boolean):0:0 -> a
    9:9:android.view.View com.example.feature.AndroidExtensionsKt.inflate$default(android.view.ViewGroup,int,boolean,int,java.lang.Object):23 -> a
    10:10:android.view.View com.example.feature.extensions.AndroidExtensionsKt.inflate(android.view.ViewGroup,int,boolean):24:24 -> a
    10:10:android.view.View com.example.feature.extensions.AndroidExtensionsKt.inflate$default(android.view.ViewGroup,int,boolean,int,java.lang.Object):23 -> a
    ...
    14:15:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String):11:12 -> a
    16:17:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String):15:16 -> a
    18:18:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String) -> a
    19:19:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String):17:17 -> a
    ...
    27:28:void com.example.ExtensionsB.extensionB(java.lang.String,java.lang.Integer):18:19 -> a

Almost all methods of the resulting class com.example.feature.c have the same obfuscated name - a, the difference is in the arguments and return types, so their signatures are different. To confirm this fact, we can look at the bytecode of com.example.feature.c class from the release binary. This can be done, for example, from Android Studio:

Build -> Analyse APK… -> <select apk/aab> -> Select .dex file which contains the *.class file -> open it

Analyse release

28 methods of this class are named a, and one method is named b.

We also wondered why the R$style class suddenly contained methods from ExtensionsA and other utility classes? The answer lies in the R8 optimizations, specifically in the class/merging optimizations. R8 can combine multiple classes into one under special conditions. This is what happened: the class com.example.feature.R$style was empty and other static methods were “moved” to this class to minimize the number of classes in *.dex files.

Kotlin std-lib

Let’s keep in mind the above mentioned facts and start dealing with another question: why the crash message contained the strange package com.example.feature.c?

Parameter specified as non-null is null: 
    method com.example.feature.c.a, parameter $this$extensionB

Why is this strange? Because in the crash in Crashlytics everything is deobfuscated using ./retrace, but this package remained obfuscated. As this exception was thrown by kotlin-specific code, we then took a look at its bytecode (for simplicity we decompile it in Java) to understand the reason:

public final class ExtensionsB {
    @NotNull
    public static void extensionB(
            @NotNull String $this$extensionB, 
            final int param
    ) {
        Intrinsics.checkParameterIsNotNull(
                $this$extensionB, 
                "$this$extensionB"
        );
    }
}

Here we can see that the argument name is stored as a string, and R8, of course, can’t change it. Next, we needed to look at the implementation Intrinsics.checkParameterIsNotNull in the Kotlin standard library to understand how the exception message was generated:

StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
StackTraceElement caller = stackTraceElements[3];
String className = caller.getClassName();
String methodName = caller.getMethodName();

IllegalArgumentException exception = new IllegalArgumentException(
        "Parameter specified as non-null is null: " +
        "method " + className + "." + methodName +
        ", parameter " + paramName
);
throw sanitizeStackTrace(exception);

That is, the full class name along with the package was taken from the thread stacktrace and placed in the exception message. Of course, ./retrace left error messages unchanged.

Before taking the final step, here is all research compiled into a single schema:

What happened

Final step

To understand what happened, we were missing one element - a real stacktrace crash. Unfortunately, neither Firebase Crashlytics nor Google Play console does not provide an opportunity to look at the stacktrace before its deobfuscation. Therefore, we needed to simulate the crash by ourselves. In our case, it turned out to be quite simple. But in other cases, it is necessary to make sure that the mapping.txt file is similar to the mapping.txt from the original crashed application version.

So, here is the real stacktrace of the emulated crash:

java.lang.IllegalArgumentException: 
        Parameter specified as non-null is null: 
        method com.example.feature.c.a, parameter $this$extensionB
    at com.example.feature.c.a(Unknown Source:2)
    at com.example.service.FcmListenerService.onMessageReceived(SourceFile:4)
    ...

The problem was Unknown Source: 2. ./retrace chose the first available method named com.example.feature.c.a because it didn’t have enough debug information in the original stacktrace! This information was likely not there because the static method ExtensionsB.extensionB had become a part of the class R$style due to R8 optimizations. Class R$style does not compile along with the entire project. Android Gradle Plugin since version 3.3.0, packs it immediately in compiled state:

Previously, the Android Gradle plugin would generate an R.java file for each of your project’s dependencies and then compile those R classes alongside your app’s other classes. The plugin now generates a JAR containing your app’s compiled R class directly, without first building intermediate R.java classes.

So what did we learn?

It would appear that we just need to turn off the R8 optimization, which merges small classes into one. The proguard syntax even allows us to do this:

-optimizations !class/merging/*

However, R8 ignores all rules associated with the discrete optimizations, and this rule will not work. It is possible to disable all optimizations together using the -dontoptimize rule, but this might affect application performance which is not desirable.

At the time of receiving this strange crash, our application was using AGP version 3.6.2, which uses R8 version 1.6.82. After upgrading the AGP version to 4.2.2 (which started using R8 version 2.2.71) the behavior of the optimizations changed, and now if the methods have a different name in the source code, then they will also have a different name in the optimized one:

com.example.feature.R$style -> com.example.feature.c:
    1:1:void com.example.ExtensionsA.extensionA(java.lang.String,java.lang.Integer,java.lang.Integer):29:30 -> A
    ...
    9:9:android.view.View com.example.feature.extensions.AndroidExtensionsKt.inflate(android.view.ViewGroup,int,boolean):0:0 -> B
    9:9:android.view.View com.example.feature.AndroidExtensionsKt.inflate$default(android.view.ViewGroup,int,boolean,int,java.lang.Object):23 -> B
    10:10:android.view.View com.example.feature.extensions.AndroidExtensionsKt.inflate(android.view.ViewGroup,int,boolean):24:24 -> B
    10:10:android.view.View com.example.feature.extensions.AndroidExtensionsKt.inflate$default(android.view.ViewGroup,int,boolean,int,java.lang.Object):23 -> B
    ...
    14:15:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String):11:12 -> C
    16:17:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String):15:16 -> C
    18:18:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String) -> C
    19:19:void com.example.ExtensionsA.extensionC(java.lang.CharSequence,java.lang.String):17:17 -> C
    ...
    27:28:void com.example.ExtensionsB.extensionB(java.lang.String,java.lang.Integer):18:19 -> D

Accordingly, if Unknown Source is present in the stack trace, then ./retrace will detect the correct name of the original method and class by unique method name.

So, at the end of the day, we just had to upgrade our Gradle plugin to a newer version - but since it is not always a working solution for some legacy projects it is good to know the reason behind this wrong stacktrace issue. Hopefully this information will save you valuable troubleshooting time or help you plan in advance to prevent the issue from occurring.