Skip to content

Commit f3d3fef

Browse files
committed
fix: thread-safety and and rebinding to current activity
1 parent b4f17fa commit f3d3fef

4 files changed

Lines changed: 399 additions & 82 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/DeferredActivityResultLauncher.kt

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,23 @@
88
package com.facebook.react.activityresult
99

1010
import androidx.activity.result.ActivityResultLauncher
11+
import androidx.activity.result.ActivityResultRegistry
1112
import androidx.activity.result.contract.ActivityResultContract
1213
import androidx.core.app.ActivityOptionsCompat
1314
import com.facebook.common.logging.FLog
15+
import com.facebook.react.bridge.UiThreadUtil
1416
import com.facebook.react.common.ReactConstants
1517

1618
/**
1719
* An [ActivityResultLauncher] handed out before the host Activity's `ActivityResultRegistry` is
1820
* available. It delegates to the real launcher once [bind] is called, and queues a single pending
1921
* [launch] issued while unbound, firing it on bind. [unbind] detaches it when the host Activity is
2022
* destroyed so that [ReactActivityResultCallerImpl] can rebind it against the next host's registry.
23+
*
24+
* [launch] and [unregister] are called off the UI thread but reach `@MainThread` registry methods,
25+
* so both hop. [delegate] and [pendingLaunch] are therefore UI-thread only and need no lock. Note
26+
* [launch] decides bound-vs-queue *inside* the hop: doing it before would let a concurrent [unbind]
27+
* strand the launch on a dead registry.
2128
*/
2229
internal class DeferredActivityResultLauncher<I>(
2330
private val key: String,
@@ -30,50 +37,58 @@ internal class DeferredActivityResultLauncher<I>(
3037
private class PendingLaunch<I>(val input: I, val options: ActivityOptionsCompat?)
3138

3239
private var delegate: ActivityResultLauncher<I>? = null
40+
private var boundRegistry: ActivityResultRegistry? = null
3341
private var pendingLaunch: PendingLaunch<I>? = null
3442

35-
@Synchronized
3643
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.")
44+
onUiThread {
45+
val boundDelegate = delegate
46+
if (boundDelegate != null) {
47+
boundDelegate.launch(input, options)
48+
} else {
49+
if (pendingLaunch != null) {
50+
FLog.w(
51+
ReactConstants.TAG,
52+
"Launcher for '$key' was launched again before an Activity was available; " +
53+
"replacing the previously queued launch.")
54+
}
55+
pendingLaunch = PendingLaunch(input, options)
4656
}
47-
pendingLaunch = PendingLaunch(input, options)
4857
}
4958
}
5059

51-
@Synchronized
5260
override fun unregister() {
53-
delegate?.unregister()
54-
delegate = null
55-
pendingLaunch = null
61+
// Drop the registration first, so nothing rebinds this launcher while the hop is in flight.
5662
onUnregister()
63+
onUiThread {
64+
delegate?.unregister()
65+
delegate = null
66+
pendingLaunch = null
67+
}
5768
}
5869

59-
/** Attaches the real launcher and fires any launch queued while unbound. */
60-
@Synchronized
61-
fun bind(launcher: ActivityResultLauncher<I>) {
70+
/**
71+
* Attaches [launcher], obtained from [registry], and fires any launch queued while unbound.
72+
* [registry] is remembered so [isBoundTo] can tell whether a later host is a different one.
73+
*/
74+
fun bind(registry: ActivityResultRegistry, launcher: ActivityResultLauncher<I>) {
75+
UiThreadUtil.assertOnUiThread()
6276
delegate = launcher
77+
boundRegistry = registry
6378
pendingLaunch?.let { pending ->
6479
pendingLaunch = null
6580
launcher.launch(pending.input, pending.options)
6681
}
6782
}
6883

69-
/** Detaches from a dying registry, keeping any queued launch for the next [bind]. */
70-
@Synchronized
84+
/** Detaches from the bound registry, keeping any queued launch for the next [bind]. */
7185
fun unbind() {
86+
UiThreadUtil.assertOnUiThread()
7287
delegate?.unregister()
7388
delegate = null
89+
boundRegistry = null
7490
}
7591

76-
@get:Synchronized
77-
val isBound: Boolean
78-
get() = delegate != null
92+
/** Whether this launcher is already bound to [registry] specifically -- not merely to something. */
93+
fun isBoundTo(registry: ActivityResultRegistry): Boolean = boundRegistry === registry
7994
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,30 +15,72 @@ import androidx.activity.result.contract.ActivityResultContract
1515
import com.facebook.common.logging.FLog
1616
import com.facebook.react.bridge.LifecycleEventListener
1717
import com.facebook.react.bridge.ReactContext
18+
import com.facebook.react.bridge.UiThreadUtil
1819
import com.facebook.react.common.ReactConstants
20+
import java.util.concurrent.ConcurrentHashMap
21+
22+
/**
23+
* Runs [block] on the UI thread, inline if already there.
24+
*
25+
* [ActivityResultRegistry] is `@MainThread` and its key tables are unsynchronized. Nothing enforces
26+
* that at runtime, so an off-thread call corrupts them silently rather than throwing -- and RN
27+
* registers on the JS thread and launches on the native-modules thread.
28+
*/
29+
internal fun onUiThread(block: () -> Unit) {
30+
if (UiThreadUtil.isOnUiThread()) block() else UiThreadUtil.runOnUiThread(block)
31+
}
1932

2033
/**
2134
* Default [ReactActivityResultCaller], owned by a [ReactContext].
2235
*
2336
* Registrations are accepted at any time -- native modules are created lazily, typically well after
2437
* 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.
38+
* either immediately (when an Activity is already available) or on the next `onHostResume`.
39+
* Registrations outlive any single Activity: keys stay stable, so AndroidX can re-associate a result
40+
* that arrives after Activity recreation.
41+
*
42+
* ## Which registry a launcher is bound to
43+
*
44+
* Every `onHostResume` reconciles each launcher against the *current* registry, rebinding it if it
45+
* is attached to a different one. It deliberately does not stop at "already bound to something":
46+
* with multi-Activity navigation the new Activity resumes before the old one is destroyed, and
47+
* `ReactHostImpl.onHostDestroy(activity)` drops the old Activity's destroy entirely once
48+
* `currentActivity` has moved on. A launcher that only checked "am I bound?" would stay attached to
49+
* the previous Activity's dead registry -- leaking it, and misrouting anything launched from the new
50+
* screen.
51+
*
52+
* ## Threading
53+
*
54+
* [entries] is concurrent and reachable from any thread. Everything that touches
55+
* [ActivityResultRegistry] goes through [onUiThread].
56+
*
57+
* Registration itself stays on the caller's thread, so the launcher is returned immediately and a
58+
* duplicate key throws from the frame that caused it. Only the registry call is hopped.
2959
*/
3060
internal class ReactActivityResultCallerImpl(private val reactContext: ReactContext) :
3161
ReactActivityResultCaller, LifecycleEventListener {
3262

3363
private class Entry<I, O>(
3464
val key: String,
3565
val registrantDescription: String,
36-
val contract: ActivityResultContract<I, O>,
37-
val callback: ActivityResultCallback<O>,
66+
private val contract: ActivityResultContract<I, O>,
67+
private val callback: ActivityResultCallback<O>,
3868
val launcher: DeferredActivityResultLauncher<I>,
39-
)
69+
) {
70+
/**
71+
* Ensures the launcher is bound to [registry], rebinding if it is currently attached to a
72+
* different one. On [Entry] so an `Entry<*, *>` can be bound without unchecked casts.
73+
*/
74+
fun bindTo(registry: ActivityResultRegistry) {
75+
if (launcher.isBoundTo(registry)) return
76+
// Release the previous host's registry first: it may already be dead, and leaving the
77+
// callback registered there leaks that Activity and misroutes anything launched from it.
78+
launcher.unbind()
79+
launcher.bind(registry, registry.register(key, contract, callback))
80+
}
81+
}
4082

41-
private val entries = LinkedHashMap<String, Entry<*, *>>()
83+
private val entries = ConcurrentHashMap<String, Entry<*, *>>()
4284

4385
init {
4486
reactContext.addLifecycleEventListener(this)
@@ -77,52 +119,36 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont
77119
callback = callback)
78120
}
79121

80-
81-
@Synchronized
82122
private fun <I, O> register(
83123
key: String,
84124
registrantDescription: String,
85125
collisionHint: String,
86126
contract: ActivityResultContract<I, O>,
87127
callback: ActivityResultCallback<O>,
88128
): ActivityResultLauncher<I> {
89-
entries[key]?.let { existing ->
129+
val launcher = DeferredActivityResultLauncher(key, contract) { entries.remove(key) }
130+
val entry = Entry(key, registrantDescription, contract, callback, launcher)
131+
entries.putIfAbsent(key, entry)?.let { existing ->
90132
throw IllegalStateException(
91133
"${existing.registrantDescription} already registered a launcher for key '$key'. " +
92134
collisionHint)
93135
}
94-
val launcher = DeferredActivityResultLauncher(key, contract) { unregister(key) }
95-
val entry = Entry(key, registrantDescription, contract, callback, launcher)
96-
entries[key] = entry
97-
currentRegistry()?.let { registry -> bind(entry, registry) }
136+
onUiThread { currentRegistry()?.let { registry -> entry.bindTo(registry) } }
98137
return launcher
99138
}
100139

101-
@Synchronized
102-
private fun unregister(key: String) {
103-
entries.remove(key)
104-
}
105-
106-
@Synchronized
107-
override fun onHostResume() {
108-
val registry = currentRegistry() ?: return
109-
for (entry in entries.values) {
110-
if (!entry.launcher.isBound) {
111-
bind(entry, registry)
112-
}
113-
}
140+
override fun onHostResume() = onUiThread {
141+
val registry = currentRegistry() ?: return@onUiThread
142+
entries.values.forEach { it.bindTo(registry) }
114143
}
115144

116145
override fun onHostPause(): Unit = Unit
117146

118-
@Synchronized
119-
override fun onHostDestroy() {
120-
// Detach from the dying registry but keep the registrations: they are rebound against the next
121-
// host's registry (same keys) on the next onHostResume, which is also how a result that
122-
// outlives the Activity gets re-associated by AndroidX.
123-
for (entry in entries.values) {
124-
entry.launcher.unbind()
125-
}
147+
override fun onHostDestroy() = onUiThread {
148+
// Detach from the dying registry but keep the registrations: they rebind against the next host's
149+
// registry under the same keys on the next onHostResume, which is how AndroidX re-associates a
150+
// result that outlives the Activity.
151+
entries.values.forEach { it.launcher.unbind() }
126152
}
127153

128154
private fun currentRegistry(): ActivityResultRegistry? {
@@ -137,8 +163,4 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont
137163
}
138164
return owner.activityResultRegistry
139165
}
140-
141-
private fun <I, O> bind(entry: Entry<I, O>, registry: ActivityResultRegistry) {
142-
entry.launcher.bind(registry.register(entry.key, entry.contract, entry.callback))
143-
}
144166
}

0 commit comments

Comments
 (0)