diff --git a/audio-livecast.config.json b/audio-livecast.config.json index 9becbcdca..bfdc42871 100644 --- a/audio-livecast.config.json +++ b/audio-livecast.config.json @@ -11,7 +11,7 @@ "PRECALL": true, "CHAT": true, "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", + "RECORDING_MODE": "MIX", "SCREEN_SHARING": false, "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", "ENCRYPTION_ENABLED": true, diff --git a/audio-livecast.config.light.json b/audio-livecast.config.light.json index f0536c576..a7221d32a 100644 --- a/audio-livecast.config.light.json +++ b/audio-livecast.config.light.json @@ -11,7 +11,7 @@ "PRECALL": true, "CHAT": true, "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", + "RECORDING_MODE": "MIX", "SCREEN_SHARING": false, "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", "ENCRYPTION_ENABLED": true, diff --git a/config.json b/config.json index ceaa4b278..04da15c32 100644 --- a/config.json +++ b/config.json @@ -14,7 +14,7 @@ "PRECALL": true, "CHAT": true, "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", + "RECORDING_MODE": "MIX", "SCREEN_SHARING": true, "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", "ENCRYPTION_ENABLED": true, diff --git a/config.light.json b/config.light.json index 01c1388e2..cd8f38815 100644 --- a/config.light.json +++ b/config.light.json @@ -14,7 +14,7 @@ "PRECALL": true, "CHAT": true, "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", + "RECORDING_MODE": "MIX", "SCREEN_SHARING": true, "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", "ENCRYPTION_ENABLED": false, diff --git a/live-streaming.config.json b/live-streaming.config.json index 21396c96a..fcb611b9a 100644 --- a/live-streaming.config.json +++ b/live-streaming.config.json @@ -11,7 +11,7 @@ "PRECALL": true, "CHAT": true, "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", + "RECORDING_MODE": "MIX", "SCREEN_SHARING": true, "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", "ENCRYPTION_ENABLED": true, diff --git a/live-streaming.config.light.json b/live-streaming.config.light.json index 37e46dc0b..7ba7a652b 100644 --- a/live-streaming.config.light.json +++ b/live-streaming.config.light.json @@ -11,7 +11,7 @@ "PRECALL": true, "CHAT": true, "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", + "RECORDING_MODE": "MIX", "SCREEN_SHARING": true, "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", "ENCRYPTION_ENABLED": true, diff --git a/template/android/app/build.gradle b/template/android/app/build.gradle index 5f57ad321..a434786ab 100644 --- a/template/android/app/build.gradle +++ b/template/android/app/build.gradle @@ -101,6 +101,13 @@ android { proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } } + + packagingOptions { + pickFirst '**/lib/arm64-v8a/libaosl.so' + pickFirst '**/lib/armeabi-v7a/libaosl.so' + pickFirst '**/lib/x86/libaosl.so' + pickFirst '**/lib/x86_64/libaosl.so' + } } dependencies { diff --git a/template/bridge/rtm/web/Types.ts b/template/bridge/rtm/web/Types.ts index 8356dbf2f..786ed9b63 100644 --- a/template/bridge/rtm/web/Types.ts +++ b/template/bridge/rtm/web/Types.ts @@ -1,3 +1,5 @@ +import {ChannelType as WebChannelType} from 'agora-rtm-sdk'; + export interface AttributesMap { [key: string]: string; } @@ -11,3 +13,184 @@ export interface ChannelAttributeOptions { */ enableNotificationToChannelMembers?: undefined | false | true; } + +// LINK STATE +export const nativeLinkStateMapping = { + IDLE: 0, + CONNECTING: 1, + CONNECTED: 2, + DISCONNECTED: 3, + SUSPENDED: 4, + FAILED: 5, +}; + +// Create reverse mapping: number -> string +export const webLinkStateMapping = Object.fromEntries( + Object.entries(nativeLinkStateMapping).map(([key, value]) => [value, key]), +); + +export const linkStatusReasonCodeMapping: {[key: string]: number} = { + UNKNOWN: 0, + LOGIN: 1, + LOGIN_SUCCESS: 2, + LOGIN_TIMEOUT: 3, + LOGIN_NOT_AUTHORIZED: 4, + LOGIN_REJECTED: 5, + RELOGIN: 6, + LOGOUT: 7, + AUTO_RECONNECT: 8, + RECONNECT_TIMEOUT: 9, + RECONNECT_SUCCESS: 10, + JOIN: 11, + JOIN_SUCCESS: 12, + JOIN_FAILED: 13, + REJOIN: 14, + LEAVE: 15, + INVALID_TOKEN: 16, + TOKEN_EXPIRED: 17, + INCONSISTENT_APP_ID: 18, + INVALID_CHANNEL_NAME: 19, + INVALID_USER_ID: 20, + NOT_INITIALIZED: 21, + RTM_SERVICE_NOT_CONNECTED: 22, + CHANNEL_INSTANCE_EXCEED_LIMITATION: 23, + OPERATION_RATE_EXCEED_LIMITATION: 24, + CHANNEL_IN_ERROR_STATE: 25, + PRESENCE_NOT_CONNECTED: 26, + SAME_UID_LOGIN: 27, + KICKED_OUT_BY_SERVER: 28, + KEEP_ALIVE_TIMEOUT: 29, + CONNECTION_ERROR: 30, + PRESENCE_NOT_READY: 31, + NETWORK_CHANGE: 32, + SERVICE_NOT_SUPPORTED: 33, + STREAM_CHANNEL_NOT_AVAILABLE: 34, + STORAGE_NOT_AVAILABLE: 35, + LOCK_NOT_AVAILABLE: 36, + LOGIN_TOO_FREQUENT: 37, +}; + +// CHANNEL TYPE +// string -> number +export const nativeChannelTypeMapping = { + NONE: 0, + MESSAGE: 1, + STREAM: 2, + USER: 3, +}; +// number -> string +export const webChannelTypeMapping = Object.fromEntries( + Object.entries(nativeChannelTypeMapping).map(([key, value]) => [value, key]), +); + +// STORAGE TYPE +// string -> number +export const nativeStorageTypeMapping = { + NONE: 0, + /** + * 1: The user storage event. + */ + USER: 1, + /** + * 2: The channel storage event. + */ + CHANNEL: 2, +}; +// number -> string +export const webStorageTypeMapping = Object.fromEntries( + Object.entries(nativeStorageTypeMapping).map(([key, value]) => [value, key]), +); + +// STORAGE EVENT TYPE +export const nativeStorageEventTypeMapping = { + /** + * 0: Unknown event type. + */ + NONE: 0, + /** + * 1: Triggered when user subscribe user metadata state or join channel with options.withMetadata = true + */ + SNAPSHOT: 1, + /** + * 2: Triggered when a remote user set metadata + */ + SET: 2, + /** + * 3: Triggered when a remote user update metadata + */ + UPDATE: 3, + /** + * 4: Triggered when a remote user remove metadata + */ + REMOVE: 4, +}; +// number -> string +export const webStorageEventTypeMapping = Object.fromEntries( + Object.entries(nativeStorageEventTypeMapping).map(([key, value]) => [ + value, + key, + ]), +); + +// PRESENCE EVENT TYPE +export const nativePresenceEventTypeMapping = { + /** + * 0: Unknown event type + */ + NONE: 0, + /** + * 1: The presence snapshot of this channel + */ + SNAPSHOT: 1, + /** + * 2: The presence event triggered in interval mode + */ + INTERVAL: 2, + /** + * 3: Triggered when remote user join channel + */ + REMOTE_JOIN: 3, + /** + * 4: Triggered when remote user leave channel + */ + REMOTE_LEAVE: 4, + /** + * 5: Triggered when remote user's connection timeout + */ + REMOTE_TIMEOUT: 5, + /** + * 6: Triggered when user changed state + */ + REMOTE_STATE_CHANGED: 6, + /** + * 7: Triggered when user joined channel without presence service + */ + ERROR_OUT_OF_SERVICE: 7, +}; +// number -> string +export const webPresenceEventTypeMapping = Object.fromEntries( + Object.entries(nativePresenceEventTypeMapping).map(([key, value]) => [ + value, + key, + ]), +); + +// MESSAGE EVENT TYPE +// string -> number +export const nativeMessageEventTypeMapping = { + /** + * 0: The binary message. + */ + BINARY: 0, + /** + * 1: The ascii message. + */ + STRING: 1, +}; +// number -> string +export const webMessageEventTypeMapping = Object.fromEntries( + Object.entries(nativePresenceEventTypeMapping).map(([key, value]) => [ + value, + key, + ]), +); diff --git a/template/bridge/rtm/web/index-legacy.ts b/template/bridge/rtm/web/index-legacy.ts new file mode 100644 index 000000000..ed092d0c6 --- /dev/null +++ b/template/bridge/rtm/web/index-legacy.ts @@ -0,0 +1,540 @@ +/* +******************************************** + 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. +********************************************* +*/ +// @ts-nocheck +import { + ChannelAttributeOptions, + RtmAttribute, + RtmChannelAttribute, + Subscription, +} from 'agora-react-native-rtm/lib/typescript/src'; +import {RtmClientEvents} from 'agora-react-native-rtm/lib/typescript/src/RtmEngine'; +import AgoraRTM, {VERSION} from 'agora-rtm-sdk'; +import RtmClient from 'agora-react-native-rtm'; +import {LogSource, logger} from '../../../src/logger/AppBuilderLogger'; +// export {RtmAttribute} +// +interface RtmAttributePlaceholder {} +export {RtmAttributePlaceholder as RtmAttribute}; + +type callbackType = (args?: any) => void; + +export default class RtmEngine { + public appId: string; + public client: RtmClient; + public channelMap = new Map([]); + public remoteInvititations = new Map([]); + public localInvititations = new Map([]); + public channelEventsMap = new Map([ + ['channelMessageReceived', () => null], + ['channelMemberJoined', () => null], + ['channelMemberLeft', () => null], + ]); + public clientEventsMap = new Map([ + ['connectionStateChanged', () => null], + ['messageReceived', () => null], + ['remoteInvitationReceived', () => null], + ['tokenExpired', () => null], + ]); + public localInvitationEventsMap = new Map([ + ['localInvitationAccepted', () => null], + ['localInvitationCanceled', () => null], + ['localInvitationFailure', () => null], + ['localInvitationReceivedByPeer', () => null], + ['localInvitationRefused', () => null], + ]); + public remoteInvitationEventsMap = new Map([ + ['remoteInvitationAccepted', () => null], + ['remoteInvitationCanceled', () => null], + ['remoteInvitationFailure', () => null], + ['remoteInvitationRefused', () => null], + ]); + constructor() { + this.appId = ''; + logger.debug(LogSource.AgoraSDK, 'Log', 'Using RTM Bridge'); + } + + on(event: any, listener: any) { + if ( + event === 'channelMessageReceived' || + event === 'channelMemberJoined' || + event === 'channelMemberLeft' + ) { + this.channelEventsMap.set(event, listener); + } else if ( + event === 'connectionStateChanged' || + event === 'messageReceived' || + event === 'remoteInvitationReceived' || + event === 'tokenExpired' + ) { + this.clientEventsMap.set(event, listener); + } else if ( + event === 'localInvitationAccepted' || + event === 'localInvitationCanceled' || + event === 'localInvitationFailure' || + event === 'localInvitationReceivedByPeer' || + event === 'localInvitationRefused' + ) { + this.localInvitationEventsMap.set(event, listener); + } else if ( + event === 'remoteInvitationAccepted' || + event === 'remoteInvitationCanceled' || + event === 'remoteInvitationFailure' || + event === 'remoteInvitationRefused' + ) { + this.remoteInvitationEventsMap.set(event, listener); + } + } + + createClient(APP_ID: string) { + this.appId = APP_ID; + this.client = AgoraRTM.createInstance(this.appId); + + if ($config.GEO_FENCING) { + try { + //include area is comma seperated value + let includeArea = $config.GEO_FENCING_INCLUDE_AREA + ? $config.GEO_FENCING_INCLUDE_AREA + : AREAS.GLOBAL; + + //exclude area is single value + let excludeArea = $config.GEO_FENCING_EXCLUDE_AREA + ? $config.GEO_FENCING_EXCLUDE_AREA + : ''; + + includeArea = includeArea?.split(','); + + //pass excludedArea if only its provided + if (excludeArea) { + AgoraRTM.setArea({ + areaCodes: includeArea, + excludedArea: excludeArea, + }); + } + //otherwise we can pass area directly + else { + AgoraRTM.setArea({areaCodes: includeArea}); + } + } catch (setAeraError) { + console.log('error on RTM setArea', setAeraError); + } + } + + window.rtmClient = this.client; + + this.client.on('ConnectionStateChanged', (state, reason) => { + this.clientEventsMap.get('connectionStateChanged')({state, reason}); + }); + + this.client.on('MessageFromPeer', (msg, uid, msgProps) => { + this.clientEventsMap.get('messageReceived')({ + text: msg.text, + ts: msgProps.serverReceivedTs, + offline: msgProps.isOfflineMessage, + peerId: uid, + }); + }); + + this.client.on('RemoteInvitationReceived', (remoteInvitation: any) => { + this.remoteInvititations.set(remoteInvitation.callerId, remoteInvitation); + this.clientEventsMap.get('remoteInvitationReceived')({ + callerId: remoteInvitation.callerId, + content: remoteInvitation.content, + state: remoteInvitation.state, + channelId: remoteInvitation.channelId, + response: remoteInvitation.response, + }); + + remoteInvitation.on('RemoteInvitationAccepted', () => { + this.remoteInvitationEventsMap.get('RemoteInvitationAccepted')({ + callerId: remoteInvitation.callerId, + content: remoteInvitation.content, + state: remoteInvitation.state, + channelId: remoteInvitation.channelId, + response: remoteInvitation.response, + }); + }); + + remoteInvitation.on('RemoteInvitationCanceled', (content: string) => { + this.remoteInvitationEventsMap.get('remoteInvitationCanceled')({ + callerId: remoteInvitation.callerId, + content: content, + state: remoteInvitation.state, + channelId: remoteInvitation.channelId, + response: remoteInvitation.response, + }); + }); + + remoteInvitation.on('RemoteInvitationFailure', (reason: string) => { + this.remoteInvitationEventsMap.get('remoteInvitationFailure')({ + callerId: remoteInvitation.callerId, + content: remoteInvitation.content, + state: remoteInvitation.state, + channelId: remoteInvitation.channelId, + response: remoteInvitation.response, + code: -1, //Web sends string, RN expect number but can't find enum + }); + }); + + remoteInvitation.on('RemoteInvitationRefused', () => { + this.remoteInvitationEventsMap.get('remoteInvitationRefused')({ + callerId: remoteInvitation.callerId, + content: remoteInvitation.content, + state: remoteInvitation.state, + channelId: remoteInvitation.channelId, + response: remoteInvitation.response, + }); + }); + }); + + this.client.on('TokenExpired', () => { + this.clientEventsMap.get('tokenExpired')({}); //RN expect evt: any + }); + } + + async login(loginParam: {uid: string; token?: string}): Promise { + return this.client.login(loginParam); + } + + async logout(): Promise { + return await this.client.logout(); + } + + async joinChannel(channelId: string): Promise { + this.channelMap.set(channelId, this.client.createChannel(channelId)); + this.channelMap + .get(channelId) + .on('ChannelMessage', (msg: {text: string}, uid: string, messagePros) => { + let text = msg.text; + let ts = messagePros.serverReceivedTs; + this.channelEventsMap.get('channelMessageReceived')({ + uid, + channelId, + text, + ts, + }); + }); + this.channelMap.get(channelId).on('MemberJoined', (uid: string) => { + this.channelEventsMap.get('channelMemberJoined')({uid, channelId}); + }); + this.channelMap.get(channelId).on('MemberLeft', (uid: string) => { + console.log('Member Left', this.channelEventsMap); + this.channelEventsMap.get('channelMemberLeft')({uid}); + }); + this.channelMap + .get(channelId) + .on('AttributesUpdated', (attributes: RtmChannelAttribute) => { + /** + * a) Kindly note the below event listener 'channelAttributesUpdated' expects type + * RtmChannelAttribute[] (array of objects [{key: 'valueOfKey', value: 'valueOfValue}]) + * whereas the above listener 'AttributesUpdated' receives attributes in object form + * {[valueOfKey]: valueOfValue} of type RtmChannelAttribute + * b) Hence in this bridge the data should be modified to keep in sync with both the + * listeners for web and listener for native + */ + /** + * 1. Loop through object + * 2. Create a object {key: "", value: ""} and push into array + * 3. Return the Array + */ + const channelAttributes = Object.keys(attributes).reduce((acc, key) => { + const {value, lastUpdateTs, lastUpdateUserId} = attributes[key]; + acc.push({key, value, lastUpdateTs, lastUpdateUserId}); + return acc; + }, []); + + this.channelEventsMap.get('ChannelAttributesUpdated')( + channelAttributes, + ); + }); + + return this.channelMap.get(channelId).join(); + } + + async leaveChannel(channelId: string): Promise { + if (this.channelMap.get(channelId)) { + return this.channelMap.get(channelId).leave(); + } else { + Promise.reject('Wrong channel'); + } + } + + async sendMessageByChannelId(channel: string, message: string): Promise { + if (this.channelMap.get(channel)) { + return this.channelMap.get(channel).sendMessage({text: message}); + } else { + console.log(this.channelMap, channel); + Promise.reject('Wrong channel'); + } + } + + destroyClient() { + console.log('Destroy called'); + this.channelEventsMap.forEach((callback, event) => { + this.client.off(event, callback); + }); + this.channelEventsMap.clear(); + this.channelMap.clear(); + this.clientEventsMap.clear(); + this.remoteInvitationEventsMap.clear(); + this.localInvitationEventsMap.clear(); + } + + async getChannelMembersBychannelId(channel: string) { + if (this.channelMap.get(channel)) { + let memberArray: Array = []; + let currentChannel = this.channelMap.get(channel); + await currentChannel.getMembers().then((arr: Array) => { + arr.map((elem: number) => { + memberArray.push({ + channelId: channel, + uid: elem, + }); + }); + }); + return {members: memberArray}; + } else { + Promise.reject('Wrong channel'); + } + } + + async queryPeersOnlineStatus(uid: Array) { + let peerArray: Array = []; + await this.client.queryPeersOnlineStatus(uid).then(list => { + Object.entries(list).forEach(value => { + peerArray.push({ + online: value[1], + uid: value[0], + }); + }); + }); + return {items: peerArray}; + } + + async renewToken(token: string) { + return this.client.renewToken(token); + } + + async getUserAttributesByUid(uid: string) { + let response = {}; + await this.client + .getUserAttributes(uid) + .then((attributes: string) => { + response = {attributes, uid}; + }) + .catch((e: any) => { + Promise.reject(e); + }); + return response; + } + + async getChannelAttributes(channelId: string) { + let response = {}; + await this.client + .getChannelAttributes(channelId) + .then((attributes: RtmChannelAttribute) => { + /** + * Here the attributes received are in the format {[valueOfKey]: valueOfValue} of type RtmChannelAttribute + * We need to convert it into (array of objects [{key: 'valueOfKey', value: 'valueOfValue}]) + /** + * 1. Loop through object + * 2. Create a object {key: "", value: ""} and push into array + * 3. Return the Array + */ + const channelAttributes = Object.keys(attributes).reduce((acc, key) => { + const {value, lastUpdateTs, lastUpdateUserId} = attributes[key]; + acc.push({key, value, lastUpdateTs, lastUpdateUserId}); + return acc; + }, []); + response = channelAttributes; + }) + .catch((e: any) => { + Promise.reject(e); + }); + return response; + } + + async removeAllLocalUserAttributes() { + return this.client.clearLocalUserAttributes(); + } + + async removeLocalUserAttributesByKeys(keys: string[]) { + return this.client.deleteLocalUserAttributesByKeys(keys); + } + + async replaceLocalUserAttributes(attributes: string[]) { + let formattedAttributes: any = {}; + attributes.map(attribute => { + let key = Object.values(attribute)[0]; + let value = Object.values(attribute)[1]; + formattedAttributes[key] = value; + }); + return this.client.setLocalUserAttributes({...formattedAttributes}); + } + + async setLocalUserAttributes(attributes: string[]) { + let formattedAttributes: any = {}; + attributes.map(attribute => { + let key = Object.values(attribute)[0]; + let value = Object.values(attribute)[1]; + formattedAttributes[key] = value; + // console.log('!!!!formattedAttributes', formattedAttributes, key, value); + }); + return this.client.setLocalUserAttributes({...formattedAttributes}); + } + + async addOrUpdateLocalUserAttributes(attributes: RtmAttribute[]) { + let formattedAttributes: any = {}; + attributes.map(attribute => { + let key = Object.values(attribute)[0]; + let value = Object.values(attribute)[1]; + formattedAttributes[key] = value; + }); + return this.client.addOrUpdateLocalUserAttributes({...formattedAttributes}); + } + + async addOrUpdateChannelAttributes( + channelId: string, + attributes: RtmChannelAttribute[], + option: ChannelAttributeOptions, + ): Promise { + let formattedAttributes: any = {}; + attributes.map(attribute => { + let key = Object.values(attribute)[0]; + let value = Object.values(attribute)[1]; + formattedAttributes[key] = value; + }); + return this.client.addOrUpdateChannelAttributes( + channelId, + {...formattedAttributes}, + option, + ); + } + + async sendLocalInvitation(invitationProps: any) { + let invite = this.client.createLocalInvitation(invitationProps.uid); + this.localInvititations.set(invitationProps.uid, invite); + invite.content = invitationProps.content; + + invite.on('LocalInvitationAccepted', (response: string) => { + this.localInvitationEventsMap.get('localInvitationAccepted')({ + calleeId: invite.calleeId, + content: invite.content, + state: invite.state, + channelId: invite.channelId, + response, + }); + }); + + invite.on('LocalInvitationCanceled', () => { + this.localInvitationEventsMap.get('localInvitationCanceled')({ + calleeId: invite.calleeId, + content: invite.content, + state: invite.state, + channelId: invite.channelId, + response: invite.response, + }); + }); + + invite.on('LocalInvitationFailure', (reason: string) => { + this.localInvitationEventsMap.get('localInvitationFailure')({ + calleeId: invite.calleeId, + content: invite.content, + state: invite.state, + channelId: invite.channelId, + response: invite.response, + code: -1, //Web sends string, RN expect number but can't find enum + }); + }); + + invite.on('LocalInvitationReceivedByPeer', () => { + this.localInvitationEventsMap.get('localInvitationReceivedByPeer')({ + calleeId: invite.calleeId, + content: invite.content, + state: invite.state, + channelId: invite.channelId, + response: invite.response, + }); + }); + + invite.on('LocalInvitationRefused', (response: string) => { + this.localInvitationEventsMap.get('localInvitationRefused')({ + calleeId: invite.calleeId, + content: invite.content, + state: invite.state, + channelId: invite.channelId, + response: response, + }); + }); + return invite.send(); + } + + async sendMessageToPeer(AgoraPeerMessage: { + peerId: string; + offline: boolean; + text: string; + }) { + return this.client.sendMessageToPeer( + {text: AgoraPeerMessage.text}, + AgoraPeerMessage.peerId, + ); + //check promise result + } + + async acceptRemoteInvitation(remoteInvitationProps: { + uid: string; + response?: string; + channelId: string; + }) { + let invite = this.remoteInvititations.get(remoteInvitationProps.uid); + // console.log(invite); + // console.log(this.remoteInvititations); + // console.log(remoteInvitationProps.uid); + return invite.accept(); + } + + async refuseRemoteInvitation(remoteInvitationProps: { + uid: string; + response?: string; + channelId: string; + }) { + return this.remoteInvititations.get(remoteInvitationProps.uid).refuse(); + } + + async cancelLocalInvitation(LocalInvitationProps: { + uid: string; + content?: string; + channelId?: string; + }) { + console.log(this.localInvititations.get(LocalInvitationProps.uid)); + return this.localInvititations.get(LocalInvitationProps.uid).cancel(); + } + + getSdkVersion(callback: (version: string) => void) { + callback(VERSION); + } + + addListener( + event: EventType, + listener: RtmClientEvents[EventType], + ): Subscription { + if (event === 'ChannelAttributesUpdated') { + this.channelEventsMap.set(event, listener as callbackType); + } + return { + remove: () => { + console.log( + 'Use destroy method to remove all the event listeners from the RtcEngine instead.', + ); + }, + }; + } +} diff --git a/template/bridge/rtm/web/index.ts b/template/bridge/rtm/web/index.ts index ed092d0c6..63c7af031 100644 --- a/template/bridge/rtm/web/index.ts +++ b/template/bridge/rtm/web/index.ts @@ -1,540 +1,472 @@ -/* -******************************************** - 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. -********************************************* -*/ -// @ts-nocheck import { - ChannelAttributeOptions, - RtmAttribute, - RtmChannelAttribute, - Subscription, -} from 'agora-react-native-rtm/lib/typescript/src'; -import {RtmClientEvents} from 'agora-react-native-rtm/lib/typescript/src/RtmEngine'; -import AgoraRTM, {VERSION} from 'agora-rtm-sdk'; -import RtmClient from 'agora-react-native-rtm'; -import {LogSource, logger} from '../../../src/logger/AppBuilderLogger'; -// export {RtmAttribute} -// -interface RtmAttributePlaceholder {} -export {RtmAttributePlaceholder as RtmAttribute}; - -type callbackType = (args?: any) => void; - -export default class RtmEngine { - public appId: string; - public client: RtmClient; - public channelMap = new Map([]); - public remoteInvititations = new Map([]); - public localInvititations = new Map([]); - public channelEventsMap = new Map([ - ['channelMessageReceived', () => null], - ['channelMemberJoined', () => null], - ['channelMemberLeft', () => null], - ]); - public clientEventsMap = new Map([ - ['connectionStateChanged', () => null], - ['messageReceived', () => null], - ['remoteInvitationReceived', () => null], - ['tokenExpired', () => null], - ]); - public localInvitationEventsMap = new Map([ - ['localInvitationAccepted', () => null], - ['localInvitationCanceled', () => null], - ['localInvitationFailure', () => null], - ['localInvitationReceivedByPeer', () => null], - ['localInvitationRefused', () => null], - ]); - public remoteInvitationEventsMap = new Map([ - ['remoteInvitationAccepted', () => null], - ['remoteInvitationCanceled', () => null], - ['remoteInvitationFailure', () => null], - ['remoteInvitationRefused', () => null], + type Metadata as NativeMetadata, + type MetadataItem as NativeMetadataItem, + type GetUserMetadataOptions as NativeGetUserMetadataOptions, + type RtmChannelType as NativeRtmChannelType, + type SetUserMetadataResponse, + type LoginOptions as NativeLoginOptions, + type RTMClientEventMap as NativeRTMClientEventMap, + type GetUserMetadataResponse as NativeGetUserMetadataResponse, + type GetChannelMetadataResponse as NativeGetChannelMetadataResponse, + type SetOrUpdateUserMetadataOptions as NativeSetOrUpdateUserMetadataOptions, + type IMetadataOptions as NativeIMetadataOptions, + type StorageEvent as NativeStorageEvent, + type PresenceEvent as NativePresenceEvent, + type MessageEvent as NativeMessageEvent, + type SubscribeOptions as NativeSubscribeOptions, + type PublishOptions as NativePublishOptions, +} from 'agora-react-native-rtm'; +import AgoraRTM, { + RTMClient, + GetUserMetadataResponse, + GetChannelMetadataResponse, + PublishOptions, + ChannelType, + MetaDataDetail, +} from 'agora-rtm-sdk'; +import { + linkStatusReasonCodeMapping, + nativeChannelTypeMapping, + nativeLinkStateMapping, + nativeMessageEventTypeMapping, + nativePresenceEventTypeMapping, + nativeStorageEventTypeMapping, + nativeStorageTypeMapping, + webChannelTypeMapping, +} from './Types'; + +type CallbackType = (args?: any) => void; + +// Conversion function +const convertWebToNativeMetadata = (webMetadata: any): NativeMetadata => { + // Convert object entries to MetadataItem array + const items: NativeMetadataItem[] = + Object.entries(webMetadata.metadata).map( + ([key, metadataItem]: [string, MetaDataDetail]) => { + return { + key: key, + value: metadataItem.value, + revision: metadataItem.revision, + authorUserId: metadataItem.authorUid, + updateTs: metadataItem.updated, + }; + }, + ) || []; + + // Create native Metadata object + const nativeMetadata: NativeMetadata = { + majorRevision: webMetadata?.revision || -1, // Use first item's revision as major revision + items: items, + itemCount: webMetadata?.totalCount || 0, + }; + + return nativeMetadata; +}; + +export class RTMWebClient { + private client: RTMClient; + private appId: string; + private userId: string; + private eventsMap = new Map([ + ['linkState', () => null], + ['storage', () => null], + ['presence', () => null], + ['message', () => null], ]); - constructor() { - this.appId = ''; - logger.debug(LogSource.AgoraSDK, 'Log', 'Using RTM Bridge'); - } - - on(event: any, listener: any) { - if ( - event === 'channelMessageReceived' || - event === 'channelMemberJoined' || - event === 'channelMemberLeft' - ) { - this.channelEventsMap.set(event, listener); - } else if ( - event === 'connectionStateChanged' || - event === 'messageReceived' || - event === 'remoteInvitationReceived' || - event === 'tokenExpired' - ) { - this.clientEventsMap.set(event, listener); - } else if ( - event === 'localInvitationAccepted' || - event === 'localInvitationCanceled' || - event === 'localInvitationFailure' || - event === 'localInvitationReceivedByPeer' || - event === 'localInvitationRefused' - ) { - this.localInvitationEventsMap.set(event, listener); - } else if ( - event === 'remoteInvitationAccepted' || - event === 'remoteInvitationCanceled' || - event === 'remoteInvitationFailure' || - event === 'remoteInvitationRefused' - ) { - this.remoteInvitationEventsMap.set(event, listener); - } - } - createClient(APP_ID: string) { - this.appId = APP_ID; - this.client = AgoraRTM.createInstance(this.appId); - - if ($config.GEO_FENCING) { - try { - //include area is comma seperated value - let includeArea = $config.GEO_FENCING_INCLUDE_AREA - ? $config.GEO_FENCING_INCLUDE_AREA - : AREAS.GLOBAL; - - //exclude area is single value - let excludeArea = $config.GEO_FENCING_EXCLUDE_AREA - ? $config.GEO_FENCING_EXCLUDE_AREA - : ''; - - includeArea = includeArea?.split(','); - - //pass excludedArea if only its provided - if (excludeArea) { - AgoraRTM.setArea({ - areaCodes: includeArea, - excludedArea: excludeArea, - }); - } - //otherwise we can pass area directly - else { - AgoraRTM.setArea({areaCodes: includeArea}); - } - } catch (setAeraError) { - console.log('error on RTM setArea', setAeraError); - } - } - - window.rtmClient = this.client; - - this.client.on('ConnectionStateChanged', (state, reason) => { - this.clientEventsMap.get('connectionStateChanged')({state, reason}); - }); - - this.client.on('MessageFromPeer', (msg, uid, msgProps) => { - this.clientEventsMap.get('messageReceived')({ - text: msg.text, - ts: msgProps.serverReceivedTs, - offline: msgProps.isOfflineMessage, - peerId: uid, + constructor(appId: string, userId: string) { + this.appId = appId; + this.userId = `${userId}`; + try { + // Create the actual web RTM client + this.client = new AgoraRTM.RTM(this.appId, this.userId); + + this.client.addEventListener('linkState', data => { + const nativeState = { + ...data, + currentState: + nativeLinkStateMapping[data.currentState] || + nativeLinkStateMapping.IDLE, + previousState: + nativeLinkStateMapping[data.previousState] || + nativeLinkStateMapping.IDLE, + reasonCode: linkStatusReasonCodeMapping[data.reasonCode] || 0, + }; + (this.eventsMap.get('linkState') ?? (() => {}))(nativeState); }); - }); - this.client.on('RemoteInvitationReceived', (remoteInvitation: any) => { - this.remoteInvititations.set(remoteInvitation.callerId, remoteInvitation); - this.clientEventsMap.get('remoteInvitationReceived')({ - callerId: remoteInvitation.callerId, - content: remoteInvitation.content, - state: remoteInvitation.state, - channelId: remoteInvitation.channelId, - response: remoteInvitation.response, + this.client.addEventListener('storage', data => { + const nativeStorageEvent: NativeStorageEvent = { + channelType: nativeChannelTypeMapping[data.channelType], + storageType: nativeStorageTypeMapping[data.storageType], + eventType: nativeStorageEventTypeMapping[data.eventType], + data: convertWebToNativeMetadata(data.data), + timestamp: data.timestamp, + }; + (this.eventsMap.get('storage') ?? (() => {}))(nativeStorageEvent); }); - remoteInvitation.on('RemoteInvitationAccepted', () => { - this.remoteInvitationEventsMap.get('RemoteInvitationAccepted')({ - callerId: remoteInvitation.callerId, - content: remoteInvitation.content, - state: remoteInvitation.state, - channelId: remoteInvitation.channelId, - response: remoteInvitation.response, - }); + this.client.addEventListener('presence', data => { + const nativePresenceEvent: NativePresenceEvent = { + channelName: data.channelName, + channelType: nativeChannelTypeMapping[data.channelType], + type: nativePresenceEventTypeMapping[data.eventType], + publisher: data.publisher, + timestamp: data.timestamp, + }; + (this.eventsMap.get('presence') ?? (() => {}))(nativePresenceEvent); }); - remoteInvitation.on('RemoteInvitationCanceled', (content: string) => { - this.remoteInvitationEventsMap.get('remoteInvitationCanceled')({ - callerId: remoteInvitation.callerId, - content: content, - state: remoteInvitation.state, - channelId: remoteInvitation.channelId, - response: remoteInvitation.response, - }); + this.client.addEventListener('message', data => { + const nativeMessageEvent: NativeMessageEvent = { + ...data, + channelType: nativeChannelTypeMapping[data.channelType], + messageType: nativeMessageEventTypeMapping[data.messageType], + message: `${data.message}`, + }; + (this.eventsMap.get('message') ?? (() => {}))(nativeMessageEvent); }); + } catch (error) { + const contextError = new Error( + `Failed to create RTMWebClient for appId: ${this.appId}, userId: ${ + this.userId + }. Error: ${error.message || error}`, + ); + console.error('RTMWebClient constructor error:', contextError); + throw contextError; + } + } - remoteInvitation.on('RemoteInvitationFailure', (reason: string) => { - this.remoteInvitationEventsMap.get('remoteInvitationFailure')({ - callerId: remoteInvitation.callerId, - content: remoteInvitation.content, - state: remoteInvitation.state, - channelId: remoteInvitation.channelId, - response: remoteInvitation.response, - code: -1, //Web sends string, RN expect number but can't find enum + // Storage methods + get storage() { + return { + setUserMetadata: ( + data: NativeMetadata, + options?: NativeSetOrUpdateUserMetadataOptions, + ): Promise => { + // 1. Validate input parameters + if (!data) { + throw new Error('setUserMetadata: data parameter is required'); + } + if (!data.items || !Array.isArray(data.items)) { + throw new Error( + 'setUserMetadata: data.items must be a non-empty array', + ); + } + if (data.items.length === 0) { + throw new Error('setUserMetadata: data.items cannot be empty'); + } + // 2. Make sure key is present as this is mandatory + // https://docs.agora.io/en/signaling/reference/api?platform=web#storagesetuserpropsag_platform + const validatedItems = data.items.map((item, index) => { + if (!item.key || typeof item.key !== 'string') { + throw new Error( + `setUserMetadata: item at index ${index} missing required 'key' property`, + ); + } + return { + key: item.key, + value: item.value || '', // Default to empty string if not provided + revision: item.revision || -1, // Default to -1 if not provided + }; }); - }); - - remoteInvitation.on('RemoteInvitationRefused', () => { - this.remoteInvitationEventsMap.get('remoteInvitationRefused')({ - callerId: remoteInvitation.callerId, - content: remoteInvitation.content, - state: remoteInvitation.state, - channelId: remoteInvitation.channelId, - response: remoteInvitation.response, + // Map native signature to web signature + return this.client.storage.setUserMetadata(validatedItems, { + addTimeStamp: options?.addTimeStamp || true, + addUserId: options?.addUserId || true, }); - }); - }); - - this.client.on('TokenExpired', () => { - this.clientEventsMap.get('tokenExpired')({}); //RN expect evt: any - }); - } - - async login(loginParam: {uid: string; token?: string}): Promise { - return this.client.login(loginParam); - } - - async logout(): Promise { - return await this.client.logout(); - } + }, - async joinChannel(channelId: string): Promise { - this.channelMap.set(channelId, this.client.createChannel(channelId)); - this.channelMap - .get(channelId) - .on('ChannelMessage', (msg: {text: string}, uid: string, messagePros) => { - let text = msg.text; - let ts = messagePros.serverReceivedTs; - this.channelEventsMap.get('channelMessageReceived')({ - uid, - channelId, - text, - ts, - }); - }); - this.channelMap.get(channelId).on('MemberJoined', (uid: string) => { - this.channelEventsMap.get('channelMemberJoined')({uid, channelId}); - }); - this.channelMap.get(channelId).on('MemberLeft', (uid: string) => { - console.log('Member Left', this.channelEventsMap); - this.channelEventsMap.get('channelMemberLeft')({uid}); - }); - this.channelMap - .get(channelId) - .on('AttributesUpdated', (attributes: RtmChannelAttribute) => { - /** - * a) Kindly note the below event listener 'channelAttributesUpdated' expects type - * RtmChannelAttribute[] (array of objects [{key: 'valueOfKey', value: 'valueOfValue}]) - * whereas the above listener 'AttributesUpdated' receives attributes in object form - * {[valueOfKey]: valueOfValue} of type RtmChannelAttribute - * b) Hence in this bridge the data should be modified to keep in sync with both the - * listeners for web and listener for native - */ + getUserMetadata: async (options: NativeGetUserMetadataOptions) => { + // Validate input parameters + if (!options) { + throw new Error('getUserMetadata: options parameter is required'); + } + if ( + !options.userId || + typeof options.userId !== 'string' || + options.userId.trim() === '' + ) { + throw new Error( + 'getUserMetadata: options.userId must be a non-empty string', + ); + } + const webResponse: GetUserMetadataResponse = + await this.client.storage.getUserMetadata({ + userId: options.userId, + }); /** - * 1. Loop through object - * 2. Create a object {key: "", value: ""} and push into array - * 3. Return the Array + * majorRevision : 13483783553 + * metadata : + * { + * isHost: {authorUid: "", revision: 13483783553, updated: 0, value : "true"}, + * screenUid: {…}} + * } + * timestamp: 0 + * totalCount: 2 + * userId: "xxx" */ - const channelAttributes = Object.keys(attributes).reduce((acc, key) => { - const {value, lastUpdateTs, lastUpdateUserId} = attributes[key]; - acc.push({key, value, lastUpdateTs, lastUpdateUserId}); - return acc; - }, []); - - this.channelEventsMap.get('ChannelAttributesUpdated')( - channelAttributes, + const items = Object.entries(webResponse.metadata).map( + ([key, metadataItem]) => ({ + key: key, + value: metadataItem.value, + }), ); - }); - - return this.channelMap.get(channelId).join(); - } - - async leaveChannel(channelId: string): Promise { - if (this.channelMap.get(channelId)) { - return this.channelMap.get(channelId).leave(); - } else { - Promise.reject('Wrong channel'); - } - } - - async sendMessageByChannelId(channel: string, message: string): Promise { - if (this.channelMap.get(channel)) { - return this.channelMap.get(channel).sendMessage({text: message}); - } else { - console.log(this.channelMap, channel); - Promise.reject('Wrong channel'); - } - } - - destroyClient() { - console.log('Destroy called'); - this.channelEventsMap.forEach((callback, event) => { - this.client.off(event, callback); - }); - this.channelEventsMap.clear(); - this.channelMap.clear(); - this.clientEventsMap.clear(); - this.remoteInvitationEventsMap.clear(); - this.localInvitationEventsMap.clear(); - } + const nativeResponse: NativeGetUserMetadataResponse = { + items: [...items], + itemCount: webResponse.totalCount, + userId: webResponse.userId, + timestamp: webResponse.timestamp, + }; + return nativeResponse; + }, - async getChannelMembersBychannelId(channel: string) { - if (this.channelMap.get(channel)) { - let memberArray: Array = []; - let currentChannel = this.channelMap.get(channel); - await currentChannel.getMembers().then((arr: Array) => { - arr.map((elem: number) => { - memberArray.push({ - channelId: channel, - uid: elem, - }); + setChannelMetadata: async ( + channelName: string, + channelType: NativeRtmChannelType, + data: NativeMetadata, + options?: NativeIMetadataOptions, + ) => { + // Validate input parameters + if ( + !channelName || + typeof channelName !== 'string' || + channelName.trim() === '' + ) { + throw new Error( + 'setChannelMetadata: channelName must be a non-empty string', + ); + } + if (typeof channelType !== 'number') { + throw new Error('setChannelMetadata: channelType must be a number'); + } + if (!data) { + throw new Error('setChannelMetadata: data parameter is required'); + } + if (!data.items || !Array.isArray(data.items)) { + throw new Error('setChannelMetadata: data.items must be an array'); + } + if (data.items.length === 0) { + throw new Error('setChannelMetadata: data.items cannot be empty'); + } + // 2. Make sure key is present as this is mandatory + // https://docs.agora.io/en/signaling/reference/api?platform=web#storagesetuserpropsag_platform + const validatedItems = data.items.map((item, index) => { + if (!item.key || typeof item.key !== 'string') { + throw new Error( + `setChannelMetadata: item at index ${index} missing required 'key' property`, + ); + } + return { + key: item.key, + value: item.value || '', // Default to empty string if not provided + revision: item.revision || -1, // Default to -1 if not provided + }; }); - }); - return {members: memberArray}; - } else { - Promise.reject('Wrong channel'); - } - } + return this.client.storage.setChannelMetadata( + channelName, + (webChannelTypeMapping[channelType] as ChannelType) || 'MESSAGE', + validatedItems, + { + addUserId: options?.addUserId || true, + addTimeStamp: options?.addTimeStamp || true, + }, + ); + }, - async queryPeersOnlineStatus(uid: Array) { - let peerArray: Array = []; - await this.client.queryPeersOnlineStatus(uid).then(list => { - Object.entries(list).forEach(value => { - peerArray.push({ - online: value[1], - uid: value[0], - }); - }); - }); - return {items: peerArray}; + getChannelMetadata: async ( + channelName: string, + channelType: NativeRtmChannelType, + ) => { + try { + const webResponse: GetChannelMetadataResponse = + await this.client.storage.getChannelMetadata( + channelName, + (webChannelTypeMapping[channelType] as ChannelType) || 'MESSAGE', + ); + + const items = Object.entries(webResponse.metadata).map( + ([key, metadataItem]) => ({ + key: key, + value: metadataItem.value, + }), + ); + const nativeResponse: NativeGetChannelMetadataResponse = { + items: [...items], + itemCount: webResponse.totalCount, + timestamp: webResponse.timestamp, + channelName: webResponse.channelName, + channelType: nativeChannelTypeMapping.MESSAGE, + }; + return nativeResponse; + } catch (error) { + const contextError = new Error( + `Failed to get channel metadata for channel '${channelName}' with type ${channelType}: ${ + error.message || error + }`, + ); + console.error('BRIDGE getChannelMetadata error:', contextError); + throw contextError; + } + }, + }; } - async renewToken(token: string) { - return this.client.renewToken(token); - } + get presence() { + return { + getOnlineUsers: async ( + channelName: string, + channelType: NativeRtmChannelType, + ) => { + // Validate input parameters + if ( + !channelName || + typeof channelName !== 'string' || + channelName.trim() === '' + ) { + throw new Error( + 'getOnlineUsers: channelName must be a non-empty string', + ); + } + if (typeof channelType !== 'number') { + throw new Error('getOnlineUsers: channelType must be a number'); + } - async getUserAttributesByUid(uid: string) { - let response = {}; - await this.client - .getUserAttributes(uid) - .then((attributes: string) => { - response = {attributes, uid}; - }) - .catch((e: any) => { - Promise.reject(e); - }); - return response; - } + try { + // Call web SDK's presence method + const result = await this.client.presence.getOnlineUsers( + channelName, + (webChannelTypeMapping[channelType] as ChannelType) || 'MESSAGE', + ); + return result; + } catch (error) { + const contextError = new Error( + `Failed to get online users for channel '${channelName}' with type ${channelType}: ${ + error.message || error + }`, + ); + console.error('BRIDGE presence error:', contextError); + throw contextError; + } + }, - async getChannelAttributes(channelId: string) { - let response = {}; - await this.client - .getChannelAttributes(channelId) - .then((attributes: RtmChannelAttribute) => { - /** - * Here the attributes received are in the format {[valueOfKey]: valueOfValue} of type RtmChannelAttribute - * We need to convert it into (array of objects [{key: 'valueOfKey', value: 'valueOfValue}]) - /** - * 1. Loop through object - * 2. Create a object {key: "", value: ""} and push into array - * 3. Return the Array - */ - const channelAttributes = Object.keys(attributes).reduce((acc, key) => { - const {value, lastUpdateTs, lastUpdateUserId} = attributes[key]; - acc.push({key, value, lastUpdateTs, lastUpdateUserId}); - return acc; - }, []); - response = channelAttributes; - }) - .catch((e: any) => { - Promise.reject(e); - }); - return response; - } + whoNow: async ( + channelName: string, + channelType?: NativeRtmChannelType, + ) => { + const webChannelType = channelType + ? (webChannelTypeMapping[channelType] as ChannelType) + : 'MESSAGE'; + return this.client.presence.whoNow(channelName, webChannelType); + }, - async removeAllLocalUserAttributes() { - return this.client.clearLocalUserAttributes(); + whereNow: async (userId: string) => { + return this.client.presence.whereNow(userId); + }, + }; } - async removeLocalUserAttributesByKeys(keys: string[]) { - return this.client.deleteLocalUserAttributesByKeys(keys); + addEventListener( + event: keyof NativeRTMClientEventMap, + listener: (event: any) => void, + ) { + if (this.client) { + // Simply replace the handler in our map - web client listeners are fixed in constructor + this.eventsMap.set(event, listener as CallbackType); + } } - async replaceLocalUserAttributes(attributes: string[]) { - let formattedAttributes: any = {}; - attributes.map(attribute => { - let key = Object.values(attribute)[0]; - let value = Object.values(attribute)[1]; - formattedAttributes[key] = value; - }); - return this.client.setLocalUserAttributes({...formattedAttributes}); + removeEventListener( + event: keyof NativeRTMClientEventMap, + _listener: (event: any) => void, + ) { + if (this.client && this.eventsMap.has(event)) { + const prevListener = this.eventsMap.get(event); + if (prevListener) { + this.client.removeEventListener(event, prevListener); + } + this.eventsMap.set(event, () => null); // reset to no-op + } } - async setLocalUserAttributes(attributes: string[]) { - let formattedAttributes: any = {}; - attributes.map(attribute => { - let key = Object.values(attribute)[0]; - let value = Object.values(attribute)[1]; - formattedAttributes[key] = value; - // console.log('!!!!formattedAttributes', formattedAttributes, key, value); - }); - return this.client.setLocalUserAttributes({...formattedAttributes}); + // Core RTM methods - direct delegation to web SDK + async login(options?: NativeLoginOptions) { + if (!options?.token) { + throw new Error('login: token is required in options'); + } + return this.client.login({token: options.token}); } - async addOrUpdateLocalUserAttributes(attributes: RtmAttribute[]) { - let formattedAttributes: any = {}; - attributes.map(attribute => { - let key = Object.values(attribute)[0]; - let value = Object.values(attribute)[1]; - formattedAttributes[key] = value; - }); - return this.client.addOrUpdateLocalUserAttributes({...formattedAttributes}); + async logout() { + return this.client.logout(); } - async addOrUpdateChannelAttributes( - channelId: string, - attributes: RtmChannelAttribute[], - option: ChannelAttributeOptions, - ): Promise { - let formattedAttributes: any = {}; - attributes.map(attribute => { - let key = Object.values(attribute)[0]; - let value = Object.values(attribute)[1]; - formattedAttributes[key] = value; - }); - return this.client.addOrUpdateChannelAttributes( - channelId, - {...formattedAttributes}, - option, - ); + async subscribe(channelName: string, options?: NativeSubscribeOptions) { + if ( + !channelName || + typeof channelName !== 'string' || + channelName.trim() === '' + ) { + throw new Error('subscribe: channelName must be a non-empty string'); + } + return this.client.subscribe(channelName, options); } - async sendLocalInvitation(invitationProps: any) { - let invite = this.client.createLocalInvitation(invitationProps.uid); - this.localInvititations.set(invitationProps.uid, invite); - invite.content = invitationProps.content; - - invite.on('LocalInvitationAccepted', (response: string) => { - this.localInvitationEventsMap.get('localInvitationAccepted')({ - calleeId: invite.calleeId, - content: invite.content, - state: invite.state, - channelId: invite.channelId, - response, - }); - }); - - invite.on('LocalInvitationCanceled', () => { - this.localInvitationEventsMap.get('localInvitationCanceled')({ - calleeId: invite.calleeId, - content: invite.content, - state: invite.state, - channelId: invite.channelId, - response: invite.response, - }); - }); - - invite.on('LocalInvitationFailure', (reason: string) => { - this.localInvitationEventsMap.get('localInvitationFailure')({ - calleeId: invite.calleeId, - content: invite.content, - state: invite.state, - channelId: invite.channelId, - response: invite.response, - code: -1, //Web sends string, RN expect number but can't find enum - }); - }); - - invite.on('LocalInvitationReceivedByPeer', () => { - this.localInvitationEventsMap.get('localInvitationReceivedByPeer')({ - calleeId: invite.calleeId, - content: invite.content, - state: invite.state, - channelId: invite.channelId, - response: invite.response, - }); - }); - - invite.on('LocalInvitationRefused', (response: string) => { - this.localInvitationEventsMap.get('localInvitationRefused')({ - calleeId: invite.calleeId, - content: invite.content, - state: invite.state, - channelId: invite.channelId, - response: response, - }); - }); - return invite.send(); + async unsubscribe(channelName: string) { + return this.client.unsubscribe(channelName); } - async sendMessageToPeer(AgoraPeerMessage: { - peerId: string; - offline: boolean; - text: string; - }) { - return this.client.sendMessageToPeer( - {text: AgoraPeerMessage.text}, - AgoraPeerMessage.peerId, - ); - //check promise result - } + async publish( + channelName: string, + message: string, + options?: NativePublishOptions, + ) { + // Validate input parameters + if ( + !channelName || + typeof channelName !== 'string' || + channelName.trim() === '' + ) { + throw new Error('publish: channelName must be a non-empty string'); + } + if (typeof message !== 'string') { + throw new Error('publish: message must be a string'); + } - async acceptRemoteInvitation(remoteInvitationProps: { - uid: string; - response?: string; - channelId: string; - }) { - let invite = this.remoteInvititations.get(remoteInvitationProps.uid); - // console.log(invite); - // console.log(this.remoteInvititations); - // console.log(remoteInvitationProps.uid); - return invite.accept(); + const webOptions: PublishOptions = { + ...options, + channelType: + (webChannelTypeMapping[options?.channelType] as ChannelType) || + 'MESSAGE', + }; + return this.client.publish(channelName, message, webOptions); } - async refuseRemoteInvitation(remoteInvitationProps: { - uid: string; - response?: string; - channelId: string; - }) { - return this.remoteInvititations.get(remoteInvitationProps.uid).refuse(); + async renewToken(token: string) { + return this.client.renewToken(token); } - async cancelLocalInvitation(LocalInvitationProps: { - uid: string; - content?: string; - channelId?: string; - }) { - console.log(this.localInvititations.get(LocalInvitationProps.uid)); - return this.localInvititations.get(LocalInvitationProps.uid).cancel(); + removeAllListeners() { + this.eventsMap = new Map([ + ['linkState', () => null], + ['storage', () => null], + ['presence', () => null], + ['message', () => null], + ]); + return this.client.removeAllListeners(); } +} - getSdkVersion(callback: (version: string) => void) { - callback(VERSION); - } +export class RtmConfig { + public appId: string; + public userId: string; - addListener( - event: EventType, - listener: RtmClientEvents[EventType], - ): Subscription { - if (event === 'ChannelAttributesUpdated') { - this.channelEventsMap.set(event, listener as callbackType); - } - return { - remove: () => { - console.log( - 'Use destroy method to remove all the event listeners from the RtcEngine instead.', - ); - }, - }; + constructor(config: {appId: string; userId: string}) { + this.appId = config.appId; + this.userId = config.userId; } } +// Factory function to create RTM client +export function createAgoraRtmClient(config: RtmConfig): RTMWebClient { + return new RTMWebClient(config.appId, config.userId); +} diff --git a/template/defaultConfig.js b/template/defaultConfig.js index 19d8fd349..d348791fa 100644 --- a/template/defaultConfig.js +++ b/template/defaultConfig.js @@ -11,7 +11,7 @@ const DefaultConfig = { PRECALL: true, CHAT: true, CLOUD_RECORDING: false, - RECORDING_MODE: 'WEB', + RECORDING_MODE: 'MIX', SCREEN_SHARING: true, LANDING_SUB_HEADING: 'The Real-Time Engagement Platform', ENCRYPTION_ENABLED: false, diff --git a/template/ios/Podfile b/template/ios/Podfile index bbb0b8661..5f5bce43a 100644 --- a/template/ios/Podfile +++ b/template/ios/Podfile @@ -5,6 +5,13 @@ require Pod::Executable.execute_command('node', ['-p', {paths: [process.argv[1]]}, )', __dir__]).strip +require 'cocoapods' +class Pod::Installer::Xcode::TargetValidator + def verify_no_duplicate_framework_and_library_names(*) + # Do nothing - allows duplicate frameworks + end +end + platform :ios, min_ios_version_supported prepare_react_native_project! @@ -59,6 +66,40 @@ target 'HelloWorld' do :mac_catalyst_enabled => false ) __apply_Xcode_12_5_M1_post_install_workaround(installer) + + # === BEGIN: Bitcode Stripping === + bitcode_strip_path = `xcrun --find bitcode_strip`.chomp + + def strip_bitcode(framework_path, bitcode_strip_path) + if File.exist?(framework_path) + puts ":wrench: Stripping bitcode from: #{framework_path}" + system("#{bitcode_strip_path} #{framework_path} -r -o #{framework_path}") + else + puts ":warning: Framework not found: #{framework_path}" + end + end + + frameworks_to_strip = [ + # Agora RTM - device & simulator + "Pods/AgoraRtm_iOS/AgoraRtmKit.xcframework/ios-arm64_armv7/AgoraRtmKit.framework/AgoraRtmKit", + "Pods/AgoraRtm_iOS/AgoraRtmKit.xcframework/ios-arm64_i386_x86_64-simulator/AgoraRtmKit.framework/AgoraRtmKit", + + # Hermes - device & simulator + "Pods/hermes-engine/destroot/Library/Frameworks/universal/hermes.xcframework/ios-arm64/hermes.framework/hermes", + "Pods/hermes-engine/destroot/Library/Frameworks/universal/hermes.xcframework/ios-arm64_x86_64-simulator/hermes.framework/hermes" + ] + + frameworks_to_strip.each do |framework| + strip_bitcode(framework, bitcode_strip_path) + end + + # Disable ENABLE_BITCODE for all Pod targets + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['ENABLE_BITCODE'] = 'NO' + end + end + # === END: Bitcode Stripping === end end diff --git a/template/package.json b/template/package.json index 445b26a24..ef2865a74 100644 --- a/template/package.json +++ b/template/package.json @@ -48,8 +48,8 @@ "url": "https://github.com/AgoraIO-Community/app-builder-core" }, "dependencies": { - "@datadog/browser-logs": "^5.15.0", - "@datadog/mobile-react-native": "^2.3.2", + "@datadog/browser-logs": "6.17.0", + "@datadog/mobile-react-native": "2.11.0", "@gorhom/bottom-sheet": "4.4.7", "@netless/react-native-whiteboard": "^0.0.14", "@openspacelabs/react-native-zoomable-view": "^2.1.1", @@ -63,9 +63,9 @@ "agora-extension-ai-denoiser": "1.1.0", "agora-extension-beauty-effect": "^1.0.2-beta", "agora-extension-virtual-background": "^1.1.3", - "agora-react-native-rtm": "1.5.1", + "agora-react-native-rtm": "2.2.4", "agora-rtc-sdk-ng": "4.23.4", - "agora-rtm-sdk": "1.5.1", + "agora-rtm-sdk": "2.2.2", "buffer": "^6.0.3", "electron-log": "4.3.5", "electron-squirrel-startup": "1.0.0", diff --git a/template/src/components/RTMConfigure-legacy.tsx b/template/src/components/RTMConfigure-legacy.tsx new file mode 100644 index 000000000..53010409d --- /dev/null +++ b/template/src/components/RTMConfigure-legacy.tsx @@ -0,0 +1,848 @@ +/* +******************************************** + 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. +********************************************* +*/ +// @ts-nocheck +import React, {useState, useContext, useEffect, useRef} from 'react'; +import RtmEngine, {RtmChannelAttribute} from 'agora-react-native-rtm'; +import { + ContentInterface, + DispatchContext, + PropsContext, + useLocalUid, +} from '../../agora-rn-uikit'; +import ChatContext from './ChatContext'; +import {Platform} from 'react-native'; +import {backOff} from 'exponential-backoff'; +import {useString} from '../utils/useString'; +import {isAndroid, isIOS, isWeb, isWebInternal} from '../utils/common'; +import {useContent, useIsAttendee, useUserName} from 'customization-api'; +import { + safeJsonParse, + timeNow, + hasJsonStructure, + getMessageTime, + get32BitUid, +} from '../rtm/utils'; +import {EventUtils, EventsQueue, EventNames} from '../rtm-events'; +import events, {PersistanceLevel} from '../rtm-events-api'; +import RTMEngine from '../rtm/RTMEngine'; +import {filterObject} from '../utils'; +import SDKEvents from '../utils/SdkEvents'; +import isSDK from '../utils/isSDK'; +import {useAsyncEffect} from '../utils/useAsyncEffect'; +import { + WaitingRoomStatus, + useRoomInfo, +} from '../components/room-info/useRoomInfo'; +import LocalEventEmitter, { + LocalEventsEnum, +} from '../rtm-events-api/LocalEvents'; +import {PSTNUserLabel} from '../language/default-labels/videoCallScreenLabels'; +import {controlMessageEnum} from '../components/ChatContext'; +import {LogSource, logger} from '../logger/AppBuilderLogger'; +import {RECORDING_BOT_UID} from '../utils/constants'; + +export enum UserType { + ScreenShare = 'screenshare', +} + +const RtmConfigure = (props: any) => { + const rtmInitTimstamp = new Date().getTime(); + const localUid = useLocalUid(); + const {callActive} = props; + const {rtcProps} = useContext(PropsContext); + const {dispatch} = useContext(DispatchContext); + const {defaultContent, activeUids} = useContent(); + const defaultContentRef = useRef({defaultContent: defaultContent}); + const activeUidsRef = useRef({activeUids: activeUids}); + + const { + waitingRoomStatus, + data: {isHost}, + } = useRoomInfo(); + const waitingRoomStatusRef = useRef({waitingRoomStatus: waitingRoomStatus}); + + const isHostRef = useRef({isHost: isHost}); + + useEffect(() => { + isHostRef.current.isHost = isHost; + }, [isHost]); + + useEffect(() => { + waitingRoomStatusRef.current.waitingRoomStatus = waitingRoomStatus; + }, [waitingRoomStatus]); + + /** + * inside event callback state won't have latest value. + * so creating ref to access the state + */ + useEffect(() => { + activeUidsRef.current.activeUids = activeUids; + }, [activeUids]); + + useEffect(() => { + defaultContentRef.current.defaultContent = defaultContent; + }, [defaultContent]); + + const [hasUserJoinedRTM, setHasUserJoinedRTM] = useState(false); + const [isInitialQueueCompleted, setIsInitialQueueCompleted] = useState(false); + const [onlineUsersCount, setTotalOnlineUsers] = useState(0); + + let engine = useRef(null!); + const timerValueRef: any = useRef(5); + + React.useEffect(() => { + setTotalOnlineUsers( + Object.keys( + filterObject( + defaultContent, + ([k, v]) => + v?.type === 'rtc' && + !v.offline && + activeUids.indexOf(v?.uid) !== -1, + ), + ).length, + ); + }, [defaultContent]); + + React.useEffect(() => { + if (!$config.ENABLE_CONVERSATIONAL_AI) { + const handBrowserClose = ev => { + ev.preventDefault(); + return (ev.returnValue = 'Are you sure you want to exit?'); + }; + const logoutRtm = () => { + engine.current.leaveChannel(rtcProps.channel); + }; + + if (!isWebInternal()) return; + window.addEventListener( + 'beforeunload', + isWeb() && !isSDK() ? handBrowserClose : () => {}, + ); + + window.addEventListener('pagehide', logoutRtm); + // cleanup this component + return () => { + window.removeEventListener( + 'beforeunload', + isWeb() && !isSDK() ? handBrowserClose : () => {}, + ); + window.removeEventListener('pagehide', logoutRtm); + }; + } + }, []); + + const doLoginAndSetupRTM = async () => { + try { + logger.log(LogSource.AgoraSDK, 'API', 'RTM login starts'); + await engine.current.login({ + uid: localUid.toString(), + token: rtcProps.rtm, + }); + logger.log(LogSource.AgoraSDK, 'API', 'RTM login done'); + RTMEngine.getInstance().setLocalUID(localUid.toString()); + logger.log(LogSource.AgoraSDK, 'API', 'RTM local Uid set'); + timerValueRef.current = 5; + await setAttribute(); + logger.log(LogSource.AgoraSDK, 'Log', 'RTM setting attribute done'); + } catch (error) { + logger.error(LogSource.AgoraSDK, 'Log', 'RTM login failed..Trying again'); + setTimeout(async () => { + timerValueRef.current = timerValueRef.current + timerValueRef.current; + doLoginAndSetupRTM(); + }, timerValueRef.current * 1000); + } + }; + + const setAttribute = async () => { + const rtmAttributes = [ + {key: 'screenUid', value: String(rtcProps.screenShareUid)}, + {key: 'isHost', value: String(isHostRef.current.isHost)}, + ]; + try { + await engine.current.setLocalUserAttributes(rtmAttributes); + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM setting local user attributes', + { + attr: rtmAttributes, + }, + ); + timerValueRef.current = 5; + await joinChannel(); + logger.log(LogSource.AgoraSDK, 'Log', 'RTM join channel done', { + data: rtmAttributes, + }); + setHasUserJoinedRTM(true); + await runQueuedEvents(); + setIsInitialQueueCompleted(true); + logger.log( + LogSource.AgoraSDK, + 'Log', + 'RTM queued events finished running', + { + attr: rtmAttributes, + }, + ); + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'Log', + 'RTM setAttribute failed..Trying again', + ); + setTimeout(async () => { + timerValueRef.current = timerValueRef.current + timerValueRef.current; + setAttribute(); + }, timerValueRef.current * 1000); + } + }; + + const joinChannel = async () => { + try { + if (RTMEngine.getInstance().channelUid !== rtcProps.channel) { + await engine.current.joinChannel(rtcProps.channel); + logger.log(LogSource.AgoraSDK, 'API', 'RTM joinChannel', { + data: rtcProps.channel, + }); + RTMEngine.getInstance().setChannelId(rtcProps.channel); + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM setChannelId', + rtcProps.channel, + ); + logger.debug( + LogSource.SDK, + 'Event', + 'Emitting rtm joined', + rtcProps.channel, + ); + SDKEvents.emit('_rtm-joined', rtcProps.channel); + } else { + logger.debug( + LogSource.AgoraSDK, + 'Log', + 'RTM already joined channel skipping', + rtcProps.channel, + ); + } + timerValueRef.current = 5; + await getMembers(); + await readAllChannelAttributes(); + logger.log(LogSource.AgoraSDK, 'Log', 'RTM getMembers done'); + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'Log', + 'RTM joinChannel failed..Trying again', + ); + setTimeout(async () => { + timerValueRef.current = timerValueRef.current + timerValueRef.current; + joinChannel(); + }, timerValueRef.current * 1000); + } + }; + + const updateRenderListState = ( + uid: number, + data: Partial, + ) => { + dispatch({type: 'UpdateRenderList', value: [uid, data]}); + }; + + const getMembers = async () => { + try { + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM getChannelMembersByID(getMembers) start', + ); + await engine.current + .getChannelMembersBychannelId(rtcProps.channel) + .then(async data => { + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM getChannelMembersByID data received', + data, + ); + await Promise.all( + data.members.map(async (member: any) => { + const backoffAttributes = backOff( + async () => { + logger.log( + LogSource.AgoraSDK, + 'API', + `RTM fetching getUserAttributesByUid for member ${member.uid}`, + ); + const attr = await engine.current.getUserAttributesByUid( + member.uid, + ); + if (!attr || !attr.attributes) { + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM attributes for member not found', + ); + throw attr; + } + logger.log( + LogSource.AgoraSDK, + 'API', + `RTM getUserAttributesByUid for member ${member.uid} received`, + { + attr, + }, + ); + for (const key in attr.attributes) { + if ( + attr.attributes.hasOwnProperty(key) && + attr.attributes[key] + ) { + return attr; + } else { + throw attr; + } + } + }, + { + retry: (e, idx) => { + logger.debug( + LogSource.AgoraSDK, + 'Log', + `[retrying] Attempt ${idx}. Fetching ${member.uid}'s name`, + e, + ); + return true; + }, + }, + ); + try { + const attr = await backoffAttributes; + console.log('[user attributes]:', {attr}); + //RTC layer uid type is number. so doing the parseInt to convert to number + //todo hari check android uid comparsion + const uid = parseInt(member.uid); + const screenUid = parseInt(attr?.attributes?.screenUid); + //start - updating user data in rtc + const userData = { + screenUid: screenUid, + //below thing for livestreaming + type: uid === parseInt(RECORDING_BOT_UID) ? 'bot' : 'rtc', + uid, + offline: false, + isHost: attr?.attributes?.isHost, + lastMessageTimeStamp: 0, + }; + updateRenderListState(uid, userData); + //end- updating user data in rtc + + //start - updating screenshare data in rtc + const screenShareUser = { + type: UserType.ScreenShare, + parentUid: uid, + }; + updateRenderListState(screenUid, screenShareUser); + //end - updating screenshare data in rtc + // setting screenshare data + // name of the screenUid, isActive: false, (when the user starts screensharing it becomes true) + // isActive to identify all active screenshare users in the call + for (const [key, value] of Object.entries(attr?.attributes)) { + if (hasJsonStructure(value as string)) { + const data = { + evt: key, + value: value, + }; + // TODOSUP: Add the data to queue, dont add same mulitple events, use set so as to not repeat events + EventsQueue.enqueue({ + data: data, + uid: member.uid, + ts: timeNow(), + }); + } + } + } catch (e) { + logger.error( + LogSource.AgoraSDK, + 'Log', + `Could not retrieve name of ${member.uid}`, + e, + ); + } + }), + ); + logger.debug( + LogSource.AgoraSDK, + 'Log', + 'RTM fetched all data and user attr...RTM init done', + ); + }); + timerValueRef.current = 5; + } catch (error) { + setTimeout(async () => { + timerValueRef.current = timerValueRef.current + timerValueRef.current; + await getMembers(); + }, timerValueRef.current * 1000); + } + }; + + const readAllChannelAttributes = async () => { + try { + await engine.current + .getChannelAttributes(rtcProps.channel) + .then(async data => { + for (const item of data) { + const {key, value, lastUpdateTs, lastUpdateUserId} = item; + if (hasJsonStructure(value as string)) { + const evtData = { + evt: key, + value, + }; + // TODOSUP: Add the data to queue, dont add same mulitple events, use set so as to not repeat events + EventsQueue.enqueue({ + data: evtData, + uid: lastUpdateUserId, + ts: lastUpdateTs, + }); + } + } + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM getChannelAttributes data received', + data, + ); + }); + timerValueRef.current = 5; + } catch (error) { + setTimeout(async () => { + timerValueRef.current = timerValueRef.current + timerValueRef.current; + await readAllChannelAttributes(); + }, timerValueRef.current * 1000); + } + }; + + const init = async () => { + //on sdk due to multiple re-render we are getting rtm error code 8 + //you are joining the same channel too frequently, exceeding the allowed rate of joining the same channel multiple times within a short period + //so checking rtm connection state before proceed + if (engine?.current?.client?.connectionState === 'CONNECTED') { + return; + } + logger.log(LogSource.AgoraSDK, 'Log', 'RTM creating engine...'); + engine.current = RTMEngine.getInstance().engine; + RTMEngine.getInstance(); + logger.log(LogSource.AgoraSDK, 'Log', 'RTM engine creation done'); + + engine.current.on('connectionStateChanged', (evt: any) => { + //console.log(evt); + }); + engine.current.on('error', (evt: any) => { + // console.log(evt); + }); + engine.current.on('channelMemberJoined', (data: any) => { + logger.log(LogSource.AgoraSDK, 'Event', 'channelMemberJoined', data); + const backoffAttributes = backOff( + async () => { + logger.log( + LogSource.AgoraSDK, + 'API', + `RTM fetching getUserAttributesByUid for member ${data.uid}`, + ); + const attr = await engine.current.getUserAttributesByUid(data.uid); + if (!attr || !attr.attributes) { + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM attributes for member not found', + ); + throw attr; + } + logger.log( + LogSource.AgoraSDK, + 'API', + `RTM getUserAttributesByUid for member ${data.uid} received`, + { + attr, + }, + ); + for (const key in attr.attributes) { + if (attr.attributes.hasOwnProperty(key) && attr.attributes[key]) { + return attr; + } else { + throw attr; + } + } + }, + { + retry: (e, idx) => { + logger.debug( + LogSource.AgoraSDK, + 'Log', + `[retrying] Attempt ${idx}. Fetching ${data.uid}'s name`, + e, + ); + return true; + }, + }, + ); + async function getname() { + try { + const attr = await backoffAttributes; + console.log('[user attributes]:', {attr}); + const uid = parseInt(data.uid); + const screenUid = parseInt(attr?.attributes?.screenUid); + + //start - updating user data in rtc + const userData = { + screenUid: screenUid, + //below thing for livestreaming + type: uid === parseInt(RECORDING_BOT_UID) ? 'bot' : 'rtc', + uid, + offline: false, + lastMessageTimeStamp: 0, + isHost: attr?.attributes?.isHost, + }; + updateRenderListState(uid, userData); + //end- updating user data in rtc + + //start - updating screenshare data in rtc + const screenShareUser = { + type: UserType.ScreenShare, + parentUid: uid, + }; + updateRenderListState(screenUid, screenShareUser); + //end - updating screenshare data in rtc + } catch (e) { + logger.error( + LogSource.AgoraSDK, + 'Event', + `Failed to retrive name of ${data.uid}`, + e, + ); + } + } + getname(); + }); + + engine.current.on('channelMemberLeft', (data: any) => { + logger.debug(LogSource.AgoraSDK, 'Event', 'channelMemberLeft', data); + // Chat of left user becomes undefined. So don't cleanup + const uid = data?.uid ? parseInt(data?.uid) : undefined; + if (!uid) return; + SDKEvents.emit('_rtm-left', uid); + // updating the rtc data + updateRenderListState(uid, { + offline: true, + }); + }); + + engine.current.addListener( + 'ChannelAttributesUpdated', + (attributeList: RtmChannelAttribute[]) => { + try { + attributeList.map((attribute: RtmChannelAttribute) => { + const {key, value, lastUpdateTs, lastUpdateUserId} = attribute; + const timestamp = getMessageTime(lastUpdateTs); + const sender = Platform.OS + ? get32BitUid(lastUpdateUserId) + : parseInt(lastUpdateUserId); + eventDispatcher( + { + evt: key, + value, + }, + sender, + timestamp, + ); + }); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + error, + ); + } + }, + ); + + engine.current.on('messageReceived', (evt: any) => { + logger.debug(LogSource.Events, 'CUSTOM_EVENTS', 'messageReceived', evt); + const {peerId, ts, text} = evt; + const [err, msg] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + err, + ); + } + + const timestamp = getMessageTime(ts); + + const sender = isAndroid() ? get32BitUid(peerId) : parseInt(peerId); + + try { + eventDispatcher(msg, sender, timestamp); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + err, + ); + } + }); + + engine.current.on('channelMessageReceived', evt => { + logger.debug( + LogSource.Events, + 'CUSTOM_EVENTS', + 'channelMessageReceived', + evt, + ); + + const {uid, channelId, text, ts} = evt; + //whiteboard upload + if (uid == 1010101) { + const [err, res] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + err, + ); + } + + if (res?.data?.data?.images) { + LocalEventEmitter.emit( + LocalEventsEnum.WHITEBOARD_FILE_UPLOAD, + res?.data?.data?.images, + ); + } + } else { + const [err, msg] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + err, + ); + } + + const timestamp = getMessageTime(ts); + + const sender = Platform.OS ? get32BitUid(uid) : parseInt(uid); + + if (channelId === rtcProps.channel) { + try { + eventDispatcher(msg, sender, timestamp); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + error, + ); + } + } + } + }); + + await doLoginAndSetupRTM(); + }; + + const runQueuedEvents = async () => { + try { + while (!EventsQueue.isEmpty()) { + const currEvt = EventsQueue.dequeue(); + await eventDispatcher(currEvt.data, currEvt.uid, currEvt.ts); + } + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while running queue events', + error, + ); + } + }; + + const eventDispatcher = async ( + data: { + evt: string; + value: string; + }, + sender: string, + ts: number, + ) => { + console.debug( + LogSource.Events, + 'CUSTOM_EVENTS', + 'inside eventDispatcher ', + data, + ); + + let evt = '', + value = {}; + + if (data.feat === 'WAITING_ROOM') { + if (data.etyp === 'REQUEST') { + const outputData = { + evt: `${data.feat}_${data.etyp}`, + payload: JSON.stringify({ + attendee_uid: data.data.data.attendee_uid, + attendee_screenshare_uid: data.data.data.attendee_screenshare_uid, + }), + persistLevel: 1, + source: 'core', + }; + const formattedData = JSON.stringify(outputData); + evt = data.feat + '_' + data.etyp; //rename if client side RTM meessage is to be sent for approval + value = formattedData; + } + if (data.etyp === 'RESPONSE') { + const outputData = { + evt: `${data.feat}_${data.etyp}`, + payload: JSON.stringify({ + approved: data.data.data.approved, + channelName: data.data.data.channel_name, + mainUser: data.data.data.mainUser, + screenShare: data.data.data.screenShare, + whiteboard: data.data.data.whiteboard, + chat: data.data.data?.chat, + }), + persistLevel: 1, + source: 'core', + }; + const formattedData = JSON.stringify(outputData); + evt = data.feat + '_' + data.etyp; + value = formattedData; + } + } else { + if ( + $config.ENABLE_WAITING_ROOM && + !isHostRef.current?.isHost && + waitingRoomStatusRef.current?.waitingRoomStatus !== + WaitingRoomStatus.APPROVED + ) { + if ( + data.evt === controlMessageEnum.muteAudio || + data.evt === controlMessageEnum.muteVideo + ) { + return; + } else { + evt = data.evt; + value = data.value; + } + } else { + evt = data.evt; + value = data.value; + } + } + + try { + const {payload, persistLevel, source} = JSON.parse(value); + // Step 1: Set local attributes + if (persistLevel === PersistanceLevel.Session) { + const rtmAttribute = {key: evt, value: value}; + await engine.current.addOrUpdateLocalUserAttributes([rtmAttribute]); + } + // Step 2: Emit the event + console.debug(LogSource.Events, 'CUSTOM_EVENTS', 'emiting event..: '); + EventUtils.emitEvent(evt, source, {payload, persistLevel, sender, ts}); + // Because async gets evaluated in a different order when in an sdk + if (evt === 'name') { + setTimeout(() => { + EventUtils.emitEvent(evt, source, { + payload, + persistLevel, + sender, + ts, + }); + }, 200); + } + } catch (error) { + console.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while emiting event:', + error, + ); + } + }; + + const end = async () => { + if (!callActive) { + return; + } + await RTMEngine.getInstance().destroy(); + logger.log(LogSource.AgoraSDK, 'API', 'RTM destroy done'); + if (isIOS() || isAndroid()) { + EventUtils.clear(); + } + setHasUserJoinedRTM(false); + logger.debug(LogSource.AgoraSDK, 'Log', 'RTM cleanup done'); + }; + + useAsyncEffect(async () => { + //waiting room attendee -> rtm login will happen on page load + if ($config.ENABLE_WAITING_ROOM) { + //attendee + //for waiting room attendee rtm login will happen on mount + if (!isHost && !callActive) { + await init(); + } + //host + if ( + isHost && + ($config.AUTO_CONNECT_RTM || (!$config.AUTO_CONNECT_RTM && callActive)) + ) { + await init(); + } + } else { + //non waiting room case + //host and attendee + if ( + $config.AUTO_CONNECT_RTM || + (!$config.AUTO_CONNECT_RTM && callActive) + ) { + await init(); + } + } + return async () => { + await end(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rtcProps.channel, rtcProps.appId, callActive]); + + return ( + + {props.children} + + ); +}; + +export default RtmConfigure; diff --git a/template/src/components/RTMConfigure.tsx b/template/src/components/RTMConfigure.tsx index 53010409d..43aae0ad7 100644 --- a/template/src/components/RTMConfigure.tsx +++ b/template/src/components/RTMConfigure.tsx @@ -9,21 +9,32 @@ information visit https://appbuilder.agora.io. ********************************************* */ -// @ts-nocheck + import React, {useState, useContext, useEffect, useRef} from 'react'; -import RtmEngine, {RtmChannelAttribute} from 'agora-react-native-rtm'; +import { + type GetChannelMetadataResponse, + type GetOnlineUsersResponse, + type LinkStateEvent, + type MessageEvent, + type Metadata, + type PresenceEvent, + type SetOrUpdateUserMetadataOptions, + type StorageEvent, + type RTMClient, + type GetUserMetadataResponse, +} from 'agora-react-native-rtm'; import { ContentInterface, DispatchContext, PropsContext, + UidType, useLocalUid, } from '../../agora-rn-uikit'; import ChatContext from './ChatContext'; import {Platform} from 'react-native'; import {backOff} from 'exponential-backoff'; -import {useString} from '../utils/useString'; import {isAndroid, isIOS, isWeb, isWebInternal} from '../utils/common'; -import {useContent, useIsAttendee, useUserName} from 'customization-api'; +import {useContent} from 'customization-api'; import { safeJsonParse, timeNow, @@ -31,8 +42,8 @@ import { getMessageTime, get32BitUid, } from '../rtm/utils'; -import {EventUtils, EventsQueue, EventNames} from '../rtm-events'; -import events, {PersistanceLevel} from '../rtm-events-api'; +import {EventUtils, EventsQueue} from '../rtm-events'; +import {PersistanceLevel} from '../rtm-events-api'; import RTMEngine from '../rtm/RTMEngine'; import {filterObject} from '../utils'; import SDKEvents from '../utils/SdkEvents'; @@ -45,60 +56,79 @@ import { import LocalEventEmitter, { LocalEventsEnum, } from '../rtm-events-api/LocalEvents'; -import {PSTNUserLabel} from '../language/default-labels/videoCallScreenLabels'; import {controlMessageEnum} from '../components/ChatContext'; import {LogSource, logger} from '../logger/AppBuilderLogger'; import {RECORDING_BOT_UID} from '../utils/constants'; +import { + nativeChannelTypeMapping, + nativeLinkStateMapping, + nativePresenceEventTypeMapping, + nativeStorageEventTypeMapping, +} from '../../bridge/rtm/web/Types'; export enum UserType { ScreenShare = 'screenshare', } +const eventTimeouts = new Map>(); + const RtmConfigure = (props: any) => { + let engine = useRef(null!); const rtmInitTimstamp = new Date().getTime(); const localUid = useLocalUid(); const {callActive} = props; const {rtcProps} = useContext(PropsContext); const {dispatch} = useContext(DispatchContext); const {defaultContent, activeUids} = useContent(); - const defaultContentRef = useRef({defaultContent: defaultContent}); - const activeUidsRef = useRef({activeUids: activeUids}); - const { waitingRoomStatus, data: {isHost}, } = useRoomInfo(); - const waitingRoomStatusRef = useRef({waitingRoomStatus: waitingRoomStatus}); + const [hasUserJoinedRTM, setHasUserJoinedRTM] = useState(false); + const [isInitialQueueCompleted, setIsInitialQueueCompleted] = useState(false); + const [onlineUsersCount, setTotalOnlineUsers] = useState(0); + const timerValueRef: any = useRef(5); + // Track RTM connection state (equivalent to v1.5x connectionState check) + const [rtmConnectionState, setRtmConnectionState] = useState(0); // 0=IDLE, 2=CONNECTED + /** + * inside event callback state won't have latest value. + * so creating ref to access the state + */ const isHostRef = useRef({isHost: isHost}); - useEffect(() => { isHostRef.current.isHost = isHost; }, [isHost]); + const waitingRoomStatusRef = useRef({waitingRoomStatus: waitingRoomStatus}); useEffect(() => { waitingRoomStatusRef.current.waitingRoomStatus = waitingRoomStatus; }, [waitingRoomStatus]); - /** - * inside event callback state won't have latest value. - * so creating ref to access the state - */ + const activeUidsRef = useRef({activeUids: activeUids}); useEffect(() => { activeUidsRef.current.activeUids = activeUids; }, [activeUids]); + const defaultContentRef = useRef({defaultContent: defaultContent}); useEffect(() => { defaultContentRef.current.defaultContent = defaultContent; }, [defaultContent]); - const [hasUserJoinedRTM, setHasUserJoinedRTM] = useState(false); - const [isInitialQueueCompleted, setIsInitialQueueCompleted] = useState(false); - const [onlineUsersCount, setTotalOnlineUsers] = useState(0); - - let engine = useRef(null!); - const timerValueRef: any = useRef(5); + // Eventdispatcher timeout refs clean + const isRTMMounted = useRef(true); + useEffect(() => { + return () => { + isRTMMounted.current = false; + // Clear all pending timeouts on unmount + for (const timeout of eventTimeouts.values()) { + clearTimeout(timeout); + } + eventTimeouts.clear(); + }; + }, []); + // Set online users React.useEffect(() => { setTotalOnlineUsers( Object.keys( @@ -107,31 +137,42 @@ const RtmConfigure = (props: any) => { ([k, v]) => v?.type === 'rtc' && !v.offline && - activeUids.indexOf(v?.uid) !== -1, + activeUidsRef.current.activeUids.indexOf(v?.uid) !== -1, ), ).length, ); }, [defaultContent]); React.useEffect(() => { - if (!$config.ENABLE_CONVERSATIONAL_AI) { + // If its not a convo ai project and + // the platform is web execute the window listeners + if (!$config.ENABLE_CONVERSATIONAL_AI && isWebInternal()) { const handBrowserClose = ev => { ev.preventDefault(); return (ev.returnValue = 'Are you sure you want to exit?'); }; const logoutRtm = () => { - engine.current.leaveChannel(rtcProps.channel); + try { + if (engine.current && RTMEngine.getInstance().channelUid) { + // First unsubscribe from channel (like v1.5x leaveChannel) + engine.current.unsubscribe(RTMEngine.getInstance().channelUid); + // Then logout + engine.current.logout(); + } + } catch (error) { + console.error('Error during browser close RTM cleanup:', error); + } }; - if (!isWebInternal()) return; + // Set up window listeners window.addEventListener( 'beforeunload', isWeb() && !isSDK() ? handBrowserClose : () => {}, ); window.addEventListener('pagehide', logoutRtm); - // cleanup this component return () => { + // Remove listeners on unmount window.removeEventListener( 'beforeunload', isWeb() && !isSDK() ? handBrowserClose : () => {}, @@ -141,23 +182,303 @@ const RtmConfigure = (props: any) => { } }, []); + const init = async (rtcUid: UidType) => { + //on sdk due to multiple re-render we are getting rtm error code 8 + //you are joining the same channel too frequently, exceeding the allowed rate of joining the same channel multiple times within a short period + //so checking rtm connection state before proceed + + // Check if already connected (equivalent to v1.5x connectionState === 'CONNECTED') + if ( + rtmConnectionState === nativeLinkStateMapping.CONNECTED && + RTMEngine.getInstance().isEngineReady + ) { + logger.log( + LogSource.AgoraSDK, + 'Log', + '🚫 RTM already connected, skipping initialization', + ); + return; + } + + try { + if (!RTMEngine.getInstance().isEngineReady) { + RTMEngine.getInstance().setLocalUID(rtcUid); + logger.log(LogSource.AgoraSDK, 'API', 'RTM local Uid set', rtcUid); + } + engine.current = RTMEngine.getInstance().engine; + // Logout any opened sessions if any + engine.current.logout(); + logger.log(LogSource.AgoraSDK, 'Log', 'RTM client creation done'); + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'Log', + 'RTM engine initialization failed:', + {error}, + ); + throw error; + } + + engine.current.addEventListener( + 'linkState', + async (data: LinkStateEvent) => { + // Update connection state for duplicate initialization prevention + setRtmConnectionState(data.currentState); + logger.log( + LogSource.AgoraSDK, + 'Event', + `RTM linkState changed: ${data.previousState} -> ${data.currentState}`, + data, + ); + if (data.currentState === nativeLinkStateMapping.CONNECTED) { + // CONNECTED state + logger.log(LogSource.AgoraSDK, 'Event', 'RTM connected', { + previousState: data.previousState, + currentState: data.currentState, + }); + } + if (data.currentState === nativeLinkStateMapping.FAILED) { + // FAILED state + logger.error(LogSource.AgoraSDK, 'Event', 'RTM connection failed', { + error: { + reasonCode: data.reasonCode, + currentState: data.currentState, + }, + }); + } + }, + ); + + engine.current.addEventListener('storage', (storage: StorageEvent) => { + // when remote user sets/updates metadata - 3 + if ( + storage.eventType === nativeStorageEventTypeMapping.SET || + storage.eventType === nativeStorageEventTypeMapping.UPDATE + ) { + const storageTypeStr = storage.storageType === 1 ? 'user' : 'channel'; + const eventTypeStr = storage.eventType === 2 ? 'SET' : 'UPDATE'; + logger.log( + LogSource.AgoraSDK, + 'Event', + `RTM storage event of type: [${eventTypeStr} ${storageTypeStr} metadata]`, + storage, + ); + try { + if (storage.data?.items && Array.isArray(storage.data.items)) { + storage.data.items.forEach(item => { + try { + if (!item || !item.key) { + logger.warn( + LogSource.Events, + 'CUSTOM_EVENTS', + 'Invalid storage item:', + item, + ); + return; + } + + const {key, value, authorUserId, updateTs} = item; + const timestamp = getMessageTime(updateTs); + const sender = Platform.OS + ? get32BitUid(authorUserId) + : parseInt(authorUserId, 10); + eventDispatcher( + { + evt: key, + value, + }, + `${sender}`, + timestamp, + ); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + `Failed to process storage item: ${JSON.stringify(item)}`, + {error}, + ); + } + }); + } + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + {error}, + ); + } + } + }); + + engine.current.addEventListener( + 'presence', + async (presence: PresenceEvent) => { + if (`${localUid}` === presence.publisher) { + return; + } + // remoteJoinChannel + if (presence.type === nativePresenceEventTypeMapping.REMOTE_JOIN) { + logger.log( + LogSource.AgoraSDK, + 'Event', + 'RTM presenceEvent of type [3 - remoteJoin] (channelMemberJoined)', + ); + const backoffAttributes = await fetchUserAttributesWithBackoffRetry( + presence.publisher, + ); + await processUserUidAttributes(backoffAttributes, presence.publisher); + } + // remoteLeaveChannel + if (presence.type === nativePresenceEventTypeMapping.REMOTE_LEAVE) { + logger.log( + LogSource.AgoraSDK, + 'Event', + 'RTM presenceEvent of type [4 - remoteLeave] (channelMemberLeft)', + presence, + ); + // Chat of left user becomes undefined. So don't cleanup + const uid = presence?.publisher + ? parseInt(presence.publisher, 10) + : undefined; + + if (!uid) { + return; + } + SDKEvents.emit('_rtm-left', uid); + // updating the rtc data + updateRenderListState(uid, { + offline: true, + }); + } + }, + ); + + engine.current.addEventListener('message', (message: MessageEvent) => { + if (`${localUid}` === message.publisher) { + return; + } + // message - 1 (channel) + if (message.channelType === nativeChannelTypeMapping.MESSAGE) { + logger.debug( + LogSource.Events, + 'CUSTOM_EVENTS', + 'messageEvent of type [1 - CHANNEL] (channelMessageReceived)', + message, + ); + const { + publisher: uid, + channelName: channelId, + message: text, + timestamp: ts, + } = message; + //whiteboard upload + if (parseInt(uid, 10) === 1010101) { + const [err, res] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + {error: err}, + ); + } + if (res?.data?.data?.images) { + LocalEventEmitter.emit( + LocalEventsEnum.WHITEBOARD_FILE_UPLOAD, + res?.data?.data?.images, + ); + } + } else { + const [err, msg] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + {error: err}, + ); + } + + const timestamp = getMessageTime(ts); + const sender = Platform.OS ? get32BitUid(uid) : parseInt(uid, 10); + + if (channelId === rtcProps.channel) { + try { + eventDispatcher(msg, `${sender}`, timestamp); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + {error}, + ); + } + } + } + } + + // message - 3 (user) + if (message.channelType === nativeChannelTypeMapping.USER) { + logger.debug( + LogSource.Events, + 'CUSTOM_EVENTS', + 'messageEvent of type [3- USER] (messageReceived)', + message, + ); + const {publisher: peerId, timestamp: ts, message: text} = message; + const [err, msg] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + {error: err}, + ); + } + + const timestamp = getMessageTime(ts); + + const sender = isAndroid() ? get32BitUid(peerId) : parseInt(peerId, 10); + + try { + eventDispatcher(msg, `${sender}`, timestamp); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + {error}, + ); + } + } + }); + + await doLoginAndSetupRTM(); + }; + const doLoginAndSetupRTM = async () => { try { logger.log(LogSource.AgoraSDK, 'API', 'RTM login starts'); await engine.current.login({ - uid: localUid.toString(), + // @ts-ignore token: rtcProps.rtm, }); logger.log(LogSource.AgoraSDK, 'API', 'RTM login done'); - RTMEngine.getInstance().setLocalUID(localUid.toString()); - logger.log(LogSource.AgoraSDK, 'API', 'RTM local Uid set'); timerValueRef.current = 5; + // waiting for login to be fully connected + await new Promise(resolve => setTimeout(resolve, 500)); await setAttribute(); - logger.log(LogSource.AgoraSDK, 'Log', 'RTM setting attribute done'); } catch (error) { - logger.error(LogSource.AgoraSDK, 'Log', 'RTM login failed..Trying again'); + logger.error( + LogSource.AgoraSDK, + 'Log', + 'RTM login failed..Trying again', + {error}, + ); setTimeout(async () => { - timerValueRef.current = timerValueRef.current + timerValueRef.current; + // Cap the timer to prevent excessive delays (max 30 seconds) + timerValueRef.current = Math.min(timerValueRef.current * 2, 30); doLoginAndSetupRTM(); }, timerValueRef.current * 1000); } @@ -169,7 +490,13 @@ const RtmConfigure = (props: any) => { {key: 'isHost', value: String(isHostRef.current.isHost)}, ]; try { - await engine.current.setLocalUserAttributes(rtmAttributes); + const data: Metadata = { + items: rtmAttributes, + }; + const options: SetOrUpdateUserMetadataOptions = { + userId: `${localUid}`, + }; + await engine.current.storage.setUserMetadata(data, options); logger.log( LogSource.AgoraSDK, 'API', @@ -179,10 +506,15 @@ const RtmConfigure = (props: any) => { }, ); timerValueRef.current = 5; - await joinChannel(); - logger.log(LogSource.AgoraSDK, 'Log', 'RTM join channel done', { - data: rtmAttributes, - }); + await subscribeChannel(); + logger.log( + LogSource.AgoraSDK, + 'Log', + 'RTM subscribe, fetch members, reading channel atrributes all done', + { + data: rtmAttributes, + }, + ); setHasUserJoinedRTM(true); await runQueuedEvents(); setIsInitialQueueCompleted(true); @@ -199,184 +531,138 @@ const RtmConfigure = (props: any) => { LogSource.AgoraSDK, 'Log', 'RTM setAttribute failed..Trying again', + {error}, ); setTimeout(async () => { - timerValueRef.current = timerValueRef.current + timerValueRef.current; + // Cap the timer to prevent excessive delays (max 30 seconds) + timerValueRef.current = Math.min(timerValueRef.current * 2, 30); setAttribute(); }, timerValueRef.current * 1000); } }; - const joinChannel = async () => { + const subscribeChannel = async () => { try { - if (RTMEngine.getInstance().channelUid !== rtcProps.channel) { - await engine.current.joinChannel(rtcProps.channel); - logger.log(LogSource.AgoraSDK, 'API', 'RTM joinChannel', { + if (RTMEngine.getInstance().channelUid === rtcProps.channel) { + logger.debug( + LogSource.AgoraSDK, + 'Log', + '🚫 RTM already subscribed channel skipping', + rtcProps.channel, + ); + } else { + await engine.current.subscribe(rtcProps.channel, { + withMessage: true, + withPresence: true, + withMetadata: true, + withLock: false, + }); + logger.log(LogSource.AgoraSDK, 'API', 'RTM subscribeChannel', { data: rtcProps.channel, }); + + // Set channel ID AFTER successful subscribe (like v1.5x) RTMEngine.getInstance().setChannelId(rtcProps.channel); logger.log( LogSource.AgoraSDK, 'API', - 'RTM setChannelId', + 'RTM setChannelId as subscribe is successful', rtcProps.channel, ); + logger.debug( LogSource.SDK, 'Event', 'Emitting rtm joined', rtcProps.channel, ); + // @ts-ignore SDKEvents.emit('_rtm-joined', rtcProps.channel); - } else { - logger.debug( + timerValueRef.current = 5; + await getMembers(); + await readAllChannelAttributes(); + logger.log( LogSource.AgoraSDK, 'Log', - 'RTM already joined channel skipping', - rtcProps.channel, + 'RTM readAllChannelAttributes and getMembers done', ); } - timerValueRef.current = 5; - await getMembers(); - await readAllChannelAttributes(); - logger.log(LogSource.AgoraSDK, 'Log', 'RTM getMembers done'); } catch (error) { logger.error( LogSource.AgoraSDK, 'Log', - 'RTM joinChannel failed..Trying again', + 'RTM subscribeChannel failed..Trying again', + {error}, ); setTimeout(async () => { - timerValueRef.current = timerValueRef.current + timerValueRef.current; - joinChannel(); + // Cap the timer to prevent excessive delays (max 30 seconds) + timerValueRef.current = Math.min(timerValueRef.current * 2, 30); + subscribeChannel(); }, timerValueRef.current * 1000); } }; - const updateRenderListState = ( - uid: number, - data: Partial, - ) => { - dispatch({type: 'UpdateRenderList', value: [uid, data]}); - }; - const getMembers = async () => { try { logger.log( LogSource.AgoraSDK, 'API', - 'RTM getChannelMembersByID(getMembers) start', + 'RTM presence.getOnlineUsers(getMembers) start', ); - await engine.current - .getChannelMembersBychannelId(rtcProps.channel) - .then(async data => { + await engine.current.presence + .getOnlineUsers(rtcProps.channel, 1) + .then(async (data: GetOnlineUsersResponse) => { logger.log( LogSource.AgoraSDK, 'API', - 'RTM getChannelMembersByID data received', + 'RTM presence.getOnlineUsers data received', data, ); await Promise.all( - data.members.map(async (member: any) => { - const backoffAttributes = backOff( - async () => { - logger.log( - LogSource.AgoraSDK, - 'API', - `RTM fetching getUserAttributesByUid for member ${member.uid}`, - ); - const attr = await engine.current.getUserAttributesByUid( - member.uid, - ); - if (!attr || !attr.attributes) { - logger.log( - LogSource.AgoraSDK, - 'API', - 'RTM attributes for member not found', - ); - throw attr; - } - logger.log( - LogSource.AgoraSDK, - 'API', - `RTM getUserAttributesByUid for member ${member.uid} received`, - { - attr, - }, - ); - for (const key in attr.attributes) { - if ( - attr.attributes.hasOwnProperty(key) && - attr.attributes[key] - ) { - return attr; - } else { - throw attr; - } - } - }, - { - retry: (e, idx) => { - logger.debug( - LogSource.AgoraSDK, - 'Log', - `[retrying] Attempt ${idx}. Fetching ${member.uid}'s name`, - e, - ); - return true; - }, - }, - ); + data.occupants?.map(async member => { try { - const attr = await backoffAttributes; - console.log('[user attributes]:', {attr}); - //RTC layer uid type is number. so doing the parseInt to convert to number - //todo hari check android uid comparsion - const uid = parseInt(member.uid); - const screenUid = parseInt(attr?.attributes?.screenUid); - //start - updating user data in rtc - const userData = { - screenUid: screenUid, - //below thing for livestreaming - type: uid === parseInt(RECORDING_BOT_UID) ? 'bot' : 'rtc', - uid, - offline: false, - isHost: attr?.attributes?.isHost, - lastMessageTimeStamp: 0, - }; - updateRenderListState(uid, userData); - //end- updating user data in rtc + const backoffAttributes = + await fetchUserAttributesWithBackoffRetry(member.userId); - //start - updating screenshare data in rtc - const screenShareUser = { - type: UserType.ScreenShare, - parentUid: uid, - }; - updateRenderListState(screenUid, screenShareUser); - //end - updating screenshare data in rtc + await processUserUidAttributes( + backoffAttributes, + member.userId, + ); // setting screenshare data // name of the screenUid, isActive: false, (when the user starts screensharing it becomes true) // isActive to identify all active screenshare users in the call - for (const [key, value] of Object.entries(attr?.attributes)) { - if (hasJsonStructure(value as string)) { - const data = { - evt: key, - value: value, - }; - // TODOSUP: Add the data to queue, dont add same mulitple events, use set so as to not repeat events - EventsQueue.enqueue({ - data: data, - uid: member.uid, - ts: timeNow(), - }); + backoffAttributes?.items?.forEach(item => { + try { + if (hasJsonStructure(item.value as string)) { + const data = { + evt: item.key, // Use item.key instead of key + value: item.value, // Use item.value instead of value + }; + // TODOSUP: Add the data to queue, dont add same mulitple events, use set so as to not repeat events + EventsQueue.enqueue({ + data: data, + uid: member.userId, + ts: timeNow(), + }); + } + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'Log', + `RTM Failed to process user attribute item for ${ + member.userId + }: ${JSON.stringify(item)}`, + {error}, + ); + // Continue processing other items } - } + }); } catch (e) { logger.error( LogSource.AgoraSDK, 'Log', - `Could not retrieve name of ${member.uid}`, - e, + `RTM Could not retrieve name of ${member.userId}`, + {error: e}, ); } }), @@ -390,7 +676,8 @@ const RtmConfigure = (props: any) => { timerValueRef.current = 5; } catch (error) { setTimeout(async () => { - timerValueRef.current = timerValueRef.current + timerValueRef.current; + // Cap the timer to prevent excessive delays (max 30 seconds) + timerValueRef.current = Math.min(timerValueRef.current * 2, 30); await getMembers(); }, timerValueRef.current * 1000); } @@ -398,286 +685,169 @@ const RtmConfigure = (props: any) => { const readAllChannelAttributes = async () => { try { - await engine.current - .getChannelAttributes(rtcProps.channel) - .then(async data => { - for (const item of data) { - const {key, value, lastUpdateTs, lastUpdateUserId} = item; - if (hasJsonStructure(value as string)) { - const evtData = { - evt: key, - value, - }; - // TODOSUP: Add the data to queue, dont add same mulitple events, use set so as to not repeat events - EventsQueue.enqueue({ - data: evtData, - uid: lastUpdateUserId, - ts: lastUpdateTs, - }); + await engine.current.storage + .getChannelMetadata(rtcProps.channel, 1) + .then(async (data: GetChannelMetadataResponse) => { + for (const item of data.items) { + try { + const {key, value, authorUserId, updateTs} = item; + if (hasJsonStructure(value as string)) { + const evtData = { + evt: key, + value, + }; + // TODOSUP: Add the data to queue, dont add same mulitple events, use set so as to not repeat events + EventsQueue.enqueue({ + data: evtData, + uid: authorUserId, + ts: updateTs, + }); + } + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'Log', + `RTM Failed to process channel attribute item: ${JSON.stringify( + item, + )}`, + {error}, + ); + // Continue processing other items } } logger.log( LogSource.AgoraSDK, 'API', - 'RTM getChannelAttributes data received', + 'RTM storage.getChannelMetadata data received', data, ); }); timerValueRef.current = 5; } catch (error) { setTimeout(async () => { - timerValueRef.current = timerValueRef.current + timerValueRef.current; + // Cap the timer to prevent excessive delays (max 30 seconds) + timerValueRef.current = Math.min(timerValueRef.current * 2, 30); await readAllChannelAttributes(); }, timerValueRef.current * 1000); } }; - const init = async () => { - //on sdk due to multiple re-render we are getting rtm error code 8 - //you are joining the same channel too frequently, exceeding the allowed rate of joining the same channel multiple times within a short period - //so checking rtm connection state before proceed - if (engine?.current?.client?.connectionState === 'CONNECTED') { - return; - } - logger.log(LogSource.AgoraSDK, 'Log', 'RTM creating engine...'); - engine.current = RTMEngine.getInstance().engine; - RTMEngine.getInstance(); - logger.log(LogSource.AgoraSDK, 'Log', 'RTM engine creation done'); + const fetchUserAttributesWithBackoffRetry = async ( + userId: string, + ): Promise => { + return backOff( + async () => { + logger.log( + LogSource.AgoraSDK, + 'API', + `RTM fetching getUserMetadata for member ${userId}`, + ); - engine.current.on('connectionStateChanged', (evt: any) => { - //console.log(evt); - }); - engine.current.on('error', (evt: any) => { - // console.log(evt); - }); - engine.current.on('channelMemberJoined', (data: any) => { - logger.log(LogSource.AgoraSDK, 'Event', 'channelMemberJoined', data); - const backoffAttributes = backOff( - async () => { - logger.log( - LogSource.AgoraSDK, - 'API', - `RTM fetching getUserAttributesByUid for member ${data.uid}`, - ); - const attr = await engine.current.getUserAttributesByUid(data.uid); - if (!attr || !attr.attributes) { - logger.log( - LogSource.AgoraSDK, - 'API', - 'RTM attributes for member not found', - ); - throw attr; - } + const attr: GetUserMetadataResponse = + await engine.current.storage.getUserMetadata({ + userId: userId, + }); + + if (!attr || !attr.items) { logger.log( LogSource.AgoraSDK, 'API', - `RTM getUserAttributesByUid for member ${data.uid} received`, - { - attr, - }, - ); - for (const key in attr.attributes) { - if (attr.attributes.hasOwnProperty(key) && attr.attributes[key]) { - return attr; - } else { - throw attr; - } - } - }, - { - retry: (e, idx) => { - logger.debug( - LogSource.AgoraSDK, - 'Log', - `[retrying] Attempt ${idx}. Fetching ${data.uid}'s name`, - e, - ); - return true; - }, - }, - ); - async function getname() { - try { - const attr = await backoffAttributes; - console.log('[user attributes]:', {attr}); - const uid = parseInt(data.uid); - const screenUid = parseInt(attr?.attributes?.screenUid); - - //start - updating user data in rtc - const userData = { - screenUid: screenUid, - //below thing for livestreaming - type: uid === parseInt(RECORDING_BOT_UID) ? 'bot' : 'rtc', - uid, - offline: false, - lastMessageTimeStamp: 0, - isHost: attr?.attributes?.isHost, - }; - updateRenderListState(uid, userData); - //end- updating user data in rtc - - //start - updating screenshare data in rtc - const screenShareUser = { - type: UserType.ScreenShare, - parentUid: uid, - }; - updateRenderListState(screenUid, screenShareUser); - //end - updating screenshare data in rtc - } catch (e) { - logger.error( - LogSource.AgoraSDK, - 'Event', - `Failed to retrive name of ${data.uid}`, - e, + 'RTM attributes for member not found', ); + throw attr; } - } - getname(); - }); - engine.current.on('channelMemberLeft', (data: any) => { - logger.debug(LogSource.AgoraSDK, 'Event', 'channelMemberLeft', data); - // Chat of left user becomes undefined. So don't cleanup - const uid = data?.uid ? parseInt(data?.uid) : undefined; - if (!uid) return; - SDKEvents.emit('_rtm-left', uid); - // updating the rtc data - updateRenderListState(uid, { - offline: true, - }); - }); + logger.log( + LogSource.AgoraSDK, + 'API', + `RTM getUserMetadata for member ${userId} received`, + {attr}, + ); - engine.current.addListener( - 'ChannelAttributesUpdated', - (attributeList: RtmChannelAttribute[]) => { - try { - attributeList.map((attribute: RtmChannelAttribute) => { - const {key, value, lastUpdateTs, lastUpdateUserId} = attribute; - const timestamp = getMessageTime(lastUpdateTs); - const sender = Platform.OS - ? get32BitUid(lastUpdateUserId) - : parseInt(lastUpdateUserId); - eventDispatcher( - { - evt: key, - value, - }, - sender, - timestamp, - ); - }); - } catch (error) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'error while dispatching through eventDispatcher', - error, - ); + if (attr.items && attr.items.length > 0) { + return attr; + } else { + throw attr; } }, + { + retry: (e, idx) => { + logger.debug( + LogSource.AgoraSDK, + 'Log', + `RTM [retrying] Attempt ${idx}. Fetching ${userId}'s attributes`, + e, + ); + return true; + }, + }, ); + }; - engine.current.on('messageReceived', (evt: any) => { - logger.debug(LogSource.Events, 'CUSTOM_EVENTS', 'messageReceived', evt); - const {peerId, ts, text} = evt; - const [err, msg] = safeJsonParse(text); - if (err) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'JSON payload incorrect, Error while parsing the payload', - err, - ); - } - - const timestamp = getMessageTime(ts); + const processUserUidAttributes = async ( + attr: GetUserMetadataResponse, + userId: string, + ) => { + try { + console.log('[user attributes]:', {attr}); + const uid = parseInt(userId, 10); + const screenUidItem = attr?.items?.find(item => item.key === 'screenUid'); + const isHostItem = attr?.items?.find(item => item.key === 'isHost'); + const screenUid = screenUidItem?.value + ? parseInt(screenUidItem.value, 10) + : undefined; - const sender = isAndroid() ? get32BitUid(peerId) : parseInt(peerId); + //start - updating user data in rtc + const userData = { + screenUid: screenUid, + //below thing for livestreaming + type: uid === parseInt(RECORDING_BOT_UID, 10) ? 'bot' : 'rtc', + uid, + offline: false, + isHost: isHostItem?.value || false, + lastMessageTimeStamp: 0, + }; + updateRenderListState(uid, userData); + //end- updating user data in rtc - try { - eventDispatcher(msg, sender, timestamp); - } catch (error) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'error while dispatching through eventDispatcher', - err, - ); + //start - updating screenshare data in rtc + if (screenUid) { + const screenShareUser = { + type: UserType.ScreenShare, + parentUid: uid, + }; + updateRenderListState(screenUid, screenShareUser); } - }); - - engine.current.on('channelMessageReceived', evt => { - logger.debug( - LogSource.Events, - 'CUSTOM_EVENTS', - 'channelMessageReceived', - evt, + //end - updating screenshare data in rtc + } catch (e) { + logger.error( + LogSource.AgoraSDK, + 'Event', + `RTM Failed to process user data for ${userId}`, + {error: e}, ); + } + }; - const {uid, channelId, text, ts} = evt; - //whiteboard upload - if (uid == 1010101) { - const [err, res] = safeJsonParse(text); - if (err) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'JSON payload incorrect, Error while parsing the payload', - err, - ); - } - - if (res?.data?.data?.images) { - LocalEventEmitter.emit( - LocalEventsEnum.WHITEBOARD_FILE_UPLOAD, - res?.data?.data?.images, - ); - } - } else { - const [err, msg] = safeJsonParse(text); - if (err) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'JSON payload incorrect, Error while parsing the payload', - err, - ); - } - - const timestamp = getMessageTime(ts); - - const sender = Platform.OS ? get32BitUid(uid) : parseInt(uid); - - if (channelId === rtcProps.channel) { - try { - eventDispatcher(msg, sender, timestamp); - } catch (error) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'error while dispatching through eventDispatcher', - error, - ); - } - } - } - }); - - await doLoginAndSetupRTM(); + const updateRenderListState = ( + uid: number, + data: Partial, + ) => { + dispatch({type: 'UpdateRenderList', value: [uid, data]}); }; const runQueuedEvents = async () => { try { while (!EventsQueue.isEmpty()) { const currEvt = EventsQueue.dequeue(); - await eventDispatcher(currEvt.data, currEvt.uid, currEvt.ts); + await eventDispatcher(currEvt.data, `${currEvt.uid}`, currEvt.ts); } } catch (error) { logger.error( LogSource.Events, 'CUSTOM_EVENTS', 'error while running queue events', - error, + {error}, ); } }; @@ -686,11 +856,13 @@ const RtmConfigure = (props: any) => { data: { evt: string; value: string; + feat?: string; + etyp?: string; }, sender: string, ts: number, ) => { - console.debug( + console.log( LogSource.Events, 'CUSTOM_EVENTS', 'inside eventDispatcher ', @@ -698,10 +870,10 @@ const RtmConfigure = (props: any) => { ); let evt = '', - value = {}; + value = ''; - if (data.feat === 'WAITING_ROOM') { - if (data.etyp === 'REQUEST') { + if (data?.feat === 'WAITING_ROOM') { + if (data?.etyp === 'REQUEST') { const outputData = { evt: `${data.feat}_${data.etyp}`, payload: JSON.stringify({ @@ -715,7 +887,7 @@ const RtmConfigure = (props: any) => { evt = data.feat + '_' + data.etyp; //rename if client side RTM meessage is to be sent for approval value = formattedData; } - if (data.etyp === 'RESPONSE') { + if (data?.etyp === 'RESPONSE') { const outputData = { evt: `${data.feat}_${data.etyp}`, payload: JSON.stringify({ @@ -756,32 +928,65 @@ const RtmConfigure = (props: any) => { } try { - const {payload, persistLevel, source} = JSON.parse(value); + let parsedValue; + try { + parsedValue = typeof value === 'string' ? JSON.parse(value) : value; + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'RTM Failed to parse event value in event dispatcher:', + {error}, + ); + return; + } + const {payload, persistLevel, source} = parsedValue; // Step 1: Set local attributes if (persistLevel === PersistanceLevel.Session) { const rtmAttribute = {key: evt, value: value}; - await engine.current.addOrUpdateLocalUserAttributes([rtmAttribute]); + const options: SetOrUpdateUserMetadataOptions = { + userId: `${localUid}`, + }; + await engine.current.storage.setUserMetadata( + { + items: [rtmAttribute], + }, + options, + ); } // Step 2: Emit the event - console.debug(LogSource.Events, 'CUSTOM_EVENTS', 'emiting event..: '); + console.log(LogSource.Events, 'CUSTOM_EVENTS', 'emiting event..: '); EventUtils.emitEvent(evt, source, {payload, persistLevel, sender, ts}); // Because async gets evaluated in a different order when in an sdk if (evt === 'name') { - setTimeout(() => { + // 1. Cancel existing timeout for this sender + if (eventTimeouts.has(sender)) { + clearTimeout(eventTimeouts.get(sender)!); + } + // 2. Create new timeout with tracking + const timeout = setTimeout(() => { + // 3. Guard against unmounted component + if (!isRTMMounted.current) { + return; + } EventUtils.emitEvent(evt, source, { payload, persistLevel, sender, ts, }); + // 4. Clean up after execution + eventTimeouts.delete(sender); }, 200); + // 5. Track the timeout for cleanup + eventTimeouts.set(sender, timeout); } } catch (error) { console.error( LogSource.Events, 'CUSTOM_EVENTS', 'error while emiting event:', - error, + {error}, ); } }; @@ -790,45 +995,58 @@ const RtmConfigure = (props: any) => { if (!callActive) { return; } + // Destroy and clean up RTM state await RTMEngine.getInstance().destroy(); + // Set the engine as null + engine.current = null; logger.log(LogSource.AgoraSDK, 'API', 'RTM destroy done'); if (isIOS() || isAndroid()) { EventUtils.clear(); } setHasUserJoinedRTM(false); + setIsInitialQueueCompleted(false); logger.debug(LogSource.AgoraSDK, 'Log', 'RTM cleanup done'); }; useAsyncEffect(async () => { //waiting room attendee -> rtm login will happen on page load - if ($config.ENABLE_WAITING_ROOM) { - //attendee - //for waiting room attendee rtm login will happen on mount - if (!isHost && !callActive) { - await init(); - } - //host - if ( - isHost && - ($config.AUTO_CONNECT_RTM || (!$config.AUTO_CONNECT_RTM && callActive)) - ) { - await init(); - } - } else { - //non waiting room case - //host and attendee - if ( - $config.AUTO_CONNECT_RTM || - (!$config.AUTO_CONNECT_RTM && callActive) - ) { - await init(); + try { + if ($config.ENABLE_WAITING_ROOM) { + //attendee + //for waiting room attendee rtm login will happen on mount + if (!isHost && !callActive) { + await init(localUid); + } + //host + if ( + isHost && + ($config.AUTO_CONNECT_RTM || + (!$config.AUTO_CONNECT_RTM && callActive)) + ) { + await init(localUid); + } + } else { + //non waiting room case + //host and attendee + if ( + $config.AUTO_CONNECT_RTM || + (!$config.AUTO_CONNECT_RTM && callActive) + ) { + await init(localUid); + } } + } catch (error) { + logger.error(LogSource.AgoraSDK, 'Log', 'RTM init failed', {error}); } return async () => { + logger.log( + LogSource.AgoraSDK, + 'Log', + 'RTM unmounting calling end(destroy) ', + ); await end(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rtcProps.channel, rtcProps.appId, callActive]); + }, [rtcProps.channel, rtcProps.appId, callActive, localUid]); return ( { - const rtmEngine: RtmEngine = RTMEngine.getInstance().engine; + const rtmEngine: RTMClient = RTMEngine.getInstance().engine; + const userId = RTMEngine.getInstance().localUid; try { const rtmAttribute = {key: evt, value: payload}; // Step 1: Call RTM API to update local attributes - await rtmEngine.addOrUpdateLocalUserAttributes([rtmAttribute]); + await rtmEngine.storage.setUserMetadata( + {items: [rtmAttribute]}, + { + userId, + }, + ); } catch (error) { logger.error( LogSource.Events, @@ -68,8 +75,8 @@ class Events { `CUSTOM_EVENT_API Event name cannot be of type ${typeof evt}`, ); } - if (evt.trim() == '') { - throw Error(`CUSTOM_EVENT_API Name or function cannot be empty`); + if (evt.trim() === '') { + throw Error('CUSTOM_EVENT_API Name or function cannot be empty'); } return true; }; @@ -103,10 +110,15 @@ class Events { rtmPayload: RTMAttributePayload, toUid?: ReceiverUid, ) => { - const to = typeof toUid == 'string' ? parseInt(toUid) : toUid; - const rtmEngine: RtmEngine = RTMEngine.getInstance().engine; + const to = typeof toUid === 'string' ? parseInt(toUid, 10) : toUid; const text = JSON.stringify(rtmPayload); + + if (!RTMEngine.getInstance().isEngineReady) { + throw new Error('RTM Engine is not ready. Call setLocalUID() first.'); + } + const rtmEngine: RTMClient = RTMEngine.getInstance().engine; + // Case 1: send to channel if ( typeof to === 'undefined' || @@ -120,7 +132,16 @@ class Events { ); try { const channelId = RTMEngine.getInstance().channelUid; - await rtmEngine.sendMessageByChannelId(channelId, text); + if (!channelId || channelId.trim() === '') { + throw new Error( + 'Channel ID is not set. Cannot send channel attributes.', + ); + } + await rtmEngine.publish(channelId, text, { + channelType: nativeChannelTypeMapping.MESSAGE, // 1 is message + customType: 'PlainText', + messageType: 1, + }); } catch (error) { logger.error( LogSource.Events, @@ -140,10 +161,10 @@ class Events { ); const adjustedUID = adjustUID(to); try { - await rtmEngine.sendMessageToPeer({ - peerId: `${adjustedUID}`, - offline: false, - text, + await rtmEngine.publish(`${adjustedUID}`, text, { + channelType: nativeChannelTypeMapping.USER, // user + customType: 'PlainText', + messageType: 1, }); } catch (error) { logger.error( @@ -164,14 +185,32 @@ class Events { to, ); try { - for (const uid of to) { - const adjustedUID = adjustUID(uid); - await rtmEngine.sendMessageToPeer({ - peerId: `${adjustedUID}`, - offline: false, - text, - }); - } + const response = await Promise.allSettled( + to.map(uid => + rtmEngine.publish(`${adjustUID(uid)}`, text, { + channelType: nativeChannelTypeMapping.USER, + customType: 'PlainText', + messageType: 1, + }), + ), + ); + response.forEach((result, index) => { + const uid = to[index]; + if (result.status === 'rejected') { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + `Failed to publish to user ${uid}:`, + result.reason, + ); + } + }); + // for (const uid of to) { + // const adjustedUID = adjustUID(uid); + // await rtmEngine.publish(`${adjustedUID}`, text, { + // channelType: 3, // user + // }); + // } } catch (error) { logger.error( LogSource.Events, @@ -192,13 +231,31 @@ class Events { 'updating channel attributes', ); try { - const rtmEngine: RtmEngine = RTMEngine.getInstance().engine; + // Validate if rtmengine is ready + if (!RTMEngine.getInstance().isEngineReady) { + throw new Error('RTM Engine is not ready. Call setLocalUID() first.'); + } + const rtmEngine: RTMClient = RTMEngine.getInstance().engine; + const channelId = RTMEngine.getInstance().channelUid; + if (!channelId || channelId.trim() === '') { + throw new Error( + 'Channel ID is not set. Cannot send channel attributes.', + ); + } + const rtmAttribute = [{key: rtmPayload.evt, value: rtmPayload.value}]; - // Step 1: Call RTM API to update local attributes - await rtmEngine.addOrUpdateChannelAttributes(channelId, rtmAttribute, { - enableNotificationToChannelMembers: true, - }); + await rtmEngine.storage.setChannelMetadata( + channelId, + nativeChannelTypeMapping.MESSAGE, + { + items: rtmAttribute, + }, + { + addUserId: true, + addTimeStamp: true, + }, + ); } catch (error) { logger.error( LogSource.Events, @@ -223,7 +280,8 @@ class Events { on = (eventName: string, listener: EventCallback): Function => { try { if (!this._validateEvt(eventName) || !this._validateListener(listener)) { - return; + // Return no-op function instead of undefined to prevent errors + return () => {}; } EventUtils.addListener(eventName, listener, this.source); console.log('CUSTOM_EVENT_API event listener registered', eventName); @@ -238,6 +296,8 @@ class Events { 'Error: events.on', error, ); + // Return no-op function on error to prevent undefined issues + return () => {}; } }; @@ -253,7 +313,11 @@ class Events { off = (eventName?: string, listener?: EventCallback) => { try { if (listener) { - if (this._validateListener(listener) && this._validateEvt(eventName)) { + if ( + eventName && + this._validateListener(listener) && + this._validateEvt(eventName) + ) { // listen off an event by eventName and listener //@ts-ignore EventUtils.removeListener(eventName, listener, this.source); @@ -295,8 +359,18 @@ class Events { persistLevel: PersistanceLevel = PersistanceLevel.None, receiver: ReceiverUid = -1, ) => { - if (!this._validateEvt(eventName)) { - return; + try { + if (!this._validateEvt(eventName)) { + return; + } + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'Event validation failed', + error, + ); + return; // Don't throw - just log and return } const persistValue = JSON.stringify({ @@ -318,6 +392,7 @@ class Events { await this._persist(eventName, persistValue); } catch (error) { logger.error(LogSource.Events, 'CUSTOM_EVENTS', 'persist error', error); + // don't throw - just log the error, application should continue running } } try { @@ -336,9 +411,10 @@ class Events { logger.error( LogSource.Events, 'CUSTOM_EVENTS', - 'sending event failed', + `Failed to send event '${eventName}' - event lost`, error, ); + // don't throw - just log the error, application should continue running } }; } diff --git a/template/src/rtm/RTMEngine.ts b/template/src/rtm/RTMEngine.ts index e1a79ad6e..7b1dd00d5 100644 --- a/template/src/rtm/RTMEngine.ts +++ b/template/src/rtm/RTMEngine.ts @@ -10,57 +10,71 @@ ********************************************* */ -import RtmEngine from 'agora-react-native-rtm'; +import { + createAgoraRtmClient, + RtmConfig, + type RTMClient, +} from 'agora-react-native-rtm'; import {isAndroid, isIOS} from '../utils/common'; class RTMEngine { - engine!: RtmEngine; + private _engine?: RTMClient; private localUID: string = ''; private channelId: string = ''; private static _instance: RTMEngine | null = null; + private constructor() { + if (RTMEngine._instance) { + return RTMEngine._instance; + } + RTMEngine._instance = this; + return RTMEngine._instance; + } + public static getInstance() { + // We are only creating the instance but not creating the rtm client yet if (!RTMEngine._instance) { - return new RTMEngine(); + RTMEngine._instance = new RTMEngine(); } return RTMEngine._instance; } - private async createClientInstance() { - await this.engine.createClient($config.APP_ID); - } + setLocalUID(localUID: string | number) { + if (localUID === null || localUID === undefined) { + throw new Error('setLocalUID: localUID cannot be null or undefined'); + } - private async destroyClientInstance() { - await this.engine.logout(); - if (isIOS() || isAndroid()) { - await this.engine.destroyClient(); + const newUID = String(localUID); + if (newUID.trim() === '') { + throw new Error( + 'setLocalUID: localUID cannot be empty after string conversion', + ); } - } - private constructor() { - if (RTMEngine._instance) { - return RTMEngine._instance; + // If UID is changing and we have an existing engine, throw error + if (this._engine && this.localUID !== newUID) { + throw new Error( + `RTMEngine: Cannot change UID from '${this.localUID}' to '${newUID}' while engine is active. ` + + `Please call destroy() first, then setLocalUID() with the new UID.`, + ); } - RTMEngine._instance = this; - this.engine = new RtmEngine(); - this.localUID = ''; - this.channelId = ''; - this.createClientInstance(); - return RTMEngine._instance; - } + this.localUID = newUID; - setLocalUID(localUID: string) { - this.localUID = localUID; + if (!this._engine) { + this.createClientInstance(); + } } setChannelId(channelID: string) { - this.channelId = channelID; - } - - setLoginInfo(localUID: string, channelID: string) { - this.localUID = localUID; + if ( + !channelID || + typeof channelID !== 'string' || + channelID.trim() === '' + ) { + throw new Error('setChannelId: channelID must be a non-empty string'); + } this.channelId = channelID; } @@ -72,16 +86,99 @@ class RTMEngine { return this.channelId; } + get isEngineReady() { + return !!this._engine && !!this.localUID; + } + + get engine(): RTMClient { + this.ensureEngineReady(); + return this._engine!; + } + + private ensureEngineReady() { + if (!this.isEngineReady) { + throw new Error( + 'RTM Engine not ready. Please call setLocalUID() with a valid UID first.', + ); + } + } + + private createClientInstance() { + try { + if (!this.localUID || this.localUID.trim() === '') { + throw new Error('Cannot create RTM client: localUID is not set'); + } + if (!$config.APP_ID) { + throw new Error('Cannot create RTM client: APP_ID is not configured'); + } + const rtmConfig = new RtmConfig({ + appId: $config.APP_ID, + userId: this.localUID, + }); + this._engine = createAgoraRtmClient(rtmConfig); + } catch (error) { + const contextError = new Error( + `Failed to create RTM client instance for userId: ${ + this.localUID + }, appId: ${$config.APP_ID}. Error: ${error.message || error}`, + ); + console.error('RTMEngine createClientInstance error:', contextError); + throw contextError; + } + } + + private async destroyClientInstance() { + try { + if (this._engine) { + // 1. Unsubscribe from channel if we have one + if (this.channelId) { + try { + await this._engine.unsubscribe(this.channelId); + } catch (error) { + console.warn( + `Failed to unsubscribe from channel '${this.channelId}':`, + error, + ); + // Continue with cleanup even if unsubscribe fails + } + } + // 2. Remove all listeners + try { + this._engine.removeAllListeners?.(); + } catch (error) { + console.warn('Failed to remove listeners:', error); + } + // 3. Logout + try { + await this._engine.logout(); + if (isAndroid() || isIOS()) { + this._engine.release(); + } + } catch (error) { + console.warn('Failed to logout:', error); + } + } + } catch (error) { + console.error('Error during client instance destruction:', error); + // Don't re-throw - we want cleanup to complete + } + } + async destroy() { try { - await this.destroyClientInstance(); - if (isIOS() || isAndroid()) { - RTMEngine._instance = null; + if (!this._engine) { + return; } - this.localUID = ''; + + await this.destroyClientInstance(); this.channelId = ''; + this.localUID = ''; + this._engine = undefined; + RTMEngine._instance = null; } catch (error) { - console.log('Error destroying instance error: ', error); + console.error('Error destroying RTM instance:', error); + // Don't re-throw - destruction should be a best-effort cleanup + // Re-throwing could prevent proper cleanup in calling code } } } diff --git a/template/src/utils/useEndCall.ts b/template/src/utils/useEndCall.ts index 1a5cc0ad4..f37991dec 100644 --- a/template/src/utils/useEndCall.ts +++ b/template/src/utils/useEndCall.ts @@ -69,7 +69,7 @@ const useEndCall = () => { if ($config.CHAT) { deleteChatUser(); } - RTMEngine.getInstance().engine.leaveChannel(rtcProps.channel); + RTMEngine.getInstance().engine.unsubscribe(rtcProps.channel); if (!ENABLE_AUTH) { // await authLogout(); await authLogin(); diff --git a/voice-chat.config.json b/voice-chat.config.json index dd2b1659b..2d668e279 100644 --- a/voice-chat.config.json +++ b/voice-chat.config.json @@ -11,7 +11,7 @@ "PRECALL": true, "CHAT": true, "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", + "RECORDING_MODE": "MIX", "SCREEN_SHARING": false, "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", "ENCRYPTION_ENABLED": true, diff --git a/voice-chat.config.light.json b/voice-chat.config.light.json index 29a08c699..6c0fe679e 100644 --- a/voice-chat.config.light.json +++ b/voice-chat.config.light.json @@ -11,7 +11,7 @@ "PRECALL": true, "CHAT": true, "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", + "RECORDING_MODE": "MIX", "SCREEN_SHARING": false, "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", "ENCRYPTION_ENABLED": true,