зеркало из
1
0
Форкнуть 0

Calling Declarative Recording Notice (#194)

* Add recording notice to Calling Declarative

* Change files
This commit is contained in:
allenplusplus 2021-05-04 11:30:38 -07:00 коммит произвёл GitHub
Родитель f0f0b1ea0e
Коммит 63a35955ed
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
12 изменённых файлов: 277 добавлений и 12 удалений

Просмотреть файл

@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "Add recording notice capability to Calling Declarative",
"packageName": "@azure/acs-calling-declarative",
"email": "allenhwang@microsoft.com",
"dependentChangeType": "patch"
}

Просмотреть файл

@ -31,6 +31,7 @@ export interface Call {
isMuted: boolean;
isScreenSharingOn: boolean;
localVideoStreams: LocalVideoStream[];
recording: RecordingCallFeature;
remoteParticipants: Map<string, RemoteParticipant>;
remoteParticipantsEnded: Map<string, RemoteParticipant>;
startTime: Date;
@ -85,6 +86,11 @@ export interface LocalVideoStream {
videoStreamRendererView: VideoStreamRendererView | undefined;
}
// @public
export interface RecordingCallFeature {
isRecordingActive: boolean;
}
// @public
export interface RemoteParticipant {
callEndReason?: CallEndReason;

Просмотреть файл

@ -95,8 +95,8 @@ class ProxyCallAgent implements ProxyHandler<CallAgent> {
private addCall = (call: Call): void => {
this._callSubscribers.get(call)?.unsubscribe();
this._callSubscribers.set(call, new CallSubscriber(call, this._context, this._internalContext));
this._context.setCall(convertSdkCallToDeclarativeCall(call));
this._callSubscribers.set(call, new CallSubscriber(call, this._context, this._internalContext));
};
public get<P extends keyof CallAgent>(target: CallAgent, prop: P): any {

Просмотреть файл

@ -3,9 +3,13 @@ import {
Call,
CallAgent,
CallClient,
CallFeatureFactoryType,
CreateViewOptions,
LocalVideoStream,
RecordingCallFeature,
RemoteVideoStream,
TranscriptionCallFeature,
TransferCallFeature,
VideoDeviceInfo,
VideoStreamRendererView
} from '@azure/communication-calling';
@ -13,6 +17,7 @@ import { callClientDeclaratify, DeclarativeCallClient } from './CallClientDeclar
import { getRemoteParticipantKey } from './Converter';
import {
addMockEmitter,
createMockApiFeatures,
createMockCall,
createMockRemoteParticipant,
createMockRemoteVideoStream,
@ -20,7 +25,11 @@ import {
MockCallAgent,
MockCommunicationUserCredential,
mockoutObjectFreeze,
MockRemoteParticipant
MockRecordingCallFeatureImpl,
MockRemoteParticipant,
MockTranscriptionCallFeatureImpl,
MockTransferCallFeatureImpl,
MOCK_RECORDING_NAME
} from './TestUtils';
mockoutObjectFreeze();
@ -40,7 +49,18 @@ jest.mock('@azure/communication-calling', () => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
dispose: () => {}
};
})
}),
Features: {
get Recording(): CallFeatureFactoryType<RecordingCallFeature> {
return MockRecordingCallFeatureImpl;
},
get Transfer(): CallFeatureFactoryType<TransferCallFeature> {
return MockTransferCallFeatureImpl;
},
get Transcription(): CallFeatureFactoryType<TranscriptionCallFeature> {
return MockTranscriptionCallFeatureImpl;
}
}
};
});
@ -71,9 +91,17 @@ function createDeclarativeClient(testData: TestData): void {
testData.declarativeCallClient = callClientDeclaratify(testData.mockCallClient);
}
async function createMockCallAndEmitCallsUpdated(testData: TestData, waitCondition?: () => boolean): Promise<void> {
async function createMockCallAndEmitCallsUpdated(
testData: TestData,
waitCondition?: () => boolean,
mockCall?: MockCall
): Promise<void> {
await testData.declarativeCallClient.createCallAgent(new MockCommunicationUserCredential());
testData.mockCall = createMockCall(mockCallId);
if (mockCall) {
testData.mockCall = mockCall;
} else {
testData.mockCall = createMockCall(mockCallId);
}
testData.mockCallAgent.calls = [testData.mockCall];
testData.mockCallAgent.emit('callsUpdated', {
added: [testData.mockCall],
@ -790,4 +818,72 @@ describe('declarative call client', () => {
testData.declarativeCallClient.state.calls.get(mockCallId)?.localVideoStreams[0]?.videoStreamRendererView
).not.toBeDefined();
});
test('should detect if call already has recording active', async () => {
const testData = {} as TestData;
createClientAndAgentMocks(testData);
createDeclarativeClient(testData);
const mockCall = createMockCall(mockCallId);
mockCall.api = createMockApiFeatures(true, new Map<string, any>());
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
await waitWithBreakCondition(
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive === true
);
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(true);
});
test('should detect recording changes in call', async () => {
const testData = {} as TestData;
createClientAndAgentMocks(testData);
createDeclarativeClient(testData);
const mockCall = createMockCall(mockCallId);
const featureCache = new Map<string, any>();
mockCall.api = createMockApiFeatures(true, featureCache);
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
await waitWithBreakCondition(
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive === true
);
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(true);
const recording = featureCache.get(MOCK_RECORDING_NAME);
recording.isRecordingActive = false;
recording.emitter.emit('isRecordingActiveChanged');
await waitWithBreakCondition(
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive === false
);
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(false);
});
test('should unsubscribe to recording changes when call ended', async () => {
const testData = {} as TestData;
createClientAndAgentMocks(testData);
createDeclarativeClient(testData);
const mockCall = createMockCall(mockCallId);
const featureCache = new Map<string, any>();
mockCall.api = createMockApiFeatures(true, featureCache);
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
await waitWithBreakCondition(
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive === true
);
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(true);
testData.mockCallAgent.calls = [];
testData.mockCallAgent.emit('callsUpdated', {
added: [],
removed: [testData.mockCall]
});
await waitWithBreakCondition(() => testData.declarativeCallClient.state.calls.size === 0);
const recording = featureCache.get(MOCK_RECORDING_NAME);
expect(recording.emitter.eventNames().length).toBe(0);
});
});

Просмотреть файл

@ -19,6 +19,16 @@ import {
UnknownIdentifierKind
} from '@azure/communication-common';
/**
* State only version of {@Link @azure/communication-calling#RecordingCallFeature}.
*/
export interface RecordingCallFeature {
/**
* Proxy of {@Link @azure/communication-calling#RecordingCallFeature.isRecordingActive}.
*/
isRecordingActive: boolean;
}
/**
* State only version of {@Link @azure/communication-calling#LocalVideoStream}.
*/
@ -162,6 +172,10 @@ export interface Call {
* {@Link Converter.getRemoteParticipantKey} to {@Link @azure/communication-calling#RemoteParticipant}
*/
remoteParticipantsEnded: Map<string, RemoteParticipant>;
/**
* Proxy of {@Link @azure/communication-calling#RecordingCallFeature}.
*/
recording: RecordingCallFeature;
/**
* Stores the local date when the call started on the client. This is not originally in the SDK but provided by the
* Declarative layer.

Просмотреть файл

@ -78,6 +78,7 @@ export class CallContext {
existingCall.isScreenSharingOn = call.isScreenSharingOn;
existingCall.localVideoStreams = call.localVideoStreams;
existingCall.remoteParticipants = call.remoteParticipants;
existingCall.recording.isRecordingActive = call.recording.isRecordingActive;
// We don't update the startTime and endTime if we are updating an existing active call
} else {
draft.calls.set(call.id, call);
@ -207,6 +208,17 @@ export class CallContext {
);
}
public setCallRecordingActive(callId: string, isRecordingActive: boolean): void {
this.setState(
produce(this._state, (draft: CallClientState) => {
const call = draft.calls.get(callId);
if (call) {
call.recording.isRecordingActive = isRecordingActive;
}
})
);
}
public setLocalVideoStreamRendererView(callId: string, view: VideoStreamRendererView | undefined): void {
this.setState(
produce(this._state, (draft: CallClientState) => {

Просмотреть файл

@ -1,6 +1,6 @@
// © Microsoft Corporation. All rights reserved.
import { Call, LocalVideoStream, RemoteParticipant } from '@azure/communication-calling';
import { Call, Features, LocalVideoStream, RemoteParticipant } from '@azure/communication-calling';
import { CallContext } from './CallContext';
import { CallIdRef } from './CallIdRef';
import {
@ -10,6 +10,7 @@ import {
} from './Converter';
import { InternalCallContext } from './InternalCallContext';
import { ParticipantSubscriber } from './ParticipantSubscriber';
import { RecordingSubscriber } from './RecordingSubscriber';
import { stopRenderVideo } from './StreamUtils';
/**
@ -23,6 +24,7 @@ export class CallSubscriber {
private _context: CallContext;
private _internalContext: InternalCallContext;
private _participantSubscribers: Map<string, ParticipantSubscriber>;
private _recordingSubscriber: RecordingSubscriber;
constructor(call: Call, context: CallContext, internalContext: InternalCallContext) {
this._call = call;
@ -31,6 +33,12 @@ export class CallSubscriber {
this._internalContext = internalContext;
this._participantSubscribers = new Map<string, ParticipantSubscriber>();
this._recordingSubscriber = new RecordingSubscriber(
this._callIdRef,
this._context,
this._call.api(Features.Recording)
);
this.subscribe();
}
@ -76,6 +84,8 @@ export class CallSubscriber {
if (localVideoStreams && localVideoStreams.length === 1) {
stopRenderVideo(this._context, this._internalContext, this._callIdRef.callId, localVideoStreams[0]);
}
this._recordingSubscriber.unsubscribe();
};
private addParticipantListener(participant: RemoteParticipant): void {

Просмотреть файл

@ -110,6 +110,7 @@ export function convertSdkCallToDeclarativeCall(call: SdkCall): DeclarativeCall
localVideoStreams: call.localVideoStreams.map(convertSdkLocalStreamToDeclarativeLocalStream),
remoteParticipants: declarativeRemoteParticipants,
remoteParticipantsEnded: new Map<string, DeclarativeRemoteParticipant>(),
recording: { isRecordingActive: false },
startTime: new Date(),
endTime: undefined
};

Просмотреть файл

@ -0,0 +1,37 @@
// © Microsoft Corporation. All rights reserved.
import { RecordingCallFeature } from '@azure/communication-calling';
import { CallContext } from './CallContext';
import { CallIdRef } from './CallIdRef';
export class RecordingSubscriber {
private _callIdRef: CallIdRef;
private _context: CallContext;
private _recording: RecordingCallFeature;
constructor(callIdRef: CallIdRef, context: CallContext, recording: RecordingCallFeature) {
this._callIdRef = callIdRef;
this._context = context;
this._recording = recording;
// If recording as already started when we joined the call, make sure it is reflected in state as there may not
// be an event for it.
if (this._recording.isRecordingActive) {
this._context.setCallRecordingActive(this._callIdRef.callId, this._recording.isRecordingActive);
}
this.subscribe();
}
private subscribe = (): void => {
this._recording.on('isRecordingActiveChanged', this.isAvailableChanged);
};
public unsubscribe = (): void => {
this._recording.off('isRecordingActiveChanged', this.isAvailableChanged);
};
private isAvailableChanged = (): void => {
this._context.setCallRecordingActive(this._callIdRef.callId, this._recording.isRecordingActive);
};
}

Просмотреть файл

@ -72,6 +72,7 @@ function createMockCall(mockCallId: string): Call {
localVideoStreams: [],
remoteParticipants: new Map<string, RemoteParticipant>(),
remoteParticipantsEnded: new Map<string, RemoteParticipant>(),
recording: { isRecordingActive: false },
startTime: new Date(),
endTime: undefined
};

Просмотреть файл

@ -2,10 +2,21 @@
import {
Call,
CallAgent,
CallApiFeature,
CallFeatureFactoryType,
Features,
IncomingCall,
LocalVideoStream,
PropertyChangedEvent,
RecordingCallFeature,
RemoteParticipant,
RemoteVideoStream
RemoteVideoStream,
TranscriptionCallFeature,
Transfer,
TransferCallFeature,
TransferRequestedEvent,
TransferToParticipant,
TransferToParticipantOptions
} from '@azure/communication-calling';
import EventEmitter from 'events';
@ -47,9 +58,47 @@ export class MockCommunicationUserCredential {
public dispose(): void {}
}
export function addMockEmitter(
object: MockCall | MockCallAgent | MockRemoteParticipant | MockRemoteVideoStream | MockIncomingCall
): any {
export class MockRecordingCallFeatureImpl implements RecordingCallFeature {
public name = 'Recording';
public isRecordingActive = false;
public emitter = new EventEmitter();
on(event: 'isRecordingActiveChanged', listener: PropertyChangedEvent): void {
this.emitter.on(event, listener);
}
off(event: 'isRecordingActiveChanged', listener: PropertyChangedEvent): void {
this.emitter.off(event, listener);
}
}
export class MockTransferCallFeatureImpl implements TransferCallFeature {
public name = 'Transfer';
public emitter = new EventEmitter();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
transfer(target: TransferToParticipant, transferOptions?: TransferToParticipantOptions): Transfer {
throw new Error('Method not implemented.');
}
on(event: 'transferRequested', listener: TransferRequestedEvent): void {
this.emitter.on(event, listener);
}
off(event: 'transferRequested', listener: TransferRequestedEvent): void {
this.emitter.off(event, listener);
}
}
export class MockTranscriptionCallFeatureImpl implements TranscriptionCallFeature {
public name = 'Transcription';
public isTranscriptionActive = false;
public emitter = new EventEmitter();
on(event: 'isTranscriptionActiveChanged', listener: PropertyChangedEvent): void {
this.emitter.on(event, listener);
}
off(event: 'isTranscriptionActiveChanged', listener: PropertyChangedEvent): void {
this.emitter.off(event, listener);
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function addMockEmitter(object: any): any {
object.emitter = new EventEmitter();
object.on = (event: any, listener: any): void => {
object.emitter.on(event, listener);
@ -67,7 +116,8 @@ export function createMockCall(mockCallId: string): MockCall {
const mockCall = {
id: mockCallId,
remoteParticipants: [] as ReadonlyArray<RemoteParticipant>,
localVideoStreams: [] as ReadonlyArray<LocalVideoStream>
localVideoStreams: [] as ReadonlyArray<LocalVideoStream>,
api: createMockApiFeatures(false, new Map<string, any>())
} as MockCall;
return addMockEmitter(mockCall);
}
@ -90,6 +140,36 @@ export function createMockRemoteVideoStream(mockIsAvailable: boolean): MockRemot
return addMockEmitter(mockRemoteVideoStream);
}
export const MOCK_RECORDING_NAME = 'Recording';
/**
* Create a mockApiFeatures function see {@Link @azure/communication-calling#Call.api}. If cache is passed in, will try
* to return existing cached TFeatures else create new TFeature, cache it, and then return it.
*
* @param isRecording
* @param cache
* @returns
*/
export function createMockApiFeatures(
isRecording: boolean,
cache: Map<string, any>
): <FeatureT extends CallApiFeature>(cls: CallFeatureFactoryType<FeatureT>) => FeatureT {
return <FeatureT extends CallApiFeature>(cls: CallFeatureFactoryType<FeatureT>): FeatureT => {
if (typeof cls === typeof Features.Recording) {
if (cache.has(MOCK_RECORDING_NAME)) {
return cache.get(MOCK_RECORDING_NAME);
}
const recording = addMockEmitter({
isRecordingActive: isRecording
});
cache.set(MOCK_RECORDING_NAME, recording);
return recording;
} else {
throw new Error('Not implemented');
}
};
}
function waitMilliseconds(duration: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {

Просмотреть файл

@ -9,5 +9,6 @@ export type {
LocalVideoStream,
RemoteVideoStream,
RemoteParticipant,
VideoStreamRendererView
VideoStreamRendererView,
RecordingCallFeature
} from './CallClientState';