@poolse/react-native-calls 0.2.0 → 0.4.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 (34) hide show
  1. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +25 -8
  2. package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
  3. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
  4. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
  5. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +10 -0
  6. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +142 -14
  7. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
  8. package/dist/index.cjs +34 -0
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +44 -2
  11. package/dist/index.d.ts +44 -2
  12. package/dist/index.js +33 -2
  13. package/dist/index.js.map +1 -1
  14. package/expo-module.config.json +6 -1
  15. package/ios/Core/CallCoreRuntime.swift +393 -0
  16. package/ios/Core/CallHttpClient.swift +210 -0
  17. package/ios/Core/CallMachine.swift +304 -0
  18. package/ios/Core/PORTS_READY.md +93 -0
  19. package/ios/Core/Ports.swift +193 -0
  20. package/ios/Core/SnapshotEncoding.swift +75 -0
  21. package/ios/Module/PoolseCallVideoView.swift +62 -0
  22. package/ios/Module/PoolseCallsAppDelegateSubscriber.swift +16 -0
  23. package/ios/Module/PoolseCallsModule.swift +111 -0
  24. package/ios/Platform/CallCore.swift +464 -0
  25. package/ios/Platform/CallKitController.swift +263 -0
  26. package/ios/Platform/LiveKitMediaPort.swift +445 -0
  27. package/ios/Platform/LiveKitVideoSurface.swift +67 -0
  28. package/ios/Platform/Ringback.swift +61 -0
  29. package/ios/Platform/VoipPush.swift +172 -0
  30. package/ios/PoolseCalls.podspec +64 -0
  31. package/package.json +7 -1
  32. package/src/expo-modules-core.d.ts +5 -0
  33. package/src/index.ts +85 -2
  34. package/src/react-shims.d.ts +22 -0
@@ -0,0 +1,263 @@
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 }) {
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
+ t.ended = true
103
+ self?.calls.removeValue(forKey: t.uuid)
104
+ }
105
+ }
106
+
107
+ /// A push that must not ring (stale, duplicate of a resolved call,
108
+ /// unparseable, a control event): report it — iOS kills an app that
109
+ /// does not — then end it at once. A push for a call CallKit is still
110
+ /// showing reuses its UUID, so the live call is untouched.
111
+ func reportAndFail(callId: String?, title: String?) {
112
+ if let callId = callId, let existing = calls.values.first(where: { $0.callId == callId && !$0.ended }) {
113
+ provider.reportNewIncomingCall(with: existing.uuid, update: update(title: existing.title, handle: callId, video: existing.hasVideo)) { _ in }
114
+ return
115
+ }
116
+ let uuid = UUID()
117
+ provider.reportNewIncomingCall(with: uuid, update: update(title: title, handle: callId ?? "loop", video: false)) { [weak self] error in
118
+ if error == nil { self?.provider.reportCall(with: uuid, endedAt: nil, reason: .failed) }
119
+ }
120
+ }
121
+
122
+ // MARK: - outgoing
123
+
124
+ /// Start an outgoing (placed or joined) call in CallKit. CallKit then
125
+ /// activates the audio session for it. `onFailure` runs if CallKit
126
+ /// refuses the transaction (the call proceeds without CallKit).
127
+ func startOutgoing(callId: String, handle: String, title: String?, video: Bool, onFailure: @escaping () -> Void) {
128
+ if tracked(callId: callId) != nil { return }
129
+ let t = Tracked(uuid: UUID(), callId: callId, outgoing: true, hasVideo: video, title: title)
130
+ calls[t.uuid] = t
131
+ let action = CXStartCallAction(call: t.uuid, handle: CXHandle(type: .generic, value: handle))
132
+ action.isVideo = video
133
+ action.contactIdentifier = title
134
+ controller.request(CXTransaction(action: action)) { [weak self] error in
135
+ DispatchQueue.main.async {
136
+ guard let error = error else {
137
+ if let title = t.title { self?.provider.reportCall(with: t.uuid, updated: self?.update(title: title, handle: handle, video: video) ?? CXCallUpdate()) }
138
+ return
139
+ }
140
+ NSLog("[PoolseCalls] CallKit start refused: %@", error.localizedDescription)
141
+ t.ended = true
142
+ self?.calls.removeValue(forKey: t.uuid)
143
+ onFailure()
144
+ }
145
+ }
146
+ }
147
+
148
+ /// `pending:<conversation>` → the server-minted id: the same call.
149
+ func rename(from old: String, to new: String) {
150
+ tracked(callId: old)?.callId = new
151
+ }
152
+
153
+ func reportConnected(callId: String) {
154
+ guard let t = tracked(callId: callId), t.outgoing, !t.connectedReported else { return }
155
+ t.connectedReported = true
156
+ provider.reportOutgoingCall(with: t.uuid, connectedAt: nil)
157
+ }
158
+
159
+ func updateTitle(callId: String, title: String?, video: Bool) {
160
+ guard let t = tracked(callId: callId), let title = title, title != t.title || video != t.hasVideo else { return }
161
+ t.title = title
162
+ t.hasVideo = video
163
+ provider.reportCall(with: t.uuid, updated: update(title: title, handle: nil, video: video))
164
+ }
165
+
166
+ // MARK: - requests from the app (JS buttons)
167
+
168
+ /// The app answered from its own UI: tell CallKit (it activates audio).
169
+ func requestAnswer(callId: String) {
170
+ guard let t = tracked(callId: callId), !t.outgoing, !t.answered else { return }
171
+ t.answered = true
172
+ controller.request(CXTransaction(action: CXAnswerCallAction(call: t.uuid))) { error in
173
+ if let error = error { NSLog("[PoolseCalls] CallKit answer refused: %@", error.localizedDescription) }
174
+ }
175
+ }
176
+
177
+ func requestMute(callId: String, muted: Bool) {
178
+ guard let t = tracked(callId: callId) else { return }
179
+ controller.request(CXTransaction(action: CXSetMutedCallAction(call: t.uuid, muted: muted))) { _ in }
180
+ }
181
+
182
+ /// End a call in CallKit. `reason` nil = ended locally (a user action
183
+ /// in our UI) → an end transaction; otherwise the call ended remotely
184
+ /// or failed → reported with that reason.
185
+ func end(_ t: Tracked, reason: CXCallEndedReason?) {
186
+ guard !t.ended else { return }
187
+ t.ended = true
188
+ if let reason = reason {
189
+ provider.reportCall(with: t.uuid, endedAt: nil, reason: reason)
190
+ calls.removeValue(forKey: t.uuid)
191
+ } else {
192
+ controller.request(CXTransaction(action: CXEndCallAction(call: t.uuid))) { [weak self] error in
193
+ DispatchQueue.main.async {
194
+ if error != nil { self?.provider.reportCall(with: t.uuid, endedAt: nil, reason: .remoteEnded) }
195
+ self?.calls.removeValue(forKey: t.uuid)
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ private func update(title: String?, handle: String?, video: Bool) -> CXCallUpdate {
202
+ let u = CXCallUpdate()
203
+ if let handle = handle { u.remoteHandle = CXHandle(type: .generic, value: handle) }
204
+ u.localizedCallerName = title ?? "Loop"
205
+ u.hasVideo = video
206
+ u.supportsHolding = false
207
+ u.supportsGrouping = false
208
+ u.supportsUngrouping = false
209
+ u.supportsDTMF = false
210
+ return u
211
+ }
212
+
213
+ // MARK: - CXProviderDelegate
214
+
215
+ func providerDidReset(_ provider: CXProvider) {
216
+ for t in calls.values { t.ended = true }
217
+ calls.removeAll()
218
+ actions?.callKitReset()
219
+ }
220
+
221
+ func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
222
+ guard let t = calls[action.callUUID], !t.ended else { action.fail(); return }
223
+ let fromUs = t.answered
224
+ t.answered = true
225
+ if !fromUs {
226
+ guard let actions = actions else { action.fail(); return }
227
+ actions.callKitAnswer(callId: t.callId)
228
+ }
229
+ action.fulfill()
230
+ }
231
+
232
+ func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
233
+ guard let t = calls[action.callUUID] else { action.fulfill(); return }
234
+ let fromUs = t.ended
235
+ t.ended = true
236
+ calls.removeValue(forKey: t.uuid)
237
+ if !fromUs { actions?.callKitEnd(callId: t.callId) }
238
+ action.fulfill()
239
+ }
240
+
241
+ func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
242
+ guard calls[action.callUUID] != nil else { action.fail(); return }
243
+ provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
244
+ action.fulfill()
245
+ }
246
+
247
+ func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
248
+ if let t = calls[action.callUUID] { actions?.callKitSetMuted(callId: t.callId, muted: action.isMuted) }
249
+ action.fulfill()
250
+ }
251
+
252
+ func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
253
+ action.fail() // no hold: one call at a time
254
+ }
255
+
256
+ func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
257
+ actions?.callKitAudioActivated(audioSession)
258
+ }
259
+
260
+ func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
261
+ actions?.callKitAudioDeactivated(audioSession)
262
+ }
263
+ }