Skip to content

Commit a10bf80

Browse files
authored
fix(voip): Android DDP thread safety and VoipPayload bundle parity (#7168)
1 parent 7862be1 commit a10bf80

6 files changed

Lines changed: 340 additions & 30 deletions

File tree

android/app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ dependencies {
155155
implementation 'androidx.security:security-crypto:1.1.0'
156156

157157
testImplementation 'junit:junit:4.13.2'
158+
testImplementation 'org.robolectric:robolectric:4.14.1'
158159

159160
// For ProcessLifecycleOwner (app foreground detection)
160161
implementation 'androidx.lifecycle:lifecycle-process:2.8.7'

android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import okhttp3.WebSocketListener
1111
import org.json.JSONArray
1212
import org.json.JSONObject
1313
import java.util.concurrent.TimeUnit
14+
import java.util.concurrent.atomic.AtomicBoolean
15+
import java.util.concurrent.atomic.AtomicInteger
1416

1517
class DDPClient {
1618
private data class QueuedMethodCall(
@@ -28,17 +30,29 @@ class DDPClient {
2830

2931
private var webSocket: WebSocket? = null
3032
private val client: OkHttpClient = sharedClient
31-
private var sendCounter = 0
33+
private val sendCounter = AtomicInteger(0)
34+
35+
@Volatile
3236
private var isConnected = false
37+
3338
private val mainHandler = Handler(Looper.getMainLooper())
3439

3540
private val pendingCallbacks = mutableMapOf<String, (JSONObject) -> Unit>()
3641
private val queuedMethodCalls = mutableListOf<QueuedMethodCall>()
42+
43+
@Volatile
3744
private var connectedCallback: ((Boolean) -> Unit)? = null
3845

46+
@Volatile
47+
private var connectTimeoutRunnable: Runnable? = null
48+
49+
private val connectResultDelivered = AtomicBoolean(false)
50+
3951
var onCollectionMessage: ((JSONObject) -> Unit)? = null
4052

4153
fun connect(host: String, callback: (Boolean) -> Unit) {
54+
resetConnectHandshakeState()
55+
4256
val wsUrl = buildWebSocketURL(host)
4357

4458
Log.d(TAG, "Connecting to $wsUrl")
@@ -60,16 +74,26 @@ class DDPClient {
6074
}
6175

6276
override fun onMessage(webSocket: WebSocket, text: String) {
77+
if (webSocket !== this@DDPClient.webSocket) return
6378
handleMessage(text)
6479
}
6580

6681
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
82+
if (webSocket !== this@DDPClient.webSocket) return
6783
Log.e(TAG, "WebSocket failure: ${t.message}")
6884
isConnected = false
69-
mainHandler.post { callback(false) }
85+
mainHandler.post {
86+
// Re-check identity on main thread: disconnect()+connect() can interleave
87+
// between the outer guard (OkHttp thread) and this runnable's execution.
88+
// Without this re-check, a stale failure can hijack a newly installed
89+
// connectedCallback via the CAS in tryDeliverConnectOutcome.
90+
if (webSocket !== this@DDPClient.webSocket) return@post
91+
tryDeliverConnectOutcome(false)
92+
}
7093
}
7194

7295
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
96+
if (webSocket !== this@DDPClient.webSocket) return
7397
Log.d(TAG, "WebSocket closed: $code $reason")
7498
isConnected = false
7599
}
@@ -134,6 +158,7 @@ class DDPClient {
134158
fun disconnect() {
135159
Log.d(TAG, "Disconnecting")
136160
isConnected = false
161+
cancelConnectTimeout()
137162
synchronized(pendingCallbacks) { pendingCallbacks.clear() }
138163
clearQueuedMethodCalls()
139164
connectedCallback = null
@@ -143,10 +168,10 @@ class DDPClient {
143168
}
144169

145170
private fun nextMessage(msg: String): JSONObject {
146-
sendCounter++
171+
val nextId = sendCounter.incrementAndGet()
147172
return JSONObject().apply {
148173
put("msg", msg)
149-
put("id", "ddp-$sendCounter")
174+
put("id", "ddp-$nextId")
150175
}
151176
}
152177

@@ -211,14 +236,44 @@ class DDPClient {
211236
}
212237
}
213238

239+
private fun resetConnectHandshakeState() {
240+
connectResultDelivered.set(false)
241+
}
242+
243+
/**
244+
* Delivers at most one outcome for the WebSocket connect handshake ([connect] callback).
245+
* Uses [AtomicBoolean] so [onFailure], the connect-timeout runnable, and `"connected"` cannot
246+
* all report conflicting results.
247+
*/
248+
private fun tryDeliverConnectOutcome(success: Boolean, connectTimeout: Boolean = false) {
249+
if (!connectResultDelivered.compareAndSet(false, true)) {
250+
return
251+
}
252+
cancelConnectTimeout()
253+
val cb = connectedCallback
254+
connectedCallback = null
255+
if (connectTimeout) {
256+
Log.e(TAG, "Connect timeout")
257+
}
258+
mainHandler.post {
259+
cb?.invoke(success)
260+
}
261+
}
262+
214263
private fun waitForConnected(timeoutMs: Long, callback: (Boolean) -> Unit) {
215264
connectedCallback = callback
216-
mainHandler.postDelayed({
217-
val cb = connectedCallback ?: return@postDelayed
218-
connectedCallback = null
219-
Log.e(TAG, "Connect timeout")
220-
cb(false)
221-
}, timeoutMs)
265+
cancelConnectTimeout()
266+
val runnable = Runnable {
267+
connectTimeoutRunnable = null
268+
tryDeliverConnectOutcome(false, connectTimeout = true)
269+
}
270+
connectTimeoutRunnable = runnable
271+
mainHandler.postDelayed(runnable, timeoutMs)
272+
}
273+
274+
private fun cancelConnectTimeout() {
275+
connectTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
276+
connectTimeoutRunnable = null
222277
}
223278

224279
private fun handleMessage(text: String) {
@@ -231,10 +286,7 @@ class DDPClient {
231286
when (json.optString("msg")) {
232287
"connected" -> {
233288
isConnected = true
234-
mainHandler.removeCallbacksAndMessages(null)
235-
val cb = connectedCallback
236-
connectedCallback = null
237-
cb?.let { mainHandler.post { it(true) } }
289+
tryDeliverConnectOutcome(true)
238290
}
239291

240292
"ping" -> {
@@ -301,4 +353,24 @@ class DDPClient {
301353
val scheme = if (useSsl) "wss" else "ws"
302354
return "$scheme://$normalizedHost/websocket"
303355
}
356+
357+
internal fun testStartConnectTimeout(timeoutMs: Long, callback: (Boolean) -> Unit) {
358+
resetConnectHandshakeState()
359+
waitForConnected(timeoutMs, callback)
360+
}
361+
362+
internal fun testDeliverRawMessage(text: String) {
363+
handleMessage(text)
364+
}
365+
366+
internal fun testDeliverConnectFailure(fromWebSocket: WebSocket? = null) {
367+
mainHandler.post {
368+
if (fromWebSocket != null && fromWebSocket !== this@DDPClient.webSocket) return@post
369+
tryDeliverConnectOutcome(false)
370+
}
371+
}
372+
373+
internal fun testSetActiveWebSocket(ws: WebSocket?) {
374+
this.webSocket = ws
375+
}
304376
}

android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ package chat.rocket.reactnative.voip
22

33
import android.util.Log
44
import com.facebook.react.bridge.ReactApplicationContext
5-
import com.facebook.react.bridge.Arguments
65
import com.facebook.react.bridge.WritableMap
76
import com.facebook.react.modules.core.DeviceEventManagerModule
8-
import java.lang.ref.WeakReference
97
import chat.rocket.reactnative.networking.NativeVoipSpec
8+
import java.lang.ref.WeakReference
9+
import java.util.concurrent.atomic.AtomicReference
1010

1111
/**
1212
* Native module to expose VoIP call data to JavaScript.
@@ -20,7 +20,8 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo
2020
private const val EVENT_VOIP_ACCEPT_FAILED = "VoipAcceptFailed"
2121

2222
private var reactContextRef: WeakReference<ReactApplicationContext>? = null
23-
private var initialEventsData: VoipPayload? = null
23+
24+
private val initialEventsData = AtomicReference<VoipPayload?>(null)
2425

2526
/**
2627
* Sets the React context reference for event emission.
@@ -54,7 +55,7 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo
5455
*/
5556
@JvmStatic
5657
fun storeInitialEvents(voipPayload: VoipPayload) {
57-
initialEventsData = voipPayload
58+
initialEventsData.set(voipPayload)
5859
emitInitialEventsEvent(voipPayload)
5960
}
6061

@@ -64,7 +65,7 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo
6465
@JvmStatic
6566
fun storeAcceptFailureForJs(payload: VoipPayload) {
6667
val failed = payload.copy(voipAcceptFailed = true)
67-
initialEventsData = failed
68+
initialEventsData.set(failed)
6869
emitVoipAcceptFailedEvent(failed)
6970
}
7071

@@ -85,7 +86,7 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo
8586
@JvmStatic
8687
fun clearInitialEventsInternal() {
8788
try {
88-
initialEventsData = null
89+
initialEventsData.set(null)
8990
Log.d(TAG, "Cleared initial events")
9091
} catch (e: Exception) {
9192
Log.e(TAG, "Error clearing initial events", e)
@@ -103,18 +104,22 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo
103104
* Returns null if no initial events.
104105
*/
105106
override fun getInitialEvents(): WritableMap? {
106-
val data = initialEventsData ?: return null
107+
while (true) {
108+
val data = initialEventsData.get() ?: return null
107109

108-
if (data.isExpired()) {
109-
Log.d(TAG, "Discarding expired VoIP initial event: ${data.callId}")
110-
clearInitialEventsInternal()
111-
return null
112-
}
110+
if (data.isExpired()) {
111+
Log.d(TAG, "Discarding expired VoIP initial event: ${data.callId}")
112+
if (initialEventsData.compareAndSet(data, null)) {
113+
return null
114+
}
115+
continue
116+
}
113117

114-
val result = data.toWritableMap()
115-
clearInitialEventsInternal()
116-
117-
return result
118+
val result = data.toWritableMap()
119+
if (initialEventsData.compareAndSet(data, null)) {
120+
return result
121+
}
122+
}
118123
}
119124

120125
/**

android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ data class VoipPayload(
7272
putString("avatarUrl", avatarUrl)
7373
putString("createdAt", createdAt)
7474
putInt("notificationId", notificationId)
75+
putBoolean("voipAcceptFailed", voipAcceptFailed)
7576
// Useful flag for MainActivity to know it's handling a VoIP action
7677
putBoolean("voipAction", true)
7778
}

0 commit comments

Comments
 (0)