@voltkit-rtc/react-native 0.0.0-stage → 0.1.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 (225) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1606 -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 +314 -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 +73 -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 +309 -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 +68 -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 +338 -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 +86 -0
  225. package/src/specs/NativeVoltkitRtc.ts +180 -0
package/README.md CHANGED
@@ -1,3 +1,1607 @@
1
- # Temporary Holding Version
1
+ # @voltkit-rtc/react-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ React Native SDK for [VoltKit RTC](https://voltkit.in/rtc): video and voice
4
+ calls, audio rooms and chat in a React Native app, on Android, with iOS in
5
+ preview. It has the hooks of [`@voltkit-rtc/react`](https://voltkit.in/docs/react-sdk),
6
+ so a React call screen ports by changing the import and its markup, and runs
7
+ the browser SDK [`@voltkit-rtc/client`](https://voltkit.in/docs/client-sdk) on
8
+ react-native-webrtc, with what a phone adds: camera and microphone permissions
9
+ asked before a device opens, audio routing to the earpiece, the loudspeaker and
10
+ headsets, a call service that keeps a call going in the background, native
11
+ timers so a reconnect still happens with the screen off, video views, and an
12
+ Expo config plugin.
13
+
14
+ **Tested on Android (emulator, API 37.1); iOS in preview; earpiece and
15
+ Bluetooth routing tested in unit tests, not on hardware.** See
16
+ [Testing status](#testing-status).
17
+
18
+ | | |
19
+ | --- | --- |
20
+ | **Provider** | `VoltkitProvider` |
21
+ | **Hooks** | `useRoom` `useConnectionState` `useParticipants` `useRemoteTracks` `useLocalMedia` `useNetworkQuality` `useWalletLow` `useVoltkitEvent` `useVoltkitClient` |
22
+ | **Media hooks** | `useAudioLevels` `useActiveSpeakers` `useIsSpeaking` `useStreamFallback` `usePublishFallback` `useMediaWarnings` `useCpuConstrained` `useDevices` |
23
+ | **Audio, permissions and the call service** | `useAudioOutput` `setSpeakerphoneOn` `selectAudioOutput` `usePermissions` `requestMediaPermissions` `checkMediaPermissions` `useCallService` |
24
+ | **Components** | `VideoTrack` `AudioTrack` |
25
+ | **Functions** | `createClient` `reactNativeClientOptions` `reactNativePlatform` `preflight` `registerVoltkitGlobals` |
26
+ | **Constants** | `SDK_VERSION` |
27
+
28
+ Your backend mints a short-lived room token with
29
+ [`@voltkit-rtc/server`](https://voltkit.in/docs/server-sdk) (or the Python, Go,
30
+ PHP or Java server SDK) and hands the app `{ token, wsUrl }`. The API secret
31
+ never reaches the app.
32
+
33
+ Contents: [Install](#install) ·
34
+ [Permissions](#permissions) ·
35
+ [Quick start](#quick-start) ·
36
+ [VoltkitProvider](#voltkitprovider) ·
37
+ [Hooks](#hooks) ·
38
+ [VideoTrack and AudioTrack](#videotrack-and-audiotrack) ·
39
+ [Audio routing](#audio-routing) ·
40
+ [Calls in the background](#calls-in-the-background) ·
41
+ [Screen sharing](#screen-sharing) ·
42
+ [Chat](#chat) ·
43
+ [Moderation](#moderation-seats-and-audio-rooms) ·
44
+ [Tokens and wallet](#token-refresh-and-wallet-notices) ·
45
+ [Your own client](#your-own-client) ·
46
+ [Errors](#errors) ·
47
+ [Troubleshooting](#troubleshooting) ·
48
+ [iOS preview](#ios-preview) ·
49
+ [Testing status](#testing-status) ·
50
+ [Help](#help)
51
+
52
+ ## Install
53
+
54
+ ### Bare React Native
55
+
56
+ ```bash
57
+ npm install @voltkit-rtc/react-native @voltkit-rtc/react @voltkit-rtc/client react-native-webrtc
58
+ npx react-native run-android # the package has native Android code: build the app again
59
+ ```
60
+
61
+ `@voltkit-rtc/client`, `@voltkit-rtc/react` and react-native-webrtc are peer
62
+ dependencies, installed beside the SDK so the app, the hooks and the video views
63
+ share one copy of each. Autolinking finds the package. Nothing goes into
64
+ `MainApplication` or `MainActivity`: a startup provider in the package sets up
65
+ react-native-webrtc's audio before it starts, and release builds need no
66
+ ProGuard or R8 rules of your own (the SDK carries the one react-native-webrtc
67
+ 155.0.1 needs). Types are included; ES modules and CommonJS both work.
68
+
69
+ **TypeScript.** Nothing to add to `tsconfig.json`: the SDK's streams and
70
+ tracks are typed by shape (`MediaStreamLike` and `MediaStreamTrackLike`,
71
+ react-native-webrtc's `MediaStream` and `MediaStreamTrack`), so
72
+ `useLocalMedia().stream?.getVideoTracks()[0]` and a remote track's `track`
73
+ compile with `@react-native/typescript-config` as it comes, without the DOM
74
+ library, and under Expo's `tsconfig.base` alike.
75
+
76
+ | Requirement | Value |
77
+ | --- | --- |
78
+ | React Native | 0.82 or newer, with the New Architecture; tested with 0.88.0 and Hermes |
79
+ | React | 19.1 or newer |
80
+ | react-native-webrtc | 155.0.1 or a later 155.x |
81
+ | `@voltkit-rtc/client` | 0.9.12 or a later 0.9.x |
82
+ | `@voltkit-rtc/react` | 0.1.1 or a later 0.1.x |
83
+ | Android | minSdk 24 (Android 7.0), react-native-webrtc's minimum |
84
+ | Expo | SDK 58 or newer, in a development build; not Expo Go |
85
+ | iOS | Preview: see [iOS preview](#ios-preview) |
86
+ | Licence | MIT |
87
+
88
+ **Globals.** Importing `@voltkit-rtc/react-native` registers
89
+ react-native-webrtc's WebRTC classes as globals, which mediasoup-client needs
90
+ (`registerVoltkitGlobals()`), so there is no `registerGlobals()` of your own to
91
+ call. Calling it again does nothing, and it returns `false` without throwing
92
+ where react-native-webrtc's native module is missing (Expo Go, an app not built
93
+ again): the provider then reports that through `onError`.
94
+
95
+ **One WebRTC per app.** Do not add a second WebRTC build: a fork of
96
+ react-native-webrtc, such as `@livekit/react-native-webrtc` or
97
+ `@stream-io/react-native-webrtc`, or a native SDK that bundles its own, such as
98
+ VoltKit's Android SDK `in.voltkit:voltkit-rtc`. Two copies of libwebrtc in one
99
+ APK clash on native symbols, and two call engines compete for the microphone
100
+ and the audio routing.
101
+
102
+ ### Expo (development builds)
103
+
104
+ ```bash
105
+ npx expo install @voltkit-rtc/react-native @voltkit-rtc/react @voltkit-rtc/client react-native-webrtc expo-dev-client
106
+ ```
107
+
108
+ Add the SDK's config plugin to `app.json` (or `app.config.ts`). It writes the
109
+ Android permissions, the call service and the `in.voltkit.rtc.*` meta-data into
110
+ the manifest, and the usage strings and background modes into `Info.plist`, so
111
+ you edit no native file:
112
+
113
+ ```json
114
+ {
115
+ "expo": {
116
+ "plugins": [
117
+ [
118
+ "@voltkit-rtc/react-native",
119
+ {
120
+ "cameraPermission": "Your camera is shown to the people in your call.",
121
+ "microphonePermission": "Your microphone is heard by the people in your call."
122
+ }
123
+ ]
124
+ ]
125
+ }
126
+ }
127
+ ```
128
+
129
+ | Option | Default | What it does |
130
+ | --- | --- | --- |
131
+ | `cameraPermission`, `microphonePermission` | the app's own `Info.plist` text, else `Allow $(PRODUCT_NAME) to use your camera in calls.` (`... microphone ...`) | The iOS prompts (`NSCameraUsageDescription`, `NSMicrophoneUsageDescription`) |
132
+ | `android.camera` | `true` | Declares `CAMERA`, with the camera features marked as not required (`uses-feature android:required="false"`), so Google Play does not hide the app from devices without them. `false` for an app that never publishes video |
133
+ | `android.callService` | `true` | Declares the [call service](#calls-in-the-background) and its permissions. `false` declares no foreground service: nothing to declare to Google Play, but a call drops a few seconds after the app leaves the screen |
134
+ | `android.callServiceCamera` | `false` | Adds the `camera` type and `FOREGROUND_SERVICE_CAMERA`, for an app that keeps the camera on in the background (`background={{ pauseCamera: false }}`) |
135
+ | `android.screenShare` | `false` | Turns on react-native-webrtc's screen-capture service for [screen sharing](#screen-sharing), with `FOREGROUND_SERVICE_MEDIA_PROJECTION` and the `mediaProjection` type on the call service |
136
+ | `android.audioMode` | `"communication"` | `"media"` for an app whose users only listen: see [Audio routing](#audio-routing) |
137
+ | `android.hardwareEchoCancellation` | `true` | The phone's own echo canceller and noise suppressor, where it has them |
138
+ | `ios.backgroundAudio` | `true` | Adds `audio` to `UIBackgroundModes`, so a call keeps its sound in the background |
139
+ | `ios.voip` | `false` | Adds `voip` to `UIBackgroundModes`, for an app that integrates CallKit itself |
140
+
141
+ Then `npx expo run:android` builds the app with its native code and installs it;
142
+ from then on `npx expo start` serves your JavaScript to that build with fast
143
+ refresh. Build again after installing or upgrading the SDK or
144
+ react-native-webrtc, and after changing the plugin's options, with
145
+ `npx expo prebuild --clean` first. Your app's `blockedPermissions` win over the
146
+ plugin, which warns at prebuild when a block leaves the call service or the
147
+ screen-capture service unable to run. **Expo Go cannot run the SDK**: it is one
148
+ app with the native modules Expo ships, and react-native-webrtc and this SDK
149
+ bring native code of their own. In Expo Go nothing crashes: the provider
150
+ reports `media-unavailable` with `data.reason` `webrtc-missing` and a message
151
+ that says it needs a development build. Everything about Expo, EAS Build and
152
+ release builds: https://voltkit.in/docs/react-native-expo.
153
+
154
+ ## Permissions
155
+
156
+ Android, in `android/app/src/main/AndroidManifest.xml`; the Expo plugin writes
157
+ these for you:
158
+
159
+ ```xml
160
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
161
+ <!-- Only if you publish video: -->
162
+ <uses-permission android:name="android.permission.CAMERA" />
163
+
164
+ <!-- Google Play reads RECORD_AUDIO and CAMERA as hardware the app cannot do without, and hides the app from
165
+ devices without them, unless the manifest marks them as not required. The camera entries go with CAMERA. -->
166
+ <uses-feature android:name="android.hardware.microphone" android:required="false" />
167
+ <uses-feature android:name="android.hardware.camera" android:required="false" />
168
+ <uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
169
+ <uses-feature android:name="android.hardware.camera.front" android:required="false" />
170
+ ```
171
+
172
+ `INTERNET`, `ACCESS_NETWORK_STATE` and `MODIFY_AUDIO_SETTINGS` come with the
173
+ SDK's own manifest, and Bluetooth headsets need no Bluetooth permission. The
174
+ [call service](#calls-in-the-background) has entries of its own.
175
+
176
+ iOS (preview), in your app's `Info.plist`:
177
+
178
+ ```xml
179
+ <key>NSCameraUsageDescription</key>
180
+ <string>Your camera is shown to the people in your call.</string>
181
+ <key>NSMicrophoneUsageDescription</key>
182
+ <string>Your microphone is heard by the people in your call.</string>
183
+ ```
184
+
185
+ `publish()` asks for what it is about to open, the microphone and the camera,
186
+ before any device opens; a viewer who only watches is never asked. A refusal
187
+ rejects `publish()` with `RpcError` `media-unavailable`, with `data.permission`
188
+ (`'camera'` or `'microphone'`) and `data.status`: `'denied'` when the system may
189
+ ask again, `'blocked'` once the user has refused for good (on Android, after
190
+ choosing "Don't allow" twice), when only the app's settings can grant it.
191
+
192
+ To ask earlier, on a screen before the call,
193
+ `requestMediaPermissions(['camera', 'microphone'])` shows the system's dialogs
194
+ for those not granted yet and resolves to each one's status: `'granted'`,
195
+ `'denied'` (the dialog may still show) or `'blocked'`. It never rejects.
196
+ `checkMediaPermissions(kinds)` reads the statuses without asking, and
197
+ `usePermissions(kinds)` keeps them as state, checked again whenever the app
198
+ comes back to the front (the user may have changed them in the settings). On
199
+ Android 13 and later, ask for `'notifications'` too, so the call's notification
200
+ shows in the notification shade; the call works without it, and older Android
201
+ versions have nothing to ask:
202
+
203
+ ```tsx
204
+ import { Button, Linking, Text, View } from 'react-native';
205
+ import { requestMediaPermissions, usePermissions } from '@voltkit-rtc/react-native';
206
+
207
+ /** A screen before the call: the camera and microphone, and the call notification on the way. */
208
+ export function BeforeTheCall({ onReady }: { onReady: () => void }) {
209
+ const media = usePermissions(['camera', 'microphone']);
210
+ const blocked = media.statuses?.camera === 'blocked' || media.statuses?.microphone === 'blocked';
211
+
212
+ async function ask() {
213
+ const statuses = await media.request();
214
+ await requestMediaPermissions(['notifications']);
215
+ if (statuses.camera === 'granted' && statuses.microphone === 'granted') onReady();
216
+ }
217
+
218
+ return (
219
+ <View>
220
+ {blocked && <Text>Allow the camera and microphone in Settings to join in.</Text>}
221
+ <Button title={blocked ? 'Open Settings' : 'Continue'} onPress={blocked ? () => void Linking.openSettings() : ask} />
222
+ </View>
223
+ );
224
+ }
225
+ ```
226
+
227
+ The SDK's own captures afterwards, such as a reconnect that opens the devices
228
+ again or a camera switch, only check and never ask: a dialog must not open in
229
+ the background.
230
+
231
+ ## Quick start
232
+
233
+ **1. A token from your backend.** Ask your endpoint right before joining, with
234
+ your app's own sign-in. [Token server](https://voltkit.in/docs/token-server)
235
+ builds the endpoint in several languages:
236
+
237
+ ```ts
238
+ // token.ts
239
+ export type Credentials = { token: string; wsUrl: string };
240
+
241
+ export async function fetchCredentials(callId: string, accessToken: string): Promise<Credentials> {
242
+ const res = await fetch(`https://api.example.com/calls/${encodeURIComponent(callId)}/token`, {
243
+ method: 'POST',
244
+ headers: { Authorization: `Bearer ${accessToken}` }, // your app's sign-in, never the RTC secret
245
+ });
246
+ if (!res.ok) throw new Error(`Could not get a token (${res.status}).`);
247
+ return (await res.json()) as Credentials;
248
+ }
249
+ ```
250
+
251
+ Tokens are short-lived (ten minutes by default), and the service refreshes them
252
+ during the session. Production `wsUrl`s start with `wss://`: Android refuses
253
+ `ws://` and `http://` in a release build unless the app allows cleartext
254
+ traffic, so allow it only for a test server on your own network, in a debug
255
+ build.
256
+
257
+ **2. A call screen.** The client side of a call: join, your camera and
258
+ microphone, everyone's video, mute, camera off, flip, loudspeaker and leave.
259
+ Remote sound needs no component: react-native-webrtc plays it.
260
+
261
+ ```tsx
262
+ // CallScreen.tsx
263
+ import { useState } from 'react';
264
+ import { Button, StyleSheet, Text, View } from 'react-native';
265
+ import {
266
+ VideoTrack,
267
+ VoltkitProvider,
268
+ useAudioOutput,
269
+ useLocalMedia,
270
+ useRemoteTracks,
271
+ useRoom,
272
+ type ClosedReason,
273
+ } from '@voltkit-rtc/react-native';
274
+ import { fetchCredentials, type Credentials } from './token';
275
+
276
+ const ENDED: Record<ClosedReason, string> = {
277
+ left: 'You left the call.',
278
+ kicked: 'You were removed from the call.',
279
+ 'room-closed': 'The call has ended.',
280
+ replaced: 'You joined this call from another device.',
281
+ 'gave-up': 'The connection was lost.',
282
+ };
283
+
284
+ export function CallScreen({ callId, accessToken }: { callId: string; accessToken: string }) {
285
+ const [credentials, setCredentials] = useState<Credentials | null>(null);
286
+ const [attempt, setAttempt] = useState(0);
287
+ const [notice, setNotice] = useState<string | null>(null);
288
+ const [busy, setBusy] = useState(false);
289
+
290
+ async function join() {
291
+ setBusy(true);
292
+ setNotice(null);
293
+ try {
294
+ setCredentials(await fetchCredentials(callId, accessToken));
295
+ setAttempt((n) => n + 1); // a new key: a new provider with a fresh client
296
+ } catch (err) {
297
+ setNotice((err as Error).message);
298
+ } finally {
299
+ setBusy(false);
300
+ }
301
+ }
302
+
303
+ if (!credentials) {
304
+ return (
305
+ <View style={styles.center}>
306
+ {notice && <Text>{notice}</Text>}
307
+ <Button title={busy ? 'Joining…' : 'Join the call'} onPress={join} disabled={busy} />
308
+ </View>
309
+ );
310
+ }
311
+
312
+ return (
313
+ <VoltkitProvider
314
+ key={attempt}
315
+ wsUrl={credentials.wsUrl}
316
+ token={credentials.token}
317
+ onError={(err) => console.warn('[voltkit]', err.message)} // warnings too: see Errors
318
+ onClosed={(reason) => {
319
+ setCredentials(null);
320
+ setNotice(ENDED[reason]);
321
+ }}
322
+ >
323
+ <Call onRetry={join} />
324
+ </VoltkitProvider>
325
+ );
326
+ }
327
+
328
+ function Call({ onRetry }: { onRetry: () => void }) {
329
+ const { state, info, error, leave } = useRoom();
330
+ const local = useLocalMedia();
331
+ const audio = useAudioOutput();
332
+ const videos = useRemoteTracks({ kind: 'video' });
333
+ const [problem, setProblem] = useState<string | null>(null);
334
+ const report = (err: Error) => setProblem(err.message);
335
+
336
+ if (state === 'idle' && error) {
337
+ return (
338
+ <View style={styles.center}>
339
+ <Text>Could not join: {error.message}</Text>
340
+ <Button title="Try again" onPress={onRetry} />
341
+ </View>
342
+ );
343
+ }
344
+ if (state === 'idle' || state === 'connecting') {
345
+ return (
346
+ <View style={styles.center}>
347
+ <Text>Joining…</Text>
348
+ </View>
349
+ );
350
+ }
351
+
352
+ const canPublish = info?.role === 'host' || info?.role === 'cohost';
353
+ const camera = local.stream?.getVideoTracks()[0];
354
+
355
+ return (
356
+ <View style={styles.screen}>
357
+ {state === 'reconnecting' && <Text>Reconnecting…</Text>}
358
+ {problem && <Text>{problem}</Text>}
359
+
360
+ <View style={styles.tiles}>
361
+ {camera && <VideoTrack track={camera} mirror={local.facingMode === 'user'} style={styles.tile} />}
362
+ {videos.map((t) => (
363
+ <VideoTrack key={t.producerId} track={t.track} producerId={t.producerId} objectFit="cover" style={styles.tile} />
364
+ ))}
365
+ </View>
366
+
367
+ <View style={styles.controls}>
368
+ {canPublish && !local.stream && (
369
+ <Button title="Start camera and microphone" onPress={() => local.publish().catch(report)} />
370
+ )}
371
+ {local.stream && (
372
+ <>
373
+ <Button title={local.micEnabled ? 'Mute' : 'Unmute'} onPress={() => local.setMicEnabled(!local.micEnabled).catch(report)} />
374
+ <Button title={local.camEnabled ? 'Camera off' : 'Camera on'} onPress={() => local.setCamEnabled(!local.camEnabled).catch(report)} />
375
+ <Button title="Flip" onPress={() => local.switchCamera().catch(report)} />
376
+ </>
377
+ )}
378
+ {audio.supported && (
379
+ <Button
380
+ title={audio.speakerphone ? 'Earpiece' : 'Loudspeaker'}
381
+ onPress={() => audio.setSpeakerphoneOn(!audio.speakerphone).catch(report)}
382
+ />
383
+ )}
384
+ <Button title="Leave" onPress={leave} />
385
+ </View>
386
+ </View>
387
+ );
388
+ }
389
+
390
+ const styles = StyleSheet.create({
391
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
392
+ screen: { flex: 1, backgroundColor: '#000' },
393
+ tiles: { flex: 1, flexDirection: 'row', flexWrap: 'wrap' },
394
+ tile: { width: '50%', aspectRatio: 3 / 4 },
395
+ controls: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8, padding: 12 },
396
+ });
397
+ ```
398
+
399
+ **3. The app:**
400
+
401
+ ```tsx
402
+ // App.tsx
403
+ import { View } from 'react-native';
404
+ import { CallScreen } from './CallScreen';
405
+
406
+ export default function App() {
407
+ // Your own sign-in gives the access token; the call id comes from your app's data.
408
+ return (
409
+ <View style={{ flex: 1 }}>
410
+ <CallScreen callId="call-42" accessToken="your-session-token" />
411
+ </View>
412
+ );
413
+ }
414
+ ```
415
+
416
+ What each part does:
417
+
418
+ - **The provider** makes the client, connects it, and closes it when it
419
+ unmounts. On joining it takes the phone's call audio (call mode, audio focus,
420
+ where sound plays) and, if the app declares it, starts the
421
+ [call service](#calls-in-the-background); it lets both go when the call ends.
422
+ `onError` hears what is not the answer to a call you made: warnings, a call
423
+ service that could not start, and in Expo Go the missing native code.
424
+ `onClosed` says why a session ended. A closed session stays closed, so joining
425
+ again mounts a new provider (a new `key`) with a fresh token.
426
+ - **`publish()`** asks for the microphone and camera permissions, then opens the
427
+ devices and publishes them; only hosts and co-hosts may. With no options it
428
+ sends the highest quality the project allows, 1080p unless your project's
429
+ settings say otherwise. That is your users' mobile data and your egress: pass
430
+ `{ quality: '720p' }` where they matter more than the picture.
431
+ - **Your own tile** is mirrored while the front camera is in use. `producerId`
432
+ on a remote `VideoTrack` turns on adaptive video: it is received at the size
433
+ it is laid out, and paused while it is hidden or the app is in the background.
434
+ - **The loudspeaker button** moves call audio between the loudspeaker and the
435
+ earpiece; a connected headset still wins. Calls start on the loudspeaker. For
436
+ a voice call held to the ear, pass `audio={{ speakerphone: false }}` to the
437
+ provider.
438
+ - **The hooks follow the client.** After `setMicEnabled(false)`, `micEnabled`
439
+ is false in every component that uses `useLocalMedia()`, and a reconnect
440
+ shows as `reconnecting` without unmounting anything.
441
+
442
+ ## VoltkitProvider
443
+
444
+ `<VoltkitProvider>` holds one `StreamClient` for the components below it. It is
445
+ the React SDK's provider underneath, so every re-exported hook works below it,
446
+ and its props are `VoltkitProviderProps`: the React provider's, plus three of
447
+ its own.
448
+
449
+ | Prop | Type | |
450
+ | --- | --- | --- |
451
+ | `wsUrl` | `string` | the room's WebSocket URL, from your backend with the token |
452
+ | `token` | `string` | a room token your backend minted |
453
+ | `connect` | `boolean` | connect the client when it is idle; default `true`. With `false`, call `useRoom().connect()` yourself |
454
+ | `onConnected` | `(result: JoinResult) => void` | after every successful join: the first, and each automatic reconnect |
455
+ | `onError` | `(error: Error) => void` | every `error` event (warnings included), a failed connect, a token the session refused, a call service that could not start, and missing native code |
456
+ | `onClosed` | `(reason: ClosedReason) => void` | the session ended for good: `left`, `kicked`, `room-closed`, `gave-up` or `replaced` |
457
+ | `client` | `StreamClient \| null` | a client you made with `createClient` and own, used instead of `wsUrl` and `token`; never closed by the provider |
458
+ | `clientOptions` | `ClientOptions` | options for the client the provider makes, laid over React Native's own. Read once, when the client is made, like `token` |
459
+ | `audio` | `AudioSessionOptions \| false` | the call audio: `speakerphone` (the loudspeaker, `true`, the default, or the earpiece, `false`, when no headset is connected, unless `setSpeakerphoneOn` chose) and `manage` (default `true`). `false`, or `manage: false`, leaves the phone's audio mode, focus and routing to your app |
460
+ | `background` | `BackgroundOptions` | what switches off while the app is in the background: the camera (`pauseCamera`, default `true`) and the microphone (`pauseMic`, default `false`) |
461
+ | `callService` | `boolean \| CallServiceText` | start the [call service](#calls-in-the-background) when the call connects, if the app declares it. Default `true`; `false` never starts it; an object changes the notification's words: `title` ("Call in progress"), `text` ("Tap to return to the call") and `leaveLabel` ("Leave") |
462
+ | `children` | `ReactNode` | |
463
+
464
+ - **The client it makes** has the options
465
+ `reactNativeClientOptions({ background, clientOptions })` returns: the
466
+ mediasoup handler (`ReactNative106`), the React Native platform
467
+ (`reactNativePlatform()`), and `background` as the client's
468
+ `pauseCameraInBackground` and `pauseMicInBackground`, with the `clientOptions`
469
+ you give laid over them. A client you make yourself for the `client` prop
470
+ comes from `createClient` (see [Your own client](#your-own-client)): a plain
471
+ `new StreamClient(...)` would look for a browser.
472
+ - **Where the native code is missing,** nothing throws and the hooks keep their
473
+ defaults: in Expo Go, or an app not built again after installing
474
+ react-native-webrtc, the provider makes no client and `onError` gets
475
+ `media-unavailable` with `data.reason` `webrtc-missing`; on Android without
476
+ the SDK's own native module, `data.reason` is `native-missing`, and the call
477
+ works without background calls, audio routing and native timers.
478
+ - **A new `token` for the same session** (the same project, room and identity,
479
+ read from the token without verifying it: the server does that) goes to the
480
+ live session with `updateToken`, with no reconnect: that is how you
481
+ [refresh a token](#token-refresh-and-wallet-notices). A token for another room
482
+ or identity closes the client and makes one for the new session. A new `wsUrl`
483
+ replaces the client, and taking `token` or `wsUrl` away closes it.
484
+ - **A closed session stays closed.** To join again, render the provider with a
485
+ new `key` and a fresh token, as the call screen does.
486
+ - **With `client`** the provider uses yours as it is: it connects it when it is
487
+ idle (unless `connect` is `false`) and never closes it.
488
+ - **The callbacks are always the latest** you passed. A client the provider lets
489
+ go of, on unmount or for a new `wsUrl`, reports nothing more.
490
+ - **StrictMode** runs effects twice in development. The provider then makes a
491
+ client, closes it before it connects, and makes the one it keeps: one socket,
492
+ one connect, nothing left open.
493
+
494
+ ## Hooks
495
+
496
+ The hooks are the React SDK's, with the same names, returns and defaults. Every
497
+ hook returns its default until the provider has a client, then follows the
498
+ client and renders again only when what it returns has changed. A hook outside
499
+ any `VoltkitProvider` does not throw: it keeps returning its default.
500
+
501
+ | Hook | Returns | Default |
502
+ | --- | --- | --- |
503
+ | `useRoom()` | `RoomState`: `client`, `info`, `state`, `error`, `connect()`, `leave()` | `state` is `'idle'`; `client`, `info` and `error` are null |
504
+ | `useConnectionState()` | `'idle'`, `'connecting'`, `'connected'`, `'reconnecting'` or `'closed'` | `'idle'` |
505
+ | `useParticipants()` | `ParticipantInfo[]`: everyone in the room, you included | `[]` |
506
+ | `useRemoteTracks(filter?)` | `RemoteTrack[]`: every track this client receives, `producerId`, `identity`, `kind`, `source`, `track`, `paused`; the filter takes `identity`, `kind` and `source` | `[]` |
507
+ | `useLocalMedia()` | `LocalMedia`: your camera, microphone and screen, and the calls that change them, with `switchCamera()` and `facingMode` | nothing published |
508
+ | `useNetworkQuality()` | `NetworkQuality`: `level`, `rttMs`, `lossPct`, `availableKbps`, about every 3 s | `null` |
509
+ | `useWalletLow()` | `WalletLowState`: `active`, `remainingSeconds`, `notice` | not active |
510
+ | `useVoltkitEvent(event, handler)` | nothing: calls `handler` for each event while mounted | |
511
+ | `useVoltkitClient()` | the provider's `StreamClient` | `null` |
512
+ | `useAudioOutput()` | `AudioOutput`: `supported`, `outputs`, `current`, `speakerphone`, `select(deviceId)`, `setSpeakerphoneOn(on)`: see [Audio routing](#audio-routing) | not supported |
513
+ | `usePermissions(kinds)` | `Permissions`: `statuses` (each `'granted'`, `'denied'` or `'blocked'`), `granted`, `request()`, `refresh()`: see [Permissions](#permissions) | `statuses` is null until first checked |
514
+ | `useCallService()` | `CallServiceState`: `declared`, `running`, `types`: see [Calls in the background](#calls-in-the-background) | not declared |
515
+
516
+ The media hooks are the React SDK's too: `useAudioLevels()` (every
517
+ participant's sound level, `speaking` included), `useActiveSpeakers()` (who is
518
+ speaking, most active first), `useIsSpeaking(identity)` (renders only when it
519
+ changes, so it suits every tile), `useStreamFallback(producerId)` (why a remote
520
+ video is held back on a poor connection: `'bandwidth'`, `'audio-protection'`,
521
+ or null), `usePublishFallback()`, `useMediaWarnings()` (`mic-silent`,
522
+ `camera-fps-low`, `camera-interrupted`, `mic-interrupted`),
523
+ `useCpuConstrained()` and `useDevices(kind?)`. Two differences on a phone:
524
+
525
+ - **Sound levels** are read from the connection's statistics every 500 ms,
526
+ where a browser reads them about four times a second, so `useAudioLevels`,
527
+ `useActiveSpeakers` and `useIsSpeaking` change twice a second.
528
+ - **`useDevices`** lists the cameras (`'videoinput'`, each with `facing`:
529
+ `front`, `back` or `external`), the microphones (`'audioinput'`) and, on
530
+ Android, the outputs (`'audiooutput'`), each with its `type`. For an output
531
+ picker, `useAudioOutput()` is simpler.
532
+
533
+ ```tsx
534
+ import { StyleSheet, Text, View } from 'react-native';
535
+ import { VideoTrack, useIsSpeaking, useStreamFallback } from '@voltkit-rtc/react-native';
536
+ import type { RemoteTrack } from '@voltkit-rtc/react-native';
537
+
538
+ /** One remote video, with a speaking ring and a note while a poor connection holds the video back. */
539
+ export function Tile({ track }: { track: RemoteTrack }) {
540
+ const speaking = useIsSpeaking(track.identity);
541
+ const held = useStreamFallback(track.producerId);
542
+ return (
543
+ <View style={[styles.tile, speaking && styles.speaking]}>
544
+ {held ? (
545
+ <Text style={styles.note}>{track.identity}: poor connection</Text>
546
+ ) : (
547
+ <VideoTrack track={track.track} producerId={track.producerId} style={styles.video} />
548
+ )}
549
+ </View>
550
+ );
551
+ }
552
+
553
+ const styles = StyleSheet.create({
554
+ tile: { width: '50%', aspectRatio: 3 / 4 },
555
+ speaking: { borderWidth: 2, borderColor: '#22c55e' },
556
+ video: { flex: 1 },
557
+ note: { padding: 8 },
558
+ });
559
+ ```
560
+
561
+ `useVoltkitClient()` is the provider's client, for everything the hooks do not
562
+ wrap: chat (`sendMessage`, `getMessages`), moderation (`raiseHand`, `promote`,
563
+ `forceMute`, `kick`, `endRoom`), seats (`claim`, `release`, `blockSlot`),
564
+ `switchDevice`, `getDiagnostics`. Its methods, events and options are the
565
+ browser SDK's: https://voltkit.in/docs/client-sdk.
566
+
567
+ ### useLocalMedia
568
+
569
+ `useLocalMedia(): LocalMedia` is your camera, microphone and screen.
570
+
571
+ | State | |
572
+ | --- | --- |
573
+ | `stream` | your camera and microphone as published (`client.local`), or null: a `MediaStreamLike` (react-native-webrtc's stream, by shape), so `stream.getVideoTracks()[0]` is the camera for a `VideoTrack` |
574
+ | `publishing` | anything is published: camera, microphone or screen |
575
+ | `micEnabled`, `camEnabled` | switched on |
576
+ | `audioQuality`, `videoQuality`, `videoFrameRate` | what is sent, after the project's limits: `'std'` or `'hd'`; `'1080p'`, `'720p'` or `'480p'`; `30` or `60` |
577
+ | `screenSharing`, `screenStream` | the screen being shared (a `MediaStreamLike`), for a preview |
578
+ | `permissions` | `MediaPermissions`: what this project lets this client publish. Offer only these |
579
+ | `facingMode` | `'user'` (front) or `'environment'` (back): the camera in use; null without a camera |
580
+
581
+ | Call | |
582
+ | --- | --- |
583
+ | `publish(options?)` | ask for the permissions, then open the camera and microphone and publish them (hosts and cohosts) |
584
+ | `unpublish()` | stop publishing everything, screen included, and release the devices |
585
+ | `setMicEnabled(on)`, `setCamEnabled(on)` | switch one off or on; viewers are told |
586
+ | `setAudioQuality(quality)` | `'std'` or `'hd'` while live; resolves to the quality in use |
587
+ | `setVideoQuality(quality)` | while live; raises the camera's capture first when it is smaller; resolves to the quality used |
588
+ | `setVideoFrameRate(frameRate)` | `30` or `60` while live; resolves to the frame rate used |
589
+ | `switchCamera()` | front and back on the same track and producer, so viewers see no gap; resolves `true` when the front camera is now in use. `media-unavailable` before a camera is published |
590
+ | `startScreenShare(options?)`, `stopScreenShare()` | Android screen sharing, after the system's consent: see [Screen sharing](#screen-sharing) |
591
+
592
+ **`publish(options?: LocalPublishOptions)`** takes the browser SDK's
593
+ `PublishOptions` (`quality`, `frameRate`, `audioQuality`, `layers`, `codec`,
594
+ `degradationPreference`) plus:
595
+
596
+ | Option | Default | |
597
+ | --- | --- | --- |
598
+ | `audio` | `true` | `true` for VoltKit's microphone settings, `false` for no microphone, or your own constraints, used as given |
599
+ | `video` | `true` | `true` for the quality's frame and frame rate, `false` for no camera, or your own constraints |
600
+ | `facingMode` | `'user'` | `'user'` (front) or `'environment'` (back): which camera to open. `switchCamera()` flips it while live |
601
+
602
+ What it captures: the frame of `quality` (1080p 1920×1080, 720p 1280×720, 480p
603
+ 854×480) at `frameRate` (30 unless you ask for 60), and an echo-cancelled,
604
+ noise-suppressed microphone, stereo for `audioQuality: 'hd'`. `quality`
605
+ defaults to the highest the project allows: the best picture, and the most
606
+ data. A phone sends two copies of its camera, half and full size, so each
607
+ viewer receives the one their connection can carry; `codec: 'auto'` picks H.264
608
+ only where the phone has a hardware H.264 encoder, and VP8 everywhere else.
609
+ 60 fps is delivered only when the capture source provides it. Phone makers
610
+ usually keep 60 fps for their own camera app, so most apps get 30 fps;
611
+ `setVideoFrameRate(60)` then resolves to `30` with a `frame-rate-fallback`
612
+ warning. Every quality and its data use: https://voltkit.in/docs/video-quality.
613
+
614
+ ```tsx
615
+ import { useLocalMedia } from '@voltkit-rtc/react-native';
616
+
617
+ export function useBackCameraFirst() {
618
+ const local = useLocalMedia();
619
+ return async function start(): Promise<boolean> {
620
+ await local.publish({ facingMode: 'environment', quality: '720p' }); // start on the back camera
621
+ return local.switchCamera(); // resolves true: the front camera is in use now
622
+ };
623
+ }
624
+ ```
625
+
626
+ Any camera: `useDevices('videoinput')` lists them, each with `facing`, and
627
+ `client.switchDevice('videoinput', deviceId)` switches the published one. An id
628
+ no camera has rejects with `bad-params`; a switch that fails rejects with
629
+ `camera-failed`, and the old camera stays on air. Microphones:
630
+ `useDevices('audioinput')` lists them, and
631
+ `client.switchDevice('audioinput', deviceId)` records from another one, such as
632
+ a wired headset's, without opening a new track.
633
+
634
+ Before opening any device, `publish` refuses with an `RpcError`: see
635
+ [When publishing fails](#when-publishing-fails).
636
+
637
+ ## VideoTrack and AudioTrack
638
+
639
+ ### VideoTrack
640
+
641
+ `<VideoTrack track ...>` shows one video track in a native view
642
+ (react-native-webrtc's `RTCView`), sized by `style`. Its props are
643
+ `VideoTrackProps`:
644
+
645
+ | Prop | |
646
+ | --- | --- |
647
+ | `track` | the video to show: a remote track's `track`, or your own camera, `useLocalMedia().stream?.getVideoTracks()[0]`. Nothing while null or undefined. A `MediaStreamTrackLike`: react-native-webrtc's track by shape, which a remote track's `track` fits as the client types it, so no DOM library is needed |
648
+ | `producerId` | a remote video's `producerId`: the client then receives it at the size this view is laid out, and pauses it at the server while the view is not `visible` or the app is in the background (adaptive stream). Leave it out for your own camera |
649
+ | `mirror` | flip it horizontally, as people expect to see their own front camera |
650
+ | `objectFit` | `'cover'` (the default) fills the view and crops the edges; `'contain'` shows the whole picture, with bars |
651
+ | `zOrder` | which of two overlapping video views is drawn on top, as for `RTCView`: 0 for a full-screen video, 1 for a preview over it |
652
+ | `visible` | `false` while the view is off screen or covered: with `producerId`, the video is paused at the server until it is `true` again. Default `true` |
653
+ | `onDimensionsChange` | called with the video's width and height when its first frame arrives and when they change |
654
+ | `style`, `testID` | as for any view |
655
+
656
+ Each track is shown through one native stream that the SDK makes, shares
657
+ between the views of that track, and releases before the client closes the
658
+ connection the track came on. Do not wrap tracks in `new MediaStream()` for an
659
+ `RTCView` of your own: react-native-webrtc ends the app when a stream is
660
+ released after its track's connection has closed, which happens on every
661
+ reconnect and when a call ends. A view that shows no frame within 3 s mounts
662
+ its native view again once (react-native-webrtc issue 1611, black video).
663
+
664
+ ### AudioTrack
665
+
666
+ Remote sound plays by itself, so a call needs no `AudioTrack`. Render one only
667
+ to change one person's volume, or to mute them on this phone. It renders
668
+ nothing. Its props are `AudioTrackProps`:
669
+
670
+ | Prop | |
671
+ | --- | --- |
672
+ | `track` | a remote audio track. Nothing while null or undefined |
673
+ | `volume` | 0 to 1 (clamped), this track's playback volume, where 1 is the track as received. Unset leaves it as it is |
674
+ | `muted` | silence this track here only; it plays again when the prop is `false` or the component unmounts |
675
+
676
+ ```tsx
677
+ import { AudioTrack, useRemoteTracks } from '@voltkit-rtc/react-native';
678
+
679
+ /** One person's microphone at half volume on this phone; everyone else plays as received. */
680
+ export function QuietVoice({ identity }: { identity: string }) {
681
+ const [mic] = useRemoteTracks({ identity, source: 'mic' });
682
+ return mic ? <AudioTrack track={mic.track} volume={0.5} /> : null;
683
+ }
684
+ ```
685
+
686
+ ## Audio routing
687
+
688
+ While a client is connected, the SDK runs the phone's call audio: it switches to
689
+ call mode, takes audio focus, and plays sound on a Bluetooth headset, else a
690
+ wired or USB headset, else the loudspeaker or the earpiece. When the last
691
+ client closes, it puts everything back as it found it. Two clients in one app,
692
+ such as the two rooms of a PK battle, share it.
693
+
694
+ ```tsx
695
+ import { Pressable, Text, View } from 'react-native';
696
+ import { useAudioOutput } from '@voltkit-rtc/react-native';
697
+
698
+ const NAMES: Record<string, string> = { speaker: 'Loudspeaker', earpiece: 'Phone', wiredHeadset: 'Headset', bluetooth: 'Bluetooth', usb: 'USB' };
699
+
700
+ export function OutputPicker() {
701
+ const audio = useAudioOutput();
702
+ if (!audio.supported) return null; // false on iOS (preview) and in the media audio mode
703
+ return (
704
+ <View>
705
+ {audio.outputs.map((device) => (
706
+ <Pressable key={device.deviceId} onPress={() => void audio.select(device.deviceId)}>
707
+ <Text style={{ fontWeight: device.deviceId === audio.current?.deviceId ? 'bold' : 'normal' }}>
708
+ {device.label || NAMES[device.type ?? ''] || device.deviceId}
709
+ </Text>
710
+ </Pressable>
711
+ ))}
712
+ </View>
713
+ );
714
+ }
715
+ ```
716
+
717
+ - **`useAudioOutput(): AudioOutput`**: `supported` (outputs can be chosen:
718
+ Android with the SDK's native module, in the `communication` audio mode),
719
+ `outputs` (`MediaDevice[]`: the loudspeaker, the earpiece, and the headsets
720
+ connected now, each with `deviceId`, `label` and `type`: `speaker`,
721
+ `earpiece`, `wiredHeadset`, `bluetooth`, `usb` or `other`), `current` (where
722
+ call audio plays now, or null while no call holds the audio), `speakerphone`
723
+ (the loudspeaker is in use or, before a call, preferred), `select(deviceId)`
724
+ and `setSpeakerphoneOn(on)`. Headsets that come and go are followed.
725
+ - **`select(deviceId)`** plays call audio on that output while it stays
726
+ connected, even over a headset; an output that is not there rejects with
727
+ `bad-params`. **`setSpeakerphoneOn(true)`** picks the loudspeaker when no
728
+ headset is connected, and `false` the earpiece; a headset still wins. Both are
729
+ also plain functions, `selectAudioOutput(deviceId)` and
730
+ `setSpeakerphoneOn(on)`, which work before any client exists, so a screen
731
+ before the call can set them: the choice holds for the calls joined later.
732
+ They reject with `media-unavailable` on iOS (`data.platform` `'ios'`) and
733
+ where the SDK's native module is missing.
734
+ - **`client.switchDevice('audiooutput', deviceId)`**, from `useVoltkitClient()`,
735
+ does what `select` does.
736
+ - **Android 12 and later** route call audio with `setCommunicationDevice`. On
737
+ Android 7 to 11, Bluetooth call audio goes over SCO, which some headsets and
738
+ car kits refuse. A headset that refuses, or says nothing for 3 seconds, is
739
+ passed over: call audio goes to the next output, and that headset is not
740
+ tried again until the user picks it, a device is added, or the call ends.
741
+ - **No Bluetooth permission** is needed: the routing goes through Android's
742
+ `AudioManager`.
743
+ - **Echo on one phone model.** The phone's own echo canceller and noise
744
+ suppressor are used where it has them. If one model echoes, turn them off with
745
+ the Expo plugin's `android.hardwareEchoCancellation: false` (or the
746
+ `in.voltkit.rtc.HW_AEC` meta-data, below), and WebRTC's own echo canceller
747
+ does the work.
748
+ - **Apps whose users only listen** can set the audio mode to `media` (the
749
+ plugin's `android.audioMode`, or the `in.voltkit.rtc.AUDIO_MODE` meta-data):
750
+ sound plays at the media volume and on Bluetooth's high-quality profile, with
751
+ no earpiece, and outputs follow the system: `useAudioOutput().supported` is
752
+ false, and `select` and `switchDevice('audiooutput', ...)` reject with
753
+ `bad-params`. It is fixed for the life of the app's process: never use it
754
+ where anyone may speak, since a listener brought on stage would echo.
755
+ - **`audio={{ manage: false }}`** on the provider leaves the phone's audio mode,
756
+ focus and routing to your app.
757
+ - **The emulator** has only the loudspeaker. Earpiece and Bluetooth routing are
758
+ tested in unit tests, not on hardware: test them on a phone.
759
+
760
+ In a React Native app without Expo, the audio settings are meta-data in
761
+ `AndroidManifest.xml`, read once when the app starts. These are the defaults:
762
+
763
+ ```xml
764
+ <application>
765
+ <!-- communication (calls and audio rooms) or media (listen-only apps) -->
766
+ <meta-data android:name="in.voltkit.rtc.AUDIO_MODE" android:value="communication" />
767
+ <!-- the phone's own echo canceller and noise suppressor, where it has them -->
768
+ <meta-data android:name="in.voltkit.rtc.HW_AEC" android:value="true" />
769
+ <!-- react-native-webrtc's screen-capture service: see Screen sharing -->
770
+ <meta-data android:name="in.voltkit.rtc.SCREEN_SHARE" android:value="false" />
771
+ </application>
772
+ ```
773
+
774
+ On iOS (preview) the SDK does not route audio: `useAudioOutput().supported` is
775
+ false, and `setSpeakerphoneOn` and `selectAudioOutput` reject with
776
+ `media-unavailable` (`data.platform` is `'ios'`).
777
+
778
+ ## Calls in the background
779
+
780
+ Android stops a call that has gone to the background unless the app runs a
781
+ foreground service that it started while it was visible:
782
+
783
+ - **The network.** Without one, Android cut the test app's connections about
784
+ 6 seconds after it went to the background or the screen went off, and the
785
+ call dropped (measured on the Android emulator while building this SDK).
786
+ - **The microphone.** An app in the background records silence unless a
787
+ foreground service is running, which from Android 11 must have the
788
+ `microphone` type.
789
+ - **Timers.** React Native's JavaScript timers stop in the background and while
790
+ the screen is off. The SDK runs the client's own timers natively (the
791
+ reconnect backoff, the connection checks), so with the service keeping the
792
+ app alive, a call still reconnects with the screen off.
793
+
794
+ The SDK's foreground service, `VoltkitCallService`, keeps the call going. The
795
+ provider starts it when the call connects, while the app is visible, and stops
796
+ it when the call ends. It is not in the SDK's own manifest: the app declares
797
+ it, so an app that never runs calls in the background has nothing to declare
798
+ to Google Play.
799
+
800
+ ### Declaring the call service
801
+
802
+ In an Expo app, the config plugin declares it unless you set
803
+ `android.callService` to `false`. In a React Native app without Expo, add this
804
+ to `android/app/src/main/AndroidManifest.xml`, then build the app again:
805
+
806
+ ```xml
807
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
808
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
809
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
810
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
811
+ <!-- Android 13+: shows the call notification in the notification shade (ask for it at run time). -->
812
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
813
+
814
+ <application>
815
+ <!-- Inside your existing <application> element. -->
816
+ <service
817
+ android:name="com.voltkit.rtc.reactnative.VoltkitCallService"
818
+ android:exported="false"
819
+ android:foregroundServiceType="microphone|mediaPlayback" />
820
+ </application>
821
+ </manifest>
822
+ ```
823
+
824
+ The service picks its foreground service types from what the app declares,
825
+ what the user has granted and what the call publishes:
826
+
827
+ | Type | When the service runs with it |
828
+ | --- | --- |
829
+ | `microphone` | `RECORD_AUDIO` is granted: the call keeps its microphone |
830
+ | `mediaPlayback` | otherwise: a listener, who keeps hearing the call |
831
+ | `camera` | only if you declare it, with `FOREGROUND_SERVICE_CAMERA`, and keep the camera on in the background with `background={{ pauseCamera: false }}` |
832
+ | `mediaProjection` | only while a screen share is live, if you declare it (with `FOREGROUND_SERVICE_MEDIA_PROJECTION` on Android 14 and later) |
833
+
834
+ `useCallService(): CallServiceState` says whether the app declares the service
835
+ (`declared`), whether it runs (`running`) and with which types (`types`). If it
836
+ cannot start, because a permission is missing from the manifest or the app was
837
+ already in the background, `onError` gets `media-unavailable` with
838
+ `data.feature` set to `'callService'` and a message that names what is missing.
839
+ The call goes on, in the foreground only. `callService={false}` on the provider
840
+ never starts it.
841
+
842
+ ```tsx
843
+ import { Text } from 'react-native';
844
+ import { useCallService } from '@voltkit-rtc/react-native';
845
+
846
+ /** A line for a settings screen: whether this build keeps calls going in the background. */
847
+ export function BackgroundCallsNote() {
848
+ const service = useCallService();
849
+ if (!service.declared) return <Text>Calls end when the app leaves the screen in this build.</Text>;
850
+ if (!service.running) return <Text>The call runs in the foreground only right now.</Text>;
851
+ return <Text>Call service running with {service.types.join(', ')}.</Text>;
852
+ }
853
+ ```
854
+
855
+ ### What happens in the background
856
+
857
+ The app counts as in the background while none of its activities is started,
858
+ as Android's process lifecycle says. A permission dialog or the screen-share
859
+ consent dialog on top of the app does not count. In the background:
860
+
861
+ - **Received videos** shown with a `producerId` pause at the server, so they use
862
+ no data, and resume when the app returns. The sound keeps playing.
863
+ - **The camera** switches off, so viewers see the camera off rather than a
864
+ frozen picture, and back on when the app returns, unless the user or a
865
+ moderator switched it meanwhile. `background={{ pauseCamera: false }}` keeps
866
+ it on, which needs the service's `camera` type; without it Android stops a
867
+ background app's camera anyway.
868
+ - **The microphone** keeps sending, as a call needs. `background={{ pauseMic: true }}`
869
+ switches it off too; leave that off for calls.
870
+ - **What the user switches** while the app is in the background is theirs to
871
+ keep. A camera that comes on air there through something started before (a
872
+ publish finishing, a reconnect publishing again) is held off until the app
873
+ returns.
874
+
875
+ ### The notification
876
+
877
+ While the service runs, Android shows its notification: "Call in progress",
878
+ "Tap to return to the call", and a Leave button. Tapping it opens the app;
879
+ Leave ends the call (`onClosed` gets `left`, and the others see you leave).
880
+ Change the words on the provider:
881
+
882
+ ```tsx
883
+ import type { ReactNode } from 'react';
884
+ import { VoltkitProvider } from '@voltkit-rtc/react-native';
885
+
886
+ export function CallProvider({ wsUrl, token, children }: { wsUrl: string; token: string; children: ReactNode }) {
887
+ return (
888
+ <VoltkitProvider
889
+ wsUrl={wsUrl}
890
+ token={token}
891
+ callService={{ title: 'Call with Asha', text: 'Tap to return to the call', leaveLabel: 'Hang up' }}
892
+ >
893
+ {children}
894
+ </VoltkitProvider>
895
+ );
896
+ }
897
+ ```
898
+
899
+ The small icon is your app's drawable named `voltkit_call_notification_icon`
900
+ (or `notification_icon`, or `ic_notification`), and the SDK's own white handset
901
+ when there is none; never the launcher icon, which Android would draw as a
902
+ solid square. On Android 13 and later the notification shows in the
903
+ notification shade only if the user allowed notifications; the service runs
904
+ either way. Swiping the app away from the recent apps ends the call: the others
905
+ see you leave, and the service stops.
906
+
907
+ ### Google Play's declaration
908
+
909
+ An app that targets Android 14 or later and declares foreground service types
910
+ declares each type in Play Console, under **Monitor and improve → App
911
+ content**: what the feature does, what happens to the user if the task is
912
+ deferred or interrupted, and a link to a video that shows a user starting the
913
+ feature. For the call service, for example:
914
+
915
+ - **Microphone:** "Keeps the user's call audio going when they switch apps or
916
+ turn the screen off during a call they started." The video starts a call,
917
+ goes to the home screen, and keeps talking.
918
+ - **Media playback:** "Keeps a call audible for a listener who leaves the app."
919
+ The video joins a room as a listener, goes to the home screen, and keeps
920
+ listening.
921
+ - **Camera** and **media projection**, if you declare them: the camera kept on
922
+ in the background, and screen sharing.
923
+
924
+ Without the service (the plugin's `android.callService: false`, or no
925
+ `<service>` block) there is nothing to declare, and a call drops about 6
926
+ seconds after the app leaves the screen.
927
+
928
+ ### Xiaomi, Redmi and POCO phones
929
+
930
+ Some phone makers' battery savers stop background apps beyond what Android
931
+ itself does, and Xiaomi's are known for it. VoltKit has tested the call service
932
+ on the Android emulator, not on these phones. If calls end in the background on
933
+ them although `useCallService().running` was true, ask users to set your app's
934
+ battery saver to "No restrictions" in the app's settings, and to allow
935
+ Autostart.
936
+
937
+ ### iOS
938
+
939
+ In the iOS preview there is no call service. Calls that keep their sound in the
940
+ background need the `audio` background mode (`UIBackgroundModes`), which the
941
+ Expo plugin adds unless you set `ios.backgroundAudio` to `false`. VoltKit has
942
+ not tested background calls on iOS.
943
+
944
+ ## Screen sharing
945
+
946
+ Screen sharing runs on Android, through react-native-webrtc's screen-capture
947
+ service. Turn it on with the Expo plugin's `android.screenShare: true`, or in a
948
+ React Native app without Expo add the permission and the meta-data, and the
949
+ `mediaProjection` type to the call service:
950
+
951
+ ```xml
952
+ <!-- FOREGROUND_SERVICE is in the call service's entries above; react-native-webrtc's service needs it too. -->
953
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
954
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
955
+
956
+ <application>
957
+ <meta-data android:name="in.voltkit.rtc.SCREEN_SHARE" android:value="true" />
958
+ <service
959
+ android:name="com.voltkit.rtc.reactnative.VoltkitCallService"
960
+ android:exported="false"
961
+ android:foregroundServiceType="microphone|mediaPlayback|mediaProjection" />
962
+ </application>
963
+ ```
964
+
965
+ ```tsx
966
+ import { Button } from 'react-native';
967
+ import { RpcError } from '@voltkit-rtc/client';
968
+ import { useLocalMedia } from '@voltkit-rtc/react-native';
969
+
970
+ export function ShareScreenButton({ onProblem }: { onProblem: (text: string) => void }) {
971
+ const local = useLocalMedia();
972
+
973
+ async function toggle() {
974
+ try {
975
+ if (local.screenSharing) await local.stopScreenShare();
976
+ else await local.startScreenShare(); // Android asks the user first
977
+ } catch (err) {
978
+ const code = err instanceof RpcError ? err.code : '';
979
+ if (code === 'screen-share-denied') return; // the user said no: nothing to show
980
+ if (code === 'screen-share-service-required') onProblem('Screen sharing is not set up in this build.');
981
+ else if (code === 'screen-share-unsupported') onProblem('Screen sharing needs Android.');
982
+ else onProblem('Could not share the screen.');
983
+ }
984
+ }
985
+
986
+ return <Button title={local.screenSharing ? 'Stop sharing' : 'Share screen'} onPress={toggle} />;
987
+ }
988
+ ```
989
+
990
+ - Android asks the user before every share; on recent versions they choose one
991
+ app or the entire screen. The screen goes out beside the camera as a second
992
+ video, with `source` `screen`, and without sound.
993
+ - Stopping from the status bar or the notification ends the share too
994
+ (`screenShareEnded`), and from Android 15 QPR1 the system also ends it when
995
+ the screen locks. The SDK stops react-native-webrtc's screen-capture service
996
+ when a share ends.
997
+ - `screen-share-service-required`: the build does not have the screen-capture
998
+ service. `screen-share-denied`: the user cancelled.
999
+ `screen-share-unsupported`: iOS. `screen-share-active`: a share is already
1000
+ running or starting. `screen-share-failed`: the capture could not start, with
1001
+ the reason in the message.
1002
+
1003
+ ## Chat
1004
+
1005
+ There are no chat hooks: chat is on the client the provider holds. Listen with
1006
+ `useVoltkitEvent` and send with `useVoltkitClient()`:
1007
+
1008
+ ```tsx
1009
+ import { useState } from 'react';
1010
+ import { Button, FlatList, Text, TextInput, View } from 'react-native';
1011
+ import { RpcError, type ChatMessage } from '@voltkit-rtc/client';
1012
+ import { useVoltkitClient, useVoltkitEvent } from '@voltkit-rtc/react-native';
1013
+
1014
+ export function Chat() {
1015
+ const client = useVoltkitClient();
1016
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
1017
+ const [draft, setDraft] = useState('');
1018
+ const [notice, setNotice] = useState<string | null>(null);
1019
+
1020
+ useVoltkitEvent('message', (m) => setMessages((list) => [...list, m])); // yours included
1021
+ useVoltkitEvent('messageDeleted', ({ id }) => setMessages((list) => list.filter((m) => m.id !== id)));
1022
+
1023
+ async function send() {
1024
+ if (!client || !draft.trim()) return;
1025
+ try {
1026
+ await client.sendMessage(draft);
1027
+ setDraft('');
1028
+ setNotice(null);
1029
+ } catch (err) {
1030
+ if (err instanceof RpcError && err.code === 'chat-muted') setNotice('A moderator has muted you in this chat.');
1031
+ else if (err instanceof RpcError && err.code === 'rate-limited') setNotice('Slow down a little.');
1032
+ else setNotice('Your message was not sent.');
1033
+ }
1034
+ }
1035
+
1036
+ return (
1037
+ <View style={{ flex: 1 }}>
1038
+ <FlatList
1039
+ data={messages}
1040
+ keyExtractor={(m) => m.id}
1041
+ renderItem={({ item }) => (
1042
+ <Text>
1043
+ <Text style={{ fontWeight: 'bold' }}>{item.from}</Text> {item.text}
1044
+ </Text>
1045
+ )}
1046
+ />
1047
+ {notice && <Text>{notice}</Text>}
1048
+ <TextInput value={draft} onChangeText={setDraft} onSubmitEditing={() => void send()} placeholder="Message" />
1049
+ <Button title="Send" onPress={() => void send()} disabled={!client} />
1050
+ </View>
1051
+ );
1052
+ }
1053
+ ```
1054
+
1055
+ Render it inside the provider. A `Text` shows a message as text, whatever it
1056
+ contains. `client.getMessages({ limit: 50 })` returns earlier messages. Chat
1057
+ without a room uses `ChatClient` from `@voltkit-rtc/client`. Direct messages,
1058
+ moderation and history: https://voltkit.in/docs/chat.
1059
+
1060
+ ## Moderation, seats and audio rooms
1061
+
1062
+ Everything that is not state is on the client: `useVoltkitClient()` returns it,
1063
+ and `useVoltkitEvent()` listens to any of its events. A listener raises a hand
1064
+ with `raiseHand()`, the host brings them on stage with
1065
+ `promote(identity, 'cohost')`, and a moderator uses
1066
+ `forceMute(identity, 'audio')`, `kick(identity, reason)` and `endRoom()`; seats
1067
+ are `claim(key)`, `release(key)` and `blockSlot(key)`. The service enforces
1068
+ each one from the role in the token.
1069
+
1070
+ ```tsx
1071
+ import { useState } from 'react';
1072
+ import { Button, Text, View } from 'react-native';
1073
+ import { useVoltkitClient, useVoltkitEvent } from '@voltkit-rtc/react-native';
1074
+
1075
+ /** For the host: who raised a hand, and a button to bring each one on stage. */
1076
+ export function RaisedHands() {
1077
+ const client = useVoltkitClient();
1078
+ const [hands, setHands] = useState<string[]>([]);
1079
+
1080
+ useVoltkitEvent('handRaised', ({ identity, handRaised }) =>
1081
+ setHands((list) => [...list.filter((i) => i !== identity), ...(handRaised ? [identity] : [])]),
1082
+ );
1083
+
1084
+ return (
1085
+ <View>
1086
+ {hands.map((identity) => (
1087
+ <View key={identity}>
1088
+ <Text>{identity} raised a hand</Text>
1089
+ <Button title="Bring on stage" onPress={() => void client?.promote(identity, 'cohost')} />
1090
+ </View>
1091
+ ))}
1092
+ </View>
1093
+ );
1094
+ }
1095
+ ```
1096
+
1097
+ For a voice call held to the ear, give the provider
1098
+ `audio={{ speakerphone: false }}` and publish the microphone alone with
1099
+ `publish({ video: false })`. One-to-one voice calls and group audio rooms, with
1100
+ a stage, seats and chat, are built on https://voltkit.in/docs/voice-calls and
1101
+ https://voltkit.in/docs/audio-rooms; their calls have the same names here.
1102
+
1103
+ ## Token refresh and wallet notices
1104
+
1105
+ The service sends the session a fresh token over its connection by itself, so
1106
+ usually there is nothing to do. When it cannot (an older server, or a project
1107
+ that is out of credit, at a usage limit or suspended), the client fires
1108
+ `tokenExpiring` about a minute before its token expires. Mint a new one and
1109
+ render the provider with it:
1110
+
1111
+ ```tsx
1112
+ import { useVoltkitEvent } from '@voltkit-rtc/react-native';
1113
+ import { fetchCredentials } from './token';
1114
+
1115
+ // Inside the provider, beside <Call />:
1116
+ // <RefreshToken callId={callId} accessToken={accessToken} onToken={(token) => setCredentials((c) => (c ? { ...c, token } : c))} />
1117
+ export function RefreshToken(props: { callId: string; accessToken: string; onToken: (token: string) => void }) {
1118
+ useVoltkitEvent('tokenExpiring', async () => {
1119
+ try {
1120
+ const { token } = await fetchCredentials(props.callId, props.accessToken);
1121
+ props.onToken(token);
1122
+ } catch {
1123
+ // The session keeps its token: only a reconnect after it has expired needs a new one.
1124
+ }
1125
+ });
1126
+ return null;
1127
+ }
1128
+ ```
1129
+
1130
+ The provider sees a new token for the same session and hands it to the live
1131
+ session with `updateToken`, with no reconnect. A refresh the server refuses
1132
+ (`bad-token`) is reported through `onError`, and the session keeps its own
1133
+ token.
1134
+
1135
+ With the VoltKit Wallet, the session is warned while the credits the project
1136
+ may spend cover less than about a minute. `useWalletLow()` turns the warnings
1137
+ into state:
1138
+
1139
+ ```tsx
1140
+ import { Text } from 'react-native';
1141
+ import { useWalletLow } from '@voltkit-rtc/react-native';
1142
+
1143
+ export function CreditBanner() {
1144
+ const { active, remainingSeconds } = useWalletLow();
1145
+ if (!active) return null;
1146
+ return <Text accessibilityRole="alert">Call credit runs out in about {remainingSeconds} s.</Text>;
1147
+ }
1148
+ ```
1149
+
1150
+ A recharge clears it. If the credits run out, the room closes: `onClosed` gets
1151
+ `room-closed`, and `roomClosed` carries the reason `quota-exhausted`.
1152
+ Flat-minute (legacy) projects get `usageLimit` instead:
1153
+ `useVoltkitEvent('usageLimit', ({ endsAt }) => ...)`.
1154
+
1155
+ ## Your own client
1156
+
1157
+ `createClient(wsUrl, token, options?)` makes a `StreamClient` for React Native,
1158
+ not yet connected: pass it to the provider's `client` prop, or call `connect()`
1159
+ yourself, and `close()` it when you are done, since the provider never closes a
1160
+ client it was given. `CreateClientOptions` is
1161
+ `{ background?: BackgroundOptions; clientOptions?: ClientOptions }`. It throws
1162
+ `media-unavailable` (`webrtc-missing`) where react-native-webrtc's native module
1163
+ is missing. Never make one with `new StreamClient(...)` alone, which would look
1164
+ for a browser.
1165
+
1166
+ ```tsx
1167
+ import { useEffect, useState, type ReactNode } from 'react';
1168
+ import { VoltkitProvider, createClient } from '@voltkit-rtc/react-native';
1169
+ import type { StreamClient } from '@voltkit-rtc/react-native';
1170
+
1171
+ /** A client the app owns: the second room of a PK battle, a client kept across screens. */
1172
+ export function OwnClient({ wsUrl, token, children }: { wsUrl: string; token: string; children: ReactNode }) {
1173
+ const [client, setClient] = useState<StreamClient | null>(null);
1174
+ useEffect(() => {
1175
+ const own = createClient(wsUrl, token, { background: { pauseCamera: false } });
1176
+ setClient(own);
1177
+ return () => {
1178
+ void own.close();
1179
+ };
1180
+ }, [wsUrl, token]);
1181
+ return <VoltkitProvider client={client}>{children}</VoltkitProvider>;
1182
+ }
1183
+ ```
1184
+
1185
+ `reactNativeClientOptions(options?)` returns the `ClientOptions` such a client
1186
+ is made with: `handlerName: 'ReactNative106'`, `platform: reactNativePlatform()`,
1187
+ `pauseCameraInBackground` and `pauseMicInBackground` from `background`, and then
1188
+ your `clientOptions`, which win. `reactNativePlatform()` is the platform the
1189
+ client runs on in React Native (`ClientOptions.platform` of `@voltkit-rtc/client`
1190
+ 0.9.12): capture, devices and audio routing, streams and their release order,
1191
+ the app's visibility from the process lifecycle, the network, and native
1192
+ timers. One object per process, shared by every client; `createClient` and the
1193
+ provider pass it for you.
1194
+
1195
+ ## Errors
1196
+
1197
+ Errors and warnings are `RpcError`s from `@voltkit-rtc/client` with a
1198
+ machine-readable `code` (`ErrorCode`): switch on the code, never the message.
1199
+ `data` carries the details, such as which permission was refused. Warnings
1200
+ (`frame-rate-fallback`, `audio-quality-not-allowed`, ...) change nothing else
1201
+ and need no action. Every code: https://voltkit.in/docs/errors.
1202
+
1203
+ | Where | What arrives |
1204
+ | --- | --- |
1205
+ | `onError` on the provider | every `error` event from the client, failures and warnings alike, a failed connect, a token the session refused, and a call service that could not start |
1206
+ | `useRoom().error` | the last of those, until a join succeeds |
1207
+ | the promise of the call | `useRoom().connect()`, `publish()`, `switchCamera()` and every other `useLocalMedia()` call, `select` and `setSpeakerphoneOn`, and the client's own calls such as `sendMessage()`, reject with the error |
1208
+ | `onClosed` | the session ended for good, and why |
1209
+
1210
+ `VideoTrack` and `AudioTrack` throw nothing. Without a client (outside a
1211
+ provider, or before it has made one) every `useLocalMedia()` call rejects with
1212
+ `not-connected`. Where react-native-webrtc's native code is missing, the
1213
+ provider makes no client at all: only `onError` hears it, with
1214
+ `media-unavailable` and `data.reason` `webrtc-missing`, while
1215
+ `useRoom().state` stays `idle`, so log `onError` while you develop.
1216
+
1217
+ ### When joining fails
1218
+
1219
+ The provider connects once and does not retry a connect that failed: `onError`
1220
+ and `useRoom().error` get the error, and the state goes back to `idle`.
1221
+ `isTerminalError(code)` from `@voltkit-rtc/client` says whether the same token
1222
+ would be refused again. Turn the code into words for the user:
1223
+
1224
+ ```ts
1225
+ import { RpcError, isTerminalError } from '@voltkit-rtc/client';
1226
+
1227
+ export function joinProblem(err: Error): { text: string; canRetry: boolean } {
1228
+ const code = err instanceof RpcError ? err.code : '';
1229
+ switch (code) {
1230
+ case 'bad-token':
1231
+ case 'no-token':
1232
+ return { text: 'Your session expired. Join again.', canRetry: true }; // a fresh token fixes it
1233
+ case 'room-ended':
1234
+ case 'no-room':
1235
+ return { text: 'This call has ended.', canRetry: false };
1236
+ case 'kicked':
1237
+ return { text: 'You were removed from this call.', canRetry: false };
1238
+ case 'quota-exhausted':
1239
+ case 'daily-limit-reached':
1240
+ case 'monthly-limit-reached':
1241
+ case 'tenant-suspended':
1242
+ case 'billing-unavailable':
1243
+ return { text: 'Calls are unavailable right now.', canRetry: false }; // your project's account, not the user
1244
+ case 'participant-quota':
1245
+ case 'no-capacity':
1246
+ return { text: 'The call is full right now. Try again in a moment.', canRetry: true };
1247
+ default:
1248
+ return { text: 'Could not connect. Check your connection and try again.', canRetry: !isTerminalError(code) };
1249
+ }
1250
+ }
1251
+ ```
1252
+
1253
+ ### When publishing fails
1254
+
1255
+ `publish()` refuses before it opens any device, so the camera light never comes
1256
+ on for a publish that cannot happen:
1257
+
1258
+ | `code` | When |
1259
+ | --- | --- |
1260
+ | `media-unavailable` | a permission was refused: `data.permission` and `data.status`, see [Permissions](#permissions) |
1261
+ | `not-connected` | the session is not connected, or there is no client yet |
1262
+ | `forbidden` | your role is not `host` or `cohost` |
1263
+ | `source-already-published` | a camera and microphone are already published, or a publish is under way: `unpublish()` first |
1264
+ | `media-disabled` | the project switched that kind off. Check `permissions.audio` and `permissions.video`, or pass `audio: false` or `video: false` |
1265
+
1266
+ A camera or microphone that another app holds, or that fails to open, rejects
1267
+ with `media-unavailable`, with `data.reason` set to the capture error's name (or
1268
+ `capture-failed`) and its words in the message: offer audio only with
1269
+ `publish({ video: false })`. The other calls refuse with codes too:
1270
+ `setMicEnabled(true)` and `setCamEnabled(true)` with `force-muted` while a
1271
+ moderator holds that device off, `switchCamera()` with `media-unavailable`
1272
+ before a camera is published, and `startScreenShare()` with the
1273
+ [screen-share codes](#screen-sharing).
1274
+
1275
+ ```ts
1276
+ import { RpcError } from '@voltkit-rtc/client';
1277
+
1278
+ type MediaProblem = {
1279
+ permission?: 'camera' | 'microphone';
1280
+ status?: 'denied' | 'blocked';
1281
+ reason?: string;
1282
+ feature?: string;
1283
+ platform?: string;
1284
+ };
1285
+
1286
+ /** Words for a media-unavailable error, or null for any other error. */
1287
+ export function mediaProblem(err: unknown): string | null {
1288
+ if (!(err instanceof RpcError) || err.code !== 'media-unavailable') return null;
1289
+ const data = (err.data ?? {}) as MediaProblem;
1290
+ if (data.permission) return data.status === 'blocked' ? `Allow the ${data.permission} in Settings.` : `The ${data.permission} is needed.`;
1291
+ if (data.reason === 'webrtc-missing') return 'This build cannot make calls. Install the latest version of the app.';
1292
+ if (data.feature === 'callService') return 'Calls may stop when you leave the app.';
1293
+ return 'The camera or microphone is not available.';
1294
+ }
1295
+ ```
1296
+
1297
+ ### Codes from React Native
1298
+
1299
+ | `code` | What happened | What to do |
1300
+ | --- | --- | --- |
1301
+ | `media-unavailable` with `data.permission` | the user refused the camera or microphone; `data.status` is `denied` or `blocked` | ask again, or send them to the app's settings (`Linking.openSettings()`) when it is `blocked` |
1302
+ | `media-unavailable` with `data.reason` `webrtc-missing` | the app has no react-native-webrtc native code: Expo Go, or an app not built again after installing the SDK. The message says it needs a development build | build the app again; use a development build, not Expo Go |
1303
+ | `media-unavailable` with `data.reason` `native-missing` | the SDK's own native module is not in the app (Android): background calls, audio routing and native timers are off; `data.feature` names what asked | build the app again |
1304
+ | `media-unavailable` with another `data.reason` | the camera or microphone could not open: the reason is the capture error's name, or `capture-failed` | offer audio only, or try again |
1305
+ | `media-unavailable` with `data.feature` `callService` | the call service could not start: a manifest entry is missing, or the app was in the background | add what the message names; the call goes on in the foreground |
1306
+ | `media-unavailable` with `data.platform` `ios` | audio routing on iOS: `setSpeakerphoneOn`, `selectAudioOutput` or `switchDevice('audiooutput', ...)` | leave the control out on iOS |
1307
+ | `camera-failed` | the camera could not switch; the old one stays on air | try again |
1308
+ | `bad-params` | an id no camera, microphone or output has, or an output chosen in the `media` audio mode | use an id from `useDevices` or `useAudioOutput` |
1309
+ | `screen-share-service-required`, `screen-share-denied`, `screen-share-unsupported`, `screen-share-active`, `screen-share-failed` | screen sharing is not set up in the build, the user cancelled, iOS, a share is already starting, or the capture could not start | see [Screen sharing](#screen-sharing) |
1310
+ | `music-unavailable` | in-app music needs a browser | leave it out of the app |
1311
+
1312
+ ### Reconnection
1313
+
1314
+ The client reconnects by itself, with backoff, and publishes your camera and
1315
+ microphone again when it is back. `useConnectionState()` and `useRoom().state`
1316
+ go to `reconnecting`, then back to `connected`: keep the call on screen and say
1317
+ it is reconnecting. A network change, such as Wi-Fi to mobile data, is handled
1318
+ the same way: the media path is repaired where it can be (the
1319
+ `mediaReconnecting` event), or the client rejoins. The SDK also listens to
1320
+ Android's network callback, so a reconnect waiting out its backoff tries again
1321
+ as soon as the network is back, also with the screen off while the call service
1322
+ runs. If it cannot reconnect, `onError` gets why (`reconnect-gave-up`, or a
1323
+ refusal such as `room-ended`), then `onClosed` gets `gave-up`, `room-closed` or
1324
+ `kicked`.
1325
+
1326
+ ## Troubleshooting
1327
+
1328
+ Named after what the build, the log or the user shows. Echo, silence, frozen
1329
+ video and blocked connections, which are the same on every platform:
1330
+ https://voltkit.in/docs/troubleshooting.
1331
+
1332
+ ### `WebRTC native module not found`
1333
+
1334
+ react-native-webrtc's own error when its native code is not in the app. The app
1335
+ was not built again after `npm install`: run `npx react-native run-android`, or
1336
+ make a new development build. Expo Go cannot load it at all.
1337
+
1338
+ ### "needs a development build"
1339
+
1340
+ The SDK's `media-unavailable` error when native code is missing: `data.reason`
1341
+ is `webrtc-missing` for react-native-webrtc's module, and `native-missing` for
1342
+ the SDK's own. The app runs in Expo Go, or was not built again after installing
1343
+ or upgrading the packages. Nothing crashes; build the app again.
1344
+
1345
+ ### "the app's native code is @voltkit-rtc/react-native ... rebuild the app"
1346
+
1347
+ A warning in the log when the app's native code was built from another version
1348
+ of the SDK than its JavaScript (`SDK_VERSION`): the package was upgraded without
1349
+ building the app again, or a development build is older than the JavaScript it
1350
+ loads. Build the app again.
1351
+
1352
+ ### `ERROR: R8: Missing class org.webrtc.Dav1dDecoder$Natives`
1353
+
1354
+ A release build with react-native-webrtc 155.0.1 fails with
1355
+ `Missing class org.webrtc.Dav1dDecoder$Natives (referenced from: org.webrtc.Dav1dDecoder$Natives org.webrtc.Dav1dDecoderJni.get() and 2 other contexts)`.
1356
+ The WebRTC build it brings references a class it does not ship, and nothing
1357
+ calls it. `@voltkit-rtc/react-native`'s R8 rules silence it, so with the SDK
1358
+ installed release builds need nothing more. If you see it, the build is not
1359
+ using the SDK's rules: add the line to `android/app/proguard-rules.pro`, or in
1360
+ Expo to `expo-build-properties`' `android.extraProguardRules`:
1361
+
1362
+ ```text
1363
+ -dontwarn org.webrtc.Dav1dDecoder$Natives
1364
+ ```
1365
+
1366
+ ### The call drops about 6 seconds after the app goes to the background
1367
+
1368
+ Or after the screen turns off. The call service is not running: Android cuts
1369
+ the app's network a few seconds after it leaves the screen. Check
1370
+ `useCallService()`: `declared` false means the manifest has no `<service>`
1371
+ block, or the Expo plugin's `android.callService` is `false`; `running` false
1372
+ with `declared` true means it could not start, and `onError` said why
1373
+ (`data.feature` is `callService`). It must start while the app is visible,
1374
+ which the provider does when the call connects. See
1375
+ [Calls in the background](#calls-in-the-background), and for Xiaomi phones
1376
+ [their battery settings](#xiaomi-redmi-and-poco-phones).
1377
+
1378
+ ### `No video stream for react tag`
1379
+
1380
+ react-native-webrtc's `RTCView` logs this when it is handed a stream it cannot
1381
+ find, and shows black (react-native-webrtc issue 1611). Show video with
1382
+ `VideoTrack`, which makes the native stream for each track itself and keeps it
1383
+ while the track is shown. If you render `RTCView` yourself, make one stream per
1384
+ track, keep it while the view is shown, and never release it after the call has
1385
+ ended.
1386
+
1387
+ ### `forgot to call registerGlobals()`
1388
+
1389
+ mediasoup-client's warning, or `UnsupportedError: device not supported`, when
1390
+ `@voltkit-rtc/client` runs in React Native without the SDK: react-native-webrtc's
1391
+ globals are not registered and no handler is named. Import
1392
+ `@voltkit-rtc/react-native` before anything that uses the client (it registers
1393
+ them), and make clients with `createClient` or the provider, never with
1394
+ `new StreamClient(...)`.
1395
+
1396
+ ### `Media projections require a foreground service of type ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION`
1397
+
1398
+ Android 14 refuses screen capture that starts without a running screen-capture
1399
+ service. With the SDK, sharing in a build without the service fails before
1400
+ that, with `screen-share-service-required`: turn on the Expo plugin's
1401
+ `android.screenShare`, or add the meta-data and permission from
1402
+ [Screen sharing](#screen-sharing), and build the app again.
1403
+
1404
+ ### `MissingForegroundServiceTypeException: Starting FGS without a type`
1405
+
1406
+ Android 14 throws this when an app starts a foreground service whose manifest
1407
+ entry has no `android:foregroundServiceType`. There the SDK's call service
1408
+ never starts without a type: if its `<service>` entry declares neither
1409
+ `microphone` nor `mediaPlayback`, it does not start, and `onError` gets
1410
+ `media-unavailable` (`data.feature` `callService`) saying so. If you copied a
1411
+ foreground service for calls from another SDK's guide, remove it:
1412
+ `VoltkitCallService` does that job.
1413
+
1414
+ ### Silence or a black picture on the emulator
1415
+
1416
+ The Android emulator's microphone records silence, and its cameras are
1417
+ simulated and slow. Test publishing sound and video on a phone; on the
1418
+ emulator, check receiving from a browser in the same room.
1419
+
1420
+ ### No earpiece on the emulator
1421
+
1422
+ The emulator has only the loudspeaker, so `useAudioOutput().outputs` lists one
1423
+ output and `setSpeakerphoneOn(false)` has nowhere else to go. Test the earpiece
1424
+ and headsets on a phone.
1425
+
1426
+ ### Echo on one phone model
1427
+
1428
+ Turn off the phone's own echo canceller with the Expo plugin's
1429
+ `android.hardwareEchoCancellation: false` (or `in.voltkit.rtc.HW_AEC` set to
1430
+ `false`) and build again; WebRTC's own canceller takes over. A headset or the
1431
+ earpiece removes echo entirely.
1432
+
1433
+ ### Google Play rejects the app for 16 KB page sizes
1434
+
1435
+ Google Play requires apps that target Android 15 and later to support 16 KB
1436
+ memory pages. The WebRTC build that react-native-webrtc 155 brings is 16 KB
1437
+ aligned, as react-native-webrtc's maintainers say and VoltKit's emulator build
1438
+ (x86_64) showed; a rejection usually means a second copy of WebRTC from another
1439
+ SDK in the app. Look for more than one `libjingle_peerconnection_so.so` (or
1440
+ another SDK's WebRTC library) in the APK, and check the alignment with
1441
+ `zipalign -c -P 16 -v 4 app-release.apk`, which ends with
1442
+ "Verification successful".
1443
+
1444
+ ### `CLEARTEXT communication ... not permitted`
1445
+
1446
+ The `wsUrl` (or your token endpoint) is `ws://` or `http://`, which Android
1447
+ refuses in a release build. Production URLs are `wss://` and `https://`. For a
1448
+ test server on your own network, allow cleartext for that host in a debug build
1449
+ only.
1450
+
1451
+ ### Build problems
1452
+
1453
+ | Message | Fix |
1454
+ | --- | --- |
1455
+ | `@voltkit-rtc/react-native needs react-native-webrtc: npm install react-native-webrtc, then rebuild the app` | install react-native-webrtc, a peer dependency, then build again |
1456
+ | `Duplicate class org.webrtc...` | another package bundles WebRTC's Java classes, such as a react-native-webrtc fork or a native call SDK: remove it |
1457
+ | `minSdkVersion 23 cannot be smaller than version 24` | raise `minSdkVersion` to 24 (in Expo, with `expo-build-properties`) |
1458
+ | `@voltkit-rtc/react-native: the Expo config plugin's build, plugin/build, is missing` | the app uses a checkout of the SDK, not the published package: run `npm run build:plugin` in `react-native-sdk`, then prebuild again |
1459
+ | Prebuild warns `» android: @voltkit-rtc/react-native android.callService: ...` or `... android.screenShare: ...` | a `blockedPermissions` conflict: the service needs a permission your app blocks, which the warning names. Unblock it, or turn the option off |
1460
+
1461
+ ## iOS preview
1462
+
1463
+ iOS is JavaScript only in 0.1.0: the SDK has no native iOS module, and
1464
+ react-native-webrtc's iOS code does the media (`cd ios && pod install` installs
1465
+ it). It is written to run there, but VoltKit has not compiled or run it on an
1466
+ iPhone or a simulator, so treat it as a preview.
1467
+
1468
+ - **Works as on Android, untested:** joining, publishing and receiving, the
1469
+ hooks, `VideoTrack`, chat, moderation and reconnection. Permissions go through
1470
+ react-native-webrtc's permission requests (iOS asks once: a refusal is
1471
+ `blocked` from then on), and the app counts as visible unless iOS reports it
1472
+ in the background.
1473
+ - **Android only:** audio routing (`useAudioOutput().supported` is false;
1474
+ `setSpeakerphoneOn` and `selectAudioOutput` reject with `media-unavailable`,
1475
+ `data.platform` `'ios'`), the call service (`useCallService().declared` is
1476
+ false), and screen sharing (`screen-share-unsupported`).
1477
+ - **Info.plist:** `NSCameraUsageDescription`, `NSMicrophoneUsageDescription`,
1478
+ and `UIBackgroundModes` with `audio` for calls that keep their sound in the
1479
+ background. The Expo plugin writes them.
1480
+ - **CocoaPods** plans to stop accepting new pods on its trunk on 2 December
1481
+ 2026. react-native-webrtc's iOS WebRTC build comes from it, so follow
1482
+ react-native-webrtc's notes for its later versions.
1483
+
1484
+ The Flutter SDK (`voltkit_rtc`) supports iOS 13 and later. Write to
1485
+ contact@voltkit.in to try the React Native SDK on iOS with us.
1486
+
1487
+ ## Testing status
1488
+
1489
+ **Tested on Android (emulator, API 37.1); iOS in preview; earpiece and
1490
+ Bluetooth routing tested in unit tests, not on hardware.** Tested with:
1491
+
1492
+ | | Version |
1493
+ | --- | --- |
1494
+ | React Native | 0.88.0, New Architecture, Hermes |
1495
+ | React | 19.3.0 |
1496
+ | react-native-webrtc | 155.0.1 (WebRTC `org.jitsi:webrtc` 155.0.1) |
1497
+ | Expo | SDK 58 (`expo` 58.0.19), development build, R8-minified release build |
1498
+ | `@voltkit-rtc/client` | 0.9.12 |
1499
+ | `@voltkit-rtc/react` | 0.1.1 |
1500
+ | Android | the Android emulator (a Pixel 10 Pro AVD, x86_64, API 37.1, Google Play image); earpiece and Bluetooth routing in unit tests, not on hardware |
1501
+ | iOS | not tested: preview |
1502
+
1503
+ What ran before this release, in this order:
1504
+
1505
+ - **Unit tests.** The JavaScript (`npm test`, Jest with React Native's preset
1506
+ and react-native-webrtc's native side mocked): the provider against a real
1507
+ client, a fake signalling server and a fake mediasoup handler (connect,
1508
+ publish, close with code 1000); the release order of the native streams
1509
+ behind `VideoTrack` on a reconnect, a kick and a closed room; publish and
1510
+ view races; permissions refused before any capture; the call audio held by
1511
+ one client and by two, and given back; the call service's start, update,
1512
+ stop, Leave and swipe-away; the `media` audio mode; the iOS preview's
1513
+ refusals; `preflight`; and the Expo plugin's manifest and Info.plist output,
1514
+ snapshot by snapshot, including a real `expo config --type introspect`. The
1515
+ Android module (JVM tests): the output choice (earpiece, loudspeaker, wired
1516
+ and USB headsets, Bluetooth), the Bluetooth SCO link and its timeout, the
1517
+ call service and its types, the native timers, the network watch, the
1518
+ preferred microphone and the manifest meta-data. The client
1519
+ (`@voltkit-rtc/client`'s own suite, with a React Native fake platform): no
1520
+ DOM global touched, every stream and track released over 50 cycles,
1521
+ statistics-based audio levels, adaptive stream from a view's size, camera
1522
+ switching, platform timers, and the background pause, ported case by case
1523
+ from the Android SDK's device test and the Flutter SDK's lifecycle tests.
1524
+ - **The client under a React Native-like platform against the real controller
1525
+ and media node** (`make -C voltkit-rtc rn-core-live`): a camera consumer at
1526
+ the real temporal layers, a reconnect in under a second, every received track
1527
+ released through the platform while the connection is live, every platform
1528
+ timer cleared after `close()`, no DOM or host-timer use, no token kept where a
1529
+ serialized client would show it.
1530
+ - **On the emulator**, the example app's R8 release build (Hermes) driven by the
1531
+ in-app scenario runner (`make -C voltkit-rtc rn-device-e2e`), against the real
1532
+ controller and media node: 44 of the 48 scenarios in the device suite passed
1533
+ on the release build and none failed. They cover joining, leaving and the
1534
+ close codes; reconnects after a network cut, a path change, a long stay in
1535
+ the background and a token refresh; the camera and microphone published, with
1536
+ viewers decoding every top-layer frame; dynacast and adaptive stream; the
1537
+ camera switch; permissions; the loss of audio focus; the background pause and
1538
+ the call service through 120 s in the background and with the screen off;
1539
+ screen sharing; the pre-call check; a Chromium peer across the LAN; and the
1540
+ `media` audio mode. Logcat showed no fatal exception, no
1541
+ `No video stream for react tag` and no unexpected `E ReactNativeJS` line.
1542
+ The other four are limits of the emulator or the test harness, with the
1543
+ evidence recorded, not faults found in the SDK: the publisher fallback (the
1544
+ emulator's microphone is silent, so Opus sent 72 packets in the 15 s window
1545
+ where the check needs 150), tracks ending under Android's camera and
1546
+ microphone privacy toggles (the AVD restricts the camera and silences the
1547
+ microphone without ending either track), a scripted tap of the notification's
1548
+ Leave action (the action is in the notification and the leave-to-close path
1549
+ is unit-tested on both sides; uiautomator cannot read a shade whose
1550
+ chronometer redraws it every second), and the swipe away from Recents (on
1551
+ this image Android delivered the removal to the service 4.6 to 10.6 s after
1552
+ the gesture, or not within 20 s; whenever it arrives, the SDK closes the
1553
+ client at once).
1554
+ - **Not on hardware**: the earpiece, Bluetooth and wired headsets, the phone's
1555
+ own echo canceller and encoders, and real microphone levels (the emulator's
1556
+ microphone records silence, so publishing sound is checked by the bytes sent
1557
+ and the node's ingress). iOS was not compiled or run.
1558
+
1559
+ ## Help
1560
+
1561
+ Documentation: https://voltkit.in/docs/react-native-sdk (the guide),
1562
+ https://voltkit.in/docs/react-native-sdk-reference (every export, prop and
1563
+ option), https://voltkit.in/docs/react-native-expo (the config plugin,
1564
+ development builds and EAS) and the tutorial
1565
+ https://voltkit.in/guides/add-video-calling-to-a-react-native-app. Questions
1566
+ and bug reports: contact@voltkit.in.
1567
+
1568
+ For a support ticket, attach the client's diagnostics snapshot. It names the
1569
+ SDK (`SDK_VERSION`), react-native-webrtc and the Android version, and holds no
1570
+ token and no media:
1571
+
1572
+ ```tsx
1573
+ import { Button, Share } from 'react-native';
1574
+ import { useVoltkitClient } from '@voltkit-rtc/react-native';
1575
+
1576
+ export function ShareDiagnostics() {
1577
+ const client = useVoltkitClient();
1578
+ async function share() {
1579
+ if (!client) return;
1580
+ await Share.share({ message: JSON.stringify(await client.getDiagnostics()) });
1581
+ }
1582
+ return <Button title="Share diagnostics" onPress={share} />;
1583
+ }
1584
+ ```
1585
+
1586
+ `preflight(wsUrl, token, options?)` is a pre-call check: it joins with a client
1587
+ of its own, publishes the microphone for a few seconds when its permission is
1588
+ already granted (it asks for nothing; pass `media` to test streams of your own),
1589
+ and resolves to a `PreflightReport` of how media travelled, the round trip,
1590
+ loss, upload and any device problem. It never rejects: read `report.ok`, and the
1591
+ steps say what failed. Give it a token for a test room of its own, since the
1592
+ same identity joining anywhere replaces its live call.
1593
+
1594
+ ```ts
1595
+ import { preflight } from '@voltkit-rtc/react-native';
1596
+
1597
+ export async function checkBeforeTheCall(wsUrl: string, token: string): Promise<string> {
1598
+ const report = await preflight(wsUrl, token, { durationMs: 4000 });
1599
+ if (report.ok) return `Ready: ${report.level ?? 'unknown'} (${report.rttMs ?? '?'} ms, ${report.lossPct ?? '?'} % loss)`;
1600
+ const failed = report.steps.find((step) => !step.ok);
1601
+ return `Not ready: ${failed?.name ?? 'unknown'} ${failed?.error ?? ''}`.trim();
1602
+ }
1603
+ ```
1604
+
1605
+ ## Licence
1606
+
1607
+ MIT