@capgo/capacitor-stream-call 0.0.2 → 0.0.3

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.
@@ -22,9 +22,10 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
22
22
  CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise),
23
23
  CAPPluginMethod(name: "setMicrophoneEnabled", returnType: CAPPluginReturnPromise),
24
24
  CAPPluginMethod(name: "setCameraEnabled", returnType: CAPPluginReturnPromise),
25
- CAPPluginMethod(name: "acceptCall", returnType: CAPPluginReturnPromise)
25
+ CAPPluginMethod(name: "acceptCall", returnType: CAPPluginReturnPromise),
26
+ CAPPluginMethod(name: "isCameraEnabled", returnType: CAPPluginReturnPromise)
26
27
  ]
27
-
28
+
28
29
  private enum State {
29
30
  case notInitialized
30
31
  case initializing
@@ -36,7 +37,7 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
36
37
  private static let tokenRefreshSemaphore = DispatchSemaphore(value: 1)
37
38
  private var currentToken: String?
38
39
  private var tokenWaitSemaphore: DispatchSemaphore?
39
-
40
+
40
41
  private var overlayView: UIView?
41
42
  private var hostingController: UIHostingController<CallOverlayView>?
42
43
  private var overlayViewModel: CallOverlayViewModel?
@@ -44,17 +45,13 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
44
45
  private var activeCallSubscription: AnyCancellable?
45
46
  private var lastVoIPToken: String?
46
47
  private var touchInterceptView: TouchInterceptView?
47
-
48
+
48
49
  private var streamVideo: StreamVideo?
49
-
50
+
50
51
  @Injected(\.callKitAdapter) var callKitAdapter
51
52
  @Injected(\.callKitPushNotificationAdapter) var callKitPushNotificationAdapter
52
-
53
- private var refreshTokenURL: String?
54
- private var refreshTokenHeaders: [String: String]?
55
-
56
53
  private var webviewDelegate: WebviewNavigationDelegate?
57
-
54
+
58
55
  override public func load() {
59
56
  // Read API key from Info.plist
60
57
  if let apiKey = Bundle.main.object(forInfoDictionaryKey: "CAPACITOR_STREAM_VIDEO_APIKEY") as? String {
@@ -63,7 +60,7 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
63
60
  if self.apiKey == nil {
64
61
  fatalError("Cannot get apikey")
65
62
  }
66
-
63
+
67
64
  // Check if we have a logged in user for handling incoming calls
68
65
  if let credentials = SecureUserRepository.shared.loadCurrentUser() {
69
66
  print("Loading user for StreamCallPlugin: \(credentials.user.name)")
@@ -71,21 +68,21 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
71
68
  self.initializeStreamVideo()
72
69
  }
73
70
  }
74
-
71
+
75
72
  // Create and set the navigation delegate
76
73
  self.webviewDelegate = WebviewNavigationDelegate(
77
74
  wrappedDelegate: self.webView?.navigationDelegate,
78
75
  onSetupOverlay: { [weak self] in
79
76
  guard let self = self else { return }
80
77
  print("Attempting to setup call view")
81
-
78
+
82
79
  self.setupViews()
83
80
  }
84
81
  )
85
-
82
+
86
83
  self.webView?.navigationDelegate = self.webviewDelegate
87
84
  }
88
-
85
+
89
86
  // private func cleanupStreamVideo() {
90
87
  // // Cancel subscriptions
91
88
  // tokenSubscription?.cancel()
@@ -111,125 +108,28 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
111
108
  //
112
109
  // state = .notInitialized
113
110
  // }
114
-
111
+
115
112
  private func requireInitialized() throws {
116
113
  guard state == .initialized else {
117
114
  throw NSError(domain: "StreamCallPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "StreamVideo not initialized"])
118
115
  }
119
116
  }
120
-
121
- private func refreshToken() throws -> UserToken {
122
- // Acquire the semaphore in a thread-safe way
123
- StreamCallPlugin.tokenRefreshQueue.sync {
124
- StreamCallPlugin.tokenRefreshSemaphore.wait()
125
- }
126
-
127
- defer {
128
- // Always release the semaphore when we're done, even if we throw an error
129
- StreamCallPlugin.tokenRefreshSemaphore.signal()
130
- }
131
-
132
- // Clear current token
133
- currentToken = nil
134
-
135
- // Create a local semaphore for waiting on the token
136
- let localSemaphore = DispatchSemaphore(value: 0)
137
- tokenWaitSemaphore = localSemaphore
138
-
139
- // Capture webView before async context
140
- guard let webView = self.webView else {
141
- print("WebView not available")
142
- tokenWaitSemaphore = nil
143
- throw NSError(domain: "StreamCallPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "WebView not available"])
144
- }
145
-
146
- guard let refreshURL = self.refreshTokenURL else {
147
- print("Refresh URL not configured")
148
- tokenWaitSemaphore = nil
149
- throw NSError(domain: "StreamCallPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Refresh URL not configured"])
150
- }
151
-
152
- let headersJSON = (self.refreshTokenHeaders ?? [:]).reduce(into: "") { result, pair in
153
- result += "\n'\(pair.key)': '\(pair.value)',"
154
- }
155
-
156
- let script = """
157
- (function() {
158
- console.log('Starting token refresh...');
159
- fetch('\(refreshURL)', {
160
- headers: {
161
- \(headersJSON)
162
- }
163
- })
164
- .then(response => {
165
- console.log('Got response:', response.status);
166
- return response.json();
167
- })
168
- .then(data => {
169
- console.log('Got data, token length:', data.token?.length);
170
- const tokenA = data.token;
171
- window.Capacitor.Plugins.StreamCall.loginMagicToken({
172
- token: tokenA
173
- });
174
- })
175
- .catch(error => {
176
- console.error('Token refresh error:', error);
177
- });
178
- })();
179
- """
180
-
181
- if Thread.isMainThread {
182
- print("Executing script on main thread")
183
- webView.evaluateJavaScript(script, completionHandler: nil)
184
- } else {
185
- print("Executing script from background thread")
186
- DispatchQueue.main.sync {
187
- webView.evaluateJavaScript(script, completionHandler: nil)
188
- }
189
- }
190
-
191
- // Set up a timeout
192
- let timeoutQueue = DispatchQueue.global()
193
- let timeoutWork = DispatchWorkItem {
194
- print("Token refresh timed out")
195
- self.tokenWaitSemaphore?.signal()
196
- self.tokenWaitSemaphore = nil
197
- }
198
- timeoutQueue.asyncAfter(deadline: .now() + 10.0, execute: timeoutWork) // 10 second timeout
199
-
200
- // Wait for token to be set via loginMagicToken or timeout
201
- localSemaphore.wait()
202
- timeoutWork.cancel()
203
- tokenWaitSemaphore = nil
204
-
205
- guard let token = currentToken else {
206
- print("Failed to get token")
207
- throw NSError(domain: "StreamCallPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to get token or timeout occurred"])
208
- }
209
-
210
- // Save the token
211
- SecureUserRepository.shared.save(token: token)
212
-
213
- print("Got the token!!!")
214
- return UserToken(stringLiteral: token)
215
- }
216
-
217
117
  @objc func loginMagicToken(_ call: CAPPluginCall) {
218
118
  guard let token = call.getString("token") else {
219
119
  call.reject("Missing token parameter")
220
120
  return
221
121
  }
222
-
122
+
223
123
  print("loginMagicToken received token")
224
124
  currentToken = token
225
125
  tokenWaitSemaphore?.signal()
226
126
  call.resolve()
227
127
  }
228
-
128
+
229
129
  private func setupTokenSubscription() {
230
130
  // Cancel existing subscription if any
231
131
  tokenSubscription?.cancel()
232
-
132
+
233
133
  // Create new subscription
234
134
  tokenSubscription = callKitPushNotificationAdapter.$deviceToken.sink { [weak self] (updatedDeviceToken: String) in
235
135
  guard let self = self else { return }
@@ -255,9 +155,12 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
255
155
  }
256
156
  }
257
157
  }
258
-
158
+
259
159
  private func setupActiveCallSubscription() {
260
160
  if let streamVideo = streamVideo {
161
+ // Track participants responses
162
+ var participantResponses: [String: String] = [:]
163
+
261
164
  Task {
262
165
  for await event in streamVideo.subscribe() {
263
166
  print("Event", event)
@@ -268,6 +171,44 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
268
171
  ])
269
172
  return
270
173
  }
174
+
175
+ if let rejectedEvent = event.rawValue as? CallRejectedEvent {
176
+ let userId = rejectedEvent.user.id
177
+ participantResponses[userId] = "rejected"
178
+ notifyListeners("callEvent", data: [
179
+ "callId": rejectedEvent.callCid,
180
+ "state": "rejected",
181
+ "userId": userId
182
+ ])
183
+
184
+ await checkAllParticipantsResponded(participantResponses: participantResponses)
185
+ return
186
+ }
187
+
188
+ if let missedEvent = event.rawValue as? CallMissedEvent {
189
+ let userId = missedEvent.user.id
190
+ participantResponses[userId] = "missed"
191
+ notifyListeners("callEvent", data: [
192
+ "callId": missedEvent.callCid,
193
+ "state": "missed",
194
+ "userId": userId
195
+ ])
196
+
197
+ await checkAllParticipantsResponded(participantResponses: participantResponses)
198
+ return
199
+ }
200
+
201
+ if let acceptedEvent = event.rawValue as? CallAcceptedEvent {
202
+ let userId = acceptedEvent.user.id
203
+ participantResponses[userId] = "accepted"
204
+ notifyListeners("callEvent", data: [
205
+ "callId": acceptedEvent.callCid,
206
+ "state": "accepted",
207
+ "userId": userId
208
+ ])
209
+ return
210
+ }
211
+
271
212
  notifyListeners("callEvent", data: [
272
213
  "callId": streamVideo.state.activeCall?.callId ?? "",
273
214
  "state": event.type
@@ -290,12 +231,12 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
290
231
  print("- Session ID: \(state.sessionId)")
291
232
  print("- All participants: \(String(describing: state.participants))")
292
233
  print("- Remote participants: \(String(describing: state.remoteParticipants))")
293
-
234
+
294
235
  // Update overlay and make visible when there's an active call
295
236
  self.overlayViewModel?.updateCall(newState)
296
237
  self.overlayView?.isHidden = false
297
238
  self.webView?.isOpaque = false
298
-
239
+
299
240
  // Notify that a call has started
300
241
  self.notifyListeners("callEvent", data: [
301
242
  "callId": newState?.cId ?? "",
@@ -306,7 +247,7 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
306
247
  self.overlayViewModel?.updateCall(nil)
307
248
  self.overlayView?.isHidden = true
308
249
  self.webView?.isOpaque = true
309
-
250
+
310
251
  // Notify that call has ended
311
252
  self.notifyListeners("callEvent", data: [
312
253
  "callId": newState?.cId ?? "",
@@ -319,57 +260,69 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
319
260
  }
320
261
  }
321
262
  }
322
-
263
+
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" }
269
+
270
+ if allResponded && allRejectedOrMissed {
271
+ print("All participants have rejected or missed the call")
272
+ call?.leave()
273
+ await MainActor.run {
274
+ self.overlayViewModel?.updateCall(nil)
275
+ self.overlayView?.isHidden = true
276
+ self.webView?.isOpaque = true
277
+ self.notifyListeners("callEvent", data: [
278
+ "callId": callCid,
279
+ "state": "ended",
280
+ "reason": "all_rejected_or_missed"
281
+ ])
282
+ }
283
+ }
284
+ }
285
+
323
286
  @objc func login(_ call: CAPPluginCall) {
324
287
  guard let token = call.getString("token"),
325
288
  let userId = call.getString("userId"),
326
- let name = call.getString("name"),
327
- let apiKey = call.getString("apiKey") else {
289
+ let name = call.getString("name") else {
328
290
  call.reject("Missing required parameters")
329
291
  return
330
292
  }
331
-
293
+
332
294
  let imageURL = call.getString("imageURL")
333
- let refreshTokenConfig = call.getObject("refreshToken")
334
- let refreshTokenURL = refreshTokenConfig?["url"] as? String
335
- let refreshTokenHeaders = refreshTokenConfig?["headers"] as? [String: String]
336
-
337
295
  let user = User(
338
296
  id: userId,
339
297
  name: name,
340
298
  imageURL: imageURL.flatMap { URL(string: $0) },
341
299
  customData: [:]
342
300
  )
343
-
301
+
344
302
  let credentials = UserCredentials(user: user, tokenValue: token)
345
303
  SecureUserRepository.shared.save(user: credentials)
346
-
347
- // Store API key and refresh config for later use
348
- self.refreshTokenURL = refreshTokenURL
349
- self.refreshTokenHeaders = refreshTokenHeaders
350
-
351
304
  // Initialize Stream Video with new credentials
352
305
  initializeStreamVideo()
353
-
306
+
354
307
  if state != .initialized {
355
308
  call.reject("Failed to initialize StreamVideo")
356
309
  return
357
310
  }
358
-
311
+
359
312
  // Update the CallOverlayView with new StreamVideo instance
360
313
  Task { @MainActor in
361
314
  self.overlayViewModel?.updateStreamVideo(self.streamVideo)
362
315
  }
363
-
316
+
364
317
  call.resolve([
365
318
  "success": true
366
319
  ])
367
320
  }
368
-
321
+
369
322
  @objc func logout(_ call: CAPPluginCall) {
370
323
  // Remove VOIP token from repository
371
324
  SecureUserRepository.shared.save(voipPushToken: nil)
372
-
325
+
373
326
  // Try to delete the device from Stream if we have the last token
374
327
  if let lastVoIPToken = lastVoIPToken {
375
328
  Task {
@@ -380,16 +333,16 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
380
333
  }
381
334
  }
382
335
  }
383
-
336
+
384
337
  // Cancel subscriptions
385
338
  tokenSubscription?.cancel()
386
339
  tokenSubscription = nil
387
340
  activeCallSubscription?.cancel()
388
341
  activeCallSubscription = nil
389
342
  lastVoIPToken = nil
390
-
343
+
391
344
  SecureUserRepository.shared.removeCurrentUser()
392
-
345
+
393
346
  // Update the CallOverlayView with nil StreamVideo instance
394
347
  Task { @MainActor in
395
348
  self.overlayViewModel?.updateCall(nil)
@@ -397,24 +350,41 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
397
350
  self.overlayView?.isHidden = true
398
351
  self.webView?.isOpaque = true
399
352
  }
400
-
353
+
401
354
  call.resolve([
402
355
  "success": true
403
356
  ])
404
357
  }
358
+
359
+ @objc func isCameraEnabled(_ call: CAPPluginCall) {
360
+ do {
361
+ try requireInitialized()
362
+ } catch {
363
+ call.reject("SDK not initialized")
364
+ }
365
+
366
+ if let activeCall = streamVideo?.state.activeCall {
367
+ call.resolve([
368
+ "enabled": activeCall.camera.status == .enabled
369
+ ])
370
+ } else {
371
+ call.reject("No active call")
372
+ }
373
+ }
405
374
 
406
375
  @objc func call(_ call: CAPPluginCall) {
407
- guard let userId = call.getString("userId") else {
408
- call.reject("Missing required parameter: userId")
376
+ guard let members = call.getArray("userIds", String.self) else {
377
+ call.reject("Missing required parameter: userIds (array of user IDs)")
378
+ return
379
+ }
380
+
381
+ if members.isEmpty {
382
+ call.reject("userIds array cannot be empty")
409
383
  return
410
384
  }
411
385
 
412
386
  // Initialize if needed
413
387
  if state == .notInitialized {
414
- guard let credentials = SecureUserRepository.shared.loadCurrentUser() else {
415
- call.reject("No user credentials found")
416
- return
417
- }
418
388
  initializeStreamVideo()
419
389
  if state != .initialized {
420
390
  call.reject("Failed to initialize StreamVideo")
@@ -436,16 +406,16 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
436
406
  print("Creating call:")
437
407
  print("- Call ID: \(callId)")
438
408
  print("- Call Type: \(callType)")
439
- print("- User ID: \(userId)")
409
+ print("- Users: \(members)")
440
410
  print("- Should Ring: \(shouldRing)")
441
411
 
442
412
  // Create the call object
443
413
  let streamCall = streamVideo?.call(callType: callType, callId: callId)
444
414
 
445
- // Start the call with the member
446
- print("Creating call with member...")
415
+ // Start the call with the members
416
+ print("Creating call with members...")
447
417
  try await streamCall?.create(
448
- memberIds: [userId],
418
+ memberIds: members,
449
419
  custom: [:],
450
420
  ring: shouldRing
451
421
  )
@@ -480,26 +450,21 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
480
450
  try requireInitialized()
481
451
 
482
452
  Task {
483
- do {
484
- if let activeCall = streamVideo?.state.activeCall {
485
- try await activeCall.leave()
486
-
487
- // Update view state instead of cleaning up
488
- await MainActor.run {
489
- self.overlayViewModel?.updateCall(nil)
490
- self.overlayView?.isHidden = true
491
- self.webView?.isOpaque = true
492
- }
453
+ if let activeCall = streamVideo?.state.activeCall {
454
+ activeCall.leave()
493
455
 
494
- call.resolve([
495
- "success": true
496
- ])
497
- } else {
498
- call.reject("No active call to end")
456
+ // Update view state instead of cleaning up
457
+ await MainActor.run {
458
+ self.overlayViewModel?.updateCall(nil)
459
+ self.overlayView?.isHidden = true
460
+ self.webView?.isOpaque = true
499
461
  }
500
- } catch {
501
- log.error("Error ending call: \(String(describing: error))")
502
- call.reject("Failed to end call: \(error.localizedDescription)")
462
+
463
+ call.resolve([
464
+ "success": true
465
+ ])
466
+ } else {
467
+ call.reject("No active call to end")
503
468
  }
504
469
  }
505
470
  } catch {
@@ -625,26 +590,10 @@ public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
625
590
  }
626
591
  print("Initializing with saved credentials for user: \(savedCredentials.user.name)")
627
592
 
628
- // Create a local reference to refreshToken to avoid capturing self
629
- let refreshTokenFn = self.refreshToken
630
-
631
593
  self.streamVideo = StreamVideo(
632
594
  apiKey: apiKey,
633
595
  user: savedCredentials.user,
634
- token: UserToken(stringLiteral: savedCredentials.tokenValue),
635
- tokenProvider: { result in
636
- print("attempt to refresh")
637
- DispatchQueue.global().async {
638
- do {
639
- let newToken = try refreshTokenFn()
640
- print("Refresh successful")
641
- result(.success(newToken))
642
- } catch {
643
- print("Refresh fail")
644
- result(.failure(error))
645
- }
646
- }
647
- }
596
+ token: UserToken(stringLiteral: savedCredentials.tokenValue)
648
597
  )
649
598
 
650
599
  state = .initialized
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-stream-call",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
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",
@@ -13,7 +13,7 @@
13
13
  "ios/Sources",
14
14
  "ios/Tests",
15
15
  "Package.swift",
16
- "StreamCall.podspec"
16
+ "CapgoCapacitorStreamCall.podspec"
17
17
  ],
18
18
  "author": "Martin Donadieu <martin@capgo.app>",
19
19
  "license": "MIT",