Skip to content

stripe_android ships stub com.facebook.react.* classes that conflict with apps using real React Native (e.g. CometChat) #2349

@RazvanTamazlicariu

Description

@RazvanTamazlicariu

Describe the problem

The stripe_android Flutter plugin (tested with v12.1.0 and v10.0.0) ships ~30 stub source files under com.facebook.react.* in its android/src/main/kotlin/com/facebook/ directory (e.g. UiThreadUtil.kt, MapBuilder.kt, ReactContext.kt, ReactApplicationContext.java, Arguments.java, ReadableMap.java, etc.).

These stubs are minimal shims used internally by Stripe's React Native SDK bridge code (com.reactnativestripesdk.*). However, they have incompatible method signatures compared to the real React Native library (com.facebook.react:react-android).

When another SDK in the same app depends on the real React Native (e.g. CometChat Calls SDK depends on react-android:0.77.2), the duplicate classes cause fatal crashes at runtime because the dex merger silently picks one version, and whichever loses is broken:

  • If Stripe's stubs win → CometChat crashes with NoSuchMethodError (e.g. UiThreadUtil.runOnUiThread(Runnable)Z or MapBuilder.builder())
  • If real RN wins → Stripe crashes with PlatformException: Stripe SDK did not initialize (because the real ReactApplicationContext has different constructors than Stripe's stub)

What was the expected behavior?

The stripe_android plugin should not ship classes under the com.facebook.react package namespace, as this conflicts with any app that also uses the real React Native library. The stubs should either:

  1. Be relocated to a Stripe-specific package (e.g. com.stripe.compat.react.*)
  2. Use compileOnly dependencies on the real React Native artifact instead of stub source files
  3. Be isolated in some other way that prevents classpath collisions

Reproduction

  • Step 1: Create a Flutter app with both flutter_stripe: ^12.1.0 and cometchat_calls_sdk: ^4.2.1 (or any SDK that depends on com.facebook.react:react-android)
  • Step 2: Build and run on Android (debug or release)
  • Step 3: Trigger CometChat call initialization → app crashes

Crash 1 – Stripe's UiThreadUtil.kt stub wins the dex merge (no @JvmStatic bridge methods):

java.lang.NoSuchMethodError: No static method runOnUiThread(Ljava/lang/Runnable;)Z
  in class Lcom/facebook/react/bridge/UiThreadUtil;
    at com.facebook.react.modules.core.ReactChoreographer.<init>(ReactChoreographer.kt:71)
    at com.facebook.react.ReactInstanceManager.<init>(ReactInstanceManager.java:315)
    at com.cometchat.calls.helpers.RNHelper.initReactInstanceManager(RNHelper.java:247)

Crash 2 – Stripe's MapBuilder.kt stub wins (no builder() method):

java.lang.NoSuchMethodError: No static method builder()Lcom/facebook/react/common/MapBuilder$Builder;
  in class Lcom/facebook/react/common/MapBuilder;
    at com.facebook.react.ReactAndroidHWInputDeviceHelper.<clinit>(ReactAndroidHWInputDeviceHelper.java:25)
    at com.facebook.react.ReactRootView.<init>(ReactRootView.java:108)
    at com.cometchat.calls.helpers.RNHelper.getView(RNHelper.java:269)

Root cause confirmed by inspecting the APK:

  • react-android:0.77.2 JAR contains UiThreadUtil.class compiled from Java with public static boolean runOnUiThread(Runnable) (descriptor (Ljava/lang/Runnable;)Z)
  • The APK's classes2.dex instead contains Stripe's Kotlin stub UiThreadUtil which has only a Companion field and access$getHandler$cp() — no static runOnUiThread method at all

Workaround: Use ASM ClassRemapper in a Gradle doLast hook to relocate all com/facebook/** classes in stripe_android's output JARs to com/stripe/compat/facebook/**, so both Stripe's stubs and the real React Native can coexist:

// In android/build.gradle (root)
import org.objectweb.asm.*
import org.objectweb.asm.commons.*

def relocateReactClassesInJar(File jarFile) {
    def OLD_PREFIX = 'com/facebook/'
    def NEW_PREFIX = 'com/stripe/compat/facebook/'
    def remapper = new Remapper() {
        @Override String map(String internalName) {
            if (internalName.startsWith(OLD_PREFIX)) {
                return NEW_PREFIX + internalName.substring(OLD_PREFIX.length())
            }
            return internalName
        }
    }
    def tmpFile = new File(jarFile.parentFile, jarFile.name + '.tmp')
    new java.util.zip.ZipOutputStream(tmpFile.newOutputStream()).withCloseable { zos ->
        new java.util.zip.ZipFile(jarFile).withCloseable { zin ->
            def entries = zin.entries()
            while (entries.hasMoreElements()) {
                def entry = entries.nextElement()
                if (entry.directory) {
                    def dirName = entry.name.startsWith(OLD_PREFIX) ?
                        NEW_PREFIX + entry.name.substring(OLD_PREFIX.length()) : entry.name
                    zos.putNextEntry(new java.util.zip.ZipEntry(dirName))
                    zos.closeEntry()
                } else if (entry.name.endsWith('.class')) {
                    def bytes = zin.getInputStream(entry).bytes
                    def reader = new ClassReader(bytes)
                    def writer = new ClassWriter(0)
                    reader.accept(new ClassRemapper(writer, remapper), 0)
                    def newName = entry.name.startsWith(OLD_PREFIX) ?
                        NEW_PREFIX + entry.name.substring(OLD_PREFIX.length()) : entry.name
                    zos.putNextEntry(new java.util.zip.ZipEntry(newName))
                    zos.write(writer.toByteArray())
                    zos.closeEntry()
                } else {
                    zos.putNextEntry(new java.util.zip.ZipEntry(entry.name))
                    zin.getInputStream(entry).withCloseable { is -> zos << is }
                    zos.closeEntry()
                }
            }
        }
    }
    jarFile.delete()
    tmpFile.renameTo(jarFile)
}

subprojects { sub ->
    if (sub.name == 'stripe_android') {
        sub.afterEvaluate {
            sub.tasks.matching {
                it.name.contains('bundleLibRuntimeToJar') ||
                it.name.contains('bundleLibCompileToJar')
            }.configureEach { task ->
                task.doLast {
                    task.outputs.files.each { file ->
                        if (file.name.endsWith('.jar') && file.exists()) {
                            relocateReactClassesInJar(file)
                        }
                    }
                }
            }
        }
    }
}

Environment

  • Version used: flutter_stripe: 12.1.0 / stripe_android: 12.1.0 (also reproduced with stripe_android: 10.0.0)
  • Other modules/plugins/libraries that might be involved: cometchat_calls_sdk: 4.2.1 (depends on com.cometchat:calls-sdk-android:4.3.3 which depends on com.facebook.react:react-android:0.77.2)
  • Flutter: 3.35.0+, Dart 3.9.0+
  • AGP: 8.1+
  • Android: debug and release builds affected

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions