From ef138fb5aa9750620e4c39787a77b7207bc84e5a Mon Sep 17 00:00:00 2001 From: Calinteodor Date: Thu, 13 Feb 2025 18:36:11 +0200 Subject: [PATCH] feat(android/ios): start/stop recording events for native (#15598) Added native android and ios events for start and stop recording. --- .../org/jitsi/meet/sdk/BroadcastAction.java | 4 +- .../jitsi/meet/sdk/BroadcastIntentHelper.java | 63 ++++++++- .../org/jitsi/meet/sdk/ExternalAPIModule.java | 2 + ios/sdk/src/ExternalAPI.h | 7 + ios/sdk/src/ExternalAPI.m | 51 ++++++- ios/sdk/src/JitsiMeetView.h | 4 + ios/sdk/src/JitsiMeetView.m | 12 ++ .../mobile/external-api/middleware.ts | 133 +++++++++++++++++- 8 files changed, 263 insertions(+), 13 deletions(-) diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastAction.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastAction.java index c8b772907116..af2e7e52db73 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastAction.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastAction.java @@ -80,7 +80,9 @@ enum Type { SET_CLOSED_CAPTIONS_ENABLED("org.jitsi.meet.SET_CLOSED_CAPTIONS_ENABLED"), TOGGLE_CAMERA("org.jitsi.meet.TOGGLE_CAMERA"), SHOW_NOTIFICATION("org.jitsi.meet.SHOW_NOTIFICATION"), - HIDE_NOTIFICATION("org.jitsi.meet.HIDE_NOTIFICATION"); + HIDE_NOTIFICATION("org.jitsi.meet.HIDE_NOTIFICATION"), + START_RECORDING("org.jitsi.meet.START_RECORDING"), + STOP_RECORDING("org.jitsi.meet.STOP_RECORDING"); private final String action; diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastIntentHelper.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastIntentHelper.java index 9a2dabdce3a6..a5d0eeb2bacb 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastIntentHelper.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastIntentHelper.java @@ -1,16 +1,13 @@ package org.jitsi.meet.sdk; import android.content.Intent; - -import org.jitsi.meet.sdk.log.JitsiMeetLogger; - -import java.util.Arrays; -import java.util.List; +import android.os.Bundle; public class BroadcastIntentHelper { public static Intent buildSetAudioMutedIntent(boolean muted) { Intent intent = new Intent(BroadcastAction.Type.SET_AUDIO_MUTED.getAction()); intent.putExtra("muted", muted); + return intent; } @@ -22,18 +19,21 @@ public static Intent buildSendEndpointTextMessageIntent(String to, String messag Intent intent = new Intent(BroadcastAction.Type.SEND_ENDPOINT_TEXT_MESSAGE.getAction()); intent.putExtra("to", to); intent.putExtra("message", message); + return intent; } public static Intent buildToggleScreenShareIntent(boolean enabled) { Intent intent = new Intent(BroadcastAction.Type.TOGGLE_SCREEN_SHARE.getAction()); intent.putExtra("enabled", enabled); + return intent; } public static Intent buildOpenChatIntent(String participantId) { Intent intent = new Intent(BroadcastAction.Type.OPEN_CHAT.getAction()); intent.putExtra("to", participantId); + return intent; } @@ -45,24 +45,28 @@ public static Intent buildSendChatMessageIntent(String participantId, String mes Intent intent = new Intent(BroadcastAction.Type.SEND_CHAT_MESSAGE.getAction()); intent.putExtra("to", participantId); intent.putExtra("message", message); + return intent; } public static Intent buildSetVideoMutedIntent(boolean muted) { Intent intent = new Intent(BroadcastAction.Type.SET_VIDEO_MUTED.getAction()); intent.putExtra("muted", muted); + return intent; } public static Intent buildSetClosedCaptionsEnabledIntent(boolean enabled) { Intent intent = new Intent(BroadcastAction.Type.SET_CLOSED_CAPTIONS_ENABLED.getAction()); intent.putExtra("enabled", enabled); + return intent; } public static Intent buildRetrieveParticipantsInfo(String requestId) { Intent intent = new Intent(BroadcastAction.Type.RETRIEVE_PARTICIPANTS_INFO.getAction()); intent.putExtra("requestId", requestId); + return intent; } @@ -78,12 +82,61 @@ public static Intent buildShowNotificationIntent( intent.putExtra("timeout", timeout); intent.putExtra("title", title); intent.putExtra("uid", uid); + return intent; } public static Intent buildHideNotificationIntent(String uid) { Intent intent = new Intent(BroadcastAction.Type.HIDE_NOTIFICATION.getAction()); intent.putExtra("uid", uid); + + return intent; + } + + public enum RecordingMode { + FILE("file"), + STREAM("stream"); + + private final String mode; + + RecordingMode(String mode) { + this.mode = mode; + } + + public String getMode() { + return mode; + } + } + + public static Intent buildStartRecordingIntent( + RecordingMode mode, + String dropboxToken, + boolean shouldShare, + String rtmpStreamKey, + String rtmpBroadcastID, + String youtubeStreamKey, + String youtubeBroadcastID, + Bundle extraMetadata, + boolean transcription) { + Intent intent = new Intent(BroadcastAction.Type.START_RECORDING.getAction()); + intent.putExtra("mode", mode.getMode()); + intent.putExtra("dropboxToken", dropboxToken); + intent.putExtra("shouldShare", shouldShare); + intent.putExtra("rtmpStreamKey", rtmpStreamKey); + intent.putExtra("rtmpBroadcastID", rtmpBroadcastID); + intent.putExtra("youtubeStreamKey", youtubeStreamKey); + intent.putExtra("youtubeBroadcastID", youtubeBroadcastID); + intent.putExtra("extraMetadata", extraMetadata); + intent.putExtra("transcription", transcription); + + return intent; + } + + public static Intent buildStopRecordingIntent(RecordingMode mode, boolean transcription) { + Intent intent = new Intent(BroadcastAction.Type.STOP_RECORDING.getAction()); + intent.putExtra("mode", mode.getMode()); + intent.putExtra("transcription", transcription); + return intent; } } diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java index 2812d2ae5ea6..22588c6577ae 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java @@ -99,6 +99,8 @@ public Map getConstants() { constants.put("TOGGLE_CAMERA", BroadcastAction.Type.TOGGLE_CAMERA.getAction()); constants.put("SHOW_NOTIFICATION", BroadcastAction.Type.SHOW_NOTIFICATION.getAction()); constants.put("HIDE_NOTIFICATION", BroadcastAction.Type.HIDE_NOTIFICATION.getAction()); + constants.put("START_RECORDING", BroadcastAction.Type.START_RECORDING.getAction()); + constants.put("STOP_RECORDING", BroadcastAction.Type.STOP_RECORDING.getAction()); return constants; } diff --git a/ios/sdk/src/ExternalAPI.h b/ios/sdk/src/ExternalAPI.h index 0ee72022d4c7..63698c7119d0 100644 --- a/ios/sdk/src/ExternalAPI.h +++ b/ios/sdk/src/ExternalAPI.h @@ -18,6 +18,11 @@ static NSString * const sendEventNotificationName = @"org.jitsi.meet.SendEvent"; +typedef NS_ENUM(NSInteger, RecordingMode) { + RecordingModeFile, + RecordingModeStream +}; + @interface ExternalAPI : RCTEventEmitter - (void)sendHangUp; @@ -33,5 +38,7 @@ static NSString * const sendEventNotificationName = @"org.jitsi.meet.SendEvent"; - (void)toggleCamera; - (void)showNotification:(NSString*)appearance :(NSString*)description :(NSString*)timeout :(NSString*)title :(NSString*)uid; - (void)hideNotification:(NSString*)uid; +- (void)startRecording:(RecordingMode)mode :(NSString*)dropboxToken :(BOOL)shouldShare :(NSString*)rtmpStreamKey :(NSString*)rtmpBroadcastID :(NSString*)youtubeStreamKey :(NSString*)youtubeBroadcastID :(NSDictionary*)extraMetadata :(BOOL)transcription; +- (void)stopRecording:(RecordingMode)mode :(BOOL)transcription; @end diff --git a/ios/sdk/src/ExternalAPI.m b/ios/sdk/src/ExternalAPI.m index efaab06b767d..8f9829bfe6be 100644 --- a/ios/sdk/src/ExternalAPI.m +++ b/ios/sdk/src/ExternalAPI.m @@ -30,6 +30,8 @@ static NSString * const toggleCameraAction = @"org.jitsi.meet.TOGGLE_CAMERA"; static NSString * const showNotificationAction = @"org.jitsi.meet.SHOW_NOTIFICATION"; static NSString * const hideNotificationAction = @"org.jitsi.meet.HIDE_NOTIFICATION"; +static NSString * const startRecordingAction = @"org.jitsi.meet.START_RECORDING"; +static NSString * const stopRecordingAction = @"org.jitsi.meet.STOP_RECORDING"; @implementation ExternalAPI @@ -56,7 +58,9 @@ - (NSDictionary *)constantsToExport { @"SET_CLOSED_CAPTIONS_ENABLED": setClosedCaptionsEnabledAction, @"TOGGLE_CAMERA": toggleCameraAction, @"SHOW_NOTIFICATION": showNotificationAction, - @"HIDE_NOTIFICATION": hideNotificationAction + @"HIDE_NOTIFICATION": hideNotificationAction, + @"START_RECORDING": startRecordingAction, + @"STOP_RECORDING": stopRecordingAction }; }; @@ -84,7 +88,9 @@ + (BOOL)requiresMainQueueSetup { setClosedCaptionsEnabledAction, toggleCameraAction, showNotificationAction, - hideNotificationAction + hideNotificationAction, + startRecordingAction, + stopRecordingAction ]; } @@ -186,7 +192,7 @@ - (void)toggleCamera { [self sendEventWithName:toggleCameraAction body:nil]; } -- (void)showNotification:(NSString *)appearance :(NSString *)description :(NSString *)timeout :(NSString *)title :(NSString *)uid { +- (void)showNotification:(NSString*)appearance :(NSString*)description :(NSString*)timeout :(NSString*)title :(NSString*)uid { NSMutableDictionary *data = [[NSMutableDictionary alloc] init]; data[@"appearance"] = appearance; data[@"description"] = description; @@ -197,11 +203,48 @@ - (void)showNotification:(NSString *)appearance :(NSString *)description :(NSStr [self sendEventWithName:showNotificationAction body:data]; } -- (void)hideNotification:(NSString *)uid { +- (void)hideNotification:(NSString*)uid { NSMutableDictionary *data = [[NSMutableDictionary alloc] init]; data[@"uid"] = uid; [self sendEventWithName:hideNotificationAction body:data]; } +static inline NSString *RecordingModeToString(RecordingMode mode) { + switch (mode) { + case RecordingModeFile: + return @"file"; + case RecordingModeStream: + return @"stream"; + default: + return nil; + } +} + +- (void)startRecording:(RecordingMode)mode :(NSString*)dropboxToken :(BOOL)shouldShare :(NSString*)rtmpStreamKey :(NSString*)rtmpBroadcastID :(NSString*)youtubeStreamKey :(NSString*)youtubeBroadcastID :(NSDictionary*)extraMetadata :(BOOL)transcription { + NSString *modeString = RecordingModeToString(mode); + NSDictionary *data = @{ + @"mode": modeString, + @"dropboxToken": dropboxToken, + @"shouldShare": @(shouldShare), + @"rtmpStreamKey": rtmpStreamKey, + @"rtmpBroadcastID": rtmpBroadcastID, + @"youtubeStreamKey": youtubeStreamKey, + @"youtubeBroadcastID": youtubeBroadcastID, + @"extraMetadata": extraMetadata, + @"transcription": @(transcription) + }; + + [self sendEventWithName:startRecordingAction body:data]; +} + +- (void)stopRecording:(RecordingMode)mode :(BOOL)transcription { + NSString *modeString = RecordingModeToString(mode); + NSDictionary *data = @{ + @"mode": modeString, + @"transcription": @(transcription) + }; + + [self sendEventWithName:stopRecordingAction body:data]; +} @end diff --git a/ios/sdk/src/JitsiMeetView.h b/ios/sdk/src/JitsiMeetView.h index e7b5f1ffa057..af3cc1a888ec 100644 --- a/ios/sdk/src/JitsiMeetView.h +++ b/ios/sdk/src/JitsiMeetView.h @@ -21,6 +21,8 @@ #import "JitsiMeetConferenceOptions.h" #import "JitsiMeetViewDelegate.h" +typedef NS_ENUM(NSInteger, RecordingMode); + @interface JitsiMeetView : UIView @property (nonatomic, nullable, weak) id delegate; @@ -49,5 +51,7 @@ - (void)toggleCamera; - (void)showNotification:(NSString * _Nonnull)appearance :(NSString * _Nullable)description :(NSString * _Nullable)timeout :(NSString * _Nullable)title :(NSString * _Nullable)uid; - (void)hideNotification:(NSString * _Nullable)uid; +- (void)startRecording:(RecordingMode)mode :(NSString * _Nullable)dropboxToken :(BOOL)shouldShare :(NSString * _Nullable)rtmpStreamKey :(NSString * _Nullable)rtmpBroadcastID :(NSString * _Nullable)youtubeStreamKey :(NSString * _Nullable)youtubeBroadcastID :(NSString * _Nullable)extraMetadata :(BOOL)transcription; +- (void)stopRecording:(RecordingMode)mode :(BOOL)transcription; @end diff --git a/ios/sdk/src/JitsiMeetView.m b/ios/sdk/src/JitsiMeetView.m index 0427d73a8a59..b89066e2a7d7 100644 --- a/ios/sdk/src/JitsiMeetView.m +++ b/ios/sdk/src/JitsiMeetView.m @@ -142,15 +142,27 @@ - (void)toggleCamera { ExternalAPI *externalAPI = [[JitsiMeet sharedInstance] getExternalAPI]; [externalAPI toggleCamera]; } + - (void)showNotification:(NSString *)appearance :(NSString *)description :(NSString *)timeout :(NSString *)title :(NSString *)uid { ExternalAPI *externalAPI = [[JitsiMeet sharedInstance] getExternalAPI]; [externalAPI showNotification:appearance :description :timeout :title :uid]; } + -(void)hideNotification:(NSString *)uid { ExternalAPI *externalAPI = [[JitsiMeet sharedInstance] getExternalAPI]; [externalAPI hideNotification:uid]; } +- (void)startRecording:(RecordingMode)mode :(NSString *)dropboxToken :(BOOL)shouldShare :(NSString *)rtmpStreamKey :(NSString *)rtmpBroadcastID :(NSString *)youtubeStreamKey :(NSString *)youtubeBroadcastID :(NSString *)extraMetadata :(BOOL)transcription { + ExternalAPI *externalAPI = [[JitsiMeet sharedInstance] getExternalAPI]; + [externalAPI startRecording:mode :dropboxToken :shouldShare :rtmpStreamKey :rtmpBroadcastID :youtubeStreamKey :youtubeBroadcastID :extraMetadata :transcription]; +} + +- (void)stopRecording:(RecordingMode)mode :(BOOL)transcription { + ExternalAPI *externalAPI = [[JitsiMeet sharedInstance] getExternalAPI]; + [externalAPI stopRecording:mode :transcription]; +} + #pragma mark Private methods - (void)registerObservers { diff --git a/react/features/mobile/external-api/middleware.ts b/react/features/mobile/external-api/middleware.ts index 32a681b156d1..0cc36dde12dd 100644 --- a/react/features/mobile/external-api/middleware.ts +++ b/react/features/mobile/external-api/middleware.ts @@ -4,7 +4,7 @@ import { debounce } from 'lodash-es'; import { NativeEventEmitter, NativeModules } from 'react-native'; import { AnyAction } from 'redux'; -// @ts-expect-error +// @ts-ignore import { ENDPOINT_TEXT_MESSAGE_NAME } from '../../../../modules/API/constants'; import { appNavigate } from '../../app/actions.native'; import { IStore } from '../../app/types'; @@ -32,8 +32,7 @@ import { JITSI_CONNECTION_URL_KEY } from '../../base/connection/constants'; import { getURLWithoutParams } from '../../base/connection/utils'; -import { - JitsiConferenceEvents } from '../../base/lib-jitsi-meet'; +import { JitsiConferenceEvents, JitsiRecordingConstants } from '../../base/lib-jitsi-meet'; import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from '../../base/media/actionTypes'; import { toggleCameraFacingMode } from '../../base/media/actions'; import { MEDIA_TYPE, VIDEO_TYPE } from '../../base/media/constants'; @@ -51,8 +50,11 @@ import { getLocalTracks, isLocalTrackMuted } from '../../base/tracks/functions.n import { ITrack } from '../../base/tracks/types'; import { CLOSE_CHAT, OPEN_CHAT } from '../../chat/actionTypes'; import { closeChat, openChat, sendMessage, setPrivateMessageRecipient } from '../../chat/actions.native'; +import { isEnabled as isDropboxEnabled } from '../../dropbox/functions.native'; import { hideNotification, showNotification } from '../../notifications/actions'; import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../../notifications/constants'; +import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../recording/constants'; +import { getActiveSession } from '../../recording/functions'; import { setRequestingSubtitles } from '../../subtitles/actions.any'; import { CUSTOM_BUTTON_PRESSED } from '../../toolbox/actionTypes'; import { muteLocal } from '../../video-menu/actions.native'; @@ -450,6 +452,129 @@ function _registerForNativeEvents(store: IStore) { eventEmitter.addListener(ExternalAPI.HIDE_NOTIFICATION, ({ uid }: any) => { dispatch(hideNotification(uid)); }); + + eventEmitter.addListener(ExternalAPI.START_RECORDING, ( + { + mode, + dropboxToken, + shouldShare, + rtmpStreamKey, + rtmpBroadcastID, + youtubeStreamKey, + youtubeBroadcastID, + extraMetadata = {}, + transcription + }: any) => { + const state = store.getState(); + const conference = getCurrentConference(state); + + if (!conference) { + logger.error('Conference is not defined'); + + return; + } + + if (dropboxToken && !isDropboxEnabled(state)) { + logger.error('Failed starting recording: dropbox is not enabled on this deployment'); + + return; + } + + if (mode === JitsiRecordingConstants.mode.STREAM && !(youtubeStreamKey || rtmpStreamKey)) { + logger.error('Failed starting recording: missing youtube or RTMP stream key'); + + return; + } + + let recordingConfig; + + if (mode === JitsiRecordingConstants.mode.FILE) { + const { recordingService } = state['features/base/config']; + + if (!recordingService?.enabled && !dropboxToken) { + logger.error('Failed starting recording: the recording service is not enabled'); + + return; + } + + if (dropboxToken) { + recordingConfig = { + mode: JitsiRecordingConstants.mode.FILE, + appData: JSON.stringify({ + 'file_recording_metadata': { + ...extraMetadata, + 'upload_credentials': { + 'service_name': RECORDING_TYPES.DROPBOX, + 'token': dropboxToken + } + } + }) + }; + } else { + recordingConfig = { + mode: JitsiRecordingConstants.mode.FILE, + appData: JSON.stringify({ + 'file_recording_metadata': { + ...extraMetadata, + 'share': shouldShare + } + }) + }; + } + } else if (mode === JitsiRecordingConstants.mode.STREAM) { + recordingConfig = { + broadcastId: youtubeBroadcastID || rtmpBroadcastID, + mode: JitsiRecordingConstants.mode.STREAM, + streamId: youtubeStreamKey || rtmpStreamKey + }; + } + + // Start audio / video recording, if requested. + if (typeof recordingConfig !== 'undefined') { + conference.startRecording(recordingConfig); + } + + if (transcription) { + store.dispatch(setRequestingSubtitles(true, false, null)); + conference.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, { + isTranscribingEnabled: true + }); + } + }); + + eventEmitter.addListener(ExternalAPI.STOP_RECORDING, ({ mode, transcription }: any) => { + const state = store.getState(); + const conference = getCurrentConference(state); + + if (!conference) { + logger.error('Conference is not defined'); + + return; + } + + if (transcription) { + store.dispatch(setRequestingSubtitles(false, false, null)); + conference.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, { + isTranscribingEnabled: false + }); + } + + if (![ JitsiRecordingConstants.mode.FILE, JitsiRecordingConstants.mode.STREAM ].includes(mode)) { + logger.error('Invalid recording mode provided!'); + + return; + } + + const activeSession = getActiveSession(state, mode); + + if (!activeSession?.id) { + logger.error('No recording or streaming session found'); + + return; + } + + conference.stopRecording(activeSession.id); + }); } /** @@ -472,6 +597,8 @@ function _unregisterForNativeEvents() { eventEmitter.removeAllListeners(ExternalAPI.TOGGLE_CAMERA); eventEmitter.removeAllListeners(ExternalAPI.SHOW_NOTIFICATION); eventEmitter.removeAllListeners(ExternalAPI.HIDE_NOTIFICATION); + eventEmitter.removeAllListeners(ExternalAPI.START_RECORDING); + eventEmitter.removeAllListeners(ExternalAPI.STOP_RECORDING); } /**