Skip to content

Commit f59bca0

Browse files
matinzdclaude
andcommitted
feat: let native modules register ActivityResultContracts via ReactContext
Adds com.facebook.react.activityresult with a ReactActivityResultCaller that registers AndroidX ActivityResultContracts against the host Activity's own ActivityResultRegistry (ReactActivity already extends ComponentActivity, so it is an ActivityResultRegistryOwner). No changes to consumers' MainActivity, no manifest entries, no forked registry. - Registration is legal at any time: modules are created lazily, so the returned launcher is a deferred wrapper that binds to the registry on onHostResume, queues a single launch issued while unbound, and rebinds under the same key after Activity recreation. - Keys are the contract's fully-qualified class name; duplicate registrations throw at registration time, with an owner-scoped overload as the escape hatch. - ReactContext gains registerForActivityResult convenience methods mirroring ComponentActivity, plus getActivityResultCaller(). - ActivityEventListener dispatch is untouched; results flow through ComponentActivity's existing onActivityResult / onRequestPermissionsResult into its registry. Demo: SampleTurboModule.requestSamplePermission (CAMERA) wired into rn-tester's SampleTurboModuleExample under an Android-only section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent bbe4afe commit f59bca0

7 files changed

Lines changed: 402 additions & 168 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.activityresult
9+
10+
import androidx.activity.result.ActivityResultLauncher
11+
import androidx.activity.result.contract.ActivityResultContract
12+
import androidx.core.app.ActivityOptionsCompat
13+
import com.facebook.common.logging.FLog
14+
import com.facebook.react.common.ReactConstants
15+
16+
/**
17+
* An [ActivityResultLauncher] handed out before the host Activity's `ActivityResultRegistry` is
18+
* available. It delegates to the real launcher once [bind] is called, and queues a single pending
19+
* [launch] issued while unbound, firing it on bind. [unbind] detaches it when the host Activity is
20+
* destroyed so that [ReactActivityResultCallerImpl] can rebind it against the next host's registry.
21+
*/
22+
internal class DeferredActivityResultLauncher<I>(
23+
private val key: String,
24+
private val contract: ActivityResultContract<I, *>,
25+
private val onUnregister: () -> Unit,
26+
) : ActivityResultLauncher<I>() {
27+
28+
override fun getContract(): ActivityResultContract<I, *> = contract
29+
30+
private class PendingLaunch<I>(val input: I, val options: ActivityOptionsCompat?)
31+
32+
private var delegate: ActivityResultLauncher<I>? = null
33+
private var pendingLaunch: PendingLaunch<I>? = null
34+
35+
@Synchronized
36+
override fun launch(input: I, options: ActivityOptionsCompat?) {
37+
val boundDelegate = delegate
38+
if (boundDelegate != null) {
39+
boundDelegate.launch(input, options)
40+
} else {
41+
if (pendingLaunch != null) {
42+
FLog.w(
43+
ReactConstants.TAG,
44+
"Launcher for '$key' was launched again before an Activity was available; " +
45+
"replacing the previously queued launch.")
46+
}
47+
pendingLaunch = PendingLaunch(input, options)
48+
}
49+
}
50+
51+
@Synchronized
52+
override fun unregister() {
53+
delegate?.unregister()
54+
delegate = null
55+
pendingLaunch = null
56+
onUnregister()
57+
}
58+
59+
/** Attaches the real launcher and fires any launch queued while unbound. */
60+
@Synchronized
61+
fun bind(launcher: ActivityResultLauncher<I>) {
62+
delegate = launcher
63+
pendingLaunch?.let { pending ->
64+
pendingLaunch = null
65+
launcher.launch(pending.input, pending.options)
66+
}
67+
}
68+
69+
/** Detaches from a dying registry, keeping any queued launch for the next [bind]. */
70+
@Synchronized
71+
fun unbind() {
72+
delegate?.unregister()
73+
delegate = null
74+
}
75+
76+
@get:Synchronized
77+
val isBound: Boolean
78+
get() = delegate != null
79+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.activityresult
9+
10+
import androidx.activity.result.ActivityResultCallback
11+
import androidx.activity.result.ActivityResultLauncher
12+
import androidx.activity.result.contract.ActivityResultContract
13+
14+
/**
15+
* Lets a native module register an AndroidX [ActivityResultContract] against the host Activity's
16+
* `ActivityResultRegistry` and receive results, without any changes to the consumer's
17+
* `MainActivity`.
18+
*
19+
* The API deliberately mirrors `androidx.activity.ComponentActivity.registerForActivityResult`:
20+
* same method name, same [ActivityResultCallback] shape, same returned [ActivityResultLauncher]
21+
* type. Unlike an Activity, a caller obtained from a `ReactContext` may register at any time --
22+
* including before any Activity exists -- and the returned launcher binds lazily to the real
23+
* registry once the host resumes.
24+
*
25+
* Registrations are keyed by the contract's fully-qualified class name. Registering the same
26+
* contract class twice throws an [IllegalStateException] at registration time; disambiguate by
27+
* subclassing the contract or using the [registerForActivityResult] overload that takes an `owner`.
28+
*/
29+
public interface ReactActivityResultCaller {
30+
31+
/**
32+
* Registers [contract] and returns a launcher for it. The registration key is the contract's
33+
* fully-qualified class name.
34+
*
35+
* @throws IllegalStateException if a registration with the same key already exists
36+
*/
37+
public fun <I, O> registerForActivityResult(
38+
contract: ActivityResultContract<I, O>,
39+
callback: ActivityResultCallback<O>,
40+
): ActivityResultLauncher<I>
41+
42+
/**
43+
* Same as [registerForActivityResult], but scopes the registration key to [owner]
44+
* (`"<owner class>:<contract class>"`). Use this when two independent callers need the same
45+
* stock contract class, or when registering from something other than a native module.
46+
*/
47+
public fun <I, O> registerForActivityResult(
48+
owner: Any,
49+
contract: ActivityResultContract<I, O>,
50+
callback: ActivityResultCallback<O>,
51+
): ActivityResultLauncher<I>
52+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.activityresult
9+
10+
import androidx.activity.result.ActivityResultCallback
11+
import androidx.activity.result.ActivityResultLauncher
12+
import androidx.activity.result.ActivityResultRegistry
13+
import androidx.activity.result.ActivityResultRegistryOwner
14+
import androidx.activity.result.contract.ActivityResultContract
15+
import com.facebook.common.logging.FLog
16+
import com.facebook.react.bridge.LifecycleEventListener
17+
import com.facebook.react.bridge.ReactContext
18+
import com.facebook.react.common.ReactConstants
19+
20+
/**
21+
* Default [ReactActivityResultCaller], owned by a [ReactContext].
22+
*
23+
* Registrations are accepted at any time -- native modules are created lazily, typically well after
24+
* the host Activity has resumed -- and bound to the current Activity's [ActivityResultRegistry]
25+
* either immediately (when an Activity is already available) or on the next `onHostResume`. When
26+
* the host Activity is destroyed the registrations are kept and rebound against the new Activity's
27+
* registry under the same keys, so AndroidX can re-associate a result that arrives after Activity
28+
* recreation.
29+
*/
30+
public class ReactActivityResultCallerImpl(private val reactContext: ReactContext) :
31+
ReactActivityResultCaller, LifecycleEventListener {
32+
33+
private class Entry<I, O>(
34+
val key: String,
35+
val registrantDescription: String,
36+
val contract: ActivityResultContract<I, O>,
37+
val callback: ActivityResultCallback<O>,
38+
val launcher: DeferredActivityResultLauncher<I>,
39+
)
40+
41+
private val entries = LinkedHashMap<String, Entry<*, *>>()
42+
43+
init {
44+
reactContext.addLifecycleEventListener(this)
45+
}
46+
47+
override fun <I, O> registerForActivityResult(
48+
contract: ActivityResultContract<I, O>,
49+
callback: ActivityResultCallback<O>,
50+
): ActivityResultLauncher<I> =
51+
register(contract.javaClass.name, callback.javaClass.name, contract, callback)
52+
53+
override fun <I, O> registerForActivityResult(
54+
owner: Any,
55+
contract: ActivityResultContract<I, O>,
56+
callback: ActivityResultCallback<O>,
57+
): ActivityResultLauncher<I> =
58+
register(
59+
"${owner.javaClass.name}:${contract.javaClass.name}",
60+
owner.javaClass.name,
61+
contract,
62+
callback)
63+
64+
@Synchronized
65+
private fun <I, O> register(
66+
key: String,
67+
registrantDescription: String,
68+
contract: ActivityResultContract<I, O>,
69+
callback: ActivityResultCallback<O>,
70+
): ActivityResultLauncher<I> {
71+
entries[key]?.let { existing ->
72+
throw IllegalStateException(
73+
"A launcher is already registered for key '$key' (registered by " +
74+
"${existing.registrantDescription}, now requested by $registrantDescription). " +
75+
"Subclass the contract to get a distinct key, or use the " +
76+
"registerForActivityResult(owner, contract, callback) overload.")
77+
}
78+
val launcher = DeferredActivityResultLauncher(key, contract) { unregister(key) }
79+
val entry = Entry(key, registrantDescription, contract, callback, launcher)
80+
entries[key] = entry
81+
currentRegistry()?.let { registry -> bind(entry, registry) }
82+
return launcher
83+
}
84+
85+
@Synchronized
86+
private fun unregister(key: String) {
87+
entries.remove(key)
88+
}
89+
90+
@Synchronized
91+
override fun onHostResume() {
92+
val registry = currentRegistry() ?: return
93+
for (entry in entries.values) {
94+
if (!entry.launcher.isBound) {
95+
bind(entry, registry)
96+
}
97+
}
98+
}
99+
100+
override fun onHostPause(): Unit = Unit
101+
102+
@Synchronized
103+
override fun onHostDestroy() {
104+
// Detach from the dying registry but keep the registrations: they are rebound against the next
105+
// host's registry (same keys) on the next onHostResume, which is also how a result that
106+
// outlives the Activity gets re-associated by AndroidX.
107+
for (entry in entries.values) {
108+
entry.launcher.unbind()
109+
}
110+
}
111+
112+
private fun currentRegistry(): ActivityResultRegistry? {
113+
val activity = reactContext.currentActivity ?: return null
114+
val owner = activity as? ActivityResultRegistryOwner
115+
if (owner == null) {
116+
FLog.w(
117+
ReactConstants.TAG,
118+
"Current Activity ${activity.javaClass.name} is not an ActivityResultRegistryOwner; " +
119+
"ActivityResultContract launchers will stay queued until one is available.")
120+
return null
121+
}
122+
return owner.activityResultRegistry
123+
}
124+
125+
private fun <I, O> bind(entry: Entry<I, O>, registry: ActivityResultRegistry) {
126+
entry.launcher.bind(registry.register(entry.key, entry.contract, entry.callback))
127+
}
128+
}

0 commit comments

Comments
 (0)