feat(android): support ActivityResultContracts in native modules - #57798
feat(android): support ActivityResultContracts in native modules#57798matinzd wants to merge 10 commits into
Conversation
…ntext Adds com.facebook.react.activityresult with a ReactActivityResultCaller that registers AndroidX ActivityResultContracts against the host Activity's own ActivityResultRegistry (ReactActivity already extends ComponentActivity, so it is an ActivityResultRegistryOwner). No changes to consumers' MainActivity, no manifest entries, no forked registry. - Registration is legal at any time: modules are created lazily, so the returned launcher is a deferred wrapper that binds to the registry on onHostResume, queues a single launch issued while unbound, and rebinds under the same key after Activity recreation. - Keys are the contract's fully-qualified class name; duplicate registrations throw at registration time, with an owner-scoped overload as the escape hatch. - ReactContext gains registerForActivityResult convenience methods mirroring ComponentActivity, plus getActivityResultCaller(). - ActivityEventListener dispatch is untouched; results flow through ComponentActivity's existing onActivityResult / onRequestPermissionsResult into its registry. Demo: SampleTurboModule.requestSamplePermission (CAMERA) wired into rn-tester's SampleTurboModuleExample under an Android-only section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
This is my decisions and how I planned implementing this with Claude. Comments are welcome! https://gist.github.com/matinzd/5cac3ef1811efc9817f055b8d27692f7 |
|
Not sure how to update |
|
@matinzd we can generate it after importing, I don't believe there's a public Gradle script for it |
Yep. I saw the command in other PRs using buck2! The PR is now ready to review. Can you please approve the jobs to run? |
|
Thanks for tackling this @matinzd, the ActivityResultContract gap is real and Health Connect is a good example. A few things I think are worth sorting first, mostly around lifecycle and threading, though I might be missing some context:
On testing, it looks like the current coverage is mostly the sample module wiring, so the lifecycle and threading paths above are not really exercised. It would help to add tests for those paths, mainly the rebind case (a launch after an Activity swap should hit the new registry) and the main-thread requirement for register and launch. |
|
Thanks for the review @fabriziocucci!
Sorry, that slipped through. Claude made a mistake, and it’s good that you caught it. I’m thinking of deriving the key from the FQCN of the native module combined with the contract FQCN. We could throw an error if the same contract is registered twice from the same module, prompting users to use the overload that accepts a caller-provided key instead. E.g: // Throws: MyModule already registered a launcher for androidx...GetContent.
private val pickAvatar = ctx.registerForActivityResult(this, GetContent()) { }
private val pickBanner = ctx.registerForActivityResult(this, GetContent()) { }
// Fix:
private val pickAvatar = ctx.registerForActivityResult("avatar", GetContent()) { }
private val pickBanner = ctx.registerForActivityResult("banner", GetContent()) { }or we can just silently add an index based key based on each module to avoid collision for e.g: // key = "com.some.image.lib.ImageModule:androidx...GetContent#0"
// key = "com.some.image.lib.ImageModule:androidx...GetContent#1"What do you think? (Not sure who to tag though from the core team)
For the rebind case, I tried adding a For the rest, I need to create an example app in order to test those cases. I will let you know when that's ready. |
You are right.
Probably worths using |
|
I implemented a fix for the thread-safety and rebinding issue. I tried to test it but it's hard to reproduce. I got some help from Claude to write some tests: f3d3fef To be honest, some parts are beyond my knowledge. The repo is too huge to understand everything, like what's running on which thread, but I tried my best to figure it out. Hope this makes more sense now. |
Summary:
Rendered readme can be found here.
Bare React Native has no way for a native module to use AndroidX
ActivityResultContracts. Modules are stuck withActivityEventListenerand self-assigned int request codes. On Android 14+ some contracts (e.g. Health Connect's permission contract) produce a synthetic intent that only anActivityResultRegistrycan service, so the classicstartActivityForResultpath fails withActivityNotFoundExceptionoutright.Libraries work around this by demanding glue code in the consumer's
MainActivity(e.g.HealthConnectPermissionDelegate.setPermissionDelegate(this)) or by shipping a transparentActivityin their manifest, which cuts against Google's single-activity guidance (matinzd/react-native-health-connect#266, #33639, #36377). Expo solved this withregisterActivityContracts; bare RN has no equivalent.ReactActivityalready extendsComponentActivity, so it already owns a realActivityResultRegistryand routes results into it. Core just needs to hand modules a path to that registry:Design notes:
ComponentActivity.registerForActivityResultand returns the realandroidx.activity.result.ActivityResultLauncher<I>. The one addition is a leadingownerargument, which scopes the registration key.onHostResumeand queues alaunch()issued while unbound.ReactActivity/ReactActivityDelegate/ReactDelegate, no new Gradle dependency, no manifest changes, no forked registry.ActivityEventListeneris untouched.Promise) died with the JS context.Registration keys
Keys are
"<owner class>:<contract class>". Because a class's fully-qualified name is globally unique, two unrelated libraries can both register a stock contract such asGetContentwithout colliding. A collision is now only reachable from one owner's own code (registering the same contract class twice), and the fix is an overload taking an extra key, which is appended to the owner-and-contract scope rather than replacing it, so no choice of key can reintroduce a cross-library collision.RN deliberately does not copy
ComponentActivity's auto-generatedactivity_rq#Nkeys: that works there because registration happens inonCreatein a deterministic order, whereas RN modules are created lazily in whatever order JS first touches them. After process deathactivity_rq#0could belong to a different module, and a restored result would be dispatched to the wrong callback and parsed with the wrong contract.Threading
ActivityResultRegistryis@MainThreadand its key tables are unsynchronized plain maps, but nothing enforces that at runtime, so an off-thread call corrupts them silently instead of throwing. RN never reaches it from the main thread by default: modules are constructed on the JS thread, so field-initializer registrations arrive onmqt_v_js, and module methods run onmqt_v_native, solaunch()arrives from there. Both were confirmed with on-device thread traces.The corruption is reproducible: four threads registering concurrently produce
ArrayIndexOutOfBoundsExceptioninside AndroidX's ownonSaveInstanceState(i.e. on rotation), plus lost registrations and duplicate request codes, which would deliver a result to the wrong callback.State is now split by owner. The registration bookkeeping is a concurrent map callable from any thread, where claiming a key is a single atomic operation. Every call reaching
ActivityResultRegistry(register,launch,unregister) is confined to the UI thread and asserted withUiThreadUtil.assertOnUiThread()in debug builds. Registration stays synchronous, so the launcher is returned immediately and a duplicate key still throws from the caller's own frame; only the registry call hops.launch()from a background thread becomes asynchronous, which it effectively already was, since binding is deferred until an Activity exists.Rebinding to the current Activity
Registrations outlive any single Activity, and every
onHostResumenow reconciles each launcher against the current registry, rebinding it if it is attached to a different one.Binding only when a launcher was unbound was not sufficient for multi-Activity navigation: the new Activity resumes before the old one is destroyed, and
ReactHostImpl.onHostDestroy(activity)then drops the old Activity's destroy entirely becausecurrentActivityhas already moved on. A launcher that stopped at "am I bound to something?" stayed attached to the previous Activity's dead registry, leaking it, and a launch from the new screen dispatched into the old Activity. The single-Activity config-change path never showed this, because there the destroy and the resume are strictly ordered.Demos:
SampleTurboModule.requestSamplePermission()(CAMERA), pluspickMediaandpickMultipleMedia(photo picker, single and multi select with a JS-controlled limit), surfaced in rn-tester's SampleTurboModule and PhotoPickerAndroid screens.Changelog:
[ANDROID] [ADDED] - Native modules can register AndroidX
ActivityResultContracts viaReactContext.registerForActivityResult, with no changes to the consumer'sMainActivityTest Plan:
./gradlew :packages:react-native:ReactAndroid:compileDebugKotlinand:compileDebugJavaWithJavacpass; codegen emits the sample module methods intoNativeSampleTurboModuleSpec.ReactAndroid/src/test/java/com/facebook/react/activityresult/:ReactActivityResultCallerImplTest(8) covers keying: two owners registering the same stock contract, one owner registering the same contract twice, distinct contract classes, the extra-key overload, a non-module owner, and unregister-then-reuse.ReactActivityResultCallerThreadingTest(8) covers threading and rebinding: the registry is untouched until the main looper runs for bothregisterandlaunch, the launcher is still returned synchronously, a duplicate key still throws on the caller's thread, a launch issued before binding is queued and fires on bind, two threads racing for one key produce exactly one winner, and resuming a second Activity rebinds to its registry (with noonHostDestroy) while leaving the old registry empty.registry.registerandonLaunchboth run onmain, while module construction (mqt_v_js) and Promise bodies (mqt_v_native) do not, as expected. Photo picker single and multi select exercised end to end; noAndroidRuntimecrashes and no UI-thread assertion failures. The tracing was removed before this diff.Example App Recording
Screen.Recording.2026-08-03.at.15.27.08.mov