Handle dependency version conflict on Android

Handle dependency version conflict on Android
Photo by Andrew Rice / Unsplash

MoeGo mobile app offers in-person payment ability, powered by Stripe and Square, to our groommers. We integrate both the Stripe Terminal SDK and the Square Reader SDK, and they share some common stuffs which sometimes bring us problem.

When working on our latest release, we upgrades the Stripe Terminal React Native SDK to the latest version to support Tap to Pay on iPhone, where the native Stripe Terminal SDKs on both Android and iOS are upgraded. This works fine on iOS, but the app starts crashing on Android when connecting to Sqaure reader!

Inspect dependencies

Let's see the crash log first:

2023-04-26 13:15:07.059 23949-23949/? E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.moement.moego.business, PID: 23949
    java.lang.NoSuchMethodError: No static method createRuntimeMessageAdapter(Ljava/lang/Class;)Lcom/squareup/wire/internal/RuntimeMessageAdapter; in class Lcom/squareup/wire/internal/ReflectionKt; or its super classes (declaration of 'com.squareup.wire.internal.ReflectionKt' appears in /data/app/~~sA_8QsQq_POvPmiiiyky5Q==/com.moement.moego.business-egP-SXVX5YwQGid8AXqG7w==/base.apk!classes12.dex)
        at com.squareup.wire.WireTypeAdapterFactory.create(WireTypeAdapterFactory.kt:77)
        at com.google.gson.Gson.getAdapter(Gson.java:556)
        at retrofit2.converter.gson.GsonConverterFactory.requestBodyConverter(GsonConverterFactory.java:74)
        at retrofit2.Retrofit.nextRequestBodyConverter(Retrofit.java:315)
        at retrofit2.Retrofit.requestBodyConverter(Retrofit.java:293)
        at retrofit2.RequestFactory$Builder.parseParameterAnnotation(RequestFactory.java:778)
        at retrofit2.RequestFactory$Builder.parseParameter(RequestFactory.java:325)
        at retrofit2.RequestFactory$Builder.build(RequestFactory.java:206)
        at retrofit2.RequestFactory.parseAnnotations(RequestFactory.java:67)
        at retrofit2.ServiceMethod.parseAnnotations(ServiceMethod.java:26)
        at retrofit2.Retrofit.loadServiceMethod(Retrofit.java:202)
        at retrofit2.Retrofit$1.invoke(Retrofit.java:160)
        at java.lang.reflect.Proxy.invoke(Proxy.java:1006)
        at $Proxy7.login(Unknown Source)
        at com.squareup.sdk.reader.authorization.ReaderSdkAuthenticator.login(ReaderSdkAuthenticator.kt:116)
        at com.squareup.sdk.reader.authorization.RealAuthorizationManager.authorize(RealAuthorizationManager.java:109)
        at com.squareup.sdk.reader.react.AuthorizationModule$2.run(AuthorizationModule.java:117)
        at android.os.Handler.handleCallback(Handler.java:938)
        at android.os.Handler.dispatchMessage(Handler.java:99)
        at android.os.Looper.loop(Looper.java:246)
        at android.app.ActivityThread.main(ActivityThread.java:8633)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:602)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1130)

To find out what's wrong, we should open the Android project with the Android Studio. Double-hit the Shift key and search for WireTypeAdapterFactory, which is complaining No static method createRuntimeMessageAdapter:

The WireTypeAdapterFactory.kt coming from wire-gson-support-3.7.0 is calling the createRuntimeMessageAdapter(Class<*>), which is provided in wire-runtime:3.7.0.

But wait, if we switch to the Project view, we will find that there are 2 wire-runtime dependencies of different versions. In wire-runtime-jvm:4.4.3, the single-parameter version of createRuntimeMessageAdapter is replaced by a two-parameter one!

In Java world, every class is given a package name, and two classes with the same name inside the same package lead to the compile error (the famous duplicate class error). That means we can't have two different versions of the same dependency in our app, though it is common in Node.js projects.

To figure out which version is chosen, we open the terminal and run the following command under the android directory:

./gradlew :app:dependencyInsight --configuration debugRuntimeClasspath --dependency wire-runtime

This will execute a Gradle task to tell us the detail about dependencies matching wire-runtime pattern. We choose debugRuntimeClasspath instead of debugCompileClasspath to get the full dependency tree vision.

com.squareup.wire:wire-runtime:4.4.3
+--- com.squareup.wire:wire-moshi-adapter:4.4.3
|    \--- com.stripe:stripeterminal-core:2.17.1
|         \--- com.stripe:stripeterminal:2.17.1
|              +--- debugRuntimeClasspath (requested com.stripe:stripeterminal:2.8.0)
|              \--- project :moego_stripe-terminal-react-native
|                   \--- debugRuntimeClasspath
\--- com.stripe:stripeterminal-core:2.17.1 (*)
​
com.squareup.wire:wire-runtime:2.2.0 -> 4.4.3
\--- com.squareup.retrofit2:converter-wire:2.7.2
     \--- com.squareup.sdk.reader:reader-sdk-xxxxxxxx:1.5.6
          \--- com.squareup.sdk.reader:reader-sdk-xxxxxxxx:1.5.6
               \--- project :react-native-square-reader-sdk
                    \--- debugRuntimeClasspath
​
com.squareup.wire:wire-runtime:3.7.0 -> 4.4.3
+--- com.squareup.sdk.reader:reader-sdk-xxxxxxxx:1.5.6
|    \--- com.squareup.sdk.reader:reader-sdk-xxxxxxxx:1.5.6
|         \--- project :react-native-square-reader-sdk
|              \--- debugRuntimeClasspath
\--- com.squareup.wire:wire-gson-support:3.7.0
     \--- com.squareup.sdk.reader:reader-sdk-xxxxxxxx:1.5.6 (*)

From the output we are told that wire-gson-support:3.7.0 depends on wire-runtime:3.7.0, but the latter is pulled up to 4.4.3 which breaks our app.

The normal approach: stick to the previous version

In most cases, we could just force Gradle to use the specific version of some dependency. We checkout the app code to the revision where the Tap to Pay on iPhone is not introduced yet, and run the dependency insight task again to find the "safe" version of wire-runtime: 4.0.0.

To lock a dependency to some version, edit android/app/build.gradle and add the following line to the dependencies block:

dependencies {
  implementation ('com.squareup.wire:wire-runtime:4.0.0') {
    version {
      strictly '4.0.0'
    }
  }
  // Other code...
}

Now the Square reader connection should work as expected, but if we are trying to discover the Stripe readers...

Caused by: java.lang.NoSuchMethodError: No static method createRuntimeMessageAdapter(Ljava/lang/Class;Z)Lcom/squareup/wire/internal/RuntimeMessageAdapter; in class Lcom/squareup/wire/internal/ReflectionKt; or its super classes (declaration of 'com.squareup.wire.internal.ReflectionKt' appears in /data/app/~~SjNakX7DciNr401J_dIdhA==/com.moement.moego.business-eVKkHNcBJoM42vrVJOJHng==/base.apk!classes30.dex)
                  at com.squareup.wire.WireJsonAdapterFactory.create(WireJsonAdapterFactory.kt:82)
                  at com.squareup.moshi.Moshi.adapter(Moshi.java:146)
                  at com.squareup.moshi.Moshi.adapter(Moshi.java:106)
                  at com.squareup.moshi.Moshi.adapter(Moshi.java:80)
                  at com.stripe.core.redaction.Extensions.toLogJson(Extensions.kt:20)
                  at com.stripe.core.redaction.Extensions.toLogJson$default(Extensions.kt:10)
                  at com.stripe.stripeterminal.internal.common.api.ApiLogPointInterceptor.intercept(ApiLogPointInterceptor.kt:26)
                  at com.stripe.core.restclient.InterceptorChain.proceed(InterceptorChain.kt:46)
                  at com.stripe.core.restclient.RestClient.execute(RestClient.kt:115)
                  at com.stripe.core.restclient.RestClient.blockingPost(RestClient.kt:57)
                  at com.stripe.proto.api.rest.MainlandApi.discoverLocations(MainlandApi.kt:336)
                  at com.stripe.proto.api.rest.MainlandApi.discoverLocations$default(MainlandApi.kt:327)
                  at com.stripe.core.transaction.AuthenticatedRestClient.discoverLocations(AuthenticatedRestClient.kt:233)
                  at com.stripe.stripeterminal.internal.common.api.ApiClient.discoverLocations(ApiClient.kt:162)
                  at com.stripe.stripeterminal.internal.common.resourcerepository.OnlineDirectResourceRepository.getReaderLocations(OnlineDirectResourceRepository.kt:832)
                  at com.stripe.stripeterminal.internal.common.resourcerepository.DirectResourceRepositoryRouter.getReaderLocations(DirectResourceRepositoryRouter.kt:173)
                  at com.stripe.stripeterminal.internal.common.resourcerepository.ProxyResourceRepository$getReaderLocations$1.invoke(ProxyResourceRepository.kt:126)
                  at com.stripe.stripeterminal.internal.common.resourcerepository.ProxyResourceRepository$getReaderLocations$1.invoke(ProxyResourceRepository.kt:126)
                  at com.stripe.stripeterminal.internal.common.resourcerepository.ProxyResourceRepository.withCurrentRepository(ProxyResourceRepository.kt:133)
                  at com.stripe.stripeterminal.internal.common.resourcerepository.ProxyResourceRepository.getReaderLocations(ProxyResourceRepository.kt:126)
                  at com.stripe.stripeterminal.internal.common.adapter.BbposAdapterLegacy.getLocationsForDiscovery(BbposAdapterLegacy.kt:805)
                  at com.stripe.stripeterminal.internal.common.adapter.BbposBluetoothAdapterLegacy.getLocationsForDiscovery(BbposBluetoothAdapterLegacy.kt:225)
15:43:24.244  E   at com.stripe.stripeterminal.internal.common.adapter.BbposBluetoothAdapterLegacy$DiscoverBluetoothReadersOperation.onUpdateDiscoveredReaders$lambda$6(BbposBluetoothAdapterLegacy.kt:577)
                  at com.stripe.stripeterminal.internal.common.adapter.BbposBluetoothAdapterLegacy$DiscoverBluetoothReadersOperation.lambda$OfuxcO_kCKP9oiD0iKlAxYfWlLs(Unknown Source:0)
                  at com.stripe.stripeterminal.internal.common.adapter.-$$Lambda$BbposBluetoothAdapterLegacy$DiscoverBluetoothReadersOperation$OfuxcO_kCKP9oiD0iKlAxYfWlLs.run(Unknown Source:6)
                  at io.reactivex.rxjava3.internal.schedulers.ScheduledDirectTask.call(ScheduledDirectTask.java:38)
                  ... 5 more

Ok, it turns out that Stripe Terminal SDK requires the latest version of wire-runtime to work properly!

Since the Tap to Pay on iPhone feature is only implemented by the iOS edition of the Stripe Terminal SDK, what about locking the version of Stripe Terminal Android SDK? In previous revision, our app depends on com.stripe:stripeterminal:2.14.0 which works well with wire-runtime:4.0.0.

We also give it a shot. This time the app doesn't crash, but behavior not very well... Open the Logcat view of Android Studio we may find some errors like this one:

16:33:30.975  I  Rejecting re-init on previously-failed class java.lang.Class<com.stripeterminalreactnative.MappersKt$WhenMappings>: java.lang.NoSuchFieldError: No static field WISEPAD_3S of type Lcom/stripe/stripeterminal/external/models/DeviceType; in class Lcom/stripe/stripeterminal/external/models/DeviceType; or its superclasses (declaration of 'com.stripe.stripeterminal.external.models.DeviceType' appears in /data/app/~~knBXOkTcLhdocfPMV5Um2g==/com.moement.moego.business-yk53x66eCZZZdbYD2X1n7w==/base.apk!classes31.dex)
                 (Throwable with no stack trace)
16:33:30.977  E  com.stripeterminalreactnative.MappersKt$WhenMappings
                 java.lang.NoClassDefFoundError: com.stripeterminalreactnative.MappersKt$WhenMappings
                  at com.stripeterminalreactnative.MappersKt.mapFromDeviceType(Mappers.kt:113)
                  at com.stripeterminalreactnative.MappersKt$mapFromReader$1.invoke(Mappers.kt:82)
                  at com.stripeterminalreactnative.MappersKt$mapFromReader$1.invoke(Mappers.kt:76)
                  at com.stripeterminalreactnative.MappersKt.nativeMapOf(Mappers.kt:63)
                  at com.stripeterminalreactnative.MappersKt.mapFromReader(Mappers.kt:76)
                  at com.stripeterminalreactnative.MappersKt$mapFromReaders$1.invoke(Mappers.kt:74)
                  at com.stripeterminalreactnative.MappersKt$mapFromReaders$1.invoke(Mappers.kt:74)
                  at com.stripeterminalreactnative.MappersKt.collectToWritableArray(Mappers.kt:448)
                  at com.stripeterminalreactnative.MappersKt.mapFromReaders(Mappers.kt:74)
                  at com.stripeterminalreactnative.listener.RNDiscoveryListener$onUpdateDiscoveredReaders$1.invoke(RNDiscoveryListener.kt:29)
                  at com.stripeterminalreactnative.listener.RNDiscoveryListener$onUpdateDiscoveredReaders$1.invoke(RNDiscoveryListener.kt:28)
                  at com.stripeterminalreactnative.ReactExtensions$sendEvent$1$1.invoke(ReactExtensions.kt:16)
                  at com.stripeterminalreactnative.ReactExtensions$sendEvent$1$1.invoke(ReactExtensions.kt:15)
                  at com.stripeterminalreactnative.MappersKt.nativeMapOf(Mappers.kt:63)
                  at com.stripeterminalreactnative.ReactExtensions.sendEvent(ReactExtensions.kt:15)
                  at com.stripeterminalreactnative.listener.RNDiscoveryListener.onUpdateDiscoveredReaders(RNDiscoveryListener.kt:28)
                  at com.stripe.stripeterminal.internal.common.callable.ProxyDiscoveryListener.onUpdateDiscoveredReaders(ProxyDiscoveryListener.kt:17)
                  at com.stripe.stripeterminal.adapter.RemoteReaderAdapter$DiscoverReadersOperation.execute(RemoteReaderAdapter.kt:153)
                  at com.stripe.stripeterminal.adapter.RemoteReaderAdapter$DiscoverReadersOperation.execute(RemoteReaderAdapter.kt:147)
                  at com.stripe.stripeterminal.adapter.RemoteReaderAdapter.discoverReaders(RemoteReaderAdapter.kt:128)
                  at com.stripe.stripeterminal.adapter.ProxyAdapter.discoverReaders(ProxyAdapter.kt:230)
                  at com.stripe.stripeterminal.TerminalSession$DiscoverReadersOperation.executeIfNotCanceled(TerminalSession.kt:1443)
                  at com.stripe.stripeterminal.TerminalSession$CancelableOperation.execute(TerminalSession.kt:693)
                  at com.stripe.stripeterminal.TerminalSession$ExternalOperation.run$core_publish(TerminalSession.kt:651)
                  at com.stripe.stripeterminal.TerminalSession.enqueueOperation$lambda-2(TerminalSession.kt:513)
                  at com.stripe.stripeterminal.TerminalSession.lambda$JON2uE3bqI6fqJ9Sw06Ciz8JRZw(Unknown Source:0)
                  at com.stripe.stripeterminal.-$$Lambda$TerminalSession$JON2uE3bqI6fqJ9Sw06Ciz8JRZw.run(Unknown Source:4)
                  at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:463)
                  at java.util.concurrent.FutureTask.run(FutureTask.java:264)
                  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1137)
                  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:637)
                  at java.lang.Thread.run(Thread.java:1012)
                 Caused by: java.lang.NoSuchFieldError: No static field WISEPAD_3S of type Lcom/stripe/stripeterminal/external/models/DeviceType; in class Lcom/stripe/stripeterminal/external/models/DeviceType; or its superclasses (declaration of 'com.stripe.stripeterminal.external.models.DeviceType' appears in /data/app/~~knBXOkTcLhdocfPMV5Um2g==/com.moement.moego.business-yk53x66eCZZZdbYD2X1n7w==/base.apk!classes31.dex)
                  at com.stripeterminalreactnative.MappersKt$WhenMappings.<clinit>(Unknown Source:135)
                  at com.stripeterminalreactnative.MappersKt.mapFromDeviceType(Mappers.kt:113) 
                  at com.stripeterminalreactnative.MappersKt$mapFromReader$1.invoke(Mappers.kt:82) 
                  at com.stripeterminalreactnative.MappersKt$mapFromReader$1.invoke(Mappers.kt:76) 
                  at com.stripeterminalreactnative.MappersKt.nativeMapOf(Mappers.kt:63) 
                  at com.stripeterminalreactnative.MappersKt.mapFromReader(Mappers.kt:76) 
                  at com.stripeterminalreactnative.MappersKt$mapFromReaders$1.invoke(Mappers.kt:74) 
                  at com.stripeterminalreactnative.MappersKt$mapFromReaders$1.invoke(Mappers.kt:74) 
                  at com.stripeterminalreactnative.MappersKt.collectToWritableArray(Mappers.kt:448) 
                  at com.stripeterminalreactnative.MappersKt.mapFromReaders(Mappers.kt:74) 
                  at com.stripeterminalreactnative.listener.RNDiscoveryListener$onUpdateDiscoveredReaders$1.invoke(RNDiscoveryListener.kt:29) 
                  at com.stripeterminalreactnative.listener.RNDiscoveryListener$onUpdateDiscoveredReaders$1.invoke(RNDiscoveryListener.kt:28) 
                  at com.stripeterminalreactnative.ReactExtensions$sendEvent$1$1.invoke(ReactExtensions.kt:16) 
                  at com.stripeterminalreactnative.ReactExtensions$sendEvent$1$1.invoke(ReactExtensions.kt:15) 
                  at com.stripeterminalreactnative.MappersKt.nativeMapOf(Mappers.kt:63) 
                  at com.stripeterminalreactnative.ReactExtensions.sendEvent(ReactExtensions.kt:15) 
                  at com.stripeterminalreactnative.listener.RNDiscoveryListener.onUpdateDiscoveredReaders(RNDiscoveryListener.kt:28) 
                  at com.stripe.stripeterminal.internal.common.callable.ProxyDiscoveryListener.onUpdateDiscoveredReaders(ProxyDiscoveryListener.kt:17) 
                  at com.stripe.stripeterminal.adapter.RemoteReaderAdapter$DiscoverReadersOperation.execute(RemoteReaderAdapter.kt:153) 
                  at com.stripe.stripeterminal.adapter.RemoteReaderAdapter$DiscoverReadersOperation.execute(RemoteReaderAdapter.kt:147) 
                  at com.stripe.stripeterminal.adapter.RemoteReaderAdapter.discoverReaders(RemoteReaderAdapter.kt:128) 
                  at com.stripe.stripeterminal.adapter.ProxyAdapter.discoverReaders(ProxyAdapter.kt:230) 
                  at com.stripe.stripeterminal.TerminalSession$DiscoverReadersOperation.executeIfNotCanceled(TerminalSession.kt:1443) 
                  at com.stripe.stripeterminal.TerminalSession$CancelableOperation.execute(TerminalSession.kt:693) 
                  at com.stripe.stripeterminal.TerminalSession$ExternalOperation.run$core_publish(TerminalSession.kt:651) 
                  at com.stripe.stripeterminal.TerminalSession.enqueueOperation$lambda-2(TerminalSession.kt:513) 
                  at com.stripe.stripeterminal.TerminalSession.lambda$JON2uE3bqI6fqJ9Sw06Ciz8JRZw(Unknown Source:0) 
                  at com.stripe.stripeterminal.-$$Lambda$TerminalSession$JON2uE3bqI6fqJ9Sw06Ciz8JRZw.run(Unknown Source:4) 
                  at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:463) 
                  at java.util.concurrent.FutureTask.run(FutureTask.java:264) 
                  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1137) 
                  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:637) 
                  at java.lang.Thread.run(Thread.java:1012) 

That's because the Android native part of the Stripe Terminal React Native SDK depends on the updated Stripe Terminal Android SDK (only some enums, though). To fix this issue, we have to fork the RN SDK and drop those usages.

Locking com.stripe:stripeterminal's version has another great downside: we will never receive the latest features and bugfixes from the Stripe official!

The hacky approach: modify wire-gson-support

As we've learnt above, wire-gson-support:3.7.0, required by Square Reader SDK, depends on wire-runtime:3.7.0. And fortunately, the wire project is open source.

We clone the wire project and checkout the 3.7.0 tag.

On v4.4.3, here's the signature of the method:

// wire-library/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/reflection.kt
fun <M : Message<M, B>, B : Message.Builder<M, B>> createRuntimeMessageAdapter(
  messageType: Class<M>,
  writeIdentityValues: Boolean,
): RuntimeMessageAdapter<M, B> {
  // ...
}

There's only one added parameter. We copy the signature to the v3.7.0 code and update the usages. Then, run the Gradle task to build the jar file:

../gradlew :wire-gson-support:assemble

We will get the jar file under wire-library/wire-gson-support/build/libs/wire-gson-support-3.7.0.jar. The javadoc and sources jars can be safely ignored.

As the last step, we copy to jar file into our RN project's android/app/libs/ directory and tell Gradle not to use the original wire-gson-support (or we will get duplicate classes error!). Modify android/app/build.gradle and add:

configurations {
  implementation {
    exclude group: 'com.squareup.wire', module: 'wire-gson-support'
  }
}

Now build and test the app!

The caveat

We have to remember in mind that the solution above is just a workaround. Our app may still be broken if the new version of wire-runtime introduces some breaking change and the old wire-gson-support can't play with it. We need to carefully review all the usages of wire-runtime by wire-gson-support to ensure that all incompatibilities are handled. And this approach is only possible when we can easily grab the source code of some dependency.