@poolse/react-native-calls 0.3.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.
- package/dist/index.cjs +10 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -2
- package/dist/index.d.ts +21 -2
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/expo-module.config.json +6 -1
- package/ios/Core/CallCoreRuntime.swift +393 -0
- package/ios/Core/CallHttpClient.swift +210 -0
- package/ios/Core/CallMachine.swift +304 -0
- package/ios/Core/PORTS_READY.md +93 -0
- package/ios/Core/Ports.swift +193 -0
- package/ios/Core/SnapshotEncoding.swift +75 -0
- package/ios/Module/PoolseCallVideoView.swift +62 -0
- package/ios/Module/PoolseCallsAppDelegateSubscriber.swift +16 -0
- package/ios/Module/PoolseCallsModule.swift +111 -0
- package/ios/Platform/CallCore.swift +464 -0
- package/ios/Platform/CallKitController.swift +263 -0
- package/ios/Platform/LiveKitMediaPort.swift +445 -0
- package/ios/Platform/LiveKitVideoSurface.swift +67 -0
- package/ios/Platform/Ringback.swift +61 -0
- package/ios/Platform/VoipPush.swift +172 -0
- package/ios/PoolseCalls.podspec +64 -0
- package/package.json +7 -1
- package/src/index.ts +33 -1
|
@@ -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
|
+
"version": "0.4.0",
|
|
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
|
-
/**
|
|
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 {
|