feat(android): support Activity Result API for native modules (#57798)

Summary:
Rendered readme can be found [here](https://github.com/matinzd/react-native/blob/feat/permission_contracts_android/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/__docs__/README.md).

Bare React Native has no way for a native module to use AndroidX `ActivityResultContract`s. Modules are stuck with `ActivityEventListener` and self-assigned int request codes, and some contracts (e.g. Health Connect's permission contract) have no `startActivityForResult` equivalent at all. Calling `registerForActivityResult` on `getCurrentActivity()` instead is a dead end: the lifecycle-observing overload crashes with `LifecycleOwner ... is attempting to register while current state is RESUMED. LifecycleOwners must call register before they are STARTED.`, because AndroidX only allows it before the Activity is `STARTED` — and native modules are created lazily, long after that ([https://github.com/react/react-native/issues/33639](https://github.com/facebook/react-native/issues/33639)).

Libraries work around this by demanding glue code in the consumer's `MainActivity`: react-native-health-connect today requires every app to add `HealthConnectPermissionDelegate.setPermissionDelegate(this)`. The proposed alternative — shipping a transparent `Activity` in the library's manifest ([matinzd/react-native-health-connect#266](https://github.com/matinzd/react-native-health-connect/pull/266), still an unreleased PR) — cuts against Google's single-activity guidance ([https://github.com/react/react-native/issues/33639](https://github.com/facebook/react-native/issues/33639), [https://github.com/react/react-native/issues/36377](https://github.com/facebook/react-native/issues/36377)). Expo solved this with [`registerActivityContracts`](https://docs.expo.dev/modules/module-api/#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:

```kotlin
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.

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] - Add support for Activity Result API for native modules

Pull Request resolved: https://github.com/react/react-native/pull/57798

Test Plan:
- `./gradlew :packages:react-native:ReactAndroid:compileDebugKotlin` and `:compileDebugJavaWithJavac` pass; codegen emits the sample module methods into `NativeSampleTurboModuleSpec`.
- Flow, ESLint, prettier, and ktfmt clean.

## Example App Recording

https://github.com/user-attachments/assets/63750917-2325-4613-9a0d-b7241ae026eb

Reviewed By: javache

Differential Revision: D115622269

Pulled By: Abbondanzo

fbshipit-source-id: ce623a84d3c5b29f1bae57c2c177517d0cd34190
This commit is contained in:
matinzd
2026-09-25 10:50:37 -07:00
committed by meta-codesync[bot]
parent ab1159ba61
commit ec2b536e14
17 changed files with 1724 additions and 4 deletions
@@ -1016,6 +1016,10 @@ public abstract class com/facebook/react/bridge/ReactContext : android/content/C
public fun onNewIntent (Landroid/app/Activity;Landroid/content/Intent;)V
public fun onUserLeaveHint (Landroid/app/Activity;)V
public fun onWindowFocusChange (Z)V
public fun registerForActivityResult (Ljava/lang/Object;Landroidx/activity/result/contract/ActivityResultContract;Landroidx/activity/result/ActivityResultCallback;)Landroidx/activity/result/ActivityResultLauncher;
public fun registerForActivityResult (Ljava/lang/Object;Landroidx/activity/result/contract/ActivityResultContract;Landroidx/activity/result/ActivityResultCallback;Landroidx/activity/result/ActivityResultCallback;)Landroidx/activity/result/ActivityResultLauncher;
public fun registerForActivityResult (Ljava/lang/Object;Ljava/lang/String;Landroidx/activity/result/contract/ActivityResultContract;Landroidx/activity/result/ActivityResultCallback;)Landroidx/activity/result/ActivityResultLauncher;
public fun registerForActivityResult (Ljava/lang/Object;Ljava/lang/String;Landroidx/activity/result/contract/ActivityResultContract;Landroidx/activity/result/ActivityResultCallback;Landroidx/activity/result/ActivityResultCallback;)Landroidx/activity/result/ActivityResultLauncher;
public abstract fun registerSegment (ILjava/lang/String;Lcom/facebook/react/bridge/Callback;)V
public fun removeActivityEventListener (Lcom/facebook/react/bridge/ActivityEventListener;)V
public fun removeExtraWindowEventListener (Lcom/facebook/react/interfaces/ExtraWindowEventListener;)V
@@ -0,0 +1,128 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.react.activityresult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.app.ActivityOptionsCompat
import com.facebook.common.logging.FLog
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.common.ReactConstants
/**
* An [ActivityResultLauncher] that may exist before any `ActivityResultRegistry` is available: it
* delegates to the real launcher once [bind] is called, queues a single [launch] issued while
* unbound (fired on bind), and can be [unbind]-ed and rebound against a new host's registry.
*
* [delegate] and [pendingLaunch] are only touched on the UI thread; [launch] and [unregister] get
* there via [onUiThread]. [launch] decides between delegating and queueing *on* the UI thread, so a
* concurrent [unbind] cannot leave it pointed at a dead registry.
*/
internal class DeferredActivityResultLauncher<I>(
private val key: String,
override val contract: ActivityResultContract<I, *>,
private val onUnregister: () -> Unit,
private val onLaunchFailure: (RuntimeException) -> Unit = {},
) : ActivityResultLauncher<I>() {
private class PendingLaunch<I>(val input: I, val options: ActivityOptionsCompat?)
private var delegate: ActivityResultLauncher<I>? = null
private var boundRegistry: ActivityResultRegistry? = null
private var pendingLaunch: PendingLaunch<I>? = null
override fun launch(input: I, options: ActivityOptionsCompat?) {
onUiThread {
val boundDelegate = delegate
if (boundDelegate != null) {
launchSafely(boundDelegate, input, options)
} else {
if (pendingLaunch != null) {
FLog.w(
ReactConstants.TAG,
"Launcher for '$key' was launched again before an Activity was available; " +
"replacing the previously queued launch.",
)
}
pendingLaunch = PendingLaunch(input, options)
}
}
}
override fun unregister() {
// Drop the registration first so nothing rebinds this launcher in the meantime.
onUnregister()
onUiThread {
try {
delegate?.unregister()
} catch (exception: RuntimeException) {
FLog.e(
ReactConstants.TAG,
"Failed to unregister ActivityResult launcher '$key'.",
exception,
)
} finally {
delegate = null
boundRegistry = null
pendingLaunch = null
}
}
}
/**
* Attaches [launcher], obtained from [registry] (remembered for [isBoundTo]), and fires any
* queued launch.
*/
fun bind(registry: ActivityResultRegistry, launcher: ActivityResultLauncher<I>) {
UiThreadUtil.assertOnUiThread()
delegate = launcher
boundRegistry = registry
pendingLaunch?.let { pending ->
pendingLaunch = null
launchSafely(launcher, pending.input, pending.options)
}
}
private fun launchSafely(
launcher: ActivityResultLauncher<I>,
input: I,
options: ActivityOptionsCompat?,
) {
try {
launcher.launch(input, options)
} catch (exception: RuntimeException) {
FLog.e(ReactConstants.TAG, "Failed to launch ActivityResult launcher '$key'.", exception)
try {
onLaunchFailure(exception)
} catch (handlerException: RuntimeException) {
FLog.e(
ReactConstants.TAG,
"Failure handler for ActivityResult launcher '$key' threw.",
handlerException,
)
}
}
}
/** Detaches from the bound registry, keeping any queued launch for the next [bind]. */
fun unbind() {
UiThreadUtil.assertOnUiThread()
try {
delegate?.unregister()
} catch (exception: RuntimeException) {
FLog.e(ReactConstants.TAG, "Failed to unbind ActivityResult launcher '$key'.", exception)
} finally {
delegate = null
boundRegistry = null
}
}
/** Whether this launcher is bound to [registry] itself, not just to any registry. */
fun isBoundTo(registry: ActivityResultRegistry): Boolean = boundRegistry === registry
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.react.activityresult
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
/**
* Lets a native module register an AndroidX [ActivityResultContract] and receive results without
* any changes to the consumer's `MainActivity`. Mirrors
* `androidx.activity.ComponentActivity.registerForActivityResult`, except registration is legal at
* any time: the returned launcher binds to the real registry once a host Activity resumes.
*
* Every registration carries a key that must be unique within the `ReactContext` and stable across
* process death (AndroidX replays a restored result to whichever registration reproduces the same
* key). The default key `"<owner class>:<contract class>"` lets unrelated libraries register the
* same stock contract without colliding; a collision throws [IllegalStateException] at registration
* time, and the keyed overload (which appends to that scope, not replaces it) resolves it.
*/
internal interface ReactActivityResultCaller {
/**
* Registers [contract] under the key `"<owner class>:<contract class>"` and returns a launcher
* for it. [owner] must be an instance of a named class — typically the native module itself.
* Anonymous classes are rejected because their generated names can change between builds, which
* breaks result delivery after the process is killed and restored. For the same reason, apps that
* minify class names (R8/ProGuard) should keep the owner class's name, since the key is not
* guaranteed to be stable between builds otherwise.
*
* @throws IllegalArgumentException if [owner] is an instance of an anonymous class
* @throws IllegalStateException if [owner] already registered this contract class
*/
fun <I, O> registerForActivityResult(
owner: Any,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I>
/**
* Registers [contract] like the three-argument overload and calls [onLaunchFailure] when the
* deferred UI-thread launch cannot be started.
*/
fun <I, O> registerForActivityResult(
owner: Any,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
onLaunchFailure: ActivityResultCallback<RuntimeException>,
): ActivityResultLauncher<I>
/**
* Registers [contract] under the key `"<owner class>:<contract class>:<key>"`. Use this when one
* owner needs several launchers of the same contract class. [key] only has to be unique among
* those, but must stay the same across process restarts, so derive it from a constant. [owner]
* carries the same requirements as the two-argument overload: it must be an instance of a named
* class.
*
* @throws IllegalArgumentException if [owner] is an instance of an anonymous class
* @throws IllegalStateException if [owner] already registered this contract class under [key]
*/
fun <I, O> registerForActivityResult(
owner: Any,
key: String,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I>
/**
* Registers [contract] like the keyed overload and calls [onLaunchFailure] when the deferred
* UI-thread launch cannot be started.
*/
fun <I, O> registerForActivityResult(
owner: Any,
key: String,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
onLaunchFailure: ActivityResultCallback<RuntimeException>,
): ActivityResultLauncher<I>
}
@@ -0,0 +1,225 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.react.activityresult
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContract
import com.facebook.common.logging.FLog
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.common.ReactConstants
import java.util.concurrent.ConcurrentHashMap
/**
* Runs [block] on the UI thread, inline if already there. [ActivityResultRegistry] is `@MainThread`
* but not enforced at runtime: an off-thread call corrupts it silently, and RN calls in from the JS
* and native-modules threads.
*/
internal fun onUiThread(block: () -> Unit) {
if (UiThreadUtil.isOnUiThread()) block() else UiThreadUtil.runOnUiThread(block)
}
/**
* Default [ReactActivityResultCaller], owned by a [ReactContext].
*
* Registrations are accepted at any time and bound to the current Activity's
* [ActivityResultRegistry] immediately or on the next `onHostResume`. They outlive any single
* Activity: keys stay stable so AndroidX can re-associate a result after Activity recreation.
*
* Every `onHostResume` checks each launcher against the *current* registry, not just "already bound
* to something": with multi-Activity navigation the new Activity resumes before the old one is
* destroyed (whose onHostDestroy is dropped once `currentActivity` moves on), so a bound-only check
* would leave launchers attached to the previous Activity's dead registry.
*
* Threading: [entries] is concurrent and reachable from any thread; everything touching the
* registry goes through [onUiThread]. Registration stays on the caller's thread so the launcher
* returns immediately and a duplicate key throws at the causing frame. Only the registry call moves
* to the UI thread.
*/
internal class ReactActivityResultCallerImpl(private val reactContext: ReactContext) :
ReactActivityResultCaller, LifecycleEventListener {
private class Entry<I, O>(
val key: String,
private val contract: ActivityResultContract<I, O>,
private val callback: ActivityResultCallback<O>,
val launcher: DeferredActivityResultLauncher<I>,
) {
/**
* Ensures the launcher is bound to [registry], rebinding if it is currently attached to a
* different one. On [Entry] so an `Entry<*, *>` can be bound without unchecked casts.
*/
fun bindTo(registry: ActivityResultRegistry) {
if (launcher.isBoundTo(registry)) return
// Release any previous (possibly dead) registry first; staying registered there leaks its
// Activity and sends launches to the wrong one.
launcher.unbind()
launcher.bind(registry, registry.register(key, contract, callback))
}
}
private val entries = ConcurrentHashMap<String, Entry<*, *>>()
init {
reactContext.addLifecycleEventListener(this)
}
override fun <I, O> registerForActivityResult(
owner: Any,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I> =
registerForActivityResult(
owner,
contract,
callback,
ActivityResultCallback { exception -> reactContext.handleException(exception) },
)
override fun <I, O> registerForActivityResult(
owner: Any,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
onLaunchFailure: ActivityResultCallback<RuntimeException>,
): ActivityResultLauncher<I> {
if (owner::class.java.isAnonymousClass) {
throw IllegalArgumentException(
"ActivityResult owner must be a named class, but got an anonymous class. " +
"Pass an instance of a named class instead."
)
}
return register(
key = "${owner.javaClass.name}:${contract.javaClass.name}",
collisionHint =
"Register once and reuse the launcher, or pass a distinct key per launcher: " +
"registerForActivityResult(owner, \"someName\", contract, callback).",
contract = contract,
callback = callback,
onLaunchFailure = onLaunchFailure,
)
}
override fun <I, O> registerForActivityResult(
owner: Any,
key: String,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I> =
registerForActivityResult(
owner,
key,
contract,
callback,
ActivityResultCallback { exception -> reactContext.handleException(exception) },
)
override fun <I, O> registerForActivityResult(
owner: Any,
key: String,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
onLaunchFailure: ActivityResultCallback<RuntimeException>,
): ActivityResultLauncher<I> {
if (owner::class.java.isAnonymousClass) {
throw IllegalArgumentException(
"ActivityResult owner must be a named class, but got an anonymous class. " +
"Pass an instance of a named class instead."
)
}
return register(
key = "${owner.javaClass.name}:${contract.javaClass.name}:$key",
collisionHint = "Pass a key that is unique among this owner's launchers of this contract.",
contract = contract,
callback = callback,
onLaunchFailure = onLaunchFailure,
)
}
private fun <I, O> register(
key: String,
collisionHint: String,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
onLaunchFailure: ActivityResultCallback<RuntimeException>,
): ActivityResultLauncher<I> {
val launcher =
DeferredActivityResultLauncher(
key = key,
contract = contract,
onUnregister = { entries.remove(key) },
onLaunchFailure = { exception -> onLaunchFailure.onActivityResult(exception) },
)
val entry = Entry(key, contract, callback, launcher)
if (entries.putIfAbsent(key, entry) != null) {
throw IllegalStateException("A launcher is already registered for key '$key'. $collisionHint")
}
onUiThread { currentRegistry()?.let { registry -> bindSafely(entry, registry) } }
return launcher
}
override fun onHostResume() = onUiThread {
if (entries.isEmpty()) return@onUiThread
val registry = currentRegistry()
if (registry == null) {
entries.values.forEach(::unbindSafely)
return@onUiThread
}
entries.values.forEach { entry -> bindSafely(entry, registry) }
}
private fun bindSafely(entry: Entry<*, *>, registry: ActivityResultRegistry) {
try {
entry.bindTo(registry)
} catch (exception: RuntimeException) {
FLog.e(
ReactConstants.TAG,
"Failed to bind ActivityResult launcher '${entry.key}'.",
exception,
)
}
}
private fun unbindSafely(entry: Entry<*, *>) {
try {
entry.launcher.unbind()
} catch (exception: RuntimeException) {
FLog.e(
ReactConstants.TAG,
"Failed to unbind ActivityResult launcher '${entry.key}'.",
exception,
)
}
}
override fun onHostPause(): Unit = Unit
override fun onHostDestroy() = onUiThread {
// Detach from the dying registry but keep the registrations: they rebind under the same keys
// on the next onHostResume, which is how AndroidX re-associates a surviving result.
entries.values.forEach(::unbindSafely)
}
private fun currentRegistry(): ActivityResultRegistry? {
val activity = reactContext.currentActivity ?: return null
if (activity !is ActivityResultRegistryOwner) {
FLog.w(
ReactConstants.TAG,
"Current Activity ${activity.javaClass.name} is not an ActivityResultRegistryOwner; " +
"ActivityResultContract launchers will remain unbound.",
)
return null
}
return activity.activityResultRegistry
}
}
@@ -0,0 +1,178 @@
# ActivityResultContracts for native modules
[🏠 Home](../../../../../../../../../../../__docs__/README.md)
This package lets an Android native module register an AndroidX
[`ActivityResultContract`](https://developer.android.com/training/basics/intents/result)
and receive results. The consumer app does not need to change its
`MainActivity`, add manifest entries, or ship extra Activities.
Before this, modules had to use `ActivityEventListener` with self-assigned int
request codes, which live in a global namespace with no coordination between
libraries. Calling `registerForActivityResult` on `getCurrentActivity()` does
not work either: AndroidX only allows it before the Activity is started, and
native modules are created lazily, long after that. See
[facebook/react-native#33639](https://github.com/facebook/react-native/issues/33639)
(Health Connect, whose permission contract cannot be used without
`registerForActivityResult`).
## 🚀 Usage
The API is `ReactContext.registerForActivityResult`. It has the same shape as
[`ComponentActivity.registerForActivityResult`](https://developer.android.com/training/basics/intents/result#register),
plus a leading `owner` argument that scopes the registration key. You can
register at any time. A field initializer is the recommended spot. The returned
launcher connects to the real registry once an Activity is available.
```kotlin
class MyModule(private val context: ReactApplicationContext) :
NativeMyModuleSpec(context) {
private var pendingPromise: Promise? = null
private val requestPermission =
context.registerForActivityResult(
/* owner = */ this,
ActivityResultContracts.RequestPermission()) { isGranted ->
pendingPromise?.resolve(isGranted)
pendingPromise = null
}
override fun requestCameraPermission(promise: Promise) {
pendingPromise = promise
requestPermission.launch(Manifest.permission.CAMERA)
}
}
```
Stock AndroidX contracts work unchanged, with their own input and output types
(for example `PickVisualMedia`).
### Registration keys and collisions
Registrations are keyed by `"<owner class>:<contract class>"`, so two unrelated
libraries can register the same stock contract without clashing. Pass a stable,
long-lived `owner`, normally the module itself. Only named classes are allowed
as owners: an anonymous object gets a generated class name that can change
between builds, which breaks result delivery after the process is killed and
restored, so passing one throws `IllegalArgumentException` at registration.
The same stability concern applies to minification. If the app minifies class
names (R8/ProGuard), the obfuscated name of the owner class is not guaranteed to
be the same from one build to the next, so a result delivered after an app
update can be dropped. Keep the owner class's name (for example with
`-keepnames`) if results must survive across builds.
Registering the same contract class twice from one owner throws
`IllegalStateException`. In that case use the overload that takes a key:
```kotlin
private val pickAvatar = ctx.registerForActivityResult(this, "avatar", GetContent()) { }
private val pickBanner = ctx.registerForActivityResult(this, "banner", GetContent()) { }
```
The key is added to the owner-and-contract prefix, not used instead of it. It
only has to be unique among that owner's launchers of that contract, and it can
never clash with another library's keys. It must stay the same across process
restarts, so derive it from a constant.
Why not automatic keys, like `ComponentActivity`'s counter? Modules are created
lazily, in whatever order JS touches them. After the process is killed and
restored, the same counter value could belong to a different module, and a
restored result would reach the wrong callback. Keys built from class names do
not depend on creation order.
### Contract parameters that come from JS
Contract constructor arguments are fixed when you register. If a value comes
from JS on each call, put it in the contract's input type instead: subclass the
stock contract and pass the value through `launch()`. See `PickUpToMedia` in
`SampleTurboModule.kt`, which does this for the photo picker's item limit.
### Working examples
- `SampleTurboModule.kt`
(`ReactCommon/react/nativemodule/samples/platform/android/`):
`requestSamplePermission`, `pickMedia`, `pickMultipleMedia`, and
`startSecondActivity` (multi-Activity regression check).
- rn-tester screens: `TurboModule/SampleTurboModuleExample.js` and
`PhotoPickerAndroid/PhotoPickerAndroid.js`.
## 📐 Design
`ReactActivity` extends `ComponentActivity`, so the host Activity already owns a
real `ActivityResultRegistry`. This package only bridges the timing gap between
lazily-created modules and that registry.
- `ReactActivityResultCaller` / `ReactActivityResultCallerImpl` (internal):
owned by the `ReactContext`. Holds the `(key, contract, callback)`
registrations and connects them to the current Activity's registry, right away
if an Activity exists, otherwise on the next `onHostResume`.
- `DeferredActivityResultLauncher` (internal): the launcher handed to callers.
It forwards to the real AndroidX launcher once connected. A `launch()` made
before that is stored (latest wins) and fired on connect.
- Registrations outlive any single Activity. `onHostDestroy` disconnects them
but keeps them, and because the keys stay the same, AndroidX can deliver a
result that arrives after the Activity was recreated.
- Every `onHostResume` checks each launcher against the current registry, not
just whether it is connected to something. With more than one Activity, the
new Activity resumes before the old one is destroyed, and the old one's
`onHostDestroy` never runs because `currentActivity` has already moved on. A
launcher that only checked "am I connected?" would stay attached to the old
Activity's registry: that Activity could never be freed, and launches from the
new screen would go to the old one.
### Threading
`ActivityResultRegistry` must only be used from the UI thread, but nothing
enforces that at runtime; calls from other threads corrupt its internal maps
silently. React Native calls in from the JS thread (registrations in field
initializers) and from the native-modules thread (`launch()`), so:
- The bookkeeping used for collision detection is a concurrent map and can be
used from any thread. Claiming a key is a single atomic step. Registration
stays synchronous: you get the launcher back immediately, and a duplicate key
throws from your own call.
- Every call that reaches the registry (`register`, `launch`, `unregister`) is
forwarded to the UI thread, and so is the launcher's connection state (checked
with assertions in debug builds).
Notes for library authors:
- Register early, in a field initializer or the constructor. Only launching
needs an Activity.
- An Activity that is not an `ActivityResultRegistryOwner` cannot serve
launchers; binding to one throws `IllegalStateException`. In practice every
`ComponentActivity` (including `ReactActivity`) is a registry owner.
- After the process is killed and restored, AndroidX redelivers a pending result
under the same key, but any state your module held for the call (typically a
`Promise`) is gone. Write callbacks so they tolerate firing with no pending
state.
- `unregister()` on the returned launcher removes the registration and frees the
key.
## 🔗 Relationship with other systems
### Part of
- [ReactAndroid](../../../../../../../../README.md): the core of React Native on
Android.
### Used by this
- `com.facebook.react.bridge.ReactContext`: exposes the public
`registerForActivityResult` methods, owns the caller instance, and drives
connecting and disconnecting through its lifecycle events.
- AndroidX `androidx.activity.result`: the contracts, launchers, and registry
that actually start activities and deliver results.
### Uses this
- `SampleTurboModule` (demo) and, in the future, third-party modules that need
activity results or AndroidX permission contracts (for example Health
Connect).
This API coexists with `ActivityEventListener`: results claimed by the AndroidX
registry are consumed by it, and everything else still reaches
`ActivityEventListener.onActivityResult`. The listener remains the right tool
for intents a module builds and starts itself.
@@ -16,12 +16,16 @@ import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Window;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContract;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.facebook.common.logging.FLog;
import com.facebook.infer.annotation.Assertions;
import com.facebook.infer.annotation.ThreadConfined;
import com.facebook.proguard.annotations.DoNotStrip;
import com.facebook.react.activityresult.ReactActivityResultCallerImpl;
import com.facebook.react.bridge.interop.InteropModuleRegistry;
import com.facebook.react.bridge.queue.MessageQueueThread;
import com.facebook.react.bridge.queue.ReactQueueConfiguration;
@@ -67,6 +71,7 @@ public abstract class ReactContext extends ContextWrapper {
private @Nullable JSExceptionHandler mJSExceptionHandler;
private @Nullable JSExceptionHandler mExceptionHandlerWrapper;
private @Nullable WeakReference<Activity> mCurrentActivity;
private @Nullable ReactActivityResultCallerImpl mActivityResultCaller;
// NOTE: When converted to Kotlin, this field should be made internal due to
// visibility restriction on InteropModuleRegistry otherwise it will be exposed to the public API.
@@ -532,6 +537,76 @@ public abstract class ReactContext extends ContextWrapper {
return mCurrentActivity.get();
}
private synchronized ReactActivityResultCallerImpl getActivityResultCaller() {
if (mActivityResultCaller == null) {
mActivityResultCaller = new ReactActivityResultCallerImpl(this);
}
return mActivityResultCaller;
}
/**
* Registers an AndroidX {@code ActivityResultContract} and returns a launcher for it, mirroring
* {@code ComponentActivity.registerForActivityResult} but with no changes required to the
* consumer's {@code MainActivity}. Registration is legal at any time; the launcher binds lazily
* once an Activity is available, queueing a {@code launch} issued while unbound.
*
* <p>The registration key is {@code "<owner class>:<contract class>"}, so {@code owner} should be
* a stable, long-lived object (typically the native module itself): the key must be reproducible
* after the process is killed and restored. Registering the same contract class twice from one
* owner throws {@link IllegalStateException}; use {@link #registerForActivityResult(Object,
* String, ActivityResultContract, ActivityResultCallback)} in that case.
*/
public <I, O> ActivityResultLauncher<I> registerForActivityResult(
Object owner, ActivityResultContract<I, O> contract, ActivityResultCallback<O> callback) {
return getActivityResultCaller().registerForActivityResult(owner, contract, callback);
}
/**
* Same as {@link #registerForActivityResult(Object, ActivityResultContract,
* ActivityResultCallback)}, but reports a failure to start the deferred UI-thread launch through
* {@code onLaunchFailure}.
*/
public <I, O> ActivityResultLauncher<I> registerForActivityResult(
Object owner,
ActivityResultContract<I, O> contract,
ActivityResultCallback<O> callback,
ActivityResultCallback<RuntimeException> onLaunchFailure) {
return getActivityResultCaller()
.registerForActivityResult(owner, contract, callback, onLaunchFailure);
}
/**
* Same as {@link #registerForActivityResult(Object, ActivityResultContract,
* ActivityResultCallback)}, but registers under {@code "<owner class>:<contract class>:<key>"}.
* Use this when one owner needs several launchers of the same contract class. {@code key} only
* has to be unique among those, but must stay the same across process restarts.
*
* @throws IllegalStateException if {@code owner} already registered this contract class under
* {@code key}
*/
public <I, O> ActivityResultLauncher<I> registerForActivityResult(
Object owner,
String key,
ActivityResultContract<I, O> contract,
ActivityResultCallback<O> callback) {
return getActivityResultCaller().registerForActivityResult(owner, key, contract, callback);
}
/**
* Same as {@link #registerForActivityResult(Object, String, ActivityResultContract,
* ActivityResultCallback)}, but reports a failure to start the deferred UI-thread launch through
* {@code onLaunchFailure}.
*/
public <I, O> ActivityResultLauncher<I> registerForActivityResult(
Object owner,
String key,
ActivityResultContract<I, O> contract,
ActivityResultCallback<O> callback,
ActivityResultCallback<RuntimeException> onLaunchFailure) {
return getActivityResultCaller()
.registerForActivityResult(owner, key, contract, callback, onLaunchFailure);
}
/**
* @deprecated DO NOT USE, this method will be removed in the near future.
*/
@@ -0,0 +1,169 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.react.activityresult
import android.app.Activity
import android.os.Bundle
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts.GetContent
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
import androidx.core.app.ActivityOptionsCompat
import com.facebook.react.bridge.ReactApplicationContext
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
/**
* Covers the registration keying scheme: owner-scoped by default so two independent modules can use
* the same stock contract, with an extra-key overload -- appended to that scope, not replacing it
* -- for one owner needing several launchers of the same contract class.
*/
@RunWith(RobolectricTestRunner::class)
class ReactActivityResultCallerImplTest {
/** Records the keys handed to [ActivityResultRegistry.register] and never starts anything. */
private class RecordingRegistry : ActivityResultRegistry() {
override fun <I, O> onLaunch(
requestCode: Int,
contract: ActivityResultContract<I, O>,
input: I,
options: ActivityOptionsCompat?,
): Unit = Unit
/** [onSaveInstanceState] is the only public window into the registry's key table. */
val registeredKeys: List<String>
get() =
Bundle()
.also { onSaveInstanceState(it) }
.getStringArrayList("KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS")
.orEmpty()
}
class TestActivity : Activity(), ActivityResultRegistryOwner {
override val activityResultRegistry: ActivityResultRegistry = RecordingRegistry()
}
/** Two distinct owner classes, standing in for two unrelated third-party modules. */
private class ModuleA
private class ModuleB
private lateinit var registry: RecordingRegistry
private lateinit var reactContext: ReactApplicationContext
private lateinit var caller: ReactActivityResultCallerImpl
private val moduleA = ModuleA()
private val moduleB = ModuleB()
private val moduleAName = ModuleA::class.java.name
private val moduleBName = ModuleB::class.java.name
private val getContentName = GetContent::class.java.name
@Before
fun setUp() {
val activity = Robolectric.buildActivity(TestActivity::class.java).create().get()
registry = activity.activityResultRegistry as RecordingRegistry
reactContext = mock<ReactApplicationContext>()
whenever(reactContext.currentActivity).thenReturn(activity)
caller = ReactActivityResultCallerImpl(reactContext)
}
@Test
fun twoOwnersMayRegisterTheSameStockContract() {
caller.registerForActivityResult(moduleA, GetContent()) {}
caller.registerForActivityResult(moduleB, GetContent()) {}
assertThat(registry.registeredKeys)
.containsExactlyInAnyOrder("$moduleAName:$getContentName", "$moduleBName:$getContentName")
}
@Test
fun oneOwnerRegisteringTheSameContractTwiceThrows() {
caller.registerForActivityResult(moduleA, GetContent()) {}
assertThatThrownBy { caller.registerForActivityResult(moduleA, GetContent()) {} }
.isInstanceOf(IllegalStateException::class.java)
.hasMessageContaining("registerForActivityResult(owner, \"someName\", contract, callback)")
}
@Test
fun oneOwnerMayRegisterDifferentContractClasses() {
caller.registerForActivityResult(moduleA, GetContent()) {}
caller.registerForActivityResult(moduleA, RequestPermission()) {}
assertThat(registry.registeredKeys)
.containsExactlyInAnyOrder(
"$moduleAName:$getContentName",
"$moduleAName:${RequestPermission::class.java.name}",
)
}
@Test
fun extraKeysAllowTwoLaunchersOfOneContract() {
caller.registerForActivityResult(moduleA, "avatar", GetContent()) {}
caller.registerForActivityResult(moduleA, "banner", GetContent()) {}
assertThat(registry.registeredKeys)
.containsExactlyInAnyOrder(
"$moduleAName:$getContentName:avatar",
"$moduleAName:$getContentName:banner",
)
}
/** The owner-and-contract scope is still applied, so a shared key across owners is safe. */
@Test
fun theSameExtraKeyFromTwoOwnersDoesNotCollide() {
caller.registerForActivityResult(moduleA, "pick", GetContent()) {}
caller.registerForActivityResult(moduleB, "pick", GetContent()) {}
assertThat(registry.registeredKeys)
.containsExactlyInAnyOrder(
"$moduleAName:$getContentName:pick",
"$moduleBName:$getContentName:pick",
)
}
@Test
fun duplicateExtraKeyForOneOwnerThrows() {
caller.registerForActivityResult(moduleA, "avatar", GetContent()) {}
assertThatThrownBy { caller.registerForActivityResult(moduleA, "avatar", GetContent()) {} }
.isInstanceOf(IllegalStateException::class.java)
.hasMessageContaining("$moduleAName:$getContentName:avatar")
.hasMessageContaining("unique among this owner's launchers")
}
@Test
fun aNonModuleOwnerKeysTheSameWayAModuleDoes() {
class MediaHelper
val helper = MediaHelper()
caller.registerForActivityResult(helper, GetContent()) {}
assertThat(registry.registeredKeys)
.containsExactly("${MediaHelper::class.java.name}:$getContentName")
}
@Test
fun unregisteringFreesTheKeyForReuse() {
val launcher = caller.registerForActivityResult(moduleA, GetContent()) {}
launcher.unregister()
caller.registerForActivityResult(moduleA, GetContent()) {}
assertThat(registry.registeredKeys).containsExactly("$moduleAName:$getContentName")
}
}
@@ -0,0 +1,322 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.react.activityresult
import android.app.Activity
import android.content.ActivityNotFoundException
import android.os.Bundle
import android.os.Looper
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts.GetContent
import androidx.core.app.ActivityOptionsCompat
import com.facebook.react.bridge.ReactApplicationContext
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
/**
* `ActivityResultRegistry` is `@MainThread` and its key tables are unsynchronized plain maps, but
* the annotation is not enforced at runtime -- off-thread access corrupts them silently rather than
* throwing. Native modules are constructed on the JS thread and their methods run on the
* native-modules thread, so every call into the registry has to be hopped to the UI thread.
*
* These tests pin that down by driving the caller from a background thread and asserting the
* registry is untouched until the main looper runs.
*/
@RunWith(RobolectricTestRunner::class)
class ReactActivityResultCallerThreadingTest {
private class RecordingRegistry : ActivityResultRegistry() {
val launchThreads = mutableListOf<String>()
var launchFailure: RuntimeException? = null
override fun <I, O> onLaunch(
requestCode: Int,
contract: ActivityResultContract<I, O>,
input: I,
options: ActivityOptionsCompat?,
) {
launchThreads += Thread.currentThread().name
launchFailure?.let { throw it }
}
/** [onSaveInstanceState] is the only public window into the registry's key table. */
val registeredKeys: List<String>
get() =
Bundle()
.also { onSaveInstanceState(it) }
.getStringArrayList("KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS")
.orEmpty()
}
private class ThrowingLauncher : ActivityResultLauncher<String>() {
override val contract: ActivityResultContract<String, *> = GetContent()
var launchCount = 0
override fun launch(input: String, options: ActivityOptionsCompat?) {
launchCount++
throw ActivityNotFoundException("No matching Activity")
}
override fun unregister(): Unit = Unit
}
class TestActivity : Activity(), ActivityResultRegistryOwner {
override val activityResultRegistry: ActivityResultRegistry = RecordingRegistry()
}
private class ModuleA
private lateinit var registry: RecordingRegistry
private lateinit var reactContext: ReactApplicationContext
private lateinit var caller: ReactActivityResultCallerImpl
private val moduleA = ModuleA()
private val expectedKey = "${ModuleA::class.java.name}:${GetContent::class.java.name}"
@Before
fun setUp() {
reactContext = mock<ReactApplicationContext>()
registry = resumeNewActivity()
caller = ReactActivityResultCallerImpl(reactContext)
}
/** Stands in for a new Activity becoming current, and returns its registry. */
private fun resumeNewActivity(): RecordingRegistry {
val activity = Robolectric.buildActivity(TestActivity::class.java).create().get()
whenever(reactContext.currentActivity).thenReturn(activity)
return activity.activityResultRegistry as RecordingRegistry
}
private fun onBackgroundThread(block: () -> Unit) {
var failure: Throwable? = null
val thread = Thread { runCatching(block).onFailure { failure = it } }
thread.start()
thread.join(10_000)
failure?.let { throw it }
}
private fun drainMainLooper() = shadowOf(Looper.getMainLooper()).idle()
@Test
fun `registering off the UI thread defers the registry call to the UI thread`() {
onBackgroundThread { caller.registerForActivityResult(moduleA, GetContent()) {} }
assertThat(registry.registeredKeys)
.describedAs("registry.register must not run on the caller's thread")
.isEmpty()
drainMainLooper()
assertThat(registry.registeredKeys).containsExactly(expectedKey)
}
@Test
fun `the launcher is returned synchronously even though binding is deferred`() {
lateinit var launcher: Any
onBackgroundThread { launcher = caller.registerForActivityResult(moduleA, GetContent()) {} }
// Registering in a field initializer depends on this: the launcher is usable immediately.
assertThat(launcher).isInstanceOf(DeferredActivityResultLauncher::class.java)
}
@Test
fun `an exception from a posted launch does not escape the UI thread`() {
val delegate = ThrowingLauncher()
val failures = mutableListOf<RuntimeException>()
val launcher = DeferredActivityResultLauncher("test", GetContent(), {}, failures::add)
launcher.bind(registry, delegate)
onBackgroundThread { launcher.launch("image/*") }
drainMainLooper()
assertThat(delegate.launchCount).isEqualTo(1)
assertThat(failures).hasSize(1)
}
@Test
fun `an exception from a queued launch does not escape bind`() {
val delegate = ThrowingLauncher()
val failures = mutableListOf<RuntimeException>()
val launcher = DeferredActivityResultLauncher("test", GetContent(), {}, failures::add)
launcher.launch("image/*")
drainMainLooper()
launcher.bind(registry, delegate)
assertThat(delegate.launchCount).isEqualTo(1)
assertThat(failures).hasSize(1)
}
@Test
fun `the explicit failure handler receives launch exceptions`() {
val failure = ActivityNotFoundException("No matching Activity")
registry.launchFailure = failure
val failures = mutableListOf<RuntimeException>()
val launcher = caller.registerForActivityResult(moduleA, GetContent(), {}, failures::add)
drainMainLooper()
onBackgroundThread { launcher.launch("image/*") }
drainMainLooper()
assertThat(failures).containsExactly(failure)
}
@Test
fun `a duplicate key still throws on the caller's own thread`() {
caller.registerForActivityResult(moduleA, GetContent()) {}
drainMainLooper()
var thrown: Throwable? = null
onBackgroundThread {
thrown =
runCatching { caller.registerForActivityResult(moduleA, GetContent()) {} }
.exceptionOrNull()
}
// Not surfaced later on the UI thread, where it would be unattributable.
assertThat(thrown).isInstanceOf(IllegalStateException::class.java)
}
@Test
fun `launching off the UI thread defers onLaunch to the UI thread`() {
val launcher = caller.registerForActivityResult(moduleA, GetContent()) {}
drainMainLooper()
onBackgroundThread { launcher.launch("image/*") }
assertThat(registry.launchThreads)
.describedAs("registry.onLaunch must not run on the caller's thread")
.isEmpty()
drainMainLooper()
assertThat(registry.launchThreads).containsExactly(Looper.getMainLooper().thread.name)
}
/**
* Multi-Activity navigation: B resumes while A is still alive, and `ReactHostImpl` then drops A's
* `onHostDestroy` because `currentActivity` has already moved to B. So no unbind ever runs for A
* -- `onHostResume` alone has to move the launcher across.
*/
@Test
fun `resuming a second activity rebinds to its registry without any onHostDestroy`() {
val launcher = caller.registerForActivityResult(moduleA, GetContent()) {}
drainMainLooper()
val registryA = registry
val registryB = resumeNewActivity()
caller.onHostResume() // note: no onHostDestroy for A, exactly as ReactHostImpl behaves
drainMainLooper()
assertThat(registryB.registeredKeys)
.describedAs("the launcher must follow the current Activity")
.containsExactly(expectedKey)
assertThat(registryA.registeredKeys)
.describedAs("staying registered on the dead registry leaks the old Activity")
.isEmpty()
launcher.launch("image/*")
drainMainLooper()
assertThat(registryB.launchThreads).hasSize(1)
assertThat(registryA.launchThreads)
.describedAs("a launch from the new screen must not dispatch into the old Activity")
.isEmpty()
}
@Test
fun `resuming an activity without a registry unbinds without throwing`() {
val launcher = caller.registerForActivityResult(moduleA, GetContent()) {}
drainMainLooper()
val registryA = registry
val plainActivity = Robolectric.buildActivity(Activity::class.java).create().get()
whenever(reactContext.currentActivity).thenReturn(plainActivity)
caller.onHostResume()
drainMainLooper()
assertThat(registryA.registeredKeys).isEmpty()
launcher.launch("image/*")
drainMainLooper()
assertThat(registryA.launchThreads).isEmpty()
val registryB = resumeNewActivity()
caller.onHostResume()
drainMainLooper()
assertThat(registryB.registeredKeys).containsExactly(expectedKey)
assertThat(registryB.launchThreads).hasSize(1)
}
@Test
fun `resuming the same activity again does not re-register`() {
caller.registerForActivityResult(moduleA, GetContent()) {}
drainMainLooper()
caller.onHostResume()
caller.onHostResume()
drainMainLooper()
assertThat(registry.registeredKeys).containsExactly(expectedKey)
}
@Test
fun `two threads racing to claim one key produce exactly one winner`() {
val start = CountDownLatch(1)
val done = CountDownLatch(2)
val failures = mutableListOf<Throwable>()
repeat(2) {
Thread {
start.await()
runCatching { caller.registerForActivityResult(moduleA, GetContent()) {} }
.onFailure { e -> synchronized(failures) { failures += e } }
done.countDown()
}
.start()
}
start.countDown()
done.await(10, TimeUnit.SECONDS)
drainMainLooper()
// Claiming the key is one atomic operation, so the loser always sees the collision.
assertThat(failures).hasSize(1)
assertThat(failures.single()).isInstanceOf(IllegalStateException::class.java)
assertThat(registry.registeredKeys).containsExactly(expectedKey)
}
@Test
fun `a launch issued before binding is queued and fires once bound`() {
lateinit var launcher: Any
onBackgroundThread {
launcher = caller.registerForActivityResult(moduleA, GetContent()) {}
@Suppress("UNCHECKED_CAST")
(launcher as DeferredActivityResultLauncher<String>).launch("image/*")
}
assertThat(registry.launchThreads).isEmpty()
drainMainLooper()
// Bind and the queued launch both land on the UI thread, in that order.
assertThat(registry.registeredKeys).containsExactly(expectedKey)
assertThat(registry.launchThreads).containsExactly(Looper.getMainLooper().thread.name)
}
}
@@ -7,12 +7,21 @@
package com.facebook.fbreact.specs
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.DisplayMetrics
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import com.facebook.proguard.annotations.DoNotStrip
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ArrayBuffer
@@ -37,6 +46,68 @@ public class SampleTurboModule(private val context: ReactApplicationContext) :
private var toast: Toast? = null
private lateinit var permissionLauncher: ActivityResultLauncher<String>
private var pendingPermissionPromise: Promise? = null
// Photo picker in single-select mode, demonstrating a contract with a typed input
// (PickVisualMediaRequest) and a nullable output. See
// https://developer.android.com/training/data-storage/shared/photo-picker
private lateinit var pickMediaLauncher: ActivityResultLauncher<PickVisualMediaRequest>
private var pendingPickMediaPromise: Promise? = null
// Photo picker in multi-select mode, using the custom [PickUpToMedia] contract (see bottom of
// this file) so the item limit can be passed per call from JS.
private lateinit var pickMultipleMediaLauncher: ActivityResultLauncher<PickUpToMedia.Request>
private var pendingPickMultipleMediaPromise: Promise? = null
override fun initialize() {
super.initialize()
permissionLauncher =
context.registerForActivityResult(
this,
ActivityResultContracts.RequestPermission(),
{ isGranted: Boolean ->
pendingPermissionPromise?.resolve(isGranted)
pendingPermissionPromise = null
},
{ exception: RuntimeException ->
pendingPermissionPromise?.reject("E_ACTIVITY_RESULT_LAUNCH_FAILED", exception)
pendingPermissionPromise = null
},
)
pickMediaLauncher =
context.registerForActivityResult(
this,
ActivityResultContracts.PickVisualMedia(),
{ uri: Uri? ->
pendingPickMediaPromise?.resolve(uri?.toString())
pendingPickMediaPromise = null
},
{ exception: RuntimeException ->
pendingPickMediaPromise?.reject("E_ACTIVITY_RESULT_LAUNCH_FAILED", exception)
pendingPickMediaPromise = null
},
)
pickMultipleMediaLauncher =
context.registerForActivityResult(
this,
PickUpToMedia(),
{ uris: List<Uri> ->
val result: WritableArray = WritableNativeArray()
uris.forEach { result.pushString(it.toString()) }
pendingPickMultipleMediaPromise?.resolve(result)
pendingPickMultipleMediaPromise = null
},
{ exception: RuntimeException ->
pendingPickMultipleMediaPromise?.reject("E_ACTIVITY_RESULT_LAUNCH_FAILED", exception)
pendingPickMultipleMediaPromise = null
},
)
}
@DoNotStrip
override fun getBool(arg: Boolean): Boolean {
log("getBool", arg, arg)
@@ -281,6 +352,87 @@ public class SampleTurboModule(private val context: ReactApplicationContext) :
}
}
/**
* Demonstrates requesting a runtime permission through the [ActivityResultRegistry] owned by
* [com.facebook.react.bridge.ReactContext], rather than through the current Activity. Unlike
* [getImageUrl], this needs no Activity to be present at registration time and no cast to
* [ComponentActivity].
*/
@DoNotStrip
@Suppress("unused")
override fun requestSamplePermission(promise: Promise) {
if (pendingPermissionPromise != null) {
promise.reject("error", "A permission request is already in flight")
return
}
pendingPermissionPromise = promise
permissionLauncher.launch(Manifest.permission.CAMERA)
}
/**
* Maps the JS-provided mime type onto the photo picker's [VisualMediaType]: null selects images
* and videos, "image/&#42;" and "video/&#42;" restrict to one kind, and any other value is
* treated as a specific mime type (e.g. "image/gif").
*/
private fun visualMediaType(
mimeType: String?
): ActivityResultContracts.PickVisualMedia.VisualMediaType =
when (mimeType) {
null -> ActivityResultContracts.PickVisualMedia.ImageAndVideo
"image/*" -> ActivityResultContracts.PickVisualMedia.ImageOnly
"video/*" -> ActivityResultContracts.PickVisualMedia.VideoOnly
else -> ActivityResultContracts.PickVisualMedia.SingleMimeType(mimeType)
}
@DoNotStrip
@Suppress("unused")
override fun pickMedia(mimeType: String?, promise: Promise) {
if (pendingPickMediaPromise != null) {
promise.reject("error", "A media pick is already in flight")
return
}
pendingPickMediaPromise = promise
pickMediaLauncher.launch(PickVisualMediaRequest(visualMediaType(mimeType)))
}
@DoNotStrip
@Suppress("unused")
override fun pickMultipleMedia(mimeType: String?, maxItems: Double, promise: Promise) {
if (pendingPickMultipleMediaPromise != null) {
promise.reject("error", "A media pick is already in flight")
return
}
val limit = maxItems.toInt()
if (limit < 2) {
promise.reject("error", "maxItems must be at least 2, got $limit")
return
}
pendingPickMultipleMediaPromise = promise
pickMultipleMediaLauncher.launch(
PickUpToMedia.Request(limit, PickVisualMediaRequest(visualMediaType(mimeType)))
)
}
/**
* Starts a second ReactActivity to exercise multi-Activity navigation: the launchers above must
* rebind to the new Activity's registry (it resumes while the old Activity is still alive).
* Launched by class name to avoid a compile-time dependency on the app; the data URI deep-links
* the new surface straight to the picker example via Linking.
*/
@DoNotStrip
@Suppress("unused")
override fun startSecondActivity() {
val activity = context.currentActivity
if (activity == null) {
Toast.makeText(context, "No current Activity to launch from", Toast.LENGTH_LONG).show()
return
}
val intent =
Intent(Intent.ACTION_VIEW, "rntester://example/PhotoPickerAndroid".toUri())
.setClassName(activity, "${activity.packageName}.RNTesterSecondActivity")
activity.startActivity(intent)
}
private fun log(method: String, input: Any?, output: Any?) {
toast?.cancel()
val message = StringBuilder("Method :")
@@ -294,7 +446,38 @@ public class SampleTurboModule(private val context: ReactApplicationContext) :
toast?.show()
}
override fun invalidate(): Unit = Unit
override fun invalidate() {
if (::permissionLauncher.isInitialized) {
permissionLauncher.unregister()
}
if (::pickMediaLauncher.isInitialized) {
pickMediaLauncher.unregister()
}
if (::pickMultipleMediaLauncher.isInitialized) {
pickMultipleMediaLauncher.unregister()
}
// Reject anything still in flight: the JS context that made these calls is going away.
// Clearing the fields also lets the still-registered callbacks tolerate a late result.
pendingPermissionPromise?.reject(
"E_MODULE_INVALIDATED",
"Permission request cancelled: SampleTurboModule was invalidated",
)
pendingPermissionPromise = null
pendingPickMediaPromise?.reject(
"E_MODULE_INVALIDATED",
"Media pick cancelled: SampleTurboModule was invalidated",
)
pendingPickMediaPromise = null
pendingPickMultipleMediaPromise?.reject(
"E_MODULE_INVALIDATED",
"Multiple media pick cancelled: SampleTurboModule was invalidated",
)
pendingPickMultipleMediaPromise = null
super.invalidate()
}
override fun getName(): String {
return NAME
@@ -306,3 +489,28 @@ public class SampleTurboModule(private val context: ReactApplicationContext) :
public const val NAME: String = "SampleTurboModule"
}
}
/**
* Photo picker contract for multi-select with a per-call item limit. Stock
* [ActivityResultContracts.PickMultipleVisualMedia] fixes the limit in its constructor, but here it
* comes from JS per call. So the contract is subclassed to carry the limit in its input type, the
* pattern library authors should copy for any contract parameter that comes from JS.
*/
private class PickUpToMedia :
ActivityResultContract<PickUpToMedia.Request, List<@JvmSuppressWildcards Uri>>() {
class Request(val maxItems: Int, val request: PickVisualMediaRequest)
// Only used to build/parse intents; its constructor limit is always overwritten below.
private val delegate = ActivityResultContracts.PickMultipleVisualMedia(2)
@SuppressLint("InlinedApi")
override fun createIntent(context: Context, input: Request): Intent =
delegate.createIntent(context, input.request).apply {
// Honored by the system photo picker. On the pre-picker ACTION_OPEN_DOCUMENT fallback
// only single-vs-multiple is distinguished, so treat the limit as best-effort there.
putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, input.maxItems)
}
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
delegate.parseResult(resultCode, intent)
}
@@ -38,6 +38,7 @@ RCT_EXPORT_MODULE()
- (void)initialize
{
// ast-grep-ignore: common/objcpp/no-uiscreen-mainscreen
UIScreen *mainScreen = UIScreen.mainScreen;
CGSize screenSize = mainScreen.bounds.size;
@@ -81,6 +82,31 @@ RCT_EXPORT_MODULE()
reject(@"Exception", @"Not implemented", nil);
}
- (void)requestSamplePermission:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
{
reject(@"E_UNSUPPORTED_PLATFORM", @"Sample permissions are only available on Android", nil);
}
- (void)pickMedia:(NSString *_Nullable)mimeType
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
reject(@"E_UNSUPPORTED_PLATFORM", @"The photo picker sample is only available on Android", nil);
}
- (void)pickMultipleMedia:(NSString *_Nullable)mimeType
maxItems:(double)maxItems
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
reject(@"E_UNSUPPORTED_PLATFORM", @"The photo picker sample is only available on Android", nil);
}
- (void)startSecondActivity
{
// Android-only sample method.
}
#pragma mark - RCTTurboModuleWithJSIBindings
- (void)installJSIBindingsWithRuntime:(facebook::jsi::Runtime &)runtime
@@ -89,6 +89,16 @@
</intent-filter>
</activity>
<!-- Second ReactActivity used by the PhotoPickerAndroid example to exercise
multi-Activity navigation with ActivityResultContract launchers: starting it on top
of RNTesterActivity forces the launchers to rebind to this Activity's registry. -->
<activity
android:name=".RNTesterSecondActivity"
android:configChanges="orientation|screenSize|uiMode|fontScale"
android:exported="false"
android:label="@string/app_name"
android:screenOrientation="fullSensor" />
<provider
android:name="com.facebook.react.modules.blob.BlobProvider"
android:authorities="@string/blob_provider_authority"
@@ -22,7 +22,7 @@ import com.facebook.react.devsupport.DevMenuConfiguration
import java.io.FileDescriptor
import java.io.PrintWriter
internal class RNTesterActivity : ReactActivity() {
internal open class RNTesterActivity : ReactActivity() {
class RNTesterActivityDelegate(val activity: ReactActivity, mainComponentName: String) :
DefaultReactActivityDelegate(activity, mainComponentName) {
private val PARAM_ROUTE = "route"
@@ -0,0 +1,15 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.react.uiapp
/**
* A second ReactActivity used by the PhotoPickerAndroid example (via
* SampleTurboModule.startSecondActivity) to verify that ActivityResultContract launchers rebind to
* the current Activity's registry under multi-Activity navigation.
*/
internal class RNTesterSecondActivity : RNTesterActivity()
@@ -0,0 +1,213 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/
'use strict';
import type {RNTesterModuleExample} from '../../types/RNTesterTypes';
import RNTesterBlock from '../../components/RNTesterBlock';
import RNTesterPage from '../../components/RNTesterPage';
import RNTesterText from '../../components/RNTesterText';
import * as React from 'react';
import {useCallback, useState} from 'react';
import {
Image,
Platform,
StyleSheet,
ToastAndroid,
TouchableOpacity,
View,
} from 'react-native';
function getNativeSampleTurboModule() {
return require('../TurboModule/NativeSampleTurboModule').default;
}
/**
* Drives the Android photo picker through SampleTurboModule, which registers
* AndroidX ActivityResultContracts.PickVisualMedia / PickMultipleVisualMedia
* against the ReactContext (no MainActivity changes). The mimeType argument
* selects the picker mode: null shows images and videos, 'image/*' and
* 'video/*' restrict to one kind, and a concrete type such as 'image/gif'
* restricts to that type only.
*/
const PhotoPickerSingle = (): React.Node => {
const [uri, setUri] = useState<?string>(null);
const pick = useCallback(async (mimeType: ?string) => {
try {
const result = await getNativeSampleTurboModule().pickMedia?.(mimeType);
setUri(result);
} catch (e) {
ToastAndroid.show('' + e, ToastAndroid.LONG);
}
}, []);
return (
<>
<View style={styles.row}>
<PickerButton label="Images & videos" onPress={() => pick(null)} />
<PickerButton label="Images only" onPress={() => pick('image/*')} />
</View>
<View style={styles.row}>
<PickerButton label="Videos only" onPress={() => pick('video/*')} />
<PickerButton label="GIFs only" onPress={() => pick('image/gif')} />
</View>
<RNTesterText style={styles.uriText}>
{uri != null ? uri : 'Nothing selected'}
</RNTesterText>
{uri != null && <Image style={styles.image} source={{uri}} />}
</>
);
};
/**
* The item limit is a per-call JS argument rather than a fixed native
* constant. Native-side, this works by subclassing PickMultipleVisualMedia so
* the limit travels in the contract's launch input instead of its constructor
* (see PickUpToMedia in SampleTurboModule.kt), the pattern library authors
* should use for any contract parameter that comes from JS.
*/
const PhotoPickerMultiple = (): React.Node => {
const [uris, setUris] = useState<Array<string>>([]);
const pick = useCallback(async (maxItems: number) => {
try {
const result = await getNativeSampleTurboModule().pickMultipleMedia?.(
null,
maxItems,
);
setUris(result ?? []);
} catch (e) {
ToastAndroid.show('' + e, ToastAndroid.LONG);
}
}, []);
return (
<>
<View style={styles.row}>
<PickerButton label="Up to 3 items" onPress={() => pick(3)} />
<PickerButton label="Up to 5 items" onPress={() => pick(5)} />
</View>
<RNTesterText style={styles.uriText}>
{uris.length > 0
? `${uris.length} item(s) selected`
: 'Nothing selected'}
</RNTesterText>
<View style={styles.thumbnailRow}>
{uris.map(itemUri => (
<Image
key={itemUri}
style={styles.thumbnail}
source={{uri: itemUri}}
/>
))}
</View>
</>
);
};
/**
* Regression check for multi-Activity navigation: opening the second Activity
* must rebind the ReactContext-registered launchers to its registry, so picks
* on each screen deliver their results to that screen.
*/
const MultiActivity = (): React.Node => {
return (
<>
<RNTesterText style={styles.uriText}>
Opens this same example in a second Activity. Pick an image there: the
result must arrive on that screen. Then go back and pick here again.
</RNTesterText>
<View style={styles.row}>
<PickerButton
label="Open in a second Activity"
onPress={() => getNativeSampleTurboModule().startSecondActivity?.()}
/>
</View>
</>
);
};
function PickerButton(props: {label: string, onPress: () => unknown}) {
return (
<TouchableOpacity onPress={props.onPress} style={styles.buttonContainer}>
<View style={styles.button}>
<RNTesterText>{props.label}</RNTesterText>
</View>
</TouchableOpacity>
);
}
class PhotoPickerAndroidExample extends React.Component<{}, {}> {
render(): React.Node {
return (
<RNTesterPage title="Photo picker via ActivityResultContracts">
{Platform.OS === 'android' && (
<>
<RNTesterBlock title="Single select">
<PhotoPickerSingle />
</RNTesterBlock>
<RNTesterBlock title="Multi select (JS-controlled limit)">
<PhotoPickerMultiple />
</RNTesterBlock>
<RNTesterBlock title="Multi-Activity navigation">
<MultiActivity />
</RNTesterBlock>
</>
)}
</RNTesterPage>
);
}
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: 10,
},
buttonContainer: {
flex: 1,
},
button: {
padding: 10,
backgroundColor: '#009688',
marginBottom: 10,
alignItems: 'center',
},
uriText: {
paddingVertical: 8,
},
image: {
width: '100%',
resizeMode: 'cover',
height: 300,
},
thumbnailRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
},
thumbnail: {
width: 72,
height: 72,
resizeMode: 'cover',
},
});
exports.title = 'PhotoPickerAndroid';
exports.description =
'Android photo picker driven by a TurboModule via ActivityResultContracts.';
exports.examples = [
{
title: 'Photo picker',
render(): React.MixedElement {
return <PhotoPickerAndroidExample />;
},
},
] as Array<RNTesterModuleExample>;
@@ -67,6 +67,13 @@ export interface Spec extends TurboModule {
) => CodegenTypes.UnsafeObject;
readonly promiseAssert?: () => Promise<void>;
readonly getImageUrl?: () => Promise<string | null>;
readonly requestSamplePermission?: () => Promise<boolean>;
readonly pickMedia?: (mimeType: ?string) => Promise<string | null>;
readonly pickMultipleMedia?: (
mimeType: ?string,
maxItems: number,
) => Promise<Array<string>>;
readonly startSecondActivity?: () => void;
}
const RNTesterSampleTurboModule: Spec =
@@ -14,7 +14,13 @@ import RNTesterText from '../../components/RNTesterText';
import NativeSampleTurboModule, {EnumInt} from './NativeSampleTurboModule';
import styles from './TurboModuleExampleCommon';
import * as React from 'react';
import {FlatList, RootTagContext, TouchableOpacity, View} from 'react-native';
import {
FlatList,
Platform,
RootTagContext,
TouchableOpacity,
View,
} from 'react-native';
type State = {
testResults: {
@@ -67,6 +73,8 @@ type ErrorExamples =
| 'promiseAssert'
| 'installJSIBindings';
type AndroidExamples = 'requestSamplePermission';
class SampleTurboModuleExample extends React.Component<{}, State> {
static contextType: React.Context<RootTag> = RootTagContext;
eventSubscriptions: EventSubscription[] = [];
@@ -174,8 +182,22 @@ class SampleTurboModuleExample extends React.Component<{}, State> {
installJSIBindings: () => global.__SampleTurboModuleJSIBindings,
};
// $FlowFixMe[missing-local-annot]
_androidTests = {
requestSamplePermission: async () => {
try {
const isGranted =
await NativeSampleTurboModule.requestSamplePermission?.();
this._setResult('requestSamplePermission', isGranted);
} catch (e) {
this._setResult('requestSamplePermission', e.message);
return e.message;
}
},
};
_setResult(
name: Examples | ErrorExamples,
name: Examples | ErrorExamples | AndroidExamples,
result:
| $FlowFixMe
| void
@@ -289,6 +311,35 @@ class SampleTurboModuleExample extends React.Component<{}, State> {
</View>
)}
/>
{Platform.OS === 'android' && (
<>
<View style={styles.item}>
<RNTesterText style={styles.buttonTextLarge}>
Activity result tests (Android)
</RNTesterText>
</View>
<FlatList
// $FlowFixMe[incompatible-type]
data={Object.keys(this._androidTests)}
keyExtractor={item => item}
renderItem={({item}: {item: AndroidExamples, ...}) => (
<View style={styles.item}>
<TouchableOpacity
style={[styles.column, styles.button]}
onPress={e => this._androidTests[item]()}>
<RNTesterText style={styles.buttonText}>
{item}
</RNTesterText>
</TouchableOpacity>
<View style={[styles.column]}>
{/* $FlowFixMe[incompatible-type] */}
{this._renderResult(item)}
</View>
</View>
)}
/>
</>
)}
<View style={styles.item}>
<RNTesterText style={styles.buttonTextLarge}>
Report errors tests
@@ -206,6 +206,11 @@ const APIs: Array<RNTesterModuleInfo> = (
category: 'Android',
module: require('../examples/ContentURLAndroid/ContentURLAndroid'),
},
{
key: 'PhotoPickerAndroid',
category: 'Android',
module: require('../examples/PhotoPickerAndroid/PhotoPickerAndroid'),
},
{
key: 'URLExample',
category: 'Basic',