Skip to content

Commit ec2b536

Browse files
matinzdmeta-codesync[bot]
authored andcommitted
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](matinzd/react-native-health-connect#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: #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
1 parent ab1159b commit ec2b536

17 files changed

Lines changed: 1724 additions & 4 deletions

File tree

‎packages/react-native/ReactAndroid/api/ReactAndroid.api‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,10 @@ public abstract class com/facebook/react/bridge/ReactContext : android/content/C
10161016
public fun onNewIntent (Landroid/app/Activity;Landroid/content/Intent;)V
10171017
public fun onUserLeaveHint (Landroid/app/Activity;)V
10181018
public fun onWindowFocusChange (Z)V
1019+
public fun registerForActivityResult (Ljava/lang/Object;Landroidx/activity/result/contract/ActivityResultContract;Landroidx/activity/result/ActivityResultCallback;)Landroidx/activity/result/ActivityResultLauncher;
1020+
public fun registerForActivityResult (Ljava/lang/Object;Landroidx/activity/result/contract/ActivityResultContract;Landroidx/activity/result/ActivityResultCallback;Landroidx/activity/result/ActivityResultCallback;)Landroidx/activity/result/ActivityResultLauncher;
1021+
public fun registerForActivityResult (Ljava/lang/Object;Ljava/lang/String;Landroidx/activity/result/contract/ActivityResultContract;Landroidx/activity/result/ActivityResultCallback;)Landroidx/activity/result/ActivityResultLauncher;
1022+
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;
10191023
public abstract fun registerSegment (ILjava/lang/String;Lcom/facebook/react/bridge/Callback;)V
10201024
public fun removeActivityEventListener (Lcom/facebook/react/bridge/ActivityEventListener;)V
10211025
public fun removeExtraWindowEventListener (Lcom/facebook/react/interfaces/ExtraWindowEventListener;)V
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.activityresult
9+
10+
import androidx.activity.result.ActivityResultLauncher
11+
import androidx.activity.result.ActivityResultRegistry
12+
import androidx.activity.result.contract.ActivityResultContract
13+
import androidx.core.app.ActivityOptionsCompat
14+
import com.facebook.common.logging.FLog
15+
import com.facebook.react.bridge.UiThreadUtil
16+
import com.facebook.react.common.ReactConstants
17+
18+
/**
19+
* An [ActivityResultLauncher] that may exist before any `ActivityResultRegistry` is available: it
20+
* delegates to the real launcher once [bind] is called, queues a single [launch] issued while
21+
* unbound (fired on bind), and can be [unbind]-ed and rebound against a new host's registry.
22+
*
23+
* [delegate] and [pendingLaunch] are only touched on the UI thread; [launch] and [unregister] get
24+
* there via [onUiThread]. [launch] decides between delegating and queueing *on* the UI thread, so a
25+
* concurrent [unbind] cannot leave it pointed at a dead registry.
26+
*/
27+
internal class DeferredActivityResultLauncher<I>(
28+
private val key: String,
29+
override val contract: ActivityResultContract<I, *>,
30+
private val onUnregister: () -> Unit,
31+
private val onLaunchFailure: (RuntimeException) -> Unit = {},
32+
) : ActivityResultLauncher<I>() {
33+
34+
private class PendingLaunch<I>(val input: I, val options: ActivityOptionsCompat?)
35+
36+
private var delegate: ActivityResultLauncher<I>? = null
37+
private var boundRegistry: ActivityResultRegistry? = null
38+
private var pendingLaunch: PendingLaunch<I>? = null
39+
40+
override fun launch(input: I, options: ActivityOptionsCompat?) {
41+
onUiThread {
42+
val boundDelegate = delegate
43+
if (boundDelegate != null) {
44+
launchSafely(boundDelegate, input, options)
45+
} else {
46+
if (pendingLaunch != null) {
47+
FLog.w(
48+
ReactConstants.TAG,
49+
"Launcher for '$key' was launched again before an Activity was available; " +
50+
"replacing the previously queued launch.",
51+
)
52+
}
53+
pendingLaunch = PendingLaunch(input, options)
54+
}
55+
}
56+
}
57+
58+
override fun unregister() {
59+
// Drop the registration first so nothing rebinds this launcher in the meantime.
60+
onUnregister()
61+
onUiThread {
62+
try {
63+
delegate?.unregister()
64+
} catch (exception: RuntimeException) {
65+
FLog.e(
66+
ReactConstants.TAG,
67+
"Failed to unregister ActivityResult launcher '$key'.",
68+
exception,
69+
)
70+
} finally {
71+
delegate = null
72+
boundRegistry = null
73+
pendingLaunch = null
74+
}
75+
}
76+
}
77+
78+
/**
79+
* Attaches [launcher], obtained from [registry] (remembered for [isBoundTo]), and fires any
80+
* queued launch.
81+
*/
82+
fun bind(registry: ActivityResultRegistry, launcher: ActivityResultLauncher<I>) {
83+
UiThreadUtil.assertOnUiThread()
84+
delegate = launcher
85+
boundRegistry = registry
86+
pendingLaunch?.let { pending ->
87+
pendingLaunch = null
88+
launchSafely(launcher, pending.input, pending.options)
89+
}
90+
}
91+
92+
private fun launchSafely(
93+
launcher: ActivityResultLauncher<I>,
94+
input: I,
95+
options: ActivityOptionsCompat?,
96+
) {
97+
try {
98+
launcher.launch(input, options)
99+
} catch (exception: RuntimeException) {
100+
FLog.e(ReactConstants.TAG, "Failed to launch ActivityResult launcher '$key'.", exception)
101+
try {
102+
onLaunchFailure(exception)
103+
} catch (handlerException: RuntimeException) {
104+
FLog.e(
105+
ReactConstants.TAG,
106+
"Failure handler for ActivityResult launcher '$key' threw.",
107+
handlerException,
108+
)
109+
}
110+
}
111+
}
112+
113+
/** Detaches from the bound registry, keeping any queued launch for the next [bind]. */
114+
fun unbind() {
115+
UiThreadUtil.assertOnUiThread()
116+
try {
117+
delegate?.unregister()
118+
} catch (exception: RuntimeException) {
119+
FLog.e(ReactConstants.TAG, "Failed to unbind ActivityResult launcher '$key'.", exception)
120+
} finally {
121+
delegate = null
122+
boundRegistry = null
123+
}
124+
}
125+
126+
/** Whether this launcher is bound to [registry] itself, not just to any registry. */
127+
fun isBoundTo(registry: ActivityResultRegistry): Boolean = boundRegistry === registry
128+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.activityresult
9+
10+
import androidx.activity.result.ActivityResultCallback
11+
import androidx.activity.result.ActivityResultLauncher
12+
import androidx.activity.result.contract.ActivityResultContract
13+
14+
/**
15+
* Lets a native module register an AndroidX [ActivityResultContract] and receive results without
16+
* any changes to the consumer's `MainActivity`. Mirrors
17+
* `androidx.activity.ComponentActivity.registerForActivityResult`, except registration is legal at
18+
* any time: the returned launcher binds to the real registry once a host Activity resumes.
19+
*
20+
* Every registration carries a key that must be unique within the `ReactContext` and stable across
21+
* process death (AndroidX replays a restored result to whichever registration reproduces the same
22+
* key). The default key `"<owner class>:<contract class>"` lets unrelated libraries register the
23+
* same stock contract without colliding; a collision throws [IllegalStateException] at registration
24+
* time, and the keyed overload (which appends to that scope, not replaces it) resolves it.
25+
*/
26+
internal interface ReactActivityResultCaller {
27+
28+
/**
29+
* Registers [contract] under the key `"<owner class>:<contract class>"` and returns a launcher
30+
* for it. [owner] must be an instance of a named class — typically the native module itself.
31+
* Anonymous classes are rejected because their generated names can change between builds, which
32+
* breaks result delivery after the process is killed and restored. For the same reason, apps that
33+
* minify class names (R8/ProGuard) should keep the owner class's name, since the key is not
34+
* guaranteed to be stable between builds otherwise.
35+
*
36+
* @throws IllegalArgumentException if [owner] is an instance of an anonymous class
37+
* @throws IllegalStateException if [owner] already registered this contract class
38+
*/
39+
fun <I, O> registerForActivityResult(
40+
owner: Any,
41+
contract: ActivityResultContract<I, O>,
42+
callback: ActivityResultCallback<O>,
43+
): ActivityResultLauncher<I>
44+
45+
/**
46+
* Registers [contract] like the three-argument overload and calls [onLaunchFailure] when the
47+
* deferred UI-thread launch cannot be started.
48+
*/
49+
fun <I, O> registerForActivityResult(
50+
owner: Any,
51+
contract: ActivityResultContract<I, O>,
52+
callback: ActivityResultCallback<O>,
53+
onLaunchFailure: ActivityResultCallback<RuntimeException>,
54+
): ActivityResultLauncher<I>
55+
56+
/**
57+
* Registers [contract] under the key `"<owner class>:<contract class>:<key>"`. Use this when one
58+
* owner needs several launchers of the same contract class. [key] only has to be unique among
59+
* those, but must stay the same across process restarts, so derive it from a constant. [owner]
60+
* carries the same requirements as the two-argument overload: it must be an instance of a named
61+
* class.
62+
*
63+
* @throws IllegalArgumentException if [owner] is an instance of an anonymous class
64+
* @throws IllegalStateException if [owner] already registered this contract class under [key]
65+
*/
66+
fun <I, O> registerForActivityResult(
67+
owner: Any,
68+
key: String,
69+
contract: ActivityResultContract<I, O>,
70+
callback: ActivityResultCallback<O>,
71+
): ActivityResultLauncher<I>
72+
73+
/**
74+
* Registers [contract] like the keyed overload and calls [onLaunchFailure] when the deferred
75+
* UI-thread launch cannot be started.
76+
*/
77+
fun <I, O> registerForActivityResult(
78+
owner: Any,
79+
key: String,
80+
contract: ActivityResultContract<I, O>,
81+
callback: ActivityResultCallback<O>,
82+
onLaunchFailure: ActivityResultCallback<RuntimeException>,
83+
): ActivityResultLauncher<I>
84+
}

0 commit comments

Comments
 (0)