Skip to content

feat(android): support ActivityResultContracts in native modules - #57798

Open
matinzd wants to merge 10 commits into
react:mainfrom
matinzd:feat/permission_contracts_android
Open

feat(android): support ActivityResultContracts in native modules#57798
matinzd wants to merge 10 commits into
react:mainfrom
matinzd:feat/permission_contracts_android

Conversation

@matinzd

@matinzd matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary:

Rendered readme can be found here.

Bare React Native has no way for a native module to use AndroidX ActivityResultContracts. Modules are stuck with ActivityEventListener and self-assigned int request codes. On Android 14+ some contracts (e.g. Health Connect's permission contract) produce a synthetic intent that only an ActivityResultRegistry can service, so the classic startActivityForResult path fails with ActivityNotFoundException outright.

Libraries work around this by demanding glue code in the consumer's MainActivity (e.g. HealthConnectPermissionDelegate.setPermissionDelegate(this)) or by shipping a transparent Activity in their manifest, which cuts against Google's single-activity guidance (matinzd/react-native-health-connect#266, #33639, #36377). Expo solved this with registerActivityContracts; bare RN has no equivalent.

ReactActivity already extends ComponentActivity, so it already owns a real ActivityResultRegistry and routes results into it. Core just needs to hand modules a path to that registry:

private val getContent = reactContext.registerForActivityResult(
    /* owner = */ this, ActivityResultContracts.GetContent()) { uri -> ... }

getContent.launch("image/*")

Design notes:

  • API mirrors ComponentActivity.registerForActivityResult and returns the real androidx.activity.result.ActivityResultLauncher<I>. The one addition is a leading owner argument, which scopes the registration key.
  • Modules register before an Activity exists (they are created lazily), so the returned launcher binds to the registry on onHostResume and queues a launch() issued while unbound.
  • No changes to ReactActivity/ReactActivityDelegate/ReactDelegate, no new Gradle dependency, no manifest changes, no forked registry. ActivityEventListener is untouched.
  • Known limitation: on process death, AndroidX redelivers the pending result under the same key, but the module's in-flight state (typically a 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 as GetContent without 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-generated activity_rq#N keys: that works there because registration happens in onCreate in a deterministic order, whereas RN modules are created lazily in whatever order JS first touches them. After process death activity_rq#0 could belong to a different module, and a restored result would be dispatched to the wrong callback and parsed with the wrong contract.

Threading

ActivityResultRegistry is @MainThread and 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 on mqt_v_js, and module methods run on mqt_v_native, so launch() arrives from there. Both were confirmed with on-device thread traces.

The corruption is reproducible: four threads registering concurrently produce ArrayIndexOutOfBoundsException inside AndroidX's own onSaveInstanceState (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 with UiThreadUtil.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 onHostResume now 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 because currentActivity has 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), plus pickMedia and pickMultipleMedia (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 via ReactContext.registerForActivityResult, with no changes to the consumer's MainActivity

Test Plan:

  • ./gradlew :packages:react-native:ReactAndroid:compileDebugKotlin and :compileDebugJavaWithJavac pass; codegen emits the sample module methods into NativeSampleTurboModuleSpec.
  • 16 unit tests under 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 both register and launch, 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 no onHostDestroy) while leaving the old registry empty.
  • Each fix was validated with a negative control, to confirm the tests fail without it: stubbing the UI-thread hop fails exactly 3 threading tests, and restoring the old "bound to anything?" check fails exactly the rebinding test.
  • Verified in rn-tester on an Android emulator with temporary thread-tracing: registry.register and onLaunch both run on main, 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; no AndroidRuntime crashes and no UI-thread assertion failures. The tracing was removed before this diff.
  • Flow, ESLint, prettier, and ktfmt clean.

Example App Recording

Screen.Recording.2026-08-03.at.15.27.08.mov

matinzd and others added 2 commits August 3, 2026 13:13
…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>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 3, 2026
@matinzd matinzd changed the title feat: initial support for permission contracts [WIP] feat(android): support for permission contracts Aug 3, 2026
@matinzd matinzd changed the title feat(android): support for permission contracts feat(android): support ActivityResultContracts in native modules Aug 3, 2026
@matinzd
matinzd marked this pull request as ready for review August 3, 2026 12:13
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Aug 3, 2026
@matinzd

matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

This is my decisions and how I planned implementing this with Claude. Comments are welcome!

https://gist.github.com/matinzd/5cac3ef1811efc9817f055b8d27692f7

@matinzd

matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Not sure how to update ReactAndroid.api tho? Are there any scripts or gradle tasks to do this? @cortinico

@matinzd
matinzd marked this pull request as draft August 3, 2026 12:56
@Abbondanzo

Abbondanzo commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@matinzd we can generate it after importing, I don't believe there's a public Gradle script for it

@matinzd
matinzd marked this pull request as ready for review August 3, 2026 13:31
@matinzd

matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

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?

@matinzd
matinzd requested a review from Abbondanzo August 3, 2026 14:05
@fabriziocucci

fabriziocucci commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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:

  1. Threading. As far as I can tell a TurboModule field initializer and Promise bodies run on the JS or native-modules thread rather than the UI thread, but ActivityResultRegistry.register and onLaunch are @MainThread and touch non-thread-safe maps. It looks like register (ReactActivityResultCallerImpl.kt) and launch (DeferredActivityResultLauncher.kt) run on the caller thread today, so I think this could crash or misbehave depending on the device. I think both would need to hop to the UI thread, unless I am misreading where they run.

  2. Rebinding to the current Activity. It seems the launcher rebinds only when it is not already bound and does not check whether the bound registry is still current. With multi-activity nav, or the order where the new activity's onHostResume fires before the old one's onHostDestroy, I think it stays bound to the old dead registry. onHostDestroy for the old activity also seems to be skipped by the activity === currentActivity guard, so unbind may never run. If that is right, a launch from the new screen would dispatch into the old activity, results land in the wrong place and the old activity leaks. The single-activity config-change path probably does not hit this since it is the serial order. I think onHostResume might need to always rebind to the current registry rather than gate on isBound.

  3. Keying by the contract class name. It seems two libraries both using a stock contract like GetContent or RequestPermission would get the same key, so the second register throws at field-init and that whole module fails to construct. I think AndroidX auto-generates keys for this reason. The owner-scoped overload helps but the default steers everyone into the collision. This one feels like a design call, so curious what you and the core team think, maybe default to owner-scoped or a caller-provided key with the FQCN as opt-in.

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.

@matinzd

matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review @fabriziocucci!

  1. Keying by the contract class name. It seems two libraries both using a stock contract like GetContent or RequestPermission would get the same key, so the second register throws at field-init and that whole module fails to construct. I think AndroidX auto-generates keys for this reason. The owner-scoped overload helps but the default steers everyone into the collision. This one feels like a design call, so curious what you and the core team think, maybe default to owner-scoped or a caller-provided key with the FQCN as opt-in.

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)

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.

For the rebind case, I tried adding a setTimeout locally when requesting a contract/permission, then used back button to close and put the app in the background. As soon as I opened the app the rebind happened and the picker showed up (Didn't check if the promise was resolved though because in that case the js context is already invalidated).

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.

@matinzd

matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
  1. Threading. As far as I can tell a TurboModule field initializer and Promise bodies run on the JS or native-modules thread rather than the UI thread, but ActivityResultRegistry.register and onLaunch are @mainthread and touch non-thread-safe maps. It looks like register (ReactActivityResultCallerImpl.kt) and launch (DeferredActivityResultLauncher.kt) run on the caller thread today, so I think this could crash or misbehave depending on the device. I think both would need to hop to the UI thread, unless I am misreading where they run.

You are right.

entries were being touched by both UI thread and the JS thread. Preparing a fix now.

Probably worths using ConcurrentHashMap.

@matinzd

matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants