@capgo/capacitor-stream-call 0.0.2
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.
- package/Package.swift +31 -0
- package/README.md +340 -0
- package/StreamCall.podspec +19 -0
- package/android/build.gradle +74 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/ee/forgr/capacitor/streamcall/CallOverlayView.kt +281 -0
- package/android/src/main/java/ee/forgr/capacitor/streamcall/CustomNotificationHandler.kt +142 -0
- package/android/src/main/java/ee/forgr/capacitor/streamcall/IncomingCallView.kt +147 -0
- package/android/src/main/java/ee/forgr/capacitor/streamcall/RingtonePlayer.kt +164 -0
- package/android/src/main/java/ee/forgr/capacitor/streamcall/StreamCallPlugin.kt +1014 -0
- package/android/src/main/java/ee/forgr/capacitor/streamcall/TouchInterceptWrapper.kt +31 -0
- package/android/src/main/java/ee/forgr/capacitor/streamcall/UserRepository.kt +111 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/android/src/main/res/values/strings.xml +7 -0
- package/dist/docs.json +533 -0
- package/dist/esm/definitions.d.ts +169 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +32 -0
- package/dist/esm/web.js +323 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +337 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +339 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/StreamCallPlugin/CallOverlayView.swift +147 -0
- package/ios/Sources/StreamCallPlugin/CustomCallParticipantImageView.swift +60 -0
- package/ios/Sources/StreamCallPlugin/CustomCallView.swift +257 -0
- package/ios/Sources/StreamCallPlugin/CustomVideoParticipantsView.swift +107 -0
- package/ios/Sources/StreamCallPlugin/ParticipantsView.swift +206 -0
- package/ios/Sources/StreamCallPlugin/StreamCallPlugin.swift +722 -0
- package/ios/Sources/StreamCallPlugin/TouchInterceptView.swift +177 -0
- package/ios/Sources/StreamCallPlugin/UserRepository.swift +96 -0
- package/ios/Sources/StreamCallPlugin/WebviewNavigationDelegate.swift +68 -0
- package/ios/Tests/StreamCallPluginTests/StreamCallPluginTests.swift +15 -0
- package/package.json +96 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Capacitor
|
|
3
|
+
import StreamVideo
|
|
4
|
+
import StreamVideoSwiftUI
|
|
5
|
+
import SwiftUI
|
|
6
|
+
import Combine
|
|
7
|
+
import WebKit
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Please read the Capacitor iOS Plugin Development Guide
|
|
11
|
+
* here: https://capacitorjs.com/docs/plugins/ios
|
|
12
|
+
*/
|
|
13
|
+
@objc(StreamCallPlugin)
|
|
14
|
+
public class StreamCallPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
15
|
+
public let identifier = "StreamCallPlugin"
|
|
16
|
+
public let jsName = "StreamCall"
|
|
17
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
18
|
+
CAPPluginMethod(name: "loginMagicToken", returnType: CAPPluginReturnPromise),
|
|
19
|
+
CAPPluginMethod(name: "login", returnType: CAPPluginReturnPromise),
|
|
20
|
+
CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise),
|
|
21
|
+
CAPPluginMethod(name: "call", returnType: CAPPluginReturnPromise),
|
|
22
|
+
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise),
|
|
23
|
+
CAPPluginMethod(name: "setMicrophoneEnabled", returnType: CAPPluginReturnPromise),
|
|
24
|
+
CAPPluginMethod(name: "setCameraEnabled", returnType: CAPPluginReturnPromise),
|
|
25
|
+
CAPPluginMethod(name: "acceptCall", returnType: CAPPluginReturnPromise)
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
private enum State {
|
|
29
|
+
case notInitialized
|
|
30
|
+
case initializing
|
|
31
|
+
case initialized
|
|
32
|
+
}
|
|
33
|
+
private var apiKey: String?
|
|
34
|
+
private var state: State = .notInitialized
|
|
35
|
+
private static let tokenRefreshQueue = DispatchQueue(label: "stream.call.token.refresh")
|
|
36
|
+
private static let tokenRefreshSemaphore = DispatchSemaphore(value: 1)
|
|
37
|
+
private var currentToken: String?
|
|
38
|
+
private var tokenWaitSemaphore: DispatchSemaphore?
|
|
39
|
+
|
|
40
|
+
private var overlayView: UIView?
|
|
41
|
+
private var hostingController: UIHostingController<CallOverlayView>?
|
|
42
|
+
private var overlayViewModel: CallOverlayViewModel?
|
|
43
|
+
private var tokenSubscription: AnyCancellable?
|
|
44
|
+
private var activeCallSubscription: AnyCancellable?
|
|
45
|
+
private var lastVoIPToken: String?
|
|
46
|
+
private var touchInterceptView: TouchInterceptView?
|
|
47
|
+
|
|
48
|
+
private var streamVideo: StreamVideo?
|
|
49
|
+
|
|
50
|
+
@Injected(\.callKitAdapter) var callKitAdapter
|
|
51
|
+
@Injected(\.callKitPushNotificationAdapter) var callKitPushNotificationAdapter
|
|
52
|
+
|
|
53
|
+
private var refreshTokenURL: String?
|
|
54
|
+
private var refreshTokenHeaders: [String: String]?
|
|
55
|
+
|
|
56
|
+
private var webviewDelegate: WebviewNavigationDelegate?
|
|
57
|
+
|
|
58
|
+
override public func load() {
|
|
59
|
+
// Read API key from Info.plist
|
|
60
|
+
if let apiKey = Bundle.main.object(forInfoDictionaryKey: "CAPACITOR_STREAM_VIDEO_APIKEY") as? String {
|
|
61
|
+
self.apiKey = apiKey
|
|
62
|
+
}
|
|
63
|
+
if self.apiKey == nil {
|
|
64
|
+
fatalError("Cannot get apikey")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check if we have a logged in user for handling incoming calls
|
|
68
|
+
if let credentials = SecureUserRepository.shared.loadCurrentUser() {
|
|
69
|
+
print("Loading user for StreamCallPlugin: \(credentials.user.name)")
|
|
70
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
71
|
+
self.initializeStreamVideo()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Create and set the navigation delegate
|
|
76
|
+
self.webviewDelegate = WebviewNavigationDelegate(
|
|
77
|
+
wrappedDelegate: self.webView?.navigationDelegate,
|
|
78
|
+
onSetupOverlay: { [weak self] in
|
|
79
|
+
guard let self = self else { return }
|
|
80
|
+
print("Attempting to setup call view")
|
|
81
|
+
|
|
82
|
+
self.setupViews()
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self.webView?.navigationDelegate = self.webviewDelegate
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// private func cleanupStreamVideo() {
|
|
90
|
+
// // Cancel subscriptions
|
|
91
|
+
// tokenSubscription?.cancel()
|
|
92
|
+
// tokenSubscription = nil
|
|
93
|
+
// activeCallSubscription?.cancel()
|
|
94
|
+
// activeCallSubscription = nil
|
|
95
|
+
// lastVoIPToken = nil
|
|
96
|
+
//
|
|
97
|
+
// // Cleanup UI
|
|
98
|
+
// Task { @MainActor in
|
|
99
|
+
// self.overlayViewModel?.updateCall(nil)
|
|
100
|
+
// self.overlayViewModel?.updateStreamVideo(nil)
|
|
101
|
+
// self.overlayView?.removeFromSuperview()
|
|
102
|
+
// self.overlayView = nil
|
|
103
|
+
// self.hostingController = nil
|
|
104
|
+
// self.overlayViewModel = nil
|
|
105
|
+
//
|
|
106
|
+
// // Reset webview
|
|
107
|
+
// self.webView?.isOpaque = true
|
|
108
|
+
// self.webView?.backgroundColor = .white
|
|
109
|
+
// self.webView?.scrollView.backgroundColor = .white
|
|
110
|
+
// }
|
|
111
|
+
//
|
|
112
|
+
// state = .notInitialized
|
|
113
|
+
// }
|
|
114
|
+
|
|
115
|
+
private func requireInitialized() throws {
|
|
116
|
+
guard state == .initialized else {
|
|
117
|
+
throw NSError(domain: "StreamCallPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "StreamVideo not initialized"])
|
|
118
|
+
}
|
|
119
|
+
}
|
|
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
|
+
@objc func loginMagicToken(_ call: CAPPluginCall) {
|
|
218
|
+
guard let token = call.getString("token") else {
|
|
219
|
+
call.reject("Missing token parameter")
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
print("loginMagicToken received token")
|
|
224
|
+
currentToken = token
|
|
225
|
+
tokenWaitSemaphore?.signal()
|
|
226
|
+
call.resolve()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private func setupTokenSubscription() {
|
|
230
|
+
// Cancel existing subscription if any
|
|
231
|
+
tokenSubscription?.cancel()
|
|
232
|
+
|
|
233
|
+
// Create new subscription
|
|
234
|
+
tokenSubscription = callKitPushNotificationAdapter.$deviceToken.sink { [weak self] (updatedDeviceToken: String) in
|
|
235
|
+
guard let self = self else { return }
|
|
236
|
+
Task {
|
|
237
|
+
do {
|
|
238
|
+
print("Setting up token subscription")
|
|
239
|
+
try self.requireInitialized()
|
|
240
|
+
if let lastVoIPToken = self.lastVoIPToken, !lastVoIPToken.isEmpty {
|
|
241
|
+
print("Deleting device: \(lastVoIPToken)")
|
|
242
|
+
try await self.streamVideo?.deleteDevice(id: lastVoIPToken)
|
|
243
|
+
}
|
|
244
|
+
if !updatedDeviceToken.isEmpty {
|
|
245
|
+
print("Setting voip device: \(updatedDeviceToken)")
|
|
246
|
+
try await self.streamVideo?.setVoipDevice(id: updatedDeviceToken)
|
|
247
|
+
// Save the token to our secure storage
|
|
248
|
+
print("Saving voip token: \(updatedDeviceToken)")
|
|
249
|
+
SecureUserRepository.shared.save(voipPushToken: updatedDeviceToken)
|
|
250
|
+
}
|
|
251
|
+
self.lastVoIPToken = updatedDeviceToken
|
|
252
|
+
} catch {
|
|
253
|
+
log.error("Error updating VOIP token: \(String(describing: error))")
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private func setupActiveCallSubscription() {
|
|
260
|
+
if let streamVideo = streamVideo {
|
|
261
|
+
Task {
|
|
262
|
+
for await event in streamVideo.subscribe() {
|
|
263
|
+
print("Event", event)
|
|
264
|
+
if let ringingEvent = event.rawValue as? CallRingEvent {
|
|
265
|
+
notifyListeners("callEvent", data: [
|
|
266
|
+
"callId": ringingEvent.callCid,
|
|
267
|
+
"state": "ringing"
|
|
268
|
+
])
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
notifyListeners("callEvent", data: [
|
|
272
|
+
"callId": streamVideo.state.activeCall?.callId ?? "",
|
|
273
|
+
"state": event.type
|
|
274
|
+
])
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Cancel existing subscription if any
|
|
279
|
+
activeCallSubscription?.cancel()
|
|
280
|
+
// Create new subscription
|
|
281
|
+
activeCallSubscription = streamVideo?.state.$activeCall.sink { [weak self] newState in
|
|
282
|
+
guard let self = self else { return }
|
|
283
|
+
Task { @MainActor in
|
|
284
|
+
do {
|
|
285
|
+
try self.requireInitialized()
|
|
286
|
+
print("Call State Update:")
|
|
287
|
+
print("- Call is nil: \(newState == nil)")
|
|
288
|
+
if let state = newState?.state {
|
|
289
|
+
print("- state: \(state)")
|
|
290
|
+
print("- Session ID: \(state.sessionId)")
|
|
291
|
+
print("- All participants: \(String(describing: state.participants))")
|
|
292
|
+
print("- Remote participants: \(String(describing: state.remoteParticipants))")
|
|
293
|
+
|
|
294
|
+
// Update overlay and make visible when there's an active call
|
|
295
|
+
self.overlayViewModel?.updateCall(newState)
|
|
296
|
+
self.overlayView?.isHidden = false
|
|
297
|
+
self.webView?.isOpaque = false
|
|
298
|
+
|
|
299
|
+
// Notify that a call has started
|
|
300
|
+
self.notifyListeners("callEvent", data: [
|
|
301
|
+
"callId": newState?.cId ?? "",
|
|
302
|
+
"state": "joined"
|
|
303
|
+
])
|
|
304
|
+
} else {
|
|
305
|
+
// If newState is nil, hide overlay and clear call
|
|
306
|
+
self.overlayViewModel?.updateCall(nil)
|
|
307
|
+
self.overlayView?.isHidden = true
|
|
308
|
+
self.webView?.isOpaque = true
|
|
309
|
+
|
|
310
|
+
// Notify that call has ended
|
|
311
|
+
self.notifyListeners("callEvent", data: [
|
|
312
|
+
"callId": newState?.cId ?? "",
|
|
313
|
+
"state": "left"
|
|
314
|
+
])
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
log.error("Error handling call state update: \(String(describing: error))")
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
@objc func login(_ call: CAPPluginCall) {
|
|
324
|
+
guard let token = call.getString("token"),
|
|
325
|
+
let userId = call.getString("userId"),
|
|
326
|
+
let name = call.getString("name"),
|
|
327
|
+
let apiKey = call.getString("apiKey") else {
|
|
328
|
+
call.reject("Missing required parameters")
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
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
|
+
let user = User(
|
|
338
|
+
id: userId,
|
|
339
|
+
name: name,
|
|
340
|
+
imageURL: imageURL.flatMap { URL(string: $0) },
|
|
341
|
+
customData: [:]
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
let credentials = UserCredentials(user: user, tokenValue: token)
|
|
345
|
+
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
|
+
// Initialize Stream Video with new credentials
|
|
352
|
+
initializeStreamVideo()
|
|
353
|
+
|
|
354
|
+
if state != .initialized {
|
|
355
|
+
call.reject("Failed to initialize StreamVideo")
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Update the CallOverlayView with new StreamVideo instance
|
|
360
|
+
Task { @MainActor in
|
|
361
|
+
self.overlayViewModel?.updateStreamVideo(self.streamVideo)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
call.resolve([
|
|
365
|
+
"success": true
|
|
366
|
+
])
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
@objc func logout(_ call: CAPPluginCall) {
|
|
370
|
+
// Remove VOIP token from repository
|
|
371
|
+
SecureUserRepository.shared.save(voipPushToken: nil)
|
|
372
|
+
|
|
373
|
+
// Try to delete the device from Stream if we have the last token
|
|
374
|
+
if let lastVoIPToken = lastVoIPToken {
|
|
375
|
+
Task {
|
|
376
|
+
do {
|
|
377
|
+
try await streamVideo?.deleteDevice(id: lastVoIPToken)
|
|
378
|
+
} catch {
|
|
379
|
+
log.error("Error deleting device during logout: \(String(describing: error))")
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Cancel subscriptions
|
|
385
|
+
tokenSubscription?.cancel()
|
|
386
|
+
tokenSubscription = nil
|
|
387
|
+
activeCallSubscription?.cancel()
|
|
388
|
+
activeCallSubscription = nil
|
|
389
|
+
lastVoIPToken = nil
|
|
390
|
+
|
|
391
|
+
SecureUserRepository.shared.removeCurrentUser()
|
|
392
|
+
|
|
393
|
+
// Update the CallOverlayView with nil StreamVideo instance
|
|
394
|
+
Task { @MainActor in
|
|
395
|
+
self.overlayViewModel?.updateCall(nil)
|
|
396
|
+
self.overlayViewModel?.updateStreamVideo(nil)
|
|
397
|
+
self.overlayView?.isHidden = true
|
|
398
|
+
self.webView?.isOpaque = true
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
call.resolve([
|
|
402
|
+
"success": true
|
|
403
|
+
])
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
@objc func call(_ call: CAPPluginCall) {
|
|
407
|
+
guard let userId = call.getString("userId") else {
|
|
408
|
+
call.reject("Missing required parameter: userId")
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Initialize if needed
|
|
413
|
+
if state == .notInitialized {
|
|
414
|
+
guard let credentials = SecureUserRepository.shared.loadCurrentUser() else {
|
|
415
|
+
call.reject("No user credentials found")
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
initializeStreamVideo()
|
|
419
|
+
if state != .initialized {
|
|
420
|
+
call.reject("Failed to initialize StreamVideo")
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
do {
|
|
426
|
+
try requireInitialized()
|
|
427
|
+
|
|
428
|
+
let callType = call.getString("type") ?? "default"
|
|
429
|
+
let shouldRing = call.getBool("ring") ?? true
|
|
430
|
+
|
|
431
|
+
// Generate a unique call ID
|
|
432
|
+
let callId = UUID().uuidString
|
|
433
|
+
|
|
434
|
+
Task {
|
|
435
|
+
do {
|
|
436
|
+
print("Creating call:")
|
|
437
|
+
print("- Call ID: \(callId)")
|
|
438
|
+
print("- Call Type: \(callType)")
|
|
439
|
+
print("- User ID: \(userId)")
|
|
440
|
+
print("- Should Ring: \(shouldRing)")
|
|
441
|
+
|
|
442
|
+
// Create the call object
|
|
443
|
+
let streamCall = streamVideo?.call(callType: callType, callId: callId)
|
|
444
|
+
|
|
445
|
+
// Start the call with the member
|
|
446
|
+
print("Creating call with member...")
|
|
447
|
+
try await streamCall?.create(
|
|
448
|
+
memberIds: [userId],
|
|
449
|
+
custom: [:],
|
|
450
|
+
ring: shouldRing
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
// Join the call
|
|
454
|
+
print("Joining call...")
|
|
455
|
+
try await streamCall?.join(create: false)
|
|
456
|
+
print("Successfully joined call")
|
|
457
|
+
|
|
458
|
+
// Update the CallOverlayView with the active call
|
|
459
|
+
await MainActor.run {
|
|
460
|
+
self.overlayViewModel?.updateCall(streamCall)
|
|
461
|
+
self.overlayView?.isHidden = false
|
|
462
|
+
self.webView?.isOpaque = false
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
call.resolve([
|
|
466
|
+
"success": true
|
|
467
|
+
])
|
|
468
|
+
} catch {
|
|
469
|
+
log.error("Error making call: \(String(describing: error))")
|
|
470
|
+
call.reject("Failed to make call: \(error.localizedDescription)")
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
} catch {
|
|
474
|
+
call.reject("StreamVideo not initialized")
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
@objc func endCall(_ call: CAPPluginCall) {
|
|
479
|
+
do {
|
|
480
|
+
try requireInitialized()
|
|
481
|
+
|
|
482
|
+
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
|
+
}
|
|
493
|
+
|
|
494
|
+
call.resolve([
|
|
495
|
+
"success": true
|
|
496
|
+
])
|
|
497
|
+
} else {
|
|
498
|
+
call.reject("No active call to end")
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
log.error("Error ending call: \(String(describing: error))")
|
|
502
|
+
call.reject("Failed to end call: \(error.localizedDescription)")
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
} catch {
|
|
506
|
+
call.reject("StreamVideo not initialized")
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
@objc func setMicrophoneEnabled(_ call: CAPPluginCall) {
|
|
511
|
+
guard let enabled = call.getBool("enabled") else {
|
|
512
|
+
call.reject("Missing required parameter: enabled")
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
do {
|
|
517
|
+
try requireInitialized()
|
|
518
|
+
|
|
519
|
+
Task {
|
|
520
|
+
do {
|
|
521
|
+
if let activeCall = streamVideo?.state.activeCall {
|
|
522
|
+
if enabled {
|
|
523
|
+
try await activeCall.microphone.enable()
|
|
524
|
+
} else {
|
|
525
|
+
try await activeCall.microphone.disable()
|
|
526
|
+
}
|
|
527
|
+
call.resolve([
|
|
528
|
+
"success": true
|
|
529
|
+
])
|
|
530
|
+
} else {
|
|
531
|
+
call.reject("No active call")
|
|
532
|
+
}
|
|
533
|
+
} catch {
|
|
534
|
+
log.error("Error setting microphone: \(String(describing: error))")
|
|
535
|
+
call.reject("Failed to set microphone: \(error.localizedDescription)")
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
} catch {
|
|
539
|
+
call.reject("StreamVideo not initialized")
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
@objc func setCameraEnabled(_ call: CAPPluginCall) {
|
|
544
|
+
guard let enabled = call.getBool("enabled") else {
|
|
545
|
+
call.reject("Missing required parameter: enabled")
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
do {
|
|
550
|
+
try requireInitialized()
|
|
551
|
+
|
|
552
|
+
Task {
|
|
553
|
+
do {
|
|
554
|
+
if let activeCall = streamVideo?.state.activeCall {
|
|
555
|
+
if enabled {
|
|
556
|
+
try await activeCall.camera.enable()
|
|
557
|
+
} else {
|
|
558
|
+
try await activeCall.camera.disable()
|
|
559
|
+
}
|
|
560
|
+
call.resolve([
|
|
561
|
+
"success": true
|
|
562
|
+
])
|
|
563
|
+
} else {
|
|
564
|
+
call.reject("No active call")
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
log.error("Error setting camera: \(String(describing: error))")
|
|
568
|
+
call.reject("Failed to set camera: \(error.localizedDescription)")
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
} catch {
|
|
572
|
+
call.reject("StreamVideo not initialized")
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
@objc func acceptCall(_ call: CAPPluginCall) {
|
|
577
|
+
do {
|
|
578
|
+
try requireInitialized()
|
|
579
|
+
|
|
580
|
+
Task {
|
|
581
|
+
do {
|
|
582
|
+
|
|
583
|
+
// Get the call object for the given ID
|
|
584
|
+
let streamCall = streamVideo?.state.ringingCall
|
|
585
|
+
if streamCall == nil {
|
|
586
|
+
call.reject("Failed to accept call as there is no ringing call")
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Join the call
|
|
591
|
+
print("Accepting and joining call \(streamCall!.cId)...")
|
|
592
|
+
try await streamCall?.accept()
|
|
593
|
+
try await streamCall?.join(create: false)
|
|
594
|
+
print("Successfully joined call")
|
|
595
|
+
|
|
596
|
+
// Update the CallOverlayView with the active call
|
|
597
|
+
await MainActor.run {
|
|
598
|
+
self.overlayViewModel?.updateCall(streamCall)
|
|
599
|
+
self.overlayView?.isHidden = false
|
|
600
|
+
self.webView?.isOpaque = false
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
call.resolve([
|
|
604
|
+
"success": true
|
|
605
|
+
])
|
|
606
|
+
} catch {
|
|
607
|
+
log.error("Error accepting call: \(String(describing: error))")
|
|
608
|
+
call.reject("Failed to accept call: \(error.localizedDescription)")
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
call.reject("StreamVideo not initialized")
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private func initializeStreamVideo() {
|
|
617
|
+
state = .initializing
|
|
618
|
+
|
|
619
|
+
// Try to get user credentials from repository
|
|
620
|
+
guard let savedCredentials = SecureUserRepository.shared.loadCurrentUser(),
|
|
621
|
+
let apiKey = self.apiKey else {
|
|
622
|
+
print("No saved credentials or API key found, skipping initialization")
|
|
623
|
+
state = .notInitialized
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
print("Initializing with saved credentials for user: \(savedCredentials.user.name)")
|
|
627
|
+
|
|
628
|
+
// Create a local reference to refreshToken to avoid capturing self
|
|
629
|
+
let refreshTokenFn = self.refreshToken
|
|
630
|
+
|
|
631
|
+
self.streamVideo = StreamVideo(
|
|
632
|
+
apiKey: apiKey,
|
|
633
|
+
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
|
+
}
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
state = .initialized
|
|
651
|
+
callKitAdapter.streamVideo = self.streamVideo
|
|
652
|
+
callKitAdapter.availabilityPolicy = .always
|
|
653
|
+
|
|
654
|
+
// Setup subscriptions for new StreamVideo instance
|
|
655
|
+
setupActiveCallSubscription()
|
|
656
|
+
setupTokenSubscription()
|
|
657
|
+
|
|
658
|
+
// Register for incoming calls
|
|
659
|
+
callKitAdapter.registerForIncomingCalls()
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private func setupViews() {
|
|
663
|
+
guard let webView = self.webView,
|
|
664
|
+
let parent = webView.superview else { return }
|
|
665
|
+
|
|
666
|
+
// Create TouchInterceptView
|
|
667
|
+
let touchInterceptView = TouchInterceptView(frame: parent.bounds)
|
|
668
|
+
touchInterceptView.translatesAutoresizingMaskIntoConstraints = false
|
|
669
|
+
self.touchInterceptView = touchInterceptView
|
|
670
|
+
|
|
671
|
+
// Remove webView from its parent
|
|
672
|
+
webView.removeFromSuperview()
|
|
673
|
+
|
|
674
|
+
// Add TouchInterceptView to the parent
|
|
675
|
+
parent.addSubview(touchInterceptView)
|
|
676
|
+
|
|
677
|
+
// Setup TouchInterceptView constraints
|
|
678
|
+
NSLayoutConstraint.activate([
|
|
679
|
+
touchInterceptView.topAnchor.constraint(equalTo: parent.topAnchor),
|
|
680
|
+
touchInterceptView.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
|
681
|
+
touchInterceptView.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
|
|
682
|
+
touchInterceptView.trailingAnchor.constraint(equalTo: parent.trailingAnchor)
|
|
683
|
+
])
|
|
684
|
+
|
|
685
|
+
// Configure webview for transparency
|
|
686
|
+
webView.isOpaque = true
|
|
687
|
+
webView.backgroundColor = .clear
|
|
688
|
+
webView.scrollView.backgroundColor = .clear
|
|
689
|
+
webView.translatesAutoresizingMaskIntoConstraints = false
|
|
690
|
+
|
|
691
|
+
// Create SwiftUI view with view model
|
|
692
|
+
let (hostingController, viewModel) = CallOverlayView.create(streamVideo: self.streamVideo)
|
|
693
|
+
hostingController.view.backgroundColor = .clear
|
|
694
|
+
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
695
|
+
|
|
696
|
+
self.hostingController = hostingController
|
|
697
|
+
self.overlayViewModel = viewModel
|
|
698
|
+
self.overlayView = hostingController.view
|
|
699
|
+
|
|
700
|
+
if let overlayView = self.overlayView {
|
|
701
|
+
// Setup the views in TouchInterceptView
|
|
702
|
+
touchInterceptView.setupWithWebView(webView, overlayView: overlayView)
|
|
703
|
+
|
|
704
|
+
// Setup constraints for webView
|
|
705
|
+
NSLayoutConstraint.activate([
|
|
706
|
+
webView.topAnchor.constraint(equalTo: touchInterceptView.topAnchor),
|
|
707
|
+
webView.bottomAnchor.constraint(equalTo: touchInterceptView.bottomAnchor),
|
|
708
|
+
webView.leadingAnchor.constraint(equalTo: touchInterceptView.leadingAnchor),
|
|
709
|
+
webView.trailingAnchor.constraint(equalTo: touchInterceptView.trailingAnchor)
|
|
710
|
+
])
|
|
711
|
+
|
|
712
|
+
// Setup constraints for overlayView
|
|
713
|
+
let safeGuide = touchInterceptView.safeAreaLayoutGuide
|
|
714
|
+
NSLayoutConstraint.activate([
|
|
715
|
+
overlayView.topAnchor.constraint(equalTo: safeGuide.topAnchor),
|
|
716
|
+
overlayView.bottomAnchor.constraint(equalTo: safeGuide.bottomAnchor),
|
|
717
|
+
overlayView.leadingAnchor.constraint(equalTo: safeGuide.leadingAnchor),
|
|
718
|
+
overlayView.trailingAnchor.constraint(equalTo: safeGuide.trailingAnchor)
|
|
719
|
+
])
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|