Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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?
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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<I>(
private val key: String,
Expand All @@ -38,7 +38,7 @@ internal class DeferredActivityResultLauncher<I>(
private var pendingLaunch: PendingLaunch<I>? = null

override fun launch(input: I, options: ActivityOptionsCompat?) {
onUiThread {
ReactActivityResultCallerImpl.UiThread.onUiThread {
val boundDelegate = delegate
if (boundDelegate != null) {
launchSafely(boundDelegate, input, options)
Expand All @@ -58,7 +58,7 @@ internal class DeferredActivityResultLauncher<I>(
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) {
Expand All @@ -80,7 +80,6 @@ internal class DeferredActivityResultLauncher<I>(
* queued launch.
*/
fun bind(registry: ActivityResultRegistry, launcher: ActivityResultLauncher<I>) {
UiThreadUtil.assertOnUiThread()
delegate = launcher
boundRegistry = registry
pendingLaunch?.let { pending ->
Expand Down Expand Up @@ -112,7 +111,6 @@ internal class DeferredActivityResultLauncher<I>(

/** Detaches from the bound registry, keeping any queued launch for the next [bind]. */
fun unbind() {
UiThreadUtil.assertOnUiThread()
try {
delegate?.unregister()
} catch (exception: RuntimeException) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<RuntimeException>,
) : 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<I, O>(
val key: String,
Expand All @@ -69,10 +71,6 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont

private val entries = ConcurrentHashMap<String, Entry<*, *>>()

init {
reactContext.addLifecycleEventListener(this)
}

override fun <I, O> registerForActivityResult(
owner: Any,
contract: ActivityResultContract<I, O>,
Expand All @@ -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 <I, O> registerForActivityResult(
Expand Down Expand Up @@ -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 <I, O> registerForActivityResult(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ReactApplicationContext>()
whenever(reactContext.currentActivity).thenReturn(activity)
caller = ReactActivityResultCallerImpl(reactContext)
caller = createCaller(reactContext)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ReactApplicationContext>()
registry = resumeNewActivity()
caller = ReactActivityResultCallerImpl(reactContext)
caller = createCaller(reactContext)
}

/** Stands in for a new Activity becoming current, and returns its registry. */
Expand Down
Loading