@poolse/react-native-calls 0.3.0 → 0.4.1

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.
@@ -0,0 +1,268 @@
1
+ import AVFoundation
2
+ import CallKit
3
+ import Foundation
4
+ import UIKit
5
+
6
+ /// What CallKit's provider actions mean to the call core. Implemented by
7
+ /// `CallCore`; absent (never configured) the controller only keeps
8
+ /// CallKit consistent and fails what it cannot honour.
9
+ protocol CallKitActions: AnyObject {
10
+ func callKitAnswer(callId: String)
11
+ func callKitEnd(callId: String)
12
+ func callKitSetMuted(callId: String, muted: Bool)
13
+ func callKitAudioActivated(_ session: AVAudioSession)
14
+ func callKitAudioDeactivated(_ session: AVAudioSession)
15
+ func callKitReset()
16
+ }
17
+
18
+ /// The iOS ring surface + call session: one CXProvider for the process.
19
+ ///
20
+ /// CallKit is the source of truth for the system UI only; the call core
21
+ /// is the source of truth for the call. Every provider action is
22
+ /// forwarded to the core, and `CallCore` reconciles CallKit against each
23
+ /// final snapshot (answer started from JS, outgoing connected, every
24
+ /// terminal path) — so whatever ends a call, CallKit is told exactly once
25
+ /// with the right reason.
26
+ ///
27
+ /// Main thread only (the provider delegate queue is main, PushKit's queue
28
+ /// is main, the runtime runs on main).
29
+ final class CallKitController: NSObject, CXProviderDelegate {
30
+ static let shared = CallKitController()
31
+
32
+ final class Tracked {
33
+ let uuid: UUID
34
+ var callId: String
35
+ let outgoing: Bool
36
+ var hasVideo: Bool
37
+ var title: String?
38
+ /// Answered (incoming) — by the CallKit UI or a request we made.
39
+ var answered = false
40
+ /// CallKit has been told it ended (or is being told).
41
+ var ended = false
42
+ var connectedReported = false
43
+
44
+ init(uuid: UUID, callId: String, outgoing: Bool, hasVideo: Bool, title: String?) {
45
+ self.uuid = uuid
46
+ self.callId = callId
47
+ self.outgoing = outgoing
48
+ self.hasVideo = hasVideo
49
+ self.title = title
50
+ }
51
+ }
52
+
53
+ let provider: CXProvider
54
+ private let controller = CXCallController()
55
+ private var calls: [UUID: Tracked] = [:]
56
+
57
+ weak var actions: CallKitActions?
58
+
59
+ private override init() {
60
+ // init(localizedName:) is deprecated (iOS 14 reads the bundle's
61
+ // display name) but still honoured; the label is the product's.
62
+ let config = CXProviderConfiguration(localizedName: "Loop")
63
+ config.supportsVideo = true
64
+ config.maximumCallGroups = 1
65
+ config.maximumCallsPerCallGroup = 1
66
+ config.supportedHandleTypes = [.generic]
67
+ config.includesCallsInRecents = false
68
+ if let icon = UIImage(named: "CallKitIcon")?.pngData() { config.iconTemplateImageData = icon }
69
+ provider = CXProvider(configuration: config)
70
+ super.init()
71
+ provider.setDelegate(self, queue: nil) // nil = main queue
72
+ }
73
+
74
+ // MARK: - lookup
75
+
76
+ func tracked(callId: String) -> Tracked? {
77
+ calls.values.first { $0.callId == callId && !$0.ended }
78
+ }
79
+
80
+ var liveCalls: [Tracked] { calls.values.filter { !$0.ended } }
81
+
82
+ // MARK: - incoming
83
+
84
+ /// Report a live ring. MUST run synchronously inside PushKit's
85
+ /// didReceiveIncomingPush. A ring already reported (push redelivery,
86
+ /// or the runtime's SHOW_RING after the push path) is re-reported with
87
+ /// the SAME UUID: CallKit rejects it as a duplicate and nothing changes,
88
+ /// which still satisfies PushKit's "report a call for every push".
89
+ func reportIncoming(ring: RingContext) {
90
+ let hasVideo = ring.media == "video"
91
+ let title = ring.conversationName ?? ring.callerName
92
+ if let existing = calls.values.first(where: { $0.callId == ring.callId && !$0.ended }) {
93
+ provider.reportNewIncomingCall(with: existing.uuid, update: update(title: title, handle: ring.conversationId, video: hasVideo)) { _ in }
94
+ return
95
+ }
96
+ let t = Tracked(uuid: UUID(), callId: ring.callId, outgoing: false, hasVideo: hasVideo, title: title)
97
+ calls[t.uuid] = t
98
+ provider.reportNewIncomingCall(with: t.uuid, update: update(title: title, handle: ring.conversationId, video: hasVideo)) { [weak self] error in
99
+ guard let error = error else { return }
100
+ NSLog("[PoolseCalls] CallKit refused incoming call %@: %@", ring.callId, error.localizedDescription)
101
+ // Refused (DND, blocked, busy group): CallKit holds nothing for it.
102
+ // End the ring in the core too — silently (no decline is sent), so
103
+ // the engine is not left RINGING_IN with no UI, counting as busy.
104
+ DispatchQueue.main.async {
105
+ t.ended = true
106
+ self?.calls.removeValue(forKey: t.uuid)
107
+ CallCore.peek()?.onRingEnded(callId: ring.callId, reason: "cancelled")
108
+ }
109
+ }
110
+ }
111
+
112
+ /// A push that must not ring (stale, duplicate of a resolved call,
113
+ /// unparseable, a control event): report it — iOS kills an app that
114
+ /// does not — then end it at once. A push for a call CallKit is still
115
+ /// showing reuses its UUID, so the live call is untouched.
116
+ func reportAndFail(callId: String?, title: String?) {
117
+ if let callId = callId, let existing = calls.values.first(where: { $0.callId == callId && !$0.ended }) {
118
+ provider.reportNewIncomingCall(with: existing.uuid, update: update(title: existing.title, handle: callId, video: existing.hasVideo)) { _ in }
119
+ return
120
+ }
121
+ let uuid = UUID()
122
+ provider.reportNewIncomingCall(with: uuid, update: update(title: title, handle: callId ?? "loop", video: false)) { [weak self] error in
123
+ if error == nil { self?.provider.reportCall(with: uuid, endedAt: nil, reason: .failed) }
124
+ }
125
+ }
126
+
127
+ // MARK: - outgoing
128
+
129
+ /// Start an outgoing (placed or joined) call in CallKit. CallKit then
130
+ /// activates the audio session for it. `onFailure` runs if CallKit
131
+ /// refuses the transaction (the call proceeds without CallKit).
132
+ func startOutgoing(callId: String, handle: String, title: String?, video: Bool, onFailure: @escaping () -> Void) {
133
+ if tracked(callId: callId) != nil { return }
134
+ let t = Tracked(uuid: UUID(), callId: callId, outgoing: true, hasVideo: video, title: title)
135
+ calls[t.uuid] = t
136
+ let action = CXStartCallAction(call: t.uuid, handle: CXHandle(type: .generic, value: handle))
137
+ action.isVideo = video
138
+ action.contactIdentifier = title
139
+ controller.request(CXTransaction(action: action)) { [weak self] error in
140
+ DispatchQueue.main.async {
141
+ guard let error = error else {
142
+ if let title = t.title { self?.provider.reportCall(with: t.uuid, updated: self?.update(title: title, handle: handle, video: video) ?? CXCallUpdate()) }
143
+ return
144
+ }
145
+ NSLog("[PoolseCalls] CallKit start refused: %@", error.localizedDescription)
146
+ t.ended = true
147
+ self?.calls.removeValue(forKey: t.uuid)
148
+ onFailure()
149
+ }
150
+ }
151
+ }
152
+
153
+ /// `pending:<conversation>` → the server-minted id: the same call.
154
+ func rename(from old: String, to new: String) {
155
+ tracked(callId: old)?.callId = new
156
+ }
157
+
158
+ func reportConnected(callId: String) {
159
+ guard let t = tracked(callId: callId), t.outgoing, !t.connectedReported else { return }
160
+ t.connectedReported = true
161
+ provider.reportOutgoingCall(with: t.uuid, connectedAt: nil)
162
+ }
163
+
164
+ func updateTitle(callId: String, title: String?, video: Bool) {
165
+ guard let t = tracked(callId: callId), let title = title, title != t.title || video != t.hasVideo else { return }
166
+ t.title = title
167
+ t.hasVideo = video
168
+ provider.reportCall(with: t.uuid, updated: update(title: title, handle: nil, video: video))
169
+ }
170
+
171
+ // MARK: - requests from the app (JS buttons)
172
+
173
+ /// The app answered from its own UI: tell CallKit (it activates audio).
174
+ func requestAnswer(callId: String) {
175
+ guard let t = tracked(callId: callId), !t.outgoing, !t.answered else { return }
176
+ t.answered = true
177
+ controller.request(CXTransaction(action: CXAnswerCallAction(call: t.uuid))) { error in
178
+ if let error = error { NSLog("[PoolseCalls] CallKit answer refused: %@", error.localizedDescription) }
179
+ }
180
+ }
181
+
182
+ func requestMute(callId: String, muted: Bool) {
183
+ guard let t = tracked(callId: callId) else { return }
184
+ controller.request(CXTransaction(action: CXSetMutedCallAction(call: t.uuid, muted: muted))) { _ in }
185
+ }
186
+
187
+ /// End a call in CallKit. `reason` nil = ended locally (a user action
188
+ /// in our UI) → an end transaction; otherwise the call ended remotely
189
+ /// or failed → reported with that reason.
190
+ func end(_ t: Tracked, reason: CXCallEndedReason?) {
191
+ guard !t.ended else { return }
192
+ t.ended = true
193
+ if let reason = reason {
194
+ provider.reportCall(with: t.uuid, endedAt: nil, reason: reason)
195
+ calls.removeValue(forKey: t.uuid)
196
+ } else {
197
+ controller.request(CXTransaction(action: CXEndCallAction(call: t.uuid))) { [weak self] error in
198
+ DispatchQueue.main.async {
199
+ if error != nil { self?.provider.reportCall(with: t.uuid, endedAt: nil, reason: .remoteEnded) }
200
+ self?.calls.removeValue(forKey: t.uuid)
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ private func update(title: String?, handle: String?, video: Bool) -> CXCallUpdate {
207
+ let u = CXCallUpdate()
208
+ if let handle = handle { u.remoteHandle = CXHandle(type: .generic, value: handle) }
209
+ u.localizedCallerName = title ?? "Loop"
210
+ u.hasVideo = video
211
+ u.supportsHolding = false
212
+ u.supportsGrouping = false
213
+ u.supportsUngrouping = false
214
+ u.supportsDTMF = false
215
+ return u
216
+ }
217
+
218
+ // MARK: - CXProviderDelegate
219
+
220
+ func providerDidReset(_ provider: CXProvider) {
221
+ for t in calls.values { t.ended = true }
222
+ calls.removeAll()
223
+ actions?.callKitReset()
224
+ }
225
+
226
+ func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
227
+ guard let t = calls[action.callUUID], !t.ended else { action.fail(); return }
228
+ let fromUs = t.answered
229
+ t.answered = true
230
+ if !fromUs {
231
+ guard let actions = actions else { action.fail(); return }
232
+ actions.callKitAnswer(callId: t.callId)
233
+ }
234
+ action.fulfill()
235
+ }
236
+
237
+ func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
238
+ guard let t = calls[action.callUUID] else { action.fulfill(); return }
239
+ let fromUs = t.ended
240
+ t.ended = true
241
+ calls.removeValue(forKey: t.uuid)
242
+ if !fromUs { actions?.callKitEnd(callId: t.callId) }
243
+ action.fulfill()
244
+ }
245
+
246
+ func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
247
+ guard calls[action.callUUID] != nil else { action.fail(); return }
248
+ provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
249
+ action.fulfill()
250
+ }
251
+
252
+ func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
253
+ if let t = calls[action.callUUID] { actions?.callKitSetMuted(callId: t.callId, muted: action.isMuted) }
254
+ action.fulfill()
255
+ }
256
+
257
+ func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
258
+ action.fail() // no hold: one call at a time
259
+ }
260
+
261
+ func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
262
+ actions?.callKitAudioActivated(audioSession)
263
+ }
264
+
265
+ func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
266
+ actions?.callKitAudioDeactivated(audioSession)
267
+ }
268
+ }