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 00000000000..cbc77ce4523 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/DeferredActivityResultLauncher.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.activityresult + +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.app.ActivityOptionsCompat +import com.facebook.common.logging.FLog +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.common.ReactConstants + +/** + * An [ActivityResultLauncher] that may exist before any `ActivityResultRegistry` is available: it + * delegates to the real launcher once [bind] is called, queues a single [launch] issued while + * unbound (fired on bind), and can be [unbind]-ed and rebound against a new host's registry. + * + * [delegate] and [pendingLaunch] are only touched on the UI thread; [launch] and [unregister] get + * there via [onUiThread]. [launch] decides between delegating and queueing *on* the UI thread, so + * a concurrent [unbind] cannot leave it pointed at a dead registry. + */ +internal class DeferredActivityResultLauncher( + 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 in the meantime. + onUnregister() + onUiThread { + delegate?.unregister() + delegate = null + pendingLaunch = null + } + } + + /** + * Attaches [launcher], obtained from [registry] (remembered for [isBoundTo]), and fires any + * queued launch. + */ + 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 bound to [registry] itself, not just to any registry. */ + 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 00000000000..c830ba7f496 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCaller.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.activityresult + +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract + +/** + * Lets a native module register an AndroidX [ActivityResultContract] and receive results without + * any changes to the consumer's `MainActivity`. Mirrors + * `androidx.activity.ComponentActivity.registerForActivityResult`, except registration is legal at + * any time: the returned launcher binds to the real registry once a host Activity resumes. + * + * Every registration carries a key that must be unique within the `ReactContext` and stable across + * process death (AndroidX replays a restored result to whichever registration reproduces the same + * key). The default key `":"` lets unrelated libraries register the + * same stock contract without colliding; a collision throws [IllegalStateException] at + * registration time, and the keyed overload (which appends to that scope, not replaces it) + * resolves it. + */ +internal interface ReactActivityResultCaller { + + /** + * Registers [contract] under the key `":"` and returns a launcher + * for it. [owner] should be a stable, long-lived object, typically the native module itself: an + * anonymous class's generated name can change between builds, which breaks result delivery + * after the process is killed and restored. + * + * @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 those, but must stay the same across process restarts, so derive it from a constant. + * + * @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 00000000000..96988a46f38 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.activityresult + +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import com.facebook.common.logging.FLog +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.common.ReactConstants +import java.util.concurrent.ConcurrentHashMap + +/** + * Runs [block] on the UI thread, inline if already there. [ActivityResultRegistry] is `@MainThread` + * but not enforced at runtime: an off-thread call corrupts it silently, and RN calls in from the JS + * and native-modules threads. + */ +internal fun onUiThread(block: () -> Unit) { + if (UiThreadUtil.isOnUiThread()) block() else UiThreadUtil.runOnUiThread(block) +} + +/** + * Default [ReactActivityResultCaller], owned by a [ReactContext]. + * + * Registrations are accepted at any time and bound to the current Activity's + * [ActivityResultRegistry] immediately or on the next `onHostResume`. They outlive any single + * Activity: keys stay stable so AndroidX can re-associate a result after Activity recreation. + * + * Every `onHostResume` checks each launcher against the *current* registry, not just "already + * bound to something": with multi-Activity navigation the new Activity resumes before the old one + * is destroyed (whose onHostDestroy is dropped once `currentActivity` moves on), so a bound-only + * check would leave launchers attached to the previous Activity's dead registry. + * + * Threading: [entries] is concurrent and reachable from any thread; everything touching the + * registry goes through [onUiThread]. Registration stays on the caller's thread so the launcher + * returns immediately and a duplicate key throws at the causing frame. Only the registry call + * moves to the UI thread. + */ +internal class ReactActivityResultCallerImpl(private val reactContext: ReactContext) : + ReactActivityResultCaller, LifecycleEventListener { + + private class Entry( + val key: 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 any previous (possibly dead) registry first; staying registered there leaks its + // Activity and sends launches to the wrong one. + launcher.unbind() + launcher.bind(registry, registry.register(key, contract, callback)) + } + } + + private val entries = ConcurrentHashMap>() + + init { + reactContext.addLifecycleEventListener(this) + } + + override fun registerForActivityResult( + owner: Any, + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher { + return register( + key = "${owner.javaClass.name}:${contract.javaClass.name}", + collisionHint = + "Register once and reuse the launcher, or pass a distinct key per launcher: " + + "registerForActivityResult(owner, \"someName\", contract, callback).", + contract = contract, + callback = callback) + } + + override fun registerForActivityResult( + owner: Any, + key: String, + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher { + return register( + key = "${owner.javaClass.name}:${contract.javaClass.name}:$key", + collisionHint = "Pass a key that is unique among this owner's launchers of this contract.", + contract = contract, + callback = callback) + } + + private fun register( + key: String, + collisionHint: String, + contract: ActivityResultContract, + callback: ActivityResultCallback, + ): ActivityResultLauncher { + val launcher = DeferredActivityResultLauncher(key, contract) { entries.remove(key) } + val entry = Entry(key, contract, callback, launcher) + if (entries.putIfAbsent(key, entry) != null) { + throw IllegalStateException( + "A launcher is already registered for key '$key'. $collisionHint") + } + onUiThread { currentRegistry()?.let { registry -> 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 under the same keys + // on the next onHostResume, which is how AndroidX re-associates a surviving result. + 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 00000000000..81d53655085 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/__docs__/README.md @@ -0,0 +1,170 @@ +# ActivityResultContracts for native modules + +[🏠 Home](../../../../../../../../../../../__docs__/README.md) + +This package lets an Android native module register an AndroidX +[`ActivityResultContract`](https://developer.android.com/training/basics/intents/result) +and receive results. The consumer app does not need to change its +`MainActivity`, add manifest entries, or ship extra Activities. + +Before this, modules had to use `ActivityEventListener` with self-assigned int +request codes, which live in a global namespace with no coordination between +libraries. Calling `registerForActivityResult` on `getCurrentActivity()` does +not work either: AndroidX only allows it before the Activity is started, and +native modules are created lazily, long after that. See +[facebook/react-native#33639](https://github.com/facebook/react-native/issues/33639) +(Health Connect, whose permission contract cannot be used without +`registerForActivityResult`). + +## 🚀 Usage + +The API is `ReactContext.registerForActivityResult`. It has the same shape as +[`ComponentActivity.registerForActivityResult`](https://developer.android.com/training/basics/intents/result#register), +plus a leading `owner` argument that scopes the registration key. You can +register at any time. A field initializer is the recommended spot. The returned +launcher connects to the real registry once an Activity is available. + +```kotlin +class MyModule(private val context: ReactApplicationContext) : + NativeMyModuleSpec(context) { + + private var pendingPromise: Promise? = null + + private val requestPermission = + context.registerForActivityResult( + /* owner = */ this, + ActivityResultContracts.RequestPermission()) { isGranted -> + pendingPromise?.resolve(isGranted) + pendingPromise = null + } + + override fun requestCameraPermission(promise: Promise) { + pendingPromise = promise + requestPermission.launch(Manifest.permission.CAMERA) + } +} +``` + +Stock AndroidX contracts work unchanged, with their own input and output types +(for example `PickVisualMedia`). + +### Registration keys and collisions + +Registrations are keyed by `":"`, so two unrelated +libraries can register the same stock contract without clashing. Pass a stable, +long-lived `owner`, normally the module itself. An anonymous object gets a +generated class name that can change between builds, which breaks result +delivery after the process is killed and restored. + +Registering the same contract class twice from one owner throws +`IllegalStateException`. In that case use the overload that takes a key: + +```kotlin +private val pickAvatar = ctx.registerForActivityResult(this, "avatar", GetContent()) { } +private val pickBanner = ctx.registerForActivityResult(this, "banner", GetContent()) { } +``` + +The key is added to the owner-and-contract prefix, not used instead of it. It +only has to be unique among that owner's launchers of that contract, and it can +never clash with another library's keys. It must stay the same across process +restarts, so derive it from a constant. + +Why not automatic keys, like `ComponentActivity`'s counter? Modules are created +lazily, in whatever order JS touches them. After the process is killed and +restored, the same counter value could belong to a different module, and a +restored result would reach the wrong callback. Keys built from class names do +not depend on creation order. + +### Contract parameters that come from JS + +Contract constructor arguments are fixed when you register. If a value comes +from JS on each call, put it in the contract's input type instead: subclass the +stock contract and pass the value through `launch()`. See `PickUpToMedia` in +`SampleTurboModule.kt`, which does this for the photo picker's item limit. + +### Working examples + +- `SampleTurboModule.kt` + (`ReactCommon/react/nativemodule/samples/platform/android/`): + `requestSamplePermission`, `pickMedia`, `pickMultipleMedia`, and + `startSecondActivity` (multi-Activity regression check). +- rn-tester screens: `TurboModule/SampleTurboModuleExample.js` and + `PhotoPickerAndroid/PhotoPickerAndroid.js`. + +## 📐 Design + +`ReactActivity` extends `ComponentActivity`, so the host Activity already owns a +real `ActivityResultRegistry`. This package only bridges the timing gap between +lazily-created modules and that registry. + +- `ReactActivityResultCaller` / `ReactActivityResultCallerImpl` (internal): + owned by the `ReactContext`. Holds the `(key, contract, callback)` + registrations and connects them to the current Activity's registry, right away + if an Activity exists, otherwise on the next `onHostResume`. +- `DeferredActivityResultLauncher` (internal): the launcher handed to callers. + It forwards to the real AndroidX launcher once connected. A `launch()` made + before that is stored (latest wins) and fired on connect. +- Registrations outlive any single Activity. `onHostDestroy` disconnects them + but keeps them, and because the keys stay the same, AndroidX can deliver a + result that arrives after the Activity was recreated. +- Every `onHostResume` checks each launcher against the current registry, not + just whether it is connected to something. With more than one Activity, the + new Activity resumes before the old one is destroyed, and the old one's + `onHostDestroy` never runs because `currentActivity` has already moved on. A + launcher that only checked "am I connected?" would stay attached to the old + Activity's registry: that Activity could never be freed, and launches from the + new screen would go to the old one. + +### Threading + +`ActivityResultRegistry` must only be used from the UI thread, but nothing +enforces that at runtime; calls from other threads corrupt its internal maps +silently. React Native calls in from the JS thread (registrations in field +initializers) and from the native-modules thread (`launch()`), so: + +- The bookkeeping used for collision detection is a concurrent map and can be + used from any thread. Claiming a key is a single atomic step. Registration + stays synchronous: you get the launcher back immediately, and a duplicate key + throws from your own call. +- Every call that reaches the registry (`register`, `launch`, `unregister`) is + forwarded to the UI thread, and so is the launcher's connection state (checked + with assertions in debug builds). + +Notes for library authors: + +- Register early, in a field initializer or the constructor. Only launching + needs an Activity. +- An Activity that is not an `ActivityResultRegistryOwner` cannot serve + launchers. They stay queued and a warning is logged. +- After the process is killed and restored, AndroidX redelivers a pending result + under the same key, but any state your module held for the call (typically a + `Promise`) is gone. Write callbacks so they tolerate firing with no pending + state. +- `unregister()` on the returned launcher removes the registration and frees the + key. + +## 🔗 Relationship with other systems + +### Part of + +- [ReactAndroid](../../../../../../../../README.md): the core of React Native on + Android. + +### Used by this + +- `com.facebook.react.bridge.ReactContext`: exposes the public + `registerForActivityResult` methods, owns the caller instance, and drives + connecting and disconnecting through its lifecycle events. +- AndroidX `androidx.activity.result`: the contracts, launchers, and registry + that actually start activities and deliver results. + +### Uses this + +- `SampleTurboModule` (demo) and, in the future, third-party modules that need + activity results or AndroidX permission contracts (for example Health + Connect). + +This API coexists with `ActivityEventListener`: results claimed by the AndroidX +registry are consumed by it, and everything else still reaches +`ActivityEventListener.onActivityResult`. The listener remains the right tool +for intents a module builds and starts itself. 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 15b0d6691a8..a826db235e0 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,48 @@ 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} and returns a launcher for it, mirroring + * {@code ComponentActivity.registerForActivityResult} but with no changes required to the + * consumer's {@code MainActivity}. Registration is legal at any time; the launcher binds lazily + * once an Activity is available, queueing a {@code launch} issued while unbound. + * + *

The registration key is {@code ":"}, so {@code owner} should + * be a stable, long-lived object (typically the native module itself): the key must be + * reproducible after the process is killed and restored. Registering the same contract class + * twice from one owner + * throws {@link IllegalStateException}; use {@link #registerForActivityResult(Object, String, + * ActivityResultContract, ActivityResultCallback)} in that case. + */ + public 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 those, but must stay the same across process restarts. + * + * @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 00000000000..773387abf45 --- /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 00000000000..f7d3954d283 --- /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 c8a36acc32b..2ebb15e05b5 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 @@ -37,6 +44,41 @@ public class SampleTurboModule(private val context: ReactApplicationContext) : private var toast: Toast? = null + private var pendingPermissionPromise: Promise? = null + + // Registered up-front against the ReactContext, which is legal even though this module 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) @@ -274,6 +316,84 @@ public class SampleTurboModule(private val context: ReactApplicationContext) : } } + /** + * Demonstrates requesting a runtime permission through the [ActivityResultRegistry] owned by + * [com.facebook.react.bridge.ReactContext], rather than through the current Activity. Unlike + * [getImageUrl], this needs no Activity to be present at registration time and no cast to + * [ComponentActivity]. + */ + @DoNotStrip + @Suppress("unused") + override fun requestSamplePermission(promise: Promise) { + if (pendingPermissionPromise != null) { + promise.reject("error", "A permission request is already in flight") + return + } + pendingPermissionPromise = promise + permissionLauncher.launch(Manifest.permission.CAMERA) + } + + /** + * Maps the JS-provided mime type onto the photo picker's [VisualMediaType]: null selects images + * and videos, "image/*" and "video/*" restrict to one kind, and any other value is + * treated as a specific mime type (e.g. "image/gif"). + */ + private fun visualMediaType(mimeType: String?): ActivityResultContracts.PickVisualMedia.VisualMediaType = + when (mimeType) { + null -> ActivityResultContracts.PickVisualMedia.ImageAndVideo + "image/*" -> ActivityResultContracts.PickVisualMedia.ImageOnly + "video/*" -> ActivityResultContracts.PickVisualMedia.VideoOnly + else -> ActivityResultContracts.PickVisualMedia.SingleMimeType(mimeType) + } + + @DoNotStrip + @Suppress("unused") + override fun pickMedia(mimeType: String?, promise: Promise) { + if (pendingPickMediaPromise != null) { + promise.reject("error", "A media pick is already in flight") + return + } + pendingPickMediaPromise = promise + pickMediaLauncher.launch(PickVisualMediaRequest(visualMediaType(mimeType))) + } + + @DoNotStrip + @Suppress("unused") + override fun pickMultipleMedia(mimeType: String?, maxItems: Double, promise: Promise) { + if (pendingPickMultipleMediaPromise != null) { + promise.reject("error", "A media pick is already in flight") + return + } + val limit = maxItems.toInt() + if (limit < 2) { + promise.reject("error", "maxItems must be at least 2, got $limit") + return + } + pendingPickMultipleMediaPromise = promise + pickMultipleMediaLauncher.launch( + PickUpToMedia.Request(limit, PickVisualMediaRequest(visualMediaType(mimeType)))) + } + + /** + * Starts a second ReactActivity to exercise multi-Activity navigation: the launchers above must + * rebind to the new Activity's registry (it resumes while the old Activity is still alive). + * Launched by class name to avoid a compile-time dependency on the app; the data URI deep-links + * the new surface straight to the picker example via Linking. + */ + @DoNotStrip + @Suppress("unused") + override fun startSecondActivity() { + val activity = context.currentActivity + if (activity == null) { + Toast.makeText(context, "No current Activity to launch from", Toast.LENGTH_LONG).show() + return + } + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse("rntester://example/PhotoPickerAndroid")) + .setClassName(activity, "${activity.packageName}.RNTesterSecondActivity") + activity.startActivity(intent) + } + private fun log(method: String, input: Any?, output: Any?) { toast?.cancel() val message = StringBuilder("Method :") @@ -287,7 +407,22 @@ 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. + // Clearing the fields also lets the still-registered callbacks tolerate a late result. + pendingPermissionPromise?.reject( + "E_MODULE_INVALIDATED", "Permission request cancelled: SampleTurboModule was invalidated") + pendingPermissionPromise = null + + pendingPickMediaPromise?.reject( + "E_MODULE_INVALIDATED", "Media pick cancelled: SampleTurboModule was invalidated") + pendingPickMediaPromise = null + + pendingPickMultipleMediaPromise?.reject( + "E_MODULE_INVALIDATED", "Multiple media pick cancelled: SampleTurboModule was invalidated") + pendingPickMultipleMediaPromise = null + super.invalidate() + } override fun getName(): String { return NAME @@ -299,3 +434,27 @@ public class SampleTurboModule(private val context: ReactApplicationContext) : public const val NAME: String = "SampleTurboModule" } } + +/** + * Photo picker contract for multi-select with a per-call item limit. Stock + * [ActivityResultContracts.PickMultipleVisualMedia] fixes the limit in its constructor, but here it + * comes from JS per call. So the contract is subclassed to carry the limit in its input type, + * the pattern library authors should copy for any contract parameter that comes from JS. + */ +private class PickUpToMedia : + ActivityResultContract>() { + 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 c458c91a220..7d26a539e0f 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeSampleTurboModule.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeSampleTurboModule.js @@ -65,6 +65,13 @@ 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>; + readonly startSecondActivity?: () => void; } export default TurboModuleRegistry.getEnforcing( diff --git a/packages/rn-tester/android/app/src/main/AndroidManifest.xml b/packages/rn-tester/android/app/src/main/AndroidManifest.xml index a842f3a0d29..cfa5ad6359c 100644 --- a/packages/rn-tester/android/app/src/main/AndroidManifest.xml +++ b/packages/rn-tester/android/app/src/main/AndroidManifest.xml @@ -89,6 +89,16 @@ + + + { + 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 => ( + + ))} + + + ); +}; + +/** + * Regression check for multi-Activity navigation: opening the second Activity + * must rebind the ReactContext-registered launchers to its registry, so picks + * on each screen deliver their results to that screen. + */ +const MultiActivity = (): React.Node => { + return ( + <> + + Opens this same example in a second Activity. Pick an image there: the + result must arrive on that screen. Then go back and pick here again. + + + getNativeSampleTurboModule().startSecondActivity?.()} + /> + + + ); +}; + +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 f7e8373a995..361af009e3b 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'; @@ -64,6 +70,8 @@ type ErrorExamples = | 'getObjectAssert' | 'promiseAssert'; +type AndroidExamples = 'requestSamplePermission'; + class SampleTurboModuleExample extends React.Component<{}, State> { static contextType: React.Context = RootTagContext; eventSubscriptions: EventSubscription[] = []; @@ -177,8 +185,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 @@ -295,6 +315,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 dd906996835..0df6c5fd219 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',