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. */