@dialstack/sdk-native 0.1.1 → 0.2.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/README.md +60 -10
- package/dist/index.d.ts +670 -54
- package/dist/index.js +3137 -602
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +361 -0
- package/dist/testing.js +606 -0
- package/dist/testing.js.map +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OS call surface (CallKit on iOS, Telecom on Android) as the bridge needs
|
|
3
|
+
* it — shaped by the bridge, not by any library. See the contract suite for
|
|
4
|
+
* what an adapter must guarantee.
|
|
5
|
+
*/
|
|
6
|
+
/** OS-side call identity: the UUID CallKit/Telecom track the call under. */
|
|
7
|
+
type OsSessionId = string;
|
|
8
|
+
interface OsIncomingCall {
|
|
9
|
+
callId: string;
|
|
10
|
+
/** Caller handle as the OS should display/dial it. */
|
|
11
|
+
from: string;
|
|
12
|
+
displayName?: string | null;
|
|
13
|
+
}
|
|
14
|
+
interface OsOutgoingCall {
|
|
15
|
+
/** The recipient handle the OS should display/dial. */
|
|
16
|
+
to: string;
|
|
17
|
+
displayName?: string | null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Why the call ended, in the OS's vocabulary. `answeredElsewhere` /
|
|
21
|
+
* `declinedElsewhere` make the OS dismiss the ring silently instead of logging
|
|
22
|
+
* a missed call.
|
|
23
|
+
*/
|
|
24
|
+
type OsEndReason = 'remoteEnded' | 'unanswered' | 'answeredElsewhere' | 'declinedElsewhere' | 'failed' | 'unknown';
|
|
25
|
+
/** Where the OS thinks a session is. `connecting` is answered but not yet reported connected. */
|
|
26
|
+
type OsSessionStatus = 'ringing' | 'connecting' | 'connected' | 'ended';
|
|
27
|
+
interface OsActiveSession {
|
|
28
|
+
sessionId: OsSessionId;
|
|
29
|
+
callId: string | null;
|
|
30
|
+
/**
|
|
31
|
+
* The OS's own view, for reconciling against the SDK call. Optional: an
|
|
32
|
+
* adapter that can't read it leaves it out and the bridge doesn't reconcile
|
|
33
|
+
* that field.
|
|
34
|
+
*/
|
|
35
|
+
status?: OsSessionStatus;
|
|
36
|
+
held?: boolean;
|
|
37
|
+
}
|
|
38
|
+
interface OsCallEvents {
|
|
39
|
+
/**
|
|
40
|
+
* A call was reported to the OS — by native code before JS existed, or by
|
|
41
|
+
* `reportIncoming`. `callId` is the call_id the session was created with (a
|
|
42
|
+
* wake push's, or the one passed to `reportIncoming`): the bridge pairs the
|
|
43
|
+
* session with its call by it, and ends a session that has none.
|
|
44
|
+
*/
|
|
45
|
+
incomingReported: (e: OsActiveSession) => void;
|
|
46
|
+
/** The OS wants to PLACE a call — the user dialled from the platform's own UI
|
|
47
|
+
* (Recents, Siri, dialer intent), not from the app. */
|
|
48
|
+
callIntent: (e: {
|
|
49
|
+
handle: string;
|
|
50
|
+
}) => void;
|
|
51
|
+
answer: (e: {
|
|
52
|
+
sessionId: OsSessionId;
|
|
53
|
+
}) => void;
|
|
54
|
+
end: (e: {
|
|
55
|
+
sessionId: OsSessionId;
|
|
56
|
+
}) => void;
|
|
57
|
+
setHeld: (e: {
|
|
58
|
+
sessionId: OsSessionId;
|
|
59
|
+
held: boolean;
|
|
60
|
+
}) => void;
|
|
61
|
+
setMuted: (e: {
|
|
62
|
+
sessionId: OsSessionId;
|
|
63
|
+
muted: boolean;
|
|
64
|
+
}) => void;
|
|
65
|
+
dtmf: (e: {
|
|
66
|
+
sessionId: OsSessionId;
|
|
67
|
+
digits: string;
|
|
68
|
+
}) => void;
|
|
69
|
+
/** iOS only in practice: CallKit gates audio, so WebRTC audio starts here. */
|
|
70
|
+
audioSessionActivated: (e: {
|
|
71
|
+
sessionId: OsSessionId | null;
|
|
72
|
+
}) => void;
|
|
73
|
+
}
|
|
74
|
+
type OsCallEventName = keyof OsCallEvents;
|
|
75
|
+
interface OsCallAdapter {
|
|
76
|
+
/** Resolves to the OS-assigned session id (the OS assigns, never the caller). */
|
|
77
|
+
reportIncoming(call: OsIncomingCall): Promise<OsSessionId>;
|
|
78
|
+
/**
|
|
79
|
+
* Report an outbound call so the OS holds a live session — on Android that
|
|
80
|
+
* starts the foreground service keeping the process and call alive when
|
|
81
|
+
* backgrounded, which an outbound call otherwise lacks.
|
|
82
|
+
*/
|
|
83
|
+
reportOutgoing(call: OsOutgoingCall): Promise<OsSessionId>;
|
|
84
|
+
/** The call the OS is showing as incoming/dialling is now connected. */
|
|
85
|
+
reportConnected(sessionId: OsSessionId): Promise<void>;
|
|
86
|
+
/** Must not re-emit `end` — the bridge is telling the OS, not the other way round. */
|
|
87
|
+
reportEnded(sessionId: OsSessionId, reason: OsEndReason): Promise<void>;
|
|
88
|
+
/** Remote-initiated state only; the OS already knows about its own actions. */
|
|
89
|
+
setHeld(sessionId: OsSessionId, held: boolean): Promise<void>;
|
|
90
|
+
setMuted(sessionId: OsSessionId, muted: boolean): Promise<void>;
|
|
91
|
+
/** The session the OS currently treats as active, if any. */
|
|
92
|
+
getActiveSession(): Promise<OsActiveSession | null>;
|
|
93
|
+
/**
|
|
94
|
+
* The app's own Answer/End buttons go through the OS too, so every answer —
|
|
95
|
+
* lock screen, notification, car, watch, in-app — arrives as the same
|
|
96
|
+
* `answer`/`end` event and there is one code path, not two.
|
|
97
|
+
*/
|
|
98
|
+
answer(sessionId: OsSessionId): Promise<void>;
|
|
99
|
+
end(sessionId: OsSessionId): Promise<void>;
|
|
100
|
+
on<K extends OsCallEventName>(event: K, listener: OsCallEvents[K]): () => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface FakeOsCallAdapterOptions {
|
|
104
|
+
/** expo-callkit-telecom re-emits its own set* actions; default on so the bridge is tested against that. */
|
|
105
|
+
echoSetActions?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Whether the OS library accepts more than one concurrent call session. Default
|
|
108
|
+
* false models the shipped expo-callkit-telecom / callkeep adapters, which
|
|
109
|
+
* reject a second reportIncoming ("one call at a time"). Set true to model a
|
|
110
|
+
* library that accepts call-waiting — used to test that the bridge itself stays
|
|
111
|
+
* agnostic and reports a second call rather than enforcing single-call.
|
|
112
|
+
*/
|
|
113
|
+
multiCall?: boolean;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* In-memory OS. The `os` side (`nativeReportIncoming`, `answer`, `end`, …)
|
|
117
|
+
* plays the platform: it is what a test drives, and what a real adapter's
|
|
118
|
+
* native code does on a device.
|
|
119
|
+
*/
|
|
120
|
+
declare class FakeOsCallAdapter implements OsCallAdapter {
|
|
121
|
+
private readonly listeners;
|
|
122
|
+
private readonly queued;
|
|
123
|
+
private readonly sessions;
|
|
124
|
+
private readonly echo;
|
|
125
|
+
private readonly multiCall;
|
|
126
|
+
private minted;
|
|
127
|
+
readonly log: string[];
|
|
128
|
+
private readonly injectedFailures;
|
|
129
|
+
private readonly ignored;
|
|
130
|
+
constructor(options?: FakeOsCallAdapterOptions);
|
|
131
|
+
reportIncoming(call: OsIncomingCall): Promise<OsSessionId>;
|
|
132
|
+
reportOutgoing(call: OsOutgoingCall): Promise<OsSessionId>;
|
|
133
|
+
reportConnected(sessionId: OsSessionId): Promise<void>;
|
|
134
|
+
reportEnded(sessionId: OsSessionId, reason: OsEndReason): Promise<void>;
|
|
135
|
+
setHeld(sessionId: OsSessionId, held: boolean): Promise<void>;
|
|
136
|
+
setMuted(sessionId: OsSessionId, muted: boolean): Promise<void>;
|
|
137
|
+
getActiveSession(): Promise<OsActiveSession | null>;
|
|
138
|
+
private view;
|
|
139
|
+
on<K extends OsCallEventName>(event: K, listener: OsCallEvents[K]): () => void;
|
|
140
|
+
/**
|
|
141
|
+
* Native reported the call before any JS existed (FCM service / PushKit handler).
|
|
142
|
+
*
|
|
143
|
+
* `callId` is the call_id the wake push carried, reported on
|
|
144
|
+
* `incomingReported` so the bridge can pair the session with its call. Pass
|
|
145
|
+
* `callId: null` for a push that carried none.
|
|
146
|
+
*/
|
|
147
|
+
nativeReportIncoming(session: OsActiveSession): void;
|
|
148
|
+
/** The user dialled from the OS's own UI (Recents / Siri / dialer). */
|
|
149
|
+
nativeCallIntent(handle: string): void;
|
|
150
|
+
/**
|
|
151
|
+
* The user answered, from the OS surface or via the adapter's `answer`.
|
|
152
|
+
*
|
|
153
|
+
* Modelled as a REQUEST, which is what both platforms do: CallKit's
|
|
154
|
+
* CXAnswerCallAction and Telecom's answer leave the call alerting until the
|
|
155
|
+
* app reports it connected. Forgetting that report is invisible in a unit test
|
|
156
|
+
* that only checks the SDK call answered — but on a device it is a phone that
|
|
157
|
+
* never stops ringing, so the fake tracks it and `stillAlerting()` asserts it.
|
|
158
|
+
*/
|
|
159
|
+
answer(sessionId: OsSessionId): Promise<void>;
|
|
160
|
+
/**
|
|
161
|
+
* Sessions the user answered that were never reported connected — the OS is
|
|
162
|
+
* still showing its incoming UI for each one.
|
|
163
|
+
*/
|
|
164
|
+
stillAlerting(): OsSessionId[];
|
|
165
|
+
/** The user ended/declined from the OS surface. The session is gone from the OS's view. */
|
|
166
|
+
end(sessionId: OsSessionId): Promise<void>;
|
|
167
|
+
hold(sessionId: OsSessionId, held: boolean): void;
|
|
168
|
+
mute(sessionId: OsSessionId, muted: boolean): void;
|
|
169
|
+
dtmf(sessionId: OsSessionId, digits: string): void;
|
|
170
|
+
/**
|
|
171
|
+
* The OS dropped a report it accepted: the session stays where it was, as iOS
|
|
172
|
+
* leaves an incoming call ringing after a connected report.
|
|
173
|
+
*/
|
|
174
|
+
ignoreNext(op: 'reportConnected' | 'setHeld', times?: number): void;
|
|
175
|
+
isConnected(sessionId: OsSessionId): boolean;
|
|
176
|
+
sessionCount(): number;
|
|
177
|
+
/** Make the next `times` calls to adapter method `op` reject, as a real OS layer can. */
|
|
178
|
+
failNext(op: keyof OsCallAdapter, times?: number): void;
|
|
179
|
+
private maybeFail;
|
|
180
|
+
private takeIgnored;
|
|
181
|
+
private require;
|
|
182
|
+
private emit;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The OS side of an adapter under test: what native code or the user does.
|
|
187
|
+
* For the fake this is the fake itself; for a real adapter it is whatever the
|
|
188
|
+
* integrator's test rig uses to drive the platform (a mocked native module, or
|
|
189
|
+
* a device).
|
|
190
|
+
*/
|
|
191
|
+
interface OsCallHarness {
|
|
192
|
+
nativeReportIncoming(session: OsActiveSession): void | Promise<void>;
|
|
193
|
+
}
|
|
194
|
+
interface OsCallAdapterFixture {
|
|
195
|
+
adapter: OsCallAdapter;
|
|
196
|
+
os: OsCallHarness;
|
|
197
|
+
}
|
|
198
|
+
/** The subset of jest/vitest the suite needs, injected so the SDK declares no test-runner types. */
|
|
199
|
+
interface TestApi {
|
|
200
|
+
describe: (name: string, fn: () => void) => void;
|
|
201
|
+
it: (name: string, fn: () => Promise<void>) => void;
|
|
202
|
+
expect: (actual: unknown) => {
|
|
203
|
+
toBe(expected: unknown): void;
|
|
204
|
+
toEqual(expected: unknown): void;
|
|
205
|
+
toBeNull(): void;
|
|
206
|
+
toHaveLength(n: number): void;
|
|
207
|
+
toBeLessThanOrEqual(n: number): void;
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* What the bridge assumes of every adapter. An adapter that passes this can be
|
|
212
|
+
* dropped in for the shipped one; one that fails it will show up as the
|
|
213
|
+
* intermittent "answer does nothing" class of bug, so run it before a device.
|
|
214
|
+
*/
|
|
215
|
+
declare function runOsCallAdapterContract(name: string, t: TestApi, fixture: () => OsCallAdapterFixture | Promise<OsCallAdapterFixture>): void;
|
|
216
|
+
|
|
217
|
+
type CallState = 'trying' | 'ringing' | 'active' | 'held' | 'ended';
|
|
218
|
+
type CallEndReason = 'hangup' | 'no-answer' | 'busy' | 'failed' | 'transferred' | 'rejected';
|
|
219
|
+
type HeldBy = 'local' | 'remote';
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* What the bridge needs from a call. Overloads rather than a generic `on`: a
|
|
223
|
+
* generic over a narrower event map is not assignable from the SDK's wider one.
|
|
224
|
+
*/
|
|
225
|
+
interface BridgeCall {
|
|
226
|
+
readonly id: string;
|
|
227
|
+
readonly from: string;
|
|
228
|
+
readonly fromName: string | null;
|
|
229
|
+
readonly state: CallState;
|
|
230
|
+
readonly isMuted: boolean;
|
|
231
|
+
answer(): void;
|
|
232
|
+
reject(reason?: 'busy' | 'decline'): void;
|
|
233
|
+
hangup(): void;
|
|
234
|
+
hold(): void;
|
|
235
|
+
resume(): void;
|
|
236
|
+
mute(): void;
|
|
237
|
+
unmute(): void;
|
|
238
|
+
sendDtmf(digits: string): void;
|
|
239
|
+
on(event: 'answered' | 'resumed', handler: () => void): void;
|
|
240
|
+
on(event: 'held', handler: (by: HeldBy) => void): void;
|
|
241
|
+
on(event: 'ended', handler: (reason: CallEndReason) => void): void;
|
|
242
|
+
off(event: 'held' | 'resumed' | 'ended', handler: (...args: never[]) => void): void;
|
|
243
|
+
}
|
|
244
|
+
interface BridgePhoneError {
|
|
245
|
+
code?: string;
|
|
246
|
+
message?: string;
|
|
247
|
+
fatal?: boolean;
|
|
248
|
+
callId?: string | null;
|
|
249
|
+
}
|
|
250
|
+
/** What the bridge needs from the phone — `DialStackPhone` satisfies it. */
|
|
251
|
+
interface BridgePhone {
|
|
252
|
+
readonly isConnected: boolean;
|
|
253
|
+
connect(): Promise<void>;
|
|
254
|
+
disconnect(): void;
|
|
255
|
+
setToken(token: string): void;
|
|
256
|
+
/** Place an outbound call. The bridge reports it to the OS around this. */
|
|
257
|
+
call(destination: string): Promise<BridgeCall>;
|
|
258
|
+
on(event: 'connected' | 'reconnected', handler: () => void): void;
|
|
259
|
+
on(event: 'disconnected', handler: (error?: unknown) => void): void;
|
|
260
|
+
on(event: 'reconnecting', handler: (attempt: number, delayMs: number) => void): void;
|
|
261
|
+
on(event: 'incoming', handler: (call: BridgeCall) => void): void;
|
|
262
|
+
on(event: 'error', handler: (err: BridgePhoneError) => void): void;
|
|
263
|
+
}
|
|
264
|
+
type AppLifecycleState = 'active' | 'background' | 'inactive' | 'unknown' | 'extension';
|
|
265
|
+
/** Foreground/background as a port so the bridge has no react-native import. */
|
|
266
|
+
interface AppLifecycle {
|
|
267
|
+
onChange(listener: (state: AppLifecycleState) => void): () => void;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
declare class FakeCall implements BridgeCall {
|
|
271
|
+
readonly id: string;
|
|
272
|
+
readonly from: string;
|
|
273
|
+
state: CallState;
|
|
274
|
+
isMuted: boolean;
|
|
275
|
+
readonly fromName: string | null;
|
|
276
|
+
readonly actions: string[];
|
|
277
|
+
private readonly handlers;
|
|
278
|
+
constructor(id: string, from?: string);
|
|
279
|
+
/**
|
|
280
|
+
* Off: hold, resume and hangup only send, like the SDK's; the outcome comes
|
|
281
|
+
* later as an event or a phone error. On: they take effect at once.
|
|
282
|
+
*/
|
|
283
|
+
autoConfirm: boolean;
|
|
284
|
+
/** Set to make answer() throw once, for the answer-failure path. */
|
|
285
|
+
failAnswer: boolean;
|
|
286
|
+
/** Make the next call to one of these fail the way the SDK's does. */
|
|
287
|
+
readonly failNext: Partial<Record<'reject' | 'hold' | 'resume' | 'mute' | 'unmute' | 'dtmf', Error>>;
|
|
288
|
+
private takeFailure;
|
|
289
|
+
answer(): void;
|
|
290
|
+
reject(reason?: 'busy' | 'decline'): void;
|
|
291
|
+
hangup(): void;
|
|
292
|
+
hold(): void;
|
|
293
|
+
resume(): void;
|
|
294
|
+
mute(): void;
|
|
295
|
+
unmute(): void;
|
|
296
|
+
sendDtmf(digits: string): void;
|
|
297
|
+
on(event: string, handler: (...args: never[]) => void): void;
|
|
298
|
+
off(event: string, handler: (...args: never[]) => void): void;
|
|
299
|
+
emitAnswered(): void;
|
|
300
|
+
emitHeld(by: HeldBy): void;
|
|
301
|
+
emitResumed(): void;
|
|
302
|
+
emitEnded(reason: CallEndReason): void;
|
|
303
|
+
private emit;
|
|
304
|
+
}
|
|
305
|
+
declare class FakePhone implements BridgePhone {
|
|
306
|
+
isConnected: boolean;
|
|
307
|
+
connectCalls: number;
|
|
308
|
+
disconnectCalls: number;
|
|
309
|
+
/** Set before connect() is called to make it fail. */
|
|
310
|
+
failConnectWith: Error | null;
|
|
311
|
+
/** Set to hold connect() open until `resolveConnect()`. */
|
|
312
|
+
manualConnect: boolean;
|
|
313
|
+
private resolvePending;
|
|
314
|
+
private pendingConnect;
|
|
315
|
+
private readonly handlers;
|
|
316
|
+
connect(): Promise<void>;
|
|
317
|
+
resolveConnect(): void;
|
|
318
|
+
tokens: string[];
|
|
319
|
+
setToken(token: string): void;
|
|
320
|
+
/** Set to make the next call() reject, for the placement-failure path. */
|
|
321
|
+
failCallWith: Error | null;
|
|
322
|
+
outboundCalls: string[];
|
|
323
|
+
callResult: FakeCall | null;
|
|
324
|
+
/** Set to hold call() open until `resolveCall()`. */
|
|
325
|
+
manualCall: boolean;
|
|
326
|
+
private resolvePendingCall;
|
|
327
|
+
call(destination: string): Promise<FakeCall>;
|
|
328
|
+
resolveCall(): void;
|
|
329
|
+
disconnect(): void;
|
|
330
|
+
/**
|
|
331
|
+
* The OS froze the backgrounded app and killed the socket with no close frame:
|
|
332
|
+
* the transport is dead but `isConnected` still reads true and NO 'disconnected'
|
|
333
|
+
* fires. Models the corpse the wake path must not trust.
|
|
334
|
+
*/
|
|
335
|
+
killSocketSilently(): void;
|
|
336
|
+
/**
|
|
337
|
+
* The socket dropped and the transport is between automatic reconnect attempts:
|
|
338
|
+
* neither connected nor connecting, and no 'disconnected' fires.
|
|
339
|
+
*/
|
|
340
|
+
startReconnecting(): void;
|
|
341
|
+
on(event: string, handler: (...args: never[]) => void): void;
|
|
342
|
+
emitIncoming(call: FakeCall): void;
|
|
343
|
+
/** A server error frame, which the SDK names a call on when it can. */
|
|
344
|
+
emitError(err: {
|
|
345
|
+
code?: string;
|
|
346
|
+
message?: string;
|
|
347
|
+
callId?: string | null;
|
|
348
|
+
}): void;
|
|
349
|
+
private emit;
|
|
350
|
+
}
|
|
351
|
+
declare class FakeLifecycle implements AppLifecycle {
|
|
352
|
+
private listeners;
|
|
353
|
+
onChange(listener: (state: AppLifecycleState) => void): () => void;
|
|
354
|
+
set(state: AppLifecycleState): void;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Test-only: the app never has a valid reason to forget its phone. */
|
|
358
|
+
declare function resetPhoneForTests(): void;
|
|
359
|
+
|
|
360
|
+
export { FakeCall, FakeLifecycle, FakeOsCallAdapter, FakePhone, resetPhoneForTests, runOsCallAdapterContract };
|
|
361
|
+
export type { FakeOsCallAdapterOptions, OsCallAdapterFixture, OsCallHarness, TestApi };
|