Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/scripts/android-release-smoke.sh
Original file line number Diff line number Diff line change
@@ -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 <apk> <output directory>
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."
84 changes: 84 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init>(...); }' "$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
Expand Down
1 change: 1 addition & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ android {
defaultConfig {
minSdkVersion getExtOrDefault("minSdkVersion")
targetSdkVersion getExtOrDefault("targetSdkVersion")
consumerProguardFiles "consumer-rules.pro"
}

buildFeatures {
Expand Down
7 changes: 7 additions & 0 deletions android/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -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 <init>(...); }
27 changes: 9 additions & 18 deletions android/src/main/java/com/sdkreactnative/SdkReactNativePackage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}
6 changes: 4 additions & 2 deletions example/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}
64 changes: 64 additions & 0 deletions src/__tests__/android-r8-consumer-rules.test.ts
Original file line number Diff line number Diff line change
@@ -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 <init>(...); }';

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\(/);
});
});
Loading