expo-callkit-telecom 0.1.0

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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +197 -0
  3. package/android/build.gradle +32 -0
  4. package/android/src/main/AndroidManifest.xml +33 -0
  5. package/android/src/main/java/expo/modules/callkittelecom/ExpoCallKitTelecomModule.kt +384 -0
  6. package/android/src/main/java/expo/modules/callkittelecom/IncomingCallActivity.kt +275 -0
  7. package/android/src/main/java/expo/modules/callkittelecom/events/CallEventEmitter.kt +151 -0
  8. package/android/src/main/java/expo/modules/callkittelecom/events/CallEvents.kt +59 -0
  9. package/android/src/main/java/expo/modules/callkittelecom/managers/CallAudioManager.kt +361 -0
  10. package/android/src/main/java/expo/modules/callkittelecom/managers/CallManager.kt +891 -0
  11. package/android/src/main/java/expo/modules/callkittelecom/managers/CallNotificationManager.kt +445 -0
  12. package/android/src/main/java/expo/modules/callkittelecom/managers/CaptureSessionManager.kt +27 -0
  13. package/android/src/main/java/expo/modules/callkittelecom/managers/DialtonePlayer.kt +171 -0
  14. package/android/src/main/java/expo/modules/callkittelecom/managers/FulfillRequestManager.kt +150 -0
  15. package/android/src/main/java/expo/modules/callkittelecom/managers/VoIPPushManager.kt +54 -0
  16. package/android/src/main/java/expo/modules/callkittelecom/models/CallModels.kt +269 -0
  17. package/android/src/main/java/expo/modules/callkittelecom/services/CallNotificationReceiver.kt +54 -0
  18. package/android/src/main/java/expo/modules/callkittelecom/services/ExpoCallKitTelecomMessagingService.kt +161 -0
  19. package/android/src/main/java/expo/modules/callkittelecom/store/CallStore.kt +181 -0
  20. package/android/src/main/java/expo/modules/callkittelecom/utils/CallKitTelecomLog.kt +52 -0
  21. package/android/src/main/java/expo/modules/callkittelecom/utils/PermissionUtils.kt +28 -0
  22. package/android/src/main/res/drawable/expo_callkit_telecom_bg_answer.xml +9 -0
  23. package/android/src/main/res/drawable/expo_callkit_telecom_bg_avatar.xml +5 -0
  24. package/android/src/main/res/drawable/expo_callkit_telecom_bg_decline.xml +9 -0
  25. package/android/src/main/res/drawable/expo_callkit_telecom_ic_answer.xml +9 -0
  26. package/android/src/main/res/drawable/expo_callkit_telecom_ic_decline.xml +9 -0
  27. package/android/src/main/res/drawable/expo_callkit_telecom_ic_videocam.xml +9 -0
  28. package/android/src/main/res/layout/activity_incoming_call.xml +169 -0
  29. package/app.json +8 -0
  30. package/app.plugin.js +1 -0
  31. package/build/Calls.d.ts +577 -0
  32. package/build/Calls.d.ts.map +1 -0
  33. package/build/Calls.js +715 -0
  34. package/build/Calls.js.map +1 -0
  35. package/build/Calls.types.d.ts +203 -0
  36. package/build/Calls.types.d.ts.map +1 -0
  37. package/build/Calls.types.js +2 -0
  38. package/build/Calls.types.js.map +1 -0
  39. package/build/ExpoCallKitTelecomModule.d.ts +3 -0
  40. package/build/ExpoCallKitTelecomModule.d.ts.map +1 -0
  41. package/build/ExpoCallKitTelecomModule.js +4 -0
  42. package/build/ExpoCallKitTelecomModule.js.map +1 -0
  43. package/build/hooks/index.d.ts +2 -0
  44. package/build/hooks/index.d.ts.map +1 -0
  45. package/build/hooks/index.js +2 -0
  46. package/build/hooks/index.js.map +1 -0
  47. package/build/hooks/useVoIPPushToken.d.ts +14 -0
  48. package/build/hooks/useVoIPPushToken.d.ts.map +1 -0
  49. package/build/hooks/useVoIPPushToken.js +26 -0
  50. package/build/hooks/useVoIPPushToken.js.map +1 -0
  51. package/build/index.d.ts +4 -0
  52. package/build/index.d.ts.map +1 -0
  53. package/build/index.js +4 -0
  54. package/build/index.js.map +1 -0
  55. package/expo-module.config.json +10 -0
  56. package/ios/AppDelegateSubscriber.swift +93 -0
  57. package/ios/ExpoCallKitTelecom.podspec +31 -0
  58. package/ios/ExpoCallKitTelecomLogger.swift +55 -0
  59. package/ios/ExpoCallKitTelecomModule.swift +503 -0
  60. package/ios/Managers/AudioManager.swift +363 -0
  61. package/ios/Managers/CallEventEmitter.swift +199 -0
  62. package/ios/Managers/CallManager+CXProviderDelegate.swift +195 -0
  63. package/ios/Managers/CallManager.swift +714 -0
  64. package/ios/Managers/CaptureSessionManager.swift +54 -0
  65. package/ios/Managers/DialtonePlayer.swift +126 -0
  66. package/ios/Managers/FulfillRequestManager.swift +154 -0
  67. package/ios/Managers/VoIPPushManager+PKPushRegistryDelegate.swift +123 -0
  68. package/ios/Managers/VoIPPushManager.swift +58 -0
  69. package/ios/Models/CallEvents.swift +263 -0
  70. package/ios/Models/CallOptions.swift +15 -0
  71. package/ios/Models/CallParticipant.swift +37 -0
  72. package/ios/Models/CallSession.swift +80 -0
  73. package/ios/Models/IncomingCallEvent.swift +196 -0
  74. package/ios/Stores/CallStore.swift +149 -0
  75. package/package.json +56 -0
  76. package/plugin/build/constants.d.ts +3 -0
  77. package/plugin/build/constants.js +7 -0
  78. package/plugin/build/withExpoCallKitTelecom.d.ts +67 -0
  79. package/plugin/build/withExpoCallKitTelecom.js +16 -0
  80. package/plugin/build/withExpoCallKitTelecomAndroid.d.ts +3 -0
  81. package/plugin/build/withExpoCallKitTelecomAndroid.js +177 -0
  82. package/plugin/build/withExpoCallKitTelecomIos.d.ts +3 -0
  83. package/plugin/build/withExpoCallKitTelecomIos.js +195 -0
  84. package/plugin/src/constants.ts +4 -0
  85. package/plugin/src/withExpoCallKitTelecom.ts +83 -0
  86. package/plugin/src/withExpoCallKitTelecomAndroid.ts +293 -0
  87. package/plugin/src/withExpoCallKitTelecomIos.ts +276 -0
  88. package/src/Calls.ts +848 -0
  89. package/src/Calls.types.ts +275 -0
  90. package/src/ExpoCallKitTelecomModule.ts +4 -0
  91. package/src/hooks/index.ts +1 -0
  92. package/src/hooks/useVoIPPushToken.ts +34 -0
  93. package/src/index.ts +3 -0
@@ -0,0 +1,263 @@
1
+ import AVFoundation
2
+ import CallKit
3
+ import Foundation
4
+
5
+ /// Protocol for events that can be sent to JS.
6
+ protocol CallEvent {
7
+ static var name: String { get }
8
+ var body: [String: Any] { get }
9
+ }
10
+
11
+ // MARK: - Constants
12
+
13
+ private enum CallEventKeys {
14
+ static let id = "id"
15
+ }
16
+
17
+ // MARK: - Call Session Events
18
+
19
+ struct CallSessionAddedEvent: CallEvent {
20
+ static let name = "onCallSessionAdded"
21
+
22
+ let session: CallSession
23
+
24
+ var body: [String: Any] {
25
+ ["session": session.toDictionary()]
26
+ }
27
+ }
28
+
29
+ struct CallSessionUpdatedEvent: CallEvent {
30
+ static let name = "onCallSessionUpdated"
31
+
32
+ let session: CallSession
33
+
34
+ var body: [String: Any] {
35
+ ["session": session.toDictionary()]
36
+ }
37
+ }
38
+
39
+ struct CallSessionRemovedEvent: CallEvent {
40
+ static let name = "onCallSessionRemoved"
41
+
42
+ let id: UUID
43
+
44
+ var body: [String: Any] {
45
+ [CallEventKeys.id: id.uuidString]
46
+ }
47
+ }
48
+
49
+ // MARK: - Audio Session Events
50
+
51
+ struct AudioSessionCallInfo {
52
+ let id: UUID
53
+ let status: CallSession.Status
54
+
55
+ init(from session: CallSession) {
56
+ self.id = session.id
57
+ self.status = session.status
58
+ }
59
+
60
+ func toDictionary() -> [String: Any] {
61
+ [
62
+ "id": id.uuidString,
63
+ "status": status.rawValue,
64
+ ]
65
+ }
66
+ }
67
+
68
+ struct AudioSessionActivatedEvent: CallEvent {
69
+ static let name = "onAudioSessionActivated"
70
+
71
+ let calls: [AudioSessionCallInfo]
72
+
73
+ var body: [String: Any] {
74
+ ["calls": calls.map { $0.toDictionary() }]
75
+ }
76
+ }
77
+
78
+ struct AudioSessionDeactivatedEvent: CallEvent {
79
+ static let name = "onAudioSessionDeactivated"
80
+
81
+ let calls: [AudioSessionCallInfo]
82
+
83
+ var body: [String: Any] {
84
+ ["calls": calls.map { $0.toDictionary() }]
85
+ }
86
+ }
87
+
88
+ struct AudioRouteChangedEvent: CallEvent {
89
+ static let name = "onAudioRouteChanged"
90
+
91
+ let inputs: [[String: String]]
92
+ let outputs: [[String: String]]
93
+
94
+ var body: [String: Any] {
95
+ [
96
+ "currentRoute": [
97
+ "inputs": inputs,
98
+ "outputs": outputs,
99
+ ]
100
+ ]
101
+ }
102
+ }
103
+
104
+ // MARK: - Call Intent Events
105
+
106
+ struct CallIntentReceivedEvent: CallEvent {
107
+ static let name = "onCallIntentReceived"
108
+
109
+ let handle: String
110
+ let handleType: String
111
+ let hasVideo: Bool
112
+
113
+ var body: [String: Any] {
114
+ [
115
+ "handle": handle,
116
+ "handleType": handleType,
117
+ "hasVideo": hasVideo,
118
+ ]
119
+ }
120
+ }
121
+
122
+ // MARK: - Call Action Events
123
+
124
+ struct IncomingCallReportedEvent: CallEvent {
125
+ static let name = "onIncomingCallReported"
126
+
127
+ let id: UUID
128
+
129
+ var body: [String: Any] {
130
+ [CallEventKeys.id: id.uuidString]
131
+ }
132
+ }
133
+
134
+ struct OutgoingCallStartedEvent: CallEvent {
135
+ static let name = "onOutgoingCallStarted"
136
+
137
+ let id: UUID
138
+
139
+ var body: [String: Any] {
140
+ [CallEventKeys.id: id.uuidString]
141
+ }
142
+ }
143
+
144
+ struct CallAnsweredEvent: CallEvent {
145
+ static let name = "onCallAnswered"
146
+
147
+ let id: UUID
148
+ let requestId: UUID
149
+
150
+ var body: [String: Any] {
151
+ [
152
+ CallEventKeys.id: id.uuidString,
153
+ "requestId": requestId.uuidString,
154
+ ]
155
+ }
156
+ }
157
+
158
+ struct CallEndedEvent: CallEvent {
159
+ static let name = "onCallEnded"
160
+
161
+ let id: UUID
162
+
163
+ var body: [String: Any] {
164
+ [CallEventKeys.id: id.uuidString]
165
+ }
166
+ }
167
+
168
+ struct CallReportedEnded: CallEvent {
169
+ static let name = "onCallReportedEnded"
170
+
171
+ let id: UUID
172
+ let reason: CXCallEndedReason
173
+
174
+ var body: [String: Any] {
175
+ [
176
+ CallEventKeys.id: id.uuidString,
177
+ "reason": Self.reasonString(for: reason),
178
+ ]
179
+ }
180
+
181
+ private static func reasonString(for reason: CXCallEndedReason) -> String {
182
+ switch reason {
183
+ case .failed: return "failed"
184
+ case .remoteEnded: return "remoteEnded"
185
+ case .unanswered: return "unanswered"
186
+ case .answeredElsewhere: return "answeredElsewhere"
187
+ case .declinedElsewhere: return "declinedElsewhere"
188
+ @unknown default: return "unknown"
189
+ }
190
+ }
191
+ }
192
+
193
+ struct SetMutedActionEvent: CallEvent {
194
+ static let name = "onSetMutedAction"
195
+
196
+ let id: UUID
197
+ let isMuted: Bool
198
+
199
+ var body: [String: Any] {
200
+ [
201
+ CallEventKeys.id: id.uuidString,
202
+ "isMuted": isMuted,
203
+ ]
204
+ }
205
+ }
206
+
207
+ struct SetHeldActionEvent: CallEvent {
208
+ static let name = "onSetHeldAction"
209
+
210
+ let id: UUID
211
+ let isOnHold: Bool
212
+
213
+ var body: [String: Any] {
214
+ [
215
+ CallEventKeys.id: id.uuidString,
216
+ "isOnHold": isOnHold,
217
+ ]
218
+ }
219
+ }
220
+
221
+ struct DTMFEvent: CallEvent {
222
+ static let name = "onDTMF"
223
+
224
+ let id: UUID
225
+ let digits: String
226
+
227
+ var body: [String: Any] {
228
+ [
229
+ CallEventKeys.id: id.uuidString,
230
+ "digits": digits,
231
+ ]
232
+ }
233
+ }
234
+
235
+ struct VideoChangedEvent: CallEvent {
236
+ static let name = "onVideoChanged"
237
+
238
+ let id: UUID
239
+ let hasVideo: Bool
240
+
241
+ var body: [String: Any] {
242
+ [
243
+ CallEventKeys.id: id.uuidString,
244
+ "hasVideo": hasVideo,
245
+ ]
246
+ }
247
+ }
248
+
249
+ // MARK: - VoIP Push Events
250
+
251
+ struct VoIPPushTokenUpdatedEvent: CallEvent {
252
+ static let name = "onVoIPPushTokenUpdated"
253
+
254
+ let token: String?
255
+
256
+ var body: [String: Any] {
257
+ var result: [String: Any] = ["type": "APNS_VOIP"]
258
+ if let token = token {
259
+ result["token"] = token
260
+ }
261
+ return result
262
+ }
263
+ }
@@ -0,0 +1,15 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+
4
+ struct CallOptions: Equatable {
5
+ var hasVideo: Bool
6
+ }
7
+
8
+ struct CallOptionsRecord: Record {
9
+ @Field
10
+ var hasVideo: Bool = false
11
+
12
+ func toModel() -> CallOptions {
13
+ CallOptions(hasVideo: hasVideo)
14
+ }
15
+ }
@@ -0,0 +1,37 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+
4
+ struct CallParticipant: Equatable {
5
+ let id: String
6
+ let phoneNumber: String?
7
+ let email: String?
8
+ let displayName: String?
9
+ let avatarUrl: String?
10
+ }
11
+
12
+ struct CallParticipantRecord: Record {
13
+ @Field
14
+ var id: String
15
+
16
+ @Field
17
+ var phoneNumber: String?
18
+
19
+ @Field
20
+ var email: String?
21
+
22
+ @Field
23
+ var displayName: String?
24
+
25
+ @Field
26
+ var avatarUrl: String?
27
+
28
+ func toModel() -> CallParticipant {
29
+ CallParticipant(
30
+ id: id,
31
+ phoneNumber: phoneNumber,
32
+ email: email,
33
+ displayName: displayName,
34
+ avatarUrl: avatarUrl
35
+ )
36
+ }
37
+ }
@@ -0,0 +1,80 @@
1
+ import Foundation
2
+
3
+ /// Represents an active CallKit session.
4
+ struct CallSession: Equatable {
5
+ let id: UUID
6
+ var options: CallOptions
7
+ let origin: Origin
8
+ let remoteParticipants: [CallParticipant]
9
+ let incomingCallEvent: IncomingCallEvent?
10
+ var status: Status
11
+ var connectedAt: Date?
12
+ var isMuted: Bool
13
+ var isOnHold: Bool
14
+ var dtmfDigits: String?
15
+
16
+ enum Origin: String {
17
+ case incoming
18
+ case outgoingApp
19
+ case outgoingSystem
20
+ }
21
+
22
+ enum Status: String {
23
+ case requesting
24
+ case ringing
25
+ case connecting
26
+ case connected
27
+ case ended
28
+ }
29
+ }
30
+
31
+ // MARK: - Serialization
32
+
33
+ extension CallSession {
34
+ func toDictionary() -> [String: Any] {
35
+ var dict: [String: Any] = [
36
+ "id": id.uuidString,
37
+ "origin": origin.rawValue,
38
+ "options": [
39
+ "hasVideo": options.hasVideo
40
+ ],
41
+ "remoteParticipants": remoteParticipants.map { participant in
42
+ var p: [String: Any] = [
43
+ "id": participant.id
44
+ ]
45
+ if let phoneNumber = participant.phoneNumber {
46
+ p["phoneNumber"] = phoneNumber
47
+ }
48
+ if let email = participant.email {
49
+ p["email"] = email
50
+ }
51
+ if let displayName = participant.displayName {
52
+ p["displayName"] = displayName
53
+ }
54
+ if let avatarUrl = participant.avatarUrl {
55
+ p["avatarUrl"] = avatarUrl
56
+ }
57
+ return p
58
+ },
59
+ "status": status.rawValue,
60
+ "isMuted": isMuted,
61
+ "isOnHold": isOnHold,
62
+ ]
63
+
64
+ if let dtmfDigits = dtmfDigits {
65
+ dict["dtmfDigits"] = dtmfDigits
66
+ }
67
+
68
+ if let connectedAt = connectedAt {
69
+ dict["connectedAt"] = ISO8601DateFormatter().string(
70
+ from: connectedAt
71
+ )
72
+ }
73
+
74
+ if let incomingCallEvent = incomingCallEvent {
75
+ dict["incomingCallEvent"] = incomingCallEvent.toDictionary()
76
+ }
77
+
78
+ return dict
79
+ }
80
+ }
@@ -0,0 +1,196 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+
4
+ /// A validated incoming call event, parsed from a push payload or supplied
5
+ /// directly by JS via `reportIncomingCall`.
6
+ ///
7
+ /// Mirrors the TS `IncomingCallEvent` declared in `src/Calls.types.ts`.
8
+ struct IncomingCallEvent: Equatable {
9
+ let eventId: String
10
+ /// Server-assigned id for this call (distinct from the native UUID assigned
11
+ /// by CallKit). Use this to talk to your backend about the call.
12
+ let serverCallId: String
13
+ let caller: Caller
14
+ let hasVideo: Bool
15
+ let startedAt: Date?
16
+ /// App-defined extra fields forwarded verbatim from the push payload.
17
+ /// Opaque to the library; excluded from `==` since `[String: Any]` is not
18
+ /// `Equatable` and events with the same `eventId` are treated as equivalent.
19
+ let metadata: [String: Any]?
20
+
21
+ struct Caller: Equatable {
22
+ let id: String
23
+ let displayName: String?
24
+ let avatarUrl: String?
25
+ let phoneNumber: String?
26
+ let email: String?
27
+ }
28
+
29
+ static func == (lhs: IncomingCallEvent, rhs: IncomingCallEvent) -> Bool {
30
+ lhs.eventId == rhs.eventId
31
+ && lhs.serverCallId == rhs.serverCallId
32
+ && lhs.caller == rhs.caller
33
+ && lhs.hasVideo == rhs.hasVideo
34
+ && lhs.startedAt == rhs.startedAt
35
+ }
36
+
37
+ func toDictionary() -> [String: Any] {
38
+ var dict: [String: Any] = [
39
+ "eventId": eventId,
40
+ "serverCallId": serverCallId,
41
+ "hasVideo": hasVideo,
42
+ "caller": {
43
+ var c: [String: Any] = ["id": caller.id]
44
+ if let displayName = caller.displayName {
45
+ c["displayName"] = displayName
46
+ }
47
+ if let avatarUrl = caller.avatarUrl {
48
+ c["avatarUrl"] = avatarUrl
49
+ }
50
+ if let phoneNumber = caller.phoneNumber {
51
+ c["phoneNumber"] = phoneNumber
52
+ }
53
+ if let email = caller.email {
54
+ c["email"] = email
55
+ }
56
+ return c
57
+ }(),
58
+ ]
59
+ if let startedAt = startedAt {
60
+ dict["startedAt"] = ISO8601DateFormatter().string(from: startedAt)
61
+ }
62
+ if let metadata = metadata {
63
+ dict["metadata"] = metadata
64
+ }
65
+ return dict
66
+ }
67
+ }
68
+
69
+ // MARK: - Expo Records (JS → Native, via `reportIncomingCall`)
70
+
71
+ struct IncomingCallCallerRecord: Record {
72
+ @Field
73
+ var id: String = ""
74
+
75
+ @Field
76
+ var displayName: String?
77
+
78
+ @Field
79
+ var avatarUrl: String?
80
+
81
+ @Field
82
+ var phoneNumber: String?
83
+
84
+ @Field
85
+ var email: String?
86
+ }
87
+
88
+ struct IncomingCallEventRecord: Record {
89
+ @Field
90
+ var eventId: String = ""
91
+
92
+ @Field
93
+ var serverCallId: String = ""
94
+
95
+ @Field
96
+ var caller: IncomingCallCallerRecord = IncomingCallCallerRecord()
97
+
98
+ @Field
99
+ var hasVideo: Bool = false
100
+
101
+ @Field
102
+ var startedAt: String?
103
+
104
+ @Field
105
+ var metadata: [String: Any]?
106
+
107
+ func toModel() throws -> IncomingCallEvent {
108
+ let startedAtDate: Date?
109
+ if let raw = startedAt, !raw.isEmpty {
110
+ startedAtDate = IncomingCallEventParser.parseRFC3339Date(raw) ?? Date()
111
+ } else {
112
+ startedAtDate = nil
113
+ }
114
+ return IncomingCallEvent(
115
+ eventId: eventId,
116
+ serverCallId: serverCallId,
117
+ caller: IncomingCallEvent.Caller(
118
+ id: caller.id,
119
+ displayName: caller.displayName,
120
+ avatarUrl: caller.avatarUrl,
121
+ phoneNumber: caller.phoneNumber,
122
+ email: caller.email
123
+ ),
124
+ hasVideo: hasVideo,
125
+ startedAt: startedAtDate,
126
+ metadata: metadata
127
+ )
128
+ }
129
+ }
130
+
131
+ // MARK: - Push-payload parsing (PushKit / FCM dictionary → model)
132
+
133
+ /// Parses an `IncomingCallEvent` from a VoIP push payload.
134
+ ///
135
+ /// The payload must wrap the event under the top-level key `"incoming_call"`.
136
+ /// There is no fallback to a flat top-level shape. Inner keys are camelCase
137
+ /// to match the TS contract and the example server's wire format.
138
+ enum IncomingCallEventParser {
139
+ static func parse(from payload: [AnyHashable: Any]) -> IncomingCallEvent? {
140
+ guard let event = payload["incoming_call"] as? [AnyHashable: Any] else {
141
+ return nil
142
+ }
143
+
144
+ let eventId = event["eventId"] as? String ?? ""
145
+ let serverCallId = event["serverCallId"] as? String ?? ""
146
+ guard !eventId.isEmpty, !serverCallId.isEmpty else {
147
+ return nil
148
+ }
149
+
150
+ guard let callerDict = event["caller"] as? [AnyHashable: Any],
151
+ let callerId = callerDict["id"] as? String,
152
+ !callerId.isEmpty
153
+ else {
154
+ return nil
155
+ }
156
+ let caller = IncomingCallEvent.Caller(
157
+ id: callerId,
158
+ displayName: callerDict["displayName"] as? String,
159
+ avatarUrl: callerDict["avatarUrl"] as? String,
160
+ phoneNumber: callerDict["phoneNumber"] as? String,
161
+ email: callerDict["email"] as? String
162
+ )
163
+
164
+ let hasVideo = event["hasVideo"] as? Bool ?? false
165
+
166
+ let startedAt: Date?
167
+ if let raw = event["startedAt"] as? String {
168
+ startedAt = parseRFC3339Date(raw)
169
+ } else {
170
+ startedAt = nil
171
+ }
172
+
173
+ let metadata = event["metadata"] as? [String: Any]
174
+
175
+ return IncomingCallEvent(
176
+ eventId: eventId,
177
+ serverCallId: serverCallId,
178
+ caller: caller,
179
+ hasVideo: hasVideo,
180
+ startedAt: startedAt,
181
+ metadata: metadata
182
+ )
183
+ }
184
+
185
+ /// Parses an RFC 3339 timestamp. Handles optional fractional seconds.
186
+ static func parseRFC3339Date(_ string: String) -> Date? {
187
+ let withFraction = ISO8601DateFormatter()
188
+ withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
189
+ if let date = withFraction.date(from: string) {
190
+ return date
191
+ }
192
+ let noFraction = ISO8601DateFormatter()
193
+ noFraction.formatOptions = [.withInternetDateTime]
194
+ return noFraction.date(from: string)
195
+ }
196
+ }