@voltkit-rtc/react-native 0.0.0-stage → 0.1.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.
Files changed (225) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1608 -2
  4. package/android/build.gradle +70 -0
  5. package/android/consumer-rules.pro +21 -0
  6. package/android/src/main/AndroidManifest.xml +25 -0
  7. package/android/src/main/java/com/voltkit/rtc/reactnative/AppVisibility.kt +67 -0
  8. package/android/src/main/java/com/voltkit/rtc/reactnative/AudioDevices.kt +67 -0
  9. package/android/src/main/java/com/voltkit/rtc/reactnative/AudioRouter.kt +487 -0
  10. package/android/src/main/java/com/voltkit/rtc/reactnative/CallServiceTypes.kt +120 -0
  11. package/android/src/main/java/com/voltkit/rtc/reactnative/NativeTimers.kt +106 -0
  12. package/android/src/main/java/com/voltkit/rtc/reactnative/NetworkWatch.kt +120 -0
  13. package/android/src/main/java/com/voltkit/rtc/reactnative/OutputChoice.kt +65 -0
  14. package/android/src/main/java/com/voltkit/rtc/reactnative/RtcConfig.kt +83 -0
  15. package/android/src/main/java/com/voltkit/rtc/reactnative/ScoLink.kt +61 -0
  16. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitCallService.kt +287 -0
  17. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcInitProvider.kt +215 -0
  18. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcModule.kt +313 -0
  19. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcPackage.kt +30 -0
  20. package/android/src/main/res/drawable/voltkit_rtc_default_call_icon.xml +15 -0
  21. package/android/src/main/res/raw/voltkit_rtc_keep.xml +7 -0
  22. package/app.plugin.js +17 -0
  23. package/lib/commonjs/audio.js +215 -0
  24. package/lib/commonjs/audio.js.map +1 -0
  25. package/lib/commonjs/components.js +207 -0
  26. package/lib/commonjs/components.js.map +1 -0
  27. package/lib/commonjs/create-client.js +47 -0
  28. package/lib/commonjs/create-client.js.map +1 -0
  29. package/lib/commonjs/errors.js +99 -0
  30. package/lib/commonjs/errors.js.map +1 -0
  31. package/lib/commonjs/globals.js +61 -0
  32. package/lib/commonjs/globals.js.map +1 -0
  33. package/lib/commonjs/index.js +234 -0
  34. package/lib/commonjs/index.js.map +1 -0
  35. package/lib/commonjs/local-media.js +392 -0
  36. package/lib/commonjs/local-media.js.map +1 -0
  37. package/lib/commonjs/native-session.js +365 -0
  38. package/lib/commonjs/native-session.js.map +1 -0
  39. package/lib/commonjs/native.js +99 -0
  40. package/lib/commonjs/native.js.map +1 -0
  41. package/lib/commonjs/package.json +1 -0
  42. package/lib/commonjs/permissions.js +166 -0
  43. package/lib/commonjs/permissions.js.map +1 -0
  44. package/lib/commonjs/platform/android.js +126 -0
  45. package/lib/commonjs/platform/android.js.map +1 -0
  46. package/lib/commonjs/platform/common.js +138 -0
  47. package/lib/commonjs/platform/common.js.map +1 -0
  48. package/lib/commonjs/platform/devices.js +151 -0
  49. package/lib/commonjs/platform/devices.js.map +1 -0
  50. package/lib/commonjs/platform/index.js +35 -0
  51. package/lib/commonjs/platform/index.js.map +1 -0
  52. package/lib/commonjs/platform/ios.js +54 -0
  53. package/lib/commonjs/platform/ios.js.map +1 -0
  54. package/lib/commonjs/platform/streams.js +273 -0
  55. package/lib/commonjs/platform/streams.js.map +1 -0
  56. package/lib/commonjs/platform/timers.js +71 -0
  57. package/lib/commonjs/platform/timers.js.map +1 -0
  58. package/lib/commonjs/platform/visibility.js +60 -0
  59. package/lib/commonjs/platform/visibility.js.map +1 -0
  60. package/lib/commonjs/preflight.js +77 -0
  61. package/lib/commonjs/preflight.js.map +1 -0
  62. package/lib/commonjs/provider.js +75 -0
  63. package/lib/commonjs/provider.js.map +1 -0
  64. package/lib/commonjs/specs/NativeVoltkitRtc.js +29 -0
  65. package/lib/commonjs/specs/NativeVoltkitRtc.js.map +1 -0
  66. package/lib/module/audio.js +207 -0
  67. package/lib/module/audio.js.map +1 -0
  68. package/lib/module/components.js +200 -0
  69. package/lib/module/components.js.map +1 -0
  70. package/lib/module/create-client.js +42 -0
  71. package/lib/module/create-client.js.map +1 -0
  72. package/lib/module/errors.js +87 -0
  73. package/lib/module/errors.js.map +1 -0
  74. package/lib/module/globals.js +53 -0
  75. package/lib/module/globals.js.map +1 -0
  76. package/lib/module/index.js +33 -0
  77. package/lib/module/index.js.map +1 -0
  78. package/lib/module/local-media.js +387 -0
  79. package/lib/module/local-media.js.map +1 -0
  80. package/lib/module/native-session.js +360 -0
  81. package/lib/module/native-session.js.map +1 -0
  82. package/lib/module/native.js +87 -0
  83. package/lib/module/native.js.map +1 -0
  84. package/lib/module/package.json +1 -0
  85. package/lib/module/permissions.js +160 -0
  86. package/lib/module/permissions.js.map +1 -0
  87. package/lib/module/platform/android.js +122 -0
  88. package/lib/module/platform/android.js.map +1 -0
  89. package/lib/module/platform/common.js +128 -0
  90. package/lib/module/platform/common.js.map +1 -0
  91. package/lib/module/platform/devices.js +142 -0
  92. package/lib/module/platform/devices.js.map +1 -0
  93. package/lib/module/platform/index.js +24 -0
  94. package/lib/module/platform/index.js.map +1 -0
  95. package/lib/module/platform/ios.js +49 -0
  96. package/lib/module/platform/ios.js.map +1 -0
  97. package/lib/module/platform/streams.js +268 -0
  98. package/lib/module/platform/streams.js.map +1 -0
  99. package/lib/module/platform/timers.js +67 -0
  100. package/lib/module/platform/timers.js.map +1 -0
  101. package/lib/module/platform/visibility.js +54 -0
  102. package/lib/module/platform/visibility.js.map +1 -0
  103. package/lib/module/preflight.js +72 -0
  104. package/lib/module/preflight.js.map +1 -0
  105. package/lib/module/provider.js +70 -0
  106. package/lib/module/provider.js.map +1 -0
  107. package/lib/module/specs/NativeVoltkitRtc.js +37 -0
  108. package/lib/module/specs/NativeVoltkitRtc.js.map +1 -0
  109. package/lib/typescript/commonjs/audio.d.ts +58 -0
  110. package/lib/typescript/commonjs/audio.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/components.d.ts +58 -0
  112. package/lib/typescript/commonjs/components.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/create-client.d.ts +35 -0
  114. package/lib/typescript/commonjs/create-client.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/errors.d.ts +34 -0
  116. package/lib/typescript/commonjs/errors.d.ts.map +1 -0
  117. package/lib/typescript/commonjs/globals.d.ts +16 -0
  118. package/lib/typescript/commonjs/globals.d.ts.map +1 -0
  119. package/lib/typescript/commonjs/index.d.ts +24 -0
  120. package/lib/typescript/commonjs/index.d.ts.map +1 -0
  121. package/lib/typescript/commonjs/local-media.d.ts +86 -0
  122. package/lib/typescript/commonjs/local-media.d.ts.map +1 -0
  123. package/lib/typescript/commonjs/native-session.d.ts +38 -0
  124. package/lib/typescript/commonjs/native-session.d.ts.map +1 -0
  125. package/lib/typescript/commonjs/native.d.ts +18 -0
  126. package/lib/typescript/commonjs/native.d.ts.map +1 -0
  127. package/lib/typescript/commonjs/package.json +1 -0
  128. package/lib/typescript/commonjs/permissions.d.ts +30 -0
  129. package/lib/typescript/commonjs/permissions.d.ts.map +1 -0
  130. package/lib/typescript/commonjs/platform/android.d.ts +4 -0
  131. package/lib/typescript/commonjs/platform/android.d.ts.map +1 -0
  132. package/lib/typescript/commonjs/platform/common.d.ts +101 -0
  133. package/lib/typescript/commonjs/platform/common.d.ts.map +1 -0
  134. package/lib/typescript/commonjs/platform/devices.d.ts +42 -0
  135. package/lib/typescript/commonjs/platform/devices.d.ts.map +1 -0
  136. package/lib/typescript/commonjs/platform/index.d.ts +6 -0
  137. package/lib/typescript/commonjs/platform/index.d.ts.map +1 -0
  138. package/lib/typescript/commonjs/platform/ios.d.ts +4 -0
  139. package/lib/typescript/commonjs/platform/ios.d.ts.map +1 -0
  140. package/lib/typescript/commonjs/platform/streams.d.ts +82 -0
  141. package/lib/typescript/commonjs/platform/streams.d.ts.map +1 -0
  142. package/lib/typescript/commonjs/platform/timers.d.ts +19 -0
  143. package/lib/typescript/commonjs/platform/timers.d.ts.map +1 -0
  144. package/lib/typescript/commonjs/platform/visibility.d.ts +11 -0
  145. package/lib/typescript/commonjs/platform/visibility.d.ts.map +1 -0
  146. package/lib/typescript/commonjs/preflight.d.ts +19 -0
  147. package/lib/typescript/commonjs/preflight.d.ts.map +1 -0
  148. package/lib/typescript/commonjs/provider.d.ts +35 -0
  149. package/lib/typescript/commonjs/provider.d.ts.map +1 -0
  150. package/lib/typescript/commonjs/specs/NativeVoltkitRtc.d.ts +157 -0
  151. package/lib/typescript/commonjs/specs/NativeVoltkitRtc.d.ts.map +1 -0
  152. package/lib/typescript/module/audio.d.ts +58 -0
  153. package/lib/typescript/module/audio.d.ts.map +1 -0
  154. package/lib/typescript/module/components.d.ts +58 -0
  155. package/lib/typescript/module/components.d.ts.map +1 -0
  156. package/lib/typescript/module/create-client.d.ts +35 -0
  157. package/lib/typescript/module/create-client.d.ts.map +1 -0
  158. package/lib/typescript/module/errors.d.ts +34 -0
  159. package/lib/typescript/module/errors.d.ts.map +1 -0
  160. package/lib/typescript/module/globals.d.ts +16 -0
  161. package/lib/typescript/module/globals.d.ts.map +1 -0
  162. package/lib/typescript/module/index.d.ts +24 -0
  163. package/lib/typescript/module/index.d.ts.map +1 -0
  164. package/lib/typescript/module/local-media.d.ts +86 -0
  165. package/lib/typescript/module/local-media.d.ts.map +1 -0
  166. package/lib/typescript/module/native-session.d.ts +38 -0
  167. package/lib/typescript/module/native-session.d.ts.map +1 -0
  168. package/lib/typescript/module/native.d.ts +18 -0
  169. package/lib/typescript/module/native.d.ts.map +1 -0
  170. package/lib/typescript/module/package.json +1 -0
  171. package/lib/typescript/module/permissions.d.ts +30 -0
  172. package/lib/typescript/module/permissions.d.ts.map +1 -0
  173. package/lib/typescript/module/platform/android.d.ts +4 -0
  174. package/lib/typescript/module/platform/android.d.ts.map +1 -0
  175. package/lib/typescript/module/platform/common.d.ts +101 -0
  176. package/lib/typescript/module/platform/common.d.ts.map +1 -0
  177. package/lib/typescript/module/platform/devices.d.ts +42 -0
  178. package/lib/typescript/module/platform/devices.d.ts.map +1 -0
  179. package/lib/typescript/module/platform/index.d.ts +6 -0
  180. package/lib/typescript/module/platform/index.d.ts.map +1 -0
  181. package/lib/typescript/module/platform/ios.d.ts +4 -0
  182. package/lib/typescript/module/platform/ios.d.ts.map +1 -0
  183. package/lib/typescript/module/platform/streams.d.ts +82 -0
  184. package/lib/typescript/module/platform/streams.d.ts.map +1 -0
  185. package/lib/typescript/module/platform/timers.d.ts +19 -0
  186. package/lib/typescript/module/platform/timers.d.ts.map +1 -0
  187. package/lib/typescript/module/platform/visibility.d.ts +11 -0
  188. package/lib/typescript/module/platform/visibility.d.ts.map +1 -0
  189. package/lib/typescript/module/preflight.d.ts +19 -0
  190. package/lib/typescript/module/preflight.d.ts.map +1 -0
  191. package/lib/typescript/module/provider.d.ts +35 -0
  192. package/lib/typescript/module/provider.d.ts.map +1 -0
  193. package/lib/typescript/module/specs/NativeVoltkitRtc.d.ts +157 -0
  194. package/lib/typescript/module/specs/NativeVoltkitRtc.d.ts.map +1 -0
  195. package/package.json +140 -4
  196. package/plugin/build/android.d.ts +36 -0
  197. package/plugin/build/android.js +265 -0
  198. package/plugin/build/index.d.ts +9 -0
  199. package/plugin/build/index.js +54 -0
  200. package/plugin/build/ios.d.ts +4 -0
  201. package/plugin/build/ios.js +43 -0
  202. package/plugin/build/options.d.ts +93 -0
  203. package/plugin/build/options.js +140 -0
  204. package/react-native.config.js +11 -0
  205. package/src/audio.ts +221 -0
  206. package/src/components.tsx +233 -0
  207. package/src/create-client.ts +53 -0
  208. package/src/errors.ts +81 -0
  209. package/src/globals.ts +54 -0
  210. package/src/index.ts +95 -0
  211. package/src/local-media.ts +420 -0
  212. package/src/native-session.tsx +384 -0
  213. package/src/native.ts +84 -0
  214. package/src/permissions.ts +173 -0
  215. package/src/platform/android.ts +128 -0
  216. package/src/platform/common.ts +157 -0
  217. package/src/platform/devices.ts +148 -0
  218. package/src/platform/index.ts +22 -0
  219. package/src/platform/ios.ts +48 -0
  220. package/src/platform/streams.ts +285 -0
  221. package/src/platform/timers.ts +72 -0
  222. package/src/platform/visibility.ts +59 -0
  223. package/src/preflight.ts +60 -0
  224. package/src/provider.tsx +88 -0
  225. package/src/specs/NativeVoltkitRtc.ts +180 -0
@@ -0,0 +1,384 @@
1
+ /**
2
+ * What a connected call needs from the device on Android, kept by the provider for its client:
3
+ *
4
+ * - The call audio (audio.ts): held from the join to the close, so the audio mode, focus and routing are a call's; two
5
+ * clients (a PK battle) hold it together and the last out restores the device.
6
+ * - The call service: a foreground service that keeps the call's network, microphone and timers alive while the app is
7
+ * in the background or the screen is off. Without it Android cuts the app's sockets about 6 s after it leaves the
8
+ * foreground or the screen goes off (Phase 0 finding 3). It runs while any call is live, for every client together
9
+ * (one notification), when the app's manifest declares it (the Expo plugin's callService, default on). Android lets
10
+ * it start, and take new foreground service types (the camera, a screen share), only while the app is visible: it
11
+ * starts on the join and is brought up to date whenever the app comes back to the front. Its notification's Leave
12
+ * action, and the app being swiped away from Recents, close every client it runs for (close code 1000, `left`).
13
+ * Failures reach the provider's onError as `media-unavailable` with `{ feature: 'callService' }`.
14
+ */
15
+ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
16
+ import type { ClientOptions, StreamClient } from '@voltkit-rtc/client';
17
+ import { useVoltkitClient } from '@voltkit-rtc/react';
18
+ import type { CallServiceOptions as NativeCallServiceOptions, CallServiceStatus, Spec } from './specs/NativeVoltkitRtc';
19
+ import { listen, nativeConstants, nativeModule } from './native';
20
+ import { callServiceFailed, describeError, logError } from './errors';
21
+ import { audioHoldOf, speakerPreference } from './audio';
22
+ import { localSessionOf } from './local-media';
23
+ import { nativeVisibility } from './platform/visibility';
24
+ import { localStream } from './platform/common';
25
+
26
+ /** The call service's notification. */
27
+ export interface CallServiceText {
28
+ /** Default "Call in progress". */
29
+ title?: string;
30
+ /** Default "Tap to return to the call". */
31
+ text?: string;
32
+ /** The Leave action's label. Default "Leave". */
33
+ leaveLabel?: string;
34
+ }
35
+
36
+ /** How the provider manages the call audio. */
37
+ export interface AudioSessionOptions {
38
+ /** The loudspeaker (true, the default) or the earpiece when no headset is connected, unless setSpeakerphoneOn chose. */
39
+ speakerphone?: boolean;
40
+ /** Hold the call audio (mode, focus, routing) while the call is connected. Default true. */
41
+ manage?: boolean;
42
+ }
43
+
44
+ // ----------------------------------------------------------------------------------------- the call service, shared
45
+
46
+ interface Want {
47
+ live: boolean;
48
+ micPublished: boolean;
49
+ cameraPublished: boolean;
50
+ sharing: boolean;
51
+ /** The client switches its camera off in the background, so the service needs no camera type. */
52
+ pauseCamera: boolean;
53
+ text: CallServiceText;
54
+ leave(): void;
55
+ report(error: Error): void;
56
+ }
57
+
58
+ export interface CallServiceState {
59
+ /** The app's manifest declares the call service (VoltkitCallService). */
60
+ declared: boolean;
61
+ /** It runs now. */
62
+ running: boolean;
63
+ /** The foreground service types it runs with: `microphone`, `mediaPlayback`, `camera`, `mediaProjection`. */
64
+ types: string[];
65
+ }
66
+
67
+ const NOT_DECLARED: CallServiceState = Object.freeze({ declared: false, running: false, types: Object.freeze([]) as unknown as string[] });
68
+
69
+ class CallServices {
70
+ /**
71
+ * What each call wants, read afresh at every decision: a call's state is its client's, which emits no event for some
72
+ * changes. With how to tell the call when its state cannot be read at all.
73
+ */
74
+ private readonly wants = new Map<object, { want: () => Want; report: (error: Error) => void }>();
75
+ private running = false;
76
+ /** The call to the native module under way: a `failed` event during a start or update is its rejection's twin. */
77
+ private inFlight: 'start' | 'update' | 'stop' | null = null;
78
+ private again = false;
79
+ /** The options the service runs with, as sent. */
80
+ private applied: string | null = null;
81
+ /** Options a start failed for: not tried again until they change or the app comes back to the front. */
82
+ private failed: string | null = null;
83
+ private watching: (() => void) | null = null;
84
+ private state: CallServiceState | null = null;
85
+ private readonly listeners = new Set<() => void>();
86
+
87
+ set(key: object, want: (() => Want) | null, report: (error: Error) => void = () => undefined): void {
88
+ if (want) this.wants.set(key, { want, report });
89
+ else this.wants.delete(key);
90
+ this.reconcile();
91
+ }
92
+
93
+ /** Something a call publishes may have changed: decide again. */
94
+ changed(): void {
95
+ this.reconcile();
96
+ }
97
+
98
+ subscribe = (listener: () => void): (() => void) => {
99
+ this.listeners.add(listener);
100
+ return () => {
101
+ this.listeners.delete(listener);
102
+ };
103
+ };
104
+
105
+ snapshot = (): CallServiceState => {
106
+ if (this.state) return this.state;
107
+ const module = nativeModule();
108
+ if (!module || !nativeConstants()?.callServiceDeclared) return NOT_DECLARED;
109
+ let status: CallServiceStatus = { declared: true, running: false, types: [] };
110
+ try {
111
+ status = module.getCallServiceStatus();
112
+ } catch (err) {
113
+ logError('could not read the call service\'s status', err);
114
+ }
115
+ this.state = { declared: status.declared, running: status.running, types: [...status.types] };
116
+ return this.state;
117
+ };
118
+
119
+ private publish(status: Partial<CallServiceState>): void {
120
+ const now = this.snapshot();
121
+ const next = { ...now, ...status };
122
+ if (next.running === now.running && next.declared === now.declared && next.types.join() === now.types.join()) return;
123
+ this.state = next;
124
+ for (const listener of [...this.listeners]) listener();
125
+ }
126
+
127
+ private live(): Want[] {
128
+ const live: Want[] = [];
129
+ for (const [key, { want, report }] of [...this.wants]) {
130
+ let w: Want;
131
+ try {
132
+ w = want();
133
+ } catch (err) {
134
+ // A client whose state cannot be read (a wrapper that throws on a read): this call is left out of the service
135
+ // for good, said once, and never keeps the other calls from theirs (the verifier's F3 on 0.1.1).
136
+ this.wants.delete(key);
137
+ logError('a call is left out of the call service: its client\'s state could not be read', err);
138
+ try {
139
+ report(callServiceFailed(`this call is left out, its client's state could not be read: ${describeError(err)}`));
140
+ } catch (reportErr) {
141
+ logError('onError threw', reportErr);
142
+ }
143
+ continue;
144
+ }
145
+ if (w.live) live.push(w);
146
+ }
147
+ return live;
148
+ }
149
+
150
+ private reconcile(): void {
151
+ const module = nativeModule();
152
+ if (!module || !nativeConstants()?.callServiceDeclared) return;
153
+ const live = this.live();
154
+ this.watch(module, live.length > 0 || this.running);
155
+ if (this.inFlight) {
156
+ this.again = true;
157
+ return;
158
+ }
159
+ if (live.length === 0) {
160
+ this.failed = null;
161
+ if (this.running || this.applied !== null) this.run(module, 'stop', null);
162
+ return;
163
+ }
164
+ const options = merged(live);
165
+ const key = JSON.stringify(options);
166
+ if ((this.running && key === this.applied) || key === this.failed) return;
167
+ // Android lets a foreground service start, or widen its types, only while the app is visible.
168
+ if (!safeVisible(module)) return;
169
+ this.run(module, this.running ? 'update' : 'start', options);
170
+ }
171
+
172
+ private run(module: Spec, what: 'start' | 'update' | 'stop', options: NativeCallServiceOptions | null): void {
173
+ this.inFlight = what;
174
+ const key = options ? JSON.stringify(options) : null;
175
+ const call = what === 'stop' ? module.stopCallService() : what === 'start' ? module.startCallService(options!) : module.updateCallService(options!);
176
+ call.then(
177
+ (status) => {
178
+ this.running = status.running;
179
+ this.applied = status.running ? key : null;
180
+ if (status.running) this.failed = null;
181
+ this.publish({ declared: status.declared, running: status.running, types: [...status.types] });
182
+ },
183
+ (err: unknown) => {
184
+ if (what === 'stop') {
185
+ logError('could not stop the call service', err);
186
+ return;
187
+ }
188
+ this.failed = key;
189
+ const error = callServiceFailed(describeError(err));
190
+ for (const want of this.live()) want.report(error);
191
+ },
192
+ ).finally(() => {
193
+ this.inFlight = null;
194
+ if (this.again) {
195
+ this.again = false;
196
+ this.reconcile();
197
+ return;
198
+ }
199
+ // Nothing left to keep running: stop listening until the next call.
200
+ if (!this.running && this.live().length === 0) this.watch(module, false);
201
+ });
202
+ }
203
+
204
+ /** Visibility and the service's own events, while a call is live or the service runs. */
205
+ private watch(module: Spec, on: boolean): void {
206
+ if (on === (this.watching !== null)) return;
207
+ if (!on) {
208
+ this.watching?.();
209
+ this.watching = null;
210
+ return;
211
+ }
212
+ const visibility = nativeVisibility(module);
213
+ const stops = [
214
+ visibility.subscribe((visible) => {
215
+ if (!visible) return;
216
+ // Back in front: try again what failed, and bring the service up to date.
217
+ this.failed = null;
218
+ this.reconcile();
219
+ }),
220
+ listen(module.onCallServiceEvent, 'onCallServiceEvent', (event) => this.onEvent(event.type, event.types, event.message)),
221
+ ];
222
+ this.watching = () => {
223
+ for (const stop of stops) stop();
224
+ };
225
+ }
226
+
227
+ private onEvent(type: string, types: string[], message: string): void {
228
+ switch (type) {
229
+ case 'started':
230
+ case 'updated':
231
+ this.running = true;
232
+ this.publish({ running: true, types: [...types] });
233
+ return;
234
+ case 'stopped':
235
+ this.running = false;
236
+ this.applied = null;
237
+ // Stopped from outside while a call is live: started again when the app next comes to the front, not now.
238
+ if (this.live().length > 0) this.failed = JSON.stringify(merged(this.live()));
239
+ this.publish({ running: false, types: [] });
240
+ this.reconcile();
241
+ return;
242
+ case 'leave':
243
+ case 'taskRemoved':
244
+ for (const want of this.live()) want.leave();
245
+ return;
246
+ case 'failed': {
247
+ // VoltkitCallService.runWith tells `failed` and then rejects the start or update: the rejection reports it (RN010-2).
248
+ if (this.inFlight === 'start' || this.inFlight === 'update') return;
249
+ const error = callServiceFailed(message || 'Android refused it');
250
+ for (const want of this.live()) want.report(error);
251
+ return;
252
+ }
253
+ default:
254
+ }
255
+ }
256
+ }
257
+
258
+ function safeVisible(module: Spec): boolean {
259
+ try {
260
+ return module.isAppVisible();
261
+ } catch {
262
+ return true;
263
+ }
264
+ }
265
+
266
+ /** What the service runs with for these calls together. */
267
+ function merged(live: Want[]): NativeCallServiceOptions {
268
+ const text = [...live].reverse();
269
+ const pick = (field: keyof CallServiceText) => text.find((w) => w.text[field])?.text[field] ?? '';
270
+ return {
271
+ title: pick('title'),
272
+ text: pick('text'),
273
+ leaveLabel: pick('leaveLabel'),
274
+ // The camera type only for a call that keeps its camera on in the background.
275
+ pauseCamera: !live.some((w) => w.cameraPublished && !w.pauseCamera),
276
+ micPublished: live.some((w) => w.micPublished),
277
+ cameraPublished: live.some((w) => w.cameraPublished),
278
+ sharing: live.some((w) => w.sharing),
279
+ };
280
+ }
281
+
282
+ const callServices = new CallServices();
283
+
284
+ /** The call service: whether the app declares it, whether it runs and with which types. */
285
+ export function useCallService(): CallServiceState {
286
+ return useSyncExternalStore(callServices.subscribe, callServices.snapshot, callServices.snapshot);
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------------------------- the provider's part
290
+
291
+ export interface NativeSessionProps {
292
+ audio: AudioSessionOptions | false | undefined;
293
+ callService: boolean | CallServiceText | undefined;
294
+ /** The provider's background.pauseCamera: for a client whose own options cannot be read (see pausesCameraInBackground). */
295
+ pauseCamera: boolean;
296
+ onError: { readonly current: ((error: Error) => void) | undefined };
297
+ }
298
+
299
+ /** Rendered by the provider inside @voltkit-rtc/react's provider: keeps the call audio and the call service for its client. */
300
+ export function NativeSession({ audio, callService, pauseCamera, onError }: NativeSessionProps): null {
301
+ const client = useVoltkitClient();
302
+ const manage = audio !== false && audio?.manage !== false;
303
+ const speakerphone = audio === false ? undefined : audio?.speakerphone;
304
+ const wantsService = callService !== false;
305
+ const text = useMemo(() => (typeof callService === 'object' && callService !== null ? callService : {}), [callService]);
306
+ const latest = useRef({ speakerphone, text, pauseCamera });
307
+ useEffect(() => {
308
+ latest.current = { speakerphone, text, pauseCamera };
309
+ });
310
+
311
+ // What useLocalMedia shows (the screen being shared) is recorded for as long as the provider has the client.
312
+ useEffect(() => (client ? localSessionOf(client).hold() : undefined), [client]);
313
+
314
+ useEffect(() => {
315
+ if (!client || !manage || !nativeModule()) return;
316
+ const hold = audioHoldOf(client, (error) => onError.current?.(error));
317
+ hold.retain();
318
+ const sync = () => {
319
+ const state = client.connectionState;
320
+ if (state === 'connected' || state === 'reconnecting') hold.acquire(speakerPreference(latest.current.speakerphone));
321
+ else if (state === 'closed') hold.release();
322
+ };
323
+ sync();
324
+ const off = client.on('connectionStateChanged', sync);
325
+ return () => {
326
+ off();
327
+ hold.unretain();
328
+ };
329
+ }, [client, manage, onError]);
330
+
331
+ useEffect(() => {
332
+ if (!client || !wantsService || !nativeModule()) return;
333
+ const key = {};
334
+ const report = (error: Error) => onError.current?.(error);
335
+ callServices.set(key, () => wantOf(client, latest.current, report), report);
336
+ const update = () => callServices.changed();
337
+ // publish() and a camera switch emit no event of their own: useLocalMedia's calls say so through the local session.
338
+ const offs = [
339
+ ...(['connectionStateChanged', 'localMediaChanged', 'localStreamChanged', 'screenShareStarted', 'screenShareEnded'] as const)
340
+ .map((event) => client.on(event, update)),
341
+ localSessionOf(client).subscribe(update),
342
+ ];
343
+ return () => {
344
+ for (const off of offs) off();
345
+ callServices.set(key, null);
346
+ };
347
+ }, [client, wantsService, onError]);
348
+
349
+ return null;
350
+ }
351
+
352
+ function wantOf(client: StreamClient, latest: { text: CallServiceText; pauseCamera: boolean }, report: (error: Error) => void): Want {
353
+ const state = client.connectionState;
354
+ const local = localStream(client);
355
+ return {
356
+ live: state === 'connected' || state === 'reconnecting',
357
+ micPublished: (local?.getAudioTracks().length ?? 0) > 0,
358
+ cameraPublished: (local?.getVideoTracks().length ?? 0) > 0,
359
+ sharing: localSessionOf(client).screen !== null,
360
+ pauseCamera: pausesCameraInBackground(client) ?? latest.pauseCamera,
361
+ text: latest.text,
362
+ leave: () => client.close(),
363
+ report,
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Whether the client switches its camera off in the background, from its own options: the provider's `background` only
369
+ * describes a client the provider made, and an app-made one (`createClient`, the `client` prop) keeps its camera on with
370
+ * `pauseCamera: false` while the service was asked for no camera type, so Android cut the camera (RN010-1). The client
371
+ * pauses only with `pauseCameraInBackground === true` (left out, the camera runs on). Null when the options cannot be
372
+ * read (missing, not an object, or a read that throws: a wrapped or stubbed client): the provider's setting stands in.
373
+ */
374
+ function pausesCameraInBackground(client: StreamClient): boolean | null {
375
+ let options: unknown;
376
+ try {
377
+ options = (client as unknown as { options?: unknown }).options;
378
+ } catch {
379
+ // A wrapper that forbids the read (a logging or sealing Proxy): not an error, the provider's setting stands in.
380
+ return null;
381
+ }
382
+ if (!options || typeof options !== 'object') return null;
383
+ return (options as ClientOptions).pauseCameraInBackground === true;
384
+ }
package/src/native.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * This package's native module (src/specs/NativeVoltkitRtc.ts), read only through here: it is null on iOS (JavaScript
3
+ * only in 0.1.0), in Expo Go and wherever the app was not rebuilt after installing the package. Everything that can do
4
+ * without it does (JavaScript timers, AppState); what cannot raises `media-unavailable` (errors.ts).
5
+ */
6
+ import { Platform } from 'react-native';
7
+ import type { EventSubscription } from 'react-native';
8
+ import NativeVoltkitRtc, { type AudioDevice, type Constants, type Spec } from './specs/NativeVoltkitRtc';
9
+ import { iosUnsupported, logError, nativeMissing } from './errors';
10
+ import type { AudioDeviceType, DeviceKind, MediaDevice } from '@voltkit-rtc/client';
11
+
12
+ /** The version of this package: what getDiagnostics() reports, and what the native side was built from. */
13
+ export const SDK_VERSION = '0.1.1';
14
+
15
+ /** The native module, or null where there is none (iOS, Expo Go, an app not rebuilt). */
16
+ export function nativeModule(): Spec | null {
17
+ if (Platform.OS !== 'android') return null;
18
+ return NativeVoltkitRtc ?? null;
19
+ }
20
+
21
+ let constants: { module: Spec; value: Constants } | null = null;
22
+ let staleWarned = false;
23
+
24
+ /** The module's constants, read once: the audio mode, screen sharing, the call service as the manifest declares them. */
25
+ export function nativeConstants(): Constants | null {
26
+ const module = nativeModule();
27
+ if (!module) return null;
28
+ if (constants?.module === module) return constants.value;
29
+ let value: Constants;
30
+ try {
31
+ value = module.getConstants();
32
+ } catch (err) {
33
+ logError('could not read the native module\'s constants', err);
34
+ return null;
35
+ }
36
+ constants = { module, value };
37
+ if (!staleWarned && value.nativeVersion && value.nativeVersion !== SDK_VERSION) {
38
+ staleWarned = true;
39
+ // eslint-disable-next-line no-console
40
+ console.warn(`[voltkit-rtc] the app's native code is @voltkit-rtc/react-native ${value.nativeVersion} but its JavaScript is ${SDK_VERSION}: rebuild the app`);
41
+ }
42
+ return value;
43
+ }
44
+
45
+ /** The native module for `feature`, or the error that says why there is none. */
46
+ export function requireNative(feature: string): Spec {
47
+ if (Platform.OS === 'ios') throw iosUnsupported(feature);
48
+ const module = nativeModule();
49
+ if (!module) throw nativeMissing(feature);
50
+ return module;
51
+ }
52
+
53
+ /** Subscribe to one of the module's events; a listener that throws is logged and the others still run. */
54
+ export function listen<T>(
55
+ emitter: ((handler: (value: T) => void) => EventSubscription) | undefined,
56
+ name: string,
57
+ listener: (value: T) => void,
58
+ ): () => void {
59
+ if (typeof emitter !== 'function') return () => undefined;
60
+ const subscription = emitter((value: T) => {
61
+ try {
62
+ listener(value);
63
+ } catch (err) {
64
+ logError(`listener for ${name} threw`, err);
65
+ }
66
+ });
67
+ return () => subscription.remove();
68
+ }
69
+
70
+ const AUDIO_TYPES: readonly AudioDeviceType[] = ['speaker', 'earpiece', 'wiredHeadset', 'bluetooth', 'usb', 'builtinMic', 'other'];
71
+ const KINDS: readonly DeviceKind[] = ['audioinput', 'audiooutput', 'videoinput'];
72
+
73
+ /** A native audio device as the client's MediaDevice. */
74
+ export function toMediaDevice(device: AudioDevice): MediaDevice {
75
+ const type = AUDIO_TYPES.includes(device.type as AudioDeviceType) ? (device.type as AudioDeviceType) : 'other';
76
+ const kind = KINDS.includes(device.kind as DeviceKind) ? (device.kind as DeviceKind) : 'audiooutput';
77
+ return { deviceId: device.deviceId, groupId: device.groupId ?? '', kind, label: device.label, type };
78
+ }
79
+
80
+ /** For tests: forget the cached constants. */
81
+ export function resetNativeForTests(): void {
82
+ constants = null;
83
+ staleWarned = false;
84
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Camera, microphone and notification permissions.
3
+ *
4
+ * Android: PermissionsAndroid. `blocked` is Android's "never ask again" (the user refused twice, or chose "Don't ask
5
+ * again"): only the app's settings can grant it now. The notification permission (Android 13+, POST_NOTIFICATIONS,
6
+ * for the call service's notification) is asked only when asked for; below Android 13 there is none to ask.
7
+ *
8
+ * iOS (preview): react-native-webrtc's permissions module. iOS asks once: a refusal is `blocked` from then on.
9
+ *
10
+ * useLocalMedia().publish() asks for what it needs before any device opens, so a refusal is a `media-unavailable`
11
+ * error with `{ permission, status }` and the camera light never comes on. The SDK's own captures afterwards (a
12
+ * reconnect, a switch) only check, never ask: a dialog must not open in the background.
13
+ */
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
+ import { AppState, PermissionsAndroid, Platform, type Permission } from 'react-native';
16
+ import { loadWebRTC } from './globals';
17
+ import { logError } from './errors';
18
+
19
+ /** What can be asked for. */
20
+ export type PermissionKind = 'camera' | 'microphone' | 'notifications';
21
+
22
+ /** `granted`; `denied` (not granted yet: asking may still show the dialog); `blocked` (only the settings can grant it). */
23
+ export type PermissionStatus = 'granted' | 'denied' | 'blocked';
24
+
25
+ export type PermissionStatuses<K extends PermissionKind = PermissionKind> = { [P in K]: PermissionStatus };
26
+
27
+ const ANDROID: Record<PermissionKind, string> = {
28
+ camera: 'android.permission.CAMERA',
29
+ microphone: 'android.permission.RECORD_AUDIO',
30
+ notifications: 'android.permission.POST_NOTIFICATIONS',
31
+ };
32
+
33
+ const unique = <K extends PermissionKind>(kinds: readonly K[]): K[] => [...new Set(kinds)];
34
+
35
+ /** Android 13 and later have a notification permission; below, notifications need none. */
36
+ const notificationsNeedAsking = () => Platform.OS === 'android' && Number(Platform.Version) >= 33;
37
+
38
+ /**
39
+ * Ask for these permissions, showing the system's dialogs for those not granted yet. Resolves with each one's status;
40
+ * never rejects.
41
+ */
42
+ export async function requestMediaPermissions<K extends PermissionKind>(kinds: readonly K[]): Promise<PermissionStatuses<K>> {
43
+ const wanted = unique(kinds);
44
+ const out = {} as PermissionStatuses<K>;
45
+ if (wanted.length === 0) return out;
46
+ if (Platform.OS === 'android') {
47
+ const ask = wanted.filter((k) => k !== 'notifications' || notificationsNeedAsking());
48
+ for (const kind of wanted) if (!ask.includes(kind)) out[kind] = 'granted';
49
+ if (ask.length === 0) return out;
50
+ let results: Record<string, string> = {};
51
+ try {
52
+ results = (await PermissionsAndroid.requestMultiple(ask.map((k) => ANDROID[k]) as Permission[])) as Record<string, string>;
53
+ } catch (err) {
54
+ logError('could not ask for permissions', err);
55
+ }
56
+ for (const kind of ask) out[kind] = androidStatus(results[ANDROID[kind]]);
57
+ return out;
58
+ }
59
+ for (const kind of wanted) out[kind] = await requestIos(kind);
60
+ return out;
61
+ }
62
+
63
+ /** Each permission's status now, without asking. Never rejects. */
64
+ export async function checkMediaPermissions<K extends PermissionKind>(kinds: readonly K[]): Promise<PermissionStatuses<K>> {
65
+ const wanted = unique(kinds);
66
+ const out = {} as PermissionStatuses<K>;
67
+ for (const kind of wanted) {
68
+ if (Platform.OS === 'android') {
69
+ if (kind === 'notifications' && !notificationsNeedAsking()) {
70
+ out[kind] = 'granted';
71
+ continue;
72
+ }
73
+ try {
74
+ out[kind] = (await PermissionsAndroid.check(ANDROID[kind] as Permission)) ? 'granted' : 'denied';
75
+ } catch (err) {
76
+ logError('could not check a permission', err);
77
+ out[kind] = 'denied';
78
+ }
79
+ } else {
80
+ out[kind] = await queryIos(kind);
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ function androidStatus(result: string | undefined): PermissionStatus {
87
+ if (result === 'granted') return 'granted';
88
+ if (result === 'never_ask_again') return 'blocked';
89
+ return 'denied';
90
+ }
91
+
92
+ async function queryIos(kind: PermissionKind): Promise<PermissionStatus> {
93
+ // The SDK shows no notification of its own on iOS: nothing to ask.
94
+ if (kind === 'notifications') return 'granted';
95
+ const webrtc = loadWebRTC();
96
+ if (!webrtc) return 'denied';
97
+ try {
98
+ const result = await webrtc.permissions.query({ name: kind });
99
+ if (result === 'granted') return 'granted';
100
+ return result === 'denied' ? 'blocked' : 'denied';
101
+ } catch (err) {
102
+ logError('could not check a permission', err);
103
+ return 'denied';
104
+ }
105
+ }
106
+
107
+ async function requestIos(kind: PermissionKind): Promise<PermissionStatus> {
108
+ const now = await queryIos(kind);
109
+ if (now !== 'denied') return now;
110
+ const webrtc = loadWebRTC();
111
+ if (!webrtc) return 'denied';
112
+ try {
113
+ return (await webrtc.permissions.request({ name: kind })) ? 'granted' : 'blocked';
114
+ } catch (err) {
115
+ logError('could not ask for a permission', err);
116
+ return 'denied';
117
+ }
118
+ }
119
+
120
+ export interface Permissions<K extends PermissionKind = PermissionKind> {
121
+ /** Each permission's status; null until first checked. Checked again whenever the app comes back to the front. */
122
+ statuses: PermissionStatuses<K> | null;
123
+ /** Every one granted. */
124
+ granted: boolean;
125
+ /** Ask for those not granted (the system's dialogs); resolves with the statuses. */
126
+ request(): Promise<PermissionStatuses<K>>;
127
+ /** Check again without asking. */
128
+ refresh(): Promise<PermissionStatuses<K>>;
129
+ }
130
+
131
+ /**
132
+ * The status of these permissions, checked on mount and whenever the app returns to the front (the user may have
133
+ * changed them in the settings), with `request()` to ask for them.
134
+ */
135
+ export function usePermissions<K extends PermissionKind>(kinds: readonly K[]): Permissions<K> {
136
+ const key = unique(kinds).sort().join(',');
137
+ const wanted = useMemo(() => (key ? (key.split(',') as K[]) : []), [key]);
138
+ const [statuses, setStatuses] = useState<PermissionStatuses<K> | null>(null);
139
+ const live = useRef(true);
140
+
141
+ const refresh = useCallback(async () => {
142
+ const next = await checkMediaPermissions(wanted);
143
+ if (live.current) setStatuses((previous) => (same(previous, next) ? previous : next));
144
+ return next;
145
+ }, [wanted]);
146
+
147
+ const request = useCallback(async () => {
148
+ const next = await requestMediaPermissions(wanted);
149
+ if (live.current) setStatuses((previous) => (same(previous, next) ? previous : next));
150
+ return next;
151
+ }, [wanted]);
152
+
153
+ useEffect(() => {
154
+ live.current = true;
155
+ void refresh();
156
+ const subscription = AppState.addEventListener('change', (state) => {
157
+ if (state === 'active') void refresh();
158
+ });
159
+ return () => {
160
+ live.current = false;
161
+ subscription?.remove();
162
+ };
163
+ }, [refresh]);
164
+
165
+ const granted = statuses !== null && wanted.every((k) => statuses[k] === 'granted');
166
+ return useMemo(() => ({ statuses, granted, request, refresh }), [statuses, granted, request, refresh]);
167
+ }
168
+
169
+ function same<K extends PermissionKind>(a: PermissionStatuses<K> | null, b: PermissionStatuses<K>): boolean {
170
+ if (!a) return false;
171
+ const keys = Object.keys(b) as K[];
172
+ return keys.length === Object.keys(a).length && keys.every((k) => a[k] === b[k]);
173
+ }