Share
Embed
[DroidKaigi 2025] Nasty Dependencies: Surviving and fixing bugs in 3rd-party libraries
As developers, we are responsible for bugs in our apps, even when they are caused by third-party dependencies.
In this session, I will give an overview of different ways to handle this situation, including:
- Debugging, logging, or fixing issues using advanced Gradle techniques;
- Forking libraries and using them in your project via Git submodules, Nexus repositories, or direct artifacts;
- Upstreaming your changes to the original library repository;
- Possible approaches for working with closed-source libraries.
This session will act more like a guidebook than a deep technical dive, offering practical options developers can choose from depending on their situation.
Yury
September 11, 2025
Transcript
98 slides
-
Nasty Dependencies: Surviving and fixing bugs in 3rd-party libraries Yury
Vlad linkedin.com/in/yury-vlad DroidKaigi 2025 1 -
UX • Users won't be happy if the app crashes
• They might stop using it and leave negative reviews • It takes around six five-star reviews to cancel out a single one-star review DroidKaigi 2025 6 -
Google Play Vitals Crashes and ANRs affect an app's search
ranking in the Play Store. DroidKaigi 2025 7 -
GitHub page of the library • A good place to
start • Search by error message or stacktrace parts • The fix might already be released DroidKaigi 2025 9 -
Snapshots • The bug is fixed but not yet released
• Some libraries provide -SNAPSHOT dependencies • These snapshots are the latest builds from the main branch • Follow the library's instructions for testing DroidKaigi 2025 10 -
Snapshot usage // app/build.gradle repositories { ... maven { url
'https://oss.sonatype.org/content/repositories/snapshots' } } dependencies { ... implementation 'org.sample.library:core:3.0.0-SNAPSHOT' implementation 'org.sample.library:utils:3.0.0' // Never mix versions implementation 'org.sample.library:utils:3.0.0-SNAPSHOT' // Use snapshot everywhere ... } DroidKaigi 2025 11 -
Reproducible builds • Reproducible builds • Android builds are reproducible
when: ◦ All dependencies and tools are locked to specific versions ◦ JDK version and vendor are identical • F-Droid provides a detailed guide DroidKaigi 2025 13 -
Making snapshots stable Download a specific snapshot version and host
it locally to lock it in place. See the Hosting section for details. DroidKaigi 2025 14 -
Issue not fixed When the issue isn't fixed upstream: •
Fix it yourself • Add workarounds to prevent crashes DroidKaigi 2025 15 -
First steps • git clone [email protected]:account/name.git • Try to reproduce
your issue in the sample app or tests DroidKaigi 2025 16 -
Issue reproducible only in your app Intuitive approach: • cd
my-app • git clone [email protected]:account/name.git library-under-investigation • Add include 'library-under-investigation' to settings.gradle DroidKaigi 2025 17 -
Direct include • Shares the same build configuration • No
isolation • Build tools version mismatch (e.g. AGP) • Gradle configs may affect each other unexpectedly DroidKaigi 2025 18 -
Composite build Gradle Composite Builds to the rescue: • git
clone git@url ../library-under-investigation • Add includeBuild('../library-under-investigation') to settings.gradle DroidKaigi 2025 19 -
Composite build // settings.gradle includeBuild('../library-under-investigation') { dependencySubstitution { substitute module('org.sample.library:core')
using project(':core') substitute module('org.sample.library:utils') using project(':utils') ... } } DroidKaigi 2025 20 -
Composite build // app/build.gradle dependencies { implementation 'org.sample.library:core' // Works
implementation project(':library-under-investigation:core') // Does not } // library-under-investigation/core/build.gradle dependencies { implementation project(':app') // Does not work } DroidKaigi 2025 24 -
Finding the root cause Now we can debug the issue
using our real app environment and usage patterns. DroidKaigi 2025 25 -
Production debugging • Debug with production data • Still follow
the same setup and use includeBuild • Add logs and meta information inside the library DroidKaigi 2025 26 -
Logs • Help understand the user path that led to
the issue • Can include useful meta info like inputs and current state DroidKaigi 2025 27 -
Logs package org.sample.library interface Logger { fun log(message: String) fun
recordException(exception: Exception, metaInfo: Map<String, String> = emptyMap()) companion object { lateinit var Instance: Logger } } DroidKaigi 2025 28 -
Logs • Add Logger.log in key places: navigation, clicks, method
calls • Keep logs short — size limits apply • Use acronyms • Catch exceptions and use Logger.recordException with metaInfo • Don’t put dynamic info in the message, pass it in metaInfo • Wrap the exception, if dynamic info is already presented DroidKaigi 2025 29 -
Logs package com.my.app object LoggerImpl : Logger { override fun
log(message: String) { // Log using app-specific crash reporting tool } override fun recordException(exception: Exception, metaInfo: Map<String, String>) { // Log using app-specific crash reporting tool } } DroidKaigi 2025 30 -
Crashlytics Crashlytics logger implementation: package com.my.app object LoggerImpl : Logger
{ override fun log(message: String) { // Crashlytics limits logs to 64kB and deletes older log entries Firebase.crashlytics.log(message) } override fun recordException(exception: Exception, metaInfo: Map<String, String>) { Firebase.crashlytics.recordException(exception) { // 64 key/value pairs maximum // Each pair can be up to 1 kB (~1000 characters) metaInfo.forEach { (k, v) -> key(k, v) } } } } DroidKaigi 2025 31 -
Sentry Sentry logger implementation: package com.my.app object LoggerImpl : Logger
{ override fun log(message: String) { // Possible to use Breadcrumb class directly with customizable fields // Messages are limited to 8192 characters Sentry.addBreadcrumb(message) } override fun recordException(exception: Exception, metaInfo: Map<String, String>) { Sentry.captureException(exception, scope -> { // Context objects are limited to 8KB metaInfo.forEach { (k, v) -> scope.setContexts(k, v) } }); } } DroidKaigi 2025 32 -
Hosting • Changes are done and tested locally • Or
there are no changes and we want a snapshot version • How to deploy to production? DroidKaigi 2025 33 -
Git We cloned the original repo, now let’s set it
up as a fork. • Create a private repo on your own server • If allowed, create GitHub fork instead • git remote rename origin upstream (to track the original) • git remote add origin fork-repo-url.git • git push -u origin main DroidKaigi 2025 34 -
Git • Use feature branches for your changes • Keep
the history clean • Bonus: public GitHub forks have CI for free DroidKaigi 2025 35 -
Git Updating the fork: • git fetch upstream • git
merge upstream/main Alternative: DroidKaigi 2025 36 -
Before committing • Local setup breaks if committed • library-under-investigation
is outside the app repo • Revert includeBuild changes before continuing DroidKaigi 2025 37 -
Naive approach • ./gradlew assembleRelease the library • Copy .aar
files into the main project • implementation files('library.aar') DroidKaigi 2025 38 -
Naive approach • No .pom or .module files • Information
about transitive dependencies is lost • Incompatible with Kotlin Multiplatform libraries DroidKaigi 2025 39 -
Transitive dependencies use 'core:1.0' use 'library' .pom, core:1.1 App org.utils:core:
1.0 -> 1.1 Library org.utils:core:1.1 DroidKaigi 2025 41 -
Transitive dependencies When not using expected version: • Behavior incompatibility
• Binary incompatibility DroidKaigi 2025 42 -
Binary compatibility Transitive dependency v1.0 → v1.1 adds a field
with default value: data class TransitiveDepClass( val fieldOne: Int = 0, + val fieldTwo: String = "", // Source compatible change ) Library is compiled against v1.1. class LibrarySdk { val instance = TransitiveDepClass() } Your app provides v1.0 (due to missing .pom ). DroidKaigi 2025 43 -
Binary compatibility App code: // Crash with NoSuchMethodException(TransitiveDepClass constructor(.,.,.)) val
library = LibrarySdk() Data class constructors change in bytecode: • v1.0: constructor(fieldOne, defaultMarkers) • v1.1: constructor(fieldOne, fieldTwo, defaultMarkers) DroidKaigi 2025 44 -
Binary compatibility R8 might highlight binary compatibility issues in the
warnings log. DroidKaigi 2025 45 -
Local Maven repository Replicates Maven repository (like mavenCentral ) by
storing files locally in a folder. DroidKaigi 2025 47 -
Local Maven repository • ./gradlew publishMavenLocal will create an ~/.m2/repository
folder in your home directory • com/sample/library/core/1.0.0/core-1.0.0.jar , *.pom and other files that are required for Maven • cp ~/.m2/repository my-app/local-maven-repo • repositories { maven { url uri('local-maven-repo') } } DroidKaigi 2025 48 -
Local Maven repository • Easy temporary solution • Storing binaries
in git is bad ◦ But we are already doing it anyway (image, assets, etc.) ◦ If you have git-lfs setup, that's great – use it! • Updating is a little bit painful • Drop the folder on each version change to not store previous versions DroidKaigi 2025 49 -
Remote Maven repository • "Big boys" approach • Storing dependencies
in a proper place • Hosting costs • Maintenance costs • Security • No builds if down DroidKaigi 2025 50 -
Remote Maven repository Options: • Sonatype Nexus Repository Manager •
JFrog Artifactory • Apache Archiva DroidKaigi 2025 51 -
Remote Maven repository Already done: • POM setup (what to
publish) is already set up and independent Required changes to the library: • Setting up a new repository • Providing secure credentials • Separating logical and infrastructure changes DroidKaigi 2025 52 -
Remote Maven repository Follow your repository publishing guidelines. publishing {
repositories { maven { name = "privateMaven" url = uri("https://my-maven-repo.com") credentials(PasswordCredentials) } } } DroidKaigi 2025 53 -
Remote Maven repository • ./gradlew publish_PublicationName_ToPrivateMavenRepository \ -PprivateMavenUsername=username \ -PprivateMavenPassword=password
• Environment variables ◦ ORG_GRADLE_PROJECT_privateMavenUsername ◦ ORG_GRADLE_PROJECT_privateMavenPassword DroidKaigi 2025 54 -
Remote Maven repository If you see: plugins { id "com.vanniktech.maven.publish"
} Utilize the following plugin task for simplicity: ./gradlew publishAllPublicationsToPrivateMavenRepository Pass the credentials in the same way. DroidKaigi 2025 55 -
Artifact signatures Execution failed for task `:core:signMavenPublication` > Cannot perform
signing task `:core:signMavenPublication` because it has no configured signatory. DroidKaigi 2025 56 -
Artifact signatures Allow verification of uploaded artifact authenticity. • Private
key creates .arc signature file for each artifact • Public key verifies signature and confirms authenticity Required by Maven Central. • Prevents attackers from uploading malware even with stolen credentials DroidKaigi 2025 57 -
Artifact signature Disable signing by removing from code: • plugins
{ id "signing" } • signing { ... } • signAllPublications() DroidKaigi 2025 58 -
Artifact signature Generate temporary signing config: • Use gpg to
create key pair • Add signing parameters: ./gradlew ... -Psigning.secretKeyRingFile=~/.gnupg/secring.gpg \ -Psigning.password=secret \ -Psigning.keyId=24875D73 Gradle documentation to signing plugin. DroidKaigi 2025 59 -
Artifact signature Remove signature Stub signature More changes in the
code base Fewer changes in the code base More problems with merging upstream Fewer problems with merging upstream DroidKaigi 2025 60 -
Git submodule • Reference to another Git repository at a
specific commit • Checked out separately in a subfolder DroidKaigi 2025 61 -
Git submodule Init flow: • git submodule add [email protected] my-lib-fork
• Use includeBuild("my-lib-fork") in settings.gradle DroidKaigi 2025 62 -
Git submodule Usage: • git clone --recursive for new clones
• git submodule update --init --recursive to initialize submodules in existing repos • git submodule update --remote to update to latest commits DroidKaigi 2025 63 -
Git submodule Possible to directly change both the fork and
the main project: • Make changes in the submodule directory • Commit changes inside the submodule • Push the submodule changes • Update the main repo to point to the new commit • Commit the submodule reference update in the main repo • Push the main repo changes DroidKaigi 2025 64 -
Git submodule • Better than binaries for frequent changes •
Git workflow adds significant complexity DroidKaigi 2025 65 -
Git subtree • Merges external repository content directly into the
main repo • Work with the code as usual • But harder to push back changes DroidKaigi 2025 66 -
Summary Hosting options: • File • Local Maven repository •
Remote Maven repository • Git submodule • Git subtree DroidKaigi 2025 67 -
Upstreaming Changes are done and tested in production. What's next?
DroidKaigi 2025 68 -
Upstreaming • Sharing is caring • You already have a
fork with changes • Reduce difference between the fork and the original DroidKaigi 2025 69 -
Upstreaming Code is owned by your employer. Seek formal legal
permission to share the changes. DroidKaigi 2025 70 -
Upstreaming • git fetch upstream • git checkout -b fix-something-branch
upstream/main • Cherry pick commits from the fork branch into the new one • Alternative: export as a patch and apply • Exclude infrastructure changes DroidKaigi 2025 71 -
Upstreaming • Follow the provided template • Explain what you
are doing • Mention any related issues: #123 DroidKaigi 2025 73 -
Upstreaming CLA – Contributor License Agreement. • Legal requirement •
"I am passing ownership of this code to you with no further legal claims" DroidKaigi 2025 74 -
Upstreaming • Might ask to change something or change it
themselves • Use "Allow maintainers to push to your branch" flag • No obligations to merge your commit • Can even redo it from scratch in their own way DroidKaigi 2025 75 -
Closed source But what if the nasty one is a
closed source library? DroidKaigi 2025 77 -
What are our options? • Reach out to engineering &
support team • Workaround on our end ◦ May break Terms of Service DroidKaigi 2025 78 -
Android component level issues • Unwanted uncontrolled initialization code in
ContentProvider • Unwanted BroadcastReceiver that crashes • Unencrypted SharedPreferences for sensitive data DroidKaigi 2025 79 -
Android component level issues Disable a component: <!-- AndroidManifest.xml -->
<provider android:name="com.thirdparty.sdk.SomeContentProvider" android:authorities="com.thirdparty.provider" android:enabled="false" tools:node="replace" /> DroidKaigi 2025 80 -
Android component level issues We can provide a custom Context
to a library: val contextWrapper = object : ContextWrapper(this) { override fun getSharedPreferences(name: String, mode: Int): SharedPreferences = TODO("Provide encrypted SharedPreferences") } Library.initialize(contextWrapper) DroidKaigi 2025 81 -
Code-level issues • Memory leaks from static variables • Incorrect
disposals DroidKaigi 2025 82 -
Reflection • Access private members by their name • No
compile-safety, can fail at runtime • Performance overhead • Obfuscation challenges DroidKaigi 2025 83 -
Reflection • Calling private methods • Fixing memory leaks •
Dumping state for analytics DroidKaigi 2025 84 -
Reflection val sdk = MySdk() // access private field val
field = sdk.javaClass.getDeclaredField("isInitialized") field.isAccessible = true Logger.log("SDK is initialized: ${field.get(sdk)}") // call private method val method = sdk.javaClass.getDeclaredMethod("dispose") method.isAccessible = true method.invoke(sdk) DroidKaigi 2025 85 -
Reflection • Reflection can invoke methods and overwrite fields •
Reflection can't change the code itself • Bytecode editing DroidKaigi 2025 86 -
Bytecode editing Col-E/Recaf • Supports both .jar and .aar •
Java decompiler • Bytecode editor • Export back DroidKaigi 2025 88 -
Our task In androidx.work:work-runtime-ktx:2.7.0 replace the default coroutine dispatcher with
IO Dispatcher. DroidKaigi 2025 89 -
CoroutineWorker package androidx.work public abstract class CoroutineWorker(...) : ListenableWorker(appContext, params)
{ /** * The coroutine context on which [doWork] will run. By default, it is [Dispatchers.Default]. */ private val coroutineContext: CoroutineDispatcher = Dispatchers.Default ... } DroidKaigi 2025 90 -
Bytecode editor aload this invokestatic - kotlinx/coroutines/Dispatchers.getDefault + kotlinx/coroutines/Dispatchers.getIO ()Lkotlinx/coroutines/CoroutineDispatcher;
putfield androidx/work/CoroutineWorker.coroutineContext Lkotlinx/coroutines/CoroutineDispatcher; DroidKaigi 2025 94 -
Hosting Options: • File • Local Maven repository • Remote
Maven repository DroidKaigi 2025 96 -
Recaf • Simple patches • LLM • "Show Kotlin Bytecode"
option in Android Studio DroidKaigi 2025 97 -
Conclusion • Be brave • Try to fix issues even
when it's not your code • Share the fixes back if possible DroidKaigi 2025 98