Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* 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] 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.
*
* [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<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 while the hop is in flight.
onUnregister()
onUiThread {
delegate?.unregister()
delegate = null
pendingLaunch = null
}
}

/**
* 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<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 already bound to [registry] specifically -- not merely to something. */
fun isBoundTo(registry: ActivityResultRegistry): Boolean = boundRegistry === registry
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* 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.
*
* 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
* (`"<owner class>:<contract class>"`), 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] 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 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 [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 [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 <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,166 @@
/*
* 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` 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`.
* 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 {

private class Entry<I, O>(
val key: String,
val registrantDescription: 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 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 = ConcurrentHashMap<String, Entry<*, *>>()

init {
reactContext.addLifecycleEventListener(this)
}

private fun getOwnerId(owner: Any): String = owner.javaClass.name

override fun <I, O> registerForActivityResult(
owner: Any,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I> {
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 <I, O> registerForActivityResult(
owner: Any,
key: String,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I> {
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)
}

private fun <I, O> register(
key: String,
registrantDescription: String,
collisionHint: String,
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I> {
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)
}
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 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? {
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