ov-components: Implement E2EE support for chat messages and participant names and add E2EE service

pull/856/head
Carlos Santos 2025-11-06 17:52:14 +01:00
parent 41bca24bfa
commit 48eec08509
14 changed files with 498 additions and 48 deletions

View File

@ -5,11 +5,13 @@ import { Component } from '@angular/core';
*/
@Component({
selector: 'ov-audio-wave',
template: `<div class="audio-container">
<div class="stick normal play"></div>
<div class="stick loud play"></div>
<div class="stick normal play"></div>
</div>`,
template: `
<div class="audio-container audio-wave-indicator">
<div class="stick normal play"></div>
<div class="stick loud play"></div>
<div class="stick normal play"></div>
</div>
`,
styleUrls: ['./audio-wave.component.scss'],
standalone: false
})

View File

@ -110,7 +110,7 @@ export class ChatPanelComponent implements OnInit, AfterViewInit {
}
private subscribeToMessages() {
this.chatService.messagesObs.pipe(takeUntil(this.destroy$)).subscribe((messages: ChatMessage[]) => {
this.chatService.chatMessages$.pipe(takeUntil(this.destroy$)).subscribe((messages: ChatMessage[]) => {
this.messageList = messages;
if (this.panelService.isChatPanelOpened()) {
this.scrollToBottom();

View File

@ -8,7 +8,7 @@
[style.background-color]="_participant?.colorProfile"
[attr.aria-label]="'Avatar for ' + participantDisplayName"
>
<mat-icon>{{_participant.hasEncryptionError ? 'lock_person' : 'person'}}</mat-icon>
<mat-icon>{{ _participant.hasEncryptionError ? 'lock_person' : 'person' }}</mat-icon>
</div>
<!-- Content section with name and status -->

View File

@ -50,6 +50,7 @@ import { ParticipantLeftEvent, ParticipantLeftReason, ParticipantModel } from '.
import { RecordingStatus } from '../../models/recording.model';
import { TemplateManagerService, SessionTemplateConfiguration } from '../../services/template/template-manager.service';
import { ViewportService } from '../../services/viewport/viewport.service';
import { E2eeService } from '../../services/e2ee/e2ee.service';
/**
* @internal
@ -138,7 +139,8 @@ export class SessionComponent implements OnInit, OnDestroy {
private backgroundService: VirtualBackgroundService,
private cd: ChangeDetectorRef,
private templateManagerService: TemplateManagerService,
protected viewportService: ViewportService
protected viewportService: ViewportService,
private e2eeService: E2eeService
) {
this.log = this.loggerSrv.get('SessionComponent');
this.setupTemplates();
@ -461,13 +463,38 @@ export class SessionComponent implements OnInit, OnDestroy {
private subscribeToDataMessage() {
this.room.on(
RoomEvent.DataReceived,
(payload: Uint8Array, participant?: RemoteParticipant, _?: DataPacket_Kind, topic?: string) => {
const event = JSON.parse(new TextDecoder().decode(payload));
this.log.d(`Data event received: ${topic}`);
async (payload: Uint8Array, participant?: RemoteParticipant, _?: DataPacket_Kind, topic?: string) => {
const storedParticipant = this.participantService.getRemoteParticipantBySid(participant?.sid || '');
if (!storedParticipant) {
this.log.w('DataReceived from unknown participant', participant);
return;
}
const { identity: participantIdentity, name: participantName } = storedParticipant;
// Decrypt payload if it's a CHAT message and E2EE is enabled
let decryptedPayload: Uint8Array = payload;
if (topic === DataTopic.CHAT && this.e2eeService.isEnabled) {
decryptedPayload = await this.e2eeService.decryptOrMask(
payload,
participantIdentity,
JSON.stringify({ message: '******' }) // The fallback text must be a valid JSON
);
}
// Decode and parse the JSON event
let event: any;
try {
event = JSON.parse(new TextDecoder().decode(decryptedPayload));
this.log.d(`Data event received: ${topic}`);
} catch (parseError) {
this.log.e('Error parsing data message:', parseError);
return; // Can't process malformed data
}
// Handle the event based on topic
switch (topic) {
case DataTopic.CHAT:
const participantName = participant?.name || 'Unknown';
this.chatService.addRemoteMessage(event.message, participantName);
this.chatService.addRemoteMessage(event.message, participantName || participantIdentity || 'Unknown');
break;
case DataTopic.RECORDING_STARTING:
this.log.d('Recording is starting', event);

View File

@ -766,7 +766,7 @@ export class ToolbarComponent implements OnInit, OnDestroy, AfterViewInit {
}
private subscribeToChatMessages() {
this.chatService.messagesObs.pipe(skip(1), takeUntil(this.destroy$)).subscribe((messages) => {
this.chatService.chatMessages$.pipe(skip(1), takeUntil(this.destroy$)).subscribe((messages) => {
if (!this.panelService.isChatPanelOpened()) {
this.unreadMessages++;
}

View File

@ -65,6 +65,7 @@ import {
LeaveButtonDirective
} from '../../directives/template/internals.directive';
import { OpenViduThemeService } from '../../services/theme/theme.service';
import { E2eeService } from '../../services/e2ee/e2ee.service';
/**
* The **VideoconferenceComponent** is the parent of all OpenVidu components.
@ -712,7 +713,8 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
private actionService: ActionService,
private libService: OpenViduComponentsConfigService,
private templateManagerService: TemplateManagerService,
private themeService: OpenViduThemeService
private themeService: OpenViduThemeService,
private e2eeService: E2eeService
) {
this.log = this.loggerSrv.get('VideoconferenceComponent');
@ -1042,9 +1044,9 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
}
});
this.libService.participantName$.pipe(takeUntil(this.destroy$)).subscribe((name: string) => {
this.libService.participantName$.pipe(takeUntil(this.destroy$)).subscribe(async (name: string) => {
if (name) {
this.latestParticipantName = name;
this.latestParticipantName = await this.e2eeService.decrypt(name);
this.storageSrv.setParticipantName(name);
// If we're waiting for a participant name to proceed with joining, do it now

View File

@ -535,8 +535,10 @@ export class ParticipantNameDirective implements AfterViewInit, OnDestroy {
/**
* @ignore
*/
update(value: string) {
if (value) this.libService.updateGeneralConfig({ participantName: value });
update(participantName: string) {
if (participantName) {
this.libService.updateGeneralConfig({ participantName });
}
}
}

View File

@ -129,6 +129,7 @@ export class ParticipantModel {
private speaking: boolean = false;
private customVideoTrack: Partial<ParticipantTrackPublication>;
private _hasEncryptionError: boolean = false;
private _decryptedName: string | undefined;
constructor(props: ParticipantProperties) {
this.participant = props.participant;
@ -171,8 +172,7 @@ export class ParticipantModel {
* @returns string
*/
get name(): string | undefined {
return this.participant.name;
// return this.identity;
return this._decryptedName ?? this.participant.name;
}
/**
@ -569,4 +569,13 @@ export class ParticipantModel {
setEncryptionError(hasError: boolean) {
this._hasEncryptionError = hasError;
}
/**
* Sets the decrypted name for this participant.
* @param decryptedName - The decrypted participant name
* @internal
*/
setDecryptedName(decryptedName: string | undefined) {
this._decryptedName = decryptedName;
}
}

View File

@ -23,6 +23,7 @@ import { GlobalConfigService } from './services/config/global-config.service';
import { OpenViduComponentsConfigService } from './services/config/directive-config.service';
import { OpenViduComponentsUiModule } from './openvidu-components-angular-ui.module';
import { ViewportService } from './services/viewport/viewport.service';
import { E2eeService } from './services/e2ee/e2ee.service';
@NgModule({
imports: [OpenViduComponentsUiModule],
@ -52,6 +53,7 @@ export class OpenViduComponentsModule {
StorageService,
VirtualBackgroundService,
ViewportService,
E2eeService,
provideHttpClient(withInterceptorsFromDi())
];

View File

@ -13,6 +13,7 @@ import { PanelService } from '../panel/panel.service';
import { ParticipantService } from '../participant/participant.service';
import { PanelType } from '../../models/panel.model';
import { TranslateService } from '../translate/translate.service';
import { E2eeService } from '../e2ee/e2ee.service';
/**
* @internal
@ -21,7 +22,7 @@ import { TranslateService } from '../translate/translate.service';
providedIn: 'root'
})
export class ChatService {
messagesObs: Observable<ChatMessage[]>;
chatMessages$: Observable<ChatMessage[]>;
private messageSound: HTMLAudioElement;
private _messageList = <BehaviorSubject<ChatMessage[]>>new BehaviorSubject<ChatMessage[]>([]);
private messageList: ChatMessage[] = [];
@ -31,10 +32,11 @@ export class ChatService {
private participantService: ParticipantService,
private panelService: PanelService,
private actionService: ActionService,
private translateService: TranslateService
private translateService: TranslateService,
private e2eeService: E2eeService
) {
this.log = this.loggerSrv.get('ChatService');
this.messagesObs = this._messageList.asObservable();
this.chatMessages$ = this._messageList.asObservable();
this.messageSound = new Audio(
'data:audio/wav;base64,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'
);
@ -60,13 +62,32 @@ export class ChatService {
}
}
/**
* Sends a chat message through the data channel.
* If E2EE is enabled, the message will be encrypted before sending.
*
* @param message The message text to send
*/
async sendMessage(message: string) {
message = message.replace(/ +(?= )/g, '');
if (message !== '' && message !== ' ') {
const strData = JSON.stringify({ message });
const data: Uint8Array = new TextEncoder().encode(strData);
await this.participantService.publishData(data, { topic: DataTopic.CHAT, reliable: true });
this.addMessage(message, true, this.participantService.getMyName()!);
const plainTextMessage = message.replace(/ +(?= )/g, '');
if (plainTextMessage !== '' && plainTextMessage !== ' ') {
try {
// Create message payload
const payload = JSON.stringify({ message: plainTextMessage });
const plainData: Uint8Array = new TextEncoder().encode(payload);
// Encrypt data if E2EE is enabled (Uint8Array → Uint8Array)
const dataToSend: Uint8Array = await this.e2eeService.encrypt(plainData);
// Send through data channel
await this.participantService.publishData(dataToSend, { topic: DataTopic.CHAT, reliable: true });
// Add to local message list
this.addMessage(plainTextMessage, true, this.participantService.getMyName()!);
} catch (error) {
this.log.e('Error sending chat message:', error);
throw error;
}
}
}

View File

@ -0,0 +1,337 @@
import { Injectable } from '@angular/core';
import { OpenViduComponentsConfigService } from '../config/directive-config.service';
import { Subject, takeUntil } from 'rxjs';
import { createKeyMaterialFromString, deriveKeys } from 'livekit-client';
/**
* Independent E2EE Service for encrypting and decrypting text-based content
* (chat messages, participant names, metadata, etc.)
*
* This service uses LiveKit's key derivation utilities combined with Web Crypto API:
* - Uses createKeyMaterialFromString from livekit-client for key material generation (PBKDF2)
* - Uses deriveKeys from livekit-client for key derivation (HKDF)
* - Uses Web Crypto API (AES-GCM) for actual encryption/decryption
* - Generates random IV for each encryption operation
*/
@Injectable({
providedIn: 'root',
})
export class E2eeService {
private static readonly ENCRYPTION_ALGORITHM = 'AES-GCM';
private static readonly IV_LENGTH = 12;
private static readonly SALT = 'livekit-e2ee-data'; // Salt for HKDF key derivation
private decryptionCache = new Map<string, string>();
private destroy$ = new Subject<void>();
private isE2EEEnabled = false;
private encryptionKey: CryptoKey | undefined;
constructor(protected configService: OpenViduComponentsConfigService) {
// Monitor E2EE key changes
this.configService.e2eeKey$.pipe(takeUntil(this.destroy$)).subscribe(async (key: any) => {
await this.setE2EEKey(key);
});
}
async setE2EEKey(key: string | null): Promise<void> {
if (key) {
this.isE2EEEnabled = true;
this.decryptionCache.clear();
await this.deriveEncryptionKey(key);
} else {
this.isE2EEEnabled = false;
this.encryptionKey = undefined;
}
}
/**
* Derives encryption key from passphrase using LiveKit's key derivation utilities
* @param passphrase The E2EE passphrase
* @private
*/
private async deriveEncryptionKey(passphrase: string): Promise<void> {
try {
// Use LiveKit's createKeyMaterialFromString (PBKDF2)
const keyMaterial = await createKeyMaterialFromString(passphrase);
// Use LiveKit's deriveKeys to get encryption key (HKDF)
const derivedKeys = await deriveKeys(keyMaterial, E2eeService.SALT);
// Store the encryption key for use in encrypt/decrypt operations
this.encryptionKey = derivedKeys.encryptionKey;
} catch (error) {
console.error('Failed to derive encryption key:', error);
this.encryptionKey = undefined;
}
}
/**
* Checks if E2EE is currently enabled and encryption key is ready
*/
get isEnabled(): boolean {
return this.isE2EEEnabled && !!this.encryptionKey;
}
/**
* Generates a random initialization vector for encryption
* @private
*/
private static generateIV(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(E2eeService.IV_LENGTH));
}
/**
* Encrypts text content using Web Crypto API with LiveKit-derived keys.
* Returns base64-encoded string suitable for metadata/names.
*
* @param text Plain text to encrypt
* @returns Encrypted text in base64 format, or original text if E2EE is disabled
*/
async encrypt(text: string): Promise<string>;
/**
* Encrypts binary data using Web Crypto API with LiveKit-derived keys.
* Returns Uint8Array suitable for data channels.
*
* @param data Plain data to encrypt
* @returns Encrypted data as Uint8Array, or original data if E2EE is disabled
*/
async encrypt(data: Uint8Array): Promise<Uint8Array>;
/**
* Implementation of encrypt overloads
*/
async encrypt(input: string | Uint8Array): Promise<string | Uint8Array> {
if (!this.isEnabled) {
return input;
}
const isString = typeof input === 'string';
if (isString && !input) {
return input;
}
if (!this.encryptionKey) {
console.warn('E2EE encryption not available: CryptoKey not initialized. Returning unencrypted data.');
return input;
}
try {
// Convert string to Uint8Array if needed
const data = isString ? new TextEncoder().encode(input as string) : (input as Uint8Array);
// Generate a random IV for this encryption
const iv = E2eeService.generateIV();
// Encrypt the data using Web Crypto API with AES-GCM
const encryptedBuffer = await crypto.subtle.encrypt(
{
name: E2eeService.ENCRYPTION_ALGORITHM,
iv: iv as BufferSource
},
this.encryptionKey,
data as BufferSource
);
const encryptedData = new Uint8Array(encryptedBuffer);
// Combine IV + encrypted payload for transport
// Format: [iv(12 bytes)][payload(variable)]
const combined = new Uint8Array(iv.length + encryptedData.length);
combined.set(iv, 0);
combined.set(encryptedData, iv.length);
// Return as base64 for strings, Uint8Array for binary data
return isString ? btoa(String.fromCharCode(...combined)) : combined;
} catch (error) {
console.error('E2EE encryption failed:', error);
// Return original input if encryption fails
return input;
}
}
/**
* Decrypts text content from base64 format using Web Crypto API.
* Suitable for decrypting participant names, metadata, etc.
*
* @param encryptedText Encrypted text in base64 format
* @param participantIdentity Identity of the participant who encrypted the content (optional, used for caching)
* @returns Decrypted plain text, or throws error if decryption fails
*/
async decrypt(encryptedText: string, participantIdentity?: string): Promise<string>;
/**
* Decrypts binary data from Uint8Array using Web Crypto API.
* Suitable for decrypting data channel messages.
*
* If E2EE is not enabled, returns the original encryptedData.
*
* @param encryptedData Encrypted data as Uint8Array (format: [iv][payload])
* @param participantIdentity Identity of the participant who encrypted the content (optional)
* @returns Decrypted data as Uint8Array
*/
async decrypt(encryptedData: Uint8Array, participantIdentity?: string): Promise<Uint8Array>;
/**
* Implementation of decrypt overloads
*/
async decrypt(input: string | Uint8Array, participantIdentity?: string): Promise<string | Uint8Array> {
if (!this.isEnabled) {
return input;
}
const isString = typeof input === 'string';
if (isString && !input) {
return input;
}
// Check cache for strings (caching binary data would be too memory intensive)
if (isString) {
const cacheKey = `${participantIdentity || 'unknown'}:${input}`;
if (this.decryptionCache.has(cacheKey)) {
return this.decryptionCache.get(cacheKey)!;
}
}
if (!this.encryptionKey) {
throw new Error('E2EE decryption not available: CryptoKey not initialized');
}
try {
// Convert to Uint8Array if string (base64)
const combined = isString ? Uint8Array.from(atob(input as string), (c) => c.charCodeAt(0)) : (input as Uint8Array);
// Extract components: iv(12) + payload(variable)
const iv = combined.slice(0, E2eeService.IV_LENGTH);
const payload = combined.slice(E2eeService.IV_LENGTH);
// Decrypt the data using Web Crypto API with AES-GCM
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: E2eeService.ENCRYPTION_ALGORITHM,
iv: iv as BufferSource
},
this.encryptionKey,
payload as BufferSource
);
const decryptedData = new Uint8Array(decryptedBuffer);
// Return as string or Uint8Array depending on input type
if (isString) {
const decoder = new TextDecoder();
const result = decoder.decode(decryptedData);
// Cache successful string decryption
const cacheKey = `${participantIdentity || 'unknown'}:${input}`;
this.decryptionCache.set(cacheKey, result);
// Limit cache size to prevent memory issues
if (this.decryptionCache.size > 1000) {
const firstKey = this.decryptionCache.keys().next().value;
if (firstKey) {
this.decryptionCache.delete(firstKey);
}
}
return result;
} else {
return decryptedData;
}
} catch (error) {
console.warn('E2EE decryption failed (wrong key or corrupted data):', error);
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Attempts to decrypt text content. If decryption fails or E2EE is not enabled,
* returns a masked string to indicate unavailable content.
*
* @param encryptedText Encrypted text in base64 format
* @param participantIdentity Identity of the participant (optional, used for caching)
* @param maskText Custom mask text to show on failure (default: '******')
* @returns Decrypted text or masked value if decryption fails
*/
async decryptOrMask(encryptedText: string, participantIdentity?: string, maskText?: string): Promise<string>;
/**
* Attempts to decrypt binary data. If decryption fails or E2EE is not enabled,
* returns the maskText encoded as Uint8Array to indicate unavailable content.
*
* @param encryptedData Encrypted data as Uint8Array
* @param participantIdentity Identity of the participant (optional)
* @param maskText Custom mask text to show on failure (default: '******')
* @returns Decrypted data or encoded maskText as Uint8Array if decryption fails
*/
async decryptOrMask(encryptedData: Uint8Array, participantIdentity?: string, maskText?: string): Promise<Uint8Array>;
/**
* Implementation of decryptOrMask overloads
*/
async decryptOrMask(
input: string | Uint8Array,
participantIdentity?: string,
maskText: string = '******'
): Promise<string | Uint8Array> {
const isString = typeof input === 'string';
// If E2EE is not enabled, return original input
if (!this.isEnabled) {
return input;
}
// If encryption key is not available, return masked value
if (!this.encryptionKey) {
return isString ? maskText : new TextEncoder().encode(maskText);
}
// If input is empty, return as-is
if ((isString && !input) || (!isString && input.length === 0)) {
return input;
}
try {
// For strings, check if it's valid base64 before attempting decryption
if (isString) {
try {
atob(input as string);
} catch {
// Not base64, likely not encrypted - return original
return input;
}
}
// Attempt decryption
return await this.decrypt(input as any, participantIdentity);
} catch (error) {
// Decryption failed - return masked value
if (isString) {
console.warn('E2EE: Failed to decrypt content, returning masked value:', error);
return maskText;
} else {
console.warn('E2EE: Failed to decrypt binary data, returning encoded mask text:', error);
return new TextEncoder().encode(maskText);
}
}
}
/**
* Clears the decryption cache.
* Should be called when E2EE key changes or when leaving a room.
*/
clearCache(): void {
this.decryptionCache.clear();
}
/**
* Cleanup on service destroy
*/
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.clearCache();
}
}

View File

@ -6,6 +6,7 @@ import {
AudioCaptureOptions,
ConnectionState,
CreateLocalTracksOptions,
E2EEOptions,
ExternalE2EEKeyProvider,
LocalAudioTrack,
LocalTrack,
@ -109,20 +110,29 @@ export class OpenViduService {
disconnectOnPageLeave: true
};
// Configure E2EE if key is provided
if (e2eeKey && e2eeKey.trim() !== '') {
this.log.d('Configuring E2EE with provided key');
this.keyProvider = new ExternalE2EEKeyProvider();
// Configure E2EE if key is provided and keyProvider exists
if (needsE2EEConfig) {
// Create worker using the copied livekit-client e2ee worker from assets
roomOptions.e2ee = {
keyProvider: this.keyProvider,
worker: new Worker('./assets/livekit/livekit-client.e2ee.worker.mjs', { type: 'module' })
};
roomOptions.e2ee = this.buildE2EEOptions();
// !This config enables the data channel encryption
// (roomOptions as any).encryption = this.buildE2EEOptions();
}
this.room = new Room(roomOptions);
this.log.d('Room initialized successfully');
} /**
}
private buildE2EEOptions(): E2EEOptions {
this.log.d('Configuring E2EE with provided key');
this.keyProvider = new ExternalE2EEKeyProvider();
// Create worker using the copied livekit-client e2ee worker from assets
return {
keyProvider: this.keyProvider,
worker: new Worker('./assets/livekit/livekit-client.e2ee.worker.mjs', { type: 'module' })
};
}
/**
* Connects local participant to the room
*/
async connectRoom(): Promise<void> {
@ -139,6 +149,7 @@ export class OpenViduService {
}
await this.room.connect(this.livekitUrl, this.livekitToken);
this.log.d(`Successfully connected to room ${this.room.name}`);
const participantName = this.storageService.getParticipantName();
if (participantName) {
this.room.localParticipant.setName(participantName);
@ -469,11 +480,10 @@ export class OpenViduService {
}
} catch (error) {
this.log.e('Failed to create new video track:', error);
throw new Error(`Failed to switch camera: ${error.message}`);
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to switch camera: ${message}`);
}
}
/**
} /**
* Switches the microphone device when the room is not connected (prejoin page)
* @param deviceId new audio device to use
* @internal
@ -520,7 +530,8 @@ export class OpenViduService {
}
} catch (error) {
this.log.e('Failed to create new audio track:', error);
throw new Error(`Failed to switch microphone: ${error.message}`);
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to switch microphone: ${message}`);
}
}

View File

@ -20,6 +20,7 @@ import {
VideoPresets
} from 'livekit-client';
import { StorageService } from '../storage/storage.service';
import { E2eeService } from '../e2ee/e2ee.service';
@Injectable({
providedIn: 'root'
@ -50,7 +51,8 @@ export class ParticipantService {
private directiveService: OpenViduComponentsConfigService,
private openviduService: OpenViduService,
private storageSrv: StorageService,
private loggerSrv: LoggerService
private loggerSrv: LoggerService,
private e2eeService: E2eeService
) {
this.log = this.loggerSrv.get('ParticipantService');
this.localParticipant$ = this.localParticipantBS.asObservable();
@ -553,11 +555,45 @@ export class ParticipantService {
}
}
private newParticipant(props: ParticipantProperties) {
private newParticipant(props: ParticipantProperties): ParticipantModel {
let participant: ParticipantModel;
if (this.globalService.hasParticipantFactory()) {
return this.globalService.getParticipantFactory().apply(this, [props]);
participant = this.globalService.getParticipantFactory().apply(this, [props]);
} else {
participant = new ParticipantModel(props);
}
// Decrypt participant name asynchronously if E2EE is enabled
this.decryptParticipantName(participant);
return participant;
}
/**
* Decrypts the participant name if E2EE is enabled.
* Updates the participant model asynchronously.
* @param participant - The participant model to decrypt the name for
* @private
*/
private async decryptParticipantName(participant: ParticipantModel): Promise<void> {
const originalName = participant.name;
if (!originalName) {
return;
}
try {
const decryptedName = await this.e2eeService.decryptOrMask(originalName, participant.identity);
participant.setDecryptedName(decryptedName);
// Update observables to reflect the decrypted name
if (participant.isLocal) {
this.updateLocalParticipant();
} else {
this.updateRemoteParticipants();
}
} catch (error) {
this.log.w('Failed to decrypt participant name:', error);
}
return new ParticipantModel(props);
}
private getScreenCaptureOptions(): ScreenShareCaptureOptions {

View File

@ -66,6 +66,7 @@ export * from './lib/services/storage/storage.service';
export * from './lib/services/translate/translate.service';
export * from './lib/services/theme/theme.service';
export * from './lib/services/viewport/viewport.service';
export * from './lib/services/e2ee/e2ee.service';
//Modules
export * from './lib/openvidu-components-angular.module';
export * from './lib/config/custom-cdk-overlay';