From 3dc8d962b8dc58d047522d80a344310f0a6b42b2 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Tue, 29 Sep 2026 16:48:17 -0700 Subject: [PATCH] Isolate Activity Result support in its own Buck target Summary: Move Android Activity Result support out of the bridge target's aggregate source list and into a dedicated library. Reuse the bridge's lifecycle interface and inject an `ActivityResultRegistryProvider` plus an exception callback into the implementation. `ReactContext` owns registry discovery and lifecycle-listener registration, so the new target's only React bridge dependency is the bridge interfaces target. Remove the individual source export shims, make the bridge depend on the new target, and give Activity Result tests direct friend access to the implementation target. Changelog: [Internal] - Isolate Android Activity Result support in its own build target Differential Revision: D121859455 --- .../ActivityResultRegistryProvider.kt | 14 +++ .../DeferredActivityResultLauncher.kt | 12 +-- .../ReactActivityResultCallerImpl.kt | 93 +++++++++---------- .../facebook/react/bridge/ReactContext.java | 23 ++++- .../ReactActivityResultCallerImplTest.kt | 11 ++- .../ReactActivityResultCallerThreadingTest.kt | 11 ++- 6 files changed, 103 insertions(+), 61 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ActivityResultRegistryProvider.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ActivityResultRegistryProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ActivityResultRegistryProvider.kt new file mode 100644 index 000000000000..7d593aa0eac7 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ActivityResultRegistryProvider.kt @@ -0,0 +1,14 @@ +/* + * 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.ActivityResultRegistry + +internal fun interface ActivityResultRegistryProvider { + fun getActivityResultRegistry(): ActivityResultRegistry? +} 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 7736faf6a390..25d91919fd7f 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 @@ -12,7 +12,6 @@ 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 /** @@ -21,8 +20,9 @@ import com.facebook.react.common.ReactConstants * unbound (fired on bind), and can be [unbind]-ed and rebound against a new host's registry. * * [delegate] and [pendingLaunch] are only touched on the UI thread; [launch] and [unregister] get - * there via [onUiThread]. [launch] decides between delegating and queueing *on* the UI thread, so a - * concurrent [unbind] cannot leave it pointed at a dead registry. + * there via [ReactActivityResultCallerImpl.UiThread.onUiThread]. [launch] decides between + * delegating and queueing *on* the UI thread, so a concurrent [unbind] cannot leave it pointed at a + * dead registry. */ internal class DeferredActivityResultLauncher( private val key: String, @@ -38,7 +38,7 @@ internal class DeferredActivityResultLauncher( private var pendingLaunch: PendingLaunch? = null override fun launch(input: I, options: ActivityOptionsCompat?) { - onUiThread { + ReactActivityResultCallerImpl.UiThread.onUiThread { val boundDelegate = delegate if (boundDelegate != null) { launchSafely(boundDelegate, input, options) @@ -58,7 +58,7 @@ internal class DeferredActivityResultLauncher( override fun unregister() { // Drop the registration first so nothing rebinds this launcher in the meantime. onUnregister() - onUiThread { + ReactActivityResultCallerImpl.UiThread.onUiThread { try { delegate?.unregister() } catch (exception: RuntimeException) { @@ -80,7 +80,6 @@ internal class DeferredActivityResultLauncher( * queued launch. */ fun bind(registry: ActivityResultRegistry, launcher: ActivityResultLauncher) { - UiThreadUtil.assertOnUiThread() delegate = launcher boundRegistry = registry pendingLaunch?.let { pending -> @@ -112,7 +111,6 @@ internal class DeferredActivityResultLauncher( /** Detaches from the bound registry, keeping any queued launch for the next [bind]. */ fun unbind() { - UiThreadUtil.assertOnUiThread() try { delegate?.unregister() } catch (exception: RuntimeException) { 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 f0ded12b1988..e8c4c10a96a3 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 @@ -7,29 +7,19 @@ package com.facebook.react.activityresult +import android.os.Handler +import android.os.Looper import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultRegistry -import androidx.activity.result.ActivityResultRegistryOwner import androidx.activity.result.contract.ActivityResultContract import com.facebook.common.logging.FLog import com.facebook.react.bridge.LifecycleEventListener -import com.facebook.react.bridge.ReactContext -import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.common.ReactConstants import java.util.concurrent.ConcurrentHashMap /** - * Runs [block] on the UI thread, inline if already there. [ActivityResultRegistry] is `@MainThread` - * but not enforced at runtime: an off-thread call corrupts it silently, and RN calls in from the JS - * and native-modules threads. - */ -internal fun onUiThread(block: () -> Unit) { - if (UiThreadUtil.isOnUiThread()) block() else UiThreadUtil.runOnUiThread(block) -} - -/** - * Default [ReactActivityResultCaller], owned by a [ReactContext]. + * Default [ReactActivityResultCaller], owned by a React context. * * Registrations are accepted at any time and bound to the current Activity's * [ActivityResultRegistry] immediately or on the next `onHostResume`. They outlive any single @@ -41,12 +31,24 @@ internal fun onUiThread(block: () -> Unit) { * would leave launchers attached to the previous Activity's dead registry. * * Threading: [entries] is concurrent and reachable from any thread; everything touching the - * registry goes through [onUiThread]. Registration stays on the caller's thread so the launcher - * returns immediately and a duplicate key throws at the causing frame. Only the registry call moves - * to the UI thread. + * registry goes through [UiThread.onUiThread]. Registration stays on the caller's thread so the + * launcher returns immediately and a duplicate key throws at the causing frame. Only the registry + * call moves to the UI thread. */ -internal class ReactActivityResultCallerImpl(private val reactContext: ReactContext) : - ReactActivityResultCaller, LifecycleEventListener { +internal class ReactActivityResultCallerImpl( + private val registryProvider: ActivityResultRegistryProvider, + private val exceptionHandler: ActivityResultCallback, +) : ReactActivityResultCaller, LifecycleEventListener { + + internal object UiThread { + private val uiHandler by lazy { Handler(Looper.getMainLooper()) } + + /** Runs [block] on the UI thread, inline if already there. */ + fun onUiThread(block: () -> Unit) { + if (Looper.getMainLooper().thread === Thread.currentThread()) block() + else uiHandler.post(Runnable(block)) + } + } private class Entry( val key: String, @@ -69,10 +71,6 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont private val entries = ConcurrentHashMap>() - init { - reactContext.addLifecycleEventListener(this) - } - override fun registerForActivityResult( owner: Any, contract: ActivityResultContract, @@ -82,7 +80,7 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont owner, contract, callback, - ActivityResultCallback { exception -> reactContext.handleException(exception) }, + ActivityResultCallback { exception -> exceptionHandler.onActivityResult(exception) }, ) override fun registerForActivityResult( @@ -120,7 +118,7 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont key, contract, callback, - ActivityResultCallback { exception -> reactContext.handleException(exception) }, + ActivityResultCallback { exception -> exceptionHandler.onActivityResult(exception) }, ) override fun registerForActivityResult( @@ -164,19 +162,20 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont if (entries.putIfAbsent(key, entry) != null) { throw IllegalStateException("A launcher is already registered for key '$key'. $collisionHint") } - onUiThread { currentRegistry()?.let { registry -> bindSafely(entry, registry) } } + UiThread.onUiThread { currentRegistry()?.let { registry -> bindSafely(entry, registry) } } return launcher } - override fun onHostResume() = onUiThread { - if (entries.isEmpty()) return@onUiThread - val registry = currentRegistry() - if (registry == null) { - entries.values.forEach(::unbindSafely) - return@onUiThread - } - entries.values.forEach { entry -> bindSafely(entry, registry) } - } + override fun onHostResume() = + UiThread.onUiThread { + if (entries.isEmpty()) return@onUiThread + val registry = currentRegistry() + if (registry == null) { + entries.values.forEach(::unbindSafely) + return@onUiThread + } + entries.values.forEach { entry -> bindSafely(entry, registry) } + } private fun bindSafely(entry: Entry<*, *>, registry: ActivityResultRegistry) { try { @@ -204,22 +203,14 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont override fun onHostPause(): Unit = Unit - override fun onHostDestroy() = onUiThread { - // Detach from the dying registry but keep the registrations: they rebind under the same keys - // on the next onHostResume, which is how AndroidX re-associates a surviving result. - entries.values.forEach(::unbindSafely) - } + override fun onHostDestroy() = + UiThread.onUiThread { + // Detach from the dying registry but keep the registrations: they rebind under the same + // keys + // on the next onHostResume, which is how AndroidX re-associates a surviving result. + entries.values.forEach(::unbindSafely) + } - private fun currentRegistry(): ActivityResultRegistry? { - val activity = reactContext.currentActivity ?: return null - if (activity !is ActivityResultRegistryOwner) { - FLog.w( - ReactConstants.TAG, - "Current Activity ${activity.javaClass.name} is not an ActivityResultRegistryOwner; " + - "ActivityResultContract launchers will remain unbound.", - ) - return null - } - return activity.activityResultRegistry - } + private fun currentRegistry(): ActivityResultRegistry? = + registryProvider.getActivityResultRegistry() } 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 850c8b0f049d..2280711ac026 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 @@ -18,6 +18,8 @@ import android.view.Window; 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 androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -537,9 +539,28 @@ public boolean startActivityForResult(Intent intent, int code, Bundle bundle) { return mCurrentActivity.get(); } + private @Nullable ActivityResultRegistry getCurrentActivityResultRegistry() { + Activity activity = getCurrentActivity(); + if (activity == null) { + return null; + } + if (!(activity instanceof ActivityResultRegistryOwner)) { + FLog.w( + TAG, + String.format( + "Activity %s lacks ActivityResultRegistryOwner; launchers remain unbound.", + activity.getClass().getName())); + return null; + } + return ((ActivityResultRegistryOwner) activity).getActivityResultRegistry(); + } + private synchronized ReactActivityResultCallerImpl getActivityResultCaller() { if (mActivityResultCaller == null) { - mActivityResultCaller = new ReactActivityResultCallerImpl(this); + mActivityResultCaller = + new ReactActivityResultCallerImpl( + this::getCurrentActivityResultRegistry, this::handleException); + addLifecycleEventListener(mActivityResultCaller); } return mActivityResultCaller; } 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 index 5e601ebc2fa9..4c3993fa841f 100644 --- 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 @@ -9,6 +9,7 @@ package com.facebook.react.activityresult import android.app.Activity import android.os.Bundle +import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultRegistry import androidx.activity.result.ActivityResultRegistryOwner import androidx.activity.result.contract.ActivityResultContract @@ -72,13 +73,21 @@ class ReactActivityResultCallerImplTest { private val moduleBName = ModuleB::class.java.name private val getContentName = GetContent::class.java.name + private fun createCaller(context: ReactApplicationContext): ReactActivityResultCallerImpl = + ReactActivityResultCallerImpl( + ActivityResultRegistryProvider { + (context.currentActivity as? ActivityResultRegistryOwner)?.activityResultRegistry + }, + ActivityResultCallback { exception -> context.handleException(exception) }, + ) + @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) + caller = createCaller(reactContext) } @Test 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 index 78e58d6d3ce2..219fae546c7c 100644 --- 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 @@ -11,6 +11,7 @@ import android.app.Activity import android.content.ActivityNotFoundException import android.os.Bundle import android.os.Looper +import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultRegistry import androidx.activity.result.ActivityResultRegistryOwner @@ -90,11 +91,19 @@ class ReactActivityResultCallerThreadingTest { private val moduleA = ModuleA() private val expectedKey = "${ModuleA::class.java.name}:${GetContent::class.java.name}" + private fun createCaller(context: ReactApplicationContext): ReactActivityResultCallerImpl = + ReactActivityResultCallerImpl( + ActivityResultRegistryProvider { + (context.currentActivity as? ActivityResultRegistryOwner)?.activityResultRegistry + }, + ActivityResultCallback { exception -> context.handleException(exception) }, + ) + @Before fun setUp() { reactContext = mock() registry = resumeNewActivity() - caller = ReactActivityResultCallerImpl(reactContext) + caller = createCaller(reactContext) } /** Stands in for a new Activity becoming current, and returns its registry. */