@apirtc/expo-apirtc-options-plugin 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,62 +1,96 @@
1
1
  # expo-apirtc-options-plugin
2
2
 
3
3
  Plugin to add apiRTC features in React Native application using Expo.
4
- This plugin simplify the integration of following features :
5
- - screenSharing for Android and iOS
4
+ This plugin simplifies the integration of the following features:
5
+ - Screen sharing for Android and iOS
6
+ - Background blur and background image replacement for Android (via ML Kit Selfie Segmentation)
6
7
 
7
8
  ## Installation
8
9
 
9
10
  ```bash
10
11
  npm install @apirtc/expo-apirtc-options-plugin
11
-
12
12
  ```
13
13
 
14
14
  ## Usage in app.json
15
15
 
16
- You need to declare the plugin in app.json file of your application :
16
+ Declare the plugin in your `app.json`:
17
17
 
18
- ```js
19
- module.exports = {
20
- ...
21
- plugins: [
22
- ['@apirtc/expo-apirtc-options-plugin', {
23
- enableMediaProjectionService: false,
24
- appleTeamId: "YOUR_TEAM_ID"
25
- }]
26
- ],
27
- };
18
+ ```json
19
+ {
20
+ "expo": {
21
+ "plugins": [
22
+ ["@apirtc/expo-apirtc-options-plugin", {
23
+ "enableMediaProjectionService": true,
24
+ "enableVideoEffects": true,
25
+ "appleTeamId": "YOUR_TEAM_ID"
26
+ }]
27
+ ]
28
+ }
29
+ }
28
30
  ```
29
31
 
30
- ### Possible options :
32
+ ### Options
31
33
 
32
- | Parameter | Description | Default value |
34
+ | Parameter | Description | Default |
33
35
  | --- | --- | --- |
34
- | enableMediaProjectionService | Can be used to deactivate screenSharing activation on Android | true |
35
- | appleTeamId | used to define appleTeamId in your application project and screensharing extension | "APPLE_TEAM_ID_NOT_SET" |
36
+ | `enableMediaProjectionService` | Enable screen sharing on Android (MediaProjection service) | `true` |
37
+ | `enableVideoEffects` | Enable background blur and background image replacement on Android | `true` |
38
+ | `appleTeamId` | Apple Team ID used for the iOS broadcast extension | `"APPLE_TEAM_ID_NOT_SET"` |
39
+
40
+ ## What does this plugin do?
41
+
42
+ ### Android
43
+
44
+ - Adds required permissions to `AndroidManifest.xml` for screen sharing and camera access
45
+ - Registers `AppLifecyclePackage` to handle app lifecycle events (e.g. releasing streams when the app is killed)
46
+ - When `enableVideoEffects: true`:
47
+ - Copies native Kotlin files (`BackgroundBlurModule`, `BlurVideoProcessor`, `BackgroundImageProcessor`, `BackgroundBlurPackage`) to the Android source tree
48
+ - Registers `BackgroundBlurPackage` in `MainApplication.kt`
49
+ - Adds `com.google.mlkit:segmentation-selfie:16.0.0-beta6` to `app/build.gradle`
50
+
51
+ ### iOS
36
52
 
37
- Example :
53
+ - Adds a broadcast extension for screen sharing
54
+ - Configures the project with all modifications needed for screen sharing
55
+
56
+ ## Using video effects from JavaScript (Android only)
57
+
58
+ After enabling `enableVideoEffects: true`, the `BackgroundBlurModule` native module is available:
38
59
 
39
60
  ```js
40
- module.exports = {
41
- ...
42
- plugins: [
43
- ['@apirtc/expo-apirtc-options-plugin', {
44
- enableMediaProjectionService: false,
45
- appleTeamId: "YOUR_TEAM_ID"
46
- }]
47
- ],
48
- };
49
- ```
61
+ import { NativeModules, Platform } from 'react-native';
62
+ const { BackgroundBlurModule } = NativeModules;
50
63
 
51
- ### What does this plugin do ? :
64
+ // Enable background blur
65
+ if (Platform.OS === 'android') {
66
+ await BackgroundBlurModule.enableBlur({
67
+ trackId: videoTrack.id, // video track ID from the local stream
68
+ strong: false, // true for stronger blur (3 passes)
69
+ });
70
+ }
52
71
 
53
- On Android :
72
+ // Enable background image replacement
73
+ if (Platform.OS === 'android') {
74
+ await BackgroundBlurModule.enableBackgroundImage({
75
+ trackId: videoTrack.id,
76
+ imageUrl: 'https://example.com/background.jpg',
77
+ });
78
+ }
79
+
80
+ // Disable any active video effect
81
+ if (Platform.OS === 'android') {
82
+ await BackgroundBlurModule.disableBlur();
83
+ }
84
+
85
+ // Check if an effect is active
86
+ if (Platform.OS === 'android') {
87
+ const isActive = await BackgroundBlurModule.isBlurEnabled();
88
+ }
89
+ ```
54
90
 
55
- - Add needed permission in your app AndroidManifest file for screenSharing
56
- - Manage the case where the application is killed by user when a screenShare is enabled :
57
- A native module AppLifecycleModule is added in your application to manage the case where the user kill the application using swipe.
58
- This module detect the onHostDestroy event to stop the screenSharing extension and unpublish stream.
91
+ ## Notes
59
92
 
60
- On iOS :
61
- - Add broadcast extension for screenSharing feature
62
- - Configure project with needed modification for screenSharing feature
93
+ - `enableVideoEffects` is Android-only. The option is silently ignored on iOS.
94
+ - ML Kit Selfie Segmentation (`16.0.0-beta6`) is currently in beta — there is no GA release of this library.
95
+ - Background effects are applied via WebRTC's `VideoProcessor` API, so both the local preview and remote peers see the processed video.
96
+ - For persistence across sessions, save the selected effect ID with `AsyncStorage` and re-apply it after `createStreamFromUserMedia()`.
@@ -0,0 +1,420 @@
1
+ package com.reactnativeapirtc
2
+
3
+ import android.util.Log
4
+ import com.facebook.react.bridge.ReactApplicationContext
5
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
6
+ import com.facebook.react.bridge.ReactMethod
7
+ import com.facebook.react.bridge.Promise
8
+ import com.facebook.react.bridge.ReadableMap
9
+ import com.oney.WebRTCModule.WebRTCModule
10
+ import org.webrtc.VideoSource
11
+
12
+ /**
13
+ * React Native module that provides background blur functionality for Android.
14
+ *
15
+ * Architecture:
16
+ * This module finds the WebRTC VideoSource (camera source) via reflection
17
+ * and sets a VideoProcessor on it. The VideoProcessor intercepts every
18
+ * camera frame, applies ML Kit segmentation + RenderScript blur, and
19
+ * forwards the processed frame to the downstream pipeline.
20
+ *
21
+ * Camera → VideoSource → [VideoProcessor] → VideoTrack → RTCView + Encoder
22
+ *
23
+ * This ensures both local preview AND remote peers see the blurred video.
24
+ *
25
+ * Exposed methods to JavaScript:
26
+ * - enableBlur(config): Promise
27
+ * - disableBlur(): Promise
28
+ * - isBlurEnabled(): Promise<boolean>
29
+ */
30
+ class BackgroundBlurModule(reactContext: ReactApplicationContext) :
31
+ ReactContextBaseJavaModule(reactContext) {
32
+
33
+ companion object {
34
+ private const val TAG = "BackgroundBlurModule"
35
+ const val NAME = "BackgroundBlurModule"
36
+ }
37
+
38
+ private var processor: BlurVideoProcessor? = null
39
+ private var imageProcessor: BackgroundImageProcessor? = null
40
+ private var videoSource: VideoSource? = null
41
+
42
+ override fun getName(): String = NAME
43
+
44
+ /**
45
+ * Enable background blur on the local video stream.
46
+ */
47
+ @ReactMethod
48
+ fun enableBlur(config: ReadableMap, promise: Promise) {
49
+ try {
50
+ val trackId = readConfigString(config, "trackId")
51
+ Log.d(TAG, "enableBlur called, trackId=$trackId")
52
+
53
+ if (trackId.isEmpty()) {
54
+ Log.e(TAG, "enableBlur rejected: trackId is empty — check that a video track is passed from JS")
55
+ promise.reject("INVALID_ARGS", "trackId is required")
56
+ return
57
+ }
58
+
59
+ val webRTCModule = reactApplicationContext
60
+ .getNativeModule(WebRTCModule::class.java)
61
+ if (webRTCModule == null) {
62
+ Log.e(TAG, "enableBlur rejected: WebRTCModule not found in ReactContext")
63
+ promise.reject("NO_WEBRTC", "WebRTCModule not found")
64
+ return
65
+ }
66
+
67
+ val source = findVideoSource(webRTCModule, trackId)
68
+ if (source == null) {
69
+ Log.e(TAG, "enableBlur rejected: VideoSource not found for trackId=$trackId")
70
+ promise.reject("NO_SOURCE", "VideoSource not found. Cannot attach blur processor.")
71
+ return
72
+ }
73
+
74
+ Log.d(TAG, "VideoSource found: $source")
75
+
76
+ if (processor != null) {
77
+ Log.d(TAG, "Replacing existing blur processor")
78
+ processor?.disable()
79
+ }
80
+
81
+ val strong = try { config.getBoolean("strong") } catch (_: Exception) { false }
82
+ val proc = BlurVideoProcessor(
83
+ reactApplicationContext,
84
+ blurRadius = if (strong) 25f else 20f,
85
+ blurPasses = if (strong) 3 else 1,
86
+ )
87
+ proc.enable()
88
+ try {
89
+ source.setVideoProcessor(proc)
90
+ } catch (e: Exception) {
91
+ Log.e(TAG, "setVideoProcessor failed — VideoSource may be disposed", e)
92
+ proc.disable()
93
+ promise.reject("SOURCE_ERROR", "Failed to attach processor to VideoSource: ${e.message}", e)
94
+ return
95
+ }
96
+
97
+ processor = proc
98
+ videoSource = source
99
+
100
+ Log.d(TAG, "Background blur enabled successfully")
101
+ promise.resolve(true)
102
+
103
+ } catch (e: Exception) {
104
+ Log.e(TAG, "Error enabling blur", e)
105
+ promise.reject("BLUR_ERROR", "Failed to enable blur: ${e.message}", e)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Disable background blur and restore normal video processing.
111
+ */
112
+ @ReactMethod
113
+ fun disableBlur(promise: Promise) {
114
+ try {
115
+ Log.d(TAG, "disableBlur called")
116
+
117
+ videoSource?.setVideoProcessor(null)
118
+ processor?.disable()
119
+ processor = null
120
+ imageProcessor?.disable()
121
+ imageProcessor = null
122
+ videoSource = null
123
+
124
+ Log.d(TAG, "Video effect disabled successfully")
125
+ promise.resolve(true)
126
+ } catch (e: Exception) {
127
+ Log.e(TAG, "Error disabling video effect", e)
128
+ promise.reject("BLUR_ERROR", "Failed to disable effect: ${e.message}", e)
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Enable background image replacement on the local video stream.
134
+ * Downloads the image from imageUrl synchronously on the native modules thread.
135
+ */
136
+ @ReactMethod
137
+ fun enableBackgroundImage(config: ReadableMap, promise: Promise) {
138
+ try {
139
+ val trackId = readConfigString(config, "trackId")
140
+ val imageUrl = readConfigString(config, "imageUrl")
141
+ Log.d(TAG, "enableBackgroundImage called, trackId=$trackId")
142
+
143
+ if (trackId.isEmpty()) {
144
+ promise.reject("INVALID_ARGS", "trackId is required")
145
+ return
146
+ }
147
+ if (imageUrl.isEmpty()) {
148
+ promise.reject("INVALID_ARGS", "imageUrl is required")
149
+ return
150
+ }
151
+
152
+ val webRTCModule = reactApplicationContext.getNativeModule(WebRTCModule::class.java)
153
+ if (webRTCModule == null) {
154
+ promise.reject("NO_WEBRTC", "WebRTCModule not found")
155
+ return
156
+ }
157
+
158
+ val source = findVideoSource(webRTCModule, trackId)
159
+ if (source == null) {
160
+ promise.reject("NO_SOURCE", "VideoSource not found for trackId=$trackId")
161
+ return
162
+ }
163
+
164
+ val bitmap = downloadBitmap(imageUrl)
165
+ if (bitmap == null) {
166
+ promise.reject("DOWNLOAD_ERROR", "Failed to download image from $imageUrl")
167
+ return
168
+ }
169
+
170
+ // Disable any active effect first
171
+ videoSource?.setVideoProcessor(null)
172
+ processor?.disable()
173
+ processor = null
174
+ imageProcessor?.disable()
175
+ imageProcessor = null
176
+
177
+ val proc = BackgroundImageProcessor(bitmap)
178
+ proc.enable()
179
+ try {
180
+ source.setVideoProcessor(proc)
181
+ } catch (e: Exception) {
182
+ proc.disable()
183
+ promise.reject("SOURCE_ERROR", "Failed to attach processor: ${e.message}", e)
184
+ return
185
+ }
186
+
187
+ imageProcessor = proc
188
+ videoSource = source
189
+
190
+ Log.d(TAG, "Background image enabled successfully")
191
+ promise.resolve(true)
192
+
193
+ } catch (e: Exception) {
194
+ Log.e(TAG, "Error enabling background image", e)
195
+ promise.reject("IMAGE_BG_ERROR", "Failed to enable background image: ${e.message}", e)
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Check if any video effect (blur or background image) is currently active.
201
+ */
202
+ @ReactMethod
203
+ fun isBlurEnabled(promise: Promise) {
204
+ promise.resolve(processor != null || imageProcessor != null)
205
+ }
206
+
207
+ /**
208
+ * Download a Bitmap from a URL synchronously (safe on the native modules background thread).
209
+ */
210
+ private fun downloadBitmap(url: String): android.graphics.Bitmap? {
211
+ return try {
212
+ Log.d(TAG, "Downloading background image: $url")
213
+ val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
214
+ connection.connectTimeout = 10_000
215
+ connection.readTimeout = 15_000
216
+ connection.doInput = true
217
+ connection.connect()
218
+ val bitmap = android.graphics.BitmapFactory.decodeStream(connection.inputStream)
219
+ connection.disconnect()
220
+ Log.d(TAG, "Downloaded: ${bitmap?.width}x${bitmap?.height}")
221
+ bitmap
222
+ } catch (e: Exception) {
223
+ Log.e(TAG, "Failed to download image from $url", e)
224
+ null
225
+ }
226
+ }
227
+
228
+ // =====================================================================
229
+ // Reflection helpers to find VideoSource in WebRTCModule
230
+ // =====================================================================
231
+
232
+ private fun readConfigString(config: ReadableMap, key: String): String {
233
+ return try {
234
+ config.getString(key) ?: ""
235
+ } catch (_: Exception) {
236
+ try {
237
+ config.getDouble(key).toInt().toString()
238
+ } catch (_: Exception) {
239
+ ""
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Find the VideoSource associated with the local camera track.
246
+ *
247
+ * Strategy:
248
+ * 1. WebRTCModule → getUserMediaImpl → tracks map → TrackPrivate.mediaSource
249
+ * 2. Deep reflection search for any VideoSource instance
250
+ */
251
+ private fun findVideoSource(module: WebRTCModule, trackId: String): VideoSource? {
252
+ Log.d(TAG, "Searching for VideoSource, trackId=$trackId")
253
+
254
+ val gumiFieldNames = listOf("getUserMediaImpl", "mGetUserMediaImpl", "gumi", "getUserMedia")
255
+ for (gumiName in gumiFieldNames) {
256
+ try {
257
+ val gumiField = module.javaClass.getDeclaredField(gumiName)
258
+ gumiField.isAccessible = true
259
+ val gumi = gumiField.get(module) ?: continue
260
+ Log.d(TAG, "Found getUserMediaImpl via field '$gumiName': ${gumi.javaClass.name}")
261
+
262
+ val source = findVideoSourceInGetUserMediaImpl(gumi, trackId)
263
+ if (source != null) return source
264
+ } catch (_: NoSuchFieldException) {
265
+ } catch (e: Exception) {
266
+ Log.d(TAG, "Error accessing $gumiName: ${e.message}")
267
+ }
268
+ }
269
+
270
+ Log.d(TAG, "Falling back to deep reflection search from WebRTCModule")
271
+ val source = deepFindVideoSource(module, 4)
272
+ if (source != null) {
273
+ Log.d(TAG, "Found VideoSource via deep search")
274
+ return source
275
+ }
276
+
277
+ Log.e(TAG, "Could not find VideoSource through any strategy")
278
+ logFieldNames(module, "WebRTCModule")
279
+ return null
280
+ }
281
+
282
+ private fun findVideoSourceInGetUserMediaImpl(gumi: Any, trackId: String): VideoSource? {
283
+ val captFieldNames = listOf("tracks", "mVideoCapturers", "videoCapturers", "capturers", "mCapturers")
284
+ for (captName in captFieldNames) {
285
+ try {
286
+ val captField = gumi.javaClass.getDeclaredField(captName)
287
+ captField.isAccessible = true
288
+ val capturers = captField.get(gumi)
289
+
290
+ if (capturers is Map<*, *>) {
291
+ Log.d(TAG, "Found capturers map '$captName' with ${capturers.size} entries, keys: ${capturers.keys}")
292
+
293
+ val exactMatch = capturers[trackId]
294
+ if (exactMatch == null) {
295
+ Log.w(TAG, "trackId='$trackId' not found in '$captName' map, falling back to first entry")
296
+ }
297
+ val controller = exactMatch ?: capturers.values.firstOrNull()
298
+ if (controller != null) {
299
+ val source = findVideoSourceInController(controller)
300
+ if (source != null) {
301
+ Log.d(TAG, "Found VideoSource in controller from '$captName'")
302
+ return source
303
+ }
304
+ }
305
+ }
306
+ } catch (_: NoSuchFieldException) {
307
+ } catch (e: Exception) {
308
+ Log.d(TAG, "Error accessing $captName: ${e.message}")
309
+ }
310
+ }
311
+
312
+ return deepFindVideoSource(gumi, 3)
313
+ }
314
+
315
+ private fun findVideoSourceInController(controller: Any): VideoSource? {
316
+ val srcFieldNames = listOf("mediaSource", "videoSource", "mVideoSource", "source", "mSource")
317
+ for (srcName in srcFieldNames) {
318
+ try {
319
+ val srcField = controller.javaClass.getDeclaredField(srcName)
320
+ srcField.isAccessible = true
321
+ val src = srcField.get(controller)
322
+ if (src is VideoSource) {
323
+ return src
324
+ }
325
+ } catch (_: NoSuchFieldException) {
326
+ } catch (e: Exception) {
327
+ Log.d(TAG, "Error accessing $srcName in controller: ${e.message}")
328
+ }
329
+ }
330
+
331
+ return findFieldOfType(controller, VideoSource::class.java)
332
+ }
333
+
334
+ private fun <T> findFieldOfType(obj: Any, type: Class<T>): T? {
335
+ var clazz: Class<*>? = obj.javaClass
336
+ while (clazz != null && clazz != Any::class.java) {
337
+ for (field in clazz.declaredFields) {
338
+ try {
339
+ field.isAccessible = true
340
+ val value = field.get(obj)
341
+ if (type.isInstance(value)) {
342
+ @Suppress("UNCHECKED_CAST")
343
+ return value as T
344
+ }
345
+ } catch (_: Exception) {}
346
+ }
347
+ clazz = clazz.superclass
348
+ }
349
+ return null
350
+ }
351
+
352
+ private fun deepFindVideoSource(root: Any, maxDepth: Int, visited: MutableSet<Int> = mutableSetOf()): VideoSource? {
353
+ if (maxDepth <= 0) return null
354
+ val id = System.identityHashCode(root)
355
+ if (!visited.add(id)) return null
356
+
357
+ var clazz: Class<*>? = root.javaClass
358
+ while (clazz != null && clazz != Any::class.java) {
359
+ for (field in clazz.declaredFields) {
360
+ try {
361
+ field.isAccessible = true
362
+ val value = field.get(root) ?: continue
363
+
364
+ if (value is VideoSource) {
365
+ Log.d(TAG, "deepFind: found VideoSource in ${root.javaClass.simpleName}.${field.name}")
366
+ return value
367
+ }
368
+
369
+ if (value is Map<*, *>) {
370
+ for (entry in value.values) {
371
+ if (entry == null) continue
372
+ if (entry is VideoSource) return entry
373
+ val found = deepFindVideoSource(entry, maxDepth - 1, visited)
374
+ if (found != null) return found
375
+ }
376
+ }
377
+
378
+ if (value is Collection<*>) {
379
+ for (item in value) {
380
+ if (item == null) continue
381
+ if (item is VideoSource) return item
382
+ val found = deepFindVideoSource(item, maxDepth - 1, visited)
383
+ if (found != null) return found
384
+ }
385
+ }
386
+
387
+ val pkg = value.javaClass.name
388
+ if (pkg.startsWith("com.oney") || pkg.startsWith("org.webrtc")) {
389
+ val found = deepFindVideoSource(value, maxDepth - 1, visited)
390
+ if (found != null) return found
391
+ }
392
+ } catch (_: Exception) {}
393
+ }
394
+ clazz = clazz.superclass
395
+ }
396
+ return null
397
+ }
398
+
399
+ private fun logFieldNames(obj: Any, label: String) {
400
+ var clazz: Class<*>? = obj.javaClass
401
+ val fields = mutableListOf<String>()
402
+ while (clazz != null && clazz != Any::class.java) {
403
+ for (field in clazz.declaredFields) {
404
+ fields.add("${field.name}: ${field.type.simpleName}")
405
+ }
406
+ clazz = clazz.superclass
407
+ }
408
+ Log.d(TAG, "$label fields: $fields")
409
+ }
410
+
411
+ override fun onCatalystInstanceDestroy() {
412
+ super.onCatalystInstanceDestroy()
413
+ videoSource?.setVideoProcessor(null)
414
+ processor?.disable()
415
+ processor = null
416
+ imageProcessor?.disable()
417
+ imageProcessor = null
418
+ videoSource = null
419
+ }
420
+ }
@@ -0,0 +1,16 @@
1
+ package com.reactnativeapirtc
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class BackgroundBlurPackage : ReactPackage {
9
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
10
+ return listOf(BackgroundBlurModule(reactContext))
11
+ }
12
+
13
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
14
+ return emptyList()
15
+ }
16
+ }