From 78adcb30104a51f3c4217303952bf137aa2441b2 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 15 Oct 2025 14:15:52 -0300 Subject: [PATCH 1/8] local lib --- package.json | 1 + yarn.lock | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/package.json b/package.json index f6a01974cf0..3420a3d0891 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@react-navigation/elements": "^2.6.1", "@react-navigation/native": "^7.1.16", "@react-navigation/native-stack": "^7.3.23", + "@rocket.chat/media-signaling": "/Users/diegomello/Development/Work/Rocket.Chat/packages/media-signaling", "@rocket.chat/message-parser": "^0.31.31", "@rocket.chat/mobile-crypto": "RocketChat/rocket.chat-mobile-crypto", "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#mobile", diff --git a/yarn.lock b/yarn.lock index b7f53c6362e..67ffa5caabc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4923,6 +4923,11 @@ resolved "https://registry.yarnpkg.com/@redux-saga/types/-/types-1.2.1.tgz#9403f51c17cae37edf870c6bc0c81c1ece5ccef8" integrity sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA== +"@rocket.chat/emitter@~0.31.25": + version "0.31.25" + resolved "https://registry.yarnpkg.com/@rocket.chat/emitter/-/emitter-0.31.25.tgz#17dd7838dc988ebc5fcd96343c4431d5cef45845" + integrity sha512-hw5BpDlNwpYSb+K5X3DNMNUVEVXxmXugUPetGZGCWvntSVFsOjYuVEypoKW6vBBXSfqCBb0kN1npYcKEb4NFBw== + "@rocket.chat/eslint-config@^0.4.0": version "0.4.0" resolved "https://registry.yarnpkg.com/@rocket.chat/eslint-config/-/eslint-config-0.4.0.tgz#d648decd02ae739eac17a32e1630332a75318ea1" @@ -4930,6 +4935,12 @@ dependencies: eslint-plugin-import "^2.17.2" +"@rocket.chat/media-signaling@file:../Rocket.Chat/packages/media-signaling": + version "0.0.1" + dependencies: + "@rocket.chat/emitter" "~0.31.25" + ajv "^8.17.1" + "@rocket.chat/message-parser@^0.31.31": version "0.31.31" resolved "https://registry.yarnpkg.com/@rocket.chat/message-parser/-/message-parser-0.31.31.tgz#9a3eea7602ac37387c6384577623865c8d536003" @@ -5932,6 +5943,16 @@ ajv@^8.0.0, ajv@^8.6.3, ajv@^8.9.0: require-from-string "^2.0.2" uri-js "^4.2.2" +ajv@^8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + anser@^1.4.9: version "1.4.10" resolved "https://registry.yarnpkg.com/anser/-/anser-1.4.10.tgz#befa3eddf282684bd03b63dcda3927aef8c2e35b" @@ -8744,6 +8765,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-uri@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" + integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== + fast-xml-parser@^4.0.12, fast-xml-parser@^4.2.4: version "4.4.1" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f" From dc70016d3d3d8a3cfcbeccb3b752e47e24d78895 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 16 Oct 2025 10:20:02 -0300 Subject: [PATCH 2/8] Add react-native-webrtc --- ios/Podfile.lock | 12 ++++++++- ios/RocketChatRN.xcodeproj/project.pbxproj | 4 +++ package.json | 1 + yarn.lock | 30 ++++++++++++++++------ 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0adfeaba160..0c0ce8538b8 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -209,6 +209,7 @@ PODS: - hermes-engine (0.79.4): - hermes-engine/Pre-built (= 0.79.4) - hermes-engine/Pre-built (0.79.4) + - JitsiWebRTC (124.0.2) - libavif/core (0.11.1) - libavif/libdav1d (0.11.1): - libavif/core @@ -1816,6 +1817,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-webrtc (124.0.7): + - JitsiWebRTC (~> 124.0.0) + - React-Core - react-native-webview (13.15.0): - DoubleConversion - glog @@ -2712,6 +2716,7 @@ DEPENDENCIES: - react-native-restart (from `../node_modules/react-native-restart`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - "react-native-slider (from `../node_modules/@react-native-community/slider`)" + - react-native-webrtc (from `../node_modules/react-native-webrtc`) - react-native-webview (from `../node_modules/react-native-webview`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) @@ -2783,6 +2788,7 @@ SPEC REPOS: - GoogleAppMeasurement - GoogleDataTransport - GoogleUtilities + - JitsiWebRTC - libavif - libdav1d - libwebp @@ -2939,6 +2945,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-safe-area-context" react-native-slider: :path: "../node_modules/@react-native-community/slider" + react-native-webrtc: + :path: "../node_modules/react-native-webrtc" react-native-webview: :path: "../node_modules/react-native-webview" React-NativeModulesApple: @@ -3091,6 +3099,7 @@ SPEC CHECKSUMS: GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d hermes-engine: 8b5a5eb386b990287d072fd7b6f6ebd9544dd251 + JitsiWebRTC: b47805ab5668be38e7ee60e2258f49badfe8e1d0 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -3142,6 +3151,7 @@ SPEC CHECKSUMS: react-native-restart: f6f591aeb40194c41b9b5013901f00e6cf7d0f29 react-native-safe-area-context: 5928d84c879db2f9eb6969ca70e68f58623dbf25 react-native-slider: 605e731593322c4bb2eb48d7d64e2e4dbf7cbd77 + react-native-webrtc: e8f0ce746353adc2744a2b933645e1aeb41eaa74 react-native-webview: e28f476ea60826ef0b1d7297244db1dfbec74acd React-NativeModulesApple: 5b234860053d0dd11f3442f38b99688ff1c9733b React-oscompat: 472a446c740e39ee39cd57cd7bfd32177c763a2b @@ -3172,7 +3182,7 @@ SPEC CHECKSUMS: React-timing: 2d07431f1c1203c5b0aaa6dc7b5f503704519218 React-utils: 67cf7dcfc18aa4c56bec19e11886033bb057d9fa ReactAppDependencyProvider: bf62814e0fde923f73fc64b7e82d76c63c284da9 - ReactCodegen: c51a63d05629675dd61caf58d1a093c4457972c0 + ReactCodegen: df3ff45729335a27d1c85bed1787e79783289968 ReactCommon: 177fca841e97b2c0e288e86097b8be04c6e7ae36 RNBootSplash: 1280eeb18d887de0a45bb4923d4fc56f25c8b99c RNCAsyncStorage: edb872909c88d8541c0bfade3f86cd7784a7c6b3 diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index 7f65bcc902d..0385f066b0b 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -1658,10 +1658,12 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-defaults-RocketChatRN/Pods-defaults-RocketChatRN-frameworks.sh", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/JitsiWebRTC/WebRTC.framework/WebRTC", "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WebRTC.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", ); runOnlyForDeploymentPostprocessing = 0; @@ -2145,10 +2147,12 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-defaults-Rocket.Chat/Pods-defaults-Rocket.Chat-frameworks.sh", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/JitsiWebRTC/WebRTC.framework/WebRTC", "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WebRTC.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", ); runOnlyForDeploymentPostprocessing = 0; diff --git a/package.json b/package.json index 3420a3d0891..c32e97a50bb 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "react-native-svg": "^15.12.1", "react-native-url-polyfill": "2.0.0", "react-native-vector-icons": "9.2.0", + "react-native-webrtc": "^124.0.7", "react-native-webview": "^13.15.0", "react-redux": "8.0.5", "reanimated-tab-view": "^0.3.0", diff --git a/yarn.lock b/yarn.lock index 67ffa5caabc..4f6b54d1278 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6512,7 +6512,7 @@ bare-path@^2.0.0, bare-path@^2.1.0: dependencies: bare-os "^2.1.0" -base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1: +base64-js@1.5.1, base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -7428,6 +7428,13 @@ debug@4, debug@^4.1.0, debug@^4.1.1: dependencies: ms "^2.1.1" +debug@4.3.4, debug@^4.3.2, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + debug@^3.1.0, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -7442,13 +7449,6 @@ debug@^4.3.1: dependencies: ms "2.1.2" -debug@^4.3.2, debug@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - debug@^4.3.5, debug@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" @@ -8506,6 +8506,11 @@ event-pubsub@4.3.0: resolved "https://registry.yarnpkg.com/event-pubsub/-/event-pubsub-4.3.0.tgz#f68d816bc29f1ec02c539dc58c8dd40ce72cb36e" integrity sha512-z7IyloorXvKbFx9Bpie2+vMJKKx1fH1EN5yiTfp8CiLOTptSYy1g8H4yDpGlEdshL1PBiFtBHepF2cNsqeEeFQ== +event-target-shim@6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-6.0.2.tgz#ea5348c3618ee8b62ff1d344f01908ee2b8a2b71" + integrity sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA== + event-target-shim@^5.0.0, event-target-shim@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" @@ -13156,6 +13161,15 @@ react-native-vector-icons@9.2.0: prop-types "^15.7.2" yargs "^16.1.1" +react-native-webrtc@^124.0.7: + version "124.0.7" + resolved "https://registry.yarnpkg.com/react-native-webrtc/-/react-native-webrtc-124.0.7.tgz#f50647a8eb3fae0ef29843eb1b5fe2c4ff75a56e" + integrity sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA== + dependencies: + base64-js "1.5.1" + debug "4.3.4" + event-target-shim "6.0.2" + react-native-webview@^13.15.0: version "13.15.0" resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.15.0.tgz#b6d2f8d8dd65897db76659ddd8198d2c74ec5a79" From 4bf95d23d7af73dae54ad9349f7cf7870b41acd3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 16 Oct 2025 10:20:27 -0300 Subject: [PATCH 3/8] Fetch VoIP_TeamCollab_Ice_Servers --- app/lib/constants/defaultSettings.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/lib/constants/defaultSettings.ts b/app/lib/constants/defaultSettings.ts index 74b3f1ed355..3d283eee345 100644 --- a/app/lib/constants/defaultSettings.ts +++ b/app/lib/constants/defaultSettings.ts @@ -300,5 +300,8 @@ export const defaultSettings = { Cloud_Workspace_AirGapped_Restrictions_Remaining_Days: { type: 'valueAsNumber' }, + VoIP_TeamCollab_Ice_Servers: { + type: 'valueAsString' + }, ...deprecatedSettings } as const; From 178f122531c0d5e9cfe6504f9e1ac8c8de276a33 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 16 Oct 2025 14:46:02 -0300 Subject: [PATCH 4/8] base work --- app/definitions/Voip.ts | 5 + app/lib/constants/defaultSettings.ts | 3 + app/lib/services/voip/MediaCallLogger.ts | 21 ++++ app/lib/services/voip/MediaSessionInstance.ts | 71 ++++++++++++ app/lib/services/voip/MediaSessionStore.ts | 105 ++++++++++++++++++ .../services/voip/parseStringToIceServers.ts | 21 ++++ app/lib/store/index.ts | 4 +- app/sagas/login.js | 14 +++ patches/@rocket.chat+sdk+1.3.3-mobile.patch | 15 +++ 9 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 app/definitions/Voip.ts create mode 100644 app/lib/services/voip/MediaCallLogger.ts create mode 100644 app/lib/services/voip/MediaSessionInstance.ts create mode 100644 app/lib/services/voip/MediaSessionStore.ts create mode 100644 app/lib/services/voip/parseStringToIceServers.ts create mode 100644 patches/@rocket.chat+sdk+1.3.3-mobile.patch diff --git a/app/definitions/Voip.ts b/app/definitions/Voip.ts new file mode 100644 index 00000000000..1f13d16e56f --- /dev/null +++ b/app/definitions/Voip.ts @@ -0,0 +1,5 @@ +export type IceServer = { + urls: string; + username?: string; + credential?: string; +}; diff --git a/app/lib/constants/defaultSettings.ts b/app/lib/constants/defaultSettings.ts index 3d283eee345..d2ea6708a49 100644 --- a/app/lib/constants/defaultSettings.ts +++ b/app/lib/constants/defaultSettings.ts @@ -303,5 +303,8 @@ export const defaultSettings = { VoIP_TeamCollab_Ice_Servers: { type: 'valueAsString' }, + VoIP_TeamCollab_Ice_Gathering_Timeout: { + type: 'valueAsNumber' + }, ...deprecatedSettings } as const; diff --git a/app/lib/services/voip/MediaCallLogger.ts b/app/lib/services/voip/MediaCallLogger.ts new file mode 100644 index 00000000000..0d9444d7f7a --- /dev/null +++ b/app/lib/services/voip/MediaCallLogger.ts @@ -0,0 +1,21 @@ +import type { IMediaSignalLogger } from '@rocket.chat/media-signaling'; + +export class MediaCallLogger implements IMediaSignalLogger { + log(...args: unknown[]): void { + console.log(`[Media Call] ${JSON.stringify(args)}`); + } + + debug(...args: unknown[]): void { + if (__DEV__) { + console.log(`[Media Call Debug] ${JSON.stringify(args)}`); + } + } + + error(...args: unknown[]): void { + console.log(`[Media Call Error] ${JSON.stringify(args)}`); + } + + warn(...args: unknown[]): void { + console.log(`[Media Call Warning] ${JSON.stringify(args)}`); + } +} diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts new file mode 100644 index 00000000000..e269c18887e --- /dev/null +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -0,0 +1,71 @@ +import { + ClientMediaSignal, + MediaCallWebRTCProcessor, + MediaSignalingSession, + WebRTCProcessorConfig +} from '@rocket.chat/media-signaling'; + +import { mediaSessionStore } from './MediaSessionStore'; +import { store } from '../../store/auxStore'; +import sdk from '../sdk'; +import { parseStringToIceServers } from './parseStringToIceServers'; +import { IceServer } from '../../../definitions/Voip'; +import { notifyUser } from '../restApi'; + +class MediaSessionInstance { + private iceServers: IceServer[] = []; + private iceGatheringTimeout: number = 5000; + private mediaSignalListener: any; + private mediaSignalsListener: any; + private instance: MediaSignalingSession | null = null; + + public init(userId: string): void { + this.iceServers = this.getIceServers(); + console.log('iceServers', this.iceServers); + this.iceGatheringTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number; + + mediaSessionStore.setWebRTCProcessorFactory( + (config: WebRTCProcessorConfig) => + new MediaCallWebRTCProcessor({ + ...config, + rtc: { ...config.rtc, iceServers: this.iceServers }, + iceGatheringTimeout: this.iceGatheringTimeout + }) + ); + + this.mediaSignalListener = sdk.onStreamData('stream-notify-user', (ddpMessage: any) => { + if (!this.instance) { + console.warn('Media Call - Tried to process signal, but no instance was set'); + return; + } + + const [, ev] = ddpMessage.fields.eventName.split('/'); + if (ev !== 'media-signal') { + return; + } + const signal = ddpMessage.fields.args[0]; + this.instance.processSignal(signal); + }); + + mediaSessionStore.setSendSignalFn((signal: ClientMediaSignal) => { + sdk.methodCall('stream-notify-user', `${userId}/media-calls`, JSON.stringify(signal)); + }); + + this.instance = mediaSessionStore.getInstance(userId); + console.log('instance', this.instance); + + const mainCall = this.instance?.getMainCall(); + console.log('mainCall', mainCall); + + if (!mainCall) { + this.instance?.startCall('sip', 'bMvbehmLppt3BzeMc'); + } + } + + private getIceServers() { + const iceServers = store.getState().settings.VoIP_TeamCollab_Ice_Servers as any; + return parseStringToIceServers(iceServers); + } +} + +export const mediaSessionInstance = new MediaSessionInstance(); diff --git a/app/lib/services/voip/MediaSessionStore.ts b/app/lib/services/voip/MediaSessionStore.ts new file mode 100644 index 00000000000..412abdbabcd --- /dev/null +++ b/app/lib/services/voip/MediaSessionStore.ts @@ -0,0 +1,105 @@ +import { Emitter } from '@rocket.chat/emitter'; +import { MediaSignalingSession, MediaCallWebRTCProcessor } from '@rocket.chat/media-signaling'; +import type { MediaSignalTransport, ClientMediaSignal, WebRTCProcessorConfig } from '@rocket.chat/media-signaling'; +import { mediaDevices } from 'react-native-webrtc'; + +import { MediaCallLogger } from './MediaCallLogger'; +// import { useIceServers } from './useIceServers'; + +type SignalTransport = MediaSignalTransport; + +const randomStringFactory = (): string => + Date.now().toString(36) + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + +// const getSessionIdKey = (userId: string): string => `rcx-media-session-id-${userId}`; + +class MediaSessionStore extends Emitter<{ change: void }> { + private sessionInstance: MediaSignalingSession | null = null; + private sendSignalFn: SignalTransport | null = null; + private _webrtcProcessorFactory: ((config: WebRTCProcessorConfig) => MediaCallWebRTCProcessor) | null = null; + + private change(): void { + this.emit('change'); + } + + public onChange(callback: () => void): () => void { + return this.on('change', callback); + } + + private webrtcProcessorFactory(config: WebRTCProcessorConfig): MediaCallWebRTCProcessor { + if (!this._webrtcProcessorFactory) { + throw new Error('WebRTC processor factory not set'); + } + return this._webrtcProcessorFactory(config); + } + + private sendSignal(signal: ClientMediaSignal): void { + if (this.sendSignalFn) { + this.sendSignalFn(signal); + return; + } + + console.warn('Media Call - Tried to send signal, but no sendSignalFn was set'); + } + + private makeInstance(userId: string): MediaSignalingSession | null { + if (this.sessionInstance !== null) { + console.log('ending session', this.sessionInstance); + this.sessionInstance.endSession(); + this.sessionInstance = null; + } + + if (!this._webrtcProcessorFactory || !this.sendSignalFn) { + console.warn('Media Call - Tried to make instance, but no webrtcProcessorFactory or sendSignalFn was set'); + return null; + } + + this.sessionInstance = new MediaSignalingSession({ + userId, + transport: (signal: ClientMediaSignal) => this.sendSignal(signal), + processorFactories: { + webrtc: (config: WebRTCProcessorConfig) => this.webrtcProcessorFactory(config) + }, + mediaStreamFactory: (constraints: any) => mediaDevices.getUserMedia(constraints) as unknown as Promise, + randomStringFactory, + logger: new MediaCallLogger() + }); + + this.change(); + + return this.sessionInstance; + } + + public getInstance(userId?: string): MediaSignalingSession | null { + if (!userId) { + console.warn('Media Call - Tried to get instance, but no userId was set'); + return null; + } + + if (this.sessionInstance?.userId === userId) { + return this.sessionInstance; + } + + return this.makeInstance(userId); + } + + public setSendSignalFn(sendSignalFn: SignalTransport): () => void { + this.sendSignalFn = sendSignalFn; + this.change(); + return () => { + this.sendSignalFn = null; + }; + } + + public setWebRTCProcessorFactory(factory: (config: WebRTCProcessorConfig) => MediaCallWebRTCProcessor): void { + this._webrtcProcessorFactory = factory; + this.change(); + } + + public getCurrentInstance(): MediaSignalingSession | null { + return this.sessionInstance; + } +} + +// TODO: change name +export const mediaSessionStore = new MediaSessionStore(); diff --git a/app/lib/services/voip/parseStringToIceServers.ts b/app/lib/services/voip/parseStringToIceServers.ts new file mode 100644 index 00000000000..2b5b47c6394 --- /dev/null +++ b/app/lib/services/voip/parseStringToIceServers.ts @@ -0,0 +1,21 @@ +import type { IceServer } from '../../../definitions/Voip'; + +export const parseStringToIceServer = (server: string): IceServer => { + const credentials = server.trim().split('@'); + const urls = credentials.pop() as string; + const [username, credential] = credentials.length === 1 ? credentials[0].split(':') : []; + + return { + urls, + ...(username && + credential && { + username: decodeURIComponent(username), + credential: decodeURIComponent(credential) + }) + }; +}; + +export const parseStringToIceServers = (string: string): IceServer[] => { + const lines = string.trim() ? string.split(',') : []; + return lines.map(line => parseStringToIceServer(line)); +}; diff --git a/app/lib/store/index.ts b/app/lib/store/index.ts index 3ae917484ac..25011f0f648 100644 --- a/app/lib/store/index.ts +++ b/app/lib/store/index.ts @@ -18,8 +18,8 @@ if (__DEV__) { applyAppStateMiddleware(), applyInternetStateMiddleware(), applyMiddleware(reduxImmutableStateInvariant), - applyMiddleware(sagaMiddleware), - applyMiddleware(logger) + applyMiddleware(sagaMiddleware) + // applyMiddleware(logger) ); } else { sagaMiddleware = createSagaMiddleware(); diff --git a/app/sagas/login.js b/app/sagas/login.js index 91dfe225f0c..9fb803f33a5 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -3,6 +3,8 @@ import { call, cancel, delay, fork, put, race, select, take, takeLatest } from ' import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { Q } from '@nozbe/watermelondb'; import * as Keychain from 'react-native-keychain'; +import { Emitter } from '@rocket.chat/emitter'; +import { MediaSignalingSession, MediaCallWebRTCProcessor } from '@rocket.chat/media-signaling'; import moment from 'moment'; import * as types from '../actions/actionsTypes'; @@ -42,6 +44,7 @@ import appNavigation from '../lib/navigation/appNavigation'; import { showActionSheetRef } from '../containers/ActionSheet'; import { SupportedVersionsWarning } from '../containers/SupportedVersions'; import { isIOS } from '../lib/methods/helpers'; +import { mediaSessionInstance } from '../lib/services/voip/MediaSessionInstance'; const getServer = state => state.server.server; const loginWithPasswordCall = args => loginWithPassword(args); @@ -214,6 +217,15 @@ const fetchUsersRoles = function* fetchRoomsFork() { } }; +const startVoipFork = function* startVoipFork() { + try { + const userId = yield select(state => state.login.user.id); + mediaSessionInstance.init(userId); + } catch (e) { + log(e); + } +}; + const handleLoginSuccess = function* handleLoginSuccess({ user }) { try { getUserPresence(user.id); @@ -230,6 +242,8 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { yield fork(fetchEnterpriseModulesFork, { user }); yield fork(subscribeSettingsFork); yield fork(fetchUsersRoles); + yield delay(1000); + yield fork(startVoipFork); setLanguage(user?.language); diff --git a/patches/@rocket.chat+sdk+1.3.3-mobile.patch b/patches/@rocket.chat+sdk+1.3.3-mobile.patch new file mode 100644 index 00000000000..ab0a55a87d2 --- /dev/null +++ b/patches/@rocket.chat+sdk+1.3.3-mobile.patch @@ -0,0 +1,15 @@ +diff --git a/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts b/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts +index 19d31ae..d5295b5 100644 +--- a/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts ++++ b/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts +@@ -549,7 +549,9 @@ export class DDPDriver extends EventEmitter implements ISocket, IDriver { + 'uiInteraction', + 'e2ekeyRequest', + 'userData', +- 'video-conference' ++ 'video-conference', ++ 'media-signal', ++ 'media-calls' + ].map(event => this.subscribe(topic, `${this.userId}/${event}`, false))) + } + From 8f9cff10ab012b0553621acf641e1813a70ba30f Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 20 Oct 2025 15:33:53 -0300 Subject: [PATCH 5/8] Able to receive a call on Android --- android/app/src/main/AndroidManifest.xml | 24 ++++ app/lib/services/voip/MediaSessionInstance.ts | 126 ++++++++++++++++-- app/lib/services/voip/MediaSessionStore.ts | 7 + app/sagas/login.js | 45 ++++++- package.json | 1 + yarn.lock | 5 + 6 files changed, 196 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 552191d1316..a4c7293f099 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,17 @@ + + + + + + + + + + + @@ -87,6 +98,19 @@ + + + + + + + + diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index e269c18887e..830309c85a5 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -1,16 +1,18 @@ import { ClientMediaSignal, + IClientMediaCall, MediaCallWebRTCProcessor, MediaSignalingSession, WebRTCProcessorConfig } from '@rocket.chat/media-signaling'; +import RNCallKeep from 'react-native-callkeep'; +import { registerGlobals } from 'react-native-webrtc'; import { mediaSessionStore } from './MediaSessionStore'; import { store } from '../../store/auxStore'; import sdk from '../sdk'; import { parseStringToIceServers } from './parseStringToIceServers'; import { IceServer } from '../../../definitions/Voip'; -import { notifyUser } from '../restApi'; class MediaSessionInstance { private iceServers: IceServer[] = []; @@ -23,6 +25,9 @@ class MediaSessionInstance { this.iceServers = this.getIceServers(); console.log('iceServers', this.iceServers); this.iceGatheringTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number; + console.log('iceGatheringTimeout', this.iceGatheringTimeout); + + registerGlobals(); mediaSessionStore.setWebRTCProcessorFactory( (config: WebRTCProcessorConfig) => @@ -33,6 +38,10 @@ class MediaSessionInstance { }) ); + mediaSessionStore.setSendSignalFn((signal: ClientMediaSignal) => { + sdk.methodCall('stream-notify-user', `${userId}/media-calls`, JSON.stringify(signal)); + }); + this.mediaSignalListener = sdk.onStreamData('stream-notify-user', (ddpMessage: any) => { if (!this.instance) { console.warn('Media Call - Tried to process signal, but no instance was set'); @@ -47,19 +56,116 @@ class MediaSessionInstance { this.instance.processSignal(signal); }); - mediaSessionStore.setSendSignalFn((signal: ClientMediaSignal) => { - sdk.methodCall('stream-notify-user', `${userId}/media-calls`, JSON.stringify(signal)); - }); - this.instance = mediaSessionStore.getInstance(userId); console.log('instance', this.instance); - const mainCall = this.instance?.getMainCall(); - console.log('mainCall', mainCall); + mediaSessionStore.onChange(() => { + const previousInstance = this.instance; + this.instance = mediaSessionStore.getInstance(userId); + console.log('previousInstance', previousInstance, 'new instance', this.instance); + }); + + RNCallKeep.addEventListener('answerCall', async ({ callUUID }) => { + const mainCall = this.instance?.getMainCall(); + console.log('answerCall', mainCall.callId, callUUID); + if (mainCall && mainCall.callId === callUUID) { + console.log('📱 User accepted call:', callUUID); + // RNCallKeep.backToForeground(); + await mainCall.accept(); + RNCallKeep.setCurrentCallActive(mainCall.callId); + } else { + console.warn('⚠️ Call not found:', callUUID); + RNCallKeep.endCall(callUUID); + } + }); + + // User tapped "Decline" or "End Call" + RNCallKeep.addEventListener('endCall', ({ callUUID }) => { + console.log('📱 User ended call:', callUUID); + + const mainCall = this.instance?.getMainCall(); + if (mainCall && mainCall.callId === callUUID) { + if (mainCall.state === 'ringing') { + mainCall.reject(); + } else { + mainCall.hangup(); + } + } + }); + + // User toggled mute button + RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }) => { + console.log('📱 Mute toggled:', muted); + + const mainCall = this.instance?.getMainCall(); + if (mainCall && mainCall.callId === callUUID) { + mainCall.setMuted(muted); + } + }); + + RNCallKeep.addEventListener('didPerformDTMFAction', ({ digits }) => { + const mainCall = this.instance?.getMainCall(); + if (mainCall) { + mainCall.sendDTMF(digits); + } + }); + + this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => { + console.log('📞 NEW CALL RECEIVED', { + callId: call.callId, + contact: call.contact.displayName || call.contact.username, + role: call.role, + state: call.state + }); + + if (call && !call.hidden) { + // Listen to state changes + call.emitter.on('stateChange', oldState => { + console.log(`📊 ${oldState} → ${call.state}`); + }); + + const displayName = call.contact.displayName || call.contact.username || 'Unknown'; + + // Show CallKeep incoming call screen + RNCallKeep.displayIncomingCall( + call.callId, // UUID + displayName, // Caller name + displayName, // Caller handle (can be phone number) + 'generic', // Call type + false // Has video + ); - if (!mainCall) { - this.instance?.startCall('sip', 'bMvbehmLppt3BzeMc'); - } + call.emitter.on('active', () => { + console.log('✅ CALL ACTIVE - Audio should work automatically!'); + const remoteStream = call.getRemoteMediaStream(); + // RNCallKeep.backToForeground(); + console.log('Remote stream:', { + id: remoteStream.id, + active: remoteStream.active, + audioTracks: remoteStream.getAudioTracks().length, + tracks: remoteStream.getTracks().map(t => ({ + kind: t.kind, + enabled: t.enabled, + readyState: t.readyState + })) + }); + // RNCallKeep.startCall(call.callId, displayName, displayName); + // That's it! No need to do anything else for audio to work. + }); + + call.emitter.on('ended', () => { + console.log('❌ CALL ENDED'); + // Optional: Clean up if needed + RNCallKeep.endCall(call.callId); + }); + + // FOR TESTING: Auto-accept after 2 seconds + // setTimeout(() => { + // console.log('🟢 AUTO-ACCEPTING...'); + // call.accept(); + // }, 2000); + } + }); } private getIceServers() { diff --git a/app/lib/services/voip/MediaSessionStore.ts b/app/lib/services/voip/MediaSessionStore.ts index 412abdbabcd..d5fd558948b 100644 --- a/app/lib/services/voip/MediaSessionStore.ts +++ b/app/lib/services/voip/MediaSessionStore.ts @@ -2,6 +2,7 @@ import { Emitter } from '@rocket.chat/emitter'; import { MediaSignalingSession, MediaCallWebRTCProcessor } from '@rocket.chat/media-signaling'; import type { MediaSignalTransport, ClientMediaSignal, WebRTCProcessorConfig } from '@rocket.chat/media-signaling'; import { mediaDevices } from 'react-native-webrtc'; +// import BackgroundTimer from 'react-native-background-timer'; import { MediaCallLogger } from './MediaCallLogger'; // import { useIceServers } from './useIceServers'; @@ -63,6 +64,12 @@ class MediaSessionStore extends Emitter<{ change: void }> { mediaStreamFactory: (constraints: any) => mediaDevices.getUserMedia(constraints) as unknown as Promise, randomStringFactory, logger: new MediaCallLogger() + // timerProcessor: { + // setInterval: BackgroundTimer.setInterval, + // clearInterval: BackgroundTimer.clearInterval, + // setTimeout: BackgroundTimer.setTimeout, + // clearTimeout: BackgroundTimer.clearTimeout + // } }); this.change(); diff --git a/app/sagas/login.js b/app/sagas/login.js index 9fb803f33a5..b134e524013 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -3,8 +3,9 @@ import { call, cancel, delay, fork, put, race, select, take, takeLatest } from ' import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { Q } from '@nozbe/watermelondb'; import * as Keychain from 'react-native-keychain'; -import { Emitter } from '@rocket.chat/emitter'; -import { MediaSignalingSession, MediaCallWebRTCProcessor } from '@rocket.chat/media-signaling'; +import RNCallKeep from 'react-native-callkeep'; +import { PermissionsAndroid } from 'react-native'; +import BackgroundTimer from 'react-native-background-timer'; import moment from 'moment'; import * as types from '../actions/actionsTypes'; @@ -217,8 +218,48 @@ const fetchUsersRoles = function* fetchRoomsFork() { } }; +function* initCallKeep() { + try { + const options = { + ios: { + appName: 'Rocket.Chat', + includesCallsInRecents: false + }, + android: { + alertTitle: 'Permissions required', + alertDescription: 'This application needs to access your phone accounts', + cancelButton: 'Cancel', + okButton: 'Ok', + imageName: 'phone_account_icon', + additionalPermissions: [ + PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, + PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, + PermissionsAndroid.PERMISSIONS.CALL_PHONE + ], + // Required to get audio in background when using Android 11 + foregroundService: { + channelId: 'chat.rocket.reactnative', + channelName: 'Rocket.Chat', + notificationTitle: 'Voice call is running on background' + } + } + }; + + RNCallKeep.setup(options); + RNCallKeep.canMakeMultipleCalls(false); + + const start = Date.now(); + setInterval(() => { + console.log('Timer fired after', Date.now() - start, 'ms'); + }, 1000); + } catch (e) { + log(e); + } +} + const startVoipFork = function* startVoipFork() { try { + yield call(initCallKeep); const userId = yield select(state => state.login.user.id); mediaSessionInstance.init(userId); } catch (e) { diff --git a/package.json b/package.json index c32e97a50bb..ffceda280af 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "react-native-animatable": "1.3.3", "react-native-background-timer": "2.4.1", "react-native-bootsplash": "^6.3.8", + "react-native-callkeep": "^4.3.16", "react-native-config-reader": "4.1.1", "react-native-console-time-polyfill": "1.2.3", "react-native-device-info": "11.1.0", diff --git a/yarn.lock b/yarn.lock index 4f6b54d1278..35e5f5764b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12917,6 +12917,11 @@ react-native-bootsplash@^6.3.8: ts-dedent "^2.2.0" xml-formatter "^3.6.5" +react-native-callkeep@^4.3.16: + version "4.3.16" + resolved "https://registry.yarnpkg.com/react-native-callkeep/-/react-native-callkeep-4.3.16.tgz#56291796984b896113ef00f8b67ae3fe177baf70" + integrity sha512-aIxn02T5zW4jNPyzRdFGTWv6xD3Vy/1AkBMB6iYvWZEHWnfmgNGF0hELqg03Vbc2BNUhfqpu17aIydos+5Hurg== + react-native-config-reader@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/react-native-config-reader/-/react-native-config-reader-4.1.1.tgz#478b69e32adcc2e9a14f6ef5fa2cbbe012b9a27e" From 850334c9552f9b356cbac9908b2cc28f0d7f9e40 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 20 Oct 2025 15:47:23 -0300 Subject: [PATCH 6/8] Small cleanup --- app/lib/services/voip/MediaSessionInstance.ts | 63 ++----------------- 1 file changed, 4 insertions(+), 59 deletions(-) diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 830309c85a5..295d2798710 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -22,12 +22,11 @@ class MediaSessionInstance { private instance: MediaSignalingSession | null = null; public init(userId: string): void { + registerGlobals(); this.iceServers = this.getIceServers(); - console.log('iceServers', this.iceServers); this.iceGatheringTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number; - console.log('iceGatheringTimeout', this.iceGatheringTimeout); - - registerGlobals(); + this.instance = mediaSessionStore.getInstance(userId); + mediaSessionStore.onChange(() => (this.instance = mediaSessionStore.getInstance(userId))); mediaSessionStore.setWebRTCProcessorFactory( (config: WebRTCProcessorConfig) => @@ -44,10 +43,8 @@ class MediaSessionInstance { this.mediaSignalListener = sdk.onStreamData('stream-notify-user', (ddpMessage: any) => { if (!this.instance) { - console.warn('Media Call - Tried to process signal, but no instance was set'); return; } - const [, ev] = ddpMessage.fields.eventName.split('/'); if (ev !== 'media-signal') { return; @@ -56,33 +53,18 @@ class MediaSessionInstance { this.instance.processSignal(signal); }); - this.instance = mediaSessionStore.getInstance(userId); - console.log('instance', this.instance); - - mediaSessionStore.onChange(() => { - const previousInstance = this.instance; - this.instance = mediaSessionStore.getInstance(userId); - console.log('previousInstance', previousInstance, 'new instance', this.instance); - }); - RNCallKeep.addEventListener('answerCall', async ({ callUUID }) => { const mainCall = this.instance?.getMainCall(); - console.log('answerCall', mainCall.callId, callUUID); if (mainCall && mainCall.callId === callUUID) { - console.log('📱 User accepted call:', callUUID); // RNCallKeep.backToForeground(); await mainCall.accept(); RNCallKeep.setCurrentCallActive(mainCall.callId); } else { - console.warn('⚠️ Call not found:', callUUID); RNCallKeep.endCall(callUUID); } }); - // User tapped "Decline" or "End Call" RNCallKeep.addEventListener('endCall', ({ callUUID }) => { - console.log('📱 User ended call:', callUUID); - const mainCall = this.instance?.getMainCall(); if (mainCall && mainCall.callId === callUUID) { if (mainCall.state === 'ringing') { @@ -93,10 +75,7 @@ class MediaSessionInstance { } }); - // User toggled mute button RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }) => { - console.log('📱 Mute toggled:', muted); - const mainCall = this.instance?.getMainCall(); if (mainCall && mainCall.callId === callUUID) { mainCall.setMuted(muted); @@ -111,47 +90,13 @@ class MediaSessionInstance { }); this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => { - console.log('📞 NEW CALL RECEIVED', { - callId: call.callId, - contact: call.contact.displayName || call.contact.username, - role: call.role, - state: call.state - }); - if (call && !call.hidden) { - // Listen to state changes call.emitter.on('stateChange', oldState => { console.log(`📊 ${oldState} → ${call.state}`); }); const displayName = call.contact.displayName || call.contact.username || 'Unknown'; - - // Show CallKeep incoming call screen - RNCallKeep.displayIncomingCall( - call.callId, // UUID - displayName, // Caller name - displayName, // Caller handle (can be phone number) - 'generic', // Call type - false // Has video - ); - - call.emitter.on('active', () => { - console.log('✅ CALL ACTIVE - Audio should work automatically!'); - const remoteStream = call.getRemoteMediaStream(); - // RNCallKeep.backToForeground(); - console.log('Remote stream:', { - id: remoteStream.id, - active: remoteStream.active, - audioTracks: remoteStream.getAudioTracks().length, - tracks: remoteStream.getTracks().map(t => ({ - kind: t.kind, - enabled: t.enabled, - readyState: t.readyState - })) - }); - // RNCallKeep.startCall(call.callId, displayName, displayName); - // That's it! No need to do anything else for audio to work. - }); + RNCallKeep.displayIncomingCall(call.callId, displayName, displayName, 'generic', false); call.emitter.on('ended', () => { console.log('❌ CALL ENDED'); From f9c795f947d0353fe4987632d262fcb5a21b83a0 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 20 Oct 2025 17:03:31 -0300 Subject: [PATCH 7/8] refactor --- app/lib/services/voip/MediaSessionInstance.ts | 95 +++++++++++++------ app/lib/services/voip/MediaSessionStore.ts | 46 ++++----- 2 files changed, 83 insertions(+), 58 deletions(-) diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 295d2798710..555093164ac 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -13,20 +13,22 @@ import { store } from '../../store/auxStore'; import sdk from '../sdk'; import { parseStringToIceServers } from './parseStringToIceServers'; import { IceServer } from '../../../definitions/Voip'; +import { IDDPMessage } from '../../../definitions/IDDPMessage'; class MediaSessionInstance { private iceServers: IceServer[] = []; private iceGatheringTimeout: number = 5000; - private mediaSignalListener: any; - private mediaSignalsListener: any; + private mediaSignalListener: { stop: () => void } | null = null; + private mediaSignalsListener: { stop: () => void } | null = null; private instance: MediaSignalingSession | null = null; + private storeTimeoutUnsubscribe: (() => void) | null = null; + private storeIceServersUnsubscribe: (() => void) | null = null; public init(userId: string): void { + this.stop(); registerGlobals(); - this.iceServers = this.getIceServers(); - this.iceGatheringTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number; - this.instance = mediaSessionStore.getInstance(userId); - mediaSessionStore.onChange(() => (this.instance = mediaSessionStore.getInstance(userId))); + this.configureRNCallKeep(); + this.configureIceServers(); mediaSessionStore.setWebRTCProcessorFactory( (config: WebRTCProcessorConfig) => @@ -36,12 +38,13 @@ class MediaSessionInstance { iceGatheringTimeout: this.iceGatheringTimeout }) ); - mediaSessionStore.setSendSignalFn((signal: ClientMediaSignal) => { sdk.methodCall('stream-notify-user', `${userId}/media-calls`, JSON.stringify(signal)); }); + this.instance = mediaSessionStore.getInstance(userId); + mediaSessionStore.onChange(() => (this.instance = mediaSessionStore.getInstance(userId))); - this.mediaSignalListener = sdk.onStreamData('stream-notify-user', (ddpMessage: any) => { + this.mediaSignalListener = sdk.onStreamData('stream-notify-user', (ddpMessage: IDDPMessage) => { if (!this.instance) { return; } @@ -53,10 +56,24 @@ class MediaSessionInstance { this.instance.processSignal(signal); }); + this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => { + if (call && !call.hidden) { + call.emitter.on('stateChange', oldState => { + console.log(`📊 ${oldState} → ${call.state}`); + }); + + const displayName = call.contact.displayName || call.contact.username || 'Unknown'; + RNCallKeep.displayIncomingCall(call.callId, displayName, displayName, 'generic', false); + + call.emitter.on('ended', () => RNCallKeep.endCall(call.callId)); + } + }); + } + + private configureRNCallKeep() { RNCallKeep.addEventListener('answerCall', async ({ callUUID }) => { const mainCall = this.instance?.getMainCall(); if (mainCall && mainCall.callId === callUUID) { - // RNCallKeep.backToForeground(); await mainCall.accept(); RNCallKeep.setCurrentCallActive(mainCall.callId); } else { @@ -88,34 +105,54 @@ class MediaSessionInstance { mainCall.sendDTMF(digits); } }); + } - this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => { - if (call && !call.hidden) { - call.emitter.on('stateChange', oldState => { - console.log(`📊 ${oldState} → ${call.state}`); - }); + private getIceServers() { + const iceServers = store.getState().settings.VoIP_TeamCollab_Ice_Servers as any; + return parseStringToIceServers(iceServers); + } - const displayName = call.contact.displayName || call.contact.username || 'Unknown'; - RNCallKeep.displayIncomingCall(call.callId, displayName, displayName, 'generic', false); + private configureIceServers() { + this.iceServers = this.getIceServers(); + this.iceGatheringTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number; - call.emitter.on('ended', () => { - console.log('❌ CALL ENDED'); - // Optional: Clean up if needed - RNCallKeep.endCall(call.callId); - }); + this.storeTimeoutUnsubscribe = store.subscribe(() => { + const currentTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number; + if (currentTimeout !== this.iceGatheringTimeout) { + this.iceGatheringTimeout = currentTimeout; + this.instance?.setIceGatheringTimeout(this.iceGatheringTimeout); + } + }); - // FOR TESTING: Auto-accept after 2 seconds - // setTimeout(() => { - // console.log('🟢 AUTO-ACCEPTING...'); - // call.accept(); - // }, 2000); + this.storeIceServersUnsubscribe = store.subscribe(() => { + const currentIceServers = this.getIceServers(); + if (currentIceServers !== this.iceServers) { + this.iceServers = currentIceServers; + this.instance?.setIceServers(this.iceServers); } }); } - private getIceServers() { - const iceServers = store.getState().settings.VoIP_TeamCollab_Ice_Servers as any; - return parseStringToIceServers(iceServers); + private stop() { + if (this.mediaSignalListener) { + this.mediaSignalListener.stop(); + } + if (this.mediaSignalsListener) { + this.mediaSignalsListener.stop(); + } + RNCallKeep.removeEventListener('answerCall'); + RNCallKeep.removeEventListener('endCall'); + RNCallKeep.removeEventListener('didPerformSetMutedCallAction'); + RNCallKeep.removeEventListener('didPerformDTMFAction'); + if (this.storeTimeoutUnsubscribe) { + this.storeTimeoutUnsubscribe(); + } + if (this.storeIceServersUnsubscribe) { + this.storeIceServersUnsubscribe(); + } + if (this.instance) { + this.instance.endSession(); + } } } diff --git a/app/lib/services/voip/MediaSessionStore.ts b/app/lib/services/voip/MediaSessionStore.ts index d5fd558948b..b359287c8cc 100644 --- a/app/lib/services/voip/MediaSessionStore.ts +++ b/app/lib/services/voip/MediaSessionStore.ts @@ -2,28 +2,25 @@ import { Emitter } from '@rocket.chat/emitter'; import { MediaSignalingSession, MediaCallWebRTCProcessor } from '@rocket.chat/media-signaling'; import type { MediaSignalTransport, ClientMediaSignal, WebRTCProcessorConfig } from '@rocket.chat/media-signaling'; import { mediaDevices } from 'react-native-webrtc'; -// import BackgroundTimer from 'react-native-background-timer'; +import BackgroundTimer from 'react-native-background-timer'; import { MediaCallLogger } from './MediaCallLogger'; -// import { useIceServers } from './useIceServers'; type SignalTransport = MediaSignalTransport; const randomStringFactory = (): string => Date.now().toString(36) + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); -// const getSessionIdKey = (userId: string): string => `rcx-media-session-id-${userId}`; - class MediaSessionStore extends Emitter<{ change: void }> { private sessionInstance: MediaSignalingSession | null = null; private sendSignalFn: SignalTransport | null = null; private _webrtcProcessorFactory: ((config: WebRTCProcessorConfig) => MediaCallWebRTCProcessor) | null = null; - private change(): void { + private change() { this.emit('change'); } - public onChange(callback: () => void): () => void { + public onChange(callback: () => void) { return this.on('change', callback); } @@ -34,25 +31,21 @@ class MediaSessionStore extends Emitter<{ change: void }> { return this._webrtcProcessorFactory(config); } - private sendSignal(signal: ClientMediaSignal): void { - if (this.sendSignalFn) { - this.sendSignalFn(signal); - return; + private sendSignal(signal: ClientMediaSignal) { + if (!this.sendSignalFn) { + throw new Error('Send signal function not set'); } - - console.warn('Media Call - Tried to send signal, but no sendSignalFn was set'); + return this.sendSignalFn(signal); } private makeInstance(userId: string): MediaSignalingSession | null { if (this.sessionInstance !== null) { - console.log('ending session', this.sessionInstance); this.sessionInstance.endSession(); this.sessionInstance = null; } if (!this._webrtcProcessorFactory || !this.sendSignalFn) { - console.warn('Media Call - Tried to make instance, but no webrtcProcessorFactory or sendSignalFn was set'); - return null; + throw new Error('WebRTC processor factory and send signal function must be set'); } this.sessionInstance = new MediaSignalingSession({ @@ -63,24 +56,22 @@ class MediaSessionStore extends Emitter<{ change: void }> { }, mediaStreamFactory: (constraints: any) => mediaDevices.getUserMedia(constraints) as unknown as Promise, randomStringFactory, - logger: new MediaCallLogger() - // timerProcessor: { - // setInterval: BackgroundTimer.setInterval, - // clearInterval: BackgroundTimer.clearInterval, - // setTimeout: BackgroundTimer.setTimeout, - // clearTimeout: BackgroundTimer.clearTimeout - // } + logger: new MediaCallLogger(), + timerProcessor: { + setInterval: (callback: () => void, interval: number) => BackgroundTimer.setInterval(callback, interval), + clearInterval: (interval: number) => BackgroundTimer.clearInterval(interval), + setTimeout: (callback: () => void, timeout: number) => BackgroundTimer.setTimeout(callback, timeout), + clearTimeout: (timeout: number) => BackgroundTimer.clearTimeout(timeout) + } }); this.change(); - return this.sessionInstance; } public getInstance(userId?: string): MediaSignalingSession | null { if (!userId) { - console.warn('Media Call - Tried to get instance, but no userId was set'); - return null; + throw new Error('User Id is required'); } if (this.sessionInstance?.userId === userId) { @@ -90,12 +81,9 @@ class MediaSessionStore extends Emitter<{ change: void }> { return this.makeInstance(userId); } - public setSendSignalFn(sendSignalFn: SignalTransport): () => void { + public setSendSignalFn(sendSignalFn: SignalTransport) { this.sendSignalFn = sendSignalFn; this.change(); - return () => { - this.sendSignalFn = null; - }; } public setWebRTCProcessorFactory(factory: (config: WebRTCProcessorConfig) => MediaCallWebRTCProcessor): void { From c14056595143d57f0c9252ba6b48cd25e9e4a07c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 21 Oct 2025 10:10:36 -0300 Subject: [PATCH 8/8] fix null string on parseStringToIceServers --- app/lib/services/voip/parseStringToIceServers.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/lib/services/voip/parseStringToIceServers.ts b/app/lib/services/voip/parseStringToIceServers.ts index 2b5b47c6394..0a339d4a84d 100644 --- a/app/lib/services/voip/parseStringToIceServers.ts +++ b/app/lib/services/voip/parseStringToIceServers.ts @@ -16,6 +16,9 @@ export const parseStringToIceServer = (server: string): IceServer => { }; export const parseStringToIceServers = (string: string): IceServer[] => { + if (!string) { + return []; + } const lines = string.trim() ? string.split(',') : []; return lines.map(line => parseStringToIceServer(line)); };