diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/DeferredActivityResultLauncher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/DeferredActivityResultLauncher.kt new file mode 100644 index 000000000000..6196a995ff5c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/DeferredActivityResultLauncher.kt @@ -0,0 +1,94 @@ +/* + * 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] handed out before the host Activity's `ActivityResultRegistry` is + * available. It delegates to the real launcher once [bind] is called, and queues a single pending + * [launch] issued while unbound, firing it on bind. [unbind] detaches it when the host Activity is + * destroyed so that [ReactActivityResultCallerImpl] can rebind it against the next host's registry. + * + * [launch] and [unregister] are called off the UI thread but reach `@MainThread` registry methods, + * so both hop. [delegate] and [pendingLaunch] are therefore UI-thread only and need no lock. Note + * [launch] decides bound-vs-queue *inside* the hop: doing it before would let a concurrent [unbind] + * strand the launch on a dead registry. + */ +internal class DeferredActivityResultLauncher( + private val key: String, + private val contract: ActivityResultContract, + private val onUnregister: () -> Unit, +) : ActivityResultLauncher() { + + override fun getContract(): ActivityResultContract = contract + + private class PendingLaunch(val input: I, val options: ActivityOptionsCompat?) + + private var delegate: ActivityResultLauncher? = null + private var boundRegistry: ActivityResultRegistry? = null + private var pendingLaunch: PendingLaunch? = null + + override fun launch(input: I, options: ActivityOptionsCompat?) { + onUiThread { + val boundDelegate = delegate + if (boundDelegate != null) { + boundDelegate.launch(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 while the hop is in flight. + onUnregister() + onUiThread { + delegate?.unregister() + delegate = null + pendingLaunch = null + } + } + + /** + * Attaches [launcher], obtained from [registry], and fires any launch queued while unbound. + * [registry] is remembered so [isBoundTo] can tell whether a later host is a different one. + */ + fun bind(registry: ActivityResultRegistry, launcher: ActivityResultLauncher) { + UiThreadUtil.assertOnUiThread() + delegate = launcher + boundRegistry = registry + pendingLaunch?.let { pending -> + pendingLaunch = null + launcher.launch(pending.input, pending.options) + } + } + + /** Detaches from the bound registry, keeping any queued launch for the next [bind]. */ + fun unbind() { + UiThreadUtil.assertOnUiThread() + delegate?.unregister() + delegate = null + boundRegistry = null + } + + /** Whether this launcher is already bound to [registry] specifically -- not merely to something. */ + fun isBoundTo(registry: ActivityResultRegistry): Boolean = boundRegistry === registry +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCaller.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCaller.kt new file mode 100644 index 000000000000..5b38b583db32 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCaller.kt @@ -0,0 +1,71 @@ +/* + * 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] against the host Activity's + * `ActivityResultRegistry` and receive results, without any changes to the consumer's + * `MainActivity`. + * + * The API deliberately mirrors `androidx.activity.ComponentActivity.registerForActivityResult`: + * same method name, same [ActivityResultCallback] shape, same returned [ActivityResultLauncher] + * type. Unlike an Activity, a caller obtained from a `ReactContext` may register at any time -- + * including before any Activity exists -- and the returned launcher binds lazily to the real + * registry once the host resumes. + * + * Every registration carries a key that must be unique within the `ReactContext` and stable across + * process death -- after the process is killed mid-flow, AndroidX replays the restored result to + * whichever registration reproduces the same key string. The default key is scoped to the caller + * (`":"`), which is what lets two unrelated libraries both register a + * stock contract such as `ActivityResultContracts.GetContent` without colliding. + * + * A collision throws an [IllegalStateException] at registration time. With owner scoping this is + * only reachable when a single owner registers the same contract class twice; the fix is the + * overload that takes an extra `key`, which is appended to -- not substituted for -- the + * owner-and-contract scope, so a poorly chosen key can never reintroduce a cross-library collision. + */ +internal interface ReactActivityResultCaller { + + /** + * Registers [contract] under the key `":"` and returns a launcher + * for it. + * + * [owner] should be a stable, long-lived object -- typically the native module itself. An + * anonymous object or a short-lived per-call helper yields a synthetic name such as + * `com.example.Foo$1`, which is fragile across builds and defeats re-association after process + * death. + * + * @throws IllegalStateException if [owner] already registered this contract class + */ + fun registerForActivityResult( + owner: Any, + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher + + /** + * Registers [contract] under the key `"::"`. Use this when one + * owner needs several launchers of the same contract class. + * + * [key] only has to be unique among [owner]'s registrations of this contract class -- the + * owner-and-contract scope is still applied -- but it must be stable across process death, so + * derive it from a constant rather than from runtime state. + * + * @throws IllegalStateException if [owner] already registered this contract class under [key] + */ + fun registerForActivityResult( + owner: Any, + key: String, + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt new file mode 100644 index 000000000000..d81673f90399 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt @@ -0,0 +1,166 @@ +/* + * 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` and its key tables are unsynchronized. Nothing enforces + * that at runtime, so an off-thread call corrupts them silently rather than throwing -- and RN + * registers on the JS thread and launches on the native-modules thread. + */ +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 -- native modules are created lazily, typically well after + * the host Activity has resumed -- and bound to the current Activity's [ActivityResultRegistry] + * either immediately (when an Activity is already available) or on the next `onHostResume`. + * Registrations outlive any single Activity: keys stay stable, so AndroidX can re-associate a result + * that arrives after Activity recreation. + * + * ## Which registry a launcher is bound to + * + * Every `onHostResume` reconciles each launcher against the *current* registry, rebinding it if it + * is attached to a different one. It deliberately does not stop at "already bound to something": + * with multi-Activity navigation the new Activity resumes before the old one is destroyed, and + * `ReactHostImpl.onHostDestroy(activity)` drops the old Activity's destroy entirely once + * `currentActivity` has moved on. A launcher that only checked "am I bound?" would stay attached to + * the previous Activity's dead registry -- leaking it, and misrouting anything launched from the new + * screen. + * + * ## Threading + * + * [entries] is concurrent and reachable from any thread. Everything that touches + * [ActivityResultRegistry] goes through [onUiThread]. + * + * Registration itself stays on the caller's thread, so the launcher is returned immediately and a + * duplicate key throws from the frame that caused it. Only the registry call is hopped. + */ +internal class ReactActivityResultCallerImpl(private val reactContext: ReactContext) : + ReactActivityResultCaller, LifecycleEventListener { + + private class Entry( + val key: String, + val registrantDescription: String, + private val contract: ActivityResultContract, + private val callback: ActivityResultCallback, + val launcher: DeferredActivityResultLauncher, + ) { + /** + * 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 the previous host's registry first: it may already be dead, and leaving the + // callback registered there leaks that Activity and misroutes anything launched from it. + launcher.unbind() + launcher.bind(registry, registry.register(key, contract, callback)) + } + } + + private val entries = ConcurrentHashMap>() + + init { + reactContext.addLifecycleEventListener(this) + } + + private fun getOwnerId(owner: Any): String = owner.javaClass.name + + override fun registerForActivityResult( + owner: Any, + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher { + val id = getOwnerId(owner) + return register( + key = "$id:${contract.javaClass.name}", + registrantDescription = id, + collisionHint = + "Register once and reuse the launcher, or pass a distinct key per launcher: " + + "registerForActivityResult(owner, \"someName\", contract, callback).", + contract = contract, + callback = callback) + } + + override fun registerForActivityResult( + owner: Any, + key: String, + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher { + val id = getOwnerId(owner) + return register( + key = "$id:${contract.javaClass.name}:$key", + registrantDescription = id, + collisionHint = "Pass a key that is unique among this owner's launchers of this contract.", + contract = contract, + callback = callback) + } + + private fun register( + key: String, + registrantDescription: String, + collisionHint: String, + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher { + val launcher = DeferredActivityResultLauncher(key, contract) { entries.remove(key) } + val entry = Entry(key, registrantDescription, contract, callback, launcher) + entries.putIfAbsent(key, entry)?.let { existing -> + throw IllegalStateException( + "${existing.registrantDescription} already registered a launcher for key '$key'. " + + collisionHint) + } + onUiThread { currentRegistry()?.let { registry -> entry.bindTo(registry) } } + return launcher + } + + override fun onHostResume() = onUiThread { + val registry = currentRegistry() ?: return@onUiThread + entries.values.forEach { it.bindTo(registry) } + } + + override fun onHostPause(): Unit = Unit + + override fun onHostDestroy() = onUiThread { + // Detach from the dying registry but keep the registrations: they rebind against the next host's + // registry under the same keys on the next onHostResume, which is how AndroidX re-associates a + // result that outlives the Activity. + entries.values.forEach { it.launcher.unbind() } + } + + private fun currentRegistry(): ActivityResultRegistry? { + val activity = reactContext.currentActivity ?: return null + val owner = activity as? ActivityResultRegistryOwner + if (owner == null) { + FLog.w( + ReactConstants.TAG, + "Current Activity ${activity.javaClass.name} is not an ActivityResultRegistryOwner; " + + "ActivityResultContract launchers will stay queued until one is available.") + return null + } + return owner.activityResultRegistry + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/__docs__/README.md b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/__docs__/README.md new file mode 100644 index 000000000000..6ea9174dc5db --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/__docs__/README.md @@ -0,0 +1,267 @@ +# 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) +against the host Activity's `ActivityResultRegistry` and receive results, with +no changes to the consumer app's `MainActivity`, no manifest entries, and no +library-shipped transparent Activities. + +Before this existed, modules had to use `ActivityEventListener` with +self-assigned int request codes: codes live in a global namespace with no +coordination between libraries, results are broadcast so every listener filters, +and intents are built and parsed by hand. On Android 14+ some contracts (e.g. +Health Connect's permission contract) produce a synthetic intent that only an +`ActivityResultRegistry` can service, so the classic `startActivityForResult` +path fails with `ActivityNotFoundException` outright. + +## 🚀 Usage + +The API is `ReactContext.registerForActivityResult`, deliberately identical in +shape to +[`ComponentActivity.registerForActivityResult`](https://developer.android.com/training/basics/intents/result#register): +same name, same `ActivityResultCallback`, and it returns the real +`androidx.activity.result.ActivityResultLauncher`. The one addition is a +leading `owner` argument, which scopes the registration key; see +[Registration keys and collisions](#registration-keys-and-collisions). + +```kotlin +class MyModule(private val context: ReactApplicationContext) : + NativeMyModuleSpec(context) { + + private var pendingPromise: Promise? = null + + // Registering in a field initializer is fine: modules are created lazily, + // long after the Activity exists, and registration is legal at any time. + 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: + +```kotlin +private val pickMedia = + context.registerForActivityResult( + /* owner = */ this, + ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> + // null when the user dismissed the picker + } + +pickMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) +``` + +### Registration keys and collisions + +Registrations are keyed by `":"`, derived by core +from the `owner` you pass. Because a class's fully-qualified name is globally +unique, two unrelated libraries can both register a stock contract such as +`GetContent` and never collide: + +```kotlin +// react-native-image-lib +class ImageModule(ctx: ReactApplicationContext) : NativeImageModuleSpec(ctx) { + private val pick = ctx.registerForActivityResult(this, GetContent()) { uri -> } + // key = "com.rnimage.ImageModule:androidx...GetContent" +} + +// react-native-doc-lib: same contract, different owner, no collision +class DocModule(ctx: ReactApplicationContext) : NativeDocModuleSpec(ctx) { + private val pick = ctx.registerForActivityResult(this, GetContent()) { uri -> } + // key = "com.rndoc.DocModule:androidx...GetContent" +} +``` + +Pass a stable, long-lived `owner`, normally the module itself. An anonymous +object or a per-call helper yields a synthetic name like `com.example.Foo$1`, +which is fragile across builds and defeats re-association after process death. + +A collision still throws `IllegalStateException` at registration time, but with +owner scoping it is only reachable from one owner's own code: registering the +same contract class twice. The fix is the **extra-key overload**: + +```kotlin +// Throws: same owner, same contract class, same key: +private val pickAvatar = ctx.registerForActivityResult(this, GetContent()) { } +private val pickBanner = ctx.registerForActivityResult(this, GetContent()) { } + +// Fix: +private val pickAvatar = ctx.registerForActivityResult(this, "avatar", GetContent()) { } +// key = "com.example.MyModule:androidx...GetContent:avatar" +private val pickBanner = ctx.registerForActivityResult(this, "banner", GetContent()) { } +// key = "com.example.MyModule:androidx...GetContent:banner" +``` + +The key you pass is **appended to** the owner-and-contract scope, not +substituted for it, so it only has to be unique among that owner's launchers of +that contract, and no choice of key can reintroduce a cross-library collision. +It does still have to be stable across process death, so derive it from a +constant rather than from runtime state. + +#### Why not auto-generated keys, like `ComponentActivity`? + +`ComponentActivity` can key registrations by an incrementing counter +(`activity_rq#0`, `activity_rq#1`, …) because it registers in `onCreate`, in a +deterministic order every time. React Native cannot: native modules are created +lazily, in whatever order JS first touches them, so after process death +`activity_rq#0` may belong to a _different_ module than it did before. A +restored result would then be dispatched to the wrong callback and parsed with +the wrong contract. Deriving the key from the owner and contract classes keeps +it reproducible regardless of creation order. + +### Parameterized contracts: passing values from JS per call + +Contract constructor arguments are fixed at registration time. If a value comes +from JS per call (say the photo picker's item limit), move it into the +contract's **input** type, where it becomes a `launch()` argument. Subclass the +stock contract and delegate: + +```kotlin +private class PickUpToMedia : + ActivityResultContract>() { + class Request(val maxItems: Int, val request: PickVisualMediaRequest) + + private val delegate = ActivityResultContracts.PickMultipleVisualMedia(2) + + override fun createIntent(context: Context, input: Request): Intent = + delegate.createIntent(context, input.request).apply { + putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, input.maxItems) + } + + override fun parseResult(resultCode: Int, intent: Intent?): List = + delegate.parseResult(resultCode, intent) +} + +// One registration serves every limit JS asks for: +launcher.launch(PickUpToMedia.Request(jsMaxItems, request)) +``` + +This is the pattern for _any_ per-call parameter. (It also happens to yield a +distinct key, since the subclass has its own class name, but that is incidental; +collisions are handled by owner scoping and the extra-key overload above.) + +### Working examples + +- `SampleTurboModule.kt` + (`ReactCommon/react/nativemodule/samples/platform/android/`): + `requestSamplePermission` (runtime permission), `pickMedia` (photo picker, + single select), `pickMultipleMedia` (multi select with a JS-controlled limit + via the `PickUpToMedia` contract above). +- rn-tester screens: `TurboModule/SampleTurboModuleExample.js` and + `PhotoPickerAndroid/PhotoPickerAndroid.js`. + +## 📐 Design + +`ReactActivity` extends `ComponentActivity`, so the host Activity already owns a +real `ActivityResultRegistry` and already routes `onActivityResult` / +`onRequestPermissionsResult` into it. This package only bridges the timing gap +between lazily-created modules and that registry; it does not fork or +reimplement the registry. + +- `ReactActivityResultCaller` / `ReactActivityResultCallerImpl` (internal): + owned by the `ReactContext`, holds `(key, contract, callback)` registrations, + and binds them to the current Activity's registry: immediately when an + Activity is available, otherwise on the next `onHostResume`. +- `DeferredActivityResultLauncher` (internal): the launcher handed to callers. + Delegates to the real AndroidX launcher once bound; a `launch()` issued while + unbound is queued (latest wins) and fired on bind. +- Registrations outlive any single Activity. `onHostDestroy` detaches them from + the dying registry but keeps them, and every `onHostResume` reconciles each + launcher against the **current** registry, rebinding it if it is attached to a + different one. Stable keys are what let AndroidX re-associate a result that + arrives after Activity recreation. + + Reconciling on every resume, rather than binding only when a launcher is + unbound, is required for multi-Activity navigation. There, the new Activity + resumes _before_ the old one is destroyed, and + `ReactHostImpl.onHostDestroy( activity)` then drops the old Activity's destroy + entirely because `currentActivity` has already moved on. A launcher that + stopped at "am I bound to something?" would stay attached to the previous + Activity's dead registry: it would leak that Activity, and a launch from the + new screen would dispatch into the old one. The single-Activity config-change + path never showed this, because there the destroy and the resume are strictly + ordered. + +### Threading + +`ActivityResultRegistry` is `@MainThread`, and its key tables are plain +unsynchronized maps. The annotation is not enforced at runtime, so calling it +off the main thread does not throw; it corrupts those maps silently, which +surfaces later as a lost registration, a `ConcurrentModificationException`-class +crash inside AndroidX's own `onSaveInstanceState` (i.e. on rotation), or two +keys sharing one request code, which delivers a result to the wrong callback. + +React Native never calls it from the main thread by default: modules are +constructed on the JS thread, so field-initializer registrations arrive on +`mqt_v_js`, and module methods run on `mqt_v_native`, so `launch()` arrives from +there. So this package hops. State is split by owner: + +- **Registration bookkeeping** (the key table used for collision detection) is a + concurrent map, callable from any thread. Claiming a key is a single atomic + operation, so two threads registering at once cannot both win. +- **Every call that reaches `ActivityResultRegistry`** (`register`, `launch`, + `unregister`) is confined to the UI thread, as is the launcher's binding + state. Each is asserted with `UiThreadUtil.assertOnUiThread()` in debug + builds, so a regression fails loudly instead of corrupting a map. + +Two consequences worth knowing: + +- **Registration is still synchronous.** You get the launcher back immediately, + and a duplicate key throws from your own frame rather than later on the UI + thread where it could not be traced back to you. Only the registry call hops. +- **`launch()` from a background thread is asynchronous.** It never guaranteed a + synchronous activity start anyway, since binding is deferred until an Activity + exists. + +Behavioral notes for library authors: + +- **Register early, ideally in a field initializer or the module constructor.** + Registration is cheap and legal at any time; launching is what needs an + Activity. +- **An Activity that is not an `ActivityResultRegistryOwner`** (i.e. does not + extend `ComponentActivity`) cannot service launchers; they stay queued and a + warning is logged. +- **Process death:** AndroidX redelivers a pending result under the same key + after the process is recreated, but whatever state your module held for the + in-flight call (typically a `Promise`) died with the JS context. Design + callbacks to tolerate firing with no pending state. +- **`unregister()`** on the returned launcher removes the registration; the same + key can then be registered again. + +## 🔗 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 and owns the caller instance; its + `LifecycleEventListener` events (`onHostResume` / `onHostDestroy`) drive + binding and rebinding. +- AndroidX `androidx.activity.result`: the contracts, launchers, and the host + Activity's `ActivityResultRegistry` that actually starts activities and + dispatches results. + +### Uses this + +- `SampleTurboModule` (demo) and, prospectively, third-party native modules that + need activity results or AndroidX permission contracts (e.g. Health Connect). + +This API coexists with `ActivityEventListener`, which is unchanged: results +claimed by the AndroidX registry are consumed by it, everything else still +reaches `ActivityEventListener.onActivityResult`. The listener remains the right +tool for intents a module builds and starts itself. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java index 15b0d6691a85..30748d599948 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java @@ -16,12 +16,18 @@ 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; @@ -29,6 +35,7 @@ import com.facebook.react.common.build.ReactBuildConfig; import com.facebook.react.interfaces.ExtraWindowEventListener; import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder; + import java.lang.ref.WeakReference; import java.util.Collection; import java.util.concurrent.CopyOnWriteArraySet; @@ -67,6 +74,7 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule { private @Nullable JSExceptionHandler mJSExceptionHandler; private @Nullable JSExceptionHandler mExceptionHandlerWrapper; private @Nullable WeakReference 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 +540,51 @@ public boolean startActivityForResult(Intent intent, int code, Bundle bundle) { return mCurrentActivity.get(); } + private synchronized ReactActivityResultCallerImpl getActivityResultCaller() { + if (mActivityResultCaller == null) { + mActivityResultCaller = new ReactActivityResultCallerImpl(this); + } + return mActivityResultCaller; + } + + /** + * Registers an AndroidX {@code ActivityResultContract} against the host Activity's {@code + * ActivityResultRegistry} and returns a launcher for it, mirroring {@code + * ComponentActivity.registerForActivityResult}. Requires no changes to the consumer's {@code + * MainActivity}. Registration is legal at any time; the returned launcher binds lazily once an + * Activity is available, and a {@code launch} issued while unbound is queued and fired on bind. + * + *

The registration key is {@code ":"}, so two unrelated libraries + * may both register a stock contract such as {@code ActivityResultContracts.GetContent} without + * colliding. {@code owner} should be a stable, long-lived object -- typically the native module + * itself -- because the key must be reproducible after process death. Registering the same + * contract class twice from one owner throws {@link IllegalStateException}; use {@link + * #registerForActivityResult(Object, String, ActivityResultContract, ActivityResultCallback)} in + * that case. + */ + public ActivityResultLauncher registerForActivityResult( + Object owner, ActivityResultContract contract, ActivityResultCallback callback) { + return getActivityResultCaller().registerForActivityResult(owner, contract, callback); + } + + /** + * Same as {@link #registerForActivityResult(Object, ActivityResultContract, + * ActivityResultCallback)}, but registers under {@code "::"}. + * Use this when one owner needs several launchers of the same contract class. {@code key} only + * has to be unique among {@code owner}'s registrations of this contract class -- the + * owner-and-contract scope is still applied -- but it must be stable across process death. + * + * @throws IllegalStateException if {@code owner} already registered this contract class under + * {@code key} + */ + public ActivityResultLauncher registerForActivityResult( + Object owner, + String key, + ActivityResultContract contract, + ActivityResultCallback callback) { + return getActivityResultCaller().registerForActivityResult(owner, key, contract, callback); + } + /** * @deprecated DO NOT USE, this method will be removed in the near future. */ diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerImplTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerImplTest.kt new file mode 100644 index 000000000000..773387abf458 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerImplTest.kt @@ -0,0 +1,164 @@ +/* + * 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 onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ): Unit = Unit + + /** [onSaveInstanceState] is the only public window into the registry's key table. */ + val registeredKeys: List + 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() + 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") + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerThreadingTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerThreadingTest.kt new file mode 100644 index 000000000000..f7d3954d2836 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerThreadingTest.kt @@ -0,0 +1,238 @@ +/* + * 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 android.os.Looper +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() + + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + launchThreads += Thread.currentThread().name + } + + /** [onSaveInstanceState] is the only public window into the registry's key table. */ + val registeredKeys: List + get() = + Bundle() + .also { onSaveInstanceState(it) } + .getStringArrayList("KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS") + .orEmpty() + } + + 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() + 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 `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 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() + + 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).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) + } +} diff --git a/packages/react-native/ReactCommon/react/nativemodule/samples/platform/android/SampleTurboModule.kt b/packages/react-native/ReactCommon/react/nativemodule/samples/platform/android/SampleTurboModule.kt index e6ae5f5060f1..cfc5e8df9dd8 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/samples/platform/android/SampleTurboModule.kt +++ b/packages/react-native/ReactCommon/react/nativemodule/samples/platform/android/SampleTurboModule.kt @@ -7,11 +7,18 @@ package com.facebook.fbreact.specs +import android.Manifest +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 com.facebook.proguard.annotations.DoNotStrip import com.facebook.react.bridge.Arguments @@ -36,6 +43,41 @@ public class SampleTurboModule(private val context: ReactApplicationContext) : private var toast: Toast? = null + private var pendingPermissionPromise: Promise? = null + + // Registered up-front against the ReactContext's own ActivityResultRegistry. This works even + // though SampleTurboModule is instantiated lazily, long after the host Activity has resumed. + private val permissionLauncher: ActivityResultLauncher = + context.registerForActivityResult(this, ActivityResultContracts.RequestPermission()) { + isGranted: Boolean -> + pendingPermissionPromise?.resolve(isGranted) + pendingPermissionPromise = null + } + + private var pendingPickMediaPromise: 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 val pickMediaLauncher: ActivityResultLauncher = + context.registerForActivityResult(this, ActivityResultContracts.PickVisualMedia()) { + uri: Uri? -> + pendingPickMediaPromise?.resolve(uri?.toString()) + pendingPickMediaPromise = null + } + + private var pendingPickMultipleMediaPromise: 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 val pickMultipleMediaLauncher: ActivityResultLauncher = + context.registerForActivityResult(this, PickUpToMedia()) { uris: List -> + val result: WritableArray = WritableNativeArray() + uris.forEach { result.pushString(it.toString()) } + pendingPickMultipleMediaPromise?.resolve(result) + pendingPickMultipleMediaPromise = null + } + @DoNotStrip override fun getBool(arg: Boolean): Boolean { log("getBool", arg, arg) @@ -249,6 +291,64 @@ 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)))) + } + private fun log(method: String, input: Any?, output: Any?) { toast?.cancel() val message = StringBuilder("Method :") @@ -262,7 +362,23 @@ public class SampleTurboModule(private val context: ReactApplicationContext) : toast?.show() } - override fun invalidate(): Unit = Unit + override fun invalidate() { + // Reject anything still in flight: the JS context that made these calls is going away, so the + // results can never be delivered. Clearing the fields also lets the callbacks (which stay + // registered until the launchers are unregistered) tolerate a late result harmlessly. + 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 @@ -274,3 +390,31 @@ 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, i.e. at + * registration time -- but here the limit comes from JS per call. The AndroidX-idiomatic fix, which + * library authors should copy, is to subclass the contract and move the dynamic value into the + * contract's *input* type, where it becomes a [androidx.activity.result.ActivityResultLauncher.launch] + * argument. The subclass also gets its own registration key for free (keys are the contract's class + * name), so it never collides with a stock [ActivityResultContracts.PickMultipleVisualMedia] + * registered by someone else. + */ +private class PickUpToMedia : + ActivityResultContract>() { + 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) + + 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 = + delegate.parseResult(resultCode, intent) +} diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeSampleTurboModule.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeSampleTurboModule.js index 65ee3fe605c1..e89e91d09162 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeSampleTurboModule.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeSampleTurboModule.js @@ -62,6 +62,12 @@ export interface Spec extends TurboModule { // Android-only readonly getImageUrl?: () => Promise; + readonly requestSamplePermission?: () => Promise; + readonly pickMedia?: (mimeType: ?string) => Promise; + readonly pickMultipleMedia?: ( + mimeType: ?string, + maxItems: number, + ) => Promise>; } export default TurboModuleRegistry.getEnforcing( diff --git a/packages/rn-tester/js/examples/PhotoPickerAndroid/PhotoPickerAndroid.js b/packages/rn-tester/js/examples/PhotoPickerAndroid/PhotoPickerAndroid.js new file mode 100644 index 000000000000..0f4e50dbb2c1 --- /dev/null +++ b/packages/rn-tester/js/examples/PhotoPickerAndroid/PhotoPickerAndroid.js @@ -0,0 +1,189 @@ +/** + * 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('react-native/Libraries/TurboModule/samples/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(null); + const pick = useCallback(async (mimeType: ?string) => { + try { + const result = await getNativeSampleTurboModule().pickMedia?.(mimeType); + setUri(result); + } catch (e) { + ToastAndroid.show('' + e, ToastAndroid.LONG); + } + }, []); + + return ( + <> + + pick(null)} /> + pick('image/*')} /> + + + pick('video/*')} /> + pick('image/gif')} /> + + + {uri != null ? uri : 'Nothing selected'} + + {uri != null && } + + ); +}; + +/** + * 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>([]); + const pick = useCallback(async (maxItems: number) => { + try { + const result = await getNativeSampleTurboModule().pickMultipleMedia?.( + null, + maxItems, + ); + setUris(result ?? []); + } catch (e) { + ToastAndroid.show('' + e, ToastAndroid.LONG); + } + }, []); + + return ( + <> + + pick(3)} /> + pick(5)} /> + + + {uris.length > 0 + ? `${uris.length} item(s) selected` + : 'Nothing selected'} + + + {uris.map(itemUri => ( + + ))} + + + ); +}; + +function PickerButton(props: {label: string, onPress: () => unknown}) { + return ( + + + {props.label} + + + ); +} + +class PhotoPickerAndroidExample extends React.Component<{}, {}> { + render(): React.Node { + return ( + + {Platform.OS === 'android' && ( + <> + + + + + + + + )} + + ); + } +} + +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 ; + }, + }, +] as Array; diff --git a/packages/rn-tester/js/examples/TurboModule/SampleTurboModuleExample.js b/packages/rn-tester/js/examples/TurboModule/SampleTurboModuleExample.js index c1c5803c06c1..830950a970af 100644 --- a/packages/rn-tester/js/examples/TurboModule/SampleTurboModuleExample.js +++ b/packages/rn-tester/js/examples/TurboModule/SampleTurboModuleExample.js @@ -13,7 +13,13 @@ import type {EventSubscription, RootTag} from 'react-native'; import RNTesterText from '../../components/RNTesterText'; 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'; import NativeSampleTurboModule from 'react-native/Libraries/TurboModule/samples/NativeSampleTurboModule'; import {EnumInt} from 'react-native/Libraries/TurboModule/samples/NativeSampleTurboModule'; @@ -61,6 +67,8 @@ type ErrorExamples = | 'getObjectAssert' | 'promiseAssert'; +type AndroidExamples = 'requestSamplePermission'; + class SampleTurboModuleExample extends React.Component<{}, State> { static contextType: React.Context = RootTagContext; eventSubscriptions: EventSubscription[] = []; @@ -163,8 +171,20 @@ class SampleTurboModuleExample extends React.Component<{}, State> { }, }; + // Kept out of `_tests` so that "Run all tests" does not raise a system permission dialog. + // $FlowFixMe[missing-local-annot] + _androidTests = { + requestSamplePermission: () => { + NativeSampleTurboModule.requestSamplePermission?.() + .then(isGranted => + this._setResult('requestSamplePermission', isGranted), + ) + .catch(e => this._setResult('requestSamplePermission', e.message)); + }, + }; + _setResult( - name: Examples | ErrorExamples, + name: Examples | ErrorExamples | AndroidExamples, result: | $FlowFixMe | void @@ -281,6 +301,34 @@ class SampleTurboModuleExample extends React.Component<{}, State> { )} /> + {Platform.OS === 'android' && ( + <> + + + Activity result tests (Android) + + + item} + renderItem={({item}: {item: AndroidExamples, ...}) => ( + + this._androidTests[item]()}> + + {item} + + + + {this._renderResult(item)} + + + )} + /> + + )} Report errors tests diff --git a/packages/rn-tester/js/utils/RNTesterList.android.js b/packages/rn-tester/js/utils/RNTesterList.android.js index dd9069968353..0df6c5fd2193 100644 --- a/packages/rn-tester/js/utils/RNTesterList.android.js +++ b/packages/rn-tester/js/utils/RNTesterList.android.js @@ -206,6 +206,11 @@ const APIs: Array = ( category: 'Android', module: require('../examples/ContentURLAndroid/ContentURLAndroid'), }, + { + key: 'PhotoPickerAndroid', + category: 'Android', + module: require('../examples/PhotoPickerAndroid/PhotoPickerAndroid'), + }, { key: 'URLExample', category: 'Basic',