@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,172 @@
1
+ import CallKit
2
+ import Foundation
3
+ import PushKit
4
+
5
+ /// Native PushKit intake — the iOS twin of CallMessagingService. The
6
+ /// registry is created at launch (PoolseCallsAppDelegateSubscriber →
7
+ /// `PoolseCallsSetup.registerVoipEarly()`), long before JS, so a VoIP push that
8
+ /// wakes a killed app is reported to CallKit natively within PushKit's
9
+ /// budget.
10
+ ///
11
+ /// Contract with iOS: EVERY VoIP push reports a call to CallKit
12
+ /// synchronously, before `completion()` — stale, duplicate, control or
13
+ /// unparseable ones included (reported, then ended `.failed`). Breaking
14
+ /// it gets the app killed and, repeated, VoIP delivery revoked.
15
+ final class VoipPush: NSObject, PKPushRegistryDelegate {
16
+ static let shared = VoipPush()
17
+
18
+ private var registry: PKPushRegistry?
19
+ private let lock = NSLock()
20
+ private var _token: String?
21
+
22
+ /// The current VoIP token (hex), readable from any thread.
23
+ var token: String? {
24
+ lock.lock(); defer { lock.unlock() }
25
+ return _token
26
+ }
27
+
28
+ /// The bridge's token subscriber (process-level, like CallCore.listener).
29
+ static var tokenListener: ((String) -> Void)?
30
+
31
+ /// Idempotent. Main thread.
32
+ func register() {
33
+ guard registry == nil else { return }
34
+ _ = CallKitController.shared // the provider must exist before any push
35
+ let r = PKPushRegistry(queue: .main)
36
+ r.delegate = self
37
+ r.desiredPushTypes = [.voIP]
38
+ registry = r
39
+ if let data = r.pushToken(for: .voIP) { setToken(data) }
40
+ }
41
+
42
+ private func setToken(_ data: Data) {
43
+ let hex = data.map { String(format: "%02x", $0) }.joined()
44
+ lock.lock(); _token = hex; lock.unlock()
45
+ VoipPush.tokenListener?(hex)
46
+ }
47
+
48
+ // MARK: - PKPushRegistryDelegate
49
+
50
+ func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
51
+ guard type == .voIP else { return }
52
+ setToken(pushCredentials.token)
53
+ }
54
+
55
+ func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
56
+ guard type == .voIP else { return }
57
+ lock.lock(); _token = nil; lock.unlock()
58
+ }
59
+
60
+ func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload,
61
+ for type: PKPushType, completion: @escaping () -> Void) {
62
+ if type == .voIP { handle(payload.dictionaryPayload) }
63
+ completion()
64
+ }
65
+
66
+ // MARK: - handling
67
+
68
+ private func handle(_ raw: [AnyHashable: Any]) {
69
+ let ck = CallKitController.shared
70
+ let p = VoipPayload(raw)
71
+ let core = CallCore.getOrNull()
72
+
73
+ switch p.event {
74
+ case "ring_ended", "finished":
75
+ // A control push on the VoIP channel still has to report a call.
76
+ ck.reportAndFail(callId: p.callId, title: p.title)
77
+ guard let core = core, let callId = p.callId else { return }
78
+ if p.event == "ring_ended" { core.onRingEnded(callId: callId, reason: p.reason ?? "finished") }
79
+ else { core.onCallFinished(callId: callId, reason: p.reason) }
80
+ default:
81
+ guard let ring = p.ringContext() else {
82
+ ck.reportAndFail(callId: p.callId, title: p.title)
83
+ return
84
+ }
85
+ guard let core = core, core.isRingLive(ring) else {
86
+ ck.reportAndFail(callId: ring.callId, title: p.title)
87
+ return
88
+ }
89
+ ck.reportIncoming(ring: ring)
90
+ core.onRing(ring)
91
+ // A ring the machine did not take (busy, superseded) must not stay
92
+ // on the CallKit screen: reconcile once the ring is processed.
93
+ core.reconcileCallKitSoon()
94
+ }
95
+ }
96
+ }
97
+
98
+ /// Lenient VoIP payload reader: top-level keys, or nested under `data` /
99
+ /// `payload` / `call`; strings, numbers or bools for any field.
100
+ struct VoipPayload {
101
+ private let fields: [String: Any]
102
+
103
+ init(_ raw: [AnyHashable: Any]) {
104
+ var out: [String: Any] = [:]
105
+ for nest in ["data", "payload", "call"] {
106
+ if let inner = raw[nest] as? [AnyHashable: Any] {
107
+ for (k, v) in inner { if let k = k as? String { out[k] = v } }
108
+ } else if let s = raw[nest] as? String, let d = s.data(using: .utf8),
109
+ let inner = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] {
110
+ for (k, v) in inner { out[k] = v }
111
+ }
112
+ }
113
+ for (k, v) in raw { if let k = k as? String, !(v is [AnyHashable: Any]) { out[k] = v } }
114
+ fields = out
115
+ }
116
+
117
+ func string(_ key: String) -> String? {
118
+ switch fields[key] {
119
+ case let s as String: return s.isEmpty ? nil : s
120
+ case let n as NSNumber: return n.stringValue
121
+ default: return nil
122
+ }
123
+ }
124
+
125
+ func int64(_ key: String) -> Int64? {
126
+ switch fields[key] {
127
+ case let n as NSNumber: return n.int64Value
128
+ case let s as String: return Int64(s) ?? Double(s).map { Int64($0) }
129
+ default: return nil
130
+ }
131
+ }
132
+
133
+ func bool(_ key: String) -> Bool? {
134
+ switch fields[key] {
135
+ case let n as NSNumber: return n.boolValue
136
+ case let s as String: return ["1", "true", "yes"].contains(s.lowercased())
137
+ default: return nil
138
+ }
139
+ }
140
+
141
+ var callId: String? { string("call_id") }
142
+ var event: String? { string("event") }
143
+ var reason: String? { string("reason") }
144
+ var title: String? { string("conversation_name") ?? string("caller_name") }
145
+
146
+ var media: String {
147
+ if let m = string("media")?.lowercased(), m == "video" || m == "audio" { return m }
148
+ return bool("has_video") == true ? "video" : "audio"
149
+ }
150
+
151
+ /// Epoch ms; a seconds value (< 1e12) is scaled. Missing: sent_at (or
152
+ /// now) + 60 s — the server's ring_ended(timeout) is the real cut-off.
153
+ private var deadlineMs: Int64 {
154
+ func ms(_ v: Int64) -> Int64 { v < 1_000_000_000_000 ? v * 1000 : v }
155
+ if let d = int64("ring_deadline"), d > 0 { return ms(d) }
156
+ let base = int64("sent_at").map(ms) ?? Int64(Date().timeIntervalSince1970 * 1000)
157
+ return base + 60_000
158
+ }
159
+
160
+ func ringContext() -> RingContext? {
161
+ guard let callId = callId, let conversationId = string("conversation_id"), let grant = string("grant") else { return nil }
162
+ return RingContext(
163
+ callId: callId,
164
+ conversationId: conversationId,
165
+ conversationName: string("conversation_name"),
166
+ callerName: string("caller_name"),
167
+ media: media,
168
+ grant: grant,
169
+ ringDeadlineEpochMs: deadlineMs
170
+ )
171
+ }
172
+ }
@@ -0,0 +1,64 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ # LiveKit's Swift SDK (and its LiveKitUniFFI / collections dependencies)
6
+ # is no longer published to CocoaPods trunk: it lives in LiveKit's own
7
+ # spec repo. A podspec cannot declare a source, and the host's Podfile is
8
+ # generated by `expo prebuild`, so instead of asking every host to add
9
+ #
10
+ # source 'https://cdn.cocoapods.org/'
11
+ # source 'https://github.com/livekit/podspecs.git'
12
+ #
13
+ # we append LiveKit's spec repo to the resolver's sources while this
14
+ # podspec is loaded (autolinking evaluates it inside `pod install`).
15
+ # Trunk stays first, so every other pod resolves exactly as before.
16
+ # Opt out with POOLSE_CALLS_NO_LIVEKIT_SOURCE=1 (then add the lines above).
17
+ LIVEKIT_SPECS_SOURCE = 'https://github.com/livekit/podspecs.git'
18
+ if ENV['POOLSE_CALLS_NO_LIVEKIT_SOURCE'] != '1' &&
19
+ defined?(Pod::Installer::Analyzer) &&
20
+ !Pod::Installer::Analyzer.method_defined?(:poolse_calls_original_sources)
21
+ Pod::Installer::Analyzer.class_eval do
22
+ alias_method :poolse_calls_original_sources, :sources
23
+ def sources
24
+ @poolse_calls_sources ||= begin
25
+ list = poolse_calls_original_sources
26
+ if list.any? { |s| s.respond_to?(:url) && s.url.to_s.sub(/\.git\z/, '') == LIVEKIT_SPECS_SOURCE.sub(/\.git\z/, '') }
27
+ list
28
+ else
29
+ list + [Pod::Config.instance.sources_manager.find_or_create_source_with_url(LIVEKIT_SPECS_SOURCE)]
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ Pod::Spec.new do |s|
37
+ s.name = 'PoolseCalls'
38
+ s.version = package['version']
39
+ s.summary = package['description']
40
+ s.description = package['description']
41
+ s.license = package['license']
42
+ s.author = 'Torsten Labs DOO'
43
+ s.homepage = 'https://poolse.dev'
44
+ s.platforms = { :ios => '15.1' }
45
+ s.swift_version = '5.9'
46
+ s.source = { :git => 'https://github.com/poolse/poolse-js-sdk.git' }
47
+ s.static_framework = true
48
+
49
+ s.dependency 'ExpoModulesCore'
50
+ # 2.15.1: LiveKitWebRTC 144.7559.10 (LKRTC-prefixed symbols in its own
51
+ # LiveKitWebRTC.framework) — links beside react-native-webrtc's
52
+ # WebRTC.framework without duplicate symbols. Not 2.16+: their vendored
53
+ # nanopb headers use <pb.h> angled includes that do not resolve when
54
+ # CocoaPods builds the pod as a static library (Expo's default).
55
+ s.dependency 'LiveKitClient', '= 2.15.1'
56
+
57
+ s.pod_target_xcconfig = {
58
+ 'DEFINES_MODULE' => 'YES',
59
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule',
60
+ }
61
+
62
+ s.frameworks = 'CallKit', 'PushKit', 'AVFoundation', 'UIKit'
63
+ s.source_files = 'Core/**/*.swift', 'Platform/**/*.swift', 'Module/**/*.swift'
64
+ end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolse/react-native-calls",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Native call core for poolse — Kotlin/Swift calling engine with a thin React Native bridge. Ring, answer and carry audio with the app killed and the phone locked.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,6 +22,12 @@
22
22
  "android/build.gradle",
23
23
  "android/consumer-rules.pro",
24
24
  "android/src",
25
+ "ios/Core",
26
+ "ios/Platform",
27
+ "ios/Module",
28
+ "ios/PoolseCalls.podspec",
29
+ "!ios/CoreTests",
30
+ "!**/.build",
25
31
  "expo-module.config.json",
26
32
  "README.md"
27
33
  ],
package/src/index.ts CHANGED
@@ -52,8 +52,21 @@ export interface Snapshot {
52
52
  export interface ConfigureOptions {
53
53
  /** Drawable resource NAME for the call notification's small icon. */
54
54
  smallIcon?: string;
55
- /** Raw resource NAME of the ringback tone (Android). */
55
+ /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */
56
56
  ringback?: string;
57
+ /**
58
+ * iOS: the APNs environment of this build's `aps-environment`
59
+ * entitlement. Echoed back with the VoIP token so the server pushes to
60
+ * the right gateway. Ignored on Android.
61
+ */
62
+ apnsEnvironment?: 'production' | 'sandbox';
63
+ }
64
+
65
+ /** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */
66
+ export interface VoipToken {
67
+ /** Hex-encoded PushKit token. */
68
+ token: string;
69
+ environment: 'production' | 'sandbox' | string;
57
70
  }
58
71
 
59
72
  export interface PlaceOptions {
@@ -80,7 +93,9 @@ interface PoolseCallsNative {
80
93
  onCallFinished(callId: string, reason: string | null): void;
81
94
  canUseFullScreenIntent(): boolean;
82
95
  openFullScreenIntentSettings(): void;
96
+ getVoipToken?(): VoipToken | null;
83
97
  addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };
98
+ addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };
84
99
  }
85
100
 
86
101
  const native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');
@@ -153,6 +168,23 @@ export const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenInt
153
168
  /** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
154
169
  export const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();
155
170
 
171
+ // ── VoIP push (iOS) ───────────────────────────────────────────────────
172
+
173
+ /**
174
+ * iOS: the current PushKit VoIP token, registered natively at launch.
175
+ * `null` until iOS issues one, and always on Android (FCM carries rings).
176
+ */
177
+ export function getVoipToken(): VoipToken | null {
178
+ return native?.getVoipToken?.() ?? null;
179
+ }
180
+
181
+ /** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */
182
+ export function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {
183
+ if (!native || !native.getVoipToken) return () => undefined;
184
+ const sub = native.addListener('onVoipToken', cb);
185
+ return () => sub.remove();
186
+ }
187
+
156
188
  // ── video ─────────────────────────────────────────────────────────────
157
189
 
158
190
  export interface CallVideoViewProps extends ViewProps {