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..f900504f858f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/DeferredActivityResultLauncher.kt @@ -0,0 +1,79 @@ +/* + * 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.contract.ActivityResultContract +import androidx.core.app.ActivityOptionsCompat +import com.facebook.common.logging.FLog +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. + */ +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 pendingLaunch: PendingLaunch? = null + + @Synchronized + override fun launch(input: I, options: ActivityOptionsCompat?) { + 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) + } + } + + @Synchronized + override fun unregister() { + delegate?.unregister() + delegate = null + pendingLaunch = null + onUnregister() + } + + /** Attaches the real launcher and fires any launch queued while unbound. */ + @Synchronized + fun bind(launcher: ActivityResultLauncher) { + delegate = launcher + pendingLaunch?.let { pending -> + pendingLaunch = null + launcher.launch(pending.input, pending.options) + } + } + + /** Detaches from a dying registry, keeping any queued launch for the next [bind]. */ + @Synchronized + fun unbind() { + delegate?.unregister() + delegate = null + } + + @get:Synchronized + val isBound: Boolean + get() = delegate != null +} 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..145d36476f5a --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCaller.kt @@ -0,0 +1,52 @@ +/* + * 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. + * + * Registrations are keyed by the contract's fully-qualified class name. Registering the same + * contract class twice throws an [IllegalStateException] at registration time; disambiguate by + * subclassing the contract or using the [registerForActivityResult] overload that takes an `owner`. + */ +internal interface ReactActivityResultCaller { + + /** + * Registers [contract] and returns a launcher for it. The registration key is the contract's + * fully-qualified class name. + * + * @throws IllegalStateException if a registration with the same key already exists + */ + fun registerForActivityResult( + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher + + /** + * Same as [registerForActivityResult], but scopes the registration key to [owner] + * (`":"`). Use this when two independent callers need the same + * stock contract class, or when registering from something other than a native module. + */ + fun registerForActivityResult( + owner: Any, + 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..ec671f4b9b14 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt @@ -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.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.common.ReactConstants + +/** + * 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`. When + * the host Activity is destroyed the registrations are kept and rebound against the new Activity's + * registry under the same keys, so AndroidX can re-associate a result that arrives after Activity + * recreation. + */ +internal class ReactActivityResultCallerImpl(private val reactContext: ReactContext) : + ReactActivityResultCaller, LifecycleEventListener { + + private class Entry( + val key: String, + val registrantDescription: String, + val contract: ActivityResultContract, + val callback: ActivityResultCallback, + val launcher: DeferredActivityResultLauncher, + ) + + private val entries = LinkedHashMap>() + + init { + reactContext.addLifecycleEventListener(this) + } + + override fun registerForActivityResult( + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher = + register(contract.javaClass.name, callback.javaClass.name, contract, callback) + + override fun registerForActivityResult( + owner: Any, + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher = + register( + "${owner.javaClass.name}:${contract.javaClass.name}", + owner.javaClass.name, + contract, + callback) + + @Synchronized + private fun register( + key: String, + registrantDescription: String, + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher { + entries[key]?.let { existing -> + throw IllegalStateException( + "A launcher is already registered for key '$key' (registered by " + + "${existing.registrantDescription}, now requested by $registrantDescription). " + + "Subclass the contract to get a distinct key, or use the " + + "registerForActivityResult(owner, contract, callback) overload.") + } + val launcher = DeferredActivityResultLauncher(key, contract) { unregister(key) } + val entry = Entry(key, registrantDescription, contract, callback, launcher) + entries[key] = entry + currentRegistry()?.let { registry -> bind(entry, registry) } + return launcher + } + + @Synchronized + private fun unregister(key: String) { + entries.remove(key) + } + + @Synchronized + override fun onHostResume() { + val registry = currentRegistry() ?: return + for (entry in entries.values) { + if (!entry.launcher.isBound) { + bind(entry, registry) + } + } + } + + override fun onHostPause(): Unit = Unit + + @Synchronized + override fun onHostDestroy() { + // Detach from the dying registry but keep the registrations: they are rebound against the next + // host's registry (same keys) on the next onHostResume, which is also how a result that + // outlives the Activity gets re-associated by AndroidX. + for (entry in entries.values) { + entry.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 + } + + private fun bind(entry: Entry, registry: ActivityResultRegistry) { + entry.launcher.bind(registry.register(entry.key, entry.contract, entry.callback)) + } +} 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..62e51d2ad3b1 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/__docs__/README.md @@ -0,0 +1,180 @@ +# 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`. + +```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( + 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( + ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> + // null when the user dismissed the picker + } + +pickMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) +``` + +### Registration keys and collisions + +The registration key is the contract's fully-qualified class name, derived by +core and never passed by the caller. Registering the same contract class twice +on one `ReactContext` throws `IllegalStateException` at registration time, +naming both registrants. Two ways to disambiguate: + +- **Subclass the contract** (preferred; see the parameterized-contract pattern + below) — the subclass has its own class name and therefore its own key. +- **Use the owner overload** — + `context.registerForActivityResult(owner, contract, callback)` scopes the key + to `":"`: + +```kotlin +private val getContent = + context.registerForActivityResult( + /* owner = */ this, ActivityResultContracts.GetContent()) { uri -> ... } +``` + +### 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, and it doubles as the +collision fix since the subclass gets a distinct key. + +### 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. +- On `onHostDestroy` registrations detach from the dying registry but are kept, + and rebind against the new Activity's registry under the same keys on the next + `onHostResume`. Stable keys are what let AndroidX re-associate a result that + arrives after Activity recreation. + +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 + contract class 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..5e60b4d5506c 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,16 @@ 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 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 +537,40 @@ 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 the contract's fully-qualified class name; registering the same + * contract class twice throws {@link IllegalStateException}. Disambiguate by subclassing the + * contract or using {@link #registerForActivityResult(Object, ActivityResultContract, + * ActivityResultCallback)}. + */ + public ActivityResultLauncher registerForActivityResult( + ActivityResultContract contract, ActivityResultCallback callback) { + return getActivityResultCaller().registerForActivityResult(contract, callback); + } + + /** + * Same as {@link #registerForActivityResult(ActivityResultContract, ActivityResultCallback)}, + * but scopes the registration key to {@code owner} so two callers can register the same contract + * class. + */ + public ActivityResultLauncher registerForActivityResult( + Object owner, ActivityResultContract contract, ActivityResultCallback callback) { + return getActivityResultCaller().registerForActivityResult(owner, contract, callback); + } + /** * @deprecated DO NOT USE, this method will be removed in the near future. */ 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..9fed63ea8b14 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,40 @@ 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(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(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(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 +290,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 +361,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 +389,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',