diff --git a/android/build.gradle b/android/build.gradle
index cb7698b37..7ff9b3b7e 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -31,7 +31,7 @@ rootProject.allprojects {
url = 'https://central.sonatype.com/repository/maven-snapshots/'
content {
// This efficiently tells Gradle to only look for this specific dependency here
- includeModule('io.getstream', 'stream-webrtc-android')
+ includeModule('io.getstream', 'stream-video-webrtc-android')
}
}
}
@@ -90,7 +90,7 @@ def kotlin_version = getExtOrDefault('kotlinVersion', '1.8.10')
println "Building Stream WebRTC React Native module with Kotlin version: $kotlin_version"
dependencies {
- api 'io.getstream:stream-video-webrtc-android:145.9.0'
+ api 'io.getstream:stream-video-webrtc-android:145.17.0'
implementation "com.facebook.react:react-android:+"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "androidx.core:core:1.7.0"
diff --git a/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java b/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java
new file mode 100644
index 000000000..30e692cd7
--- /dev/null
+++ b/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java
@@ -0,0 +1,462 @@
+package com.oney.WebRTCModule;
+
+import android.util.Base64;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.facebook.react.bridge.Arguments;
+import com.facebook.react.bridge.ReadableMap;
+import com.facebook.react.bridge.ReadableType;
+import com.facebook.react.bridge.WritableArray;
+import com.facebook.react.bridge.WritableMap;
+
+import org.webrtc.EncryptionManager;
+import org.webrtc.RtpReceiver;
+import org.webrtc.RtpSender;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Bridge over the native {@link EncryptionManager} (framed AES-GCM E2EE). Only control operations
+ * and events cross the bridge, never media frames: the transforms run entirely inside libwebrtc.
+ *
+ *
The native binding reports failures by throwing unchecked exceptions while iOS returns an
+ * error object. Both are normalized here into a {@code {error}} result map so the JS surface is
+ * identical on both platforms.
+ */
+class EncryptionManagerBridge {
+ static final String TAG = EncryptionManagerBridge.class.getCanonicalName();
+
+ private final WebRTCModule webRTCModule;
+ private final Map managers = new HashMap<>();
+
+ EncryptionManagerBridge(WebRTCModule webRTCModule) {
+ this.webRTCModule = webRTCModule;
+ }
+
+ boolean isSupported() {
+ return EncryptionManager.isSupported();
+ }
+
+ WritableMap create(ReadableMap options) {
+ String userId = getString(options, "userId");
+ if (userId == null || userId.isEmpty()) {
+ return error("encryptionManagerCreate() requires a non-empty userId");
+ }
+
+ try {
+ Integer algorithm = getInt(options, "algorithm");
+ EncryptionManager manager = algorithm != null
+ ? EncryptionManager.create(userId, EncryptionManager.Algorithm.values()[algorithm])
+ : EncryptionManager.create(userId);
+
+ String handle = UUID.randomUUID().toString();
+ // The observer is wired before the handle is returned so no event can be missed.
+ manager.setObserver(new EventObserver(handle));
+ managers.put(handle, manager);
+
+ WritableMap result = Arguments.createMap();
+ result.putString("handle", handle);
+ return result;
+ } catch (RuntimeException e) {
+ return error(e);
+ }
+ }
+
+ WritableMap dispose(ReadableMap options) {
+ String handle = getString(options, "handle");
+ EncryptionManager manager = handle != null ? managers.get(handle) : null;
+ if (manager == null) {
+ // Disposing twice, or after a reload, is not an error.
+ return Arguments.createMap();
+ }
+
+ try {
+ manager.setObserver(null);
+ manager.dispose();
+ } catch (RuntimeException e) {
+ // Deregistered only once the native cleanup succeeds, so a failed manager stays
+ // reachable for a later dispose() or for disposeAll() on teardown.
+ return error(e);
+ }
+
+ managers.remove(handle);
+ return Arguments.createMap();
+ }
+
+ /**
+ * Disposes every live manager. Called on module teardown (e.g. a bundle reload), where the peer
+ * connections are about to be closed underneath any attached transform and nothing in JS can
+ * survive to dispose them itself.
+ */
+ void disposeAll() {
+ for (Map.Entry entry : managers.entrySet()) {
+ try {
+ entry.getValue().setObserver(null);
+ entry.getValue().dispose();
+ } catch (RuntimeException e) {
+ Log.w(TAG, "disposeAll(): error disposing manager " + entry.getKey(), e);
+ }
+ }
+ managers.clear();
+ }
+
+ WritableMap setKey(ReadableMap options) {
+ EncryptionManager manager = getManager(options);
+ if (manager == null) {
+ return error("encryptionManagerSetKey(): manager not found");
+ }
+
+ String userId = getString(options, "userId");
+ Integer keyIndex = getKeyIndex(options);
+ byte[] key = getKey(options);
+ if (userId == null || userId.isEmpty() || keyIndex == null || key == null) {
+ return error("encryptionManagerSetKey() requires userId, a keyIndex in 0-255 and key");
+ }
+
+ try {
+ manager.setKey(userId, keyIndex, key);
+ return Arguments.createMap();
+ } catch (RuntimeException e) {
+ return error(e);
+ }
+ }
+
+ WritableMap setSharedKey(ReadableMap options) {
+ EncryptionManager manager = getManager(options);
+ if (manager == null) {
+ return error("encryptionManagerSetSharedKey(): manager not found");
+ }
+
+ Integer keyIndex = getKeyIndex(options);
+ byte[] key = getKey(options);
+ if (keyIndex == null || key == null) {
+ return error("encryptionManagerSetSharedKey() requires a keyIndex in 0-255 and key");
+ }
+
+ try {
+ manager.setSharedKey(keyIndex, key);
+ return Arguments.createMap();
+ } catch (RuntimeException e) {
+ return error(e);
+ }
+ }
+
+ WritableMap removeKey(ReadableMap options) {
+ EncryptionManager manager = getManager(options);
+ if (manager == null) {
+ return error("encryptionManagerRemoveKey(): manager not found");
+ }
+
+ String userId = getString(options, "userId");
+ Integer keyIndex = getKeyIndex(options);
+ if (userId == null || userId.isEmpty() || keyIndex == null) {
+ return error("encryptionManagerRemoveKey() requires userId and a keyIndex in 0-255");
+ }
+
+ try {
+ manager.removeKey(userId, keyIndex);
+ return Arguments.createMap();
+ } catch (RuntimeException e) {
+ return error(e);
+ }
+ }
+
+ WritableMap removeAllKeys(ReadableMap options) {
+ EncryptionManager manager = getManager(options);
+ if (manager == null) {
+ return error("encryptionManagerRemoveAllKeys(): manager not found");
+ }
+
+ String userId = getString(options, "userId");
+ if (userId == null || userId.isEmpty()) {
+ return error("encryptionManagerRemoveAllKeys() requires userId");
+ }
+
+ try {
+ manager.removeAllKeys(userId);
+ return Arguments.createMap();
+ } catch (RuntimeException e) {
+ return error(e);
+ }
+ }
+
+ WritableMap removeSharedKey(ReadableMap options) {
+ EncryptionManager manager = getManager(options);
+ if (manager == null) {
+ return error("encryptionManagerRemoveSharedKey(): manager not found");
+ }
+
+ Integer keyIndex = getKeyIndex(options);
+ if (keyIndex == null) {
+ return error("encryptionManagerRemoveSharedKey() requires a keyIndex in 0-255");
+ }
+
+ try {
+ manager.removeSharedKey(keyIndex);
+ return Arguments.createMap();
+ } catch (RuntimeException e) {
+ return error(e);
+ }
+ }
+
+ WritableMap encrypt(ReadableMap options) {
+ EncryptionManager manager = getManager(options);
+ if (manager == null) {
+ return error("encryptionManagerEncrypt(): manager not found");
+ }
+
+ Integer peerConnectionId = getInt(options, "peerConnectionId");
+ String senderId = getString(options, "senderId");
+ if (peerConnectionId == null || senderId == null) {
+ return error("encryptionManagerEncrypt() requires peerConnectionId and senderId");
+ }
+
+ PeerConnectionObserver pco = webRTCModule.getPeerConnectionObserver(peerConnectionId);
+ if (pco == null) {
+ return error("encryptionManagerEncrypt(): peer connection " + peerConnectionId + " not found");
+ }
+
+ RtpSender sender = pco.getSender(senderId);
+ if (sender == null) {
+ return error("encryptionManagerEncrypt(): sender " + senderId + " not found");
+ }
+
+ try {
+ manager.encrypt(sender, getString(options, "codec"), parseTrackType(options));
+ return Arguments.createMap();
+ } catch (RuntimeException e) {
+ return error(e);
+ }
+ }
+
+ WritableMap decrypt(ReadableMap options) {
+ EncryptionManager manager = getManager(options);
+ if (manager == null) {
+ return error("encryptionManagerDecrypt(): manager not found");
+ }
+
+ Integer peerConnectionId = getInt(options, "peerConnectionId");
+ String receiverId = getString(options, "receiverId");
+ String userId = getString(options, "userId");
+ if (peerConnectionId == null || receiverId == null || userId == null || userId.isEmpty()) {
+ return error("encryptionManagerDecrypt() requires peerConnectionId, receiverId and userId");
+ }
+
+ PeerConnectionObserver pco = webRTCModule.getPeerConnectionObserver(peerConnectionId);
+ if (pco == null) {
+ return error("encryptionManagerDecrypt(): peer connection " + peerConnectionId + " not found");
+ }
+
+ RtpReceiver receiver = pco.getReceiver(receiverId);
+ if (receiver == null) {
+ return error("encryptionManagerDecrypt(): receiver " + receiverId + " not found");
+ }
+
+ try {
+ manager.decrypt(receiver, userId, parseTrackType(options));
+ return Arguments.createMap();
+ } catch (RuntimeException e) {
+ return error(e);
+ }
+ }
+
+ void enablePerformanceReporting(String handle, boolean enabled) {
+ EncryptionManager manager = managers.get(handle);
+ if (manager == null) {
+ throw new IllegalStateException("manager not found");
+ }
+
+ manager.enablePerformanceReporting(enabled);
+ }
+
+ void requestKeyState(String handle) {
+ EncryptionManager manager = managers.get(handle);
+ if (manager == null) {
+ throw new IllegalStateException("manager not found");
+ }
+
+ manager.requestKeyState();
+ }
+
+ private class EventObserver implements EncryptionManager.Observer {
+ private final String handle;
+
+ EventObserver(String handle) {
+ this.handle = handle;
+ }
+
+ @Override
+ public void onE2eeEvent(EncryptionManager.E2eeEvent event) {
+ WritableMap params = Arguments.createMap();
+ params.putString("managerId", handle);
+ params.putString("type", event.type.name);
+ params.putString("userId", event.userId);
+
+ if (event.trackType != null) {
+ params.putInt("trackType", event.trackType.getValue());
+ }
+ if (event.keyIndex != null) {
+ params.putInt("keyIndex", event.keyIndex);
+ }
+ if (event.version != null) {
+ params.putInt("version", event.version);
+ }
+ if (event.reason != null) {
+ params.putString("reason", event.reason);
+ }
+ if (event.keyState != null) {
+ params.putMap("keyState", keyStateToMap(event.keyState));
+ }
+ if (event.encode != null) {
+ params.putArray("encode", trackPerfToArray(event.encode));
+ }
+ if (event.decode != null) {
+ params.putArray("decode", trackPerfToArray(event.decode));
+ }
+
+ // This callback runs on the crypto worker; events must be emitted off it.
+ ThreadUtils.runOnExecutor(() -> webRTCModule.sendEvent("encryptionManagerEvent", params));
+ }
+ }
+
+ /** Key fingerprints only; raw key material never crosses the bridge. */
+ private static WritableMap keyStateToMap(EncryptionManager.KeyStateReport keyState) {
+ WritableArray perUserKeys = Arguments.createArray();
+ for (EncryptionManager.UserKey key : keyState.perUserKeys) {
+ WritableMap row = Arguments.createMap();
+ row.putString("userId", key.userId);
+ row.putInt("keyIndex", key.keyIndex);
+ row.putString("fingerprint", key.fingerprint);
+ perUserKeys.pushMap(row);
+ }
+
+ WritableArray sharedKeys = Arguments.createArray();
+ for (EncryptionManager.SharedKey key : keyState.sharedKeys) {
+ WritableMap row = Arguments.createMap();
+ row.putInt("keyIndex", key.keyIndex);
+ row.putString("fingerprint", key.fingerprint);
+ row.putBoolean("isActive", key.isActive);
+ sharedKeys.pushMap(row);
+ }
+
+ WritableMap map = Arguments.createMap();
+ map.putArray("perUserKeys", perUserKeys);
+ map.putArray("sharedKeys", sharedKeys);
+ return map;
+ }
+
+ private static WritableArray trackPerfToArray(java.util.List samples) {
+ WritableArray rows = Arguments.createArray();
+ for (EncryptionManager.TrackPerf sample : samples) {
+ WritableMap row = Arguments.createMap();
+ row.putString("userId", sample.userId);
+ row.putInt("trackType", sample.trackType.getValue());
+ if (sample.codec != null) {
+ row.putString("codec", sample.codec);
+ }
+ row.putDouble("fps", sample.fps);
+ row.putDouble("maxCryptoMs", sample.maxCryptoMs);
+ rows.pushMap(row);
+ }
+
+ return rows;
+ }
+
+ @Nullable
+ private EncryptionManager getManager(ReadableMap options) {
+ String handle = getString(options, "handle");
+ return handle != null ? managers.get(handle) : null;
+ }
+
+ @Nullable
+ private static byte[] getKey(ReadableMap options) {
+ String key = getString(options, "key");
+ if (key == null) {
+ return null;
+ }
+
+ try {
+ return Base64.decode(key, Base64.NO_WRAP);
+ } catch (IllegalArgumentException e) {
+ Log.w(TAG, "Failed to decode the key: " + e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Absent means "read audio vs video from the sender/receiver"; screenshare must be explicit. A
+ * value that is present but unusable is rejected rather than inferred: silently falling back
+ * would group a screen-share-audio track with the microphone and share its replay window.
+ */
+ @Nullable
+ private static EncryptionManager.TrackType parseTrackType(ReadableMap options) {
+ if (!options.hasKey("trackType") || options.isNull("trackType")) {
+ return null;
+ }
+
+ // Validated before it is narrowed, like the key index: getInt truncates, so a fractional value
+ // would land on a valid enum entry instead of being rejected -- 1.5 would become VIDEO.
+ // NaN fails the first comparison; the range check covers both infinities.
+ double trackType =
+ options.getType("trackType") == ReadableType.Number ? options.getDouble("trackType") : Double.NaN;
+ if (trackType != Math.floor(trackType) || trackType < 0
+ || trackType >= EncryptionManager.TrackType.values().length) {
+ throw new IllegalArgumentException("got an unusable trackType");
+ }
+
+ return EncryptionManager.TrackType.values()[(int) trackType];
+ }
+
+ @Nullable
+ private static String getString(ReadableMap options, String key) {
+ if (!options.hasKey(key) || options.getType(key) != ReadableType.String) {
+ return null;
+ }
+
+ return options.getString(key);
+ }
+
+ /**
+ * A key index is validated before it is narrowed: {@link ReadableMap#getInt} truncates, so a
+ * fractional or non-finite index would otherwise land silently on a valid slot instead of being
+ * rejected -- {@code removeSharedKey(-0.5)} would delete slot 0.
+ */
+ @Nullable
+ private static Integer getKeyIndex(ReadableMap options) {
+ if (!options.hasKey("keyIndex") || options.getType("keyIndex") != ReadableType.Number) {
+ return null;
+ }
+
+ // NaN fails the first comparison; the range check covers both infinities.
+ double keyIndex = options.getDouble("keyIndex");
+ if (keyIndex != Math.floor(keyIndex) || keyIndex < 0 || keyIndex > 255) {
+ return null;
+ }
+
+ return (int) keyIndex;
+ }
+
+ @Nullable
+ private static Integer getInt(ReadableMap options, String key) {
+ if (!options.hasKey(key) || options.getType(key) != ReadableType.Number) {
+ return null;
+ }
+
+ return options.getInt(key);
+ }
+
+ private static WritableMap error(RuntimeException e) {
+ String message = e.getMessage();
+ return error(message != null ? message : e.getClass().getSimpleName());
+ }
+
+ private static WritableMap error(String message) {
+ WritableMap result = Arguments.createMap();
+ result.putString("error", message);
+ return result;
+ }
+}
diff --git a/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java b/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java
index 29156265b..3a9c9d4e2 100644
--- a/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java
+++ b/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java
@@ -159,6 +159,20 @@ RtpSender getSender(String id) {
return null;
}
+ RtpReceiver getReceiver(String id) {
+ if (this.peerConnection == null) {
+ return null;
+ }
+
+ for (RtpReceiver receiver : this.peerConnection.getReceivers()) {
+ if (receiver.id().equals(id)) {
+ return receiver;
+ }
+ }
+
+ return null;
+ }
+
RtpTransceiver getTransceiver(String id) {
if (this.peerConnection == null) {
return null;
diff --git a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java
index 24092baae..a0b5baf10 100644
--- a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java
+++ b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java
@@ -65,6 +65,8 @@ public class WebRTCModule extends ReactContextBaseJavaModule {
private final GetUserMediaImpl getUserMediaImpl;
+ private final EncryptionManagerBridge encryptionManagerBridge = new EncryptionManagerBridge(this);
+
@Nullable
private RTCCameraPreviewView activeCameraPreview;
@@ -265,6 +267,10 @@ public void invalidate() {
try {
ThreadUtils
.submitToExecutor(() -> {
+ // 0. Dispose E2EE managers: their frame transforms are held by the senders and
+ // receivers of the PeerConnections disposed next.
+ encryptionManagerBridge.disposeAll();
+
// 1. Dispose PeerConnections (dispose() calls close() internally)
for (int i = 0; i < mPeerConnectionObservers.size(); i++) {
try {
@@ -1773,6 +1779,97 @@ public void dataChannelSend(int peerConnectionId, String reactTag, String data,
});
}
+ /**
+ * The E2EE key and attach operations below are blocking-synchronous on purpose. An async attach
+ * would leave a window where a sender exists before its transform is installed, which is a
+ * plaintext window. Only the observational calls use promises.
+ */
+ private WritableMap submitEncryptionCall(Callable callable) {
+ try {
+ return ThreadUtils.submitToExecutor(callable).get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+
+ @ReactMethod(isBlockingSynchronousMethod = true)
+ public boolean encryptionManagerIsSupported() {
+ return encryptionManagerBridge.isSupported();
+ }
+
+ @ReactMethod(isBlockingSynchronousMethod = true)
+ public WritableMap encryptionManagerCreate(ReadableMap options) {
+ return submitEncryptionCall(() -> encryptionManagerBridge.create(options));
+ }
+
+ @ReactMethod(isBlockingSynchronousMethod = true)
+ public WritableMap encryptionManagerDispose(ReadableMap options) {
+ return submitEncryptionCall(() -> encryptionManagerBridge.dispose(options));
+ }
+
+ @ReactMethod(isBlockingSynchronousMethod = true)
+ public WritableMap encryptionManagerSetKey(ReadableMap options) {
+ return submitEncryptionCall(() -> encryptionManagerBridge.setKey(options));
+ }
+
+ @ReactMethod(isBlockingSynchronousMethod = true)
+ public WritableMap encryptionManagerSetSharedKey(ReadableMap options) {
+ return submitEncryptionCall(() -> encryptionManagerBridge.setSharedKey(options));
+ }
+
+ @ReactMethod(isBlockingSynchronousMethod = true)
+ public WritableMap encryptionManagerRemoveKey(ReadableMap options) {
+ return submitEncryptionCall(() -> encryptionManagerBridge.removeKey(options));
+ }
+
+ @ReactMethod(isBlockingSynchronousMethod = true)
+ public WritableMap encryptionManagerRemoveAllKeys(ReadableMap options) {
+ return submitEncryptionCall(() -> encryptionManagerBridge.removeAllKeys(options));
+ }
+
+ @ReactMethod(isBlockingSynchronousMethod = true)
+ public WritableMap encryptionManagerRemoveSharedKey(ReadableMap options) {
+ return submitEncryptionCall(() -> encryptionManagerBridge.removeSharedKey(options));
+ }
+
+ @ReactMethod(isBlockingSynchronousMethod = true)
+ public WritableMap encryptionManagerEncrypt(ReadableMap options) {
+ return submitEncryptionCall(() -> encryptionManagerBridge.encrypt(options));
+ }
+
+ @ReactMethod(isBlockingSynchronousMethod = true)
+ public WritableMap encryptionManagerDecrypt(ReadableMap options) {
+ return submitEncryptionCall(() -> encryptionManagerBridge.decrypt(options));
+ }
+
+ @ReactMethod
+ public void encryptionManagerEnablePerformanceReporting(String handle, boolean enabled, Promise promise) {
+ ThreadUtils.runOnExecutor(() -> {
+ try {
+ encryptionManagerBridge.enablePerformanceReporting(handle, enabled);
+ promise.resolve(null);
+ } catch (RuntimeException e) {
+ promise.reject("encryptionManagerEnablePerformanceReportingFailed", e.getMessage(), e);
+ }
+ });
+ }
+
+ @ReactMethod
+ public void encryptionManagerRequestKeyState(String handle, Promise promise) {
+ ThreadUtils.runOnExecutor(() -> {
+ try {
+ encryptionManagerBridge.requestKeyState(handle);
+ promise.resolve(null);
+ } catch (RuntimeException e) {
+ promise.reject("encryptionManagerRequestKeyStateFailed", e.getMessage(), e);
+ }
+ });
+ }
+
@ReactMethod
public void addListener(String eventName) {
// Keep: Required for RN built in Event Emitter Calls.
diff --git a/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj b/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj
index b1dfb3660..0085287d7 100644
--- a/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj
+++ b/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj
@@ -192,17 +192,13 @@
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-GumTestApp/Pods-GumTestApp-frameworks.sh",
- "${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
- "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/StreamWebRTC/WebRTC.framework/WebRTC",
- "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermesvm.framework/hermesvm",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
- "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
- "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WebRTC.framework",
- "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermesvm.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
diff --git a/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m b/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m
new file mode 100644
index 000000000..c461d4c59
--- /dev/null
+++ b/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m
@@ -0,0 +1,516 @@
+#import
+
+#import
+#import
+
+#import "WebRTCModule+RTCPeerConnection.h"
+#import "WebRTCModule.h"
+
+/*
+ * Bridge over the native `RTCEncryptionManager` (framed AES-GCM E2EE). Only control operations and
+ * events cross the bridge, never media frames: the transforms run entirely inside libwebrtc.
+ *
+ * The attach path (`encrypt`/`decrypt`) and every key operation are blocking-synchronous. An async
+ * attach would leave a window where a sender exists before its transform is installed, which is a
+ * plaintext window. Only the observational calls use promises.
+ */
+
+static char kEncryptionManagerHandleKey;
+
+@interface WebRTCModule (RTCEncryption)
+@end
+
+@implementation WebRTCModule (RTCEncryption)
+
+#pragma mark - Helpers
+
+/* Values omitted by JS arrive as nil or NSNull; both mean "let native decide". */
+static id RTCEncryptionOptionalValue(NSDictionary *options, NSString *key, Class expectedClass) {
+ id value = options[key];
+ if (value == nil || value == (id)kCFNull || ![value isKindOfClass:expectedClass]) {
+ return nil;
+ }
+
+ return value;
+}
+
+static NSDictionary *RTCEncryptionErrorResult(NSError *error, NSString *fallback) {
+ NSString *message = error.localizedDescription.length > 0 ? error.localizedDescription : fallback;
+ return @{@"error" : message};
+}
+
+- (nullable RTC_OBJC_TYPE(RTCEncryptionManager) *)encryptionManagerForOptions:(NSDictionary *)options {
+ NSString *handle = RTCEncryptionOptionalValue(options, @"handle", [NSString class]);
+ if (handle == nil) {
+ return nil;
+ }
+
+ return self.encryptionManagers[handle];
+}
+
+/*
+ * Absent means "read audio vs video from the sender/receiver"; screenshare must be explicit. A value
+ * that is present but unusable is rejected rather than inferred: silently falling back would group a
+ * screen-share-audio track with the microphone and share its replay window.
+ */
+static BOOL RTCEncryptionTrackTypeFromOptions(NSDictionary *options, NSNumber **trackType) {
+ id value = options[@"trackType"];
+ if (value == nil || value == (id)kCFNull) {
+ *trackType = nil;
+ return YES;
+ }
+
+ if (![value isKindOfClass:[NSNumber class]]) {
+ return NO;
+ }
+
+ /* Validated before it is narrowed, like the key index: `integerValue` truncates, so a fractional
+ * value would land on a valid enum entry instead of being rejected -- 1.5 would become video.
+ * NaN fails the first comparison; the range check covers both infinities. */
+ double type = [(NSNumber *)value doubleValue];
+ if (type != trunc(type) || type < RTCEncryptionTrackTypeAudio || type > RTCEncryptionTrackTypeScreenshareAudio) {
+ return NO;
+ }
+
+ *trackType = @((NSInteger)type);
+ return YES;
+}
+
+/*
+ * A key index is validated before it is narrowed: `intValue` truncates, so a fractional or non-finite
+ * index would otherwise land silently on a valid slot instead of being rejected -- a `keyIndex` of
+ * -0.5 would remove slot 0.
+ */
+static BOOL RTCEncryptionKeyIndexFromOptions(NSDictionary *options, int *keyIndex) {
+ NSNumber *value = RTCEncryptionOptionalValue(options, @"keyIndex", [NSNumber class]);
+ if (value == nil) {
+ return NO;
+ }
+
+ /* NaN fails the first comparison; the range check covers both infinities. */
+ double index = value.doubleValue;
+ if (index != trunc(index) || index < 0 || index > 255) {
+ return NO;
+ }
+
+ *keyIndex = (int)index;
+ return YES;
+}
+
+- (nullable NSData *)encryptionKeyFromOptions:(NSDictionary *)options {
+ NSString *key = RTCEncryptionOptionalValue(options, @"key", [NSString class]);
+ if (key == nil) {
+ return nil;
+ }
+
+ return [[NSData alloc] initWithBase64EncodedString:key options:0];
+}
+
+#pragma mark - Lifecycle
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerIsSupported) {
+ return @([RTC_OBJC_TYPE(RTCEncryptionManager) isSupported]);
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerCreate : (nonnull NSDictionary *)options) {
+ __block NSDictionary *result = nil;
+
+ dispatch_sync(self.workerQueue, ^{
+ NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]);
+ if (userId.length == 0) {
+ result = @{@"error" : @"encryptionManagerCreate() requires a non-empty userId"};
+ return;
+ }
+
+ NSNumber *algorithm = RTCEncryptionOptionalValue(options, @"algorithm", [NSNumber class]);
+ if (algorithm != nil && algorithm.integerValue != RTCEncryptionAlgorithmAes128Gcm &&
+ algorithm.integerValue != RTCEncryptionAlgorithmAes256Gcm) {
+ result = @{@"error" : @"encryptionManagerCreate() got an unknown algorithm"};
+ return;
+ }
+
+ NSError *error = nil;
+ RTC_OBJC_TYPE(RTCEncryptionManager) * manager;
+ if (algorithm != nil) {
+ manager = [RTC_OBJC_TYPE(RTCEncryptionManager) createWithUserId:userId
+ algorithm:algorithm.integerValue
+ error:&error];
+ } else {
+ manager = [RTC_OBJC_TYPE(RTCEncryptionManager) createWithUserId:userId error:&error];
+ }
+
+ if (manager == nil) {
+ result = RTCEncryptionErrorResult(error, @"Failed to create the encryption manager");
+ return;
+ }
+
+ NSString *handle = [[NSUUID UUID] UUIDString];
+ objc_setAssociatedObject(manager, &kEncryptionManagerHandleKey, handle, OBJC_ASSOCIATION_COPY);
+ /* The delegate is wired before the handle is returned so no event can be missed. */
+ manager.delegate = self;
+ self.encryptionManagers[handle] = manager;
+
+ result = @{@"handle" : handle};
+ });
+
+ return result;
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerDispose : (nonnull NSDictionary *)options) {
+ __block NSDictionary *result = @{};
+
+ dispatch_sync(self.workerQueue, ^{
+ NSString *handle = RTCEncryptionOptionalValue(options, @"handle", [NSString class]);
+ RTC_OBJC_TYPE(RTCEncryptionManager) *manager = handle != nil ? self.encryptionManagers[handle] : nil;
+ if (manager == nil) {
+ /* Disposing twice, or after a reload, is not an error. */
+ return;
+ }
+
+ manager.delegate = nil;
+ [manager dispose];
+ objc_setAssociatedObject(manager, &kEncryptionManagerHandleKey, nil, OBJC_ASSOCIATION_COPY);
+ [self.encryptionManagers removeObjectForKey:handle];
+ });
+
+ return result;
+}
+
+#pragma mark - Keys
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerSetKey : (nonnull NSDictionary *)options) {
+ __block NSDictionary *result = @{};
+
+ dispatch_sync(self.workerQueue, ^{
+ RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options];
+ if (manager == nil) {
+ result = @{@"error" : @"encryptionManagerSetKey(): manager not found"};
+ return;
+ }
+
+ NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]);
+ int keyIndex = 0;
+ BOOL hasKeyIndex = RTCEncryptionKeyIndexFromOptions(options, &keyIndex);
+ NSData *key = [self encryptionKeyFromOptions:options];
+ if (userId.length == 0 || !hasKeyIndex || key == nil) {
+ result = @{@"error" : @"encryptionManagerSetKey() requires userId, a keyIndex in 0-255 and key"};
+ return;
+ }
+
+ NSError *error = nil;
+ if (![manager setKey:userId keyIndex:keyIndex rawKey:key error:&error]) {
+ result = RTCEncryptionErrorResult(error, @"setKey failed");
+ }
+ });
+
+ return result;
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerSetSharedKey : (nonnull NSDictionary *)options) {
+ __block NSDictionary *result = @{};
+
+ dispatch_sync(self.workerQueue, ^{
+ RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options];
+ if (manager == nil) {
+ result = @{@"error" : @"encryptionManagerSetSharedKey(): manager not found"};
+ return;
+ }
+
+ int keyIndex = 0;
+ BOOL hasKeyIndex = RTCEncryptionKeyIndexFromOptions(options, &keyIndex);
+ NSData *key = [self encryptionKeyFromOptions:options];
+ if (!hasKeyIndex || key == nil) {
+ result = @{@"error" : @"encryptionManagerSetSharedKey() requires a keyIndex in 0-255 and key"};
+ return;
+ }
+
+ NSError *error = nil;
+ if (![manager setSharedKey:keyIndex rawKey:key error:&error]) {
+ result = RTCEncryptionErrorResult(error, @"setSharedKey failed");
+ }
+ });
+
+ return result;
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerRemoveKey : (nonnull NSDictionary *)options) {
+ __block NSDictionary *result = @{};
+
+ dispatch_sync(self.workerQueue, ^{
+ RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options];
+ if (manager == nil) {
+ result = @{@"error" : @"encryptionManagerRemoveKey(): manager not found"};
+ return;
+ }
+
+ NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]);
+ int keyIndex = 0;
+ BOOL hasKeyIndex = RTCEncryptionKeyIndexFromOptions(options, &keyIndex);
+ if (userId.length == 0 || !hasKeyIndex) {
+ result = @{@"error" : @"encryptionManagerRemoveKey() requires userId and a keyIndex in 0-255"};
+ return;
+ }
+
+ NSError *error = nil;
+ if (![manager removeKey:userId keyIndex:keyIndex error:&error]) {
+ result = RTCEncryptionErrorResult(error, @"removeKey failed");
+ }
+ });
+
+ return result;
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerRemoveAllKeys : (nonnull NSDictionary *)options) {
+ __block NSDictionary *result = @{};
+
+ dispatch_sync(self.workerQueue, ^{
+ RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options];
+ if (manager == nil) {
+ result = @{@"error" : @"encryptionManagerRemoveAllKeys(): manager not found"};
+ return;
+ }
+
+ NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]);
+ if (userId.length == 0) {
+ result = @{@"error" : @"encryptionManagerRemoveAllKeys() requires userId"};
+ return;
+ }
+
+ NSError *error = nil;
+ if (![manager removeAllKeys:userId error:&error]) {
+ result = RTCEncryptionErrorResult(error, @"removeAllKeys failed");
+ }
+ });
+
+ return result;
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerRemoveSharedKey : (nonnull NSDictionary *)options) {
+ __block NSDictionary *result = @{};
+
+ dispatch_sync(self.workerQueue, ^{
+ RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options];
+ if (manager == nil) {
+ result = @{@"error" : @"encryptionManagerRemoveSharedKey(): manager not found"};
+ return;
+ }
+
+ int keyIndex = 0;
+ BOOL hasKeyIndex = RTCEncryptionKeyIndexFromOptions(options, &keyIndex);
+ if (!hasKeyIndex) {
+ result = @{@"error" : @"encryptionManagerRemoveSharedKey() requires a keyIndex in 0-255"};
+ return;
+ }
+
+ NSError *error = nil;
+ if (![manager removeSharedKey:keyIndex error:&error]) {
+ result = RTCEncryptionErrorResult(error, @"removeSharedKey failed");
+ }
+ });
+
+ return result;
+}
+
+#pragma mark - Attach
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerEncrypt : (nonnull NSDictionary *)options) {
+ __block NSDictionary *result = @{};
+
+ dispatch_sync(self.workerQueue, ^{
+ RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options];
+ if (manager == nil) {
+ result = @{@"error" : @"encryptionManagerEncrypt(): manager not found"};
+ return;
+ }
+
+ NSNumber *peerConnectionId = RTCEncryptionOptionalValue(options, @"peerConnectionId", [NSNumber class]);
+ NSString *senderId = RTCEncryptionOptionalValue(options, @"senderId", [NSString class]);
+ if (peerConnectionId == nil || senderId == nil) {
+ result = @{@"error" : @"encryptionManagerEncrypt() requires peerConnectionId and senderId"};
+ return;
+ }
+
+ RTCRtpSender *sender = [self getSenderByPeerConnectionId:peerConnectionId senderId:senderId];
+ if (sender == nil) {
+ result =
+ @{@"error" : [NSString stringWithFormat:@"encryptionManagerEncrypt(): sender %@ not found", senderId]};
+ return;
+ }
+
+ NSNumber *trackType = nil;
+ if (!RTCEncryptionTrackTypeFromOptions(options, &trackType)) {
+ result = @{@"error" : @"encryptionManagerEncrypt() got an unusable trackType"};
+ return;
+ }
+
+ NSError *error = nil;
+ NSString *codec = RTCEncryptionOptionalValue(options, @"codec", [NSString class]);
+ if (![manager encrypt:sender codec:codec trackType:trackType error:&error]) {
+ result = RTCEncryptionErrorResult(error, @"Failed to attach the encrypt transform");
+ }
+ });
+
+ return result;
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerDecrypt : (nonnull NSDictionary *)options) {
+ __block NSDictionary *result = @{};
+
+ dispatch_sync(self.workerQueue, ^{
+ RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options];
+ if (manager == nil) {
+ result = @{@"error" : @"encryptionManagerDecrypt(): manager not found"};
+ return;
+ }
+
+ NSNumber *peerConnectionId = RTCEncryptionOptionalValue(options, @"peerConnectionId", [NSNumber class]);
+ NSString *receiverId = RTCEncryptionOptionalValue(options, @"receiverId", [NSString class]);
+ NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]);
+ if (peerConnectionId == nil || receiverId == nil || userId.length == 0) {
+ result = @{@"error" : @"encryptionManagerDecrypt() requires peerConnectionId, receiverId and userId"};
+ return;
+ }
+
+ RTCRtpReceiver *receiver = [self getReceiverByPeerConnectionId:peerConnectionId receiverId:receiverId];
+ if (receiver == nil) {
+ result = @{
+ @"error" : [NSString stringWithFormat:@"encryptionManagerDecrypt(): receiver %@ not found", receiverId]
+ };
+ return;
+ }
+
+ NSNumber *trackType = nil;
+ if (!RTCEncryptionTrackTypeFromOptions(options, &trackType)) {
+ result = @{@"error" : @"encryptionManagerDecrypt() got an unusable trackType"};
+ return;
+ }
+
+ NSError *error = nil;
+ if (![manager decrypt:receiver userId:userId trackType:trackType error:&error]) {
+ result = RTCEncryptionErrorResult(error, @"Failed to attach the decrypt transform");
+ }
+ });
+
+ return result;
+}
+
+#pragma mark - Diagnostics
+
+RCT_EXPORT_METHOD(encryptionManagerEnablePerformanceReporting : (nonnull NSString *)handle enabled : (BOOL)
+ enabled resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) {
+ RTC_OBJC_TYPE(RTCEncryptionManager) *manager = self.encryptionManagers[handle];
+ if (manager == nil) {
+ reject(@"encryptionManagerEnablePerformanceReportingFailed", @"manager not found", nil);
+ return;
+ }
+
+ NSError *error = nil;
+ if (![manager enablePerformanceReporting:enabled error:&error]) {
+ reject(@"encryptionManagerEnablePerformanceReportingFailed", @"enablePerformanceReporting failed", error);
+ return;
+ }
+
+ resolve(nil);
+}
+
+RCT_EXPORT_METHOD(encryptionManagerRequestKeyState : (nonnull NSString *)handle resolve : (RCTPromiseResolveBlock)
+ resolve reject : (RCTPromiseRejectBlock)reject) {
+ RTC_OBJC_TYPE(RTCEncryptionManager) *manager = self.encryptionManagers[handle];
+ if (manager == nil) {
+ reject(@"encryptionManagerRequestKeyStateFailed", @"manager not found", nil);
+ return;
+ }
+
+ NSError *error = nil;
+ if (![manager requestKeyState:&error]) {
+ reject(@"encryptionManagerRequestKeyStateFailed", @"requestKeyState failed", error);
+ return;
+ }
+
+ resolve(nil);
+}
+
+#pragma mark - RTCEncryptionManagerDelegate
+
+/* Key fingerprints only; raw key material never crosses the bridge. */
+- (NSArray *)keyStatePerUserKeysToJSON:(RTC_OBJC_TYPE(RTCEncryptionKeyState) *)keyState {
+ NSMutableArray *keys = [NSMutableArray arrayWithCapacity:keyState.perUserKeys.count];
+ for (RTC_OBJC_TYPE(RTCEncryptionUserKey) * key in keyState.perUserKeys) {
+ [keys addObject:@{@"userId" : key.userId, @"keyIndex" : @(key.keyIndex), @"fingerprint" : key.fingerprint}];
+ }
+
+ return keys;
+}
+
+- (NSArray *)keyStateSharedKeysToJSON:(RTC_OBJC_TYPE(RTCEncryptionKeyState) *)keyState {
+ NSMutableArray *keys = [NSMutableArray arrayWithCapacity:keyState.sharedKeys.count];
+ for (RTC_OBJC_TYPE(RTCEncryptionSharedKey) * key in keyState.sharedKeys) {
+ [keys addObject:@{
+ @"keyIndex" : @(key.keyIndex),
+ @"fingerprint" : key.fingerprint,
+ @"isActive" : @(key.isActive)
+ }];
+ }
+
+ return keys;
+}
+
+- (NSArray *)trackPerfToJSON:(NSArray *)samples {
+ NSMutableArray *rows = [NSMutableArray arrayWithCapacity:samples.count];
+ for (RTC_OBJC_TYPE(RTCEncryptionTrackPerf) * sample in samples) {
+ NSMutableDictionary *row = [NSMutableDictionary dictionaryWithDictionary:@{
+ @"userId" : sample.userId,
+ @"trackType" : @(sample.trackType),
+ @"fps" : @(sample.fps),
+ @"maxCryptoMs" : @(sample.maxCryptoMs)
+ }];
+ if (sample.codec != nil) {
+ row[@"codec"] = sample.codec;
+ }
+
+ [rows addObject:row];
+ }
+
+ return rows;
+}
+
+- (void)encryptionManager:(RTC_OBJC_TYPE(RTCEncryptionManager) *)manager
+ didReceiveEvent:(RTC_OBJC_TYPE(RTCE2eeEvent) *)event {
+ id handle = objc_getAssociatedObject(manager, &kEncryptionManagerHandleKey);
+ if (![handle isKindOfClass:[NSString class]]) {
+ RCTLogWarn(@"E2EE event for a manager without a handle");
+ return;
+ }
+
+ NSMutableDictionary *body = [NSMutableDictionary
+ dictionaryWithDictionary:@{@"managerId" : (NSString *)handle, @"type" : event.name, @"userId" : event.userId}];
+
+ if (event.trackType != nil) {
+ body[@"trackType"] = event.trackType;
+ }
+ if (event.keyIndex != nil) {
+ body[@"keyIndex"] = event.keyIndex;
+ }
+ if (event.version != nil) {
+ body[@"version"] = event.version;
+ }
+ if (event.reason != nil) {
+ body[@"reason"] = event.reason;
+ }
+ if (event.keyState != nil) {
+ body[@"keyState"] = @{
+ @"perUserKeys" : [self keyStatePerUserKeysToJSON:event.keyState],
+ @"sharedKeys" : [self keyStateSharedKeysToJSON:event.keyState]
+ };
+ }
+ if (event.encode != nil) {
+ body[@"encode"] = [self trackPerfToJSON:event.encode];
+ }
+ if (event.decode != nil) {
+ body[@"decode"] = [self trackPerfToJSON:event.decode];
+ }
+
+ [self sendEventWithName:kEventEncryptionManagerEvent body:body];
+}
+
+@end
diff --git a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h
index 60099a12d..d7d1aa720 100644
--- a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h
+++ b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h
@@ -19,4 +19,9 @@
- (void)peerConnectionClose:(nonnull NSNumber *)objectID;
- (void)peerConnectionDispose:(nonnull NSNumber *)objectID;
+- (nullable RTCRtpSender *)getSenderByPeerConnectionId:(nonnull NSNumber *)peerConnectionId
+ senderId:(nonnull NSString *)senderId;
+- (nullable RTCRtpReceiver *)getReceiverByPeerConnectionId:(nonnull NSNumber *)peerConnectionId
+ receiverId:(nonnull NSString *)receiverId;
+
@end
diff --git a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m
index 167c9e94f..4f14214f0 100644
--- a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m
+++ b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m
@@ -95,6 +95,40 @@ + (RTCCertificate *)getCertificate:(NSString *)certId {
int _transceiverNextId = 0;
+- (nullable RTCRtpSender *)getSenderByPeerConnectionId:(nonnull NSNumber *)peerConnectionId
+ senderId:(nonnull NSString *)senderId {
+ RTCPeerConnection *peerConnection = self.peerConnections[peerConnectionId];
+ if (!peerConnection) {
+ RCTLogWarn(@"PeerConnection %@ not found", peerConnectionId);
+ return nil;
+ }
+
+ for (RTCRtpSender *s in peerConnection.senders) {
+ if ([senderId isEqual:s.senderId]) {
+ return s;
+ }
+ }
+
+ return nil;
+}
+
+- (nullable RTCRtpReceiver *)getReceiverByPeerConnectionId:(nonnull NSNumber *)peerConnectionId
+ receiverId:(nonnull NSString *)receiverId {
+ RTCPeerConnection *peerConnection = self.peerConnections[peerConnectionId];
+ if (!peerConnection) {
+ RCTLogWarn(@"PeerConnection %@ not found", peerConnectionId);
+ return nil;
+ }
+
+ for (RTCRtpReceiver *r in peerConnection.receivers) {
+ if ([receiverId isEqual:r.receiverId]) {
+ return r;
+ }
+ }
+
+ return nil;
+}
+
/*
* This method is synchronous and blocking. This is done so we can implement createDataChannel
* in the same way (synchronous) since the peer connection needs to exist before.
diff --git a/ios/RCTWebRTC/WebRTCModule.h b/ios/RCTWebRTC/WebRTCModule.h
index 2dc567d11..6395db79a 100644
--- a/ios/RCTWebRTC/WebRTCModule.h
+++ b/ios/RCTWebRTC/WebRTCModule.h
@@ -32,6 +32,7 @@ static NSString *const kEventAudioDeviceModuleEngineWillRelease = @"audioDeviceM
static NSString *const kEventAudioDeviceModuleDevicesUpdated = @"audioDeviceModuleDevicesUpdated";
static NSString *const kEventAudioDeviceModuleAudioProcessingStateUpdated =
@"audioDeviceModuleAudioProcessingStateUpdated";
+static NSString *const kEventEncryptionManagerEvent = @"encryptionManagerEvent";
@class AudioDeviceModule;
@class CaptureController;
@@ -52,6 +53,8 @@ static NSString *const kEventAudioDeviceModuleAudioProcessingStateUpdated =
@property(nonatomic, strong) NSMutableDictionary *peerConnections;
@property(nonatomic, strong) NSMutableDictionary *localStreams;
@property(nonatomic, strong) NSMutableDictionary *localTracks;
+@property(nonatomic, strong)
+ NSMutableDictionary *encryptionManagers;
@property(nonatomic, weak) id activeCameraPreview;
diff --git a/ios/RCTWebRTC/WebRTCModule.m b/ios/RCTWebRTC/WebRTCModule.m
index ffde484d5..f8902de2e 100644
--- a/ios/RCTWebRTC/WebRTCModule.m
+++ b/ios/RCTWebRTC/WebRTCModule.m
@@ -37,6 +37,16 @@ + (BOOL)requiresMainQueueSetup {
}
- (void)dealloc {
+ // E2EE managers first: their frame transforms are held by the senders and receivers of the peer
+ // connections closed below, and nothing in JS survives to dispose them.
+ for (NSString *handle in _encryptionManagers) {
+ RTC_OBJC_TYPE(RTCEncryptionManager) *manager = _encryptionManagers[handle];
+ manager.delegate = nil;
+ [manager dispose];
+ }
+ [_encryptionManagers removeAllObjects];
+ _encryptionManagers = nil;
+
[_localTracks removeAllObjects];
_localTracks = nil;
[_localStreams removeAllObjects];
@@ -120,6 +130,7 @@ - (instancetype)init {
_peerConnections = [NSMutableDictionary new];
_localStreams = [NSMutableDictionary new];
_localTracks = [NSMutableDictionary new];
+ _encryptionManagers = [NSMutableDictionary new];
dispatch_queue_attr_t attributes =
dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, -1);
@@ -296,7 +307,8 @@ - (BOOL)disposeCurrentFactoryOrdered {
kEventAudioDeviceModuleEngineDidDisable,
kEventAudioDeviceModuleEngineWillRelease,
kEventAudioDeviceModuleDevicesUpdated,
- kEventAudioDeviceModuleAudioProcessingStateUpdated
+ kEventAudioDeviceModuleAudioProcessingStateUpdated,
+ kEventEncryptionManagerEvent
];
}
diff --git a/package-lock.json b/package-lock.json
index 7e12e03bf..51b0bf6a5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@stream-io/react-native-webrtc",
- "version": "145.3.3",
+ "version": "145.4.0-alpha.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@stream-io/react-native-webrtc",
- "version": "145.3.3",
+ "version": "145.4.0-alpha.1",
"license": "MIT",
"dependencies": {
"base64-js": "^1.5.1",
diff --git a/package.json b/package.json
index 4e23445e1..e3f08d9dd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@stream-io/react-native-webrtc",
- "version": "145.3.3",
+ "version": "145.4.0-alpha.1",
"repository": {
"type": "git",
"url": "git+https://github.com/GetStream/react-native-webrtc.git"
diff --git a/src/RTCEncryptionManager.ts b/src/RTCEncryptionManager.ts
new file mode 100644
index 000000000..45f74d17a
--- /dev/null
+++ b/src/RTCEncryptionManager.ts
@@ -0,0 +1,202 @@
+import * as base64 from 'base64-js';
+import { NativeModules, type EmitterSubscription } from 'react-native';
+
+import {
+ encryptionManagerEvents,
+ type RTCEncryptionEventData,
+ type RTCEncryptionEventType,
+ RTCEncryptionTrackType,
+} from './RTCEncryptionManagerEvents';
+import RTCRtpReceiver from './RTCRtpReceiver';
+import RTCRtpSender from './RTCRtpSender';
+
+const { WebRTCModule } = NativeModules;
+
+/** AES-GCM key size. Default is AES-128, i.e. 16-byte keys. */
+export enum RTCEncryptionAlgorithm {
+ AES_128_GCM = 0,
+ AES_256_GCM = 1,
+}
+
+export interface RTCEncryptionManagerOptions {
+ algorithm?: RTCEncryptionAlgorithm;
+}
+
+type NativeResult = { handle?: string, error?: string };
+
+/**
+ * End-to-end encryption of already-encoded media frames (framed AES-GCM). One manager holds the
+ * keys and attaches encrypt/decrypt transforms to RTP senders and receivers; the SFU then forwards
+ * ciphertext it cannot read.
+ *
+ * The key and attach operations are synchronous by design: an async attach would leave a window
+ * where a sender exists before its transform is installed, which is a plaintext window. They throw
+ * on failure so a caller can never mistake a failed attach for a successful one.
+ *
+ * There is no detach API. Once attached, the only exits are destroying the sender/receiver or
+ * calling {@link dispose}, which drops in-flight frames rather than draining them. Nothing native
+ * cleans a manager up when a peer connection closes, so callers must dispose explicitly.
+ */
+export default class RTCEncryptionManager {
+ _handle: string;
+ _disposed = false;
+ _subscription: EmitterSubscription | null = null;
+ _listeners: Map void>> = new Map();
+
+ /** Native always supports encoded transforms, unlike the browser Encoded Transform API. */
+ static isSupported(): boolean {
+ return Boolean(WebRTCModule?.encryptionManagerIsSupported?.());
+ }
+
+ static create(userId: string, options: RTCEncryptionManagerOptions = {}): RTCEncryptionManager {
+ const result: NativeResult = WebRTCModule.encryptionManagerCreate({
+ userId,
+ ...(options.algorithm === undefined ? {} : { algorithm: options.algorithm }),
+ });
+
+ if (result?.error || !result?.handle) {
+ throw new Error(result?.error ?? 'Failed to create the encryption manager');
+ }
+
+ return new RTCEncryptionManager(result.handle);
+ }
+
+ constructor(handle: string) {
+ this._handle = handle;
+ this._registerEvents();
+ }
+
+ /**
+ * Install a key for a participant. `rawKey` must be 16 bytes for AES-128 or 32 for AES-256, and
+ * `keyIndex` must be in the 0-255 range.
+ */
+ setKey(userId: string, keyIndex: number, rawKey: Uint8Array): void {
+ this._invoke('encryptionManagerSetKey', {
+ userId,
+ keyIndex,
+ key: base64.fromByteArray(rawKey),
+ });
+ }
+
+ /** Install a key shared by every participant of the call. */
+ setSharedKey(keyIndex: number, rawKey: Uint8Array): void {
+ this._invoke('encryptionManagerSetSharedKey', {
+ keyIndex,
+ key: base64.fromByteArray(rawKey),
+ });
+ }
+
+ removeKey(userId: string, keyIndex: number): void {
+ this._invoke('encryptionManagerRemoveKey', { userId, keyIndex });
+ }
+
+ removeAllKeys(userId: string): void {
+ this._invoke('encryptionManagerRemoveAllKeys', { userId });
+ }
+
+ removeSharedKey(keyIndex: number): void {
+ this._invoke('encryptionManagerRemoveSharedKey', { keyIndex });
+ }
+
+ /**
+ * Attach the encrypt transform to a sender.
+ *
+ * `codec` is an exact lowercase pin (`opus`/`vp8`/`vp9`/`h264`); anything else fails closed.
+ * Omitting it reads the codec from the frame. Omitting `trackType` defaults to audio vs video
+ * from the sender, so screen-share types must be passed explicitly.
+ */
+ encrypt(sender: RTCRtpSender, codec?: string, trackType?: RTCEncryptionTrackType): void {
+ this._invoke('encryptionManagerEncrypt', {
+ peerConnectionId: sender._peerConnectionId,
+ senderId: sender._id,
+ ...(codec === undefined ? {} : { codec }),
+ ...(trackType === undefined ? {} : { trackType }),
+ });
+ }
+
+ /** Attach the decrypt transform to a receiver carrying `userId`'s media. */
+ decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: RTCEncryptionTrackType): void {
+ this._invoke('encryptionManagerDecrypt', {
+ peerConnectionId: receiver._peerConnectionId,
+ receiverId: receiver._id,
+ userId,
+ ...(trackType === undefined ? {} : { trackType }),
+ });
+ }
+
+ /** When enabled, `e2ee.perf_report` is emitted once per second. */
+ enablePerformanceReporting(enabled: boolean): Promise {
+ this._assertNotDisposed();
+
+ return WebRTCModule.encryptionManagerEnablePerformanceReporting(this._handle, enabled);
+ }
+
+ /** Ask for an `e2ee.key_state` event carrying the current key fingerprints. */
+ requestKeyState(): Promise {
+ this._assertNotDisposed();
+
+ return WebRTCModule.encryptionManagerRequestKeyState(this._handle);
+ }
+
+ on(type: RTCEncryptionEventType, listener: (data: RTCEncryptionEventData) => void): void {
+ this._assertNotDisposed();
+
+ let listeners = this._listeners.get(type);
+
+ if (!listeners) {
+ listeners = new Set();
+ this._listeners.set(type, listeners);
+ }
+
+ listeners.add(listener);
+ }
+
+ off(type: RTCEncryptionEventType, listener: (data: RTCEncryptionEventData) => void): void {
+ this._listeners.get(type)?.delete(listener);
+ }
+
+ /** Drops in-flight frames. Keys and transforms are released; the handle becomes unusable. */
+ dispose(): void {
+ if (this._disposed) {
+ return;
+ }
+
+ // Marked disposed only once the native cleanup succeeds: the native side keeps a manager
+ // registered when its cleanup throws, so a failed dispose() has to stay retryable here too.
+ this._invoke('encryptionManagerDispose', {});
+
+ this._disposed = true;
+ this._subscription?.remove();
+ this._subscription = null;
+ this._listeners.clear();
+ }
+
+ _assertNotDisposed(): void {
+ if (this._disposed) {
+ throw new Error('RTCEncryptionManager has been disposed');
+ }
+ }
+
+ _invoke(method: string, params: Record): void {
+ this._assertNotDisposed();
+
+ const result: NativeResult = WebRTCModule[method]({ handle: this._handle, ...params });
+
+ if (result?.error) {
+ throw new Error(result.error);
+ }
+ }
+
+ _registerEvents(): void {
+ // Idempotent, and keeps this class usable when imported directly rather than via index.ts.
+ encryptionManagerEvents.setupListeners();
+
+ this._subscription = encryptionManagerEvents.addEncryptionManagerEventListener(ev => {
+ if (ev.managerId !== this._handle) {
+ return;
+ }
+
+ this._listeners.get(ev.type)?.forEach(listener => listener(ev));
+ });
+ }
+}
diff --git a/src/RTCEncryptionManagerEvents.ts b/src/RTCEncryptionManagerEvents.ts
new file mode 100644
index 000000000..9862b6491
--- /dev/null
+++ b/src/RTCEncryptionManagerEvents.ts
@@ -0,0 +1,100 @@
+import { NativeEventEmitter, NativeModules } from 'react-native';
+
+const { WebRTCModule } = NativeModules;
+
+/**
+ * Track type of an encrypted stream. Screen-share audio is distinct from microphone audio: replay
+ * state is kept per (userId, trackType), so collapsing the two mis-groups it.
+ */
+export enum RTCEncryptionTrackType {
+ AUDIO = 0,
+ VIDEO = 1,
+ SCREEN_SHARE = 2,
+ SCREEN_SHARE_AUDIO = 3,
+}
+
+export type RTCEncryptionEventType =
+ | 'e2ee.decryption_failed'
+ | 'e2ee.decryption_resumed'
+ | 'e2ee.decryption_stalled'
+ | 'e2ee.encryption_failed'
+ | 'e2ee.missing_key'
+ | 'e2ee.unencrypted_frame'
+ | 'e2ee.unsupported_version'
+ | 'e2ee.key_state'
+ | 'e2ee.perf_report';
+
+export interface RTCEncryptionUserKey {
+ userId: string;
+ keyIndex: number;
+ /** 16-char hex of SHA-256(rawKey)[:8]. Never key material. */
+ fingerprint: string;
+}
+
+export interface RTCEncryptionSharedKey {
+ keyIndex: number;
+ fingerprint: string;
+ isActive: boolean;
+}
+
+/** Payload of `e2ee.key_state`. At most one shared key is active. */
+export interface RTCEncryptionKeyState {
+ perUserKeys: RTCEncryptionUserKey[];
+ sharedKeys: RTCEncryptionSharedKey[];
+}
+
+/** One row of `e2ee.perf_report`. `codec` is set on encode samples only. */
+export interface RTCEncryptionTrackPerf {
+ userId: string;
+ trackType: RTCEncryptionTrackType;
+ codec?: string;
+ fps: number;
+ maxCryptoMs: number;
+}
+
+export interface RTCEncryptionEventData {
+ /** Handle of the manager the event belongs to. */
+ managerId: string;
+ type: RTCEncryptionEventType;
+ userId: string;
+ trackType?: RTCEncryptionTrackType;
+ keyIndex?: number;
+ version?: number;
+ reason?: string;
+ keyState?: RTCEncryptionKeyState;
+ encode?: RTCEncryptionTrackPerf[];
+ decode?: RTCEncryptionTrackPerf[];
+}
+
+/**
+ * Event emitter for native `RTCEncryptionManager` events. This is a dedicated emitter rather than an
+ * entry in the NATIVE_EVENTS allowlist of EventEmitter.ts, so the allowlist does not have to stay
+ * hand-synced with the native event constants.
+ */
+class RTCEncryptionManagerEventEmitter {
+ private eventEmitter: NativeEventEmitter | null = null;
+
+ public setupListeners() {
+ // Only setup once (idempotent)
+ if (this.eventEmitter !== null) {
+ return;
+ }
+
+ if (WebRTCModule) {
+ this.eventEmitter = new NativeEventEmitter(WebRTCModule);
+ }
+ }
+
+ /**
+ * Subscribe to every `e2ee.*` event of every manager. Consumers filter by `managerId`.
+ */
+ addEncryptionManagerEventListener(listener: (data: RTCEncryptionEventData) => void) {
+ if (!this.eventEmitter) {
+ throw new Error('RTCEncryptionManagerEvents: native module not available');
+ }
+
+ return this.eventEmitter.addListener('encryptionManagerEvent', listener);
+ }
+}
+
+export const encryptionManagerEvents = new RTCEncryptionManagerEventEmitter();
diff --git a/src/index.ts b/src/index.ts
index 6b3499629..5a1f177a3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -23,6 +23,20 @@ import permissions from './Permissions';
import RTCAudioSession from './RTCAudioSession';
import RTCCameraPreviewView from './RTCCameraPreviewView';
import RTCCertificate from './RTCCertificate';
+import RTCEncryptionManager, {
+ RTCEncryptionAlgorithm,
+ type RTCEncryptionManagerOptions,
+} from './RTCEncryptionManager';
+import {
+ encryptionManagerEvents,
+ type RTCEncryptionEventData,
+ type RTCEncryptionEventType,
+ type RTCEncryptionKeyState,
+ type RTCEncryptionSharedKey,
+ RTCEncryptionTrackType,
+ type RTCEncryptionTrackPerf,
+ type RTCEncryptionUserKey,
+} from './RTCEncryptionManagerEvents';
import RTCErrorEvent from './RTCErrorEvent';
import RTCIceCandidate from './RTCIceCandidate';
import RTCPeerConnection from './RTCPeerConnection';
@@ -43,6 +57,9 @@ setupNativeEvents();
// Ensure audioDeviceModuleEvents is initialized and event listeners are registered
audioDeviceModuleEvents.setupListeners();
+// Ensure encryptionManagerEvents is initialized and event listeners are registered
+encryptionManagerEvents.setupListeners();
+
export {
RTCIceCandidate,
RTCPeerConnection,
@@ -71,6 +88,17 @@ export {
AudioDeviceModule,
AudioEngineMuteMode,
audioDeviceModuleEvents,
+ RTCEncryptionManager,
+ RTCEncryptionAlgorithm,
+ RTCEncryptionTrackType,
+ encryptionManagerEvents,
+ type RTCEncryptionManagerOptions,
+ type RTCEncryptionEventData,
+ type RTCEncryptionEventType,
+ type RTCEncryptionKeyState,
+ type RTCEncryptionUserKey,
+ type RTCEncryptionSharedKey,
+ type RTCEncryptionTrackPerf,
};
declare const global: any;
@@ -103,4 +131,7 @@ function registerGlobals(): void {
// Ensure audioDeviceModuleEvents is initialized and event listeners are registered
audioDeviceModuleEvents.setupListeners();
+
+ // Ensure encryptionManagerEvents is initialized and event listeners are registered
+ encryptionManagerEvents.setupListeners();
}
diff --git a/stream-react-native-webrtc.podspec b/stream-react-native-webrtc.podspec
index 398d67432..546743fa7 100644
--- a/stream-react-native-webrtc.podspec
+++ b/stream-react-native-webrtc.podspec
@@ -21,7 +21,7 @@ Pod::Spec.new do |s|
s.swift_version = '5.0'
s.dependency 'React-Core'
# WebRTC version from https://github.com/GetStream/stream-video-swift-webrtc releases
- s.dependency 'StreamWebRTC', '= 145.15.0'
+ s.dependency 'StreamWebRTC', '145.17.0'
# Swift/Objective-C compatibility #https://blog.cocoapods.org/CocoaPods-1.5.0/
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES'