androidgradleproguardandroid-libraryandroid-r8

How to run Proguard/R8 on a library before running it on an app, as if it was an external dependency?


I'm afraid I'm either misunderstanding the behavior of Proguard/R8 or it's not suited for my needs. I'd appreciate some clarification if there's any Proguard expert out there.

Here's my context. I'm building an Android library that's primarily meant to be distributed to external parties in closed source. That being said, I'm also building an Android app that depends on this very library as well. I have a Proguard configuration that's here to obfuscate my library so that the .aar I'm distributing is obfuscated. I'm also referencing a consumer-rules.pro Proguard files which indicates which classes to keep in order not to break the lib when the consumer uses Proguard as well. This part here seems to be working properly. The .aar file that's output after a gradle build in release mode looks adequate to me.

I also did the experiment of making my Android app depend on my generated .aar and, after adding the missing dependencies required by my lib in the Android app (since the .aar isn't accompanied by a .module file for gradle) it seems to be working.

Now the problem happens when my app depends on my library through the more conventional and appropriate way: implementation project(':my-lib'). It looks to me like Proguard isn't actually applying to my library first and only then to my app. It looks more like the library's and the app's source code are aggregated and then Proguard runs on the whole source code. The results are definitely different, some parts that were to be obfuscated in my lib aren't anymore, some code has been shrunk while it was supposed to be kept and my custom mapping doesn't apply.

Here's a visualization of the important parts of my file tree:

my-project/
├─ my-app/
│  ├─ ...
│  ├─ build.gradle
│  ├─ proguard-rules.pro
├─ my-lib/
│  ├─ ...
│  ├─ build.gradle
│  ├─ custom-mapping.txt
│  ├─ consumer-rules.pro
│  ├─ proguard-rules.pro

my-app's build.gradle:

android {
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}
...
dependencies {
    ...
    implementation project(':my-lib')
}

my-lib's build.gradle:

android {
    defaultConfig {
        ...
        consumerProguardFiles "consumer-rules.pro"
    }
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

my-lib's proguard-rules.pro:

-applymapping "custom-mapping.txt"

# Place every obfuscated class in a single package
-flattenpackagehierarchy 'com.mycompany.mylib.obfuscated'

# Do not attempt to shrink the code when building this library as a standalone, otherwise all of
# the code will get stripped, ending up with an empty aar file
-dontshrink

# Here are my keep rules
...

my-lib's consumer-rules.pro:

# Some keep rules related to Android internal classes that I rely on in my library

# Field that shouldn't be stripped if the class is present
-keepclassmembers class com.mycompany.mylib.obfuscated.z.z { # <-- the z.z comes from the custom mapping I'm applying to ensure it's always obfuscated the same way
    # Fields that I want to keep and that would be shrunk without specifying it
    ...
}

So yeah, I think I've detailed everything notable to my context. I guess my question could be rephrased as: how do I get the same result as an external app depending on my library would but still depending on my local library?

Of course I'm aware I could depend on the published library, but that would make the development painful as I would constantly have to publish test binaries, which I don't want to.


Solution

  • As @sgjesse pointed out in the comment of my question, this behavior is actually a bug on Proguard, referenced in this issue tracker.

    So please refer to this tracker at the time of reading this to see if it's been resolved in a newer version of Proguard.

    In the meantime, here's the solution I found to work around this issue in a relatively clean manner:

    Inside my-app's build.gradle, I added this:

    afterEvaluate {
        // For each release flavor, add the `publishReleasePublicationToMavenLocal` for the library as a
        // prerequisite for the release build task to make sure the latest version is used
        android.applicationVariants.configureEach { variant ->
            def variantName = variant.name.capitalize()
    
            if (!variantName.endsWith("Release")) {
                return
            }
    
            try {
                def publishMyLibToMavenLocal = ":my-lib:publishReleasePublicationToMavenLocal"
                def buildReleaseDemoApp = "pre${variantName}Build"
                tasks.named(buildReleaseMyApp).get().dependsOn(publishMyLibToMavenLocal)
            } catch (Exception error) {
                println("Warning: ${error.localizedMessage}")
            }
        }
    }
    

    Note that I have different flavors of my-app, hence the looping over each flavor. This effectively build a new version of my-lib and publishes it to the local maven repository before each release build of my-app.

    Then, in the same file, in the dependencies block:

    dependencies {
        // My-lib
        debugImplementation project(':my-lib')
        // In order to apply R8 obfuscation on both the library and the application distinctly, we rely
        // on the local maven repository where the library release build will be built and published before
        // every my-app release build
        releaseImplementation "com.mycompany:my-lib:$LIBRARY_VERSION_NAME"
    }
    

    This way, I'm still depending directly on my project's library, which means I can still use breakpoints to debug the library through the application. But for release builds, I'm depending on the local maven version which has been built in release mode and thus correctly obfuscated.

    Note that $LIBRARY_VERSION_NAME is a variable defined in project's gradle.properties that's used in my-lib to set the current version.

    This has been working well so far. Hope it helps other folks while the bug remains present.