@iternio/react-native-auto-play 0.5.12 → 0.5.14-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,6 +41,7 @@ class VirtualRenderer(
41
41
  private val isCluster: Boolean = false
42
42
  ) {
43
43
  private var virtualDisplay: VirtualDisplay? = null
44
+ private val pendingDisplays = mutableListOf<VirtualDisplay>()
44
45
 
45
46
  private var reactSurfaceImpl: ReactSurfaceImpl? = null
46
47
  private var reactSurfaceView: ReactSurfaceView? = null
@@ -92,8 +93,8 @@ class VirtualRenderer(
92
93
  stableArea = Rect(0, 0, 0, 0)
93
94
  visibleArea = Rect(0, 0, 0, 0)
94
95
 
95
- virtualDisplay?.release()
96
- virtualDisplay = null
96
+ // Not releasing virtualDisplay here, and not relying on this firing at all (unreliable on
97
+ // some Automotive devices) -- see applySurface/onDraw sweep for where the old display actually gets freed.
97
98
  }
98
99
 
99
100
  override fun onScroll(distanceX: Float, distanceY: Float) {
@@ -236,10 +237,15 @@ class VirtualRenderer(
236
237
  // surface itself is kept alive so JS state survives the resize; the JS side is informed
237
238
  // via the windowInformation listener since the window initial prop stays stale.
238
239
  val isResize = reactSurfaceView != null && (width != surfaceWidth
239
- || height != surfaceHeight || dpi != surfaceContainer.dpi)
240
+ || height != surfaceHeight || dpi != surfaceContainer.dpi)
240
241
 
241
242
  val manager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
242
- virtualDisplay?.release()
243
+
244
+ virtualDisplay?.let {
245
+ // released once its replacement (created below) has drawn
246
+ pendingDisplays.add(it)
247
+ }
248
+
243
249
  virtualDisplay = manager.createVirtualDisplay(
244
250
  moduleName,
245
251
  surfaceWidth,
@@ -415,6 +421,23 @@ class VirtualRenderer(
415
421
  rootContainer.addView(it)
416
422
  }
417
423
 
424
+ rootContainer.viewTreeObserver.addOnDrawListener(object :
425
+ ViewTreeObserver.OnDrawListener {
426
+ override fun onDraw() {
427
+ rootContainer.post {
428
+ rootContainer.viewTreeObserver.removeOnDrawListener(this)
429
+ // Releasing here, only once a new display has drawn, avoids the window-absent gap that
430
+ // stops Choreographer. Releasing old displays any earlier reintroduces that suspension. It
431
+ // freezes the map (Mapbox ValueAnimator) and RN (setInterval/setTimeout) until the phone
432
+ // screen is woken.
433
+ pendingDisplays.forEach {
434
+ it.release()
435
+ }
436
+ pendingDisplays.clear()
437
+ }
438
+ }
439
+ })
440
+
418
441
  setContentView(rootContainer)
419
442
  }
420
443
  }
@@ -491,6 +514,13 @@ class VirtualRenderer(
491
514
  virtualDisplay?.release()
492
515
  virtualDisplay = null
493
516
 
517
+ pendingDisplays.forEach {
518
+ // make sure to release any pending displays in case
519
+ // the new one did not draw yet but stop is called
520
+ it.release()
521
+ }
522
+ pendingDisplays.clear()
523
+
494
524
  try {
495
525
  stopReactSurface()
496
526
  } finally {
@@ -18,14 +18,26 @@ struct SafeAreaListener {
18
18
  }
19
19
 
20
20
  class HybridAutoPlay: HybridAutoPlaySpec {
21
+ /// Guards the three listener dictionaries below. They are mutated from the
22
+ /// JS thread (add/remove) and read from the main thread (UIKit layout
23
+ /// driving `emitSafeAreaInsets`), so every access must be serialised.
24
+ private static let listenersLock = NSLock()
21
25
  private static var listeners = [EventName: [StateListener]]()
22
26
  private static var renderStateListeners = [String: [RenderStateListener]]()
23
27
  private static var safeAreaInsetsListeners = [String: [SafeAreaListener]]()
24
28
 
29
+ private static func withListenersLock<T>(_ body: () -> T) -> T {
30
+ listenersLock.lock()
31
+ defer { listenersLock.unlock() }
32
+ return body()
33
+ }
34
+
25
35
  override init() {
26
- HybridAutoPlay.listeners.removeAll()
27
- HybridAutoPlay.renderStateListeners.removeAll()
28
- HybridAutoPlay.safeAreaInsetsListeners.removeAll()
36
+ HybridAutoPlay.withListenersLock {
37
+ HybridAutoPlay.listeners.removeAll()
38
+ HybridAutoPlay.renderStateListeners.removeAll()
39
+ HybridAutoPlay.safeAreaInsetsListeners.removeAll()
40
+ }
29
41
  super.init()
30
42
  }
31
43
 
@@ -34,18 +46,22 @@ class HybridAutoPlay: HybridAutoPlaySpec {
34
46
  {
35
47
  let listener = StateListener(id: UUID(), callback: callback)
36
48
 
37
- HybridAutoPlay.listeners[eventType, default: []].append(listener)
49
+ HybridAutoPlay.withListenersLock {
50
+ HybridAutoPlay.listeners[eventType, default: []].append(listener)
51
+ }
38
52
 
39
53
  if eventType == .didconnect && SceneStore.isRootModuleConnected() {
40
54
  callback()
41
55
  }
42
56
 
43
57
  return {
44
- HybridAutoPlay.listeners[eventType]?.removeAll {
45
- $0.id == listener.id
46
- }
47
- if HybridAutoPlay.listeners[eventType]?.isEmpty ?? false {
48
- HybridAutoPlay.listeners.removeValue(forKey: eventType)
58
+ HybridAutoPlay.withListenersLock {
59
+ HybridAutoPlay.listeners[eventType]?.removeAll {
60
+ $0.id == listener.id
61
+ }
62
+ if HybridAutoPlay.listeners[eventType]?.isEmpty ?? false {
63
+ HybridAutoPlay.listeners.removeValue(forKey: eventType)
64
+ }
49
65
  }
50
66
  }
51
67
  }
@@ -56,24 +72,28 @@ class HybridAutoPlay: HybridAutoPlaySpec {
56
72
  ) throws -> () -> Void {
57
73
  let listener = RenderStateListener(id: UUID(), callback: callback)
58
74
 
59
- HybridAutoPlay.renderStateListeners[moduleName, default: []].append(
60
- listener
61
- )
75
+ HybridAutoPlay.withListenersLock {
76
+ HybridAutoPlay.renderStateListeners[moduleName, default: []].append(
77
+ listener
78
+ )
79
+ }
62
80
 
63
81
  if let state = SceneStore.getState(moduleName: moduleName) {
64
82
  callback(state)
65
83
  }
66
84
 
67
85
  return {
68
- HybridAutoPlay.renderStateListeners[moduleName]?.removeAll {
69
- $0.id == listener.id
70
- }
71
- if HybridAutoPlay.renderStateListeners[moduleName]?.isEmpty
72
- ?? false
73
- {
74
- HybridAutoPlay.renderStateListeners.removeValue(
75
- forKey: moduleName
76
- )
86
+ HybridAutoPlay.withListenersLock {
87
+ HybridAutoPlay.renderStateListeners[moduleName]?.removeAll {
88
+ $0.id == listener.id
89
+ }
90
+ if HybridAutoPlay.renderStateListeners[moduleName]?.isEmpty
91
+ ?? false
92
+ {
93
+ HybridAutoPlay.renderStateListeners.removeValue(
94
+ forKey: moduleName
95
+ )
96
+ }
77
97
  }
78
98
  }
79
99
  }
@@ -92,9 +112,10 @@ class HybridAutoPlay: HybridAutoPlaySpec {
92
112
  ) throws -> () -> Void {
93
113
  let listener = SafeAreaListener(id: UUID(), callback: callback)
94
114
 
95
- HybridAutoPlay.safeAreaInsetsListeners[moduleName, default: []].append(
96
- listener
97
- )
115
+ HybridAutoPlay.withListenersLock {
116
+ HybridAutoPlay.safeAreaInsetsListeners[moduleName, default: []]
117
+ .append(listener)
118
+ }
98
119
 
99
120
  if let safeAreaInsets = SceneStore.getScene(moduleName: moduleName)?
100
121
  .safeAreaInsets
@@ -106,15 +127,17 @@ class HybridAutoPlay: HybridAutoPlaySpec {
106
127
  }
107
128
 
108
129
  return {
109
- HybridAutoPlay.safeAreaInsetsListeners[moduleName]?.removeAll {
110
- $0.id == listener.id
111
- }
112
- if HybridAutoPlay.safeAreaInsetsListeners[moduleName]?.isEmpty
113
- ?? false
114
- {
115
- HybridAutoPlay.safeAreaInsetsListeners.removeValue(
116
- forKey: moduleName
117
- )
130
+ HybridAutoPlay.withListenersLock {
131
+ HybridAutoPlay.safeAreaInsetsListeners[moduleName]?.removeAll {
132
+ $0.id == listener.id
133
+ }
134
+ if HybridAutoPlay.safeAreaInsetsListeners[moduleName]?.isEmpty
135
+ ?? false
136
+ {
137
+ HybridAutoPlay.safeAreaInsetsListeners.removeValue(
138
+ forKey: moduleName
139
+ )
140
+ }
118
141
  }
119
142
  }
120
143
  }
@@ -298,14 +321,21 @@ class HybridAutoPlay: HybridAutoPlaySpec {
298
321
 
299
322
  // MARK: events
300
323
  static func emit(event: EventName) {
301
- HybridAutoPlay.listeners[event]?.forEach { listener in
324
+ // Snapshot under the lock, then call out without holding it so a
325
+ // callback that adds or removes a listener cannot deadlock.
326
+ let listeners = HybridAutoPlay.withListenersLock {
327
+ HybridAutoPlay.listeners[event] ?? []
328
+ }
329
+ listeners.forEach { listener in
302
330
  listener.callback()
303
331
  }
304
332
  }
305
333
 
306
334
  static func emitRenderState(moduleName: String, state: VisibilityState) {
307
- HybridAutoPlay.renderStateListeners[moduleName]?.forEach {
308
- listener in
335
+ let listeners = HybridAutoPlay.withListenersLock {
336
+ HybridAutoPlay.renderStateListeners[moduleName] ?? []
337
+ }
338
+ listeners.forEach { listener in
309
339
  listener.callback(state)
310
340
  }
311
341
  }
@@ -317,14 +347,19 @@ class HybridAutoPlay: HybridAutoPlaySpec {
317
347
  let insets = HybridAutoPlay.getSafeAreaInsets(
318
348
  safeAreaInsets: safeAreaInsets
319
349
  )
320
- HybridAutoPlay.safeAreaInsetsListeners[moduleName]?.forEach {
321
- listener in listener.callback(insets)
350
+ let listeners = HybridAutoPlay.withListenersLock {
351
+ HybridAutoPlay.safeAreaInsetsListeners[moduleName] ?? []
322
352
  }
353
+ listeners.forEach { listener in listener.callback(insets) }
323
354
  }
324
355
 
325
356
  static func removeListeners(templateId: String) {
326
- HybridAutoPlay.renderStateListeners.removeValue(forKey: templateId)
327
- HybridAutoPlay.safeAreaInsetsListeners.removeValue(forKey: templateId)
357
+ HybridAutoPlay.withListenersLock {
358
+ HybridAutoPlay.renderStateListeners.removeValue(forKey: templateId)
359
+ HybridAutoPlay.safeAreaInsetsListeners.removeValue(
360
+ forKey: templateId
361
+ )
362
+ }
328
363
  }
329
364
 
330
365
  static func getSafeAreaInsets(safeAreaInsets: UIEdgeInsets)
@@ -7,26 +7,40 @@
7
7
  import NitroModules
8
8
 
9
9
  class HybridCarPlayDashboard: HybridCarPlayDashboardSpec {
10
+ /// Guards the listener dictionaries below. `addListener*` mutate them from
11
+ /// the JS thread while `DashboardSceneDelegate` emits from main-thread
12
+ /// CarPlay callbacks, and Swift dictionaries are not thread-safe.
13
+ private static let listenersLock = NSLock()
10
14
  private static var listeners = [EventName: [String: () -> Void]]()
11
15
  private static var colorSchemeListeners = [
12
16
  String: (_:ColorScheme) -> Void
13
17
  ]()
14
18
 
19
+ private static func withListenersLock<T>(_ body: () -> T) -> T {
20
+ listenersLock.lock()
21
+ defer { listenersLock.unlock() }
22
+ return body()
23
+ }
24
+
15
25
  func addListener(eventType: EventName, callback: @escaping () -> Void)
16
26
  throws -> () -> Void
17
27
  {
18
28
  let uuid = UUID().uuidString
19
- HybridCarPlayDashboard.listeners[eventType, default: [:]][uuid] =
20
- callback
29
+ HybridCarPlayDashboard.withListenersLock {
30
+ HybridCarPlayDashboard.listeners[eventType, default: [:]][uuid] =
31
+ callback
32
+ }
21
33
 
22
34
  if eventType == .didconnect && SceneStore.isDashboardModuleConnected() {
23
35
  callback()
24
36
  }
25
37
 
26
38
  return {
27
- HybridCarPlayDashboard.listeners[eventType]?.removeValue(
28
- forKey: uuid
29
- )
39
+ HybridCarPlayDashboard.withListenersLock {
40
+ _ = HybridCarPlayDashboard.listeners[eventType]?.removeValue(
41
+ forKey: uuid
42
+ )
43
+ }
30
44
  }
31
45
  }
32
46
 
@@ -54,23 +68,36 @@ class HybridCarPlayDashboard: HybridCarPlayDashboardSpec {
54
68
  callback: @escaping (_ payload: ColorScheme) -> Void
55
69
  ) throws -> () -> Void {
56
70
  let uuid = UUID().uuidString
57
- HybridCarPlayDashboard.colorSchemeListeners[uuid] = callback
71
+ HybridCarPlayDashboard.withListenersLock {
72
+ HybridCarPlayDashboard.colorSchemeListeners[uuid] = callback
73
+ }
58
74
 
59
75
  return {
60
- HybridCarPlayDashboard.colorSchemeListeners.removeValue(
61
- forKey: uuid
62
- )
76
+ HybridCarPlayDashboard.withListenersLock {
77
+ _ = HybridCarPlayDashboard.colorSchemeListeners.removeValue(
78
+ forKey: uuid
79
+ )
80
+ }
63
81
  }
64
82
  }
65
83
 
66
84
  static func emit(event: EventName) {
67
- HybridCarPlayDashboard.listeners[event]?.values.forEach {
85
+ // Snapshot under the lock, then call out without holding it so a
86
+ // callback that adds or removes a listener cannot deadlock.
87
+ let listeners = HybridCarPlayDashboard.withListenersLock {
88
+ HybridCarPlayDashboard.listeners[event].map { Array($0.values) }
89
+ ?? []
90
+ }
91
+ listeners.forEach {
68
92
  $0()
69
93
  }
70
94
  }
71
95
 
72
96
  static func emitColorScheme(colorScheme: ColorScheme) {
73
- HybridCarPlayDashboard.colorSchemeListeners.values.forEach {
97
+ let listeners = HybridCarPlayDashboard.withListenersLock {
98
+ Array(HybridCarPlayDashboard.colorSchemeListeners.values)
99
+ }
100
+ listeners.forEach {
74
101
  $0(colorScheme)
75
102
  }
76
103
  }
@@ -7,6 +7,11 @@
7
7
  import NitroModules
8
8
 
9
9
  class HybridCluster: HybridClusterSpec {
10
+ /// Guards every listener dictionary below. `addListener*` mutate them from
11
+ /// the JS thread while `ClusterSceneDelegate` emits from main-thread
12
+ /// CarPlay callbacks, and Swift dictionaries are not thread-safe.
13
+ private static let listenersLock = NSLock()
14
+
10
15
  private static var listeners = [
11
16
  ClusterEventName: [String: (_:String) -> Void]
12
17
  ]()
@@ -31,12 +36,20 @@ class HybridCluster: HybridClusterSpec {
31
36
  String: (_: String, _: Bool) -> Void
32
37
  ]()
33
38
 
39
+ private static func withListenersLock<T>(_ body: () -> T) -> T {
40
+ listenersLock.lock()
41
+ defer { listenersLock.unlock() }
42
+ return body()
43
+ }
44
+
34
45
  override init() {
35
- HybridCluster.listeners.removeAll()
36
- HybridCluster.colorSchemeListeners.removeAll()
37
- HybridCluster.zoomListeners.removeAll()
38
- HybridCluster.compassListeners.removeAll()
39
- HybridCluster.speedLimitListeners.removeAll()
46
+ HybridCluster.withListenersLock {
47
+ HybridCluster.listeners.removeAll()
48
+ HybridCluster.colorSchemeListeners.removeAll()
49
+ HybridCluster.zoomListeners.removeAll()
50
+ HybridCluster.compassListeners.removeAll()
51
+ HybridCluster.speedLimitListeners.removeAll()
52
+ }
40
53
  super.init()
41
54
  }
42
55
 
@@ -45,20 +58,22 @@ class HybridCluster: HybridClusterSpec {
45
58
  callback: @escaping (_ clusterId: String) -> Void
46
59
  ) throws -> () -> Void {
47
60
  let uuid = UUID().uuidString
48
- HybridCluster.listeners[eventType, default: [:]][uuid] =
49
- callback
61
+ let queuedClusterIds = HybridCluster.withListenersLock {
62
+ () -> [String]? in
63
+ HybridCluster.listeners[eventType, default: [:]][uuid] = callback
50
64
 
51
- if let queuedClusterIds = HybridCluster.eventQueue[eventType] {
52
- for clusterId in queuedClusterIds {
53
- callback(clusterId)
54
- }
55
- HybridCluster.eventQueue[eventType] = nil
65
+ return HybridCluster.eventQueue.removeValue(forKey: eventType)
56
66
  }
57
67
 
68
+ // Drain the queue outside the lock — the callback runs JS.
69
+ queuedClusterIds?.forEach { clusterId in callback(clusterId) }
70
+
58
71
  return {
59
- HybridCluster.listeners[eventType]?.removeValue(
60
- forKey: uuid
61
- )
72
+ HybridCluster.withListenersLock {
73
+ _ = HybridCluster.listeners[eventType]?.removeValue(
74
+ forKey: uuid
75
+ )
76
+ }
62
77
  }
63
78
  }
64
79
 
@@ -90,7 +105,17 @@ class HybridCluster: HybridClusterSpec {
90
105
  attributedInactiveDescriptionVariants:
91
106
  [NitroAttributedString]
92
107
  ) throws {
93
- if #available(iOS 15.4, *) {
108
+ guard #available(iOS 15.4, *) else {
109
+ throw AutoPlayError.unsupportedVersion(
110
+ "Cluster support only available on iOS >= 15.4"
111
+ )
112
+ }
113
+
114
+ // This runs on the JS thread, but it ends up assigning
115
+ // CPInstrumentClusterController.attributedInactiveDescriptionVariants,
116
+ // which is main-thread only — and the stored variants are also read
117
+ // from main-thread trait/content-style callbacks.
118
+ Task { @MainActor in
94
119
  guard
95
120
  let scene = SceneStore.getClusterScene(
96
121
  clusterId: clusterId
@@ -104,23 +129,22 @@ class HybridCluster: HybridClusterSpec {
104
129
  attributedInactiveDescriptionVariants
105
130
  )
106
131
  }
107
- else {
108
- throw AutoPlayError.unsupportedVersion(
109
- "Cluster support only available on iOS >= 15.4"
110
- )
111
- }
112
132
  }
113
133
 
114
134
  func addListenerColorScheme(
115
135
  callback: @escaping (String, ColorScheme) -> Void
116
136
  ) throws -> () -> Void {
117
137
  let uuid = UUID().uuidString
118
- HybridCluster.colorSchemeListeners[uuid] = callback
138
+ HybridCluster.withListenersLock {
139
+ HybridCluster.colorSchemeListeners[uuid] = callback
140
+ }
119
141
 
120
142
  return {
121
- HybridCluster.colorSchemeListeners.removeValue(
122
- forKey: uuid
123
- )
143
+ HybridCluster.withListenersLock {
144
+ _ = HybridCluster.colorSchemeListeners.removeValue(
145
+ forKey: uuid
146
+ )
147
+ }
124
148
  }
125
149
  }
126
150
 
@@ -128,12 +152,16 @@ class HybridCluster: HybridClusterSpec {
128
152
  callback: @escaping (_ clusterId: String, _ payload: ZoomEvent) -> Void
129
153
  ) throws -> () -> Void {
130
154
  let uuid = UUID().uuidString
131
- HybridCluster.zoomListeners[uuid] = callback
155
+ HybridCluster.withListenersLock {
156
+ HybridCluster.zoomListeners[uuid] = callback
157
+ }
132
158
 
133
159
  return {
134
- HybridCluster.zoomListeners.removeValue(
135
- forKey: uuid
136
- )
160
+ HybridCluster.withListenersLock {
161
+ _ = HybridCluster.zoomListeners.removeValue(
162
+ forKey: uuid
163
+ )
164
+ }
137
165
  }
138
166
  }
139
167
 
@@ -141,12 +169,16 @@ class HybridCluster: HybridClusterSpec {
141
169
  callback: @escaping (_ clusterId: String, _ payload: Bool) -> Void
142
170
  ) throws -> () -> Void {
143
171
  let uuid = UUID().uuidString
144
- HybridCluster.compassListeners[uuid] = callback
172
+ HybridCluster.withListenersLock {
173
+ HybridCluster.compassListeners[uuid] = callback
174
+ }
145
175
 
146
176
  return {
147
- HybridCluster.compassListeners.removeValue(
148
- forKey: uuid
149
- )
177
+ HybridCluster.withListenersLock {
178
+ _ = HybridCluster.compassListeners.removeValue(
179
+ forKey: uuid
180
+ )
181
+ }
150
182
  }
151
183
  }
152
184
 
@@ -154,48 +186,72 @@ class HybridCluster: HybridClusterSpec {
154
186
  callback: @escaping (_ clusterId: String, _ payload: Bool) -> Void
155
187
  ) throws -> () -> Void {
156
188
  let uuid = UUID().uuidString
157
- HybridCluster.speedLimitListeners[uuid] = callback
189
+ HybridCluster.withListenersLock {
190
+ HybridCluster.speedLimitListeners[uuid] = callback
191
+ }
158
192
 
159
193
  return {
160
- HybridCluster.speedLimitListeners.removeValue(
161
- forKey: uuid
162
- )
194
+ HybridCluster.withListenersLock {
195
+ _ = HybridCluster.speedLimitListeners.removeValue(
196
+ forKey: uuid
197
+ )
198
+ }
163
199
  }
164
200
  }
165
201
 
166
202
  static func emit(event: ClusterEventName, clusterId: String) {
167
- guard let listeners = HybridCluster.listeners[event], !listeners.isEmpty
168
- else {
169
- // no listeners -> queue the event
170
- HybridCluster.eventQueue[event, default: []].append(clusterId)
171
- return
203
+ // Snapshot under the lock, then call out without holding it so a
204
+ // callback that adds or removes a listener cannot deadlock.
205
+ let listeners = HybridCluster.withListenersLock {
206
+ () -> [(_: String) -> Void] in
207
+ guard let listeners = HybridCluster.listeners[event],
208
+ !listeners.isEmpty
209
+ else {
210
+ // no listeners -> queue the event
211
+ HybridCluster.eventQueue[event, default: []].append(clusterId)
212
+ return []
213
+ }
214
+
215
+ return Array(listeners.values)
172
216
  }
173
217
 
174
- listeners.values.forEach {
218
+ listeners.forEach {
175
219
  $0(clusterId)
176
220
  }
177
221
  }
178
222
 
179
223
  static func emitColorScheme(clusterId: String, colorScheme: ColorScheme) {
180
- HybridCluster.colorSchemeListeners.values.forEach {
224
+ let listeners = HybridCluster.withListenersLock {
225
+ Array(HybridCluster.colorSchemeListeners.values)
226
+ }
227
+ listeners.forEach {
181
228
  $0(clusterId, colorScheme)
182
229
  }
183
230
  }
184
231
 
185
232
  static func emitZoom(clusterId: String, payload: ZoomEvent) {
186
- HybridCluster.zoomListeners.values.forEach {
233
+ let listeners = HybridCluster.withListenersLock {
234
+ Array(HybridCluster.zoomListeners.values)
235
+ }
236
+ listeners.forEach {
187
237
  $0(clusterId, payload)
188
238
  }
189
239
  }
190
240
 
191
241
  static func emitCompass(clusterId: String, payload: Bool) {
192
- HybridCluster.compassListeners.values.forEach {
242
+ let listeners = HybridCluster.withListenersLock {
243
+ Array(HybridCluster.compassListeners.values)
244
+ }
245
+ listeners.forEach {
193
246
  $0(clusterId, payload)
194
247
  }
195
248
  }
196
249
 
197
250
  static func emitSpeedLimit(clusterId: String, payload: Bool) {
198
- HybridCluster.speedLimitListeners.values.forEach {
251
+ let listeners = HybridCluster.withListenersLock {
252
+ Array(HybridCluster.speedLimitListeners.values)
253
+ }
254
+ listeners.forEach {
199
255
  $0(clusterId, payload)
200
256
  }
201
257
  }
@@ -12,36 +12,49 @@ class SceneStore {
12
12
  static let dashboardModuleName = "CarPlayDashboard"
13
13
  static let windowSceneModuleName = "main"
14
14
 
15
+ /// Guards `renderState` and `store`. Scenes are added/removed on the main
16
+ /// thread while the dictionaries are read from the JS thread, and Swift
17
+ /// dictionaries are not thread-safe.
18
+ private static let lock = NSLock()
19
+
15
20
  private static var renderState = [String: VisibilityState]()
16
21
 
17
22
  private static var store: [String: AutoPlayScene] = [:]
18
23
 
24
+ private static func withLock<T>(_ body: () -> T) -> T {
25
+ lock.lock()
26
+ defer { lock.unlock() }
27
+ return body()
28
+ }
29
+
19
30
  static func addScene(moduleName: String, scene: AutoPlayScene) {
20
- store[moduleName] = scene
31
+ withLock { store[moduleName] = scene }
21
32
  }
22
33
 
23
34
  static func removeScene(moduleName: String) {
24
- store.removeValue(forKey: moduleName)
35
+ let _ = withLock { store.removeValue(forKey: moduleName) }
25
36
  }
26
37
 
27
38
  static func getScene(moduleName: String) -> AutoPlayScene? {
28
- return store[moduleName]
39
+ return withLock { store[moduleName] }
29
40
  }
30
41
 
31
42
  static func isRootModuleConnected() -> Bool {
32
- return store[SceneStore.rootModuleName]?.isConnected ?? false
43
+ return getScene(moduleName: SceneStore.rootModuleName)?.isConnected
44
+ ?? false
33
45
  }
34
46
 
35
47
  static func isDashboardModuleConnected() -> Bool {
36
- return store[SceneStore.dashboardModuleName]?.isConnected ?? false
48
+ return getScene(moduleName: SceneStore.dashboardModuleName)?.isConnected
49
+ ?? false
37
50
  }
38
51
 
39
52
  static func getState(moduleName: String) -> VisibilityState? {
40
- return renderState[moduleName]
53
+ return withLock { renderState[moduleName] }
41
54
  }
42
55
 
43
56
  static func setState(moduleName: String, state: VisibilityState) {
44
- renderState[moduleName] = state
57
+ withLock { renderState[moduleName] = state }
45
58
 
46
59
  HybridAutoPlay.emitRenderState(
47
60
  moduleName: moduleName,
@@ -77,10 +90,10 @@ class SceneStore {
77
90
  }
78
91
 
79
92
  static func getRootScene() -> AutoPlayScene? {
80
- return store[SceneStore.rootModuleName]
93
+ return getScene(moduleName: SceneStore.rootModuleName)
81
94
  }
82
95
 
83
96
  static func getRootTraitCollection() -> UITraitCollection? {
84
- return store[SceneStore.rootModuleName]?.traitCollection
97
+ return getRootScene()?.traitCollection
85
98
  }
86
99
  }
@@ -7,47 +7,67 @@
7
7
  import CarPlay
8
8
 
9
9
  class TemplateStore {
10
+ /// Guards `store`. Templates are added from the JS thread (the
11
+ /// `createXTemplate` hybrid methods are synchronous and never hop actors)
12
+ /// while the CarPlay delegate callbacks remove/purge them on the main
13
+ /// thread, and Swift dictionaries are not thread-safe.
14
+ private let lock = NSLock()
10
15
  private var store: [String: AutoPlayTemplate] = [:]
11
16
 
17
+ private func withLock<T>(_ body: () -> T) -> T {
18
+ lock.lock()
19
+ defer { lock.unlock() }
20
+ return body()
21
+ }
22
+
12
23
  func getCPTemplate(templateId key: String) -> CPTemplate? {
13
- return store[key]?.getTemplate()
24
+ return withLock { store[key] }?.getTemplate()
14
25
  }
15
26
 
16
27
  func getTemplate(templateId: String) throws -> AutoPlayTemplate {
17
- if let template = store[templateId] {
28
+ if let template = withLock({ store[templateId] }) {
18
29
  return template
19
30
  }
20
31
  throw AutoPlayError.templateNotFound(templateId)
21
32
  }
22
33
 
23
34
  func addTemplate(template: AutoPlayTemplate, templateId: String) {
24
- store[templateId] = template
35
+ withLock { store[templateId] = template }
25
36
  }
26
37
 
27
38
  func removeTemplate(templateId: String) {
28
- store[templateId]?.onPopped()
39
+ // Remove first, then notify outside the lock: `onPopped` runs a JS
40
+ // callback which can re-enter the store (see
41
+ // VoiceInputTemplate.onDidDisappear).
42
+ let removed = withLock { store.removeValue(forKey: templateId) }
29
43
 
30
- store.removeValue(forKey: templateId)
44
+ removed?.onPopped()
31
45
  }
32
46
 
33
47
  func removeTemplates(templateIds: [String]) {
34
- templateIds.forEach { templateId in
35
- store[templateId]?.onPopped()
48
+ let removed = withLock {
49
+ templateIds.compactMap { store.removeValue(forKey: $0) }
36
50
  }
37
51
 
38
- store = store.filter { !templateIds.contains($0.key) }
52
+ removed.forEach { template in template.onPopped() }
39
53
  }
40
54
 
41
55
  func purge() {
42
- store = store.filter { !($0.value.getTemplate() is CPSearchTemplate) }
56
+ withLock {
57
+ store = store.filter {
58
+ !($0.value.getTemplate() is CPSearchTemplate)
59
+ }
60
+ }
43
61
  }
44
62
 
45
63
  @MainActor
46
64
  func traitCollectionDidChange() {
47
- store.values.forEach { template in template.traitCollectionDidChange() }
65
+ let templates = withLock { Array(store.values) }
66
+
67
+ templates.forEach { template in template.traitCollectionDidChange() }
48
68
  }
49
69
 
50
70
  func disconnect() {
51
- store = [:]
71
+ withLock { store = [:] }
52
72
  }
53
73
  }
@@ -9,10 +9,17 @@ import CoreText
9
9
  import UIKit
10
10
 
11
11
  class SymbolFont {
12
+ /// Guards the font cache below. Glyphs are rendered both during template
13
+ /// construction on the JS thread and from `@MainActor` template
14
+ /// invalidation, so the cache is touched from more than one thread.
15
+ private static let cacheLock = NSLock()
12
16
  private static var cachedFontName: String?
13
17
  private static var cachedPSName: String?
14
18
 
15
19
  private static func loadFont(named fontName: String) -> String? {
20
+ cacheLock.lock()
21
+ defer { cacheLock.unlock() }
22
+
16
23
  if fontName == cachedFontName {
17
24
  return cachedPSName
18
25
  }
@@ -307,9 +307,11 @@ class VoiceInputManager {
307
307
  throw VoiceInputError.converterUnavailable
308
308
  }
309
309
 
310
- recordingStart = Date()
311
- silenceStart = nil
312
- firstBufferContinuation = nil
310
+ stopLock.withLock {
311
+ recordingStart = Date()
312
+ silenceStart = nil
313
+ firstBufferContinuation = nil
314
+ }
313
315
 
314
316
  inputNode.installTap(
315
317
  onBus: 0,
@@ -381,18 +383,22 @@ class VoiceInputManager {
381
383
  now.timeIntervalSince(start) * 1000 >= VoiceInputManager.warmupMs
382
384
  {
383
385
  let peak = newSamples.reduce(0) { max($0, abs(Int($1))) }
384
- if peak < VoiceInputManager.silenceAmplitudeThreshold {
385
- if self.silenceStart == nil {
386
- self.silenceStart = now
387
- }
388
- if let silenceBegin = self.silenceStart,
389
- now.timeIntervalSince(silenceBegin) * 1000 >= silenceThresholdMs
390
- {
391
- self.triggerAutoStop(interfaceController: interfaceController)
386
+ // `silenceStart` is also cleared from cleanup() on another
387
+ // thread, so the read-modify-write has to hold stopLock.
388
+ let silenceElapsedMs = self.stopLock.withLock { () -> Double? in
389
+ guard peak < VoiceInputManager.silenceAmplitudeThreshold else {
390
+ self.silenceStart = nil
391
+ return nil
392
392
  }
393
+
394
+ let silenceBegin = self.silenceStart ?? now
395
+ self.silenceStart = silenceBegin
396
+
397
+ return now.timeIntervalSince(silenceBegin) * 1000
393
398
  }
394
- else {
395
- self.silenceStart = nil
399
+
400
+ if let silenceElapsedMs, silenceElapsedMs >= silenceThresholdMs {
401
+ self.triggerAutoStop(interfaceController: interfaceController)
396
402
  }
397
403
  }
398
404
  }
@@ -412,10 +418,11 @@ class VoiceInputManager {
412
418
  audioEngine?.stop()
413
419
  audioEngine = nil
414
420
  recognitionRequest = nil
415
- recordingStart = nil
416
- silenceStart = nil
417
421
  // Drain firstBufferContinuation so the template Task doesn't hang if stop() fired before the first buffer.
418
422
  let pendingCont = stopLock.withLock { () -> CheckedContinuation<Void, Never>? in
423
+ recordingStart = nil
424
+ silenceStart = nil
425
+
419
426
  let c = firstBufferContinuation
420
427
  firstBufferContinuation = nil
421
428
  return c
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iternio/react-native-auto-play",
3
- "version": "0.5.12",
3
+ "version": "0.5.14-beta",
4
4
  "description": "Android Auto and Apple CarPlay for react-native",
5
5
  "main": "lib/index",
6
6
  "module": "lib/index",