mirror of
https://github.com/react/react-native.git
synced 2026-09-28 05:13:06 +08:00
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:
committed by
meta-codesync[bot]
parent
ab1159ba61
commit
ec2b536e14
@@ -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
|
||||
|
||||
+128
@@ -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
|
||||
}
|
||||
+84
@@ -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>
|
||||
}
|
||||
+225
@@ -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
|
||||
}
|
||||
}
|
||||
+178
@@ -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.
|
||||
+75
@@ -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.
|
||||
*/
|
||||
|
||||
+169
@@ -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")
|
||||
}
|
||||
}
|
||||
+322
@@ -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)
|
||||
}
|
||||
}
|
||||
+209
-1
@@ -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/*" and "video/*" 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)
|
||||
}
|
||||
|
||||
+26
@@ -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"
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+15
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user