@@ -6,6 +6,12 @@ import PushKit
66 * VoipModuleSwift - Swift implementation for VoIP push notifications and initial events data.
77 * This class provides static methods called by VoipModule.mm (the TurboModule bridge).
88 *
9+ * Threading:
10+ * - `lastVoipToken` and `initialEventsData` are synchronized on `bridgeStateQueue` because they are
11+ * written on the main thread (PushKit, native call flows) and read from the React Native bridge thread.
12+ * - **All other static state is main-thread-only** (PushKit registry on `.main`, `CXCallObserver` on
13+ * `.main`, and `trackIncomingCall` / `clearTrackedIncomingCall` already bounce work to main where needed).
14+ *
915 * This module:
1016 * - Manages PushKit VoIP registration
1117 * - Tracks VoIP push tokens
@@ -28,7 +34,9 @@ public final class VoipService: NSObject {
2834 private static let TAG = " RocketChat.VoipService "
2935 private static let voipTokenStorageKey = " RCVoipPushToken "
3036 private static let storage = MMKVBridge . build ( )
31-
37+ /// Serializes access to `lastVoipToken` and `initialEventsData` (main-thread writers vs RN bridge readers).
38+ private static let bridgeStateQueue = DispatchQueue ( label: " chat.rocket.ios.voipService.bridgeState " )
39+
3240 // MARK: - Static Properties
3341
3442 private static var initialEventsData : VoipPayload ?
@@ -67,7 +75,8 @@ public final class VoipService: NSObject {
6775 public static func voipRegistration( ) {
6876 if isVoipRegistered {
6977 #if DEBUG
70- print ( " [ \( TAG) ] voipRegistration already registered. Returning lastVoipToken: \( lastVoipToken) " )
78+ let tokenSnapshot = bridgeStateQueue. sync { lastVoipToken }
79+ print ( " [ \( TAG) ] voipRegistration already registered. Returning lastVoipToken: \( tokenSnapshot) " )
7180 #endif
7281 return
7382 }
@@ -100,14 +109,21 @@ public final class VoipService: NSObject {
100109 // Convert token data to hex string
101110 let token = credentials. token. map { String ( format: " %02x " , $0) } . joined ( )
102111
103- if lastVoipToken == token {
112+ let tokenUnchanged = bridgeStateQueue. sync { ( ) -> Bool in
113+ if lastVoipToken == token {
114+ return true
115+ }
116+ lastVoipToken = token
117+ return false
118+ }
119+
120+ if tokenUnchanged {
104121 #if DEBUG
105122 print ( " [ \( TAG) ] VoIP token unchanged " )
106123 #endif
107124 return
108125 }
109126
110- lastVoipToken = token
111127 persistVoipToken ( token)
112128
113129 #if DEBUG
@@ -126,7 +142,9 @@ public final class VoipService: NSObject {
126142 // TODO: remove voip token from all logged in workspaces, since they share the same token
127143 @objc
128144 public static func invalidatePushToken( ) {
129- lastVoipToken = " "
145+ bridgeStateQueue. sync {
146+ lastVoipToken = " "
147+ }
130148 storage. removeValue ( forKey: voipTokenStorageKey)
131149
132150 #if DEBUG
@@ -161,39 +179,45 @@ public final class VoipService: NSObject {
161179 /// Stores initial events for JS to retrieve.
162180 @objc
163181 public static func storeInitialEvents( _ payload: VoipPayload ) {
164- initialEventsData = payload
165-
166- #if DEBUG
167- print ( " [ \( TAG) ] Stored initial events: \( payload. callId) " )
168- #endif
182+ bridgeStateQueue. sync {
183+ initialEventsData = payload
184+
185+ #if DEBUG
186+ print ( " [ \( TAG) ] Stored initial events: \( payload. callId) " )
187+ #endif
188+ }
169189 }
170190
171191 /// Gets any initial events. Returns nil if no initial events.
172192 @objc
173193 public static func getInitialEvents( ) -> [ String : Any ] ? {
174- guard let data = initialEventsData else {
175- return nil
176- }
177-
178- if data. isExpired ( ) {
179- clearInitialEventsInternal ( )
180- return nil
194+ bridgeStateQueue. sync {
195+ guard let data = initialEventsData else {
196+ return nil
197+ }
198+
199+ if data. isExpired ( ) {
200+ clearInitialEventsUnlocked ( )
201+ return nil
202+ }
203+
204+ let result = data. toDictionary ( )
205+ clearInitialEventsUnlocked ( )
206+
207+ return result
181208 }
182-
183- let result = data. toDictionary ( )
184- clearInitialEventsInternal ( )
185-
186- return result
187209 }
188-
210+
189211 /// Clears any initial events
190212 @objc
191213 public static func clearInitialEvents( ) {
192- clearInitialEventsInternal ( )
214+ bridgeStateQueue. sync {
215+ clearInitialEventsUnlocked ( )
216+ }
193217 }
194-
195- /// Clears initial events (internal)
196- private static func clearInitialEventsInternal ( ) {
218+
219+ /// Clears initial events. Caller must already be running on `bridgeStateQueue`.
220+ private static func clearInitialEventsUnlocked ( ) {
197221 initialEventsData = nil
198222 #if DEBUG
199223 print ( " [ \( TAG) ] Cleared initial events " )
@@ -205,10 +229,12 @@ public final class VoipService: NSObject {
205229 /// Returns the last registered VoIP token
206230 @objc
207231 public static func getLastVoipToken( ) -> String {
208- if lastVoipToken. isEmpty {
209- lastVoipToken = loadPersistedVoipToken ( )
232+ bridgeStateQueue. sync {
233+ if lastVoipToken. isEmpty {
234+ lastVoipToken = loadPersistedVoipToken ( )
235+ }
236+ return lastVoipToken
210237 }
211- return lastVoipToken
212238 }
213239
214240 private static func loadPersistedVoipToken( ) -> String {
@@ -502,8 +528,10 @@ public final class VoipService: NSObject {
502528 cancelIncomingCallTimeout ( for: payload. callId)
503529 clearTrackedIncomingCall ( for: payload. callUUID)
504530
505- if initialEventsData? . callId == payload. callId {
506- clearInitialEventsInternal ( )
531+ bridgeStateQueue. sync {
532+ if initialEventsData? . callId == payload. callId {
533+ clearInitialEventsUnlocked ( )
534+ }
507535 }
508536
509537 // End the just-reported CallKit call immediately (reason 2 = unanswered / declined).
0 commit comments