@capgo/capacitor-stream-call 0.0.3 → 0.0.4

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.
@@ -48,10 +48,16 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
48
48
 
49
49
  private var streamVideo: StreamVideo?
50
50
 
51
+ // Track the current active call ID
52
+ private var currentActiveCallId: String?
53
+
51
54
  @Injected(\.callKitAdapter) var callKitAdapter
52
55
  @Injected(\.callKitPushNotificationAdapter) var callKitPushNotificationAdapter
53
56
  private var webviewDelegate: WebviewNavigationDelegate?
54
57
 
58
+ // Add class property to store call states
59
+ private var callStates: [String: (members: [MemberResponse], participantResponses: [String: String], createdAt: Date, timer: Timer?)] = [:]
60
+
55
61
  override public func load() {
56
62
  // Read API key from Info.plist
57
63
  if let apiKey = Bundle.main.object(forInfoDictionaryKey: "CAPACITOR_STREAM_VIDEO_APIKEY") as? String {
@@ -158,55 +164,139 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
158
164
 
159
165
  private func setupActiveCallSubscription() {
160
166
  if let streamVideo = streamVideo {
161
- // Track participants responses
162
- var participantResponses: [String: String] = [:]
163
-
164
167
  Task {
165
168
  for await event in streamVideo.subscribe() {
166
- print("Event", event)
169
+ // print("Event", event)
167
170
  if let ringingEvent = event.rawValue as? CallRingEvent {
168
171
  notifyListeners("callEvent", data: [
169
172
  "callId": ringingEvent.callCid,
170
173
  "state": "ringing"
171
174
  ])
172
- return
175
+ continue
176
+ }
177
+
178
+ if let callCreatedEvent = event.rawValue as? CallCreatedEvent {
179
+ print("CallCreatedEvent \(String(describing: userId))")
180
+
181
+ let callCid = callCreatedEvent.callCid
182
+ let members = callCreatedEvent.members
183
+
184
+ // Create timer on main thread
185
+ await MainActor.run {
186
+ // Store in the combined callStates map
187
+ self.callStates[callCid] = (
188
+ members: members,
189
+ participantResponses: [:],
190
+ createdAt: Date(),
191
+ timer: nil
192
+ )
193
+
194
+ // Start timer to check for timeout every second
195
+ // Use @objc method as timer target to avoid sendable closure issues
196
+ let timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(self.checkCallTimeoutTimer(_:)), userInfo: callCid, repeats: true)
197
+
198
+ // Update timer in callStates
199
+ self.callStates[callCid]?.timer = timer
200
+ }
173
201
  }
174
202
 
175
203
  if let rejectedEvent = event.rawValue as? CallRejectedEvent {
176
204
  let userId = rejectedEvent.user.id
177
- participantResponses[userId] = "rejected"
205
+ let callCid = rejectedEvent.callCid
206
+
207
+ // Operate on callStates on the main thread
208
+ await MainActor.run {
209
+ // Update the combined callStates map
210
+ if var callState = self.callStates[callCid] {
211
+ callState.participantResponses[userId] = "rejected"
212
+ self.callStates[callCid] = callState
213
+ }
214
+ }
215
+
216
+ print("CallRejectedEvent \(userId)")
178
217
  notifyListeners("callEvent", data: [
179
- "callId": rejectedEvent.callCid,
218
+ "callId": callCid,
180
219
  "state": "rejected",
181
220
  "userId": userId
182
221
  ])
183
222
 
184
- await checkAllParticipantsResponded(participantResponses: participantResponses)
185
- return
223
+ await checkAllParticipantsResponded(callCid: callCid)
224
+ continue
186
225
  }
187
226
 
188
227
  if let missedEvent = event.rawValue as? CallMissedEvent {
189
228
  let userId = missedEvent.user.id
190
- participantResponses[userId] = "missed"
229
+ let callCid = missedEvent.callCid
230
+
231
+ // Operate on callStates on the main thread
232
+ await MainActor.run {
233
+ // Update the combined callStates map
234
+ if var callState = self.callStates[callCid] {
235
+ callState.participantResponses[userId] = "missed"
236
+ self.callStates[callCid] = callState
237
+ }
238
+ }
239
+
240
+ print("CallMissedEvent \(userId)")
191
241
  notifyListeners("callEvent", data: [
192
- "callId": missedEvent.callCid,
242
+ "callId": callCid,
193
243
  "state": "missed",
194
244
  "userId": userId
195
245
  ])
196
246
 
197
- await checkAllParticipantsResponded(participantResponses: participantResponses)
198
- return
247
+ await checkAllParticipantsResponded(callCid: callCid)
248
+ continue
249
+ }
250
+
251
+ if let participantLeftEvent = event.rawValue as? CallSessionParticipantLeftEvent {
252
+ let callIdSplit = participantLeftEvent.callCid.split(separator: ":")
253
+ if (callIdSplit.count != 2) {
254
+ print("CallSessionParticipantLeftEvent invalid cID \(participantLeftEvent.callCid)")
255
+ continue
256
+ }
257
+
258
+ let callType = callIdSplit[0]
259
+ let callId = callIdSplit[1]
260
+
261
+ let call = streamVideo.call(callType: String(callType), callId: String(callId))
262
+ if await MainActor.run(body: { (call.state.session?.participants.count ?? 1) - 1 <= 1 }) {
263
+
264
+
265
+ print("We are left solo in a call. Ending. cID: \(participantLeftEvent.callCid)")
266
+
267
+ Task {
268
+ if let activeCall = streamVideo.state.activeCall {
269
+ activeCall.leave()
270
+ }
271
+ }
272
+ }
199
273
  }
200
274
 
201
275
  if let acceptedEvent = event.rawValue as? CallAcceptedEvent {
202
276
  let userId = acceptedEvent.user.id
203
- participantResponses[userId] = "accepted"
277
+ let callCid = acceptedEvent.callCid
278
+
279
+ // Operate on callStates on the main thread
280
+ await MainActor.run {
281
+ // Update the combined callStates map
282
+ if var callState = self.callStates[callCid] {
283
+ callState.participantResponses[userId] = "accepted"
284
+
285
+ // If someone accepted, invalidate the timer as we don't need to check anymore
286
+ callState.timer?.invalidate()
287
+ callState.timer = nil
288
+
289
+ self.callStates[callCid] = callState
290
+ }
291
+ }
292
+
293
+ print("CallAcceptedEvent \(userId)")
204
294
  notifyListeners("callEvent", data: [
205
- "callId": acceptedEvent.callCid,
295
+ "callId": callCid,
206
296
  "state": "accepted",
207
297
  "userId": userId
208
298
  ])
209
- return
299
+ continue
210
300
  }
211
301
 
212
302
  notifyListeners("callEvent", data: [
@@ -221,17 +311,23 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
221
311
  // Create new subscription
222
312
  activeCallSubscription = streamVideo?.state.$activeCall.sink { [weak self] newState in
223
313
  guard let self = self else { return }
314
+
224
315
  Task { @MainActor in
225
316
  do {
226
317
  try self.requireInitialized()
227
318
  print("Call State Update:")
228
319
  print("- Call is nil: \(newState == nil)")
320
+
229
321
  if let state = newState?.state {
230
322
  print("- state: \(state)")
231
323
  print("- Session ID: \(state.sessionId)")
232
324
  print("- All participants: \(String(describing: state.participants))")
233
325
  print("- Remote participants: \(String(describing: state.remoteParticipants))")
234
326
 
327
+ // Store the active call ID when a call becomes active
328
+ self.currentActiveCallId = newState?.cId
329
+ print("Updated current active call ID: \(String(describing: self.currentActiveCallId))")
330
+
235
331
  // Update overlay and make visible when there's an active call
236
332
  self.overlayViewModel?.updateCall(newState)
237
333
  self.overlayView?.isHidden = false
@@ -243,16 +339,34 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
243
339
  "state": "joined"
244
340
  ])
245
341
  } else {
342
+ // Get the call ID that was active before the state changed to nil
343
+ let endingCallId = self.currentActiveCallId
344
+ print("Call ending: \(String(describing: endingCallId))")
345
+
246
346
  // If newState is nil, hide overlay and clear call
247
347
  self.overlayViewModel?.updateCall(nil)
248
348
  self.overlayView?.isHidden = true
249
349
  self.webView?.isOpaque = true
250
350
 
251
- // Notify that call has ended
351
+ // Notify that call has ended - use the properly tracked call ID
252
352
  self.notifyListeners("callEvent", data: [
253
- "callId": newState?.cId ?? "",
353
+ "callId": endingCallId ?? "",
254
354
  "state": "left"
255
355
  ])
356
+
357
+ // Clean up any resources for this call
358
+ if let callCid = endingCallId {
359
+ // Invalidate and remove the timer
360
+ self.callStates[callCid]?.timer?.invalidate()
361
+
362
+ // Remove call from callStates
363
+ self.callStates.removeValue(forKey: callCid)
364
+
365
+ print("Cleaned up resources for ended call: \(callCid)")
366
+ }
367
+
368
+ // Clear the active call ID
369
+ self.currentActiveCallId = nil
256
370
  }
257
371
  } catch {
258
372
  log.error("Error handling call state update: \(String(describing: error))")
@@ -261,16 +375,134 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
261
375
  }
262
376
  }
263
377
 
264
- private func checkAllParticipantsResponded(participantResponses: [String: String]) async {
265
- let call = streamVideo?.state.activeCall
266
- let totalParticipants = await call?.state.participants.count ?? 0
267
- let allResponded = participantResponses.count == totalParticipants
268
- let allRejectedOrMissed = participantResponses.values.allSatisfy { $0 == "rejected" || $0 == "missed" }
378
+ @objc private func checkCallTimeoutTimer(_ timer: Timer) {
379
+ guard let callCid = timer.userInfo as? String else { return }
380
+
381
+ Task { [weak self] in
382
+ guard let self = self else { return }
383
+ await self.checkCallTimeout(callCid: callCid)
384
+ }
385
+ }
386
+
387
+ private func checkCallTimeout(callCid: String) async {
388
+ // Get a local copy of the call state from the main thread
389
+ let callState: (members: [MemberResponse], participantResponses: [String: String], createdAt: Date, timer: Timer?)? = await MainActor.run {
390
+ return self.callStates[callCid]
391
+ }
392
+
393
+ guard let callState = callState else { return }
394
+
395
+ // Calculate time elapsed since call creation
396
+ let now = Date()
397
+ let elapsedSeconds = now.timeIntervalSince(callState.createdAt)
398
+
399
+ // Check if 30 seconds have passed
400
+ if elapsedSeconds >= 30.0 {
401
+
402
+ // Check if anyone has accepted
403
+ let hasAccepted = callState.participantResponses.values.contains { $0 == "accepted" }
404
+
405
+ if !hasAccepted {
406
+ print("Call \(callCid) has timed out after \(elapsedSeconds) seconds")
407
+ print("No one accepted call \(callCid), marking all non-responders as missed")
408
+
409
+ // Mark all members who haven't responded as "missed"
410
+ for member in callState.members {
411
+ let memberId = member.userId
412
+ let needsToBeMarkedAsMissed = await MainActor.run {
413
+ return self.callStates[callCid]?.participantResponses[memberId] == nil
414
+ }
415
+
416
+ if needsToBeMarkedAsMissed {
417
+ // Update callStates map on main thread
418
+ await MainActor.run {
419
+ var updatedCallState = self.callStates[callCid]
420
+ updatedCallState?.participantResponses[memberId] = "missed"
421
+ if let updatedCallState = updatedCallState {
422
+ self.callStates[callCid] = updatedCallState
423
+ }
424
+ }
425
+
426
+ // Notify listeners
427
+ await MainActor.run {
428
+ self.notifyListeners("callEvent", data: [
429
+ "callId": callCid,
430
+ "state": "missed",
431
+ "userId": memberId
432
+ ])
433
+ }
434
+ }
435
+ }
436
+
437
+ // End the call
438
+ if let call = streamVideo?.state.activeCall, call.cId == callCid {
439
+ call.leave()
440
+ }
441
+
442
+ // Clean up timer on main thread
443
+ await MainActor.run {
444
+ self.callStates[callCid]?.timer?.invalidate()
445
+ var updatedCallState = self.callStates[callCid]
446
+ updatedCallState?.timer = nil
447
+ if let updatedCallState = updatedCallState {
448
+ self.callStates[callCid] = updatedCallState
449
+ }
450
+
451
+ // Remove from callStates
452
+ self.callStates.removeValue(forKey: callCid)
453
+ }
454
+
455
+ // Update UI
456
+ await MainActor.run {
457
+ self.overlayViewModel?.updateCall(nil)
458
+ self.overlayView?.isHidden = true
459
+ self.webView?.isOpaque = true
460
+ self.notifyListeners("callEvent", data: [
461
+ "callId": callCid,
462
+ "state": "ended",
463
+ "reason": "timeout"
464
+ ])
465
+ }
466
+ }
467
+ }
468
+ }
469
+
470
+ private func checkAllParticipantsResponded(callCid: String) async {
471
+ // Get a local copy of the call state from the main thread
472
+ let callState: (members: [MemberResponse], participantResponses: [String: String], createdAt: Date, timer: Timer?)? = await MainActor.run {
473
+ return self.callStates[callCid]
474
+ }
475
+
476
+ guard let callState = callState else {
477
+ print("Call state not found for cId: \(callCid)")
478
+ return
479
+ }
480
+
481
+ let totalParticipants = callState.members.count
482
+ let responseCount = callState.participantResponses.count
483
+
484
+ print("Total participants: \(totalParticipants), Responses: \(responseCount)")
485
+
486
+ let allResponded = responseCount >= totalParticipants
487
+ let allRejectedOrMissed = allResponded &&
488
+ callState.participantResponses.values.allSatisfy { $0 == "rejected" || $0 == "missed" }
269
489
 
270
490
  if allResponded && allRejectedOrMissed {
271
491
  print("All participants have rejected or missed the call")
272
- call?.leave()
492
+
493
+ // End the call
494
+ if let call = streamVideo?.state.activeCall, call.cId == callCid {
495
+ call.leave()
496
+ }
497
+
498
+ // Clean up timer and remove from callStates on main thread
273
499
  await MainActor.run {
500
+ // Clean up timer
501
+ self.callStates[callCid]?.timer?.invalidate()
502
+
503
+ // Remove from callStates
504
+ self.callStates.removeValue(forKey: callCid)
505
+
274
506
  self.overlayViewModel?.updateCall(nil)
275
507
  self.overlayView?.isHidden = true
276
508
  self.webView?.isOpaque = true
@@ -132,8 +132,8 @@ class TouchInterceptView: UIView {
132
132
  // Convert point to global coordinates for labeled frame checking
133
133
  let globalPoint = convert(point, to: nil)
134
134
 
135
- print("TouchInterceptView - Hit test at global point: \(globalPoint)")
136
- print("Current labeled frames: \(labeledFrames.map { "\($0.label): \($0.frame)" }.joined(separator: ", "))")
135
+ //print("TouchInterceptView - Hit test at global point: \(globalPoint)")
136
+ //print("Current labeled frames: \(labeledFrames.map { "\($0.label): \($0.frame)" }.joined(separator: ", "))")
137
137
 
138
138
  // Convert point for both views
139
139
  let webViewPoint = convert(point, to: webView)
@@ -142,7 +142,7 @@ class TouchInterceptView: UIView {
142
142
  // First check if the point is inside any labeled frame
143
143
  for labeledFrame in labeledFrames {
144
144
  if labeledFrame.frame.contains(globalPoint) {
145
- print("Hit labeled frame: \(labeledFrame.label)")
145
+ //print("Hit labeled frame: \(labeledFrame.label)")
146
146
  // If it's in a labeled frame, let the overlay handle it
147
147
  if overlayView.point(inside: overlayPoint, with: event),
148
148
  let overlayHitView = overlayView.hitTest(overlayPoint, with: event) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-stream-call",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Uses the https://getstream.io/ SDK to implement calling in Capacitor",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",
@@ -39,7 +39,7 @@
39
39
  "ios",
40
40
  "android",
41
41
  "web",
42
- "capacitor-plugin"
42
+ "capacitor-plugin"
43
43
  ],
44
44
  "scripts": {
45
45
  "verify": "npm run verify:ios && npm run verify:android && npm run verify:web",