@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,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.2.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
  ],
@@ -8,4 +8,9 @@
8
8
  // cannot collide with the app's own copy of the types.
9
9
  declare module 'expo-modules-core' {
10
10
  export function requireOptionalNativeModule<T>(name: string): T | null;
11
+ /** A native view registered by an Expo module (`View(...)` + `Name(...)`). */
12
+ export function requireNativeViewManager<P>(
13
+ moduleName: string,
14
+ viewName?: string,
15
+ ): import('react').ComponentType<P>;
11
16
  }
package/src/index.ts CHANGED
@@ -6,7 +6,9 @@
6
6
  // On a platform or build without the native module (Expo Go, web,
7
7
  // unlinked), every command is a safe no-op and the state stays idle —
8
8
  // so a host can render against this unconditionally.
9
- import { requireOptionalNativeModule } from 'expo-modules-core';
9
+ import { createElement, type ComponentType, type ReactElement } from 'react';
10
+ import { View, type ViewProps } from 'react-native';
11
+ import { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';
10
12
 
11
13
  /**
12
14
  * A call's complete state. `null` means idle — no call. After a call
@@ -36,18 +38,35 @@ export interface Snapshot {
36
38
  externalId: string;
37
39
  speaking: boolean;
38
40
  muted: boolean;
41
+ /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */
39
42
  cameraOn: boolean;
40
43
  }>;
41
44
  audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
42
45
  mutedSelf: boolean;
46
+ /** True only while the local camera track is actually published and enabled. */
43
47
  cameraOn: boolean;
48
+ /** Facing of the published local camera; absent when the camera is off. */
49
+ cameraFacing?: 'front' | 'back';
44
50
  }
45
51
 
46
52
  export interface ConfigureOptions {
47
53
  /** Drawable resource NAME for the call notification's small icon. */
48
54
  smallIcon?: string;
49
- /** Raw resource NAME of the ringback tone (Android). */
55
+ /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */
50
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;
51
70
  }
52
71
 
53
72
  export interface PlaceOptions {
@@ -74,7 +93,9 @@ interface PoolseCallsNative {
74
93
  onCallFinished(callId: string, reason: string | null): void;
75
94
  canUseFullScreenIntent(): boolean;
76
95
  openFullScreenIntentSettings(): void;
96
+ getVoipToken?(): VoipToken | null;
77
97
  addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };
98
+ addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };
78
99
  }
79
100
 
80
101
  const native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');
@@ -146,3 +167,65 @@ export const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenInt
146
167
 
147
168
  /** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
148
169
  export const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();
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
+
188
+ // ── video ─────────────────────────────────────────────────────────────
189
+
190
+ export interface CallVideoViewProps extends ViewProps {
191
+ /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */
192
+ participantId?: string;
193
+ local?: boolean;
194
+ /** default: true for local front camera, false otherwise */
195
+ mirror?: boolean;
196
+ /** default 'cover' */
197
+ fit?: 'cover' | 'contain';
198
+ }
199
+
200
+ type NativeVideoProps = CallVideoViewProps;
201
+
202
+ let nativeVideo: ComponentType<NativeVideoProps> | null | undefined;
203
+
204
+ /** Resolved lazily (first render) and only when the native module exists. */
205
+ function nativeVideoView(): ComponentType<NativeVideoProps> | null {
206
+ if (nativeVideo !== undefined) return nativeVideo;
207
+ nativeVideo = null;
208
+ if (native) {
209
+ try {
210
+ nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');
211
+ } catch {
212
+ nativeVideo = null; // an older native build without the view
213
+ }
214
+ }
215
+ return nativeVideo;
216
+ }
217
+
218
+ /**
219
+ * A participant's camera (or, with `local`, your own), rendered natively
220
+ * by the call core. Shows nothing (stays transparent) while that camera
221
+ * is off — draw the avatar from the snapshot's `cameraOn` underneath.
222
+ * Without the native module it renders an empty View.
223
+ */
224
+ export function CallVideoView(props: CallVideoViewProps): ReactElement | null {
225
+ const Native = nativeVideoView();
226
+ if (!Native) {
227
+ const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;
228
+ return createElement(View, rest);
229
+ }
230
+ return createElement(Native, props);
231
+ }
@@ -0,0 +1,22 @@
1
+ // Minimal ambient declarations for the React / React Native surface this
2
+ // package touches, so it type-checks without either installed in the
3
+ // monorepo (the host app always supplies both). The emitted
4
+ // `dist/index.d.ts` imports the REAL types by module name, so consumers
5
+ // type-check against their own React Native — these shims never ship.
6
+ declare module 'react' {
7
+ export interface ReactElement {
8
+ type: unknown;
9
+ props: unknown;
10
+ key: string | null;
11
+ }
12
+ export type ComponentType<P = object> = (props: P) => ReactElement | null;
13
+ export function createElement(type: unknown, props?: unknown, ...children: unknown[]): ReactElement;
14
+ }
15
+
16
+ declare module 'react-native' {
17
+ export interface ViewProps {
18
+ style?: unknown;
19
+ testID?: string;
20
+ }
21
+ export const View: unknown;
22
+ }