diff --git a/package.json b/package.json index 000d3d9f3..79b8c00b4 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ ], "scripts": { "vercel-build": "npm run dev-setup && cd template && npm run web:build && cd .. && npm run copy-vercel", - "uikit": "rm -rf template/agora-rn-uikit && git clone https://github.com/AgoraIO-Community/appbuilder-ui-kit.git template/agora-rn-uikit && cd template/agora-rn-uikit && git checkout appbuilder-uikit-3.1.11", + "uikit": "rm -rf template/agora-rn-uikit && git clone https://github.com/AgoraIO-Community/appbuilder-ui-kit.git template/agora-rn-uikit && cd template/agora-rn-uikit && git checkout appbuilder-uikit-3.1.22", "deps": "cd template && npm i --force", "dev-setup": "npm run uikit && npm run deps && node devSetup.js", "web-build": "cd template && npm run web:build && cd .. && npm run copy-vercel", diff --git a/template/bridge/rtc/webNg/RtcEngine.ts b/template/bridge/rtc/webNg/RtcEngine.ts index 9801f768f..0ac68b0ac 100644 --- a/template/bridge/rtc/webNg/RtcEngine.ts +++ b/template/bridge/rtc/webNg/RtcEngine.ts @@ -45,7 +45,18 @@ import { type ScreenEncoderConfigurationPreset, type VideoEncoderConfiguration, } from '../../../src/app-state/useVideoQuality'; -import {getScreenshareReleaseOrigin} from '../../../src/subComponents/screenshare/screenshareJourney'; +import { + getScreenshareReleaseOrigin, + getScreenshareSessionBoundaryMessage, +} from '../../../src/subComponents/screenshare/screenshareJourney'; + +export type ScreenshareMediaDiagnostics = { + role: 'sender' | 'viewer'; + screenshareSessionId: string; + screenShareUid: UID; + stats: Record | null; + mediaTrack: MediaStreamTrack | null; +}; interface MediaDeviceInfo { readonly deviceId: string; @@ -190,6 +201,14 @@ interface ScreenStream { audio?: ILocalAudioTrack; video?: ILocalVideoTrack; } +interface ActiveScreenshareLifecycle { + audio?: ILocalAudioTrack; + video: ILocalVideoTrack; + joined: boolean; + cleaned: boolean; + cleanupPromise?: Promise; + journeyData: Record; +} interface RemoteStream { audio?: IRemoteAudioTrack; video?: IRemoteVideoTrack; @@ -253,13 +272,122 @@ export default class RtcEngine { public screenStream: ScreenStream = {}; public remoteStreams = new Map(); private inScreenshare: Boolean = false; - private isScreenshareCleanupInProgress = false; + private screenshareOperationState: + | 'inactive' + | 'starting' + | 'active' + | 'stopping' = 'inactive'; + private screenshareOperationGeneration = 0; + private activeScreenshareLifecycle: ActiveScreenshareLifecycle | null = null; private activeScreenshareJourneyContext: { screenshareSessionId?: string; recordingActive?: boolean; screenShareUid?: UID; stopActorUid?: UID; } | null = null; + private remoteScreenshareUids = new Set(); + + registerRemoteScreenshareUid(uid: UID) { + if (uid === undefined || uid === null) { + return; + } + this.remoteScreenshareUids.add(uid); + } + + private logRemoteScreenshareTermination( + uid: UID, + sdkEvent: 'user-unpublished' | 'user-left', + mediaType?: 'audio' | 'video', + ) { + if (!this.remoteScreenshareUids.has(uid)) { + return; + } + const videoTrack = this.remoteStreams?.get?.(uid)?.video; + let mediaTrack: MediaStreamTrack | null = null; + try { + mediaTrack = videoTrack?.getMediaStreamTrack?.() || null; + } catch (_) {} + logger.log( + LogSource.AgoraSDK, + 'Event', + `[SCREENSHARE_JOURNEY] receiver detected screen share stopped through RTC ${sdkEvent}`, + { + action: 'stop', + stage: 'receiver_rtc_termination', + outcome: 'detected', + role: 'viewer', + screenshareSessionId: 'remote-session-by-screen-uid', + screenShareUid: uid, + stopOrigin: 'remote_sender_or_connection', + sdkEvent, + mediaType, + mediaTrack: mediaTrack + ? { + readyState: mediaTrack.readyState, + enabled: mediaTrack.enabled, + muted: mediaTrack.muted, + } + : null, + }, + ); + this.remoteScreenshareUids.delete(uid); + } + + getScreenshareMediaDiagnostics(uid: UID): ScreenshareMediaDiagnostics { + const isSender = uid === 1; + if (!isSender) { + this.registerRemoteScreenshareUid(uid); + } + const videoTrack = isSender + ? this.screenStream?.video + : this.remoteStreams?.get?.(uid)?.video; + let stats: Record | null = null; + try { + stats = isSender + ? this.screenClient?.getLocalVideoStats?.() || null + : this.client?.getRemoteVideoStats?.()?.[uid] || null; + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'API', + '[SCREENSHARE_JOURNEY] screen share media health stats collection failed', + { + action: 'monitor', + stage: 'media_health', + outcome: 'stats_unavailable', + role: isSender ? 'sender' : 'viewer', + screenshareSessionId: isSender + ? this.activeScreenshareJourneyContext?.screenshareSessionId || + 'unknown-session' + : 'remote-session-by-screen-uid', + screenShareUid: isSender + ? this.activeScreenshareJourneyContext?.screenShareUid || + this.screenClient?.uid + : uid, + ...getScreenshareErrorDetails(error), + }, + ); + } + return { + role: isSender ? 'sender' : 'viewer', + screenshareSessionId: isSender + ? this.activeScreenshareJourneyContext?.screenshareSessionId || + 'unknown-session' + : 'remote-session-by-screen-uid', + screenShareUid: isSender + ? this.activeScreenshareJourneyContext?.screenShareUid || + this.screenClient?.uid + : uid, + stats, + mediaTrack: (() => { + try { + return videoTrack?.getMediaStreamTrack?.() || null; + } catch (_) { + return null; + } + })(), + }; + } private videoProfile: | VideoEncoderConfigurationPreset | VideoEncoderConfiguration; @@ -739,6 +867,7 @@ export default class RtcEngine { this.client.on('user-left', user => { logger.log(LogSource.AgoraSDK, 'Event', 'RTC [user-left]', user); const uid = user.uid; + this.logRemoteScreenshareTermination(uid, 'user-left'); if (this.remoteStreams.has(uid)) { this.remoteStreams.delete(uid); } @@ -819,6 +948,13 @@ export default class RtcEngine { user, mediaType, ); + if (mediaType === 'video') { + this.logRemoteScreenshareTermination( + user.uid, + 'user-unpublished', + mediaType, + ); + } if (mediaType === 'audio') { const data = this.remoteStreams.get(user.uid); try { @@ -1537,6 +1673,167 @@ export default class RtcEngine { console.error('Please use enableEncryption instead'); } + private async cleanupActiveScreenshare( + stopOrigin: string, + notifyScreenshareStopped: boolean, + screenshareAttemptId: string, + expectedLifecycle?: ActiveScreenshareLifecycle, + ): Promise { + const lifecycle = expectedLifecycle || this.activeScreenshareLifecycle; + const journeyData = { + ...(lifecycle?.journeyData || this.activeScreenshareJourneyContext || {}), + action: 'stop', + screenshareAttemptId, + stopOrigin, + }; + + if (!lifecycle) { + logger.log( + LogSource.AgoraSDK, + 'API', + `[SCREENSHARE_JOURNEY] screen share stop cleanup skipped because there is no active RTC lifecycle`, + { + ...journeyData, + stage: 'cleanup', + outcome: 'skipped', + operationState: this.screenshareOperationState, + duplicateReason: 'no_active_rtc_lifecycle', + }, + ); + if (notifyScreenshareStopped) { + await (this.eventsMap.get('onScreenshareStopped') as callbackType)( + stopOrigin, + screenshareAttemptId, + journeyData.screenshareSessionId || 'unknown-session', + journeyData.stopActorUid, + ); + } + this.inScreenshare = false; + this.screenshareOperationState = 'inactive'; + return; + } + + if ( + expectedLifecycle && + this.activeScreenshareLifecycle !== expectedLifecycle + ) { + logger.log( + LogSource.AgoraSDK, + 'API', + `[SCREENSHARE_JOURNEY] screen share stop ignored stale track-ended callback`, + { + ...journeyData, + stage: 'track_ended', + outcome: 'skipped', + operationState: this.screenshareOperationState, + duplicateReason: 'stale_screenshare_lifecycle', + }, + ); + return; + } + + if (lifecycle.cleanupPromise) { + logger.log( + LogSource.AgoraSDK, + 'API', + `[SCREENSHARE_JOURNEY] screen share stop joined cleanup already in progress`, + { + ...journeyData, + stage: 'cleanup', + outcome: 'skipped', + operationState: this.screenshareOperationState, + duplicateReason: 'cleanup_already_in_progress', + }, + ); + return lifecycle.cleanupPromise; + } + + this.screenshareOperationState = 'stopping'; + lifecycle.cleanupPromise = Promise.resolve().then(async () => { + let cleanupError: unknown; + const attemptCleanup = async (operation: () => unknown) => { + try { + await operation(); + } catch (error) { + cleanupError = cleanupError || error; + } + }; + try { + await attemptCleanup(() => + (this.eventsMap.get('onUserOffline') as callbackType)( + {}, + this.screenClient.uid, + ), + ); + await attemptCleanup(() => lifecycle.audio?.stop()); + await attemptCleanup(() => lifecycle.video?.stop()); + await attemptCleanup(() => lifecycle.audio?.close()); + await attemptCleanup(() => lifecycle.video?.close()); + if (lifecycle.joined) { + await attemptCleanup(() => this.screenClient.leave()); + } + if (cleanupError) { + logger.error( + LogSource.AgoraSDK, + 'API', + `[SCREENSHARE_JOURNEY] screen share stop RTC cleanup completed with errors`, + { + ...journeyData, + stage: 'cleanup', + outcome: 'partial_failure', + operationState: 'inactive', + ...getScreenshareErrorDetails(cleanupError), + }, + ); + } else { + logger.log( + LogSource.AgoraSDK, + 'API', + `[SCREENSHARE_JOURNEY] screen share stop RTC cleanup completed`, + { + ...journeyData, + stage: 'cleanup', + outcome: 'success', + operationState: 'inactive', + }, + ); + } + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'API', + `[SCREENSHARE_JOURNEY] screen share stop RTC cleanup failed`, + { + ...journeyData, + stage: 'cleanup', + outcome: 'failure', + operationState: this.screenshareOperationState, + ...getScreenshareErrorDetails(error), + }, + ); + throw error; + } finally { + lifecycle.cleaned = true; + if (this.activeScreenshareLifecycle === lifecycle) { + this.activeScreenshareLifecycle = null; + this.screenStream = {}; + this.inScreenshare = false; + this.screenshareOperationState = 'inactive'; + this.activeScreenshareJourneyContext = null; + if (notifyScreenshareStopped) { + await (this.eventsMap.get('onScreenshareStopped') as callbackType)( + stopOrigin, + screenshareAttemptId, + journeyData.screenshareSessionId || 'unknown-session', + journeyData.stopActorUid, + ); + } + } + } + }); + return await lifecycle.cleanupPromise; + } + // async removeAllListeners(event: EventType) { // this.client.removeAllListeners(eventName); // } @@ -1548,7 +1845,23 @@ export default class RtcEngine { requestedStopOrigin, typeof document !== 'undefined' ? document.visibilityState : undefined, ); - if (this.inScreenshare) { + if (this.screenshareOperationState === 'starting') { + this.screenshareOperationGeneration += 1; + this.screenshareOperationState = 'inactive'; + logger.log( + LogSource.AgoraSDK, + 'API', + `[SCREENSHARE_JOURNEY] screen share start cancelled by RTC release from ${stopOrigin}`, + { + action: 'start', + stage: 'release', + outcome: 'cancelled', + stopOrigin, + operationState: 'inactive', + }, + ); + } + if (this.activeScreenshareLifecycle || this.inScreenshare) { const screenshareAttemptId = `release-${Date.now()}`; const journeyData = { action: 'stop', @@ -1574,21 +1887,10 @@ export default class RtcEngine { journeyData, ); try { - (this.eventsMap.get('onUserOffline') as callbackType)( - {}, - this.screenClient.uid, - ); - this.screenStream.audio?.stop(); - this.screenStream.video?.stop(); - this.screenStream.audio?.close(); - this.screenStream.video?.close(); - await this.screenClient.leave(); - this.inScreenshare = false; - (this.eventsMap.get('onScreenshareStopped') as callbackType)( + await this.cleanupActiveScreenshare( stopOrigin, + true, screenshareAttemptId, - journeyData.screenshareSessionId, - journeyData.stopActorUid, ); logger.log( LogSource.AgoraSDK, @@ -1608,6 +1910,19 @@ export default class RtcEngine { }, ); } finally { + logger.log( + LogSource.AgoraSDK, + 'API', + getScreenshareSessionBoundaryMessage( + 'end', + journeyData.screenshareSessionId, + ), + { + ...journeyData, + stage: 'session_boundary', + outcome: 'ended', + }, + ); this.activeScreenshareJourneyContext = null; } } @@ -1696,280 +2011,353 @@ export default class RtcEngine { ...screenShareConfig, encoderConfig: this.screenShareProfile, }; - let joined = false; - let cleanupCompleted = false; - const cleanupScreenshare = async ( - stopOrigin: string, - notifyScreenshareStopped: boolean, - screenshareAttemptId = journeyData.screenshareAttemptId, - ) => { - const cleanupJourneyData = notifyScreenshareStopped - ? { - ...journeyData, - action: 'stop', - screenshareAttemptId, - stopOrigin, - } - : journeyData; - if (this.isScreenshareCleanupInProgress || cleanupCompleted) { - logger.log( - LogSource.AgoraSDK, - 'API', - `[SCREENSHARE_JOURNEY] screen share ${cleanupJourneyData.action} cleanup skipped because cleanup is already running or completed`, - { - ...cleanupJourneyData, - stage: 'cleanup', - outcome: 'skipped', - stopOrigin, - }, + if (journeyData.action === 'stop') { + logger.log( + LogSource.AgoraSDK, + 'API', + `[SCREENSHARE_JOURNEY] screen share stop entered RTC engine from ${journeyData.stopOrigin}`, + { + ...journeyData, + stage: 'rtc_stop', + outcome: 'started', + requestedAction: 'stop', + executedAction: 'stop', + operationState: this.screenshareOperationState, + }, + ); + await this.cleanupActiveScreenshare( + journeyData.stopOrigin, + true, + journeyData.screenshareAttemptId, + ); + return; + } + + if (this.screenshareOperationState !== 'inactive' || this.inScreenshare) { + const duplicateStartError = Object.assign( + new Error( + `Screen share start rejected while RTC state is ${this.screenshareOperationState}`, + ), + {code: 'SCREENSHARE_OPERATION_IN_PROGRESS'}, + ); + logger.log( + LogSource.AgoraSDK, + 'API', + `[SCREENSHARE_JOURNEY] screen share start rejected because RTC screen share is ${this.screenshareOperationState}`, + { + ...journeyData, + stage: 'precondition', + outcome: 'skipped', + requestedAction: 'start', + executedAction: 'none', + operationState: this.screenshareOperationState, + duplicateReason: `start_requested_while_${this.screenshareOperationState}`, + }, + ); + throw duplicateStartError; + } + + this.screenshareOperationState = 'starting'; + const operationGeneration = ++this.screenshareOperationGeneration; + const ensureStartIsCurrent = () => { + if (operationGeneration !== this.screenshareOperationGeneration) { + throw Object.assign( + new Error('Screen share start was cancelled by cleanup'), + {code: 'SCREENSHARE_START_CANCELLED'}, ); - return; } - this.isScreenshareCleanupInProgress = true; - try { - if (joined || this.inScreenshare) { - (this.eventsMap.get('onUserOffline') as callbackType)( - {}, - this.screenClient.uid, + }; + let stage = 'encryption'; + let joined = false; + let localVideoTrack: ILocalVideoTrack | undefined; + let localAudioTrack: ILocalAudioTrack | undefined; + try { + logger.debug( + LogSource.AgoraSDK, + 'Log', + '[SCREENSHARE_JOURNEY] screen share start entered RTC engine', + { + ...journeyData, + stage: 'rtc_start', + outcome: 'started', + requestedAction: 'start', + executedAction: 'start', + operationState: this.screenshareOperationState, + }, + ); + if (encryption && encryption.screenKey && encryption.mode) { + let mode: EncryptionMode; + mode = this.getEncryptionMode(true, encryption?.mode); + try { + /** + * Since version 4.7.0, if client leaves a call + * and joins again the encryption needs to be + * set again + */ + logger.log( + LogSource.AgoraSDK, + 'Log', + '[SCREENSHARE_JOURNEY] screen share start configuring RTC screen-client encryption', + {...journeyData, stage, outcome: 'started'}, ); - } - this.screenStream.audio?.stop(); - this.screenStream.video?.stop(); - this.screenStream.audio?.close(); - this.screenStream.video?.close(); - if (joined || this.inScreenshare) { - await this.screenClient.leave(); - } - this.screenStream = {}; - this.inScreenshare = false; - this.activeScreenshareJourneyContext = null; - if (notifyScreenshareStopped) { - (this.eventsMap.get('onScreenshareStopped') as callbackType)( - stopOrigin, - screenshareAttemptId, - cleanupJourneyData.screenshareSessionId, - cleanupJourneyData.stopActorUid, + await this.screenClient.setEncryptionConfig( + mode, + encryption.screenKey, + encryption.salt, + true, // encryptDataStream ); + ensureStartIsCurrent(); + logger.log( + LogSource.AgoraSDK, + 'Log', + '[SCREENSHARE_JOURNEY] screen share start RTC screen-client encryption configured successfully', + {...journeyData, stage, outcome: 'success'}, + ); + } catch (e) { + logger.error( + LogSource.AgoraSDK, + 'Log', + '[SCREENSHARE_JOURNEY] screen share start RTC screen-client encryption configuration failed', + { + ...journeyData, + stage, + outcome: 'failure', + ...getScreenshareErrorDetails(e), + }, + ); + throw e; } + } else { logger.log( - LogSource.AgoraSDK, - 'API', - `[SCREENSHARE_JOURNEY] screen share ${cleanupJourneyData.action} RTC cleanup completed`, - { - ...cleanupJourneyData, - stage: 'cleanup', - outcome: 'success', - stopOrigin, - }, - ); - cleanupCompleted = true; - } finally { - this.isScreenshareCleanupInProgress = false; - } - }; - if (!this.inScreenshare) { - let stage = 'encryption'; - try { - logger.debug( LogSource.AgoraSDK, 'Log', - '[SCREENSHARE_JOURNEY] screen share start entered RTC engine', - {...journeyData, stage: 'rtc_start', outcome: 'started'}, + '[SCREENSHARE_JOURNEY] screen share start RTC screen-client encryption skipped because encryption is not configured', + {...journeyData, stage, outcome: 'skipped'}, ); - if (encryption && encryption.screenKey && encryption.mode) { - let mode: EncryptionMode; - mode = this.getEncryptionMode(true, encryption?.mode); - try { - /** - * Since version 4.7.0, if client leaves a call - * and joins again the encryption needs to be - * set again - */ - logger.log( - LogSource.AgoraSDK, - 'Log', - '[SCREENSHARE_JOURNEY] screen share start configuring RTC screen-client encryption', - {...journeyData, stage, outcome: 'started'}, - ); - await this.screenClient.setEncryptionConfig( - mode, - encryption.screenKey, - encryption.salt, - true, // encryptDataStream - ); - logger.log( - LogSource.AgoraSDK, - 'Log', - '[SCREENSHARE_JOURNEY] screen share start RTC screen-client encryption configured successfully', - {...journeyData, stage, outcome: 'success'}, - ); - } catch (e) { - logger.error( - LogSource.AgoraSDK, - 'Log', - '[SCREENSHARE_JOURNEY] screen share start RTC screen-client encryption configuration failed', - { - ...journeyData, - stage, - outcome: 'failure', - ...getScreenshareErrorDetails(e), - }, - ); - throw e; - } - } else { + } + + stage = 'create_screen_video_track'; + logger.log( + LogSource.AgoraSDK, + 'API', + '[SCREENSHARE_JOURNEY] screen share start calling AgoraRTC.createScreenVideoTrack', + { + ...journeyData, + stage, + outcome: 'started', + config, + }, + ); + const screenTracks = await AgoraRTC.createScreenVideoTrack(config, audio); + const isSingleScreenTrack = this.isSingleTrack(screenTracks); + localVideoTrack = isSingleScreenTrack ? screenTracks : screenTracks[0]; + localAudioTrack = isSingleScreenTrack ? undefined : screenTracks[1]; + ensureStartIsCurrent(); + logger.log( + LogSource.AgoraSDK, + 'API', + '[SCREENSHARE_JOURNEY] screen share start AgoraRTC.createScreenVideoTrack completed successfully', + { + ...journeyData, + stage, + outcome: 'success', + hasVideoTrack: Boolean( + isSingleScreenTrack ? screenTracks : screenTracks[0], + ), + hasAudioTrack: Boolean(isSingleScreenTrack ? false : screenTracks[1]), + }, + ); + stage = 'rtc_join'; + logger.log( + LogSource.AgoraSDK, + 'API', + '[SCREENSHARE_JOURNEY] screen share start calling screenClient.join', + {...journeyData, stage, outcome: 'started', channelName, optionalUid}, + ); + await this.screenClient.join( + this.appId, + channelName, + token || null, + optionalUid || null, + ); + joined = true; + ensureStartIsCurrent(); + logger.log( + LogSource.AgoraSDK, + 'API', + '[SCREENSHARE_JOURNEY] screen share start screenClient.join completed successfully', + {...journeyData, stage, outcome: 'success'}, + ); + stage = 'rtc_publish'; + logger.log( + LogSource.AgoraSDK, + 'API', + '[SCREENSHARE_JOURNEY] screen share start calling screenClient.publish', + {...journeyData, stage, outcome: 'started'}, + ); + await this.screenClient.publish( + localAudioTrack ? [localVideoTrack, localAudioTrack] : localVideoTrack, + ); + ensureStartIsCurrent(); + logger.log( + LogSource.AgoraSDK, + 'API', + '[SCREENSHARE_JOURNEY] screen share start screenClient.publish completed successfully', + {...journeyData, stage, outcome: 'success'}, + ); + const lifecycle: ActiveScreenshareLifecycle = { + video: localVideoTrack, + audio: localAudioTrack, + joined, + cleaned: false, + journeyData, + }; + this.screenStream = { + video: localVideoTrack, + audio: localAudioTrack, + }; + this.activeScreenshareLifecycle = lifecycle; + this.inScreenshare = true; + this.screenshareOperationState = 'active'; + this.activeScreenshareJourneyContext = { + screenshareSessionId: journeyData.screenshareSessionId, + recordingActive: journeyData.recordingActive, + screenShareUid: journeyData.screenShareUid, + stopActorUid: journeyData.stopActorUid, + }; + localVideoTrack.on('track-ended', async () => { + const nativeStopAttemptId = `${ + journeyData.screenshareAttemptId + }-native-${Date.now()}`; + const mediaTrack = localVideoTrack?.getMediaStreamTrack?.(); + if (this.activeScreenshareLifecycle !== lifecycle) { logger.log( LogSource.AgoraSDK, - 'Log', - '[SCREENSHARE_JOURNEY] screen share start RTC screen-client encryption skipped because encryption is not configured', - {...journeyData, stage, outcome: 'skipped'}, + 'API', + '[SCREENSHARE_JOURNEY] screen share stop ignored stale video track-ended callback', + { + ...journeyData, + action: 'stop', + screenshareAttemptId: nativeStopAttemptId, + stage: 'track_ended', + outcome: 'skipped', + stopOrigin: 'browser_native_control', + operationState: this.screenshareOperationState, + duplicateReason: 'stale_screenshare_lifecycle', + }, ); + return; } - - stage = 'create_screen_video_track'; logger.log( LogSource.AgoraSDK, 'API', - '[SCREENSHARE_JOURNEY] screen share start calling AgoraRTC.createScreenVideoTrack', + '[SCREENSHARE_JOURNEY] screen share stop detected from browser native control through video track-ended', { ...journeyData, - stage, + action: 'stop', + screenshareAttemptId: nativeStopAttemptId, + stage: 'track_ended', outcome: 'started', - config, - }, - ); - const screenTracks = await AgoraRTC.createScreenVideoTrack( - config, - audio, - ); - const isSingleScreenTrack = this.isSingleTrack(screenTracks); - logger.log( - LogSource.AgoraSDK, - 'API', - '[SCREENSHARE_JOURNEY] screen share start AgoraRTC.createScreenVideoTrack completed successfully', - { - ...journeyData, - stage, - outcome: 'success', - hasVideoTrack: Boolean( - isSingleScreenTrack ? screenTracks : screenTracks[0], - ), - hasAudioTrack: Boolean( - isSingleScreenTrack ? false : screenTracks[1], - ), + stopOrigin: 'browser_native_control', + mediaTrack: mediaTrack + ? { + readyState: mediaTrack.readyState, + enabled: mediaTrack.enabled, + muted: mediaTrack.muted, + label: mediaTrack.label, + settings: mediaTrack.getSettings?.(), + } + : null, }, ); - if (isSingleScreenTrack) { - this.screenStream.video = screenTracks; - } else { - this.screenStream.video = screenTracks[0]; - this.screenStream.audio = screenTracks[1]; - } - stage = 'rtc_join'; - logger.log( - LogSource.AgoraSDK, - 'API', - '[SCREENSHARE_JOURNEY] screen share start calling screenClient.join', - {...journeyData, stage, outcome: 'started', channelName, optionalUid}, - ); - await this.screenClient.join( - this.appId, - channelName, - token || null, - optionalUid || null, - ); - joined = true; - this.inScreenshare = true; - logger.log( - LogSource.AgoraSDK, - 'API', - '[SCREENSHARE_JOURNEY] screen share start screenClient.join completed successfully', - {...journeyData, stage, outcome: 'success'}, - ); - stage = 'rtc_publish'; - logger.log( - LogSource.AgoraSDK, - 'API', - '[SCREENSHARE_JOURNEY] screen share start calling screenClient.publish', - {...journeyData, stage, outcome: 'started'}, - ); - await this.screenClient.publish( - this.screenStream.audio - ? [this.screenStream.video, this.screenStream.audio] - : this.screenStream.video, - ); - logger.log( - LogSource.AgoraSDK, - 'API', - '[SCREENSHARE_JOURNEY] screen share start screenClient.publish completed successfully', - {...journeyData, stage, outcome: 'success'}, - ); - this.activeScreenshareJourneyContext = { - screenshareSessionId: journeyData.screenshareSessionId, - recordingActive: journeyData.recordingActive, - screenShareUid: journeyData.screenShareUid, - stopActorUid: journeyData.stopActorUid, - }; - this.screenStream.video.on('track-ended', async () => { - const nativeStopAttemptId = `${ - journeyData.screenshareAttemptId - }-native-${Date.now()}`; - const mediaTrack = this.screenStream.video?.getMediaStreamTrack?.(); - logger.log( + try { + await this.cleanupActiveScreenshare( + 'browser_native_control', + true, + nativeStopAttemptId, + lifecycle, + ); + } catch (error) { + logger.error( LogSource.AgoraSDK, 'API', - '[SCREENSHARE_JOURNEY] screen share stop detected from browser native control through video track-ended', + '[SCREENSHARE_JOURNEY] screen share stop from browser native control completed with cleanup errors', { ...journeyData, action: 'stop', screenshareAttemptId: nativeStopAttemptId, - stage: 'track_ended', - outcome: 'started', + stage: 'cleanup', + outcome: 'failure', stopOrigin: 'browser_native_control', - mediaTrack: mediaTrack - ? { - readyState: mediaTrack.readyState, - enabled: mediaTrack.enabled, - muted: mediaTrack.muted, - label: mediaTrack.label, - settings: mediaTrack.getSettings?.(), - } - : null, + ...getScreenshareErrorDetails(error), }, ); - await cleanupScreenshare( - 'browser_native_control', - true, - nativeStopAttemptId, - ); - }); - } catch (e) { - logger.error( + } + logger.log( LogSource.AgoraSDK, 'API', - `[SCREENSHARE_JOURNEY] screen share start failed inside RTC engine at ${stage}`, + getScreenshareSessionBoundaryMessage( + 'end', + journeyData.screenshareSessionId, + ), { ...journeyData, - stage, - outcome: 'failure', - joined, - ...getScreenshareErrorDetails(e), + action: 'stop', + screenshareAttemptId: nativeStopAttemptId, + stage: 'session_boundary', + outcome: 'ended', + stopOrigin: 'browser_native_control', }, ); - if (joined || this.screenStream.video || this.screenStream.audio) { - await cleanupScreenshare('startup_failure', false); - } - throw e; - } - } else { - logger.log( + }); + } catch (e) { + logger.error( LogSource.AgoraSDK, 'API', - `[SCREENSHARE_JOURNEY] screen share stop entered RTC engine from ${journeyData.stopOrigin}`, - {...journeyData, stage: 'rtc_stop', outcome: 'started'}, + `[SCREENSHARE_JOURNEY] screen share start failed inside RTC engine at ${stage}`, + { + ...journeyData, + stage, + outcome: 'failure', + joined, + ...getScreenshareErrorDetails(e), + }, ); - await cleanupScreenshare(journeyData.stopOrigin, true); - this.activeScreenshareJourneyContext = null; + const cleanupOperations: Array<() => unknown> = [ + () => localAudioTrack?.stop(), + () => localVideoTrack?.stop(), + () => localAudioTrack?.close(), + () => localVideoTrack?.close(), + ]; + if (joined) { + cleanupOperations.push(() => this.screenClient.leave()); + } + try { + for (const cleanupOperation of cleanupOperations) { + try { + await cleanupOperation(); + } catch (cleanupError) { + logger.error( + LogSource.AgoraSDK, + 'API', + `[SCREENSHARE_JOURNEY] screen share start failure cleanup operation failed`, + { + ...journeyData, + stage: 'cleanup', + outcome: 'failure', + ...getScreenshareErrorDetails(cleanupError), + }, + ); + } + } + } finally { + this.screenStream = {}; + this.activeScreenshareLifecycle = null; + this.inScreenshare = false; + this.activeScreenshareJourneyContext = null; + this.screenshareOperationState = 'inactive'; + } + throw e; } } } diff --git a/template/bridge/rtc/webNg/RtcSurfaceView.tsx b/template/bridge/rtc/webNg/RtcSurfaceView.tsx index 9dc69df51..ee801d1f4 100644 --- a/template/bridge/rtc/webNg/RtcSurfaceView.tsx +++ b/template/bridge/rtc/webNg/RtcSurfaceView.tsx @@ -1,25 +1,30 @@ /* ******************************************** Copyright © 2021 Agora Lab, Inc., all rights reserved. - AppBuilder and all associated components, source code, APIs, services, and documentation - (the “Materials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be - accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. - Use without a license or in violation of any license terms and conditions (including use for - any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more - information visit https://appbuilder.agora.io. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the “Materials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. ********************************************* */ import {ILocalVideoTrack, IRemoteVideoTrack} from 'agora-rtc-sdk-ng'; -import React, {useEffect} from 'react'; +import React, {useEffect, useRef} from 'react'; import {StyleProp, StyleSheet, ViewProps, ViewStyle} from 'react-native'; import {RenderModeType} from './Types'; import {LogSource, logger} from '../../../src/logger/AppBuilderLogger'; +import {getScreenshareMediaHealthOutcome} from './screenshareMediaHealth'; + +let screenshareRenderInstanceCounter = 0; export interface RtcSurfaceViewProps extends ViewProps { canvas: { renderMode?: RenderModeType; uid?: number; + isScreenshare?: boolean; + screenshareSurface?: 'min' | 'max' | 'unknown'; }; } @@ -30,28 +35,210 @@ export interface StyleProps { interface SurfaceViewInterface extends RtcSurfaceViewProps, StyleProps {} const RtcSurfaceView = (props: SurfaceViewInterface) => { - const {uid, renderMode} = props.canvas; + const { + uid, + renderMode, + isScreenshare = false, + screenshareSurface = renderMode === RenderModeType.RenderModeHidden + ? 'min' + : 'max', + } = props.canvas; + const renderInstanceIdRef = useRef(null); + if (!renderInstanceIdRef.current) { + renderInstanceIdRef.current = `screenshare-render-${++screenshareRenderInstanceCounter}`; + } + const renderInstanceId = renderInstanceIdRef.current; const stream: ILocalVideoTrack | IRemoteVideoTrack = uid === 0 - ? window.engine.localStream.video + ? window.engine?.localStream?.video : uid === 1 - ? window.engine.screenStream.video - : window.engine.remoteStreams.get(uid)?.video; + ? window.engine?.screenStream?.video + : window.engine?.remoteStreams?.get?.(uid)?.video; useEffect( function () { + if (isScreenshare && uid !== undefined && uid !== 1) { + window.engine?.registerRemoteScreenshareUid?.(uid); + } if (stream?.play) { - if (renderMode === RenderModeType.RenderModeFit) { - stream.play(String(uid), {fit: 'contain'}); - } else { - stream.play(String(uid)); + try { + if (renderMode === RenderModeType.RenderModeFit) { + stream.play(String(uid), {fit: 'contain'}); + } else { + stream.play(String(uid)); + } + if (isScreenshare) { + logger.log( + LogSource.AgoraSDK, + 'API', + '[SCREENSHARE_JOURNEY] screen share video surface mounted and track.play invoked', + { + action: 'monitor', + stage: 'video_play', + outcome: 'invoked', + screenShareUid: uid, + role: uid === 1 ? 'sender' : 'viewer', + screenshareSurface, + renderInstanceId, + }, + ); + } + } catch (error) { + if (isScreenshare) { + logger.error( + LogSource.AgoraSDK, + 'API', + '[SCREENSHARE_JOURNEY] screen share video track play failed', + { + action: 'monitor', + stage: 'video_play', + outcome: 'failure', + screenShareUid: uid, + role: uid === 1 ? 'sender' : 'viewer', + screenshareSurface, + renderInstanceId, + sdkErrorName: (error as Error)?.name, + sdkErrorMessage: + (error as Error)?.message || String(error || ''), + }, + ); + } } } + let healthTimer: ReturnType | undefined; + let consecutiveUnhealthySamples = 0; + let lastLoggedOutcome: string | undefined; + if (isScreenshare && stream) { + const checkHealth = () => { + const diagnostics = + window.engine?.getScreenshareMediaDiagnostics?.(uid); + if (!diagnostics) { + return; + } + const stats: any = diagnostics?.stats || {}; + const videoElement = document + .getElementById(String(uid)) + ?.querySelector('video'); + const isSender = diagnostics.role === 'sender'; + const outcome = getScreenshareMediaHealthOutcome({ + trackReadyState: diagnostics.mediaTrack?.readyState, + bitrate: isSender ? stats.sendBitrate : stats.receiveBitrate, + frameRate: isSender + ? stats.sendFrameRate || stats.captureFrameRate + : stats.renderFrameRate || + stats.decodeFrameRate || + stats.receiveFrameRate, + width: isSender + ? stats.sendResolutionWidth || stats.captureResolutionWidth + : stats.receiveResolutionWidth, + height: isSender + ? stats.sendResolutionHeight || stats.captureResolutionHeight + : stats.receiveResolutionHeight, + videoElementPresent: Boolean(videoElement), + renderedWidth: videoElement?.videoWidth, + renderedHeight: videoElement?.videoHeight, + }); + consecutiveUnhealthySamples = + outcome === 'healthy' ? 0 : consecutiveUnhealthySamples + 1; + const reportableOutcome = + outcome === 'healthy' + ? lastLoggedOutcome && lastLoggedOutcome !== 'healthy' + ? 'recovered' + : 'healthy' + : consecutiveUnhealthySamples >= 2 + ? outcome + : 'checking'; + if ( + reportableOutcome !== 'checking' && + reportableOutcome !== lastLoggedOutcome + ) { + logger[ + reportableOutcome === 'healthy' || + reportableOutcome === 'recovered' + ? 'log' + : 'warn' + ]( + LogSource.AgoraSDK, + 'API', + `[SCREENSHARE_JOURNEY] screen share media health ${reportableOutcome}`, + { + action: 'monitor', + stage: 'media_health', + outcome: reportableOutcome, + role: diagnostics.role, + screenshareSessionId: diagnostics.screenshareSessionId, + screenShareUid: diagnostics.screenShareUid, + screenshareSurface, + renderInstanceId, + stats, + mediaTrack: diagnostics.mediaTrack + ? { + readyState: diagnostics.mediaTrack.readyState, + enabled: diagnostics.mediaTrack.enabled, + muted: diagnostics.mediaTrack.muted, + } + : null, + render: { + videoElementPresent: Boolean(videoElement), + readyState: videoElement?.readyState, + paused: videoElement?.paused, + videoWidth: videoElement?.videoWidth, + videoHeight: videoElement?.videoHeight, + }, + }, + ); + lastLoggedOutcome = reportableOutcome; + } + }; + healthTimer = setInterval(checkHealth, 5000); + return () => { + clearInterval(healthTimer); + let mediaTrack: MediaStreamTrack | null = null; + try { + mediaTrack = stream?.getMediaStreamTrack?.() || null; + } catch (_) {} + logger.log( + LogSource.AgoraSDK, + 'Event', + '[SCREENSHARE_JOURNEY] screen share video render unmounted', + { + action: 'stop', + stage: 'video_render_cleanup', + outcome: 'unmounted', + role: uid === 1 ? 'sender' : 'viewer', + screenshareSessionId: + uid === 1 + ? window.engine?.getScreenshareMediaDiagnostics?.(uid) + ?.screenshareSessionId || 'unknown-session' + : 'remote-session-by-screen-uid', + screenShareUid: uid, + screenshareSurface, + renderInstanceId, + playbackCleanup: 'track_playback_stopped', + mediaTrack: mediaTrack + ? { + readyState: mediaTrack.readyState, + enabled: mediaTrack.enabled, + muted: mediaTrack.muted, + } + : null, + }, + ); + stream?.stop?.(); + }; + } return () => { console.log(`unmounting stream ${uid}`, stream); stream && stream.stop(); }; }, - [uid, renderMode, stream], + [ + uid, + renderMode, + stream, + isScreenshare, + screenshareSurface, + renderInstanceId, + ], ); return stream ? ( diff --git a/template/bridge/rtc/webNg/__tests__/screenshareLifecycle.test.ts b/template/bridge/rtc/webNg/__tests__/screenshareLifecycle.test.ts new file mode 100644 index 000000000..93a54d4da --- /dev/null +++ b/template/bridge/rtc/webNg/__tests__/screenshareLifecycle.test.ts @@ -0,0 +1,358 @@ +const mockCreateScreenVideoTrack = jest.fn(); + +jest.mock('agora-rtc-sdk-ng', () => ({ + __esModule: true, + default: { + createScreenVideoTrack: mockCreateScreenVideoTrack, + setArea: jest.fn(), + setLogLevel: jest.fn(), + enableLogUpload: jest.fn(), + disableLogUpload: jest.fn(), + }, +})); + +jest.mock('react-native-agora', () => ({})); + +jest.mock('../../../../agora-rn-uikit', () => ({ + ChannelProfileType: { + ChannelProfileCommunication: 0, + ChannelProfileLiveBroadcasting: 1, + }, + ClientRoleType: { + ClientRoleBroadcaster: 1, + ClientRoleAudience: 2, + }, +})); + +jest.mock('../../../../src/logger/AppBuilderLogger', () => ({ + LogSource: {AgoraSDK: 'AgoraSDK'}, + logger: { + log: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})); + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +}; + +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return {promise, resolve, reject}; +}; + +const createTrack = () => { + const handlers: Record Promise> = {}; + return { + handlers, + track: { + on: jest.fn((event: string, handler: () => Promise) => { + handlers[event] = handler; + }), + stop: jest.fn(), + close: jest.fn(), + getMediaStreamTrack: jest.fn(() => ({ + readyState: 'ended', + enabled: true, + muted: false, + label: 'screen', + getSettings: () => ({}), + })), + }, + }; +}; + +describe('web RTC screen-share lifecycle', () => { + let RtcEngine: any; + let engine: any; + let screenClient: any; + + const callScreenshare = (action: 'start' | 'stop', sessionId = 'session-1') => + engine.startScreenshare( + 'token', + 'channel', + null, + 101, + 'app-id', + engine, + null, + {encoderConfig: '1080p_2'}, + 'auto', + { + action, + screenshareAttemptId: `${action}-request`, + screenshareSessionId: sessionId, + screenShareUid: 101, + stopOrigin: 'toolbar', + }, + ); + + beforeAll(() => { + (global as any).window = {}; + RtcEngine = require('../RtcEngine').default; + }); + + beforeEach(() => { + jest.clearAllMocks(); + engine = new RtcEngine(); + engine.initialize({appId: 'app-id'}); + screenClient = { + uid: 101, + join: jest.fn().mockResolvedValue(101), + publish: jest.fn().mockResolvedValue(undefined), + leave: jest.fn().mockResolvedValue(undefined), + setEncryptionConfig: jest.fn().mockResolvedValue(undefined), + }; + engine.screenClient = screenClient; + engine.client = {off: jest.fn()}; + }); + + it('rejects a second Start before it can open another picker', async () => { + const picker = deferred(); + const {track} = createTrack(); + mockCreateScreenVideoTrack.mockReturnValueOnce(picker.promise); + + const firstStart = callScreenshare('start'); + await expect( + callScreenshare('start', 'duplicate-session'), + ).rejects.toMatchObject({code: 'SCREENSHARE_OPERATION_IN_PROGRESS'}); + + expect(mockCreateScreenVideoTrack).toHaveBeenCalledTimes(1); + picker.resolve(track); + await firstStart; + expect(screenClient.join).toHaveBeenCalledTimes(1); + expect(screenClient.publish).toHaveBeenCalledTimes(1); + }); + + it('executes an explicit Stop as a no-op when RTC is inactive', async () => { + const stopped = jest.fn(); + engine.eventsMap.set('onScreenshareStopped', stopped); + + await callScreenshare('stop'); + + expect(mockCreateScreenVideoTrack).not.toHaveBeenCalled(); + expect(screenClient.join).not.toHaveBeenCalled(); + expect(screenClient.leave).not.toHaveBeenCalled(); + expect(stopped).toHaveBeenCalledTimes(1); + }); + + it('cleans only locally created tracks when Start fails', async () => { + const {track} = createTrack(); + mockCreateScreenVideoTrack.mockResolvedValueOnce(track); + screenClient.join.mockRejectedValueOnce( + Object.assign(new Error('already connected'), { + code: 'INVALID_OPERATION', + }), + ); + + await expect(callScreenshare('start')).rejects.toMatchObject({ + code: 'INVALID_OPERATION', + }); + + expect(track.stop).toHaveBeenCalledTimes(1); + expect(track.close).toHaveBeenCalledTimes(1); + expect(screenClient.leave).not.toHaveBeenCalled(); + expect(engine.screenStream).toEqual({}); + }); + + it('leaves and closes local tracks when publish fails', async () => { + const {track} = createTrack(); + mockCreateScreenVideoTrack.mockResolvedValueOnce(track); + screenClient.publish.mockRejectedValueOnce(new Error('publish failed')); + + await expect(callScreenshare('start')).rejects.toThrow('publish failed'); + + expect(track.stop).toHaveBeenCalledTimes(1); + expect(track.close).toHaveBeenCalledTimes(1); + expect(screenClient.leave).toHaveBeenCalledTimes(1); + expect(engine.screenStream).toEqual({}); + }); + + it('preserves the publish error and attempts every cleanup operation when a track cleanup throws', async () => { + const {track} = createTrack(); + const publishError = new Error('publish failed'); + track.stop.mockImplementationOnce(() => { + throw new Error('track stop failed'); + }); + mockCreateScreenVideoTrack.mockResolvedValueOnce(track); + screenClient.publish.mockRejectedValueOnce(publishError); + + await expect(callScreenshare('start')).rejects.toBe(publishError); + + expect(track.stop).toHaveBeenCalledTimes(1); + expect(track.close).toHaveBeenCalledTimes(1); + expect(screenClient.leave).toHaveBeenCalledTimes(1); + expect(engine.screenStream).toEqual({}); + expect(engine.inScreenshare).toBe(false); + }); + + it('returns to inactive after picker cancellation so a later Start can run', async () => { + mockCreateScreenVideoTrack.mockRejectedValueOnce( + Object.assign(new Error('permission denied'), { + code: 'PERMISSION_DENIED', + }), + ); + + await expect(callScreenshare('start')).rejects.toMatchObject({ + code: 'PERMISSION_DENIED', + }); + + const {track} = createTrack(); + mockCreateScreenVideoTrack.mockResolvedValueOnce(track); + await callScreenshare('start', 'session-2'); + + expect(mockCreateScreenVideoTrack).toHaveBeenCalledTimes(2); + expect(screenClient.join).toHaveBeenCalledTimes(1); + expect(screenClient.publish).toHaveBeenCalledTimes(1); + expect(screenClient.leave).not.toHaveBeenCalled(); + }); + + it('does not publish a pending Start after end-call release', async () => { + const picker = deferred(); + const {track} = createTrack(); + mockCreateScreenVideoTrack.mockReturnValueOnce(picker.promise); + + const pendingStart = callScreenshare('start'); + await engine.release('end_call_cleanup'); + picker.resolve(track); + + await expect(pendingStart).rejects.toMatchObject({ + code: 'SCREENSHARE_START_CANCELLED', + }); + expect(track.stop).toHaveBeenCalledTimes(1); + expect(track.close).toHaveBeenCalledTimes(1); + expect(screenClient.join).not.toHaveBeenCalled(); + expect(screenClient.publish).not.toHaveBeenCalled(); + }); + + it('ignores a stale track-ended callback after app cleanup', async () => { + const {track, handlers} = createTrack(); + const stopped = jest.fn(); + engine.eventsMap.set('onScreenshareStopped', stopped); + mockCreateScreenVideoTrack.mockResolvedValueOnce(track); + + await callScreenshare('start'); + await callScreenshare('stop'); + await handlers['track-ended'](); + + expect(screenClient.leave).toHaveBeenCalledTimes(1); + expect(track.stop).toHaveBeenCalledTimes(1); + expect(track.close).toHaveBeenCalledTimes(1); + expect(stopped).toHaveBeenCalledTimes(1); + }); + + it('joins an existing cleanup when Stop is requested twice', async () => { + const {track} = createTrack(); + const leave = deferred(); + const stopped = jest.fn(); + engine.eventsMap.set('onScreenshareStopped', stopped); + mockCreateScreenVideoTrack.mockResolvedValueOnce(track); + screenClient.leave.mockReturnValueOnce(leave.promise); + + await callScreenshare('start'); + const firstStop = callScreenshare('stop'); + const secondStop = callScreenshare('stop'); + leave.resolve(undefined); + await Promise.all([firstStop, secondStop]); + + expect(screenClient.leave).toHaveBeenCalledTimes(1); + expect(track.stop).toHaveBeenCalledTimes(1); + expect(track.close).toHaveBeenCalledTimes(1); + expect(stopped).toHaveBeenCalledTimes(1); + }); + + it('attempts leave and the stopped callback when track cleanup fails', async () => { + const {track} = createTrack(); + const stopped = jest.fn(); + engine.eventsMap.set('onScreenshareStopped', stopped); + mockCreateScreenVideoTrack.mockResolvedValueOnce(track); + track.stop.mockImplementationOnce(() => { + throw new Error('track stop failed'); + }); + + await callScreenshare('start'); + await expect(callScreenshare('stop')).resolves.toBeUndefined(); + + expect(track.close).toHaveBeenCalledTimes(1); + expect(screenClient.leave).toHaveBeenCalledTimes(1); + expect(stopped).toHaveBeenCalledTimes(1); + expect(engine.inScreenshare).toBe(false); + }); + + it('handles leave rejection from native track-ended without rejecting the SDK callback', async () => { + const {track, handlers} = createTrack(); + const stopped = jest.fn(); + engine.eventsMap.set('onScreenshareStopped', stopped); + mockCreateScreenVideoTrack.mockResolvedValueOnce(track); + screenClient.leave.mockRejectedValueOnce(new Error('leave failed')); + + await callScreenshare('start'); + await expect(handlers['track-ended']()).resolves.toBeUndefined(); + + expect(screenClient.leave).toHaveBeenCalledTimes(1); + expect(stopped).toHaveBeenCalledTimes(1); + expect(engine.inScreenshare).toBe(false); + const {logger} = require('../../../../src/logger/AppBuilderLogger'); + const journeyMessages = logger.log.mock.calls + .map((call: unknown[]) => call[2]) + .filter((message: unknown) => + String(message).includes('[SCREENSHARE_JOURNEY]'), + ); + expect(journeyMessages[journeyMessages.length - 1]).toBe( + '----- [SCREENSHARE_JOURNEY] SCREEN SHARE SESSION END | sessionId=session-1 -----', + ); + }); + + it('writes the session end boundary after release cleanup logs', async () => { + const {track} = createTrack(); + const stopped = jest.fn(); + engine.eventsMap.set('onScreenshareStopped', stopped); + mockCreateScreenVideoTrack.mockResolvedValueOnce(track); + + await callScreenshare('start'); + await engine.release('end_call_cleanup'); + + const {logger} = require('../../../../src/logger/AppBuilderLogger'); + const journeyMessages = logger.log.mock.calls + .map((call: unknown[]) => call[2]) + .filter((message: unknown) => + String(message).includes('[SCREENSHARE_JOURNEY]'), + ); + expect(journeyMessages[journeyMessages.length - 1]).toBe( + '----- [SCREENSHARE_JOURNEY] SCREEN SHARE SESSION END | sessionId=session-1 -----', + ); + }); + + it('logs a receiver screen-share termination once for an RTC event', () => { + const remoteTrack = createTrack().track; + engine.remoteStreams.set(202, {video: remoteTrack}); + engine.registerRemoteScreenshareUid(202); + + engine.logRemoteScreenshareTermination(202, 'user-unpublished', 'video'); + engine.logRemoteScreenshareTermination(202, 'user-left'); + + const {logger} = require('../../../../src/logger/AppBuilderLogger'); + const terminationLogs = logger.log.mock.calls.filter((call: unknown[]) => + String(call[2]).includes( + 'receiver detected screen share stopped through RTC', + ), + ); + expect(terminationLogs).toHaveLength(1); + expect(terminationLogs[0][2]).toContain('user-unpublished'); + expect(terminationLogs[0][3]).toMatchObject({ + action: 'stop', + stage: 'receiver_rtc_termination', + role: 'viewer', + screenShareUid: 202, + sdkEvent: 'user-unpublished', + }); + }); +}); diff --git a/template/bridge/rtc/webNg/__tests__/screenshareMediaHealth.test.ts b/template/bridge/rtc/webNg/__tests__/screenshareMediaHealth.test.ts new file mode 100644 index 000000000..224b2aba6 --- /dev/null +++ b/template/bridge/rtc/webNg/__tests__/screenshareMediaHealth.test.ts @@ -0,0 +1,49 @@ +import {getScreenshareMediaHealthOutcome} from '../screenshareMediaHealth'; + +describe('screen-share media health classification', () => { + it('classifies flowing and rendered video as healthy', () => { + expect( + getScreenshareMediaHealthOutcome({ + trackReadyState: 'live', + bitrate: 900000, + frameRate: 15, + width: 1920, + height: 1080, + videoElementPresent: true, + renderedWidth: 1920, + renderedHeight: 1080, + }), + ).toBe('healthy'); + }); + + it('classifies zero bitrate or frames as suspected blank video', () => { + expect( + getScreenshareMediaHealthOutcome({ + trackReadyState: 'live', + bitrate: 0, + frameRate: 0, + width: 1920, + height: 1080, + }), + ).toBe('suspected_blank'); + }); + + it('classifies a missing render element as suspected blank video', () => { + expect( + getScreenshareMediaHealthOutcome({ + trackReadyState: 'live', + bitrate: 900000, + frameRate: 15, + width: 1920, + height: 1080, + videoElementPresent: false, + }), + ).toBe('suspected_blank'); + }); + + it('reports an ended source track separately', () => { + expect(getScreenshareMediaHealthOutcome({trackReadyState: 'ended'})).toBe( + 'track_ended', + ); + }); +}); diff --git a/template/bridge/rtc/webNg/screenshareMediaHealth.ts b/template/bridge/rtc/webNg/screenshareMediaHealth.ts new file mode 100644 index 000000000..8145ee03e --- /dev/null +++ b/template/bridge/rtc/webNg/screenshareMediaHealth.ts @@ -0,0 +1,49 @@ +export type ScreenshareMediaHealthOutcome = + | 'healthy' + | 'suspected_blank' + | 'track_ended' + | 'stats_unavailable'; + +export const getScreenshareMediaHealthOutcome = ({ + trackReadyState, + bitrate, + frameRate, + width, + height, + videoElementPresent = true, + renderedWidth, + renderedHeight, +}: { + trackReadyState?: MediaStreamTrackState; + bitrate?: number; + frameRate?: number; + width?: number; + height?: number; + videoElementPresent?: boolean; + renderedWidth?: number; + renderedHeight?: number; +}): ScreenshareMediaHealthOutcome => { + if (trackReadyState === 'ended') { + return 'track_ended'; + } + if ( + bitrate === undefined && + frameRate === undefined && + width === undefined && + height === undefined + ) { + return 'stats_unavailable'; + } + if ( + !videoElementPresent || + !bitrate || + !frameRate || + !width || + !height || + renderedWidth === 0 || + renderedHeight === 0 + ) { + return 'suspected_blank'; + } + return 'healthy'; +}; diff --git a/template/src/components/Controls.tsx b/template/src/components/Controls.tsx index b4e64986d..0fbe1630c 100644 --- a/template/src/components/Controls.tsx +++ b/template/src/components/Controls.tsx @@ -344,8 +344,12 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { } = useRoomInfo(); const {setShowInvitePopup, setShowStopRecordingPopup, setShowLayoutOption} = useVideoCall(); - const {isScreenshareActive, startScreenshare, stopScreenshare} = - useScreenshare(); + const { + isScreenshareActive, + operationState = isScreenshareActive ? 'active' : 'inactive', + startScreenshare, + stopScreenshare, + } = useScreenshare(); const {isRecordingActive, startRecording, inProgress, deleteRecording} = useRecording(); const {setChatType} = useChatUIControls(); @@ -705,10 +709,12 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { componentName: 'screenshare', order: 8, disabled: - rtcProps.role == ClientRoleType.ClientRoleAudience && - $config.EVENT_MODE && - $config.RAISE_HAND && - !isHost, + operationState === 'starting' || + operationState === 'stopping' || + (rtcProps.role == ClientRoleType.ClientRoleAudience && + $config.EVENT_MODE && + $config.RAISE_HAND && + !isHost), icon: isScreenshareActive ? 'stop-screen-share' : 'screen-share', iconColor: isScreenshareActive ? $config.SEMANTIC_ERROR diff --git a/template/src/logger/transports/__tests__/agora-transport.test.ts b/template/src/logger/transports/__tests__/agora-transport.test.ts new file mode 100644 index 000000000..8e9582c44 --- /dev/null +++ b/template/src/logger/transports/__tests__/agora-transport.test.ts @@ -0,0 +1,48 @@ +const mockDatadogLog = jest.fn(); + +jest.mock('@datadog/browser-logs', () => ({ + datadogLogs: { + init: jest.fn(), + logger: {log: mockDatadogLog}, + }, +})); + +const {getTransportLogger} = jest.requireActual('../agora-transport.ts'); + +describe('Agora Datadog transport structured content', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('emits screen-share journey fields as an object that Datadog can filter', () => { + const transport = getTransportLogger(); + const journey = { + screenshareSessionId: 'screen-session-1', + screenshareAttemptId: 'attempt-1', + action: 'start', + }; + + transport('[SCREENSHARE_JOURNEY] start', 'info', {}, {}, [journey]); + + expect(mockDatadogLog).toHaveBeenCalledWith( + '[SCREENSHARE_JOURNEY] start', + expect.objectContaining({logContent: journey}), + 'info', + undefined, + ); + }); + + it('preserves the existing array shape for unrelated logs', () => { + const transport = getTransportLogger(); + const content = [{value: 'unchanged'}]; + + transport('other log', 'info', {}, {}, content); + + expect(mockDatadogLog).toHaveBeenCalledWith( + 'other log', + expect.objectContaining({logContent: content}), + 'info', + undefined, + ); + }); +}); diff --git a/template/src/logger/transports/agora-transport.ts b/template/src/logger/transports/agora-transport.ts index fa3308099..3934eaafd 100644 --- a/template/src/logger/transports/agora-transport.ts +++ b/template/src/logger/transports/agora-transport.ts @@ -22,15 +22,27 @@ export const getTransportLogger = () => { contextInfo: Object, logContent: any[], ) => { + const primaryLogContent = + logContent?.length === 1 && + logContent[0] && + typeof logContent[0] === 'object' + ? logContent[0] + : undefined; + const datadogLogContent = primaryLogContent?.screenshareSessionId + ? primaryLogContent + : logContent; + datadogLogs.logger.log( logMessage, - {...columns, logMessage, logType, contextInfo, logContent}, + { + ...columns, + logMessage, + logType, + contextInfo, + logContent: datadogLogContent, + }, logType, - logType === 'error' - ? logContent?.length - ? logContent[0] - : undefined - : undefined, + logType === 'error' ? primaryLogContent : undefined, ); }; }; diff --git a/template/src/rtm-events-api/Events.ts b/template/src/rtm-events-api/Events.ts index fb921f8f0..13302fc14 100644 --- a/template/src/rtm-events-api/Events.ts +++ b/template/src/rtm-events-api/Events.ts @@ -361,7 +361,7 @@ class Events { ) => { try { if (!this._validateEvt(eventName)) { - return; + return false; } } catch (error) { logger.error( @@ -407,6 +407,7 @@ class Events { } else { await this._send(rtmPayload, receiver); } + return true; } catch (error) { logger.error( LogSource.Events, @@ -415,6 +416,7 @@ class Events { error, ); // don't throw - just log the error, application should continue running + return false; } }; } diff --git a/template/src/rtm-events-api/__tests__/Events.test.ts b/template/src/rtm-events-api/__tests__/Events.test.ts new file mode 100644 index 000000000..00cffe5d3 --- /dev/null +++ b/template/src/rtm-events-api/__tests__/Events.test.ts @@ -0,0 +1,55 @@ +const mockPublish = jest.fn(); + +jest.mock('../../rtm/RTMEngine', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + isEngineReady: true, + localUid: '101', + channelUid: 'channel-1', + engine: { + publish: mockPublish, + storage: {setUserMetadata: jest.fn().mockResolvedValue(undefined)}, + }, + }), + }, +})); + +jest.mock('../../logger/AppBuilderLogger', () => ({ + LogSource: {Events: 'Events'}, + logger: { + log: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('../../../bridge/rtm/web/Types', () => ({ + nativeChannelTypeMapping: {MESSAGE: 0, USER: 1}, +})); + +jest.mock('../../rtm/utils', () => ({adjustUID: (uid: number) => uid})); + +import Events from '../Events'; + +describe('Events.send delivery result', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns true when RTM publish succeeds', async () => { + mockPublish.mockResolvedValueOnce(undefined); + + await expect(new Events().send('screenshare', 'started')).resolves.toBe( + true, + ); + }); + + it('returns false without throwing when RTM publish fails', async () => { + mockPublish.mockRejectedValueOnce(new Error('network unavailable')); + + await expect(new Events().send('screenshare', 'started')).resolves.toBe( + false, + ); + }); +}); diff --git a/template/src/subComponents/screenshare/ScreenshareButton.tsx b/template/src/subComponents/screenshare/ScreenshareButton.tsx index 2ff1a3792..325860459 100644 --- a/template/src/subComponents/screenshare/ScreenshareButton.tsx +++ b/template/src/subComponents/screenshare/ScreenshareButton.tsx @@ -10,6 +10,7 @@ ********************************************* */ import React, {useContext} from 'react'; +import {StyleSheet, View} from 'react-native'; import IconButton, {IconButtonProps} from '../../atoms/IconButton'; import {useString} from '../../utils/useString'; import {useScreenshare} from './useScreenshare'; @@ -48,12 +49,21 @@ const ScreenshareButton = (props: ScreenshareButtonProps) => { } = useRoomInfo(); const local = useLocalUserInfo(); const isHandRaised = useIsHandRaised(); - const {isScreenshareActive, startScreenshare, stopScreenshare} = - useScreenshare(); + const { + isScreenshareActive, + operationState = isScreenshareActive ? 'active' : 'inactive', + startScreenshare, + stopScreenshare, + } = useScreenshare(); + const isScreenshareTransitioning = + operationState === 'starting' || operationState === 'stopping'; const {setShowStartScreenSharePopup} = useVideoCall(); const screenShareButtonLabel = useString(toolbarItemShareText); const lstooltip = useString(livestreamingShareTooltipText); const onPress = () => { + if (isScreenshareTransitioning) { + return; + } if (isScreenshareActive) { stopScreenshare('toolbar'); } else { @@ -74,7 +84,16 @@ const ScreenshareButton = (props: ScreenshareButtonProps) => { ? $config.SEMANTIC_ERROR : $config.SECONDARY_ACTION_COLOR, }, - onPress: onPressCustom || onPress, + disabled: isScreenshareTransitioning, + onPress: () => { + if (isScreenshareTransitioning) { + return; + } + (onPressCustom || onPress)(); + }, + containerStyle: isScreenshareTransitioning + ? styles.transitioning + : undefined, btnTextProps: { text: showLabel ? label || screenShareButtonLabel(isScreenshareActive) @@ -106,7 +125,16 @@ const ScreenshareButton = (props: ScreenshareButtonProps) => { } return props?.render ? ( - props.render(onPress, isScreenshareActive) + + {props.render(() => { + if (!isScreenshareTransitioning) { + onPress(); + } + }, isScreenshareActive)} + ) : isToolbarMenuItem ? ( ) : ( @@ -115,3 +143,9 @@ const ScreenshareButton = (props: ScreenshareButtonProps) => { }; export default ScreenshareButton; + +const styles = StyleSheet.create({ + transitioning: { + opacity: 0.4, + }, +}); diff --git a/template/src/subComponents/screenshare/ScreenshareConfigure.tsx b/template/src/subComponents/screenshare/ScreenshareConfigure.tsx index aa3cfd48e..91904e97d 100644 --- a/template/src/subComponents/screenshare/ScreenshareConfigure.tsx +++ b/template/src/subComponents/screenshare/ScreenshareConfigure.tsx @@ -11,7 +11,11 @@ */ import React, {useContext, useEffect, useRef, useState} from 'react'; import {DispatchContext, PropsContext, UidType} from '../../../agora-rn-uikit'; -import {ScreenshareContext, ScreenshareStopOrigin} from './useScreenshare'; +import { + ScreenshareContext, + ScreenshareOperationState, + ScreenshareStopOrigin, +} from './useScreenshare'; import { getGridLayoutName, getPinnedLayoutName, @@ -40,11 +44,16 @@ import { import {LogSource, logger} from '../../logger/AppBuilderLogger'; import getUniqueID from '../../utils/getUniqueID'; import { + getScreenshareSessionBoundaryMessage, getScreenshareSessionId, getScreenshareError, isUserCancelOrPermissionDenied, SCREENSHARE_JOURNEY, } from './screenshareJourney'; +import { + getScreenshareStartDecision, + getScreenshareStopDecision, +} from './screenshareOperation'; type ScreenshareAction = 'start' | 'stop'; @@ -57,7 +66,16 @@ export const ScreenshareConfigure = (props: { const toastHeading = useString(videoRoomScreenShareErrorToastHeading)(); const toastSubHeading = useString(videoRoomScreenShareErrorToastSubHeading)(); const [isScreenshareActive, setScreenshareActive] = useState(false); + const [operationState, setOperationState] = + useState('inactive'); + const operationStateRef = useRef('inactive'); const activeScreenshareSessionIdRef = useRef(null); + const pendingScreenshareSessionIdRef = useRef(null); + const queuedStopRef = useRef<{ + origin: ScreenshareStopOrigin; + stopActorUid?: UidType; + } | null>(null); + const completedStopSessionIdRef = useRef(null); const stopScreenshareRef = useRef< ( origin?: ScreenshareStopOrigin, @@ -75,6 +93,11 @@ export const ScreenshareConfigure = (props: { const {currentLayout} = useLayout(); const currentLayoutRef = useRef({currentLayout: currentLayout}); + const updateOperationState = (state: ScreenshareOperationState) => { + operationStateRef.current = state; + setOperationState(state); + }; + const {executeNormalQuery, executePresenterQuery} = useRecordingLayoutQuery(); const {channel, appId, screenShareUid, screenShareToken, encryption} = @@ -247,7 +270,7 @@ export const ScreenshareConfigure = (props: { }; }, []); - const ScreenshareStoppedCallback = ( + const ScreenshareStoppedCallback = async ( stopOrigin: ScreenshareStopOrigin = 'unknown', screenshareAttemptId = getUniqueID(), screenshareSessionId = activeScreenshareSessionIdRef.current || @@ -255,6 +278,28 @@ export const ScreenshareConfigure = (props: { stopActorUid?: UidType, ) => { const callbackStartedAt = Date.now(); + if (completedStopSessionIdRef.current === screenshareSessionId) { + logger.log( + LogSource.Internals, + 'SCREENSHARE', + `${SCREENSHARE_JOURNEY} screen share stop callback ignored because the session cleanup already completed`, + { + action: 'stop', + stage: 'stop_callback', + outcome: 'skipped', + screenshareAttemptId, + screenshareSessionId, + recordingActive: props.isRecordingActive, + screenShareUid, + stopOrigin, + stopActorUid, + operationState: operationStateRef.current, + duplicateReason: 'session_cleanup_already_completed', + }, + ); + return; + } + completedStopSessionIdRef.current = screenshareSessionId; logger.log( LogSource.Internals, 'SCREENSHARE', @@ -272,6 +317,7 @@ export const ScreenshareConfigure = (props: { }, ); setScreenshareActive(false); + updateOperationState('inactive'); logger.log( LogSource.Internals, 'SCREENSHARE', @@ -288,7 +334,7 @@ export const ScreenshareConfigure = (props: { stopActorUid, }, ); - events.send( + const rtmEventSent = await events.send( EventNames.SCREENSHARE_ATTRIBUTE, JSON.stringify({ action: EventActions.SCREENSHARE_STOPPED, @@ -299,11 +345,13 @@ export const ScreenshareConfigure = (props: { logger.log( LogSource.Internals, 'SCREENSHARE', - `${SCREENSHARE_JOURNEY} screen share stop RTM event sent to remote users`, + `${SCREENSHARE_JOURNEY} screen share stop RTM event ${ + rtmEventSent ? 'sent to remote users' : 'failed to send to remote users' + }`, { action: 'stop', stage: 'rtm_event', - outcome: 'success', + outcome: rtmEventSent ? 'success' : 'failure', screenshareAttemptId, screenshareSessionId, recordingActive: props.isRecordingActive, @@ -384,6 +432,8 @@ export const ScreenshareConfigure = (props: { }, ); activeScreenshareSessionIdRef.current = null; + pendingScreenshareSessionIdRef.current = null; + queuedStopRef.current = null; }; useEffect(() => { @@ -464,10 +514,14 @@ export const ScreenshareConfigure = (props: { stopActorUid?: UidType, ) => { const screenshareAttemptId = getUniqueID(); - const screenshareSessionId = getScreenshareSessionId( - 'stop', - activeScreenshareSessionIdRef.current, - getUniqueID, + const screenshareSessionId = + activeScreenshareSessionIdRef.current || + pendingScreenshareSessionIdRef.current || + getScreenshareSessionId('stop', null, getUniqueID); + const currentOperationState = operationStateRef.current; + const stopDecision = getScreenshareStopDecision( + currentOperationState, + Boolean(queuedStopRef.current), ); logger.log( LogSource.Internals, @@ -483,9 +537,53 @@ export const ScreenshareConfigure = (props: { screenShareUid, stopOrigin, stopActorUid, + operationState: currentOperationState, }, ); - if (!isScreenshareActive) { + if (currentOperationState === 'starting') { + if (stopDecision === 'skip') { + logger.log( + LogSource.Internals, + 'SCREENSHARE', + `${SCREENSHARE_JOURNEY} screen share stop skipped because a stop is already queued while start is pending`, + { + action: 'stop', + stage: 'precondition', + outcome: 'skipped', + screenshareAttemptId, + screenshareSessionId, + recordingActive: props.isRecordingActive, + screenShareUid, + stopOrigin, + stopActorUid, + operationState: currentOperationState, + duplicateReason: 'stop_already_queued', + }, + ); + return; + } + queuedStopRef.current = {origin: stopOrigin, stopActorUid}; + logger.log( + LogSource.Internals, + 'SCREENSHARE', + `${SCREENSHARE_JOURNEY} screen share stop queued until the pending start finishes`, + { + action: 'stop', + stage: 'precondition', + outcome: 'queued', + screenshareAttemptId, + screenshareSessionId, + recordingActive: props.isRecordingActive, + screenShareUid, + stopOrigin, + stopActorUid, + operationState: currentOperationState, + queuedStop: true, + }, + ); + return; + } + if (currentOperationState === 'inactive') { logger.log( LogSource.Internals, 'SCREENSHARE', @@ -500,64 +598,178 @@ export const ScreenshareConfigure = (props: { screenShareUid, stopOrigin, stopActorUid, + operationState: currentOperationState, + duplicateReason: 'already_inactive', + }, + ); + return; + } + if (stopDecision === 'skip') { + logger.log( + LogSource.Internals, + 'SCREENSHARE', + `${SCREENSHARE_JOURNEY} screen share stop skipped because stop is already in progress`, + { + action: 'stop', + stage: 'precondition', + outcome: 'skipped', + screenshareAttemptId, + screenshareSessionId, + recordingActive: props.isRecordingActive, + screenShareUid, + stopOrigin, + stopActorUid, + operationState: currentOperationState, + duplicateReason: 'already_stopping', }, ); return; } - await userScreenshare( + updateOperationState('stopping'); + const stopped = await userScreenshare( false, screenshareAttemptId, screenshareSessionId, stopOrigin, stopActorUid, ); + if (stopped) { + logger.log( + LogSource.Internals, + 'SCREENSHARE', + getScreenshareSessionBoundaryMessage('end', screenshareSessionId), + { + action: 'stop', + stage: 'session_boundary', + outcome: 'ended', + screenshareAttemptId, + screenshareSessionId, + recordingActive: props.isRecordingActive, + screenShareUid, + stopOrigin, + stopActorUid, + operationState: operationStateRef.current, + }, + ); + } else if (operationStateRef.current === 'stopping') { + updateOperationState('active'); + } }; const startScreenshare = async () => { const screenshareAttemptId = getUniqueID(); - const screenshareSessionId = getScreenshareSessionId( - 'start', - activeScreenshareSessionIdRef.current, - getUniqueID, - ); + const currentOperationState = operationStateRef.current; + const startDecision = getScreenshareStartDecision(currentOperationState); + const screenshareSessionId = + pendingScreenshareSessionIdRef.current || + activeScreenshareSessionIdRef.current || + getScreenshareSessionId('start', null, getUniqueID); + const logStartRequested = () => + logger.log( + LogSource.Internals, + 'SCREENSHARE', + `${SCREENSHARE_JOURNEY} screen share start requested from UI`, + { + action: 'start', + stage: 'ui_request', + outcome: 'started', + screenshareAttemptId, + screenshareSessionId, + recordingActive: props.isRecordingActive, + screenShareUid, + stopOrigin: 'unknown', + operationState: currentOperationState, + }, + ); + if (startDecision === 'skip') { + logStartRequested(); + logger.log( + LogSource.Internals, + 'SCREENSHARE', + `${SCREENSHARE_JOURNEY} screen share start skipped because screen share is ${currentOperationState}`, + { + action: 'start', + stage: 'precondition', + outcome: 'skipped', + screenshareAttemptId, + screenshareSessionId, + recordingActive: props.isRecordingActive, + screenShareUid, + stopOrigin: 'unknown', + operationState: currentOperationState, + duplicateReason: `start_requested_while_${currentOperationState}`, + }, + ); + return; + } + pendingScreenshareSessionIdRef.current = screenshareSessionId; + completedStopSessionIdRef.current = null; + updateOperationState('starting'); logger.log( LogSource.Internals, 'SCREENSHARE', - `${SCREENSHARE_JOURNEY} screen share start requested from UI`, + getScreenshareSessionBoundaryMessage('start', screenshareSessionId), { action: 'start', - stage: 'ui_request', + stage: 'session_boundary', outcome: 'started', screenshareAttemptId, screenshareSessionId, recordingActive: props.isRecordingActive, screenShareUid, stopOrigin: 'unknown', + operationState: operationStateRef.current, }, ); - if (isScreenshareActive) { + logStartRequested(); + const started = await userScreenshare( + true, + screenshareAttemptId, + screenshareSessionId, + 'unknown', + ); + const queuedStop = queuedStopRef.current; + queuedStopRef.current = null; + if (started && queuedStop) { logger.log( LogSource.Internals, 'SCREENSHARE', - `${SCREENSHARE_JOURNEY} screen share start skipped because screen share is already active`, + `${SCREENSHARE_JOURNEY} screen share executing the stop queued during start`, { - action: 'start', + action: 'stop', + stage: 'precondition', + outcome: 'started', + screenshareAttemptId: getUniqueID(), + screenshareSessionId, + recordingActive: props.isRecordingActive, + screenShareUid, + stopOrigin: queuedStop.origin, + stopActorUid: queuedStop.stopActorUid, + operationState: operationStateRef.current, + queuedStop: true, + }, + ); + await stopScreenshare(queuedStop.origin, queuedStop.stopActorUid); + } else if (!started && queuedStop) { + logger.log( + LogSource.Internals, + 'SCREENSHARE', + `${SCREENSHARE_JOURNEY} screen share discarded the queued stop because start failed`, + { + action: 'stop', stage: 'precondition', outcome: 'skipped', - screenshareAttemptId, + screenshareAttemptId: getUniqueID(), screenshareSessionId, recordingActive: props.isRecordingActive, screenShareUid, - stopOrigin: 'unknown', + stopOrigin: queuedStop.origin, + stopActorUid: queuedStop.stopActorUid, + operationState: operationStateRef.current, + queuedStop: false, + duplicateReason: 'start_failed', }, ); - return; } - await userScreenshare( - true, - screenshareAttemptId, - screenshareSessionId, - 'unknown', - ); }; stopScreenshareRef.current = stopScreenshare; @@ -572,6 +784,7 @@ export const ScreenshareConfigure = (props: { const action: ScreenshareAction = isActive ? 'start' : 'stop'; let stage = 'recording_layout'; let recordingLayoutFailed = false; + let rtcOperationSucceeded = false; logger.log( LogSource.Internals, 'SCREENSHARE', @@ -676,6 +889,7 @@ export const ScreenshareConfigure = (props: { stopActorUid, }, ); + rtcOperationSucceeded = true; logger.log( LogSource.Internals, 'SCREENSHARE', @@ -692,10 +906,12 @@ export const ScreenshareConfigure = (props: { stopActorUid, }, ); - isActive && setScreenshareActive(true); if (isActive) { activeScreenshareSessionIdRef.current = screenshareSessionId; + pendingScreenshareSessionIdRef.current = null; + updateOperationState('active'); + setScreenshareActive(true); stage = 'local_active_state'; logger.log( LogSource.Internals, @@ -744,7 +960,7 @@ export const ScreenshareConfigure = (props: { ); // 2. Inform everyone in the channel screenshare is actice stage = 'rtm_event'; - events.send( + const rtmEventSent = await events.send( EventNames.SCREENSHARE_ATTRIBUTE, JSON.stringify({ action: EventActions.SCREENSHARE_STARTED, @@ -756,11 +972,15 @@ export const ScreenshareConfigure = (props: { logger.log( LogSource.Internals, 'SCREENSHARE', - `${SCREENSHARE_JOURNEY} screen share start RTM event sent to remote users`, + `${SCREENSHARE_JOURNEY} screen share start RTM event ${ + rtmEventSent + ? 'sent to remote users' + : 'failed to send to remote users' + }`, { action, stage, - outcome: 'success', + outcome: rtmEventSent ? 'success' : 'failure', screenshareAttemptId, screenshareSessionId, recordingActive: props.isRecordingActive, @@ -791,6 +1011,7 @@ export const ScreenshareConfigure = (props: { elapsedMs: Date.now() - startedAt, }, ); + return true; } catch (e) { const userCancelOrPermissionDenied = isUserCancelOrPermissionDenied(e); logger.error( @@ -828,6 +1049,30 @@ export const ScreenshareConfigure = (props: { secondaryBtn: null, }); } + if (isActive && !rtcOperationSucceeded) { + pendingScreenshareSessionIdRef.current = null; + activeScreenshareSessionIdRef.current = null; + setScreenshareActive(false); + updateOperationState('inactive'); + logger.log( + LogSource.Internals, + 'SCREENSHARE', + getScreenshareSessionBoundaryMessage('end', screenshareSessionId), + { + action: 'start', + stage: 'session_boundary', + outcome: 'ended_with_start_failure', + screenshareAttemptId, + screenshareSessionId, + recordingActive: props.isRecordingActive, + screenShareUid, + stopOrigin, + stopActorUid, + operationState: operationStateRef.current, + }, + ); + } + return rtcOperationSucceeded; } }; @@ -835,6 +1080,7 @@ export const ScreenshareConfigure = (props: { { expect(SCREENSHARE_JOURNEY).toBe('[SCREENSHARE_JOURNEY]'); }); + it('puts the session ID directly in start and end boundary messages', () => { + expect(getScreenshareSessionBoundaryMessage('start', 'session-123')).toBe( + '----- [SCREENSHARE_JOURNEY] SCREEN SHARE SESSION START | sessionId=session-123 -----', + ); + expect(getScreenshareSessionBoundaryMessage('end', 'session-123')).toBe( + '----- [SCREENSHARE_JOURNEY] SCREEN SHARE SESSION END | sessionId=session-123 -----', + ); + }); + it.each([ [{code: 'PERMISSION_DENIED'}, true], [{name: 'NotAllowedError'}, true], diff --git a/template/src/subComponents/screenshare/__tests__/screenshareOperation.test.ts b/template/src/subComponents/screenshare/__tests__/screenshareOperation.test.ts new file mode 100644 index 000000000..ae46fd51e --- /dev/null +++ b/template/src/subComponents/screenshare/__tests__/screenshareOperation.test.ts @@ -0,0 +1,31 @@ +import { + getScreenshareStartDecision, + getScreenshareStopDecision, +} from '../screenshareOperation'; + +describe('screen-share operation decisions', () => { + it.each([ + ['inactive', 'accept'], + ['starting', 'skip'], + ['active', 'skip'], + ['stopping', 'skip'], + ] as const)('handles Start while %s', (state, expected) => { + expect(getScreenshareStartDecision(state)).toBe(expected); + }); + + it('queues the first Stop received while Start is pending', () => { + expect(getScreenshareStopDecision('starting', false)).toBe('queue'); + }); + + it('skips additional Stops when one is already queued', () => { + expect(getScreenshareStopDecision('starting', true)).toBe('skip'); + }); + + it.each([ + ['inactive', 'skip'], + ['active', 'execute'], + ['stopping', 'skip'], + ] as const)('handles Stop while %s', (state, expected) => { + expect(getScreenshareStopDecision(state, false)).toBe(expected); + }); +}); diff --git a/template/src/subComponents/screenshare/screenshareJourney.ts b/template/src/subComponents/screenshare/screenshareJourney.ts index 0265df356..59767dad3 100644 --- a/template/src/subComponents/screenshare/screenshareJourney.ts +++ b/template/src/subComponents/screenshare/screenshareJourney.ts @@ -1,5 +1,11 @@ export const SCREENSHARE_JOURNEY = '[SCREENSHARE_JOURNEY]'; +export const getScreenshareSessionBoundaryMessage = ( + boundary: 'start' | 'end', + screenshareSessionId: string, +) => + `----- ${SCREENSHARE_JOURNEY} SCREEN SHARE SESSION ${boundary.toUpperCase()} | sessionId=${screenshareSessionId} -----`; + export const getScreenshareSessionId = ( action: 'start' | 'stop', activeScreenshareSessionId: string | null, diff --git a/template/src/subComponents/screenshare/screenshareOperation.ts b/template/src/subComponents/screenshare/screenshareOperation.ts new file mode 100644 index 000000000..cff57547f --- /dev/null +++ b/template/src/subComponents/screenshare/screenshareOperation.ts @@ -0,0 +1,21 @@ +import {ScreenshareOperationState} from './useScreenshare'; + +export type ScreenshareStartDecision = 'accept' | 'skip'; +export type ScreenshareStopDecision = 'execute' | 'queue' | 'skip'; + +export const getScreenshareStartDecision = ( + state: ScreenshareOperationState, +): ScreenshareStartDecision => (state === 'inactive' ? 'accept' : 'skip'); + +export const getScreenshareStopDecision = ( + state: ScreenshareOperationState, + hasQueuedStop: boolean, +): ScreenshareStopDecision => { + if (state === 'active') { + return 'execute'; + } + if (state === 'starting' && !hasQueuedStop) { + return 'queue'; + } + return 'skip'; +}; diff --git a/template/src/subComponents/screenshare/useScreenshare.tsx b/template/src/subComponents/screenshare/useScreenshare.tsx index ded4230ed..b3d622a8d 100644 --- a/template/src/subComponents/screenshare/useScreenshare.tsx +++ b/template/src/subComponents/screenshare/useScreenshare.tsx @@ -25,8 +25,15 @@ export type ScreenshareStopOrigin = | 'page_unload' | 'unknown'; +export type ScreenshareOperationState = + | 'inactive' + | 'starting' + | 'active' + | 'stopping'; + export interface ScreenshareContextInterface { isScreenshareActive: boolean; + operationState?: ScreenshareOperationState; startScreenshare: () => Promise | void; stopScreenshare: ( origin?: ScreenshareStopOrigin, @@ -36,6 +43,7 @@ export interface ScreenshareContextInterface { export const ScreenshareContext = createContext({ isScreenshareActive: false, + operationState: 'inactive', startScreenshare: () => {}, stopScreenshare: () => {}, //@ts-ignore