From bbe4afe9496e22425e2a73dac819a7ff6b2df83f Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Sun, 15 Sep 2024 21:56:21 +0200 Subject: [PATCH 01/10] feat: initial support for permission contracts [WIP] --- .../facebook/react/bridge/ReactContext.java | 194 +++++++++++++++--- 1 file changed, 169 insertions(+), 25 deletions(-) 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..e54564a06028 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 @@ -7,21 +7,47 @@ package com.facebook.react.bridge; +import static android.app.Activity.RESULT_CANCELED; +import static androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions.ACTION_REQUEST_PERMISSIONS; +import static androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions.EXTRA_PERMISSIONS; +import static androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult.EXTRA_ACTIVITY_OPTIONS_BUNDLE; +import static androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult.ACTION_INTENT_SENDER_REQUEST; +import static androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult.EXTRA_INTENT_SENDER_REQUEST; +import static androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult.EXTRA_SEND_INTENT_EXCEPTION; import static com.facebook.infer.annotation.ThreadConfined.UI; import android.app.Activity; import android.content.Context; import android.content.ContextWrapper; import android.content.Intent; +import android.content.IntentSender; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; import android.view.LayoutInflater; import android.view.Window; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultCaller; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.ActivityResultRegistry; +import androidx.activity.result.ActivityResultRegistryOwner; +import androidx.activity.result.IntentSenderRequest; +import androidx.activity.result.contract.ActivityResultContract; +import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.app.ActivityCompat; +import androidx.core.app.ActivityOptionsCompat; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LifecycleRegistry; + 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.ReactActivity; import com.facebook.react.bridge.interop.InteropModuleRegistry; import com.facebook.react.bridge.queue.MessageQueueThread; import com.facebook.react.bridge.queue.ReactQueueConfiguration; @@ -32,12 +58,16 @@ import java.lang.ref.WeakReference; import java.util.Collection; import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicInteger; /** * Abstract ContextWrapper for Android application or activity {@link Context} and {@link * CatalystInstance} */ -public abstract class ReactContext extends ContextWrapper { +public abstract class ReactContext extends ContextWrapper implements + LifecycleOwner, + ActivityResultRegistryOwner, + ActivityResultCaller { @DoNotStrip public interface RCTDeviceEventEmitter extends JavaScriptModule { @@ -48,7 +78,7 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule { private static final String TAG = "ReactContext"; private final CopyOnWriteArraySet mLifecycleEventListeners = - new CopyOnWriteArraySet<>(); + new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet mActivityEventListeners = new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet mExtraWindowEventListeners = @@ -59,6 +89,8 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule { private LifecycleState mLifecycleState = LifecycleState.BEFORE_CREATE; + private final AtomicInteger mNextLocalRequestCode = new AtomicInteger(); + private @Nullable LayoutInflater mInflater; private @Nullable ReactQueueConfiguration mQueueConfig; private @Nullable MessageQueueThread mUiMessageQueueThread; @@ -68,6 +100,8 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule { private @Nullable JSExceptionHandler mExceptionHandlerWrapper; private @Nullable WeakReference mCurrentActivity; + private final LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this); + // 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. protected @Nullable InteropModuleRegistry mInteropModuleRegistry; @@ -77,6 +111,79 @@ public ReactContext(Context base) { super(base); } + private final ActivityResultRegistry mActivityResultRegistry = new ActivityResultRegistry() { + + @SuppressWarnings("deprecation") + @Override + public void onLaunch( + final int requestCode, + @NonNull ActivityResultContract contract, + I input, + @Nullable ActivityOptionsCompat options) { + Activity activity = mCurrentActivity.get(); + + // Immediate result path + final ActivityResultContract.SynchronousResult synchronousResult = + contract.getSynchronousResult(activity, input); + if (synchronousResult != null) { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + dispatchResult(requestCode, synchronousResult.getValue()); + } + }); + return; + } + + // Start activity path + Intent intent = contract.createIntent(activity, input); + Bundle optionsBundle = null; + // If there are any extras, we should defensively set the classLoader + if (intent.getExtras() != null && intent.getExtras().getClassLoader() == null) { + intent.setExtrasClassLoader(activity.getClassLoader()); + } + if (intent.hasExtra(EXTRA_ACTIVITY_OPTIONS_BUNDLE)) { + optionsBundle = intent.getBundleExtra(EXTRA_ACTIVITY_OPTIONS_BUNDLE); + intent.removeExtra(EXTRA_ACTIVITY_OPTIONS_BUNDLE); + } else if (options != null) { + optionsBundle = options.toBundle(); + } + if (ACTION_REQUEST_PERMISSIONS.equals(intent.getAction())) { + + // requestPermissions path + String[] permissions = intent.getStringArrayExtra(EXTRA_PERMISSIONS); + + if (permissions == null) { + permissions = new String[0]; + } + + + ActivityCompat.requestPermissions(activity, permissions, requestCode); + } else if (ACTION_INTENT_SENDER_REQUEST.equals(intent.getAction())) { + IntentSenderRequest request = + intent.getParcelableExtra(EXTRA_INTENT_SENDER_REQUEST); + try { + // startIntentSenderForResult path + ActivityCompat.startIntentSenderForResult(activity, request.getIntentSender(), + requestCode, request.getFillInIntent(), request.getFlagsMask(), + request.getFlagsValues(), 0, optionsBundle); + } catch (final IntentSender.SendIntentException e) { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + dispatchResult(requestCode, RESULT_CANCELED, + new Intent().setAction(ACTION_INTENT_SENDER_REQUEST) + .putExtra(EXTRA_SEND_INTENT_EXCEPTION, e)); + } + }); + } + } else { + // startActivityForResult path + ActivityCompat.startActivityForResult(activity, intent, requestCode, optionsBundle); + } + } + }; + protected void initializeFromOther(ReactContext other) { if (other.hasReactInstance()) { initializeMessageQueueThreads(other.mQueueConfig); @@ -88,8 +195,8 @@ protected void initializeFromOther(ReactContext other) { public synchronized void initializeMessageQueueThreads(ReactQueueConfiguration queueConfig) { FLog.d(TAG, "initializeMessageQueueThreads() is called."); if (mUiMessageQueueThread != null - || mNativeModulesMessageQueueThread != null - || mJSMessageQueueThread != null) { + || mNativeModulesMessageQueueThread != null + || mJSMessageQueueThread != null) { throw new IllegalStateException("Message queue threads already initialized"); } mQueueConfig = queueConfig; @@ -228,19 +335,19 @@ public void addLifecycleEventListener(final LifecycleEventListener listener) { break; case RESUMED: runOnUiQueueThread( - new Runnable() { - @Override - public void run() { - if (!mLifecycleEventListeners.contains(listener)) { - return; - } - try { - listener.onHostResume(); - } catch (RuntimeException e) { - handleException(e); - } + new Runnable() { + @Override + public void run() { + if (!mLifecycleEventListeners.contains(listener)) { + return; } - }); + try { + listener.onHostResume(); + } catch (RuntimeException e) { + handleException(e); + } + } + }); break; default: throw new IllegalStateException("Unhandled lifecycle state."); @@ -368,12 +475,14 @@ private void onHostDestroyImpl() { /** Should be called by the hosting Fragment in {@link Fragment#onActivityResult} */ public void onActivityResult( - Activity activity, int requestCode, int resultCode, @Nullable Intent data) { - for (ActivityEventListener listener : mActivityEventListeners) { - try { - listener.onActivityResult(activity, requestCode, resultCode, data); - } catch (RuntimeException e) { - handleException(e); + Activity activity, int requestCode, int resultCode, @Nullable Intent data) { + if (!mActivityResultRegistry.dispatchResult(requestCode, resultCode, data)) { + for (ActivityEventListener listener : mActivityEventListeners) { + try { + listener.onActivityResult(activity, requestCode, resultCode, data); + } catch (RuntimeException e) { + handleException(e); + } } } } @@ -430,7 +539,7 @@ public void assertOnNativeModulesQueueThread() { /** TODO(T85807990): Fail fast if the ReactContext isn't initialized */ if (!mIsInitialized) { throw new IllegalStateException( - "Tried to call assertOnNativeModulesQueueThread() on an uninitialized ReactContext"); + "Tried to call assertOnNativeModulesQueueThread() on an uninitialized ReactContext"); } Assertions.assertNotNull(mNativeModulesMessageQueueThread).assertIsOnThread(); } @@ -439,8 +548,8 @@ public void assertOnNativeModulesQueueThread(String message) { /** TODO(T85807990): Fail fast if the ReactContext isn't initialized */ if (!mIsInitialized) { throw new IllegalStateException( - "Tried to call assertOnNativeModulesQueueThread(message) on an uninitialized" - + " ReactContext"); + "Tried to call assertOnNativeModulesQueueThread(message) on an uninitialized" + + " ReactContext"); } Assertions.assertNotNull(mNativeModulesMessageQueueThread).assertIsOnThread(message); } @@ -520,6 +629,41 @@ public boolean startActivityForResult(Intent intent, int code, Bundle bundle) { return false; } + @NonNull + @Override + public final ActivityResultLauncher registerForActivityResult( + @NonNull final ActivityResultContract contract, + @NonNull final ActivityResultRegistry registry, + @NonNull final ActivityResultCallback callback) { + return registry.register( + "activity_rq#" + mNextLocalRequestCode.getAndIncrement(), this, contract, callback); + } + + @NonNull + @Override + public final ActivityResultLauncher registerForActivityResult( + @NonNull ActivityResultContract contract, + @NonNull ActivityResultCallback callback) { + return registerForActivityResult(contract, mActivityResultRegistry, callback); + } + + /** + * Get the {@link ActivityResultRegistry} associated with this activity. + * + * @return the {@link ActivityResultRegistry} + */ + @NonNull + @Override + public ActivityResultRegistry getActivityResultRegistry() { + return mActivityResultRegistry; + } + + @NonNull + @Override + public Lifecycle getLifecycle() { + return mLifecycleRegistry; + } + /** * Get the activity to which this context is currently attached, or {@code null} if not attached. * DO NOT HOLD LONG-LIVED REFERENCES TO THE OBJECT RETURNED BY THIS METHOD, AS THIS WILL CAUSE From f59bca02533008959e5c07115bcb6ef93bf3bebc Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:03:12 +0200 Subject: [PATCH 02/10] feat: let native modules register ActivityResultContracts via ReactContext Adds com.facebook.react.activityresult with a ReactActivityResultCaller that registers AndroidX ActivityResultContracts against the host Activity's own ActivityResultRegistry (ReactActivity already extends ComponentActivity, so it is an ActivityResultRegistryOwner). No changes to consumers' MainActivity, no manifest entries, no forked registry. - Registration is legal at any time: modules are created lazily, so the returned launcher is a deferred wrapper that binds to the registry on onHostResume, queues a single launch issued while unbound, and rebinds under the same key after Activity recreation. - Keys are the contract's fully-qualified class name; duplicate registrations throw at registration time, with an owner-scoped overload as the escape hatch. - ReactContext gains registerForActivityResult convenience methods mirroring ComponentActivity, plus getActivityResultCaller(). - ActivityEventListener dispatch is untouched; results flow through ComponentActivity's existing onActivityResult / onRequestPermissionsResult into its registry. Demo: SampleTurboModule.requestSamplePermission (CAMERA) wired into rn-tester's SampleTurboModuleExample under an Android-only section. Co-Authored-By: Claude Opus 5 --- .../DeferredActivityResultLauncher.kt | 79 ++++++ .../ReactActivityResultCaller.kt | 52 ++++ .../ReactActivityResultCallerImpl.kt | 128 ++++++++++ .../facebook/react/bridge/ReactContext.java | 228 +++++------------- .../platform/android/SampleTurboModule.kt | 30 +++ .../modules/NativeSampleTurboModule.js | 1 + .../TurboModule/SampleTurboModuleExample.js | 52 +++- 7 files changed, 402 insertions(+), 168 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/DeferredActivityResultLauncher.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCaller.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt 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..a1e14a10fa7a --- /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`. + */ +public 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 + */ + public 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. + */ + public 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..5e1c156b0f31 --- /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. + */ +public 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/bridge/ReactContext.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java index e54564a06028..d56ab7535905 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 @@ -7,47 +7,26 @@ package com.facebook.react.bridge; -import static android.app.Activity.RESULT_CANCELED; -import static androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions.ACTION_REQUEST_PERMISSIONS; -import static androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions.EXTRA_PERMISSIONS; -import static androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult.EXTRA_ACTIVITY_OPTIONS_BUNDLE; -import static androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult.ACTION_INTENT_SENDER_REQUEST; -import static androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult.EXTRA_INTENT_SENDER_REQUEST; -import static androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult.EXTRA_SEND_INTENT_EXCEPTION; import static com.facebook.infer.annotation.ThreadConfined.UI; import android.app.Activity; import android.content.Context; import android.content.ContextWrapper; import android.content.Intent; -import android.content.IntentSender; import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; import android.view.LayoutInflater; import android.view.Window; import androidx.activity.result.ActivityResultCallback; -import androidx.activity.result.ActivityResultCaller; import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.ActivityResultRegistry; -import androidx.activity.result.ActivityResultRegistryOwner; -import androidx.activity.result.IntentSenderRequest; import androidx.activity.result.contract.ActivityResultContract; -import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.app.ActivityCompat; -import androidx.core.app.ActivityOptionsCompat; -import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.LifecycleRegistry; - 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.ReactActivity; +import com.facebook.react.activityresult.ReactActivityResultCaller; +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; @@ -58,16 +37,12 @@ import java.lang.ref.WeakReference; import java.util.Collection; import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.atomic.AtomicInteger; /** * Abstract ContextWrapper for Android application or activity {@link Context} and {@link * CatalystInstance} */ -public abstract class ReactContext extends ContextWrapper implements - LifecycleOwner, - ActivityResultRegistryOwner, - ActivityResultCaller { +public abstract class ReactContext extends ContextWrapper { @DoNotStrip public interface RCTDeviceEventEmitter extends JavaScriptModule { @@ -78,7 +53,7 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule { private static final String TAG = "ReactContext"; private final CopyOnWriteArraySet mLifecycleEventListeners = - new CopyOnWriteArraySet<>(); + new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet mActivityEventListeners = new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet mExtraWindowEventListeners = @@ -89,8 +64,6 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule { private LifecycleState mLifecycleState = LifecycleState.BEFORE_CREATE; - private final AtomicInteger mNextLocalRequestCode = new AtomicInteger(); - private @Nullable LayoutInflater mInflater; private @Nullable ReactQueueConfiguration mQueueConfig; private @Nullable MessageQueueThread mUiMessageQueueThread; @@ -99,8 +72,7 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule { private @Nullable JSExceptionHandler mJSExceptionHandler; private @Nullable JSExceptionHandler mExceptionHandlerWrapper; private @Nullable WeakReference mCurrentActivity; - - private final LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this); + private @Nullable ReactActivityResultCaller 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. @@ -111,79 +83,6 @@ public ReactContext(Context base) { super(base); } - private final ActivityResultRegistry mActivityResultRegistry = new ActivityResultRegistry() { - - @SuppressWarnings("deprecation") - @Override - public void onLaunch( - final int requestCode, - @NonNull ActivityResultContract contract, - I input, - @Nullable ActivityOptionsCompat options) { - Activity activity = mCurrentActivity.get(); - - // Immediate result path - final ActivityResultContract.SynchronousResult synchronousResult = - contract.getSynchronousResult(activity, input); - if (synchronousResult != null) { - new Handler(Looper.getMainLooper()).post(new Runnable() { - @Override - public void run() { - dispatchResult(requestCode, synchronousResult.getValue()); - } - }); - return; - } - - // Start activity path - Intent intent = contract.createIntent(activity, input); - Bundle optionsBundle = null; - // If there are any extras, we should defensively set the classLoader - if (intent.getExtras() != null && intent.getExtras().getClassLoader() == null) { - intent.setExtrasClassLoader(activity.getClassLoader()); - } - if (intent.hasExtra(EXTRA_ACTIVITY_OPTIONS_BUNDLE)) { - optionsBundle = intent.getBundleExtra(EXTRA_ACTIVITY_OPTIONS_BUNDLE); - intent.removeExtra(EXTRA_ACTIVITY_OPTIONS_BUNDLE); - } else if (options != null) { - optionsBundle = options.toBundle(); - } - if (ACTION_REQUEST_PERMISSIONS.equals(intent.getAction())) { - - // requestPermissions path - String[] permissions = intent.getStringArrayExtra(EXTRA_PERMISSIONS); - - if (permissions == null) { - permissions = new String[0]; - } - - - ActivityCompat.requestPermissions(activity, permissions, requestCode); - } else if (ACTION_INTENT_SENDER_REQUEST.equals(intent.getAction())) { - IntentSenderRequest request = - intent.getParcelableExtra(EXTRA_INTENT_SENDER_REQUEST); - try { - // startIntentSenderForResult path - ActivityCompat.startIntentSenderForResult(activity, request.getIntentSender(), - requestCode, request.getFillInIntent(), request.getFlagsMask(), - request.getFlagsValues(), 0, optionsBundle); - } catch (final IntentSender.SendIntentException e) { - new Handler(Looper.getMainLooper()).post(new Runnable() { - @Override - public void run() { - dispatchResult(requestCode, RESULT_CANCELED, - new Intent().setAction(ACTION_INTENT_SENDER_REQUEST) - .putExtra(EXTRA_SEND_INTENT_EXCEPTION, e)); - } - }); - } - } else { - // startActivityForResult path - ActivityCompat.startActivityForResult(activity, intent, requestCode, optionsBundle); - } - } - }; - protected void initializeFromOther(ReactContext other) { if (other.hasReactInstance()) { initializeMessageQueueThreads(other.mQueueConfig); @@ -195,8 +94,8 @@ protected void initializeFromOther(ReactContext other) { public synchronized void initializeMessageQueueThreads(ReactQueueConfiguration queueConfig) { FLog.d(TAG, "initializeMessageQueueThreads() is called."); if (mUiMessageQueueThread != null - || mNativeModulesMessageQueueThread != null - || mJSMessageQueueThread != null) { + || mNativeModulesMessageQueueThread != null + || mJSMessageQueueThread != null) { throw new IllegalStateException("Message queue threads already initialized"); } mQueueConfig = queueConfig; @@ -335,19 +234,19 @@ public void addLifecycleEventListener(final LifecycleEventListener listener) { break; case RESUMED: runOnUiQueueThread( - new Runnable() { - @Override - public void run() { - if (!mLifecycleEventListeners.contains(listener)) { - return; - } - try { - listener.onHostResume(); - } catch (RuntimeException e) { - handleException(e); + new Runnable() { + @Override + public void run() { + if (!mLifecycleEventListeners.contains(listener)) { + return; + } + try { + listener.onHostResume(); + } catch (RuntimeException e) { + handleException(e); + } } - } - }); + }); break; default: throw new IllegalStateException("Unhandled lifecycle state."); @@ -475,14 +374,12 @@ private void onHostDestroyImpl() { /** Should be called by the hosting Fragment in {@link Fragment#onActivityResult} */ public void onActivityResult( - Activity activity, int requestCode, int resultCode, @Nullable Intent data) { - if (!mActivityResultRegistry.dispatchResult(requestCode, resultCode, data)) { - for (ActivityEventListener listener : mActivityEventListeners) { - try { - listener.onActivityResult(activity, requestCode, resultCode, data); - } catch (RuntimeException e) { - handleException(e); - } + Activity activity, int requestCode, int resultCode, @Nullable Intent data) { + for (ActivityEventListener listener : mActivityEventListeners) { + try { + listener.onActivityResult(activity, requestCode, resultCode, data); + } catch (RuntimeException e) { + handleException(e); } } } @@ -539,7 +436,7 @@ public void assertOnNativeModulesQueueThread() { /** TODO(T85807990): Fail fast if the ReactContext isn't initialized */ if (!mIsInitialized) { throw new IllegalStateException( - "Tried to call assertOnNativeModulesQueueThread() on an uninitialized ReactContext"); + "Tried to call assertOnNativeModulesQueueThread() on an uninitialized ReactContext"); } Assertions.assertNotNull(mNativeModulesMessageQueueThread).assertIsOnThread(); } @@ -548,8 +445,8 @@ public void assertOnNativeModulesQueueThread(String message) { /** TODO(T85807990): Fail fast if the ReactContext isn't initialized */ if (!mIsInitialized) { throw new IllegalStateException( - "Tried to call assertOnNativeModulesQueueThread(message) on an uninitialized" - + " ReactContext"); + "Tried to call assertOnNativeModulesQueueThread(message) on an uninitialized" + + " ReactContext"); } Assertions.assertNotNull(mNativeModulesMessageQueueThread).assertIsOnThread(message); } @@ -629,41 +526,6 @@ public boolean startActivityForResult(Intent intent, int code, Bundle bundle) { return false; } - @NonNull - @Override - public final ActivityResultLauncher registerForActivityResult( - @NonNull final ActivityResultContract contract, - @NonNull final ActivityResultRegistry registry, - @NonNull final ActivityResultCallback callback) { - return registry.register( - "activity_rq#" + mNextLocalRequestCode.getAndIncrement(), this, contract, callback); - } - - @NonNull - @Override - public final ActivityResultLauncher registerForActivityResult( - @NonNull ActivityResultContract contract, - @NonNull ActivityResultCallback callback) { - return registerForActivityResult(contract, mActivityResultRegistry, callback); - } - - /** - * Get the {@link ActivityResultRegistry} associated with this activity. - * - * @return the {@link ActivityResultRegistry} - */ - @NonNull - @Override - public ActivityResultRegistry getActivityResultRegistry() { - return mActivityResultRegistry; - } - - @NonNull - @Override - public Lifecycle getLifecycle() { - return mLifecycleRegistry; - } - /** * Get the activity to which this context is currently attached, or {@code null} if not attached. * DO NOT HOLD LONG-LIVED REFERENCES TO THE OBJECT RETURNED BY THIS METHOD, AS THIS WILL CAUSE @@ -676,6 +538,40 @@ public Lifecycle getLifecycle() { return mCurrentActivity.get(); } + /** + * Get the {@link ReactActivityResultCaller} for this context, which lets a native module register + * an AndroidX {@code ActivityResultContract} against the host Activity's {@code + * ActivityResultRegistry} and receive results without any changes to the consumer's {@code + * MainActivity}. Registration is legal at any time; launchers bind lazily once an Activity is + * available. + */ + public synchronized ReactActivityResultCaller getActivityResultCaller() { + if (mActivityResultCaller == null) { + mActivityResultCaller = new ReactActivityResultCallerImpl(this); + } + return mActivityResultCaller; + } + + /** + * Convenience for {@link ReactActivityResultCaller#registerForActivityResult( + * ActivityResultContract, ActivityResultCallback)}, mirroring {@code + * ComponentActivity.registerForActivityResult}. + */ + public ActivityResultLauncher registerForActivityResult( + ActivityResultContract contract, ActivityResultCallback callback) { + return getActivityResultCaller().registerForActivityResult(contract, callback); + } + + /** + * Convenience for {@link ReactActivityResultCaller#registerForActivityResult(Object, + * ActivityResultContract, ActivityResultCallback)}; 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..9a83b95228f3 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,13 @@ package com.facebook.fbreact.specs +import android.Manifest import android.net.Uri import android.os.Build import android.util.DisplayMetrics import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import com.facebook.proguard.annotations.DoNotStrip import com.facebook.react.bridge.Arguments @@ -36,6 +38,17 @@ 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 + } + @DoNotStrip override fun getBool(arg: Boolean): Boolean { log("getBool", arg, arg) @@ -249,6 +262,23 @@ 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) + } + private fun log(method: String, input: Any?, output: Any?) { toast?.cancel() val message = StringBuilder("Method :") 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..c8e38d9276ed 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,7 @@ export interface Spec extends TurboModule { // Android-only readonly getImageUrl?: () => Promise; + readonly requestSamplePermission?: () => Promise; } export default TurboModuleRegistry.getEnforcing( 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 From 23ff60638e0479bbcc873537c46b67cd8e775c59 Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:03:59 +0200 Subject: [PATCH 03/10] fix: mark ReactActivityResultCaller and its implementation internal --- .../ReactActivityResultCaller.kt | 6 ++-- .../ReactActivityResultCallerImpl.kt | 2 +- .../facebook/react/bridge/ReactContext.java | 31 +++++++++---------- 3 files changed, 19 insertions(+), 20 deletions(-) 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 index a1e14a10fa7a..145d36476f5a 100644 --- 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 @@ -26,7 +26,7 @@ import androidx.activity.result.contract.ActivityResultContract * contract class twice throws an [IllegalStateException] at registration time; disambiguate by * subclassing the contract or using the [registerForActivityResult] overload that takes an `owner`. */ -public interface ReactActivityResultCaller { +internal interface ReactActivityResultCaller { /** * Registers [contract] and returns a launcher for it. The registration key is the contract's @@ -34,7 +34,7 @@ public interface ReactActivityResultCaller { * * @throws IllegalStateException if a registration with the same key already exists */ - public fun registerForActivityResult( + fun registerForActivityResult( contract: ActivityResultContract, callback: ActivityResultCallback, ): ActivityResultLauncher @@ -44,7 +44,7 @@ public interface ReactActivityResultCaller { * (`":"`). Use this when two independent callers need the same * stock contract class, or when registering from something other than a native module. */ - public fun registerForActivityResult( + fun registerForActivityResult( owner: Any, contract: ActivityResultContract, callback: ActivityResultCallback, 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 index 5e1c156b0f31..ec671f4b9b14 100644 --- 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 @@ -27,7 +27,7 @@ import com.facebook.react.common.ReactConstants * registry under the same keys, so AndroidX can re-associate a result that arrives after Activity * recreation. */ -public class ReactActivityResultCallerImpl(private val reactContext: ReactContext) : +internal class ReactActivityResultCallerImpl(private val reactContext: ReactContext) : ReactActivityResultCaller, LifecycleEventListener { private class Entry( 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 d56ab7535905..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 @@ -25,7 +25,6 @@ import com.facebook.infer.annotation.Assertions; import com.facebook.infer.annotation.ThreadConfined; import com.facebook.proguard.annotations.DoNotStrip; -import com.facebook.react.activityresult.ReactActivityResultCaller; import com.facebook.react.activityresult.ReactActivityResultCallerImpl; import com.facebook.react.bridge.interop.InteropModuleRegistry; import com.facebook.react.bridge.queue.MessageQueueThread; @@ -72,7 +71,7 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule { private @Nullable JSExceptionHandler mJSExceptionHandler; private @Nullable JSExceptionHandler mExceptionHandlerWrapper; private @Nullable WeakReference mCurrentActivity; - private @Nullable ReactActivityResultCaller mActivityResultCaller; + 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. @@ -538,14 +537,7 @@ public boolean startActivityForResult(Intent intent, int code, Bundle bundle) { return mCurrentActivity.get(); } - /** - * Get the {@link ReactActivityResultCaller} for this context, which lets a native module register - * an AndroidX {@code ActivityResultContract} against the host Activity's {@code - * ActivityResultRegistry} and receive results without any changes to the consumer's {@code - * MainActivity}. Registration is legal at any time; launchers bind lazily once an Activity is - * available. - */ - public synchronized ReactActivityResultCaller getActivityResultCaller() { + private synchronized ReactActivityResultCallerImpl getActivityResultCaller() { if (mActivityResultCaller == null) { mActivityResultCaller = new ReactActivityResultCallerImpl(this); } @@ -553,9 +545,16 @@ public synchronized ReactActivityResultCaller getActivityResultCaller() { } /** - * Convenience for {@link ReactActivityResultCaller#registerForActivityResult( - * ActivityResultContract, ActivityResultCallback)}, mirroring {@code - * ComponentActivity.registerForActivityResult}. + * 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) { @@ -563,9 +562,9 @@ public ActivityResultLauncher registerForActivityResult( } /** - * Convenience for {@link ReactActivityResultCaller#registerForActivityResult(Object, - * ActivityResultContract, ActivityResultCallback)}; scopes the registration key to {@code owner} - * so two callers can register the same contract class. + * 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) { From 91bc282c183cd3c9a5a50e370b2eff2f4bcdf243 Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:29:02 +0200 Subject: [PATCH 04/10] feat: add photo picker example --- .../platform/android/SampleTurboModule.kt | 97 +++++++++ .../modules/NativeSampleTurboModule.js | 5 + .../PhotoPickerAndroid/PhotoPickerAndroid.js | 189 ++++++++++++++++++ .../js/utils/RNTesterList.android.js | 5 + 4 files changed, 296 insertions(+) create mode 100644 packages/rn-tester/js/examples/PhotoPickerAndroid/PhotoPickerAndroid.js 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 9a83b95228f3..7ff68b8d9206 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 @@ -8,12 +8,17 @@ 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 @@ -49,6 +54,29 @@ public class SampleTurboModule(private val context: ReactApplicationContext) : 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) @@ -279,6 +307,47 @@ public class SampleTurboModule(private val context: ReactApplicationContext) : 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 :") @@ -304,3 +373,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 c8e38d9276ed..e89e91d09162 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeSampleTurboModule.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeSampleTurboModule.js @@ -63,6 +63,11 @@ 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/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', From 957e0b58afaad62574a1147fcbb6979a52fdd579 Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:29:08 +0200 Subject: [PATCH 05/10] docs: add readme --- .../react/activityresult/__docs__/README.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/__docs__/README.md 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..e2d20305549b --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/__docs__/README.md @@ -0,0 +1,182 @@ +# 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. From 60543c755456e491e42e2b188c805e92e28b968e Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:01:43 +0200 Subject: [PATCH 06/10] fix: discard inflight promises when js context invalidates --- .../platform/android/SampleTurboModule.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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 7ff68b8d9206..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 @@ -361,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 From 758c66506a257b8d0ce5d9417eac070b95d63d0a Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:04:06 +0200 Subject: [PATCH 07/10] fix: formatting --- .../react/activityresult/__docs__/README.md | 66 +++++++++---------- 1 file changed, 32 insertions(+), 34 deletions(-) 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 index e2d20305549b..62e51d2ad3b1 100644 --- 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 @@ -10,11 +10,11 @@ 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. +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 @@ -46,8 +46,7 @@ class MyModule(private val context: ReactApplicationContext) : } ``` -Stock AndroidX contracts work unchanged, with their own input and output -types: +Stock AndroidX contracts work unchanged, with their own input and output types: ```kotlin private val pickMedia = @@ -69,8 +68,8 @@ 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 `":"`: + `context.registerForActivityResult(owner, contract, callback)` scopes the key + to `":"`: ```kotlin private val getContent = @@ -80,10 +79,10 @@ private val getContent = ### 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: +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 : @@ -105,7 +104,7 @@ private class PickUpToMedia : launcher.launch(PickUpToMedia.Request(jsMaxItems, request)) ``` -This is the pattern for *any* per-call parameter, and it doubles as the +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 @@ -120,8 +119,8 @@ collision fix since the subclass gets a distinct key. ## 📐 Design -`ReactActivity` extends `ComponentActivity`, so the host Activity already owns -a real `ActivityResultRegistry` and already routes `onActivityResult` / +`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. @@ -133,16 +132,16 @@ reimplement the registry. - `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. +- 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. +- **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. @@ -150,15 +149,15 @@ Behavioral notes for library authors: 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. +- **`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. +- [ReactAndroid](../../../../../../../../README.md) — the core of React Native + on Android. ### Used by this @@ -166,17 +165,16 @@ Behavioral notes for library authors: `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 +- 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). +- `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. +reaches `ActivityEventListener.onActivityResult`. The listener remains the right +tool for intents a module builds and starts itself. From 0fd25c1c242e8717240419d108ca172a51f38e95 Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:43:31 +0200 Subject: [PATCH 08/10] fix: key strategy for registering activity contracts --- .../ReactActivityResultCaller.kt | 37 ++++++--- .../ReactActivityResultCallerImpl.kt | 40 +++++++--- .../react/activityresult/__docs__/README.md | 77 +++++++++++++++---- .../facebook/react/bridge/ReactContext.java | 36 ++++++--- .../platform/android/SampleTurboModule.kt | 7 +- 5 files changed, 146 insertions(+), 51 deletions(-) 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 index 145d36476f5a..5b38b583db32 100644 --- 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 @@ -22,30 +22,49 @@ import androidx.activity.result.contract.ActivityResultContract * 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`. + * Every registration carries a key that must be unique within the `ReactContext` and stable across + * process death -- after the process is killed mid-flow, AndroidX replays the restored result to + * whichever registration reproduces the same key string. The default key is scoped to the caller + * (`":"`), which is what lets two unrelated libraries both register a + * stock contract such as `ActivityResultContracts.GetContent` without colliding. + * + * A collision throws an [IllegalStateException] at registration time. With owner scoping this is + * only reachable when a single owner registers the same contract class twice; the fix is the + * overload that takes an extra `key`, which is appended to -- not substituted for -- the + * owner-and-contract scope, so a poorly chosen key can never reintroduce a cross-library collision. */ internal interface ReactActivityResultCaller { /** - * Registers [contract] and returns a launcher for it. The registration key is the contract's - * fully-qualified class name. + * Registers [contract] under the key `":"` and returns a launcher + * for it. + * + * [owner] should be a stable, long-lived object -- typically the native module itself. An + * anonymous object or a short-lived per-call helper yields a synthetic name such as + * `com.example.Foo$1`, which is fragile across builds and defeats re-association after process + * death. * - * @throws IllegalStateException if a registration with the same key already exists + * @throws IllegalStateException if [owner] already registered this contract class */ fun registerForActivityResult( + owner: Any, 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. + * Registers [contract] under the key `"::"`. Use this when one + * owner needs several launchers of the same contract class. + * + * [key] only has to be unique among [owner]'s registrations of this contract class -- the + * owner-and-contract scope is still applied -- but it must be stable across process death, so + * derive it from a constant rather than from runtime state. + * + * @throws IllegalStateException if [owner] already registered this contract class under [key] */ fun registerForActivityResult( owner: Any, + key: String, contract: ActivityResultContract, callback: ActivityResultCallback, ): ActivityResultLauncher diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt index ec671f4b9b14..2669789bee38 100644 --- 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 @@ -44,36 +44,52 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont reactContext.addLifecycleEventListener(this) } + private fun getOwnerId(owner: Any): String = owner.javaClass.name + override fun registerForActivityResult( + owner: Any, contract: ActivityResultContract, callback: ActivityResultCallback, - ): ActivityResultLauncher = - register(contract.javaClass.name, callback.javaClass.name, contract, callback) + ): ActivityResultLauncher { + val id = getOwnerId(owner) + return register( + key = "$id:${contract.javaClass.name}", + registrantDescription = id, + collisionHint = + "Register once and reuse the launcher, or pass a distinct key per launcher: " + + "registerForActivityResult(owner, \"someName\", contract, callback).", + contract = contract, + callback = callback) + } override fun registerForActivityResult( owner: Any, + key: String, contract: ActivityResultContract, callback: ActivityResultCallback, - ): ActivityResultLauncher = - register( - "${owner.javaClass.name}:${contract.javaClass.name}", - owner.javaClass.name, - contract, - callback) + ): ActivityResultLauncher { + val id = getOwnerId(owner) + return register( + key = "$id:${contract.javaClass.name}:$key", + registrantDescription = id, + collisionHint = "Pass a key that is unique among this owner's launchers of this contract.", + contract = contract, + callback = callback) + } + @Synchronized private fun register( key: String, registrantDescription: String, + collisionHint: 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.") + "${existing.registrantDescription} already registered a launcher for key '$key'. " + + collisionHint) } val launcher = DeferredActivityResultLauncher(key, contract) { unregister(key) } val entry = Entry(key, registrantDescription, contract, callback, launcher) 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 index 62e51d2ad3b1..0f52f2874e16 100644 --- 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 @@ -22,7 +22,9 @@ 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`. +`androidx.activity.result.ActivityResultLauncher`. The one addition is a +leading `owner` argument, which scopes the registration key — see +[Registration keys and collisions](#registration-keys-and-collisions). ```kotlin class MyModule(private val context: ReactApplicationContext) : @@ -34,6 +36,7 @@ class MyModule(private val context: ReactApplicationContext) : // long after the Activity exists, and registration is legal at any time. private val requestPermission = context.registerForActivityResult( + /* owner = */ this, ActivityResultContracts.RequestPermission()) { isGranted -> pendingPromise?.resolve(isGranted) pendingPromise = null @@ -51,6 +54,7 @@ Stock AndroidX contracts work unchanged, with their own input and output types: ```kotlin private val pickMedia = context.registerForActivityResult( + /* owner = */ this, ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> // null when the user dismissed the picker } @@ -60,23 +64,62 @@ 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: +Registrations are keyed by `":"`, derived by core +from the `owner` you pass. Because a class's fully-qualified name is globally +unique, two unrelated libraries can both register a stock contract such as +`GetContent` and never collide: -- **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 +// react-native-image-lib +class ImageModule(ctx: ReactApplicationContext) : NativeImageModuleSpec(ctx) { + private val pick = ctx.registerForActivityResult(this, GetContent()) { uri -> } + // key = "com.rnimage.ImageModule:androidx...GetContent" +} + +// react-native-doc-lib — same contract, different owner, no collision +class DocModule(ctx: ReactApplicationContext) : NativeDocModuleSpec(ctx) { + private val pick = ctx.registerForActivityResult(this, GetContent()) { uri -> } + // key = "com.rndoc.DocModule:androidx...GetContent" +} +``` + +Pass a stable, long-lived `owner` — normally the module itself. An anonymous +object or a per-call helper yields a synthetic name like `com.example.Foo$1`, +which is fragile across builds and defeats re-association after process death. + +A collision still throws `IllegalStateException` at registration time, but with +owner scoping it is only reachable from one owner's own code: registering the +same contract class twice. The fix is the **extra-key overload**: ```kotlin -private val getContent = - context.registerForActivityResult( - /* owner = */ this, ActivityResultContracts.GetContent()) { uri -> ... } +// Throws — same owner, same contract class, same key: +private val pickAvatar = ctx.registerForActivityResult(this, GetContent()) { } +private val pickBanner = ctx.registerForActivityResult(this, GetContent()) { } + +// Fix: +private val pickAvatar = ctx.registerForActivityResult(this, "avatar", GetContent()) { } +// key = "com.example.MyModule:androidx...GetContent:avatar" +private val pickBanner = ctx.registerForActivityResult(this, "banner", GetContent()) { } +// key = "com.example.MyModule:androidx...GetContent:banner" ``` +The key you pass is **appended to** the owner-and-contract scope, not +substituted for it, so it only has to be unique among that owner's launchers of +that contract — and no choice of key can reintroduce a cross-library collision. +It does still have to be stable across process death, so derive it from a +constant rather than from runtime state. + +#### Why not auto-generated keys, like `ComponentActivity`? + +`ComponentActivity` can key registrations by an incrementing counter +(`activity_rq#0`, `activity_rq#1`, …) because it registers in `onCreate`, in a +deterministic order every time. React Native cannot: native modules are created +lazily, in whatever order JS first touches them, so after process death +`activity_rq#0` may belong to a _different_ module than it did before. A +restored result would then be dispatched to the wrong callback and parsed with +the wrong contract. Deriving the key from the owner and contract classes keeps +it reproducible regardless of creation order. + ### Parameterized contracts: passing values from JS per call Contract constructor arguments are fixed at registration time. If a value comes @@ -104,8 +147,10 @@ private class PickUpToMedia : 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. +This is the pattern for _any_ per-call parameter. (It also happens to yield a +distinct key, since the subclass has its own class name — but that is +incidental; collisions are handled by owner scoping and the extra-key overload +above.) ### Working examples @@ -150,7 +195,7 @@ Behavioral notes for library authors: 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. + key can then be registered again. ## 🔗 Relationship with other systems 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 5e60b4d5506c..30748d599948 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java @@ -16,11 +16,13 @@ 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; @@ -33,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; @@ -551,24 +554,35 @@ private synchronized ReactActivityResultCallerImpl getActivityResultCaller() { * 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)}. + *

The registration key is {@code ":"}, so two unrelated libraries + * may both register a stock contract such as {@code ActivityResultContracts.GetContent} without + * colliding. {@code owner} should be a stable, long-lived object -- typically the native module + * itself -- because the key must be reproducible after process death. Registering the same + * contract class twice from one owner throws {@link IllegalStateException}; use {@link + * #registerForActivityResult(Object, String, ActivityResultContract, ActivityResultCallback)} in + * that case. */ public ActivityResultLauncher registerForActivityResult( - ActivityResultContract contract, ActivityResultCallback callback) { - return getActivityResultCaller().registerForActivityResult(contract, callback); + Object owner, ActivityResultContract contract, ActivityResultCallback callback) { + return getActivityResultCaller().registerForActivityResult(owner, 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. + * Same as {@link #registerForActivityResult(Object, ActivityResultContract, + * ActivityResultCallback)}, but registers under {@code "::"}. + * Use this when one owner needs several launchers of the same contract class. {@code key} only + * has to be unique among {@code owner}'s registrations of this contract class -- the + * owner-and-contract scope is still applied -- but it must be stable across process death. + * + * @throws IllegalStateException if {@code owner} already registered this contract class under + * {@code key} */ public ActivityResultLauncher registerForActivityResult( - Object owner, ActivityResultContract contract, ActivityResultCallback callback) { - return getActivityResultCaller().registerForActivityResult(owner, contract, callback); + Object owner, + String key, + ActivityResultContract contract, + ActivityResultCallback callback) { + return getActivityResultCaller().registerForActivityResult(owner, key, contract, callback); } /** 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 9fed63ea8b14..cfc5e8df9dd8 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/samples/platform/android/SampleTurboModule.kt +++ b/packages/react-native/ReactCommon/react/nativemodule/samples/platform/android/SampleTurboModule.kt @@ -48,7 +48,7 @@ public class SampleTurboModule(private val context: ReactApplicationContext) : // 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()) { + context.registerForActivityResult(this, ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> pendingPermissionPromise?.resolve(isGranted) pendingPermissionPromise = null @@ -60,7 +60,8 @@ public class SampleTurboModule(private val context: ReactApplicationContext) : // (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? -> + context.registerForActivityResult(this, ActivityResultContracts.PickVisualMedia()) { + uri: Uri? -> pendingPickMediaPromise?.resolve(uri?.toString()) pendingPickMediaPromise = null } @@ -70,7 +71,7 @@ public class SampleTurboModule(private val context: ReactApplicationContext) : // 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 -> + context.registerForActivityResult(this, PickUpToMedia()) { uris: List -> val result: WritableArray = WritableNativeArray() uris.forEach { result.pushString(it.toString()) } pendingPickMultipleMediaPromise?.resolve(result) From b4f17fab55ff266cf4a80fb7cc460b3593e86c88 Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:26:25 +0200 Subject: [PATCH 09/10] test: add tests for keying mechanism --- .../ReactActivityResultCallerImplTest.kt | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerImplTest.kt diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerImplTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerImplTest.kt new file mode 100644 index 000000000000..773387abf458 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerImplTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.activityresult + +import android.app.Activity +import android.os.Bundle +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts.GetContent +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.core.app.ActivityOptionsCompat +import com.facebook.react.bridge.ReactApplicationContext +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +/** + * Covers the registration keying scheme: owner-scoped by default so two independent modules can use + * the same stock contract, with an extra-key overload -- appended to that scope, not replacing it -- + * for one owner needing several launchers of the same contract class. + */ +@RunWith(RobolectricTestRunner::class) +class ReactActivityResultCallerImplTest { + + /** Records the keys handed to [ActivityResultRegistry.register] and never starts anything. */ + private class RecordingRegistry : ActivityResultRegistry() { + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ): Unit = Unit + + /** [onSaveInstanceState] is the only public window into the registry's key table. */ + val registeredKeys: List + get() = + Bundle() + .also { onSaveInstanceState(it) } + .getStringArrayList("KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS") + .orEmpty() + } + + class TestActivity : Activity(), ActivityResultRegistryOwner { + override val activityResultRegistry: ActivityResultRegistry = RecordingRegistry() + } + + /** Two distinct owner classes, standing in for two unrelated third-party modules. */ + private class ModuleA + + private class ModuleB + + private lateinit var registry: RecordingRegistry + private lateinit var reactContext: ReactApplicationContext + private lateinit var caller: ReactActivityResultCallerImpl + + private val moduleA = ModuleA() + private val moduleB = ModuleB() + + private val moduleAName = ModuleA::class.java.name + private val moduleBName = ModuleB::class.java.name + private val getContentName = GetContent::class.java.name + + @Before + fun setUp() { + val activity = Robolectric.buildActivity(TestActivity::class.java).create().get() + registry = activity.activityResultRegistry as RecordingRegistry + reactContext = mock() + whenever(reactContext.currentActivity).thenReturn(activity) + caller = ReactActivityResultCallerImpl(reactContext) + } + + @Test + fun twoOwnersMayRegisterTheSameStockContract() { + caller.registerForActivityResult(moduleA, GetContent()) {} + caller.registerForActivityResult(moduleB, GetContent()) {} + + assertThat(registry.registeredKeys) + .containsExactlyInAnyOrder( + "$moduleAName:$getContentName", "$moduleBName:$getContentName") + } + + @Test + fun oneOwnerRegisteringTheSameContractTwiceThrows() { + caller.registerForActivityResult(moduleA, GetContent()) {} + + assertThatThrownBy { caller.registerForActivityResult(moduleA, GetContent()) {} } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("registerForActivityResult(owner, \"someName\", contract, callback)") + } + + @Test + fun oneOwnerMayRegisterDifferentContractClasses() { + caller.registerForActivityResult(moduleA, GetContent()) {} + caller.registerForActivityResult(moduleA, RequestPermission()) {} + + assertThat(registry.registeredKeys) + .containsExactlyInAnyOrder( + "$moduleAName:$getContentName", "$moduleAName:${RequestPermission::class.java.name}") + } + + @Test + fun extraKeysAllowTwoLaunchersOfOneContract() { + caller.registerForActivityResult(moduleA, "avatar", GetContent()) {} + caller.registerForActivityResult(moduleA, "banner", GetContent()) {} + + assertThat(registry.registeredKeys) + .containsExactlyInAnyOrder( + "$moduleAName:$getContentName:avatar", "$moduleAName:$getContentName:banner") + } + + /** The owner-and-contract scope is still applied, so a shared key across owners is safe. */ + @Test + fun theSameExtraKeyFromTwoOwnersDoesNotCollide() { + caller.registerForActivityResult(moduleA, "pick", GetContent()) {} + caller.registerForActivityResult(moduleB, "pick", GetContent()) {} + + assertThat(registry.registeredKeys) + .containsExactlyInAnyOrder( + "$moduleAName:$getContentName:pick", "$moduleBName:$getContentName:pick") + } + + @Test + fun duplicateExtraKeyForOneOwnerThrows() { + caller.registerForActivityResult(moduleA, "avatar", GetContent()) {} + + assertThatThrownBy { caller.registerForActivityResult(moduleA, "avatar", GetContent()) {} } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("$moduleAName:$getContentName:avatar") + .hasMessageContaining("unique among this owner's launchers") + } + + @Test + fun aNonModuleOwnerKeysTheSameWayAModuleDoes() { + class MediaHelper + + val helper = MediaHelper() + caller.registerForActivityResult(helper, GetContent()) {} + + assertThat(registry.registeredKeys) + .containsExactly("${MediaHelper::class.java.name}:$getContentName") + } + + @Test + fun unregisteringFreesTheKeyForReuse() { + val launcher = caller.registerForActivityResult(moduleA, GetContent()) {} + launcher.unregister() + + caller.registerForActivityResult(moduleA, GetContent()) {} + + assertThat(registry.registeredKeys).containsExactly("$moduleAName:$getContentName") + } +} From f3d3fef733c11202154212bb098d512e1789c5cf Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:34:27 +0200 Subject: [PATCH 10/10] fix: thread-safety and and rebinding to current activity --- .../DeferredActivityResultLauncher.kt | 61 +++-- .../ReactActivityResultCallerImpl.kt | 102 +++++--- .../react/activityresult/__docs__/README.md | 80 ++++-- .../ReactActivityResultCallerThreadingTest.kt | 238 ++++++++++++++++++ 4 files changed, 399 insertions(+), 82 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerThreadingTest.kt 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 index f900504f858f..6196a995ff5c 100644 --- 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 @@ -8,9 +8,11 @@ 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 /** @@ -18,6 +20,11 @@ import com.facebook.react.common.ReactConstants * available. It delegates to the real launcher once [bind] is called, and queues a single pending * [launch] issued while unbound, firing it on bind. [unbind] detaches it when the host Activity is * destroyed so that [ReactActivityResultCallerImpl] can rebind it against the next host's registry. + * + * [launch] and [unregister] are called off the UI thread but reach `@MainThread` registry methods, + * so both hop. [delegate] and [pendingLaunch] are therefore UI-thread only and need no lock. Note + * [launch] decides bound-vs-queue *inside* the hop: doing it before would let a concurrent [unbind] + * strand the launch on a dead registry. */ internal class DeferredActivityResultLauncher( private val key: String, @@ -30,50 +37,58 @@ internal class DeferredActivityResultLauncher( private class PendingLaunch(val input: I, val options: ActivityOptionsCompat?) private var delegate: ActivityResultLauncher? = null + private var boundRegistry: ActivityResultRegistry? = 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.") + 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) } - pendingLaunch = PendingLaunch(input, options) } } - @Synchronized override fun unregister() { - delegate?.unregister() - delegate = null - pendingLaunch = null + // Drop the registration first, so nothing rebinds this launcher while the hop is in flight. onUnregister() + onUiThread { + delegate?.unregister() + delegate = null + pendingLaunch = null + } } - /** Attaches the real launcher and fires any launch queued while unbound. */ - @Synchronized - fun bind(launcher: ActivityResultLauncher) { + /** + * Attaches [launcher], obtained from [registry], and fires any launch queued while unbound. + * [registry] is remembered so [isBoundTo] can tell whether a later host is a different one. + */ + fun bind(registry: ActivityResultRegistry, launcher: ActivityResultLauncher) { + UiThreadUtil.assertOnUiThread() delegate = launcher + boundRegistry = registry pendingLaunch?.let { pending -> pendingLaunch = null launcher.launch(pending.input, pending.options) } } - /** Detaches from a dying registry, keeping any queued launch for the next [bind]. */ - @Synchronized + /** Detaches from the bound registry, keeping any queued launch for the next [bind]. */ fun unbind() { + UiThreadUtil.assertOnUiThread() delegate?.unregister() delegate = null + boundRegistry = null } - @get:Synchronized - val isBound: Boolean - get() = delegate != null + /** Whether this launcher is already bound to [registry] specifically -- not merely to something. */ + fun isBoundTo(registry: ActivityResultRegistry): Boolean = boundRegistry === registry } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt index 2669789bee38..d81673f90399 100644 --- 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 @@ -15,17 +15,47 @@ import androidx.activity.result.contract.ActivityResultContract import com.facebook.common.logging.FLog import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.common.ReactConstants +import java.util.concurrent.ConcurrentHashMap + +/** + * Runs [block] on the UI thread, inline if already there. + * + * [ActivityResultRegistry] is `@MainThread` and its key tables are unsynchronized. Nothing enforces + * that at runtime, so an off-thread call corrupts them silently rather than throwing -- and RN + * registers on the JS thread and launches on the native-modules thread. + */ +internal fun onUiThread(block: () -> Unit) { + if (UiThreadUtil.isOnUiThread()) block() else UiThreadUtil.runOnUiThread(block) +} /** * Default [ReactActivityResultCaller], owned by a [ReactContext]. * * Registrations are accepted at any time -- native modules are created lazily, typically well after * the host Activity has resumed -- and bound to the current Activity's [ActivityResultRegistry] - * either immediately (when an Activity is already available) or on the next `onHostResume`. 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. + * either immediately (when an Activity is already available) or on the next `onHostResume`. + * Registrations outlive any single Activity: keys stay stable, so AndroidX can re-associate a result + * that arrives after Activity recreation. + * + * ## Which registry a launcher is bound to + * + * Every `onHostResume` reconciles each launcher against the *current* registry, rebinding it if it + * is attached to a different one. It deliberately does not stop at "already bound to something": + * with multi-Activity navigation the new Activity resumes before the old one is destroyed, and + * `ReactHostImpl.onHostDestroy(activity)` drops the old Activity's destroy entirely once + * `currentActivity` has moved on. A launcher that only checked "am I bound?" would stay attached to + * the previous Activity's dead registry -- leaking it, and misrouting anything launched from the new + * screen. + * + * ## Threading + * + * [entries] is concurrent and reachable from any thread. Everything that touches + * [ActivityResultRegistry] goes through [onUiThread]. + * + * Registration itself stays on the caller's thread, so the launcher is returned immediately and a + * duplicate key throws from the frame that caused it. Only the registry call is hopped. */ internal class ReactActivityResultCallerImpl(private val reactContext: ReactContext) : ReactActivityResultCaller, LifecycleEventListener { @@ -33,12 +63,24 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont private class Entry( val key: String, val registrantDescription: String, - val contract: ActivityResultContract, - val callback: ActivityResultCallback, + private val contract: ActivityResultContract, + private val callback: ActivityResultCallback, val launcher: DeferredActivityResultLauncher, - ) + ) { + /** + * Ensures the launcher is bound to [registry], rebinding if it is currently attached to a + * different one. On [Entry] so an `Entry<*, *>` can be bound without unchecked casts. + */ + fun bindTo(registry: ActivityResultRegistry) { + if (launcher.isBoundTo(registry)) return + // Release the previous host's registry first: it may already be dead, and leaving the + // callback registered there leaks that Activity and misroutes anything launched from it. + launcher.unbind() + launcher.bind(registry, registry.register(key, contract, callback)) + } + } - private val entries = LinkedHashMap>() + private val entries = ConcurrentHashMap>() init { reactContext.addLifecycleEventListener(this) @@ -77,8 +119,6 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont callback = callback) } - - @Synchronized private fun register( key: String, registrantDescription: String, @@ -86,43 +126,29 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont contract: ActivityResultContract, callback: ActivityResultCallback, ): ActivityResultLauncher { - entries[key]?.let { existing -> + val launcher = DeferredActivityResultLauncher(key, contract) { entries.remove(key) } + val entry = Entry(key, registrantDescription, contract, callback, launcher) + entries.putIfAbsent(key, entry)?.let { existing -> throw IllegalStateException( "${existing.registrantDescription} already registered a launcher for key '$key'. " + collisionHint) } - val launcher = DeferredActivityResultLauncher(key, contract) { unregister(key) } - val entry = Entry(key, registrantDescription, contract, callback, launcher) - entries[key] = entry - currentRegistry()?.let { registry -> bind(entry, registry) } + onUiThread { currentRegistry()?.let { registry -> entry.bindTo(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 onHostResume() = onUiThread { + val registry = currentRegistry() ?: return@onUiThread + entries.values.forEach { it.bindTo(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() - } + override fun onHostDestroy() = onUiThread { + // Detach from the dying registry but keep the registrations: they rebind against the next host's + // registry under the same keys on the next onHostResume, which is how AndroidX re-associates a + // result that outlives the Activity. + entries.values.forEach { it.launcher.unbind() } } private fun currentRegistry(): ActivityResultRegistry? { @@ -137,8 +163,4 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont } 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 index 0f52f2874e16..6ea9174dc5db 100644 --- 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 @@ -23,7 +23,7 @@ shape to [`ComponentActivity.registerForActivityResult`](https://developer.android.com/training/basics/intents/result#register): same name, same `ActivityResultCallback`, and it returns the real `androidx.activity.result.ActivityResultLauncher`. The one addition is a -leading `owner` argument, which scopes the registration key — see +leading `owner` argument, which scopes the registration key; see [Registration keys and collisions](#registration-keys-and-collisions). ```kotlin @@ -76,14 +76,14 @@ class ImageModule(ctx: ReactApplicationContext) : NativeImageModuleSpec(ctx) { // key = "com.rnimage.ImageModule:androidx...GetContent" } -// react-native-doc-lib — same contract, different owner, no collision +// react-native-doc-lib: same contract, different owner, no collision class DocModule(ctx: ReactApplicationContext) : NativeDocModuleSpec(ctx) { private val pick = ctx.registerForActivityResult(this, GetContent()) { uri -> } // key = "com.rndoc.DocModule:androidx...GetContent" } ``` -Pass a stable, long-lived `owner` — normally the module itself. An anonymous +Pass a stable, long-lived `owner`, normally the module itself. An anonymous object or a per-call helper yields a synthetic name like `com.example.Foo$1`, which is fragile across builds and defeats re-association after process death. @@ -92,7 +92,7 @@ owner scoping it is only reachable from one owner's own code: registering the same contract class twice. The fix is the **extra-key overload**: ```kotlin -// Throws — same owner, same contract class, same key: +// Throws: same owner, same contract class, same key: private val pickAvatar = ctx.registerForActivityResult(this, GetContent()) { } private val pickBanner = ctx.registerForActivityResult(this, GetContent()) { } @@ -105,7 +105,7 @@ private val pickBanner = ctx.registerForActivityResult(this, "banner", GetConten The key you pass is **appended to** the owner-and-contract scope, not substituted for it, so it only has to be unique among that owner's launchers of -that contract — and no choice of key can reintroduce a cross-library collision. +that contract, and no choice of key can reintroduce a cross-library collision. It does still have to be stable across process death, so derive it from a constant rather than from runtime state. @@ -123,7 +123,7 @@ it reproducible regardless of creation order. ### Parameterized contracts: passing values from JS per call Contract constructor arguments are fixed at registration time. If a value comes -from JS per call — say the photo picker's item limit — move it into the +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: @@ -148,14 +148,13 @@ launcher.launch(PickUpToMedia.Request(jsMaxItems, request)) ``` This is the pattern for _any_ per-call parameter. (It also happens to yield a -distinct key, since the subclass has its own class name — but that is -incidental; collisions are handled by owner scoping and the extra-key overload -above.) +distinct key, since the subclass has its own class name, but that is incidental; +collisions are handled by owner scoping and the extra-key overload above.) ### Working examples - `SampleTurboModule.kt` - (`ReactCommon/react/nativemodule/samples/platform/android/`) — + (`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). @@ -167,21 +166,64 @@ above.) `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 +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 + 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 +- Registrations outlive any single Activity. `onHostDestroy` detaches them from + the dying registry but keeps them, and every `onHostResume` reconciles each + launcher against the **current** registry, rebinding it if it is attached to a + different one. Stable keys are what let AndroidX re-associate a result that arrives after Activity recreation. + Reconciling on every resume, rather than binding only when a launcher is + unbound, is required for multi-Activity navigation. There, the new Activity + resumes _before_ the old one is destroyed, and + `ReactHostImpl.onHostDestroy( activity)` then drops the old Activity's destroy + entirely because `currentActivity` has already moved on. A launcher that + stopped at "am I bound to something?" would stay attached to the previous + Activity's dead registry: it would leak that Activity, and a launch from the + new screen would dispatch into the old one. The single-Activity config-change + path never showed this, because there the destroy and the resume are strictly + ordered. + +### Threading + +`ActivityResultRegistry` is `@MainThread`, and its key tables are plain +unsynchronized maps. The annotation is not enforced at runtime, so calling it +off the main thread does not throw; it corrupts those maps silently, which +surfaces later as a lost registration, a `ConcurrentModificationException`-class +crash inside AndroidX's own `onSaveInstanceState` (i.e. on rotation), or two +keys sharing one request code, which delivers a result to the wrong callback. + +React Native never calls it from the main thread by default: modules are +constructed on the JS thread, so field-initializer registrations arrive on +`mqt_v_js`, and module methods run on `mqt_v_native`, so `launch()` arrives from +there. So this package hops. State is split by owner: + +- **Registration bookkeeping** (the key table used for collision detection) is a + concurrent map, callable from any thread. Claiming a key is a single atomic + operation, so two threads registering at once cannot both win. +- **Every call that reaches `ActivityResultRegistry`** (`register`, `launch`, + `unregister`) is confined to the UI thread, as is the launcher's binding + state. Each is asserted with `UiThreadUtil.assertOnUiThread()` in debug + builds, so a regression fails loudly instead of corrupting a map. + +Two consequences worth knowing: + +- **Registration is still synchronous.** You get the launcher back immediately, + and a duplicate key throws from your own frame rather than later on the UI + thread where it could not be traced back to you. Only the registry call hops. +- **`launch()` from a background thread is asynchronous.** It never guaranteed a + synchronous activity start anyway, since binding is deferred until an Activity + exists. + Behavioral notes for library authors: - **Register early, ideally in a field initializer or the module constructor.** @@ -201,16 +243,16 @@ Behavioral notes for library authors: ### Part of -- [ReactAndroid](../../../../../../../../README.md) — the core of React Native - on Android. +- [ReactAndroid](../../../../../../../../README.md): the core of React Native on + Android. ### Used by this -- `com.facebook.react.bridge.ReactContext` — exposes the public +- `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 +- AndroidX `androidx.activity.result`: the contracts, launchers, and the host Activity's `ActivityResultRegistry` that actually starts activities and dispatches results. diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerThreadingTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerThreadingTest.kt new file mode 100644 index 000000000000..f7d3954d2836 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/activityresult/ReactActivityResultCallerThreadingTest.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.activityresult + +import android.app.Activity +import android.os.Bundle +import android.os.Looper +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts.GetContent +import androidx.core.app.ActivityOptionsCompat +import com.facebook.react.bridge.ReactApplicationContext +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +/** + * `ActivityResultRegistry` is `@MainThread` and its key tables are unsynchronized plain maps, but + * the annotation is not enforced at runtime -- off-thread access corrupts them silently rather than + * throwing. Native modules are constructed on the JS thread and their methods run on the + * native-modules thread, so every call into the registry has to be hopped to the UI thread. + * + * These tests pin that down by driving the caller from a background thread and asserting the + * registry is untouched until the main looper runs. + */ +@RunWith(RobolectricTestRunner::class) +class ReactActivityResultCallerThreadingTest { + + private class RecordingRegistry : ActivityResultRegistry() { + val launchThreads = mutableListOf() + + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + launchThreads += Thread.currentThread().name + } + + /** [onSaveInstanceState] is the only public window into the registry's key table. */ + val registeredKeys: List + get() = + Bundle() + .also { onSaveInstanceState(it) } + .getStringArrayList("KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS") + .orEmpty() + } + + class TestActivity : Activity(), ActivityResultRegistryOwner { + override val activityResultRegistry: ActivityResultRegistry = RecordingRegistry() + } + + private class ModuleA + + private lateinit var registry: RecordingRegistry + private lateinit var reactContext: ReactApplicationContext + private lateinit var caller: ReactActivityResultCallerImpl + + private val moduleA = ModuleA() + private val expectedKey = "${ModuleA::class.java.name}:${GetContent::class.java.name}" + + @Before + fun setUp() { + reactContext = mock() + registry = resumeNewActivity() + caller = ReactActivityResultCallerImpl(reactContext) + } + + /** Stands in for a new Activity becoming current, and returns its registry. */ + private fun resumeNewActivity(): RecordingRegistry { + val activity = Robolectric.buildActivity(TestActivity::class.java).create().get() + whenever(reactContext.currentActivity).thenReturn(activity) + return activity.activityResultRegistry as RecordingRegistry + } + + private fun onBackgroundThread(block: () -> Unit) { + var failure: Throwable? = null + val thread = Thread { runCatching(block).onFailure { failure = it } } + thread.start() + thread.join(10_000) + failure?.let { throw it } + } + + private fun drainMainLooper() = shadowOf(Looper.getMainLooper()).idle() + + @Test + fun `registering off the UI thread defers the registry call to the UI thread`() { + onBackgroundThread { caller.registerForActivityResult(moduleA, GetContent()) {} } + + assertThat(registry.registeredKeys) + .describedAs("registry.register must not run on the caller's thread") + .isEmpty() + + drainMainLooper() + + assertThat(registry.registeredKeys).containsExactly(expectedKey) + } + + @Test + fun `the launcher is returned synchronously even though binding is deferred`() { + lateinit var launcher: Any + onBackgroundThread { launcher = caller.registerForActivityResult(moduleA, GetContent()) {} } + + // Registering in a field initializer depends on this: the launcher is usable immediately. + assertThat(launcher).isInstanceOf(DeferredActivityResultLauncher::class.java) + } + + @Test + fun `a duplicate key still throws on the caller's own thread`() { + caller.registerForActivityResult(moduleA, GetContent()) {} + drainMainLooper() + + var thrown: Throwable? = null + onBackgroundThread { + thrown = runCatching { caller.registerForActivityResult(moduleA, GetContent()) {} }.exceptionOrNull() + } + + // Not surfaced later on the UI thread, where it would be unattributable. + assertThat(thrown).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `launching off the UI thread defers onLaunch to the UI thread`() { + val launcher = caller.registerForActivityResult(moduleA, GetContent()) {} + drainMainLooper() + + onBackgroundThread { launcher.launch("image/*") } + + assertThat(registry.launchThreads) + .describedAs("registry.onLaunch must not run on the caller's thread") + .isEmpty() + + drainMainLooper() + + assertThat(registry.launchThreads).containsExactly(Looper.getMainLooper().thread.name) + } + + /** + * Multi-Activity navigation: B resumes while A is still alive, and `ReactHostImpl` then drops + * A's `onHostDestroy` because `currentActivity` has already moved to B. So no unbind ever runs + * for A -- `onHostResume` alone has to move the launcher across. + */ + @Test + fun `resuming a second activity rebinds to its registry without any onHostDestroy`() { + val launcher = caller.registerForActivityResult(moduleA, GetContent()) {} + drainMainLooper() + val registryA = registry + + val registryB = resumeNewActivity() + caller.onHostResume() // note: no onHostDestroy for A, exactly as ReactHostImpl behaves + drainMainLooper() + + assertThat(registryB.registeredKeys) + .describedAs("the launcher must follow the current Activity") + .containsExactly(expectedKey) + assertThat(registryA.registeredKeys) + .describedAs("staying registered on the dead registry leaks the old Activity") + .isEmpty() + + launcher.launch("image/*") + drainMainLooper() + + assertThat(registryB.launchThreads).hasSize(1) + assertThat(registryA.launchThreads) + .describedAs("a launch from the new screen must not dispatch into the old Activity") + .isEmpty() + } + + @Test + fun `resuming the same activity again does not re-register`() { + caller.registerForActivityResult(moduleA, GetContent()) {} + drainMainLooper() + + caller.onHostResume() + caller.onHostResume() + drainMainLooper() + + assertThat(registry.registeredKeys).containsExactly(expectedKey) + } + + @Test + fun `two threads racing to claim one key produce exactly one winner`() { + val start = CountDownLatch(1) + val done = CountDownLatch(2) + val failures = mutableListOf() + + repeat(2) { + Thread { + start.await() + runCatching { caller.registerForActivityResult(moduleA, GetContent()) {} } + .onFailure { e -> synchronized(failures) { failures += e } } + done.countDown() + } + .start() + } + start.countDown() + done.await(10, TimeUnit.SECONDS) + drainMainLooper() + + // Claiming the key is one atomic operation, so the loser always sees the collision. + assertThat(failures).hasSize(1) + assertThat(failures.single()).isInstanceOf(IllegalStateException::class.java) + assertThat(registry.registeredKeys).containsExactly(expectedKey) + } + + @Test + fun `a launch issued before binding is queued and fires once bound`() { + lateinit var launcher: Any + onBackgroundThread { + launcher = caller.registerForActivityResult(moduleA, GetContent()) {} + @Suppress("UNCHECKED_CAST") + (launcher as DeferredActivityResultLauncher).launch("image/*") + } + + assertThat(registry.launchThreads).isEmpty() + + drainMainLooper() + + // Bind and the queued launch both land on the UI thread, in that order. + assertThat(registry.registeredKeys).containsExactly(expectedKey) + assertThat(registry.launchThreads).containsExactly(Looper.getMainLooper().thread.name) + } +}