Skip to content
Open
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,92 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.facebook.react.activityresult

import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.app.ActivityOptionsCompat
import com.facebook.common.logging.FLog
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.common.ReactConstants

/**
* An [ActivityResultLauncher] that may exist before any `ActivityResultRegistry` is available: it
* delegates to the real launcher once [bind] is called, queues a single [launch] issued while
* unbound (fired on bind), and can be [unbind]-ed and rebound against a new host's registry.
*
* [delegate] and [pendingLaunch] are only touched on the UI thread; [launch] and [unregister] get
* there via [onUiThread]. [launch] decides between delegating and queueing *on* the UI thread, so
* a concurrent [unbind] cannot leave it pointed at a dead registry.
*/
internal class DeferredActivityResultLauncher<I>(
private val key: String,
private val contract: ActivityResultContract<I, *>,
private val onUnregister: () -> Unit,
) : ActivityResultLauncher<I>() {

override fun getContract(): ActivityResultContract<I, *> = contract

private class PendingLaunch<I>(val input: I, val options: ActivityOptionsCompat?)

private var delegate: ActivityResultLauncher<I>? = null
private var boundRegistry: ActivityResultRegistry? = null
private var pendingLaunch: PendingLaunch<I>? = null

override fun launch(input: I, options: ActivityOptionsCompat?) {
onUiThread {
val boundDelegate = delegate
if (boundDelegate != null) {
boundDelegate.launch(input, options)
} else {
if (pendingLaunch != null) {
FLog.w(
ReactConstants.TAG,
"Launcher for '$key' was launched again before an Activity was available; " +
"replacing the previously queued launch.")
}
pendingLaunch = PendingLaunch(input, options)
}
}
}

override fun unregister() {
// Drop the registration first so nothing rebinds this launcher in the meantime.
onUnregister()
onUiThread {
delegate?.unregister()
delegate = null
pendingLaunch = null
}
}

/**
* Attaches [launcher], obtained from [registry] (remembered for [isBoundTo]), and fires any
* queued launch.
*/
fun bind(registry: ActivityResultRegistry, launcher: ActivityResultLauncher<I>) {
UiThreadUtil.assertOnUiThread()
delegate = launcher
boundRegistry = registry
pendingLaunch?.let { pending ->
pendingLaunch = null
launcher.launch(pending.input, pending.options)
}
}

/** Detaches from the bound registry, keeping any queued launch for the next [bind]. */
fun unbind() {
UiThreadUtil.assertOnUiThread()
delegate?.unregister()
delegate = null
boundRegistry = null
}

/** Whether this launcher is bound to [registry] itself, not just to any registry. */
fun isBoundTo(registry: ActivityResultRegistry): Boolean = boundRegistry === registry
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.facebook.react.activityresult

import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract

/**
* Lets a native module register an AndroidX [ActivityResultContract] and receive results without
* any changes to the consumer's `MainActivity`. Mirrors
* `androidx.activity.ComponentActivity.registerForActivityResult`, except registration is legal at
* any time: the returned launcher binds to the real registry once a host Activity resumes.
*
* Every registration carries a key that must be unique within the `ReactContext` and stable across
* process death (AndroidX replays a restored result to whichever registration reproduces the same
* key). The default key `"<owner class>:<contract class>"` lets unrelated libraries register the
* same stock contract without colliding; a collision throws [IllegalStateException] at
* registration time, and the keyed overload (which appends to that scope, not replaces it)
* resolves it.
*/
internal interface ReactActivityResultCaller {

/**
* Registers [contract] under the key `"<owner class>:<contract class>"` and returns a launcher
* for it. [owner] should be a stable, long-lived object, typically the native module itself: an
* anonymous class's generated name can change between builds, which breaks result delivery
* after the process is killed and restored.
*
* @throws IllegalStateException if [owner] already registered this contract class
*/
fun <I, O> registerForActivityResult(
owner: Any,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I>

/**
* Registers [contract] under the key `"<owner class>:<contract class>:<key>"`. Use this when
* one owner needs several launchers of the same contract class. [key] only has to be unique
* among those, but must stay the same across process restarts, so derive it from a constant.
*
* @throws IllegalStateException if [owner] already registered this contract class under [key]
*/
fun <I, O> registerForActivityResult(
owner: Any,
key: String,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.facebook.react.activityresult

import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContract
import com.facebook.common.logging.FLog
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.common.ReactConstants
import java.util.concurrent.ConcurrentHashMap

/**
* Runs [block] on the UI thread, inline if already there. [ActivityResultRegistry] is `@MainThread`
* but not enforced at runtime: an off-thread call corrupts it silently, and RN calls in from the JS
* and native-modules threads.
*/
internal fun onUiThread(block: () -> Unit) {
if (UiThreadUtil.isOnUiThread()) block() else UiThreadUtil.runOnUiThread(block)
}

/**
* Default [ReactActivityResultCaller], owned by a [ReactContext].
*
* Registrations are accepted at any time and bound to the current Activity's
* [ActivityResultRegistry] immediately or on the next `onHostResume`. They outlive any single
* Activity: keys stay stable so AndroidX can re-associate a result after Activity recreation.
*
* Every `onHostResume` checks each launcher against the *current* registry, not just "already
* bound to something": with multi-Activity navigation the new Activity resumes before the old one
* is destroyed (whose onHostDestroy is dropped once `currentActivity` moves on), so a bound-only
* check would leave launchers attached to the previous Activity's dead registry.
*
* Threading: [entries] is concurrent and reachable from any thread; everything touching the
* registry goes through [onUiThread]. Registration stays on the caller's thread so the launcher
* returns immediately and a duplicate key throws at the causing frame. Only the registry call
* moves to the UI thread.
*/
internal class ReactActivityResultCallerImpl(private val reactContext: ReactContext) :
ReactActivityResultCaller, LifecycleEventListener {

private class Entry<I, O>(
val key: String,
private val contract: ActivityResultContract<I, O>,
private val callback: ActivityResultCallback<O>,
val launcher: DeferredActivityResultLauncher<I>,
) {
/**
* Ensures the launcher is bound to [registry], rebinding if it is currently attached to a
* different one. On [Entry] so an `Entry<*, *>` can be bound without unchecked casts.
*/
fun bindTo(registry: ActivityResultRegistry) {
if (launcher.isBoundTo(registry)) return
// Release any previous (possibly dead) registry first; staying registered there leaks its
// Activity and sends launches to the wrong one.
launcher.unbind()
launcher.bind(registry, registry.register(key, contract, callback))
}
}

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

init {
reactContext.addLifecycleEventListener(this)
}

override fun <I, O> registerForActivityResult(
owner: Any,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I> {
return register(
key = "${owner.javaClass.name}:${contract.javaClass.name}",
collisionHint =
"Register once and reuse the launcher, or pass a distinct key per launcher: " +
"registerForActivityResult(owner, \"someName\", contract, callback).",
contract = contract,
callback = callback)
}

override fun <I, O> registerForActivityResult(
owner: Any,
key: String,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I> {
return register(
key = "${owner.javaClass.name}:${contract.javaClass.name}:$key",
collisionHint = "Pass a key that is unique among this owner's launchers of this contract.",
contract = contract,
callback = callback)
}

private fun <I, O> register(
key: String,
collisionHint: String,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I> {
val launcher = DeferredActivityResultLauncher(key, contract) { entries.remove(key) }
val entry = Entry(key, contract, callback, launcher)
if (entries.putIfAbsent(key, entry) != null) {
throw IllegalStateException(
"A launcher is already registered for key '$key'. $collisionHint")
}
onUiThread { currentRegistry()?.let { registry -> entry.bindTo(registry) } }
return launcher
}

override fun onHostResume() = onUiThread {
val registry = currentRegistry() ?: return@onUiThread
entries.values.forEach { it.bindTo(registry) }
}

override fun onHostPause(): Unit = Unit

override fun onHostDestroy() = onUiThread {
// Detach from the dying registry but keep the registrations: they rebind under the same keys
// on the next onHostResume, which is how AndroidX re-associates a surviving result.
entries.values.forEach { it.launcher.unbind() }
}

private fun currentRegistry(): ActivityResultRegistry? {
val activity = reactContext.currentActivity ?: return null
val owner = activity as? ActivityResultRegistryOwner
if (owner == null) {
FLog.w(
ReactConstants.TAG,
"Current Activity ${activity.javaClass.name} is not an ActivityResultRegistryOwner; " +
"ActivityResultContract launchers will stay queued until one is available.")
return null
}
return owner.activityResultRegistry
}
}
Loading
Loading