From 13cc015d5b9aad62f467ec261f735fe9cbc1c9df Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Sat, 7 Nov 2026 06:32:36 +0000 Subject: [PATCH] DID-10135: [FIX-RN-R8] Android: no launch crash under R8 optimization (ReactModuleInfo looked up by reflection) --- .github/scripts/android-release-smoke.sh | 93 +++++++++++++++++++ .github/workflows/ci.yml | 84 +++++++++++++++++ android/build.gradle | 1 + android/consumer-rules.pro | 7 ++ .../sdkreactnative/SdkReactNativePackage.kt | 27 ++---- example/android/app/build.gradle | 6 +- .../didit/authreactnative/DiditAuthPackage.kt | 25 ++--- .../android-r8-consumer-rules.test.ts | 64 +++++++++++++ 8 files changed, 269 insertions(+), 38 deletions(-) create mode 100755 .github/scripts/android-release-smoke.sh create mode 100644 android/consumer-rules.pro create mode 100644 src/__tests__/android-r8-consumer-rules.test.ts diff --git a/.github/scripts/android-release-smoke.sh b/.github/scripts/android-release-smoke.sh new file mode 100755 index 0000000..c0a033a --- /dev/null +++ b/.github/scripts/android-release-smoke.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Launches the example's minified release APK on a running emulator and starts +# a verification through the TurboModule. Fails when the app crashes or never +# shows its screen. Screenshots and logs are written to the output directory. +# +# Usage: android-release-smoke.sh +set -euo pipefail + +APK="$1" +OUT="$2" +PACKAGE=sdkreactnative.example +mkdir -p "$OUT" + +save_logs() { + adb logcat -d >"$OUT/logcat.txt" 2>&1 || true + adb logcat -b crash -d >"$OUT/crash.txt" 2>&1 || true +} +trap save_logs EXIT + +fail() { + echo "::error::$1" + adb exec-out screencap -p >"$OUT/failure.png" || true + dump_ui >"$OUT/failure-ui.xml" || true + exit 1 +} + +# Prints the current view hierarchy; empty when the dump did not complete. +dump_ui() { + adb shell rm -f /sdcard/ui.xml + adb shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true + adb shell cat /sdcard/ui.xml 2>/dev/null || true +} + +# Waits until the screen shows an element with exactly this text. +wait_for_text() { + local deadline=$((SECONDS + $2)) + while ((SECONDS < deadline)); do + # Not grep -q: exiting early would fail the pipe under pipefail. + if dump_ui | grep -F "text=\"$1\"" >/dev/null; then + return 0 + fi + sleep 2 + done + return 1 +} + +# Taps the center of the first element whose attributes contain the match, +# retrying while the view hierarchy dump comes back empty. +tap() { + local bounds="" + for _ in 1 2 3 4 5; do + bounds=$(dump_ui | tr '>' '\n' | grep -m 1 -F "$1" | + sed -nE 's/.*bounds="\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\]".*/\1 \2 \3 \4/p' || true) + [ -n "$bounds" ] && break + sleep 2 + done + [ -n "$bounds" ] || fail "No element matching $1 on screen" + # shellcheck disable=SC2086 + set -- $bounds + adb shell input tap $((($1 + $3) / 2)) $((($2 + $4) / 2)) +} + +# Only crashes of the example count; other emulator processes may crash. +assert_running() { + adb shell pidof "$PACKAGE" >/dev/null || fail "$PACKAGE is not running $1" + if adb logcat -b crash -d | grep -F "$PACKAGE" >/dev/null; then + adb logcat -b crash -d + fail "$PACKAGE crashed $1" + fi +} + +adb install -r "$APK" +adb logcat -b all -c + +# No network: the check covers launching and the native call, not the API. +adb shell cmd connectivity airplane-mode enable || true + +adb shell am start -W -n "$PACKAGE/.MainActivity" +wait_for_text "Didit SDK Example" 120 || fail "The example screen never appeared" +adb exec-out screencap -p >"$OUT/01-launched.png" +assert_running "after launch" + +tap 'class="android.widget.EditText"' +adb shell input text r8-smoke-token +tap 'text="Start Verification"' + +# The module resolves the call with a failed result, which the example shows +# as an alert. +wait_for_text "Verification Failed" 120 || fail "The verification call never returned" +adb exec-out screencap -p >"$OUT/02-verification-returned.png" +assert_running "after starting a verification" + +echo "The minified release launched and the verification module answered." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f30431a..9f73553 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,6 +134,90 @@ jobs: run: | yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" + android-release-minified: + name: Minified Android release launches + runs-on: ubuntu-latest + timeout-minutes: 75 + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Setup + uses: ./.github/actions/setup + + - name: Select released native SDK for compatibility check + run: | + node - <<'JS' + const fs = require('fs'); + const pkg = require('./package.json'); + delete pkg.diditNativeSdkVersions.source; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + JS + + - name: Install JDK + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: 'zulu' + java-version: '17' + + - name: Finalize Android SDK + run: | + /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" + + - name: Cache Gradle + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + ~/.gradle/wrapper + ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # The example's release build type runs R8 with + # proguard-android-optimize.txt, the configuration that crashed host + # apps at launch (issue #61). + - name: Build minified release APK + working-directory: example/android + run: | + ./gradlew :app:assembleRelease --no-daemon --console=plain \ + -PreactNativeArchitectures=x86_64 \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=1g" + + - name: Check the library's R8 rules reached the app + run: | + CONFIGURATION=example/android/app/build/outputs/mapping/release/configuration.txt + test -f "$CONFIGURATION" || { ls -R example/android/app/build/outputs; exit 1; } + grep -F -- '-keep class com.facebook.react.module.model.ReactModuleInfo { public (...); }' "$CONFIGURATION" + mkdir -p "$RUNNER_TEMP/android-release-smoke" + cp "$CONFIGURATION" "$RUNNER_TEMP/android-release-smoke/r8-configuration.txt" + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Launch the minified release on an emulator + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: Nexus 6 + disable-animations: true + emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none + script: bash .github/scripts/android-release-smoke.sh example/android/app/build/outputs/apk/release/app-release.apk "$RUNNER_TEMP/android-release-smoke" + + - name: Upload screenshots and logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: android-release-smoke + path: ${{ runner.temp }}/android-release-smoke + if-no-files-found: ignore + build-ios: name: Released iOS compatibility and bridge tests runs-on: macos-latest diff --git a/android/build.gradle b/android/build.gradle index 6ffaa99..cb44cf9 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -85,6 +85,7 @@ android { defaultConfig { minSdkVersion getExtOrDefault("minSdkVersion") targetSdkVersion getExtOrDefault("targetSdkVersion") + consumerProguardFiles "consumer-rules.pro" } buildFeatures { diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro new file mode 100644 index 0000000..de4d764 --- /dev/null +++ b/android/consumer-rules.pro @@ -0,0 +1,7 @@ +# R8/ProGuard rules applied to every app that depends on this library. + +# Keep ReactModuleInfo's public constructors as declared. R8's optimizing pass +# otherwise drops unused constructor parameters, and code that picks a +# constructor by its parameter count, as earlier releases of this package did, +# then crashes the app at launch (issue #61). +-keep class com.facebook.react.module.model.ReactModuleInfo { public (...); } diff --git a/android/src/main/java/com/sdkreactnative/SdkReactNativePackage.kt b/android/src/main/java/com/sdkreactnative/SdkReactNativePackage.kt index bc71542..4cf2bff 100644 --- a/android/src/main/java/com/sdkreactnative/SdkReactNativePackage.kt +++ b/android/src/main/java/com/sdkreactnative/SdkReactNativePackage.kt @@ -15,31 +15,22 @@ class SdkReactNativePackage : BaseReactPackage() { } } + // Direct call, never a reflective lookup: R8's optimizing pass rewrites + // ReactModuleInfo's constructors, so counting their parameters at runtime + // crashed minified apps on launch (issue #61). This 6-parameter constructor + // exists in every supported React Native release (0.76+), and the library + // compiles inside the host build, so a React Native that dropped it fails + // that build instead of the app. override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { - val constructors = ReactModuleInfo::class.java.constructors - val sevenParamCtor = constructors.firstOrNull { it.parameterTypes.size == 7 } - val info = if (sevenParamCtor != null) { - // RN 0.76–0.78: constructor includes hasConstants parameter - sevenParamCtor.newInstance( + mapOf( + SdkReactNativeModule.NAME to ReactModuleInfo( SdkReactNativeModule.NAME, SdkReactNativeModule.NAME, false, // canOverrideExistingModule false, // needsEagerInit - false, // hasConstants false, // isCxxModule true // isTurboModule ) - } else { - // RN 0.79+: hasConstants was removed - constructors.first { it.parameterTypes.size == 6 }.newInstance( - SdkReactNativeModule.NAME, - SdkReactNativeModule.NAME, - false, // canOverrideExistingModule - false, // needsEagerInit - false, // isCxxModule - true // isTurboModule - ) - } as ReactModuleInfo - mapOf(SdkReactNativeModule.NAME to info) + ) } } diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 769a2da..d89a1e7 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -56,8 +56,10 @@ react { /** * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + * On, with the optimizing default rules, so CI launches the release build the + * way a host app with R8 enabled ships it. */ -def enableProguardInReleaseBuilds = false +def enableProguardInReleaseBuilds = true /** * The preferred build flavor of JavaScriptCore (JSC) @@ -102,7 +104,7 @@ android { // see https://reactnative.dev/docs/signed-apk-android. signingConfig signingConfigs.debug minifyEnabled enableProguardInReleaseBuilds - proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" } } } diff --git a/packages/auth-react-native/android/src/main/java/me/didit/authreactnative/DiditAuthPackage.kt b/packages/auth-react-native/android/src/main/java/me/didit/authreactnative/DiditAuthPackage.kt index 958493d..1cfa810 100644 --- a/packages/auth-react-native/android/src/main/java/me/didit/authreactnative/DiditAuthPackage.kt +++ b/packages/auth-react-native/android/src/main/java/me/didit/authreactnative/DiditAuthPackage.kt @@ -10,31 +10,20 @@ class DiditAuthPackage : BaseReactPackage() { override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = if (name == DiditAuthModule.NAME) DiditAuthModule(reactContext) else null + // Direct call, never a reflective lookup: R8's optimizing pass rewrites + // ReactModuleInfo's constructors, so counting their parameters at runtime + // crashed minified apps on launch. This 6-parameter constructor exists in + // every React Native release this package supports. override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { - val constructors = ReactModuleInfo::class.java.constructors - val sevenParamCtor = constructors.firstOrNull { it.parameterTypes.size == 7 } - val info = if (sevenParamCtor != null) { - // RN 0.77-0.78 includes hasConstants; newer versions may retain this overload. - sevenParamCtor.newInstance( + mapOf( + DiditAuthModule.NAME to ReactModuleInfo( DiditAuthModule.NAME, DiditAuthModule::class.java.name, false, // canOverrideExistingModule false, // needsEagerInit - false, // hasConstants false, // isCxxModule true // isTurboModule ) - } else { - // RN 0.79+ also supports registration without hasConstants. - constructors.first { it.parameterTypes.size == 6 }.newInstance( - DiditAuthModule.NAME, - DiditAuthModule::class.java.name, - false, // canOverrideExistingModule - false, // needsEagerInit - false, // isCxxModule - true // isTurboModule - ) - } as ReactModuleInfo - mapOf(DiditAuthModule.NAME to info) + ) } } diff --git a/src/__tests__/android-r8-consumer-rules.test.ts b/src/__tests__/android-r8-consumer-rules.test.ts new file mode 100644 index 0000000..8e61f75 --- /dev/null +++ b/src/__tests__/android-r8-consumer-rules.test.ts @@ -0,0 +1,64 @@ +import { execFileSync } from 'child_process'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * R8's optimizing pass (proguard-android-optimize.txt) drops unused + * constructor parameters. Picking a ReactModuleInfo constructor by its + * parameter count then threw during React Native startup, so every minified + * host app crashed at launch (issue #61). These tests pin the direct + * constructor call in both packages and the consumer rule every host app + * receives from the published archive. + */ + +const root = join(__dirname, '..', '..'); +const read = (path: string) => readFileSync(join(root, path), 'utf8'); + +const KEEP_RULE = + '-keep class com.facebook.react.module.model.ReactModuleInfo { public (...); }'; + +const PACKAGE_SOURCES = [ + 'android/src/main/java/com/sdkreactnative/SdkReactNativePackage.kt', + 'packages/auth-react-native/android/src/main/java/me/didit/authreactnative/DiditAuthPackage.kt', +]; + +function packedFiles(): string[] { + const output = execFileSync( + process.execPath, + [ + join(root, '.yarn/releases/yarn-4.11.0.cjs'), + 'pack', + '--dry-run', + '--json', + ], + { cwd: root, encoding: 'utf8' } + ); + + return output + .trim() + .split('\n') + .map((line) => JSON.parse(line).location) + .filter((location): location is string => typeof location === 'string'); +} + +describe('Android R8 safety', () => { + it('ships the ReactModuleInfo keep rule as a consumer rule', () => { + const rules = read('android/consumer-rules.pro') + .split('\n') + .map((line) => line.trim()); + + expect(rules).toContain(KEEP_RULE); + expect(read('android/build.gradle')).toMatch( + /defaultConfig\s*\{[^}]*consumerProguardFiles\s+"consumer-rules\.pro"/ + ); + expect(packedFiles()).toContain('android/consumer-rules.pro'); + }); + + it.each(PACKAGE_SOURCES)('%s constructs ReactModuleInfo directly', (path) => { + const source = read(path); + + expect(source).not.toMatch(/ReactModuleInfo::class\.java/); + expect(source).not.toMatch(/\.constructors\b|\.newInstance\(/); + expect(source).toMatch(/\bReactModuleInfo\(/); + }); +});