From d60936034e90bd7a5ac82ad2b5db068c55788937 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 10 Dec 2025 10:48:15 -0300 Subject: [PATCH 01/12] feat(notifications): integrate expo-notifications for improved push handling and migrate notification logic - Added expo-notifications for push notification management, replacing react-native-notifications. - Implemented device token registration and notification response handling. - Enhanced badge count management and notification dismissal methods. - Set up notification categories for iOS to support actions like reply and video conference responses. - Updated MainApplication to reflect new notification architecture. --- .../rocket/reactnative/MainApplication.kt | 15 + app/lib/notifications/index.ts | 14 +- app/lib/notifications/push.ts | 235 ++++++++++++---- jest.setup.js | 20 ++ package.json | 2 + yarn.lock | 259 +++++++++++++++++- 6 files changed, 465 insertions(+), 80 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 0d07364246b..d5792a4b0f6 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -28,6 +28,21 @@ import expo.modules.ApplicationLifecycleDispatcher import chat.rocket.reactnative.networking.SSLPinningTurboPackage; import chat.rocket.reactnative.notification.CustomPushNotification; +/** + * Main Application class. + * + * NOTIFICATION ARCHITECTURE (Migration in Progress): + * - JS layer uses expo-notifications for token registration and event handling + * - Native layer uses react-native-notifications + CustomPushNotification for: + * - FCM message handling (higher priority service) + * - Notification display with MessagingStyle + * - E2E encrypted message decryption + * - Direct reply functionality + * - Message-id-only notification loading + * + * INotificationsApplication interface is required by react-native-notifications + * to route FCM messages to CustomPushNotification for advanced processing. + */ open class MainApplication : Application(), ReactApplication, INotificationsApplication { override val reactNativeHost: ReactNativeHost = diff --git a/app/lib/notifications/index.ts b/app/lib/notifications/index.ts index 1d7ad538494..fe0709e11b8 100644 --- a/app/lib/notifications/index.ts +++ b/app/lib/notifications/index.ts @@ -58,12 +58,14 @@ export const onNotification = (push: INotification): void => { }; export const getDeviceToken = (): string => deviceToken; -export const setBadgeCount = (count?: number): void => setNotificationsBadgeCount(count); -export const removeNotificationsAndBadge = () => { - removeAllNotifications(); - setBadgeCount(); +export const setBadgeCount = (count?: number): void => { + setNotificationsBadgeCount(count); }; -export const initializePushNotifications = (): Promise | undefined => { - setBadgeCount(); +export const removeNotificationsAndBadge = async (): Promise => { + await removeAllNotifications(); + await setNotificationsBadgeCount(); +}; +export const initializePushNotifications = async (): Promise => { + await setNotificationsBadgeCount(); return pushNotificationConfigure(onNotification); }; diff --git a/app/lib/notifications/push.ts b/app/lib/notifications/push.ts index 639e83733ef..c35cfc3dce9 100644 --- a/app/lib/notifications/push.ts +++ b/app/lib/notifications/push.ts @@ -1,13 +1,6 @@ -import { - Notifications, - type Registered, - type RegistrationError, - type NotificationCompletion, - type Notification, - NotificationAction, - NotificationCategory -} from 'react-native-notifications'; -import { PermissionsAndroid, Platform } from 'react-native'; +import * as Notifications from 'expo-notifications'; +import * as Device from 'expo-device'; +import { Platform } from 'react-native'; import { type INotification } from '../../definitions'; import { isIOS } from '../methods/helpers'; @@ -16,18 +9,155 @@ import I18n from '../../i18n'; export let deviceToken = ''; -export const setNotificationsBadgeCount = (count = 0): void => { - if (isIOS) { - Notifications.ios.setBadgeCount(count); +export const setNotificationsBadgeCount = async (count = 0): Promise => { + try { + await Notifications.setBadgeCountAsync(count); + } catch (e) { + console.log('Failed to set badge count:', e); } }; -export const removeAllNotifications = (): void => { - Notifications.removeAllDeliveredNotifications(); +export const removeAllNotifications = async (): Promise => { + try { + await Notifications.dismissAllNotificationsAsync(); + } catch (e) { + console.log('Failed to dismiss notifications:', e); + } }; let configured = false; +/** + * Transform expo-notifications response to the INotification format expected by the app + */ +const transformNotificationResponse = (response: Notifications.NotificationResponse): INotification => { + const { notification, actionIdentifier, userText } = response; + const trigger = notification.request.trigger; + const content = notification.request.content; + + // Get the raw data from the notification + let payload: Record = {}; + + if (trigger && 'type' in trigger && trigger.type === 'push') { + if (Platform.OS === 'android' && 'remoteMessage' in trigger && trigger.remoteMessage) { + // Android: data comes from remoteMessage.data + payload = trigger.remoteMessage.data || {}; + } else if (Platform.OS === 'ios' && 'payload' in trigger && trigger.payload) { + // iOS: data comes from payload (userInfo) + payload = trigger.payload as Record; + } + } + + // Fallback to content.data if trigger data is not available + if (Object.keys(payload).length === 0 && content.data) { + payload = content.data as Record; + } + + // Add action identifier if it's a specific action (not default tap) + if (actionIdentifier && actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) { + payload.action = { identifier: actionIdentifier }; + if (userText) { + payload.action.userText = userText; + } + } + + return { + payload: { + message: content.body || payload.message || '', + style: payload.style || '', + ejson: payload.ejson || '', + collapse_key: payload.collapse_key || '', + notId: payload.notId || notification.request.identifier || '', + msgcnt: payload.msgcnt || '', + title: content.title || payload.title || '', + from: payload.from || '', + image: payload.image || '', + soundname: payload.soundname || '', + action: payload.action + }, + identifier: notification.request.identifier + }; +}; + +/** + * Set up notification categories for iOS (actions like Reply, Accept, Decline) + */ +const setupNotificationCategories = async (): Promise => { + if (!isIOS) { + return; + } + + try { + // Message category with Reply action + await Notifications.setNotificationCategoryAsync('MESSAGE', [ + { + identifier: 'REPLY_ACTION', + buttonTitle: I18n.t('Reply'), + textInput: { + submitButtonTitle: I18n.t('Reply'), + placeholder: I18n.t('Type_message') + }, + options: { + opensAppToForeground: false + } + } + ]); + + // Video conference category with Accept/Decline actions + await Notifications.setNotificationCategoryAsync('VIDEOCONF', [ + { + identifier: 'ACCEPT_ACTION', + buttonTitle: I18n.t('accept'), + options: { + opensAppToForeground: true + } + }, + { + identifier: 'DECLINE_ACTION', + buttonTitle: I18n.t('decline'), + options: { + opensAppToForeground: true + } + } + ]); + } catch (e) { + console.log('Failed to set notification categories:', e); + } +}; + +/** + * Request notification permissions and register for push notifications + */ +const registerForPushNotifications = async (): Promise => { + if (!Device.isDevice) { + console.log('Push notifications require a physical device'); + return null; + } + + try { + // Check and request permissions + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + + if (existingStatus !== 'granted') { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + + if (finalStatus !== 'granted') { + console.log('Failed to get push notification permissions'); + return null; + } + + // Get the device push token (FCM for Android, APNs for iOS) + const tokenData = await Notifications.getDevicePushTokenAsync(); + return tokenData.data; + } catch (e) { + console.log('Error registering for push notifications:', e); + return null; + } +}; + export const pushNotificationConfigure = (onNotification: (notification: INotification) => void): Promise => { if (configured) { return Promise.resolve({ configured: true }); @@ -35,50 +165,36 @@ export const pushNotificationConfigure = (onNotification: (notification: INotifi configured = true; - if (isIOS) { - // init - Notifications.ios.registerRemoteNotifications(); - - const notificationAction = new NotificationAction('REPLY_ACTION', 'background', I18n.t('Reply'), true, { - buttonTitle: I18n.t('Reply'), - placeholder: I18n.t('Type_message') - }); - const acceptAction = new NotificationAction('ACCEPT_ACTION', 'foreground', I18n.t('accept'), true); - const rejectAction = new NotificationAction('DECLINE_ACTION', 'foreground', I18n.t('decline'), true); - - const notificationCategory = new NotificationCategory('MESSAGE', [notificationAction]); - const videoConfCategory = new NotificationCategory('VIDEOCONF', [acceptAction, rejectAction]); - - Notifications.setCategories([videoConfCategory, notificationCategory]); - } else if (Platform.OS === 'android' && Platform.constants.Version >= 33) { - // @ts-ignore - PermissionsAndroid.request('android.permission.POST_NOTIFICATIONS').then(permissionStatus => { - if (permissionStatus === 'granted') { - Notifications.registerRemoteNotifications(); - } else { - // TODO: Ask user to enable notifications - } - }); - } else { - Notifications.registerRemoteNotifications(); - } + // Set up how notifications should be handled when the app is in foreground + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: false, + shouldPlaySound: false, + shouldSetBadge: false, + shouldShowBanner: false, + shouldShowList: false + }) + }); + + // Set up notification categories for iOS + setupNotificationCategories(); - Notifications.events().registerRemoteNotificationsRegistered((event: Registered) => { - deviceToken = event.deviceToken; + // Register for push notifications and get token + registerForPushNotifications().then(token => { + if (token) { + deviceToken = token; + } }); - Notifications.events().registerRemoteNotificationsRegistrationFailed((event: RegistrationError) => { - // TODO: Handle error - console.log(event); + // Listen for token updates + Notifications.addPushTokenListener(tokenData => { + deviceToken = tokenData.data; }); - Notifications.events().registerNotificationReceivedForeground( - (notification: Notification, completion: (response: NotificationCompletion) => void) => { - completion({ alert: false, sound: false, badge: false }); - } - ); + // Listen for notification responses (when user taps on notification) + Notifications.addNotificationResponseReceivedListener(response => { + const notification = transformNotificationResponse(response); - Notifications.events().registerNotificationOpened((notification: Notification, completion: () => void) => { if (isIOS) { const { background } = reduxStore.getState().app; if (background) { @@ -87,14 +203,13 @@ export const pushNotificationConfigure = (onNotification: (notification: INotifi } else { onNotification(notification); } - completion(); }); - Notifications.events().registerNotificationReceivedBackground( - (notification: Notification, completion: (response: any) => void) => { - completion({ alert: true, sound: true, badge: false }); - } - ); + // Get initial notification (app was opened by tapping a notification) + const lastResponse = Notifications.getLastNotificationResponse(); + if (lastResponse) { + return Promise.resolve(transformNotificationResponse(lastResponse)); + } - return Notifications.getInitialNotification(); + return Promise.resolve(null); }; diff --git a/jest.setup.js b/jest.setup.js index 6374b2beb62..c9746c555d4 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -136,6 +136,26 @@ jest.mock('@react-navigation/native', () => { }; }); +jest.mock('expo-notifications', () => ({ + getDevicePushTokenAsync: jest.fn(() => Promise.resolve({ data: 'mock-token' })), + getPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'granted' })), + requestPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'granted' })), + setBadgeCountAsync: jest.fn(() => Promise.resolve(true)), + dismissAllNotificationsAsync: jest.fn(() => Promise.resolve()), + setNotificationHandler: jest.fn(), + setNotificationCategoryAsync: jest.fn(() => Promise.resolve()), + addNotificationReceivedListener: jest.fn(() => ({ remove: jest.fn() })), + addNotificationResponseReceivedListener: jest.fn(() => ({ remove: jest.fn() })), + addPushTokenListener: jest.fn(() => ({ remove: jest.fn() })), + getLastNotificationResponse: jest.fn(() => null), + DEFAULT_ACTION_IDENTIFIER: 'expo.modules.notifications.actions.DEFAULT' +})); + +jest.mock('expo-device', () => ({ + isDevice: true +})); + +// Keep react-native-notifications mock for native code compatibility during migration jest.mock('react-native-notifications', () => ({ Notifications: { getInitialNotification: jest.fn(() => Promise.resolve()), diff --git a/package.json b/package.json index 18036f1628e..c7f926b4f67 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "expo-apple-authentication": "7.2.3", "expo-av": "15.1.3", "expo-camera": "16.1.5", + "expo-device": "^8.0.10", "expo-document-picker": "13.1.4", "expo-file-system": "18.1.7", "expo-haptics": "14.1.3", @@ -70,6 +71,7 @@ "expo-keep-awake": "14.1.3", "expo-local-authentication": "16.0.3", "expo-navigation-bar": "^4.2.4", + "expo-notifications": "^0.32.14", "expo-status-bar": "^2.2.3", "expo-system-ui": "^5.0.7", "expo-video-thumbnails": "9.1.2", diff --git a/yarn.lock b/yarn.lock index 42818383dc4..bf157f74b93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2750,6 +2750,26 @@ xcode "^3.0.1" xml2js "0.6.0" +"@expo/config-plugins@~54.0.3": + version "54.0.3" + resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-54.0.3.tgz#2b9ffd68a48e3b51299cdbe3ee777b9f5163fc03" + integrity sha512-tBIUZIxLQfCu5jmqTO+UOeeDUGIB0BbK6xTMkPRObAXRQeTLPPfokZRCo818d2owd+Bcmq1wBaDz0VY3g+glfw== + dependencies: + "@expo/config-types" "^54.0.9" + "@expo/json-file" "~10.0.7" + "@expo/plist" "^0.4.7" + "@expo/sdk-runtime-versions" "^1.0.0" + chalk "^4.1.2" + debug "^4.3.5" + getenv "^2.0.0" + glob "^13.0.0" + resolve-from "^5.0.0" + semver "^7.5.4" + slash "^3.0.0" + slugify "^1.6.6" + xcode "^3.0.1" + xml2js "0.6.0" + "@expo/config-types@^53.0.3": version "53.0.3" resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-53.0.3.tgz#d083d9b095972e89eee96c41d085feb5b92d2749" @@ -2760,6 +2780,11 @@ resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-53.0.4.tgz#fe64fac734531ae883d18529b32586c23ffb1ceb" integrity sha512-0s+9vFx83WIToEr0Iwy4CcmiUXa5BgwBmEjylBB2eojX5XAMm9mJvw9KpjAb8m7zq2G0Q6bRbeufkzgbipuNQg== +"@expo/config-types@^54.0.9": + version "54.0.9" + resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-54.0.9.tgz#b9279c47fe249b774fbd3358b6abddea08f1bcec" + integrity sha512-Llf4jwcrAnrxgE5WCdAOxtMf8FGwS4Sk0SSgI0NnIaSyCnmOCAm80GPFvsK778Oj19Ub4tSyzdqufPyeQPksWw== + "@expo/config@~11.0.7", "@expo/config@~11.0.8": version "11.0.8" resolved "https://registry.yarnpkg.com/@expo/config/-/config-11.0.8.tgz#658538d4321cf6edf6741f8b8506fda0046d5e94" @@ -2798,6 +2823,25 @@ slugify "^1.3.4" sucrase "3.35.0" +"@expo/config@~12.0.11": + version "12.0.11" + resolved "https://registry.yarnpkg.com/@expo/config/-/config-12.0.11.tgz#b9f1cecd6bac4c2101bb8489b918556dc100434f" + integrity sha512-bGKNCbHirwgFlcOJHXpsAStQvM0nU3cmiobK0o07UkTfcUxl9q9lOQQh2eoMGqpm6Vs1IcwBpYye6thC3Nri/w== + dependencies: + "@babel/code-frame" "~7.10.4" + "@expo/config-plugins" "~54.0.3" + "@expo/config-types" "^54.0.9" + "@expo/json-file" "^10.0.7" + deepmerge "^4.3.1" + getenv "^2.0.0" + glob "^13.0.0" + require-from-string "^2.0.2" + resolve-from "^5.0.0" + resolve-workspace-root "^2.0.0" + semver "^7.6.0" + slugify "^1.3.4" + sucrase "~3.35.1" + "@expo/devcert@^1.1.2": version "1.1.4" resolved "https://registry.yarnpkg.com/@expo/devcert/-/devcert-1.1.4.tgz#d98807802a541847cc42791a606bfdc26e641277" @@ -2827,6 +2871,17 @@ dotenv-expand "~11.0.6" getenv "^1.0.0" +"@expo/env@~2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@expo/env/-/env-2.0.8.tgz#2aea906eed3d297b2e19608dc1a800fba0a3fe03" + integrity sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA== + dependencies: + chalk "^4.0.0" + debug "^4.3.4" + dotenv "~16.4.5" + dotenv-expand "~11.0.6" + getenv "^2.0.0" + "@expo/fingerprint@0.12.4": version "0.12.4" resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.12.4.tgz#d4cc4de50e7b6d4e03b0d38850d1e4a136b74c8c" @@ -2858,6 +2913,30 @@ temp-dir "~2.0.0" unique-string "~2.0.0" +"@expo/image-utils@^0.8.8": + version "0.8.8" + resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.8.8.tgz#db5d460fd0c7101b10e9d027ffbe42f9cf115248" + integrity sha512-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA== + dependencies: + "@expo/spawn-async" "^1.7.2" + chalk "^4.0.0" + getenv "^2.0.0" + jimp-compact "0.16.1" + parse-png "^2.1.0" + resolve-from "^5.0.0" + resolve-global "^1.0.0" + semver "^7.6.0" + temp-dir "~2.0.0" + unique-string "~2.0.0" + +"@expo/json-file@^10.0.7", "@expo/json-file@~10.0.7": + version "10.0.8" + resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-10.0.8.tgz#05e524d1ecc0011db0a6d66b525ea2f58cfe6d43" + integrity sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ== + dependencies: + "@babel/code-frame" "~7.10.4" + json5 "^2.2.3" + "@expo/json-file@^9.1.4", "@expo/json-file@~9.1.4": version "9.1.4" resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-9.1.4.tgz#e719d092c08afb3234643f9285e57c6a24989327" @@ -2920,6 +2999,15 @@ base64-js "^1.2.3" xmlbuilder "^15.1.1" +"@expo/plist@^0.4.7": + version "0.4.8" + resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.4.8.tgz#e014511a4a5008cf2b832b91caa8e9f2704127cc" + integrity sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ== + dependencies: + "@xmldom/xmldom" "^0.8.8" + base64-js "^1.2.3" + xmlbuilder "^15.1.1" + "@expo/prebuild-config@^9.0.5": version "9.0.5" resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-9.0.5.tgz#b8b864b5e19489a1f66442ae30d5d7295f658297" @@ -3433,6 +3521,23 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917" integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw== +"@ide/backoff@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@ide/backoff/-/backoff-1.0.0.tgz#466842c25bd4a4833e0642fab41ccff064010176" + integrity sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g== + +"@isaacs/balanced-match@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" + integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== + +"@isaacs/brace-expansion@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" + integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== + dependencies: + "@isaacs/balanced-match" "^4.0.1" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -6123,6 +6228,17 @@ asap@~2.0.6: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +assert@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd" + integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw== + dependencies: + call-bind "^1.0.2" + is-nan "^1.3.2" + object-is "^1.1.5" + object.assign "^4.1.4" + util "^0.12.5" + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" @@ -6406,6 +6522,11 @@ babel-preset-jest@^29.6.3: babel-plugin-jest-hoist "^29.6.3" babel-preset-current-node-syntax "^1.0.0" +badgin@^1.1.5: + version "1.2.3" + resolved "https://registry.yarnpkg.com/badgin/-/badgin-1.2.3.tgz#994b5f519827d7d5422224825b2c8faea2bc43ad" + integrity sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -6595,6 +6716,16 @@ call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply- es-errors "^1.3.0" function-bind "^1.1.2" +call-bind@^1.0.0, call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" @@ -6606,16 +6737,6 @@ call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" -call-bind@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" - integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== - dependencies: - call-bind-apply-helpers "^1.0.0" - es-define-property "^1.0.0" - get-intrinsic "^1.2.4" - set-function-length "^1.2.2" - call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" @@ -8322,6 +8443,11 @@ expo-apple-authentication@7.2.3: resolved "https://registry.yarnpkg.com/expo-apple-authentication/-/expo-apple-authentication-7.2.3.tgz#524817c1b2c0b165343039d183ee91c4674be5fe" integrity sha512-2izNn8qhUUM/gXMxA2byOn4AymUpmhaZlnGZy1vpndT0dMXd3T/Wk8j67rEB0+JhQY11iEQGXBG8cfro7LV0dA== +expo-application@~7.0.8: + version "7.0.8" + resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-7.0.8.tgz#320af0d6c39b331456d3bc833b25763c702d23db" + integrity sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q== + expo-asset@~11.1.5: version "11.1.5" resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-11.1.5.tgz#5cad3d781c9d0edec31b9b3adbba574eb4d5dd3e" @@ -8350,6 +8476,21 @@ expo-constants@~17.1.5: "@expo/config" "~11.0.7" "@expo/env" "~1.0.5" +expo-constants@~18.0.11: + version "18.0.11" + resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.11.tgz#522680c14f0a54cfd968d581961848c6397d44ae" + integrity sha512-xnfrfZ7lHjb+03skhmDSYeFF7OU2K3Xn/lAeP+7RhkV2xp2f5RCKtOUYajCnYeZesvMrsUxOsbGOP2JXSOH3NA== + dependencies: + "@expo/config" "~12.0.11" + "@expo/env" "~2.0.8" + +expo-device@^8.0.10: + version "8.0.10" + resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-8.0.10.tgz#88be854d6de5568392ed814b44dad0e19d1d50f8" + integrity sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA== + dependencies: + ua-parser-js "^0.7.33" + expo-document-picker@13.1.4: version "13.1.4" resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-13.1.4.tgz#f78a91e31dfac8ff26ea065bdc015ce4938eb1cc" @@ -8429,6 +8570,19 @@ expo-navigation-bar@^4.2.4: react-native-edge-to-edge "1.6.0" react-native-is-edge-to-edge "^1.1.6" +expo-notifications@^0.32.14: + version "0.32.14" + resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.32.14.tgz#8f2161207e81272aac2fe4f1981b2ff7c8ef5529" + integrity sha512-IRxzsd94+c1sim7R9OWdICPINmL4iwsLWcG3n6FKgzZal2ZZbBym2/m/k5yv3NQORUpytqB373WBJDZvaPCtgw== + dependencies: + "@expo/image-utils" "^0.8.8" + "@ide/backoff" "^1.0.0" + abort-controller "^3.0.0" + assert "^2.0.0" + badgin "^1.1.5" + expo-application "~7.0.8" + expo-constants "~18.0.11" + expo-status-bar@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/expo-status-bar/-/expo-status-bar-2.2.3.tgz#09385a866732328e0af3b4588c4f349a15fd7cd0" @@ -8936,6 +9090,11 @@ getenv@^1.0.0: resolved "https://registry.yarnpkg.com/getenv/-/getenv-1.0.0.tgz#874f2e7544fbca53c7a4738f37de8605c3fcfc31" integrity sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg== +getenv@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/getenv/-/getenv-2.0.0.tgz#b1698c7b0f29588f4577d06c42c73a5b475c69e0" + integrity sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ== + github-from-package@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" @@ -8967,6 +9126,15 @@ glob@^10.3.10, glob@^10.4.2: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" +glob@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.0.tgz#9d9233a4a274fc28ef7adce5508b7ef6237a1be3" + integrity sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA== + dependencies: + minimatch "^10.1.1" + minipass "^7.1.2" + path-scurry "^2.0.0" + glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -8979,6 +9147,13 @@ glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: once "^1.3.0" path-is-absolute "^1.0.0" +global-dirs@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" + integrity sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg== + dependencies: + ini "^1.3.4" + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -9384,7 +9559,7 @@ inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -ini@~1.3.0: +ini@^1.3.4, ini@~1.3.0: version "1.3.8" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== @@ -9562,6 +9737,14 @@ is-map@^2.0.3: resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== +is-nan@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" + integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + is-negative-zero@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" @@ -10784,6 +10967,11 @@ lru-cache@^10.2.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== +lru-cache@^11.0.0: + version "11.2.4" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.4.tgz#ecb523ebb0e6f4d837c807ad1abaea8e0619770d" + integrity sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg== + lru-cache@^4.1.1: version "4.1.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" @@ -11352,6 +11540,13 @@ minimatch@9.0.3: dependencies: brace-expansion "^2.0.1" +minimatch@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.1.1.tgz#e6e61b9b0c1dcab116b5a7d1458e8b6ae9e73a55" + integrity sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ== + dependencies: + "@isaacs/brace-expansion" "^5.0.0" + minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -11639,6 +11834,14 @@ object-inspect@^1.13.1: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== +object-is@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" + integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -11995,6 +12198,14 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-scurry@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.1.tgz#4b6572376cfd8b811fca9cd1f5c24b3cbac0fe10" + integrity sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -13105,6 +13316,13 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== +resolve-global@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-global/-/resolve-global-1.0.0.tgz#a2a79df4af2ca3f49bf77ef9ddacd322dad19255" + integrity sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw== + dependencies: + global-dirs "^0.1.1" + resolve-pkg-maps@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" @@ -14039,6 +14257,19 @@ sucrase@3.35.0: pirates "^4.0.1" ts-interface-checker "^0.1.9" +sucrase@~3.35.1: + version "3.35.1" + resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.1.tgz#4619ea50393fe8bd0ae5071c26abd9b2e346bfe1" + integrity sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.2" + commander "^4.0.0" + lines-and-columns "^1.1.6" + mz "^2.7.0" + pirates "^4.0.1" + tinyglobby "^0.2.11" + ts-interface-checker "^0.1.9" + sudo-prompt@^8.2.0: version "8.2.5" resolved "https://registry.yarnpkg.com/sudo-prompt/-/sudo-prompt-8.2.5.tgz#cc5ef3769a134bb94b24a631cc09628d4d53603e" @@ -14242,7 +14473,7 @@ tiny-invariant@^1.3.3: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== -tinyglobby@^0.2.14: +tinyglobby@^0.2.11, tinyglobby@^0.2.14: version "0.2.15" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== @@ -14535,7 +14766,7 @@ typical@^5.2.0: resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066" integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg== -ua-parser-js@1.0.2: +ua-parser-js@1.0.2, ua-parser-js@^0.7.33: version "1.0.2" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.2.tgz#e2976c34dbfb30b15d2c300b2a53eac87c57a775" integrity sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg== @@ -14729,7 +14960,7 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -util@^0.12.4: +util@^0.12.4, util@^0.12.5: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== From 6fa178e3f58d967c5c196fa5de6d820aa4d26d68 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 10 Dec 2025 14:09:34 -0300 Subject: [PATCH 02/12] refactor(notifications): remove react-native-notifications and implement custom FCM handling - Removed react-native-notifications dependency and related mock configurations. - Introduced RCFirebaseMessagingService for handling FCM messages and routing to CustomPushNotification. - Updated CustomPushNotification to manage notifications without react-native-notifications, enhancing E2E decryption and MessagingStyle support. - Adjusted MainApplication and notification classes to reflect the new architecture and improve notification processing. - Cleaned up unused imports and code related to the previous notification system. --- android/app/build.gradle | 2 - android/app/src/main/AndroidManifest.xml | 8 + .../rocket/reactnative/MainApplication.kt | 33 +- .../notification/CustomPushNotification.java | 366 ++++++++---------- .../reactnative/notification/Ejson.java | 16 +- .../reactnative/notification/Encryption.java | 14 +- .../RCFirebaseMessagingService.java | 54 +++ .../notification/ReplyBroadcast.java | 8 +- jest.setup.js | 15 - package.json | 1 - .../react-native-notifications+5.1.0.patch | 173 --------- yarn.lock | 5 - 12 files changed, 236 insertions(+), 459 deletions(-) create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.java delete mode 100644 patches/react-native-notifications+5.1.0.patch diff --git a/android/app/build.gradle b/android/app/build.gradle index 9ec188c7d98..bc2dcf954ff 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -93,7 +93,6 @@ android { versionName "4.68.0" vectorDrawables.useSupportLibrary = true manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String] - missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60" // See note below! resValue "string", "rn_config_reader_custom_package", "chat.rocket.reactnative" } @@ -144,7 +143,6 @@ dependencies { implementation jscFlavor } - implementation project(':react-native-notifications') implementation "com.google.firebase:firebase-messaging:23.3.1" implementation project(':watermelondb-jsi') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 552191d1316..cbfcd8d1313 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -75,6 +75,14 @@ + + + + + + { int attempts = 0; int maxAttempts = 50; // 5 seconds total (50 * 100ms) - while (!mAppLifecycleFacade.isReactInitialized() && attempts < maxAttempts) { + while (!isReactInitialized() && attempts < maxAttempts) { try { Thread.sleep(100); // Wait 100ms attempts++; if (attempts % 10 == 0 && ENABLE_VERBOSE_LOGS) { - android.util.Log.d(TAG, "Still waiting for React initialization... (" + (attempts * 100) + "ms elapsed)"); + Log.d(TAG, "Still waiting for React initialization... (" + (attempts * 100) + "ms elapsed)"); } } catch (InterruptedException e) { - android.util.Log.e(TAG, "Wait interrupted", e); + Log.e(TAG, "Wait interrupted", e); Thread.currentThread().interrupt(); return; } } - if (mAppLifecycleFacade.isReactInitialized()) { - android.util.Log.i(TAG, "React initialized after " + (attempts * 100) + "ms, proceeding with notification"); + if (isReactInitialized()) { + Log.i(TAG, "React initialized after " + (attempts * 100) + "ms, proceeding with notification"); try { handleNotification(); } catch (Exception e) { - android.util.Log.e(TAG, "Failed to process notification after React initialization", e); + Log.e(TAG, "Failed to process notification after React initialization", e); } } else { - android.util.Log.e(TAG, "Timeout waiting for React initialization after " + (maxAttempts * 100) + "ms, processing without MMKV"); + Log.e(TAG, "Timeout waiting for React initialization after " + (maxAttempts * 100) + "ms, processing without MMKV"); try { handleNotification(); } catch (Exception e) { - android.util.Log.e(TAG, "Failed to process notification without React context", e); + Log.e(TAG, "Failed to process notification without React context", e); } } }).start(); @@ -141,23 +150,21 @@ public void onReceived() throws InvalidNotificationException { } if (ENABLE_VERBOSE_LOGS) { - android.util.Log.d(TAG, "React already initialized, proceeding with notification"); + Log.d(TAG, "React already initialized, proceeding with notification"); } try { handleNotification(); } catch (Exception e) { - android.util.Log.e(TAG, "Failed to process notification on main thread", e); - throw new InvalidNotificationException("Notification processing failed: " + e.getMessage()); + Log.e(TAG, "Failed to process notification on main thread", e); } } private void handleNotification() { - Bundle received = mNotificationProps.asBundle(); - Ejson receivedEjson = safeFromJson(received.getString("ejson", "{}"), Ejson.class); + Ejson receivedEjson = safeFromJson(mBundle.getString("ejson", "{}"), Ejson.class); if (receivedEjson != null && receivedEjson.notificationType != null && receivedEjson.notificationType.equals("message-id-only")) { - android.util.Log.d(TAG, "Detected message-id-only notification, will fetch full content from server"); + Log.d(TAG, "Detected message-id-only notification, will fetch full content from server"); loadNotificationAndProcess(receivedEjson); return; // Exit early, notification will be processed in callback } @@ -171,30 +178,19 @@ private void loadNotificationAndProcess(Ejson ejson) { @Override public void call(@Nullable Bundle bundle) { if (bundle != null) { - android.util.Log.d(TAG, "Successfully loaded notification content from server, updating notification props"); + Log.d(TAG, "Successfully loaded notification content from server, updating notification props"); if (ENABLE_VERBOSE_LOGS) { - // BEFORE createProps - android.util.Log.d(TAG, "[BEFORE createProps] bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false)); - android.util.Log.d(TAG, "[BEFORE createProps] bundle.title=" + (bundle.getString("title") != null ? "[present]" : "[null]")); - android.util.Log.d(TAG, "[BEFORE createProps] bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0)); - android.util.Log.d(TAG, "[BEFORE createProps] bundle has ejson=" + (bundle.getString("ejson") != null)); + Log.d(TAG, "[BEFORE update] bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false)); + Log.d(TAG, "[BEFORE update] bundle.title=" + (bundle.getString("title") != null ? "[present]" : "[null]")); + Log.d(TAG, "[BEFORE update] bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0)); } synchronized(CustomPushNotification.this) { - mNotificationProps = createProps(bundle); - } - - if (ENABLE_VERBOSE_LOGS) { - // AFTER createProps - verify it worked - Bundle verifyBundle = mNotificationProps.asBundle(); - android.util.Log.d(TAG, "[AFTER createProps] mNotificationProps.notificationLoaded=" + verifyBundle.getBoolean("notificationLoaded", false)); - android.util.Log.d(TAG, "[AFTER createProps] mNotificationProps.title=" + (verifyBundle.getString("title") != null ? "[present]" : "[null]")); - android.util.Log.d(TAG, "[AFTER createProps] mNotificationProps.message length=" + (verifyBundle.getString("message") != null ? verifyBundle.getString("message").length() : 0)); - android.util.Log.d(TAG, "[AFTER createProps] mNotificationProps has ejson=" + (verifyBundle.getString("ejson") != null)); + mBundle = bundle; } } else { - android.util.Log.w(TAG, "Failed to load notification content from server, will display placeholder notification"); + Log.w(TAG, "Failed to load notification content from server, will display placeholder notification"); } processNotification(); @@ -203,28 +199,26 @@ public void call(@Nullable Bundle bundle) { } private void processNotification() { - // We should re-read these values since that can be changed by notificationLoad - Bundle bundle = mNotificationProps.asBundle(); - Ejson loadedEjson = safeFromJson(bundle.getString("ejson", "{}"), Ejson.class); - String notId = bundle.getString("notId", "1"); + Ejson loadedEjson = safeFromJson(mBundle.getString("ejson", "{}"), Ejson.class); + String notId = mBundle.getString("notId", "1"); if (ENABLE_VERBOSE_LOGS) { - android.util.Log.d(TAG, "[processNotification] notId=" + notId); - android.util.Log.d(TAG, "[processNotification] bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false)); - android.util.Log.d(TAG, "[processNotification] bundle.title=" + (bundle.getString("title") != null ? "[present]" : "[null]")); - android.util.Log.d(TAG, "[processNotification] bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0)); - android.util.Log.d(TAG, "[processNotification] loadedEjson.notificationType=" + (loadedEjson != null ? loadedEjson.notificationType : "null")); - android.util.Log.d(TAG, "[processNotification] loadedEjson.sender=" + (loadedEjson != null && loadedEjson.sender != null ? loadedEjson.sender.username : "null")); + Log.d(TAG, "[processNotification] notId=" + notId); + Log.d(TAG, "[processNotification] bundle.notificationLoaded=" + mBundle.getBoolean("notificationLoaded", false)); + Log.d(TAG, "[processNotification] bundle.title=" + (mBundle.getString("title") != null ? "[present]" : "[null]")); + Log.d(TAG, "[processNotification] bundle.message length=" + (mBundle.getString("message") != null ? mBundle.getString("message").length() : 0)); + Log.d(TAG, "[processNotification] loadedEjson.notificationType=" + (loadedEjson != null ? loadedEjson.notificationType : "null")); + Log.d(TAG, "[processNotification] loadedEjson.sender=" + (loadedEjson != null && loadedEjson.sender != null ? loadedEjson.sender.username : "null")); } // Handle E2E encrypted notifications if (isE2ENotification(loadedEjson)) { - handleE2ENotification(bundle, loadedEjson, notId); + handleE2ENotification(mBundle, loadedEjson, notId); return; // E2E processor will handle showing the notification } // Handle regular (non-E2E) notifications - showNotification(bundle, loadedEjson, notId); + showNotification(mBundle, loadedEjson, notId); } /** @@ -245,8 +239,7 @@ private void handleE2ENotification(Bundle bundle, Ejson ejson, String notId) { if (decrypted != null) { bundle.putString("message", decrypted); - mNotificationProps = createProps(bundle); - bundle = mNotificationProps.asBundle(); + mBundle = bundle; ejson = safeFromJson(bundle.getString("ejson", "{}"), Ejson.class); showNotification(bundle, ejson, notId); } else { @@ -266,11 +259,9 @@ private void handleE2ENotification(Bundle bundle, Ejson ejson, String notId) { new E2ENotificationProcessor.NotificationCallback() { @Override public void onDecryptionComplete(Bundle decryptedBundle, Ejson decryptedEjson, String notificationId) { - // Update props and show notification - mNotificationProps = createProps(decryptedBundle); - Bundle finalBundle = mNotificationProps.asBundle(); - Ejson finalEjson = safeFromJson(finalBundle.getString("ejson", "{}"), Ejson.class); - showNotification(finalBundle, finalEjson, notificationId); + mBundle = decryptedBundle; + Ejson finalEjson = safeFromJson(decryptedBundle.getString("ejson", "{}"), Ejson.class); + showNotification(decryptedBundle, finalEjson, notificationId); } @Override @@ -308,134 +299,145 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) { String avatarUri = ejson != null ? ejson.getAvatarUri() : null; if (ENABLE_VERBOSE_LOGS) { - android.util.Log.d(TAG, "[showNotification] avatarUri=" + (avatarUri != null ? "[present]" : "[null]")); + Log.d(TAG, "[showNotification] avatarUri=" + (avatarUri != null ? "[present]" : "[null]")); } bundle.putString("avatarUri", avatarUri); // Handle special notification types if (ejson != null && ejson.notificationType instanceof String && ejson.notificationType.equals("videoconf")) { - notifyReceivedToJS(); + // Video conf notifications are handled by notifee + Log.d(TAG, "Video conference notification - handled by notifee"); } else { // Show regular notification if (ENABLE_VERBOSE_LOGS) { - android.util.Log.d(TAG, "[Before add to notificationMessages] notId=" + notId + ", bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0) + ", bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false)); + Log.d(TAG, "[Before add to notificationMessages] notId=" + notId + ", bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0) + ", bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false)); } notificationMessages.get(notId).add(bundle); if (ENABLE_VERBOSE_LOGS) { - android.util.Log.d(TAG, "[After add] notificationMessages[" + notId + "].size=" + notificationMessages.get(notId).size()); + Log.d(TAG, "[After add] notificationMessages[" + notId + "].size=" + notificationMessages.get(notId).size()); } postNotification(Integer.parseInt(notId)); - notifyReceivedToJS(); } } - @Override - public void onOpened() { - Bundle bundle = mNotificationProps.asBundle(); - final String notId = bundle.getString("notId", "1"); - notificationMessages.remove(notId); - digestNotification(); + private void postNotification(int notificationId) { + Notification.Builder notification = buildNotification(notificationId); + if (notification != null && notificationManager != null) { + notificationManager.notify(notificationId, notification.build()); + } + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ); + if (notificationManager != null) { + notificationManager.createNotificationChannel(channel); + } + } } - @Override - protected Notification.Builder getNotificationBuilder(PendingIntent intent) { - final Notification.Builder notification = new Notification.Builder(mContext); - - Bundle bundle = mNotificationProps.asBundle(); - String notId = bundle.getString("notId", "1"); - String title = bundle.getString("title"); - String message = bundle.getString("message"); - Boolean notificationLoaded = bundle.getBoolean("notificationLoaded", false); - Ejson ejson = safeFromJson(bundle.getString("ejson", "{}"), Ejson.class); + private Notification.Builder buildNotification(int notificationId) { + String notId = Integer.toString(notificationId); + String title = mBundle.getString("title"); + String message = mBundle.getString("message"); + Boolean notificationLoaded = mBundle.getBoolean("notificationLoaded", false); + Ejson ejson = safeFromJson(mBundle.getString("ejson", "{}"), Ejson.class); if (ENABLE_VERBOSE_LOGS) { - android.util.Log.d(TAG, "[getNotificationBuilder] notId=" + notId); - android.util.Log.d(TAG, "[getNotificationBuilder] notificationLoaded=" + notificationLoaded); - android.util.Log.d(TAG, "[getNotificationBuilder] title=" + (title != null ? "[present]" : "[null]")); - android.util.Log.d(TAG, "[getNotificationBuilder] message length=" + (message != null ? message.length() : 0)); - android.util.Log.d(TAG, "[getNotificationBuilder] ejson=" + (ejson != null ? "present" : "null")); - android.util.Log.d(TAG, "[getNotificationBuilder] ejson.notificationType=" + (ejson != null ? ejson.notificationType : "null")); - android.util.Log.d(TAG, "[getNotificationBuilder] ejson.sender=" + (ejson != null && ejson.sender != null ? ejson.sender.username : "null")); + Log.d(TAG, "[buildNotification] notId=" + notId); + Log.d(TAG, "[buildNotification] notificationLoaded=" + notificationLoaded); + Log.d(TAG, "[buildNotification] title=" + (title != null ? "[present]" : "[null]")); + Log.d(TAG, "[buildNotification] message length=" + (message != null ? message.length() : 0)); + } + + // Create pending intent to open the app + Intent intent = new Intent(mContext, MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtras(mBundle); + + PendingIntent pendingIntent; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + pendingIntent = PendingIntent.getActivity(mContext, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + } else { + pendingIntent = PendingIntent.getActivity(mContext, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + Notification.Builder notification; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notification = new Notification.Builder(mContext, CHANNEL_ID); + } else { + notification = new Notification.Builder(mContext); } notification .setContentTitle(title) .setContentText(message) - .setContentIntent(intent) + .setContentIntent(pendingIntent) .setPriority(Notification.PRIORITY_HIGH) .setDefaults(Notification.DEFAULT_ALL) .setAutoCancel(true); - Integer notificationId = Integer.parseInt(notId); notificationColor(notification); - notificationChannel(notification); - notificationIcons(notification, bundle); + notificationIcons(notification, mBundle); notificationDismiss(notification, notificationId); // if notificationType is null (RC < 3.5) or notificationType is different of message-id-only or notification was loaded successfully if (ejson == null || ejson.notificationType == null || !ejson.notificationType.equals("message-id-only") || notificationLoaded) { - android.util.Log.i(TAG, "[getNotificationBuilder] ✅ Rendering FULL notification style (ejson=" + (ejson != null) + ", notificationType=" + (ejson != null ? ejson.notificationType : "null") + ", notificationLoaded=" + notificationLoaded + ")"); - notificationStyle(notification, notificationId, bundle); - notificationReply(notification, notificationId, bundle); - - // message couldn't be loaded from server (Fallback notification) + Log.i(TAG, "[buildNotification] ✅ Rendering FULL notification style"); + notificationStyle(notification, notificationId, mBundle); + notificationReply(notification, notificationId, mBundle); } else { - android.util.Log.w(TAG, "[getNotificationBuilder] ⚠️ Rendering FALLBACK notification (ejson=" + (ejson != null) + ", notificationType=" + (ejson != null ? ejson.notificationType : "null") + ", notificationLoaded=" + notificationLoaded + ")"); - // iterate over the current notification ids to dismiss fallback notifications from same server - for (Map.Entry> bundleList : notificationMessages.entrySet()) { - // iterate over the notifications with this id (same host + rid) - Iterator iterator = bundleList.getValue().iterator(); - while (iterator.hasNext()) { - Bundle not = (Bundle) iterator.next(); - // get the notification info - Ejson notEjson = safeFromJson(not.getString("ejson", "{}"), Ejson.class); - // if already has a notification from same server - if (ejson != null && notEjson != null && ejson.serverURL().equals(notEjson.serverURL())) { - String id = not.getString("notId"); - // cancel this notification - if (notificationManager != null) { - try { - notificationManager.cancel(Integer.parseInt(id)); - if (ENABLE_VERBOSE_LOGS) { - android.util.Log.d(TAG, "Cancelled previous fallback notification from same server"); - } - } catch (NumberFormatException e) { - android.util.Log.e(TAG, "Invalid notification ID for cancel: " + id, e); + Log.w(TAG, "[buildNotification] ⚠️ Rendering FALLBACK notification"); + // Cancel previous fallback notifications from same server + cancelPreviousFallbackNotifications(ejson); + } + + return notification; + } + + private void cancelPreviousFallbackNotifications(Ejson ejson) { + for (Map.Entry> bundleList : notificationMessages.entrySet()) { + Iterator iterator = bundleList.getValue().iterator(); + while (iterator.hasNext()) { + Bundle not = iterator.next(); + Ejson notEjson = safeFromJson(not.getString("ejson", "{}"), Ejson.class); + if (ejson != null && notEjson != null && ejson.serverURL().equals(notEjson.serverURL())) { + String id = not.getString("notId"); + if (notificationManager != null && id != null) { + try { + notificationManager.cancel(Integer.parseInt(id)); + if (ENABLE_VERBOSE_LOGS) { + Log.d(TAG, "Cancelled previous fallback notification from same server"); } + } catch (NumberFormatException e) { + Log.e(TAG, "Invalid notification ID for cancel: " + id, e); } } } } } - - return notification; - } - - private void notifyReceivedToJS() { - boolean isReactInitialized = mAppLifecycleFacade.isReactInitialized(); - if (isReactInitialized) { - mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.getRunningReactContext()); - } } private Bitmap getAvatar(String uri) { if (uri == null || uri.isEmpty()) { if (ENABLE_VERBOSE_LOGS) { - android.util.Log.w(TAG, "getAvatar called with null/empty URI"); + Log.w(TAG, "getAvatar called with null/empty URI"); } return largeIcon(); } - // Sanitize URL for logging (remove query params with tokens) - String sanitizedUri = uri; - int queryStart = uri.indexOf("?"); - if (queryStart != -1) { - sanitizedUri = uri.substring(0, queryStart) + "?[auth_params]"; - } - if (ENABLE_VERBOSE_LOGS) { - android.util.Log.d(TAG, "Fetching avatar from: " + sanitizedUri); + String sanitizedUri = uri; + int queryStart = uri.indexOf("?"); + if (queryStart != -1) { + sanitizedUri = uri.substring(0, queryStart) + "?[auth_params]"; + } + Log.d(TAG, "Fetching avatar from: " + sanitizedUri); } try { @@ -446,16 +448,9 @@ private Bitmap getAvatar(String uri) { .submit(100, 100) .get(); - if (avatar != null) { - if (ENABLE_VERBOSE_LOGS) { - android.util.Log.d(TAG, "Successfully loaded avatar"); - } - } else { - android.util.Log.w(TAG, "Avatar loaded but is null"); - } return avatar != null ? avatar : largeIcon(); } catch (final ExecutionException | InterruptedException e) { - android.util.Log.e(TAG, "Failed to fetch avatar: " + e.getMessage(), e); + Log.e(TAG, "Failed to fetch avatar: " + e.getMessage(), e); return largeIcon(); } } @@ -464,16 +459,13 @@ private Bitmap largeIcon() { final Resources res = mContext.getResources(); String packageName = mContext.getPackageName(); int largeIconResId = res.getIdentifier("ic_notification", "drawable", packageName); - Bitmap largeIconBitmap = BitmapFactory.decodeResource(res, largeIconResId); - return largeIconBitmap; + return BitmapFactory.decodeResource(res, largeIconResId); } private void notificationIcons(Notification.Builder notification, Bundle bundle) { final Resources res = mContext.getResources(); String packageName = mContext.getPackageName(); - int smallIconResId = res.getIdentifier("ic_notification", "drawable", packageName); - Ejson ejson = safeFromJson(bundle.getString("ejson", "{}"), Ejson.class); notification.setSmallIcon(smallIconResId); @@ -486,28 +478,6 @@ private void notificationIcons(Notification.Builder notification, Bundle bundle) } } - private void notificationChannel(Notification.Builder notification) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - String CHANNEL_ID = "rocketchatrn_channel_01"; - String CHANNEL_NAME = "All"; - - // User-visible importance level: Urgent - Makes a sound and appears as a heads-up notification - // https://developer.android.com/training/notify-user/channels#importance - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_HIGH); - - final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); - if (notificationManager != null) { - notificationManager.createNotificationChannel(channel); - } else { - android.util.Log.e(TAG, "NotificationManager is null, cannot create notification channel"); - } - - notification.setChannelId(CHANNEL_ID); - } - } - private String extractMessage(String message, Ejson ejson) { if (message == null) { return ""; @@ -515,7 +485,7 @@ private String extractMessage(String message, Ejson ejson) { if (ejson != null && ejson.type != null && !ejson.type.equals("d")) { int pos = message.indexOf(":"); int start = pos == -1 ? 0 : pos + 2; - return message.substring(start, message.length()); + return message.substring(start); } return message; } @@ -530,25 +500,17 @@ private void notificationStyle(Notification.Builder notification, int notId, Bun List bundles = notificationMessages.get(Integer.toString(notId)); if (ENABLE_VERBOSE_LOGS) { - android.util.Log.d(TAG, "[notificationStyle] notId=" + notId + ", bundles=" + (bundles != null ? bundles.size() : "null")); - if (bundles != null && bundles.size() > 0) { - Bundle firstBundle = bundles.get(0); - android.util.Log.d(TAG, "[notificationStyle] first bundle.message length=" + (firstBundle.getString("message") != null ? firstBundle.getString("message").length() : 0)); - android.util.Log.d(TAG, "[notificationStyle] first bundle.notificationLoaded=" + firstBundle.getBoolean("notificationLoaded", false)); - } + Log.d(TAG, "[notificationStyle] notId=" + notId + ", bundles=" + (bundles != null ? bundles.size() : "null")); } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { Notification.InboxStyle messageStyle = new Notification.InboxStyle(); if (bundles != null) { - for (int i = 0; i < bundles.size(); i++) { - Bundle data = bundles.get(i); + for (Bundle data : bundles) { String message = data.getString("message"); - messageStyle.addLine(message); } } - notification.setStyle(messageStyle); } else { Notification.MessagingStyle messageStyle; @@ -567,9 +529,7 @@ private void notificationStyle(Notification.Builder notification, int notId, Bun messageStyle.setConversationTitle(title); if (bundles != null) { - for (int i = 0; i < bundles.size(); i++) { - Bundle data = bundles.get(i); - + for (Bundle data : bundles) { long timestamp = data.getLong("time"); String message = data.getString("message"); String senderId = data.getString("senderId"); @@ -582,18 +542,16 @@ private void notificationStyle(Notification.Builder notification, int notId, Bun messageStyle.addMessage(m, timestamp, senderName); } else { Bitmap avatar = getAvatar(avatarUri); - String senderName = ejson != null ? ejson.senderName : "Unknown"; - Person.Builder sender = new Person.Builder() + Person.Builder senderBuilder = new Person.Builder() .setKey(senderId) .setName(senderName); if (avatar != null) { - sender.setIcon(Icon.createWithBitmap(avatar)); + senderBuilder.setIcon(Icon.createWithBitmap(avatar)); } - Person person = sender.build(); - + Person person = senderBuilder.build(); messageStyle.addMessage(m, timestamp, person); } } @@ -630,8 +588,7 @@ private void notificationReply(Notification.Builder notification, int notificati .setLabel(label) .build(); - CharSequence title = label; - Notification.Action replyAction = new Notification.Action.Builder(smallIconResId, title, replyPendingIntent) + Notification.Action replyAction = new Notification.Action.Builder(smallIconResId, label, replyPendingIntent) .addRemoteInput(remoteInput) .setAllowGeneratedReplies(true) .build(); @@ -657,11 +614,6 @@ private void notificationLoad(Ejson ejson, Callback callback) { /** * Safely parses JSON string to object with error handling. - * - * @param json JSON string to parse - * @param classOfT Target class type - * @param Type parameter - * @return Parsed object, or null if parsing fails */ private static T safeFromJson(String json, Class classOfT) { if (json == null || json.trim().isEmpty() || json.equals("{}")) { diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index c12017dd0bc..d85bb740a84 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -7,8 +7,6 @@ import com.ammarahmed.mmkv.SecureKeystore; import com.tencent.mmkv.MMKV; -import com.wix.reactnativenotifications.core.AppLifecycleFacade; -import com.wix.reactnativenotifications.core.AppLifecycleFacadeHolder; import java.math.BigInteger; @@ -73,19 +71,9 @@ private synchronized void ensureMMKVInitialized() { initializationAttempted = true; - // Try to get ReactApplicationContext from available sources + // Get ReactApplicationContext from CustomPushNotification if (this.reactContext == null) { - AppLifecycleFacade facade = AppLifecycleFacadeHolder.get(); - if (facade != null) { - Object runningContext = facade.getRunningReactContext(); - if (runningContext instanceof ReactApplicationContext) { - this.reactContext = (ReactApplicationContext) runningContext; - } - } - - if (this.reactContext == null) { - this.reactContext = CustomPushNotification.reactApplicationContext; - } + this.reactContext = CustomPushNotification.reactApplicationContext; } // Initialize MMKV if context is available diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java index b9fffc4bba7..f0fd33cea8f 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java @@ -8,8 +8,6 @@ import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.WritableMap; -import com.wix.reactnativenotifications.core.AppLifecycleFacade; -import com.wix.reactnativenotifications.core.AppLifecycleFacadeHolder; import com.google.gson.Gson; import com.google.gson.JsonObject; import chat.rocket.mobilecrypto.algorithms.AESCrypto; @@ -319,10 +317,8 @@ public String decryptMessage(final Ejson ejson, final Context context) { if (context instanceof ReactApplicationContext) { this.reactContext = (ReactApplicationContext) context; } else { - AppLifecycleFacade facade = AppLifecycleFacadeHolder.get(); - if (facade != null && facade.getRunningReactContext() instanceof ReactApplicationContext) { - this.reactContext = (ReactApplicationContext) facade.getRunningReactContext(); - } + // Fallback to CustomPushNotification's static context + this.reactContext = CustomPushNotification.reactApplicationContext; } if (this.reactContext == null) { @@ -370,10 +366,8 @@ public String decryptMessage(final Ejson ejson, final Context context) { public EncryptionContent encryptMessageContent(final String message, final String id, final Ejson ejson) { try { - AppLifecycleFacade facade = AppLifecycleFacadeHolder.get(); - if (facade != null && facade.getRunningReactContext() instanceof ReactApplicationContext) { - this.reactContext = (ReactApplicationContext) facade.getRunningReactContext(); - } + // Get ReactApplicationContext from CustomPushNotification + this.reactContext = CustomPushNotification.reactApplicationContext; // Use reactContext for database access if (this.reactContext == null) { diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.java b/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.java new file mode 100644 index 00000000000..b1c75b453b5 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.java @@ -0,0 +1,54 @@ +package chat.rocket.reactnative.notification; + +import android.os.Bundle; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +import java.util.Map; + +/** + * Custom Firebase Messaging Service for Rocket.Chat. + * + * Handles incoming FCM messages and routes them to CustomPushNotification + * for advanced processing (E2E decryption, MessagingStyle, direct reply, etc.) + */ +public class RCFirebaseMessagingService extends FirebaseMessagingService { + private static final String TAG = "RocketChat.FCM"; + + @Override + public void onMessageReceived(@NonNull RemoteMessage remoteMessage) { + Log.d(TAG, "FCM message received from: " + remoteMessage.getFrom()); + + Map data = remoteMessage.getData(); + if (data.isEmpty()) { + Log.w(TAG, "FCM message has no data payload, ignoring"); + return; + } + + // Convert FCM data to Bundle for processing + Bundle bundle = new Bundle(); + for (Map.Entry entry : data.entrySet()) { + bundle.putString(entry.getKey(), entry.getValue()); + } + + // Process the notification + try { + CustomPushNotification notification = new CustomPushNotification(this, bundle); + notification.onReceived(); + } catch (Exception e) { + Log.e(TAG, "Error processing FCM message", e); + } + } + + @Override + public void onNewToken(@NonNull String token) { + Log.d(TAG, "FCM token refreshed"); + // Token handling is done by expo-notifications JS layer + // which uses getDevicePushTokenAsync() + super.onNewToken(token); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java b/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java index 428332c3bf3..f07f99a1b18 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java @@ -27,8 +27,6 @@ import okhttp3.RequestBody; import okhttp3.Response; -import com.wix.reactnativenotifications.core.NotificationIntentAdapter; - public class ReplyBroadcast extends BroadcastReceiver { private Context mContext; private Bundle bundle; @@ -43,7 +41,11 @@ public void onReceive(Context context, Intent intent) { } mContext = context; - bundle = NotificationIntentAdapter.extractPendingNotificationDataFromIntent(intent); + // Extract bundle directly from intent extras + bundle = intent.getBundleExtra("pushNotification"); + if (bundle == null) { + bundle = intent.getExtras(); + } notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); String notId = bundle.getString("notId"); diff --git a/jest.setup.js b/jest.setup.js index c9746c555d4..774f02d2527 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -155,21 +155,6 @@ jest.mock('expo-device', () => ({ isDevice: true })); -// Keep react-native-notifications mock for native code compatibility during migration -jest.mock('react-native-notifications', () => ({ - Notifications: { - getInitialNotification: jest.fn(() => Promise.resolve()), - registerRemoteNotifications: jest.fn(), - events: () => ({ - registerRemoteNotificationsRegistered: jest.fn(), - registerRemoteNotificationsRegistrationFailed: jest.fn(), - registerNotificationReceivedForeground: jest.fn(), - registerNotificationReceivedBackground: jest.fn(), - registerNotificationOpened: jest.fn() - }) - } -})); - jest.mock('@discord/bottom-sheet', () => { const react = require('react-native'); return { diff --git a/package.json b/package.json index c7f926b4f67..5b6113c11ad 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,6 @@ "react-native-mime-types": "2.3.0", "react-native-mmkv-storage": "^12.0.0", "react-native-modal": "13.0.1", - "react-native-notifications": "5.1.0", "react-native-notifier": "1.6.1", "react-native-picker-select": "9.0.1", "react-native-platform-touchable": "1.1.1", diff --git a/patches/react-native-notifications+5.1.0.patch b/patches/react-native-notifications+5.1.0.patch deleted file mode 100644 index 57c1f458984..00000000000 --- a/patches/react-native-notifications+5.1.0.patch +++ /dev/null @@ -1,173 +0,0 @@ -diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java -index ac04274..f2cfd00 100644 ---- a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java -+++ b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java -@@ -30,7 +30,7 @@ public class PushNotification implements IPushNotification { - final protected AppLifecycleFacade mAppLifecycleFacade; - final protected AppLaunchHelper mAppLaunchHelper; - final protected JsIOHelper mJsIOHelper; -- final protected PushNotificationProps mNotificationProps; -+ protected PushNotificationProps mNotificationProps; - final protected AppVisibilityListener mAppVisibilityListener = new AppVisibilityListener() { - @Override - public void onAppVisible() { -diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/fcm/FcmToken.java b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/fcm/FcmToken.java -index 7db6e8d..0127e8a 100644 ---- a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/fcm/FcmToken.java -+++ b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/fcm/FcmToken.java -@@ -5,11 +5,12 @@ import android.os.Bundle; - import android.util.Log; - - import com.facebook.react.ReactApplication; --import com.facebook.react.ReactInstanceManager; - import com.facebook.react.bridge.ReactContext; - import com.google.firebase.messaging.FirebaseMessaging; - import com.wix.reactnativenotifications.BuildConfig; - import com.wix.reactnativenotifications.core.JsIOHelper; -+import com.wix.reactnativenotifications.core.AppLifecycleFacade; -+import com.wix.reactnativenotifications.core.AppLifecycleFacadeHolder; - - import static com.wix.reactnativenotifications.Defs.LOGTAG; - import static com.wix.reactnativenotifications.Defs.TOKEN_RECEIVED_EVENT_NAME; -@@ -88,8 +89,8 @@ public class FcmToken implements IFcmToken { - } - - protected void sendTokenToJS() { -- final ReactInstanceManager instanceManager = ((ReactApplication) mAppContext).getReactNativeHost().getReactInstanceManager(); -- final ReactContext reactContext = instanceManager.getCurrentReactContext(); -+ AppLifecycleFacade facade = AppLifecycleFacadeHolder.get(); -+ final ReactContext reactContext = facade.getRunningReactContext(); - - // Note: Cannot assume react-context exists cause this is an async dispatched service. - if (reactContext != null && reactContext.hasActiveCatalystInstance()) { -diff --git a/node_modules/react-native-notifications/lib/dist/Notifications.d.ts b/node_modules/react-native-notifications/lib/dist/Notifications.d.ts -index 6e49fd4..5fe9515 100644 ---- a/node_modules/react-native-notifications/lib/dist/Notifications.d.ts -+++ b/node_modules/react-native-notifications/lib/dist/Notifications.d.ts -@@ -32,7 +32,7 @@ export declare class NotificationsRoot { - /** - * setCategories - */ -- setCategories(categories: [NotificationCategory?]): void; -+ setCategories(categories: NotificationCategory[]): void; - /** - * cancelLocalNotification - */ -diff --git a/node_modules/react-native-notifications/lib/ios/RCTConvert+RNNotifications.h b/node_modules/react-native-notifications/lib/ios/RCTConvert+RNNotifications.h -index 8b2c269..8667351 100644 ---- a/node_modules/react-native-notifications/lib/ios/RCTConvert+RNNotifications.h -+++ b/node_modules/react-native-notifications/lib/ios/RCTConvert+RNNotifications.h -@@ -1,5 +1,5 @@ - #import --@import UserNotifications; -+#import - - @interface RCTConvert (UIMutableUserNotificationAction) - + (UIMutableUserNotificationAction *)UIMutableUserNotificationAction:(id)json; -diff --git a/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.h b/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.h -index 4bc5292..71df0bc 100644 ---- a/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.h -+++ b/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.h -@@ -4,7 +4,7 @@ typedef void (^RCTPromiseResolveBlock)(id result); - typedef void (^RCTResponseSenderBlock)(NSArray *response); - typedef void (^RCTPromiseRejectBlock)(NSString *code, NSString *message, NSError *error); - --@import UserNotifications; -+#import - - @interface RNNotificationCenter : NSObject - -diff --git a/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.m b/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.m -index afd5c73..ec4dd85 100644 ---- a/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.m -+++ b/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.m -@@ -48,14 +48,15 @@ - } - - - (void)setCategories:(NSArray *)json { -- NSMutableSet* categories = nil; -+ NSMutableSet* categories = [NSMutableSet new]; - -- if ([json count] > 0) { -- categories = [NSMutableSet new]; -- for (NSDictionary* categoryJson in json) { -- [categories addObject:[RCTConvert UNMutableUserNotificationCategory:categoryJson]]; -+ for (NSDictionary* categoryJson in json) { -+ UNNotificationCategory *category = [RCTConvert UNMutableUserNotificationCategory:categoryJson]; -+ if (category) { -+ [categories addObject:category]; - } - } -+ - [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:categories]; - } - -diff --git a/node_modules/react-native-notifications/lib/ios/RNNotificationCenterListener.h b/node_modules/react-native-notifications/lib/ios/RNNotificationCenterListener.h -index 77a67dd..eaf0043 100644 ---- a/node_modules/react-native-notifications/lib/ios/RNNotificationCenterListener.h -+++ b/node_modules/react-native-notifications/lib/ios/RNNotificationCenterListener.h -@@ -1,5 +1,5 @@ - #import --@import UserNotifications; -+#import - #import "RNNotificationEventHandler.h" - - @interface RNNotificationCenterListener : NSObject -diff --git a/node_modules/react-native-notifications/lib/ios/RNNotificationEventHandler.h b/node_modules/react-native-notifications/lib/ios/RNNotificationEventHandler.h -index a07c6e9..8e3ca6a 100644 ---- a/node_modules/react-native-notifications/lib/ios/RNNotificationEventHandler.h -+++ b/node_modules/react-native-notifications/lib/ios/RNNotificationEventHandler.h -@@ -1,5 +1,5 @@ - #import --@import UserNotifications; -+#import - #import "RNNotificationsStore.h" - #import "RNEventEmitter.h" - -diff --git a/node_modules/react-native-notifications/lib/ios/RNNotificationParser.h b/node_modules/react-native-notifications/lib/ios/RNNotificationParser.h -index 7aa2bfb..c1c019c 100644 ---- a/node_modules/react-native-notifications/lib/ios/RNNotificationParser.h -+++ b/node_modules/react-native-notifications/lib/ios/RNNotificationParser.h -@@ -1,5 +1,5 @@ - #import --@import UserNotifications; -+#import - - @interface RNNotificationParser : NSObject - -diff --git a/node_modules/react-native-notifications/lib/ios/RNNotificationParser.m b/node_modules/react-native-notifications/lib/ios/RNNotificationParser.m -index 62f3043..840acda 100644 ---- a/node_modules/react-native-notifications/lib/ios/RNNotificationParser.m -+++ b/node_modules/react-native-notifications/lib/ios/RNNotificationParser.m -@@ -12,7 +12,18 @@ - } - - + (NSDictionary *)parseNotificationResponse:(UNNotificationResponse *)response { -- NSDictionary* responseDict = @{@"notification": [RCTConvert UNNotificationPayload:response.notification], @"identifier": response.notification.request.identifier, @"action": [self parseNotificationResponseAction:response]}; -+ NSMutableDictionary *notificationPayload = [[RCTConvert UNNotificationPayload:response.notification] mutableCopy]; -+ -+ NSDictionary *responseAction = [self parseNotificationResponseAction:response]; -+ -+ if (responseAction != nil) { -+ [notificationPayload setObject:responseAction forKey:@"action"]; -+ } -+ -+ NSDictionary *responseDict = @{ -+ @"notification": [notificationPayload copy], -+ @"identifier": response.notification.request.identifier -+ }; - - return responseDict; - } -diff --git a/node_modules/react-native-notifications/lib/ios/RNNotificationsStore.h b/node_modules/react-native-notifications/lib/ios/RNNotificationsStore.h -index 4f8a171..7e4f9ca 100644 ---- a/node_modules/react-native-notifications/lib/ios/RNNotificationsStore.h -+++ b/node_modules/react-native-notifications/lib/ios/RNNotificationsStore.h -@@ -1,6 +1,6 @@ - #import - #import --@import UserNotifications; -+#import - - @interface RNNotificationsStore : NSObject - diff --git a/yarn.lock b/yarn.lock index bf157f74b93..91c90435d99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12778,11 +12778,6 @@ react-native-modal@13.0.1: prop-types "^15.6.2" react-native-animatable "1.3.3" -react-native-notifications@5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/react-native-notifications/-/react-native-notifications-5.1.0.tgz#8cba105fd57ab9d5df9d27284acf1e2b4f3d7ea3" - integrity sha512-laqDSDlCvEASmJR6cXpqaryK855ejQd07vrfYERzhv68YDOoSkKy/URExRP4vAfAOVqHhix80tLbNUcfvZk2VQ== - react-native-notifier@1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/react-native-notifier/-/react-native-notifier-1.6.1.tgz#eec07bdebed6c22cd22f5167555b7762e4119552" From 557c45cb0730dd5a75da31db6de38e08ea9362b3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 10 Dec 2025 14:24:07 -0300 Subject: [PATCH 03/12] refactor(notifications): remove react-native-notifications and enhance reply handling - Removed react-native-notifications dependency and related code from the project. - Implemented a custom reply notification handler in ReplyNotification to manage direct replies from iOS notifications. - Updated AppDelegate to configure the new reply notification handler. - Adjusted Podfile and Podfile.lock to reflect the removal of react-native-notifications and added necessary Expo modules. - Cleaned up imports and ensured compatibility with the new notification architecture. --- ios/AppDelegate.swift | 16 +-- ios/Podfile | 3 +- ios/Podfile.lock | 26 +++-- ios/ReplyNotification.swift | 129 +++++++++++++++------ ios/RocketChatRN-Bridging-Header.h | 6 - ios/RocketChatRN.xcodeproj/project.pbxproj | 40 ++++--- 6 files changed, 142 insertions(+), 78 deletions(-) diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index 386a6feffd6..d5697908b38 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -27,8 +27,7 @@ public class AppDelegate: ExpoAppDelegate { MMKV.initialize(rootDir: nil, groupDir: groupDir, logLevel: .debug) } - // Initialize notifications - RNNotifications.startMonitorNotifications() + // Configure reply notification handler (integrates with expo-notifications) ReplyNotification.configure() let delegate = ReactNativeDelegate() @@ -63,19 +62,6 @@ public class AppDelegate: ExpoAppDelegate { return result } - // Remote Notification handling - public override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - RNNotifications.didRegisterForRemoteNotifications(withDeviceToken: deviceToken) - } - - public override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - RNNotifications.didFailToRegisterForRemoteNotificationsWithError(error) - } - - public override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - RNNotifications.didReceiveBackgroundNotification(userInfo, withCompletionHandler: completionHandler) - } - // Linking API public override func application( _ app: UIApplication, diff --git a/ios/Podfile b/ios/Podfile index 693b2016291..9fe51d0a279 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -36,8 +36,7 @@ end $static_framework = [ 'WatermelonDB', 'simdjson', - 'react-native-mmkv-storage', - 'react-native-notifications' + 'react-native-mmkv-storage' ] pre_install do |installer| Pod::Installer::Xcode::TargetValidator.send(:define_method, :verify_no_static_framework_transitive_dependencies) {} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b443f1d65a8..21cccfaf10a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -27,11 +27,15 @@ PODS: - BVLinearGradient (2.6.2): - React-Core - DoubleConversion (1.1.6) + - EXApplication (7.0.8): + - ExpoModulesCore - EXAV (15.1.3): - ExpoModulesCore - ReactCommon/turbomodule/core - EXConstants (17.1.5): - ExpoModulesCore + - EXNotifications (0.32.14): + - ExpoModulesCore - Expo (53.0.7): - DoubleConversion - ExpoModulesCore @@ -67,6 +71,8 @@ PODS: - ExpoModulesCore - ZXingObjC/OneD - ZXingObjC/PDF417 + - ExpoDevice (8.0.10): + - ExpoModulesCore - ExpoDocumentPicker (13.1.4): - ExpoModulesCore - ExpoFileSystem (18.1.7): @@ -1713,8 +1719,6 @@ PODS: - Yoga - react-native-netinfo (11.3.1): - React-Core - - react-native-notifications (5.1.0): - - React-Core - react-native-restart (0.0.22): - React-Core - react-native-safe-area-context (5.4.0): @@ -2643,12 +2647,15 @@ DEPENDENCIES: - "BugsnagReactNative (from `../node_modules/@bugsnag/react-native`)" - BVLinearGradient (from `../node_modules/react-native-linear-gradient`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) + - EXApplication (from `../node_modules/expo-application/ios`) - EXAV (from `../node_modules/expo-av/ios`) - EXConstants (from `../node_modules/expo-constants/ios`) + - EXNotifications (from `../node_modules/expo-notifications/ios`) - Expo (from `../node_modules/expo`) - ExpoAppleAuthentication (from `../node_modules/expo-apple-authentication/ios`) - ExpoAsset (from `../node_modules/expo-asset/ios`) - ExpoCamera (from `../node_modules/expo-camera/ios`) + - ExpoDevice (from `../node_modules/expo-device/ios`) - ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`) - ExpoFileSystem (from `../node_modules/expo-file-system/ios`) - ExpoFont (from `../node_modules/expo-font/ios`) @@ -2706,7 +2713,6 @@ DEPENDENCIES: - react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`) - react-native-mmkv-storage (from `../node_modules/react-native-mmkv-storage`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - - react-native-notifications (from `../node_modules/react-native-notifications`) - 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`)" @@ -2805,10 +2811,14 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-linear-gradient" DoubleConversion: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" + EXApplication: + :path: "../node_modules/expo-application/ios" EXAV: :path: "../node_modules/expo-av/ios" EXConstants: :path: "../node_modules/expo-constants/ios" + EXNotifications: + :path: "../node_modules/expo-notifications/ios" Expo: :path: "../node_modules/expo" ExpoAppleAuthentication: @@ -2817,6 +2827,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-asset/ios" ExpoCamera: :path: "../node_modules/expo-camera/ios" + ExpoDevice: + :path: "../node_modules/expo-device/ios" ExpoDocumentPicker: :path: "../node_modules/expo-document-picker/ios" ExpoFileSystem: @@ -2928,8 +2940,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-mmkv-storage" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" - react-native-notifications: - :path: "../node_modules/react-native-notifications" react-native-restart: :path: "../node_modules/react-native-restart" react-native-safe-area-context: @@ -3052,12 +3062,15 @@ SPEC CHECKSUMS: BugsnagReactNative: 8150cc1facb5c69c7a5d27d614fc50b4ed03c2b8 BVLinearGradient: 7815a70ab485b7b155186dd0cc836363e0288cad DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb + EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f EXAV: 90c33266835bf7e61dc0731fa8d82833d22d286f EXConstants: a1112af878fddfe6acc0399473ac56a07ced0f47 + EXNotifications: 52c982ff8aa4ed971534e27f20dbf532aa4082b1 Expo: bb70dfd014457bcca19f33e8c783afdc18308434 ExpoAppleAuthentication: b589b71be6bb817decf8f35e92c6281365140289 ExpoAsset: 3bc9adb7dbbf27ae82c18ca97eb988a3ae7e73b1 ExpoCamera: 105a9a963c443a3e112c51dd81290d81cd8da94a + ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a ExpoDocumentPicker: 344f16224e6a8a088f2693667a8b713160f8f57b ExpoFileSystem: 175267faf2b38511b01ac110243b13754dac57d3 ExpoFont: abbb91a911eb961652c2b0a22eef801860425ed6 @@ -3133,7 +3146,6 @@ SPEC CHECKSUMS: react-native-keyboard-controller: 9ec7ee23328c30251a399cffd8b54324a00343bf react-native-mmkv-storage: 9afc38c25213482f668c80bf2f0a50f75dda1777 react-native-netinfo: 2e3c27627db7d49ba412bfab25834e679db41e21 - react-native-notifications: 3bafa1237ae8a47569a84801f17d80242fe9f6a5 react-native-restart: f6f591aeb40194c41b9b5013901f00e6cf7d0f29 react-native-safe-area-context: 5928d84c879db2f9eb6969ca70e68f58623dbf25 react-native-slider: 605e731593322c4bb2eb48d7d64e2e4dbf7cbd77 @@ -3200,6 +3212,6 @@ SPEC CHECKSUMS: Yoga: dfabf1234ccd5ac41d1b1d43179f024366ae9831 ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 -PODFILE CHECKSUM: 4c73563b34520b90c036817cdb9ccf65fea5f5c5 +PODFILE CHECKSUM: 80f90fcad8e4b1f21b36f1dbbae085fa0ddff08c COCOAPODS: 1.15.2 diff --git a/ios/ReplyNotification.swift b/ios/ReplyNotification.swift index cffb486528e..3d3a2de7403 100644 --- a/ios/ReplyNotification.swift +++ b/ios/ReplyNotification.swift @@ -9,49 +9,110 @@ import Foundation import UserNotifications +/// Handles direct reply from iOS notifications. +/// Intercepts REPLY_ACTION responses and sends messages natively, +/// while forwarding all other notification events to expo-notifications. @objc(ReplyNotification) -class ReplyNotification: RNNotificationEventHandler { - private static let dispatchOnce: Void = { - let instance: AnyClass! = object_getClass(ReplyNotification()) - let originalMethod = class_getInstanceMethod(instance, #selector(didReceive)) - let swizzledMethod = class_getInstanceMethod(instance, #selector(replyNotification_didReceiveNotificationResponse)) - if let originalMethod = originalMethod, let swizzledMethod = swizzledMethod { - method_exchangeImplementations(originalMethod, swizzledMethod) - } - }() +class ReplyNotification: NSObject, UNUserNotificationCenterDelegate { + private static var shared: ReplyNotification? + private weak var originalDelegate: UNUserNotificationCenterDelegate? @objc public static func configure() { - _ = self.dispatchOnce + let instance = ReplyNotification() + shared = instance + + // Store the original delegate (expo-notifications) and set ourselves as the delegate + let center = UNUserNotificationCenter.current() + instance.originalDelegate = center.delegate + center.delegate = instance } - @objc - func replyNotification_didReceiveNotificationResponse(_ response: UNNotificationResponse, completionHandler: @escaping(() -> Void)) { + // MARK: - UNUserNotificationCenterDelegate + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + // Handle REPLY_ACTION natively if response.actionIdentifier == "REPLY_ACTION" { - if let notification = RCTConvert.unNotificationPayload(response.notification) { - if let data = (notification["ejson"] as? String)?.data(using: .utf8) { - if let payload = try? JSONDecoder().decode(Payload.self, from: data), let rid = payload.rid { - if let msg = (response as? UNTextInputNotificationResponse)?.userText { - let rocketchat = RocketChat(server: payload.host.removeTrailingSlash()) - let backgroundTask = UIApplication.shared.beginBackgroundTask(expirationHandler: nil) - - rocketchat.sendMessage(rid: rid, message: msg, threadIdentifier: payload.tmid) { response in - guard let response = response, response.success else { - let content = UNMutableNotificationContent() - content.body = "Failed to reply message." - let request = UNNotificationRequest(identifier: "replyFailure", content: content, trigger: nil) - UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) - return - } - UIApplication.shared.endBackgroundTask(backgroundTask) - } - } - } + handleReplyAction(response: response, completionHandler: completionHandler) + return + } + + // Forward to original delegate (expo-notifications) + if let originalDelegate = originalDelegate { + originalDelegate.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler) + } else { + completionHandler() + } + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Forward to original delegate (expo-notifications) + if let originalDelegate = originalDelegate { + originalDelegate.userNotificationCenter?(center, willPresent: notification, withCompletionHandler: completionHandler) + } else { + completionHandler([]) + } + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + openSettingsFor notification: UNNotification? + ) { + // Forward to original delegate (expo-notifications) + if let originalDelegate = originalDelegate { + if #available(iOS 12.0, *) { + originalDelegate.userNotificationCenter?(center, openSettingsFor: notification) + } + } + } + + // MARK: - Reply Handling + + private func handleReplyAction(response: UNNotificationResponse, completionHandler: @escaping () -> Void) { + guard let textResponse = response as? UNTextInputNotificationResponse else { + completionHandler() + return + } + + let userInfo = response.notification.request.content.userInfo + + guard let ejsonString = userInfo["ejson"] as? String, + let ejsonData = ejsonString.data(using: .utf8), + let payload = try? JSONDecoder().decode(Payload.self, from: ejsonData), + let rid = payload.rid else { + completionHandler() + return + } + + let message = textResponse.userText + let rocketchat = RocketChat(server: payload.host.removeTrailingSlash()) + let backgroundTask = UIApplication.shared.beginBackgroundTask(expirationHandler: nil) + + rocketchat.sendMessage(rid: rid, message: message, threadIdentifier: payload.tmid) { response in + // Ensure we're on the main thread for UI operations + DispatchQueue.main.async { + defer { + UIApplication.shared.endBackgroundTask(backgroundTask) + completionHandler() + } + + guard let response = response, response.success else { + // Show failure notification + let content = UNMutableNotificationContent() + content.body = "Failed to reply message." + let request = UNNotificationRequest(identifier: "replyFailure", content: content, trigger: nil) + UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) + return } } - } else { - let body = RNNotificationParser.parseNotificationResponse(response) - RNEventEmitter.sendEvent(RNNotificationOpened, body: body) } } } diff --git a/ios/RocketChatRN-Bridging-Header.h b/ios/RocketChatRN-Bridging-Header.h index 84cdd29326b..528f9e1aa39 100644 --- a/ios/RocketChatRN-Bridging-Header.h +++ b/ios/RocketChatRN-Bridging-Header.h @@ -4,12 +4,6 @@ #import #import -#import -#import -#import -#import -#import -#import #import #import #import diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index 7f679cbc227..a3169c2bf9c 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -1530,7 +1530,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export EXTRA_PACKAGER_ARGS=\"--sourcemap-output $TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\"\nexport NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh\n"; + shellScript = "export EXTRA_PACKAGER_ARGS=\"--sourcemap-output $TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\"\nexport NODE_BINARY=/Users/diegomello/.nvm/versions/node/v22.14.0/bin/node\n../node_modules/react-native/scripts/react-native-xcode.sh\n"; }; 17344D2847CBD7141D4AF748 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; @@ -1540,8 +1540,11 @@ inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-defaults-RocketChatRN/Pods-defaults-RocketChatRN-resources.sh", "${PODS_CONFIGURATION_BUILD_DIR}/BugsnagReactNative/Bugsnag.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle", @@ -1570,8 +1573,11 @@ name = "[CP] Copy Pods Resources"; outputPaths = ( "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Bugsnag.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle", @@ -1627,8 +1633,11 @@ inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-defaults-Rocket.Chat/Pods-defaults-Rocket.Chat-resources.sh", "${PODS_CONFIGURATION_BUILD_DIR}/BugsnagReactNative/Bugsnag.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle", @@ -1657,8 +1666,11 @@ name = "[CP] Copy Pods Resources"; outputPaths = ( "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Bugsnag.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle", @@ -1734,7 +1746,7 @@ inputFileListPaths = ( ); inputPaths = ( - "$TARGET_BUILD_DIR/$INFOPLIST_PATH", + $TARGET_BUILD_DIR/$INFOPLIST_PATH, ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -1820,7 +1832,7 @@ inputFileListPaths = ( ); inputPaths = ( - "$TARGET_BUILD_DIR/$INFOPLIST_PATH", + $TARGET_BUILD_DIR/$INFOPLIST_PATH, ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -1829,7 +1841,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "SOURCE_MAP=\"$TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\" ../node_modules/@bugsnag/react-native/bugsnag-react-native-xcode.sh\n"; + shellScript = "#SOURCE_MAP=\"$TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\" ../node_modules/@bugsnag/react-native/bugsnag-react-native-xcode.sh\n"; }; 84028E94C77DEBDD5200728D /* [Expo] Configure project */ = { isa = PBXShellScriptBuildPhase; @@ -1958,8 +1970,11 @@ inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-defaults-NotificationService/Pods-defaults-NotificationService-resources.sh", "${PODS_CONFIGURATION_BUILD_DIR}/BugsnagReactNative/Bugsnag.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle", @@ -1988,8 +2003,11 @@ name = "[CP] Copy Pods Resources"; outputPaths = ( "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Bugsnag.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle", @@ -2602,7 +2620,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - "$PODS_CONFIGURATION_BUILD_DIR/Firebase", + $PODS_CONFIGURATION_BUILD_DIR/Firebase, "$(SRCROOT)/../node_modules/react-native-mmkv-storage/ios/**", ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist; @@ -2679,7 +2697,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - "$PODS_CONFIGURATION_BUILD_DIR/Firebase", + $PODS_CONFIGURATION_BUILD_DIR/Firebase, "$(SRCROOT)/../node_modules/react-native-mmkv-storage/ios/**", ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist; @@ -3181,10 +3199,7 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -3248,10 +3263,7 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; From 01e6e605ba83abf98248a9080a274dde012363a2 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 10 Dec 2025 15:15:30 -0300 Subject: [PATCH 04/12] feat(notifications): implement video conference notification handling - Introduced a new video conference notification system, replacing the previous handling with a custom implementation. - Added VideoConfNotification class to manage incoming call notifications with accept/decline actions. - Implemented VideoConfBroadcast receiver to handle notification actions and store them for the JS layer. - Updated MainActivity to process video conference intents and integrate with the new notification system. - Enhanced getInitialNotification to check for pending video conference actions. - Updated AndroidManifest.xml to register the new broadcast receiver. - Cleaned up related code and ensured compatibility with the new notification architecture. --- android/app/src/main/AndroidManifest.xml | 9 + .../chat/rocket/reactnative/MainActivity.kt | 60 ++++- .../rocket/reactnative/MainApplication.kt | 2 + .../notification/CustomPushNotification.java | 37 ++- .../reactnative/notification/Ejson.java | 8 + .../notification/VideoConfBroadcast.java | 81 ++++++ .../notification/VideoConfModule.java | 78 ++++++ .../notification/VideoConfNotification.java | 236 ++++++++++++++++++ .../notification/VideoConfPackage.java | 32 +++ app/index.tsx | 5 +- .../videoConf/getInitialNotification.ts | 31 ++- app/sagas/deepLinking.js | 8 +- app/views/JitsiMeetView/index.tsx | 6 +- index.js | 5 +- 14 files changed, 573 insertions(+), 25 deletions(-) create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfPackage.java diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cbfcd8d1313..4fb70ab1a89 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -92,6 +92,15 @@ android:enabled="true" android:exported="true" > + + + + + + diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt b/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt index e2ed12f7eab..502ae26784f 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt @@ -7,8 +7,11 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate import android.os.Bundle import com.zoontek.rnbootsplash.RNBootSplash -import android.content.Intent; -import android.content.res.Configuration; +import android.content.Intent +import android.content.res.Configuration +import chat.rocket.reactnative.notification.VideoConfModule +import chat.rocket.reactnative.notification.VideoConfNotification +import com.google.gson.GsonBuilder class MainActivity : ReactActivity() { @@ -25,9 +28,60 @@ class MainActivity : ReactActivity() { override fun createReactActivityDelegate(): ReactActivityDelegate = DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) - override fun onCreate(savedInstanceState: Bundle?) { + override fun onCreate(savedInstanceState: Bundle?) { RNBootSplash.init(this, R.style.BootTheme) super.onCreate(null) + + // Handle video conf action from notification + intent?.let { handleVideoConfIntent(it) } + } + + public override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Handle video conf action when activity is already running + handleVideoConfIntent(intent) + } + + private fun handleVideoConfIntent(intent: Intent) { + if (intent.getBooleanExtra("videoConfAction", false)) { + val notificationId = intent.getIntExtra("notificationId", 0) + val event = intent.getStringExtra("event") ?: return + val rid = intent.getStringExtra("rid") ?: "" + val callerId = intent.getStringExtra("callerId") ?: "" + val callerName = intent.getStringExtra("callerName") ?: "" + val host = intent.getStringExtra("host") ?: "" + val callId = intent.getStringExtra("callId") ?: "" + + android.util.Log.d("RocketChat.MainActivity", "Handling video conf intent - event: $event, rid: $rid, host: $host, callId: $callId") + + // Cancel the notification + if (notificationId != 0) { + VideoConfNotification.cancelById(this, notificationId) + } + + // Store action for JS to pick up - include all required fields + val data = mapOf( + "notificationType" to "videoconf", + "rid" to rid, + "event" to event, + "host" to host, + "callId" to callId, + "caller" to mapOf( + "_id" to callerId, + "name" to callerName + ) + ) + + val gson = GsonBuilder().create() + val jsonData = gson.toJson(data) + + android.util.Log.d("RocketChat.MainActivity", "Storing video conf action: $jsonData") + + VideoConfModule.storePendingAction(this, jsonData) + + // Clear the video conf flag to prevent re-processing + intent.removeExtra("videoConfAction") + } } override fun invokeDefaultOnBackPressed() { diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 2090b7d7913..2705c02d21a 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -20,6 +20,7 @@ import com.bugsnag.android.Bugsnag import expo.modules.ApplicationLifecycleDispatcher import chat.rocket.reactnative.networking.SSLPinningTurboPackage; import chat.rocket.reactnative.notification.CustomPushNotification; +import chat.rocket.reactnative.notification.VideoConfPackage; /** * Main Application class. @@ -41,6 +42,7 @@ open class MainApplication : Application(), ReactApplication { PackageList(this).packages.apply { add(SSLPinningTurboPackage()) add(WatermelonDBJSIPackage()) + add(VideoConfPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java b/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java index 496b1dd24e8..723ce880cb3 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java @@ -304,10 +304,9 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) { bundle.putString("avatarUri", avatarUri); // Handle special notification types - if (ejson != null && ejson.notificationType instanceof String && - ejson.notificationType.equals("videoconf")) { - // Video conf notifications are handled by notifee - Log.d(TAG, "Video conference notification - handled by notifee"); + if (ejson != null && "videoconf".equals(ejson.notificationType)) { + handleVideoConfNotification(bundle, ejson); + return; } else { // Show regular notification if (ENABLE_VERBOSE_LOGS) { @@ -321,6 +320,36 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) { } } + /** + * Handles video conference notifications. + * Shows incoming call notification or cancels existing one based on status. + */ + private void handleVideoConfNotification(Bundle bundle, Ejson ejson) { + VideoConfNotification videoConf = new VideoConfNotification(mContext); + + Integer status = ejson.status; + String rid = ejson.rid; + // Video conf uses 'caller' field, regular messages use 'sender' + String callerId = ""; + if (ejson.caller != null && ejson.caller._id != null) { + callerId = ejson.caller._id; + } else if (ejson.sender != null && ejson.sender._id != null) { + callerId = ejson.sender._id; + } + + Log.d(TAG, "Video conf notification - status: " + status + ", rid: " + rid); + + if (status == null || status == 0) { + // Incoming call - show notification + videoConf.showIncomingCall(bundle, ejson); + } else if (status == 4) { + // Call cancelled/ended - dismiss notification + videoConf.cancelCall(rid, callerId); + } else { + Log.d(TAG, "Unknown video conf status: " + status); + } + } + private void postNotification(int notificationId) { Notification.Builder notification = buildNotification(notificationId); if (notification != null && notificationManager != null) { diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index d85bb740a84..64f3c3bac59 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -35,11 +35,14 @@ public class Ejson { String rid; String type; Sender sender; + Caller caller; // For video conf notifications String messageId; + String callId; // For video conf notifications String notificationType; String messageType; String senderName; String msg; + Integer status; // For video conf: 0=incoming, 4=cancelled String tmid; @@ -226,6 +229,11 @@ static class Sender { String name; } + static class Caller { + String _id; + String name; + } + static class Content { String algorithm; String ciphertext; diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.java b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.java new file mode 100644 index 00000000000..ec94c17db5e --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.java @@ -0,0 +1,81 @@ +package chat.rocket.reactnative.notification; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.util.HashMap; +import java.util.Map; + +import chat.rocket.reactnative.MainActivity; + +/** + * Handles video conference notification actions (accept/decline). + * Stores the action for the JS layer to process when the app opens. + */ +public class VideoConfBroadcast extends BroadcastReceiver { + private static final String TAG = "RocketChat.VideoConf"; + + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + Bundle extras = intent.getExtras(); + + if (action == null || extras == null) { + Log.w(TAG, "Received broadcast with null action or extras"); + return; + } + + Log.d(TAG, "Received video conf action: " + action); + + String event = null; + if (VideoConfNotification.ACTION_ACCEPT.equals(action)) { + event = "accept"; + } else if (VideoConfNotification.ACTION_DECLINE.equals(action)) { + event = "decline"; + } + + if (event == null) { + Log.w(TAG, "Unknown action: " + action); + return; + } + + // Cancel the notification + int notificationId = extras.getInt("notificationId", 0); + if (notificationId != 0) { + VideoConfNotification.cancelById(context, notificationId); + } + + // Build data for JS layer + Map data = new HashMap<>(); + data.put("notificationType", extras.getString("notificationType", "videoconf")); + data.put("rid", extras.getString("rid", "")); + data.put("event", event); + + // Add caller info + Map caller = new HashMap<>(); + caller.put("_id", extras.getString("callerId", "")); + caller.put("name", extras.getString("callerName", "")); + data.put("caller", caller); + + // Store action for the JS layer to pick up + Gson gson = new GsonBuilder().create(); + String jsonData = gson.toJson(data); + + VideoConfModule.storePendingAction(context, jsonData); + + Log.d(TAG, "Stored video conf action: " + event + " for rid: " + extras.getString("rid")); + + // Launch the app + Intent launchIntent = new Intent(context, MainActivity.class); + launchIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + launchIntent.putExtras(extras); + launchIntent.putExtra("event", event); + context.startActivity(launchIntent); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.java b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.java new file mode 100644 index 00000000000..9b6139b00c4 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.java @@ -0,0 +1,78 @@ +package chat.rocket.reactnative.notification; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.NonNull; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.module.annotations.ReactModule; + +/** + * Native module to expose video conference notification actions to JavaScript. + * Used to retrieve pending video conf actions when the app opens. + */ +@ReactModule(name = VideoConfModule.NAME) +public class VideoConfModule extends ReactContextBaseJavaModule { + public static final String NAME = "VideoConfModule"; + private static final String PREFS_NAME = "RocketChatPrefs"; + private static final String KEY_VIDEO_CONF_ACTION = "videoConfAction"; + + public VideoConfModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + @NonNull + public String getName() { + return NAME; + } + + /** + * Gets any pending video conference action. + * Returns null if no pending action. + */ + @ReactMethod + public void getPendingAction(Promise promise) { + try { + Context context = getReactApplicationContext(); + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String action = prefs.getString(KEY_VIDEO_CONF_ACTION, null); + + // Clear the action after reading + if (action != null) { + prefs.edit().remove(KEY_VIDEO_CONF_ACTION).apply(); + } + + promise.resolve(action); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + /** + * Clears any pending video conference action. + */ + @ReactMethod + public void clearPendingAction() { + try { + Context context = getReactApplicationContext(); + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + prefs.edit().remove(KEY_VIDEO_CONF_ACTION).apply(); + } catch (Exception e) { + // Ignore errors + } + } + + /** + * Stores a video conference action. + * Called from native code when user interacts with video conf notification. + */ + public static void storePendingAction(Context context, String actionJson) { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + prefs.edit().putString(KEY_VIDEO_CONF_ACTION, actionJson).apply(); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.java b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.java new file mode 100644 index 00000000000..9bcfdd32b3d --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.java @@ -0,0 +1,236 @@ +package chat.rocket.reactnative.notification; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.media.AudioAttributes; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; + +import androidx.core.app.NotificationCompat; + +import chat.rocket.reactnative.MainActivity; + +/** + * Handles video conference call notifications with call-style UI. + * Displays incoming call notifications with Accept/Decline actions. + */ +public class VideoConfNotification { + private static final String TAG = "RocketChat.VideoConf"; + + public static final String CHANNEL_ID = "video-conf-call"; + public static final String CHANNEL_NAME = "Video Calls"; + + public static final String ACTION_ACCEPT = "chat.rocket.reactnative.ACTION_VIDEO_CONF_ACCEPT"; + public static final String ACTION_DECLINE = "chat.rocket.reactnative.ACTION_VIDEO_CONF_DECLINE"; + public static final String EXTRA_NOTIFICATION_DATA = "notification_data"; + + private final Context context; + private final NotificationManager notificationManager; + + public VideoConfNotification(Context context) { + this.context = context; + this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + createNotificationChannel(); + } + + /** + * Creates the notification channel for video calls with high importance and ringtone sound. + */ + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ); + + channel.setDescription("Incoming video conference calls"); + channel.enableLights(true); + channel.enableVibration(true); + channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + + // Set ringtone sound + Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .build(); + channel.setSound(ringtoneUri, audioAttributes); + + if (notificationManager != null) { + notificationManager.createNotificationChannel(channel); + } + } + } + + /** + * Displays an incoming video call notification. + * + * @param bundle The notification data bundle + * @param ejson The parsed notification payload + */ + public void showIncomingCall(Bundle bundle, Ejson ejson) { + String rid = ejson.rid; + // Video conf uses 'caller' field, regular messages use 'sender' + String callerId = ""; + String callerName = "Unknown"; + + if (ejson.caller != null) { + callerId = ejson.caller._id != null ? ejson.caller._id : ""; + callerName = ejson.caller.name != null ? ejson.caller.name : "Unknown"; + } else if (ejson.sender != null) { + // Fallback to sender if caller is not present + callerId = ejson.sender._id != null ? ejson.sender._id : ""; + callerName = ejson.sender.name != null ? ejson.sender.name : (ejson.senderName != null ? ejson.senderName : "Unknown"); + } + + // Generate unique notification ID from rid + callerId + String notificationIdStr = (rid + callerId).replaceAll("[^A-Za-z0-9]", ""); + int notificationId = notificationIdStr.hashCode(); + + Log.d(TAG, "Showing incoming call notification from: " + callerName); + + // Create intent data for actions - include all required fields for JS + Bundle intentData = new Bundle(); + intentData.putString("rid", rid != null ? rid : ""); + intentData.putString("notificationType", "videoconf"); + intentData.putString("callerId", callerId); + intentData.putString("callerName", callerName); + intentData.putString("host", ejson.host != null ? ejson.host : ""); + intentData.putString("callId", ejson.callId != null ? ejson.callId : ""); + intentData.putString("ejson", bundle.getString("ejson", "{}")); + intentData.putInt("notificationId", notificationId); + + Log.d(TAG, "Intent data - rid: " + rid + ", callerId: " + callerId + ", host: " + ejson.host + ", callId: " + ejson.callId); + + // Full screen intent - opens app when notification is tapped + Intent fullScreenIntent = new Intent(context, MainActivity.class); + fullScreenIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + fullScreenIntent.putExtras(intentData); + fullScreenIntent.putExtra("event", "default"); + + PendingIntent fullScreenPendingIntent; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + fullScreenPendingIntent = PendingIntent.getActivity( + context, notificationId, fullScreenIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + } else { + fullScreenPendingIntent = PendingIntent.getActivity( + context, notificationId, fullScreenIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ); + } + + // Accept action - directly opens MainActivity (Android 12+ blocks trampoline pattern) + Intent acceptIntent = new Intent(context, MainActivity.class); + acceptIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + acceptIntent.putExtras(intentData); + acceptIntent.putExtra("event", "accept"); + acceptIntent.putExtra("videoConfAction", true); + acceptIntent.setAction(ACTION_ACCEPT + "_" + notificationId); // Unique action to differentiate intents + + PendingIntent acceptPendingIntent; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + acceptPendingIntent = PendingIntent.getActivity( + context, notificationId + 1, acceptIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + } else { + acceptPendingIntent = PendingIntent.getActivity( + context, notificationId + 1, acceptIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ); + } + + // Decline action - directly opens MainActivity + Intent declineIntent = new Intent(context, MainActivity.class); + declineIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + declineIntent.putExtras(intentData); + declineIntent.putExtra("event", "decline"); + declineIntent.putExtra("videoConfAction", true); + declineIntent.setAction(ACTION_DECLINE + "_" + notificationId); // Unique action to differentiate intents + + PendingIntent declinePendingIntent; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + declinePendingIntent = PendingIntent.getActivity( + context, notificationId + 2, declineIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + } else { + declinePendingIntent = PendingIntent.getActivity( + context, notificationId + 2, declineIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ); + } + + // Get icons + Resources res = context.getResources(); + String packageName = context.getPackageName(); + int smallIconResId = res.getIdentifier("ic_notification", "drawable", packageName); + + // Build notification + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(smallIconResId) + .setContentTitle("Incoming call") + .setContentText("Video call from " + callerName) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setAutoCancel(false) + .setOngoing(true) + .setFullScreenIntent(fullScreenPendingIntent, true) + .setContentIntent(fullScreenPendingIntent) + .addAction(0, "Decline", declinePendingIntent) + .addAction(0, "Accept", acceptPendingIntent); + + // Set sound for pre-O devices + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); + builder.setSound(ringtoneUri); + } + + // Show notification + if (notificationManager != null) { + notificationManager.notify(notificationId, builder.build()); + Log.d(TAG, "Video call notification displayed with ID: " + notificationId); + } + } + + /** + * Cancels a video call notification. + * + * @param rid The room ID + * @param callerId The caller's user ID + */ + public void cancelCall(String rid, String callerId) { + String notificationIdStr = (rid + callerId).replaceAll("[^A-Za-z0-9]", ""); + int notificationId = notificationIdStr.hashCode(); + + if (notificationManager != null) { + notificationManager.cancel(notificationId); + Log.d(TAG, "Video call notification cancelled with ID: " + notificationId); + } + } + + /** + * Cancels a video call notification by notification ID. + * + * @param notificationId The notification ID + */ + public static void cancelById(Context context, int notificationId) { + NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) { + manager.cancel(notificationId); + Log.d(TAG, "Video call notification cancelled with ID: " + notificationId); + } + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfPackage.java b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfPackage.java new file mode 100644 index 00000000000..0575365d14b --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfPackage.java @@ -0,0 +1,32 @@ +package chat.rocket.reactnative.notification; + +import androidx.annotation.NonNull; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * React Native package for video conference notification module. + */ +public class VideoConfPackage implements ReactPackage { + + @NonNull + @Override + public List createNativeModules(@NonNull ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new VideoConfModule(reactContext)); + return modules; + } + + @NonNull + @Override + public List createViewManagers(@NonNull ReactApplicationContext reactContext) { + return Collections.emptyList(); + } +} diff --git a/app/index.tsx b/app/index.tsx index 3e87e3bcaa0..6b8cd73920f 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -138,7 +138,10 @@ export default class Root extends React.Component<{}, IState> { return; } - await getInitialNotification(); + const handledVideoConf = await getInitialNotification(); + if (handledVideoConf) { + return; + } // Open app from deep linking const deepLinking = await Linking.getInitialURL(); diff --git a/app/lib/notifications/videoConf/getInitialNotification.ts b/app/lib/notifications/videoConf/getInitialNotification.ts index 5e48a842ce1..220f177f80e 100644 --- a/app/lib/notifications/videoConf/getInitialNotification.ts +++ b/app/lib/notifications/videoConf/getInitialNotification.ts @@ -1,15 +1,28 @@ +import { NativeModules, Platform } from 'react-native'; + import { deepLinkingClickCallPush } from '../../../actions/deepLinking'; -import { isAndroid } from '../../methods/helpers'; import { store } from '../../store/auxStore'; -export const getInitialNotification = async (): Promise => { - if (isAndroid) { - const notifee = require('@notifee/react-native').default; - const initialNotification = await notifee.getInitialNotification(); - if (initialNotification?.notification?.data?.notificationType === 'videoconf') { - store.dispatch( - deepLinkingClickCallPush({ ...initialNotification?.notification?.data, event: initialNotification?.pressAction?.id }) - ); +const { VideoConfModule } = NativeModules; + +/** + * Check for pending video conference actions from native notification handling. + * @returns true if a video conf action was found and dispatched, false otherwise + */ +export const getInitialNotification = async (): Promise => { + if (Platform.OS === 'android' && VideoConfModule) { + try { + const pendingAction = await VideoConfModule.getPendingAction(); + if (pendingAction) { + const data = JSON.parse(pendingAction); + if (data?.notificationType === 'videoconf') { + store.dispatch(deepLinkingClickCallPush(data)); + return true; + } + } + } catch (error) { + console.log('Error getting video conf initial notification:', error); } } + return false; }; diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index abc28aba567..d1d57dedf44 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -46,7 +46,7 @@ const waitForNavigation = () => { if (Navigation.navigationRef.current) { return Promise.resolve(); } - return new Promise((resolve) => { + return new Promise(resolve => { const listener = () => { emitter.off('navigationReady', listener); resolve(); @@ -223,7 +223,7 @@ const handleNavigateCallRoom = function* handleNavigateCallRoom({ params }) { if (room) { const isMasterDetail = yield select(state => state.app.isMasterDetail); yield navigateToRoom({ item: room, isMasterDetail }); - const uid = params.caller._id; + const uid = params.caller?._id; const { rid, callId, event } = params; if (event === 'accept') { yield call(notifyUser, `${uid}/video-conference`, { @@ -246,6 +246,10 @@ const handleNavigateCallRoom = function* handleNavigateCallRoom({ params }) { const handleClickCallPush = function* handleClickCallPush({ params }) { let { host } = params; + if (!host) { + return; + } + if (host.slice(-1) === '/') { host = host.slice(0, host.length - 1); } diff --git a/app/views/JitsiMeetView/index.tsx b/app/views/JitsiMeetView/index.tsx index 67544b8de63..fe86c05d1c7 100644 --- a/app/views/JitsiMeetView/index.tsx +++ b/app/views/JitsiMeetView/index.tsx @@ -2,7 +2,7 @@ import CookieManager from '@react-native-cookies/cookies'; import { type RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake'; import React, { useCallback, useEffect, useState } from 'react'; -import { ActivityIndicator, BackHandler, Linking, SafeAreaView, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, Linking, SafeAreaView, StyleSheet, View } from 'react-native'; import WebView, { type WebViewNavigation } from 'react-native-webview'; import { userAgent } from '../../lib/constants/userAgent'; @@ -52,8 +52,8 @@ const JitsiMeetView = (): React.ReactElement => { await Linking.openURL(`org.jitsi.meet://${callUrl}`); goBack(); } catch (error) { - // As the jitsi app was not opened, disable the backhandler on android - BackHandler.addEventListener('hardwareBackPress', () => true); + // Jitsi app not installed - will use WebView instead + // No need to block back button as WebView handles navigation } }, [goBack, url]); diff --git a/index.js b/index.js index 4844e1da5dd..778e46478d1 100644 --- a/index.js +++ b/index.js @@ -22,9 +22,8 @@ if (process.env.USE_STORYBOOK) { LogBox.ignoreAllLogs(); - if (isAndroid) { - require('./app/lib/notifications/videoConf/backgroundNotificationHandler'); - } + // Note: Android video conference notifications are now handled natively + // in RCFirebaseMessagingService -> CustomPushNotification -> VideoConfNotification AppRegistry.registerComponent(appName, () => require('./app/index').default); } From 8d0506c3f6ab2b4f06e826262ec731654671a87a Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 10 Dec 2025 15:31:40 -0300 Subject: [PATCH 05/12] feat(notifications): enhance video conference notification handling for iOS - Added support for video conference notifications in the iOS NotificationService. - Implemented logic to process incoming video call notifications, including handling call status and displaying appropriate alerts. - Updated Payload and NotificationType models to accommodate video conference data. - Enhanced getInitialNotification to check for video conference actions on iOS using expo-notifications. - Improved error handling for notification responses. --- .../videoConf/getInitialNotification.ts | 35 +++++++++++++ .../NotificationService.swift | 49 +++++++++++++++++-- ios/Shared/Models/NotificationType.swift | 1 + ios/Shared/Models/Payload.swift | 12 ++++- 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/app/lib/notifications/videoConf/getInitialNotification.ts b/app/lib/notifications/videoConf/getInitialNotification.ts index 220f177f80e..4d8f980be90 100644 --- a/app/lib/notifications/videoConf/getInitialNotification.ts +++ b/app/lib/notifications/videoConf/getInitialNotification.ts @@ -1,3 +1,5 @@ +import * as Notifications from 'expo-notifications'; +import EJSON from 'ejson'; import { NativeModules, Platform } from 'react-native'; import { deepLinkingClickCallPush } from '../../../actions/deepLinking'; @@ -10,6 +12,7 @@ const { VideoConfModule } = NativeModules; * @returns true if a video conf action was found and dispatched, false otherwise */ export const getInitialNotification = async (): Promise => { + // Android: Check native module for pending action if (Platform.OS === 'android' && VideoConfModule) { try { const pendingAction = await VideoConfModule.getPendingAction(); @@ -24,5 +27,37 @@ export const getInitialNotification = async (): Promise => { console.log('Error getting video conf initial notification:', error); } } + + // iOS: Check expo-notifications for last response with video conf action + if (Platform.OS === 'ios') { + try { + const lastResponse = await Notifications.getLastNotificationResponseAsync(); + if (lastResponse) { + const { actionIdentifier, notification } = lastResponse; + + // Check if it's a video conf action (Accept or Decline) + if (actionIdentifier === 'ACCEPT_ACTION' || actionIdentifier === 'DECLINE_ACTION') { + const trigger = notification.request.trigger; + let payload: Record = {}; + + if (trigger && 'type' in trigger && trigger.type === 'push' && 'payload' in trigger && trigger.payload) { + payload = trigger.payload as Record; + } + + if (payload.ejson) { + const ejsonData = EJSON.parse(payload.ejson); + if (ejsonData?.notificationType === 'videoconf') { + const event = actionIdentifier === 'ACCEPT_ACTION' ? 'accept' : 'decline'; + store.dispatch(deepLinkingClickCallPush({ ...ejsonData, event })); + return true; + } + } + } + } + } catch (error) { + console.log('Error getting iOS video conf initial notification:', error); + } + } + return false; }; diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index 1d18a413e39..57cf88cf17b 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -13,11 +13,18 @@ class NotificationService: UNNotificationServiceExtension { if let bestAttemptContent = bestAttemptContent { let ejson = (bestAttemptContent.userInfo["ejson"] as? String ?? "").data(using: .utf8)! guard let data = try? (JSONDecoder().decode(Payload.self, from: ejson)) else { + contentHandler(bestAttemptContent) return } rocketchat = RocketChat(server: data.host.removeTrailingSlash()) + // Handle video conference notifications + if data.notificationType == .videoconf { + self.processVideoConf(payload: data, request: request) + return + } + // If the notification has the content on the payload, show it if data.notificationType != .messageIdOnly { self.processPayload(payload: data) @@ -35,17 +42,49 @@ class NotificationService: UNNotificationServiceExtension { } // Request the content from server - self.rocketchat?.getPushWithId(data.messageId) { notification in - if let notification = notification { - self.bestAttemptContent?.title = notification.title - self.bestAttemptContent?.body = notification.text - self.processPayload(payload: notification.payload) + if let messageId = data.messageId { + self.rocketchat?.getPushWithId(messageId) { notification in + if let notification = notification { + self.bestAttemptContent?.title = notification.title + self.bestAttemptContent?.body = notification.text + self.processPayload(payload: notification.payload) + } } } } } } + func processVideoConf(payload: Payload, request: UNNotificationRequest) { + guard let bestAttemptContent = bestAttemptContent else { + return + } + + // Status 4 means call cancelled/ended - remove any existing notification + if payload.status == 4 { + if let rid = payload.rid, let callerId = payload.caller?._id { + let notificationId = "\(rid)\(callerId)".replacingOccurrences(of: "[^A-Za-z0-9]", with: "", options: .regularExpression) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notificationId]) + } + // Don't show anything for cancelled calls + contentHandler?(UNNotificationContent()) + return + } + + // Status 0 (or nil) means incoming call - show notification with actions + let callerName = payload.caller?.name ?? "Unknown" + + bestAttemptContent.title = NSLocalizedString("Video Call", comment: "") + bestAttemptContent.body = String(format: NSLocalizedString("Incoming call from %@", comment: ""), callerName) + bestAttemptContent.categoryIdentifier = "VIDEOCONF" + bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName("ringtone.mp3")) + if #available(iOS 15.0, *) { + bestAttemptContent.interruptionLevel = .timeSensitive + } + + contentHandler?(bestAttemptContent) + } + func processPayload(payload: Payload) { // If is a encrypted message if payload.messageType == .e2e { diff --git a/ios/Shared/Models/NotificationType.swift b/ios/Shared/Models/NotificationType.swift index 95e41bed78c..0e769aa0910 100644 --- a/ios/Shared/Models/NotificationType.swift +++ b/ios/Shared/Models/NotificationType.swift @@ -11,4 +11,5 @@ import Foundation enum NotificationType: String, Codable { case message = "message" case messageIdOnly = "message-id-only" + case videoconf = "videoconf" } diff --git a/ios/Shared/Models/Payload.swift b/ios/Shared/Models/Payload.swift index 124ccf612e6..2a91f3a1b38 100644 --- a/ios/Shared/Models/Payload.swift +++ b/ios/Shared/Models/Payload.swift @@ -8,12 +8,17 @@ import Foundation +struct Caller: Codable { + let _id: String? + let name: String? +} + struct Payload: Codable { let host: String let rid: String? let type: RoomType? let sender: Sender? - let messageId: String + let messageId: String? let notificationType: NotificationType? let name: String? let messageType: MessageType? @@ -21,4 +26,9 @@ struct Payload: Codable { let senderName: String? let tmid: String? let content: EncryptedContent? + + // Video conference fields + let caller: Caller? + let callId: String? + let status: Int? } From 4826fbe97c7a5e4a95fe559bd14965d25001a18a Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 10 Dec 2025 15:36:42 -0300 Subject: [PATCH 06/12] feat(notifications): improve video conference notification handling - Enhanced the onNotification function to streamline processing of video conference notifications, including accept and decline actions. - Updated getInitialNotification to handle video conference actions more effectively, ensuring proper event dispatching based on user interaction. - Improved error handling and code readability by reducing nested conditions and clarifying logic flow. --- app/lib/notifications/index.ts | 56 +++++++++++-------- .../videoConf/getInitialNotification.ts | 28 +++++----- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/app/lib/notifications/index.ts b/app/lib/notifications/index.ts index fe0709e11b8..65adab40d62 100644 --- a/app/lib/notifications/index.ts +++ b/app/lib/notifications/index.ts @@ -17,39 +17,47 @@ interface IEjson { export const onNotification = (push: INotification): void => { const identifier = String(push?.payload?.action?.identifier); + + // Handle video conf notification actions (Accept/Decline buttons) if (identifier === 'ACCEPT_ACTION' || identifier === 'DECLINE_ACTION') { - if (push?.payload && push?.payload?.ejson) { - const notification = EJSON.parse(push?.payload?.ejson); + if (push?.payload?.ejson) { + const notification = EJSON.parse(push.payload.ejson); store.dispatch(deepLinkingClickCallPush({ ...notification, event: identifier === 'ACCEPT_ACTION' ? 'accept' : 'decline' })); return; } } - if (push?.payload) { - try { - const notification = push?.payload; - if (notification.ejson) { - const { rid, name, sender, type, host, messageId }: IEjson = EJSON.parse(notification.ejson); - const types: Record = { - c: 'channel', - d: 'direct', - p: 'group', - l: 'channels' - }; - let roomName = type === SubscriptionType.DIRECT ? sender.username : name; - if (type === SubscriptionType.OMNICHANNEL) { - roomName = sender.name; - } + if (push?.payload?.ejson) { + try { + const notification = EJSON.parse(push.payload.ejson); - const params = { - host, - rid, - messageId, - path: `${types[type]}/${roomName}` - }; - store.dispatch(deepLinkingOpen(params)); + // Handle video conf notification tap (default action) - treat as accept + if (notification?.notificationType === 'videoconf') { + store.dispatch(deepLinkingClickCallPush({ ...notification, event: 'accept' })); return; } + + // Handle regular message notifications + const { rid, name, sender, type, host, messageId }: IEjson = notification; + const types: Record = { + c: 'channel', + d: 'direct', + p: 'group', + l: 'channels' + }; + let roomName = type === SubscriptionType.DIRECT ? sender.username : name; + if (type === SubscriptionType.OMNICHANNEL) { + roomName = sender.name; + } + + const params = { + host, + rid, + messageId, + path: `${types[type]}/${roomName}` + }; + store.dispatch(deepLinkingOpen(params)); + return; } catch (e) { console.warn(e); } diff --git a/app/lib/notifications/videoConf/getInitialNotification.ts b/app/lib/notifications/videoConf/getInitialNotification.ts index 4d8f980be90..d48ed78cbf7 100644 --- a/app/lib/notifications/videoConf/getInitialNotification.ts +++ b/app/lib/notifications/videoConf/getInitialNotification.ts @@ -34,23 +34,23 @@ export const getInitialNotification = async (): Promise => { const lastResponse = await Notifications.getLastNotificationResponseAsync(); if (lastResponse) { const { actionIdentifier, notification } = lastResponse; + const trigger = notification.request.trigger; + let payload: Record = {}; - // Check if it's a video conf action (Accept or Decline) - if (actionIdentifier === 'ACCEPT_ACTION' || actionIdentifier === 'DECLINE_ACTION') { - const trigger = notification.request.trigger; - let payload: Record = {}; - - if (trigger && 'type' in trigger && trigger.type === 'push' && 'payload' in trigger && trigger.payload) { - payload = trigger.payload as Record; - } + if (trigger && 'type' in trigger && trigger.type === 'push' && 'payload' in trigger && trigger.payload) { + payload = trigger.payload as Record; + } - if (payload.ejson) { - const ejsonData = EJSON.parse(payload.ejson); - if (ejsonData?.notificationType === 'videoconf') { - const event = actionIdentifier === 'ACCEPT_ACTION' ? 'accept' : 'decline'; - store.dispatch(deepLinkingClickCallPush({ ...ejsonData, event })); - return true; + if (payload.ejson) { + const ejsonData = EJSON.parse(payload.ejson); + if (ejsonData?.notificationType === 'videoconf') { + // Accept/Decline actions or default tap (treat as accept) + let event = 'accept'; + if (actionIdentifier === 'DECLINE_ACTION') { + event = 'decline'; } + store.dispatch(deepLinkingClickCallPush({ ...ejsonData, event })); + return true; } } } From 952068de14e122a56e7ad09976250f20bb52094e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 10 Dec 2025 17:56:38 -0300 Subject: [PATCH 07/12] refactor(notifications): remove Notifee and Firebase messaging dependencies - Removed @notifee/react-native and @react-native-firebase/messaging from the project, along with related code and configurations. - Updated notification handling to utilize expo-notifications instead, ensuring a more streamlined approach to push notifications. - Cleaned up package.json, yarn.lock, and Podfile.lock to reflect the removal of obsolete dependencies. - Deleted background notification handler and adjusted notification settings management accordingly. --- .../backgroundNotificationHandler.ts | 125 ------------------ app/sagas/troubleshootingNotification.ts | 6 +- .../components/DeviceNotificationSettings.tsx | 3 +- ios/Podfile.lock | 9 -- ios/RocketChatRN.xcodeproj/project.pbxproj | 22 +-- package.json | 2 - patches/@notifee+react-native+7.8.2.patch | 41 ------ ...ct-native-firebase+messaging+21.12.2.patch | 19 --- react-native.config.js | 8 +- yarn.lock | 10 -- 10 files changed, 19 insertions(+), 226 deletions(-) delete mode 100644 app/lib/notifications/videoConf/backgroundNotificationHandler.ts delete mode 100644 patches/@notifee+react-native+7.8.2.patch delete mode 100644 patches/@react-native-firebase+messaging+21.12.2.patch diff --git a/app/lib/notifications/videoConf/backgroundNotificationHandler.ts b/app/lib/notifications/videoConf/backgroundNotificationHandler.ts deleted file mode 100644 index ca33cc13787..00000000000 --- a/app/lib/notifications/videoConf/backgroundNotificationHandler.ts +++ /dev/null @@ -1,125 +0,0 @@ -import notifee, { AndroidCategory, AndroidFlags, AndroidImportance, AndroidVisibility, type Event } from '@notifee/react-native'; -import { getMessaging as messaging } from '@react-native-firebase/messaging'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import ejson from 'ejson'; - -import { deepLinkingClickCallPush } from '../../../actions/deepLinking'; -import i18n from '../../../i18n'; -import { colors } from '../../constants/colors'; -import { store } from '../../store/auxStore'; - -const VIDEO_CONF_CHANNEL = 'video-conf-call'; -const VIDEO_CONF_TYPE = 'videoconf'; - -interface Caller { - _id?: string; - name?: string; -} - -interface NotificationData { - notificationType?: string; - status?: number; - rid?: string; - caller?: Caller; -} - -const createChannel = () => - notifee.createChannel({ - id: VIDEO_CONF_CHANNEL, - name: 'Video Call', - lights: true, - vibration: true, - importance: AndroidImportance.HIGH, - sound: 'ringtone' - }); - -const handleBackgroundEvent = async (event: Event) => { - const { pressAction, notification } = event.detail; - const notificationData = notification?.data; - if ( - typeof notificationData?.caller === 'object' && - (notificationData.caller as Caller)?._id && - (event.type === 1 || event.type === 2) - ) { - if (store?.getState()?.app.ready) { - store.dispatch(deepLinkingClickCallPush({ ...notificationData, event: pressAction?.id })); - } else { - AsyncStorage.setItem('pushNotification', JSON.stringify({ ...notificationData, event: pressAction?.id })); - } - await notifee.cancelNotification( - `${notificationData.rid}${(notificationData.caller as Caller)._id}`.replace(/[^A-Za-z0-9]/g, '') - ); - } -}; - -const backgroundNotificationHandler = () => { - notifee.onBackgroundEvent(handleBackgroundEvent); -}; - -const displayVideoConferenceNotification = async (notification: NotificationData) => { - const id = `${notification.rid}${notification.caller?._id}`.replace(/[^A-Za-z0-9]/g, ''); - const actions = [ - { - title: i18n.t('accept'), - pressAction: { - id: 'accept', - launchActivity: 'default' - } - }, - { - title: i18n.t('decline'), - pressAction: { - id: 'decline', - launchActivity: 'default' - } - } - ]; - - await notifee.displayNotification({ - id, - title: i18n.t('conference_call'), - body: `${i18n.t('Incoming_call_from')} ${notification.caller?.name}`, - data: notification as { [key: string]: string | number | object }, - android: { - channelId: VIDEO_CONF_CHANNEL, - category: AndroidCategory.CALL, - visibility: AndroidVisibility.PUBLIC, - importance: AndroidImportance.HIGH, - smallIcon: 'ic_notification', - color: colors.light.badgeBackgroundLevel4, - actions, - lightUpScreen: true, - loopSound: true, - sound: 'ringtone', - autoCancel: false, - ongoing: true, - pressAction: { - id: 'default', - launchActivity: 'default' - }, - flags: [AndroidFlags.FLAG_NO_CLEAR] - } - }); -}; - -const setBackgroundNotificationHandler = () => { - createChannel(); - messaging().setBackgroundMessageHandler(async message => { - if (message?.data?.ejson) { - const notification: NotificationData = ejson.parse(message?.data?.ejson as string); - if (notification?.notificationType === VIDEO_CONF_TYPE) { - if (notification.status === 0) { - await displayVideoConferenceNotification(notification); - } else if (notification.status === 4) { - const id = `${notification.rid}${notification.caller?._id}`.replace(/[^A-Za-z0-9]/g, ''); - await notifee.cancelNotification(id); - } - } - } - - return null; - }); -}; - -setBackgroundNotificationHandler(); -backgroundNotificationHandler(); diff --git a/app/sagas/troubleshootingNotification.ts b/app/sagas/troubleshootingNotification.ts index 0717c083f19..193568890e9 100644 --- a/app/sagas/troubleshootingNotification.ts +++ b/app/sagas/troubleshootingNotification.ts @@ -1,6 +1,6 @@ import { type Action } from 'redux'; import { call, takeLatest, put } from 'typed-redux-saga'; -import notifee, { AuthorizationStatus } from '@notifee/react-native'; +import * as Notifications from 'expo-notifications'; import { TROUBLESHOOTING_NOTIFICATION } from '../actions/actionsTypes'; import { setTroubleshootingNotification } from '../actions/troubleshootingNotification'; @@ -19,8 +19,8 @@ function* init() { let defaultPushGateway = false; let pushGatewayEnabled = false; try { - const { authorizationStatus } = yield* call(notifee.getNotificationSettings); - deviceNotificationEnabled = authorizationStatus > AuthorizationStatus.DENIED; + const { status } = yield* call(Notifications.getPermissionsAsync); + deviceNotificationEnabled = status === 'granted'; } catch (e) { log(e); } diff --git a/app/views/PushTroubleshootView/components/DeviceNotificationSettings.tsx b/app/views/PushTroubleshootView/components/DeviceNotificationSettings.tsx index 9e77e43c47b..0adf5702b9d 100644 --- a/app/views/PushTroubleshootView/components/DeviceNotificationSettings.tsx +++ b/app/views/PushTroubleshootView/components/DeviceNotificationSettings.tsx @@ -1,4 +1,3 @@ -import notifee from '@notifee/react-native'; import React from 'react'; import { Linking } from 'react-native'; @@ -19,7 +18,7 @@ export default function DeviceNotificationSettings(): React.ReactElement { if (isIOS) { Linking.openURL('app-settings:'); } else { - notifee.openNotificationSettings(); + Linking.openSettings(); } }; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 21cccfaf10a..0ab9d11f83b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2389,11 +2389,6 @@ PODS: - React-Core - RNLocalize (2.1.1): - React-Core - - RNNotifee (7.8.2): - - React-Core - - RNNotifee/NotifeeCore (= 7.8.2) - - RNNotifee/NotifeeCore (7.8.2): - - React-Core - RNReanimated (3.17.1): - DoubleConversion - glog @@ -2764,7 +2759,6 @@ DEPENDENCIES: - RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`) - RNKeychain (from `../node_modules/react-native-keychain`) - RNLocalize (from `../node_modules/react-native-localize`) - - "RNNotifee (from `../node_modules/@notifee/react-native`)" - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) - RNSVG (from `../node_modules/react-native-svg`) @@ -3042,8 +3036,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-keychain" RNLocalize: :path: "../node_modules/react-native-localize" - RNNotifee: - :path: "../node_modules/@notifee/react-native" RNReanimated: :path: "../node_modules/react-native-reanimated" RNScreens: @@ -3197,7 +3189,6 @@ SPEC CHECKSUMS: RNImageCropPicker: b219389d3a300679b396e81d501e8c8169ffa3c0 RNKeychain: bbe2f6d5cc008920324acb49ef86ccc03d3b38e4 RNLocalize: ca86348d88b9a89da0e700af58d428ab3f343c4e - RNNotifee: 8768d065bf1e2f9f8f347b4bd79147431c7eacd6 RNReanimated: f52ccd5ceea2bae48d7421eec89b3f0c10d7b642 RNScreens: b13e4c45f0406f33986a39c0d8da0324bff94435 RNSVG: 680e961f640e381aab730a04b2371969686ed9f7 diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index a3169c2bf9c..80fbac43faf 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -1530,7 +1530,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export EXTRA_PACKAGER_ARGS=\"--sourcemap-output $TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\"\nexport NODE_BINARY=/Users/diegomello/.nvm/versions/node/v22.14.0/bin/node\n../node_modules/react-native/scripts/react-native-xcode.sh\n"; + shellScript = "export EXTRA_PACKAGER_ARGS=\"--sourcemap-output $TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\"\nexport NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh\n"; }; 17344D2847CBD7141D4AF748 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; @@ -1746,7 +1746,7 @@ inputFileListPaths = ( ); inputPaths = ( - $TARGET_BUILD_DIR/$INFOPLIST_PATH, + "$TARGET_BUILD_DIR/$INFOPLIST_PATH", ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -1832,7 +1832,7 @@ inputFileListPaths = ( ); inputPaths = ( - $TARGET_BUILD_DIR/$INFOPLIST_PATH, + "$TARGET_BUILD_DIR/$INFOPLIST_PATH", ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -1841,7 +1841,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "#SOURCE_MAP=\"$TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\" ../node_modules/@bugsnag/react-native/bugsnag-react-native-xcode.sh\n"; + shellScript = "SOURCE_MAP=\"$TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\" ../node_modules/@bugsnag/react-native/bugsnag-react-native-xcode.sh\n"; }; 84028E94C77DEBDD5200728D /* [Expo] Configure project */ = { isa = PBXShellScriptBuildPhase; @@ -2620,7 +2620,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - $PODS_CONFIGURATION_BUILD_DIR/Firebase, + "$PODS_CONFIGURATION_BUILD_DIR/Firebase", "$(SRCROOT)/../node_modules/react-native-mmkv-storage/ios/**", ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist; @@ -2697,7 +2697,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - $PODS_CONFIGURATION_BUILD_DIR/Firebase, + "$PODS_CONFIGURATION_BUILD_DIR/Firebase", "$(SRCROOT)/../node_modules/react-native-mmkv-storage/ios/**", ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist; @@ -3199,7 +3199,10 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -3263,7 +3266,10 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/package.json b/package.json index 5b6113c11ad..489c43244bc 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@expo/vector-icons": "^14.1.0", "@hookform/resolvers": "^2.9.10", - "@notifee/react-native": "7.8.2", "@nozbe/watermelondb": "^0.28.1-0", "@react-native-async-storage/async-storage": "^1.22.3", "@react-native-camera-roll/camera-roll": "^7.10.0", @@ -41,7 +40,6 @@ "@react-native-firebase/analytics": "^21.12.2", "@react-native-firebase/app": "^21.12.2", "@react-native-firebase/crashlytics": "^21.12.2", - "@react-native-firebase/messaging": "^21.12.2", "@react-native-masked-view/masked-view": "^0.3.1", "@react-native-picker/picker": "^2.11.0", "@react-native/codegen": "^0.80.0", diff --git a/patches/@notifee+react-native+7.8.2.patch b/patches/@notifee+react-native+7.8.2.patch deleted file mode 100644 index cdb17b7853a..00000000000 --- a/patches/@notifee+react-native+7.8.2.patch +++ /dev/null @@ -1,41 +0,0 @@ ---- a/node_modules/@notifee/react-native/android/src/main/java/io/invertase/notifee/NotifeeApiModule.java -+++ b/node_modules/@notifee/react-native/android/src/main/java/io/invertase/notifee/NotifeeApiModule.java -@@ -238,7 +238,7 @@ public class NotifeeApiModule extends ReactContextBaseJavaModule implements Perm - @ReactMethod - public void requestPermission(Promise promise) { - // For Android 12 and below, we return the notification settings -- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { -+ if (Build.VERSION.SDK_INT < 33) { - Notifee.getInstance() - .getNotificationSettings( - (e, aBundle) -> NotifeeReactUtils.promiseResolver(promise, e, aBundle)); -@@ -265,7 +265,7 @@ public class NotifeeApiModule extends ReactContextBaseJavaModule implements Perm - (e, aBundle) -> NotifeeReactUtils.promiseResolver(promise, e, aBundle)); - - activity.requestPermissions( -- new String[] {Manifest.permission.POST_NOTIFICATIONS}, -+ new String[] {"android.permission.POST_NOTIFICATIONS"}, - Notifee.REQUEST_CODE_NOTIFICATION_PERMISSION, - this); - } -diff --git a/node_modules/@notifee/react-native/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.m b/node_modules/@notifee/react-native/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.m -index cf8020d..3a1e080 100644 ---- a/node_modules/@notifee/react-native/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.m -+++ b/node_modules/@notifee/react-native/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.m -@@ -179,11 +179,11 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center - - _notificationOpenedAppID = notifeeNotification[@"id"]; - -- // handle notification outside of notifee -- if (notifeeNotification == nil) { -- notifeeNotification = -- [NotifeeCoreUtil parseUNNotificationRequest:response.notification.request]; -- } -+ // disable notifee handler on ios devices -+ // if (notifeeNotification == nil) { -+ // notifeeNotification = -+ // [NotifeeCoreUtil parseUNNotificationRequest:response.notification.request]; -+ // } - - if (notifeeNotification != nil) { - if ([response.actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier]) { diff --git a/patches/@react-native-firebase+messaging+21.12.2.patch b/patches/@react-native-firebase+messaging+21.12.2.patch deleted file mode 100644 index 34905325f15..00000000000 --- a/patches/@react-native-firebase+messaging+21.12.2.patch +++ /dev/null @@ -1,19 +0,0 @@ -diff --git a/node_modules/@react-native-firebase/messaging/android/src/main/AndroidManifest.xml b/node_modules/@react-native-firebase/messaging/android/src/main/AndroidManifest.xml -index 2f6741b..d4f4abb 100644 ---- a/node_modules/@react-native-firebase/messaging/android/src/main/AndroidManifest.xml -+++ b/node_modules/@react-native-firebase/messaging/android/src/main/AndroidManifest.xml -@@ -9,12 +9,12 @@ - - -- - - - -- -+ --> - Date: Wed, 10 Dec 2025 18:25:14 -0300 Subject: [PATCH 08/12] fix lint --- app/lib/notifications/push.ts | 18 +++++++++--------- .../videoConf/getInitialNotification.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/lib/notifications/push.ts b/app/lib/notifications/push.ts index c35cfc3dce9..9dc160ff2e3 100644 --- a/app/lib/notifications/push.ts +++ b/app/lib/notifications/push.ts @@ -32,8 +32,7 @@ let configured = false; */ const transformNotificationResponse = (response: Notifications.NotificationResponse): INotification => { const { notification, actionIdentifier, userText } = response; - const trigger = notification.request.trigger; - const content = notification.request.content; + const { trigger, content } = notification.request; // Get the raw data from the notification let payload: Record = {}; @@ -167,13 +166,14 @@ export const pushNotificationConfigure = (onNotification: (notification: INotifi // Set up how notifications should be handled when the app is in foreground Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: false, - shouldPlaySound: false, - shouldSetBadge: false, - shouldShowBanner: false, - shouldShowList: false - }) + handleNotification: () => + Promise.resolve({ + shouldShowAlert: false, + shouldPlaySound: false, + shouldSetBadge: false, + shouldShowBanner: false, + shouldShowList: false + }) }); // Set up notification categories for iOS diff --git a/app/lib/notifications/videoConf/getInitialNotification.ts b/app/lib/notifications/videoConf/getInitialNotification.ts index d48ed78cbf7..12f92031389 100644 --- a/app/lib/notifications/videoConf/getInitialNotification.ts +++ b/app/lib/notifications/videoConf/getInitialNotification.ts @@ -34,7 +34,7 @@ export const getInitialNotification = async (): Promise => { const lastResponse = await Notifications.getLastNotificationResponseAsync(); if (lastResponse) { const { actionIdentifier, notification } = lastResponse; - const trigger = notification.request.trigger; + const { trigger } = notification.request; let payload: Record = {}; if (trigger && 'type' in trigger && trigger.type === 'push' && 'payload' in trigger && trigger.payload) { From f0379395ac735a5b3724680fbffc1460d4f7e887 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 11 Dec 2025 10:34:26 -0300 Subject: [PATCH 09/12] target arm64 on e2e ci --- ios/fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index f8df411c23d..318f743be2b 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -122,7 +122,7 @@ platform :ios do build_path: "./fastlane/build", configuration: "Release", derived_data_path: "./fastlane/derived_data", - xcargs: "-parallelizeTargets -jobs 4" + xcargs: "-parallelizeTargets -jobs 4 ARCHS=arm64" ) end From 3009671892bb2fe94d957b118c66639d3aa4ebeb Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 11 Dec 2025 10:43:04 -0300 Subject: [PATCH 10/12] Fix user agent on push.get --- ios/Shared/RocketChat/API/Request.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ios/Shared/RocketChat/API/Request.swift b/ios/Shared/RocketChat/API/Request.swift index a1f6a728c57..97c54bf8574 100644 --- a/ios/Shared/RocketChat/API/Request.swift +++ b/ios/Shared/RocketChat/API/Request.swift @@ -55,6 +55,7 @@ extension Request { request.httpMethod = method.rawValue request.httpBody = body() request.addValue(contentType, forHTTPHeaderField: "Content-Type") + request.addValue(userAgent, forHTTPHeaderField: "User-Agent") if let userId = api.credentials?.userId { request.addValue(userId, forHTTPHeaderField: "x-user-id") @@ -69,4 +70,14 @@ extension Request { return request } + + private var userAgent: String { + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + let systemVersion = "\(osVersion.majorVersion).\(osVersion.minorVersion)" + + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown" + + return "RC Mobile; ios \(systemVersion); v\(appVersion) (\(buildNumber))" + } } From 6620a1bd8fbbe9498d9c61da71ac09b05af37024 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 11 Dec 2025 14:07:16 -0300 Subject: [PATCH 11/12] refactor(notifications): migrate video conference notification handling to TurboModules - Replaced the existing VideoConfModule and related classes with a TurboModule implementation for improved performance and integration. - Updated MainApplication to use VideoConfTurboPackage instead of the legacy VideoConfPackage. - Enhanced notification handling by introducing a new User-Agent header in relevant requests. - Removed obsolete Java classes and streamlined the notification architecture to utilize Kotlin for better maintainability. - Improved the handling of video conference actions and ensured compatibility with the new TurboModule system. --- .../rocket/reactnative/MainApplication.kt | 4 +- .../notification/LoadNotification.java | 1 + .../notification/NativeVideoConfSpec.kt | 23 ++ .../notification/NotificationHelper.java | 14 ++ .../RCFirebaseMessagingService.java | 54 ---- .../RCFirebaseMessagingService.kt | 51 ++++ .../notification/ReplyBroadcast.java | 1 + .../notification/VideoConfBroadcast.java | 81 ------ .../notification/VideoConfBroadcast.kt | 73 ++++++ .../notification/VideoConfModule.java | 78 ------ .../notification/VideoConfModule.kt | 66 +++++ .../notification/VideoConfNotification.java | 236 ------------------ .../notification/VideoConfNotification.kt | 210 ++++++++++++++++ .../notification/VideoConfPackage.java | 32 --- .../notification/VideoConfTurboPackage.kt | 37 +++ app/lib/native/NativeVideoConfAndroid.ts | 9 + .../videoConf/getInitialNotification.ts | 9 +- 17 files changed, 491 insertions(+), 488 deletions(-) create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/NativeVideoConfSpec.kt delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.kt delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.kt delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.kt delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfPackage.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfTurboPackage.kt create mode 100644 app/lib/native/NativeVideoConfAndroid.ts diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 2705c02d21a..8b532b363bc 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -20,7 +20,7 @@ import com.bugsnag.android.Bugsnag import expo.modules.ApplicationLifecycleDispatcher import chat.rocket.reactnative.networking.SSLPinningTurboPackage; import chat.rocket.reactnative.notification.CustomPushNotification; -import chat.rocket.reactnative.notification.VideoConfPackage; +import chat.rocket.reactnative.notification.VideoConfTurboPackage /** * Main Application class. @@ -42,7 +42,7 @@ open class MainApplication : Application(), ReactApplication { PackageList(this).packages.apply { add(SSLPinningTurboPackage()) add(WatermelonDBJSIPackage()) - add(VideoConfPackage()) + add(VideoConfTurboPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/LoadNotification.java b/android/app/src/main/java/chat/rocket/reactnative/notification/LoadNotification.java index 6b6158393fc..eb528df4f3b 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/LoadNotification.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/LoadNotification.java @@ -127,6 +127,7 @@ public void load(ReactApplicationContext reactApplicationContext, final Ejson ej Request request = new Request.Builder() .header("x-user-id", userId) .header("x-auth-token", userToken) + .header("User-Agent", NotificationHelper.getUserAgent()) .url(urlBuilder.addQueryParameter("id", messageId).build()) .build(); diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/NativeVideoConfSpec.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/NativeVideoConfSpec.kt new file mode 100644 index 00000000000..8eaea08cfd4 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/NativeVideoConfSpec.kt @@ -0,0 +1,23 @@ +package chat.rocket.reactnative.notification + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.turbomodule.core.interfaces.TurboModule + +abstract class NativeVideoConfSpec(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext), TurboModule { + + companion object { + const val NAME = "VideoConfModule" + } + + override fun getName(): String = NAME + + @ReactMethod + abstract fun getPendingAction(promise: Promise) + + @ReactMethod + abstract fun clearPendingAction() +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java index ac2f1256301..3b53a0be419 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java @@ -1,5 +1,7 @@ package chat.rocket.reactnative.notification; +import android.os.Build; + import chat.rocket.reactnative.BuildConfig; /** @@ -23,5 +25,17 @@ public static String sanitizeUrl(String url) { } return "[workspace]"; } + + /** + * Get the User-Agent string for API requests + * Format: RC Mobile; android {systemVersion}; v{appVersion} ({buildNumber}) + * @return User-Agent string + */ + public static String getUserAgent() { + String systemVersion = Build.VERSION.RELEASE; + String appVersion = BuildConfig.VERSION_NAME; + int buildNumber = BuildConfig.VERSION_CODE; + return String.format("RC Mobile; android %s; v%s (%d)", systemVersion, appVersion, buildNumber); + } } diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.java b/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.java deleted file mode 100644 index b1c75b453b5..00000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.java +++ /dev/null @@ -1,54 +0,0 @@ -package chat.rocket.reactnative.notification; - -import android.os.Bundle; -import android.util.Log; - -import androidx.annotation.NonNull; - -import com.google.firebase.messaging.FirebaseMessagingService; -import com.google.firebase.messaging.RemoteMessage; - -import java.util.Map; - -/** - * Custom Firebase Messaging Service for Rocket.Chat. - * - * Handles incoming FCM messages and routes them to CustomPushNotification - * for advanced processing (E2E decryption, MessagingStyle, direct reply, etc.) - */ -public class RCFirebaseMessagingService extends FirebaseMessagingService { - private static final String TAG = "RocketChat.FCM"; - - @Override - public void onMessageReceived(@NonNull RemoteMessage remoteMessage) { - Log.d(TAG, "FCM message received from: " + remoteMessage.getFrom()); - - Map data = remoteMessage.getData(); - if (data.isEmpty()) { - Log.w(TAG, "FCM message has no data payload, ignoring"); - return; - } - - // Convert FCM data to Bundle for processing - Bundle bundle = new Bundle(); - for (Map.Entry entry : data.entrySet()) { - bundle.putString(entry.getKey(), entry.getValue()); - } - - // Process the notification - try { - CustomPushNotification notification = new CustomPushNotification(this, bundle); - notification.onReceived(); - } catch (Exception e) { - Log.e(TAG, "Error processing FCM message", e); - } - } - - @Override - public void onNewToken(@NonNull String token) { - Log.d(TAG, "FCM token refreshed"); - // Token handling is done by expo-notifications JS layer - // which uses getDevicePushTokenAsync() - super.onNewToken(token); - } -} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt new file mode 100644 index 00000000000..c8aba96e6b9 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt @@ -0,0 +1,51 @@ +package chat.rocket.reactnative.notification + +import android.os.Bundle +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage + +/** + * Custom Firebase Messaging Service for Rocket.Chat. + * + * Handles incoming FCM messages and routes them to CustomPushNotification + * for advanced processing (E2E decryption, MessagingStyle, direct reply, etc.) + */ +class RCFirebaseMessagingService : FirebaseMessagingService() { + + companion object { + private const val TAG = "RocketChat.FCM" + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + Log.d(TAG, "FCM message received from: ${remoteMessage.from}") + + val data = remoteMessage.data + if (data.isEmpty()) { + Log.w(TAG, "FCM message has no data payload, ignoring") + return + } + + // Convert FCM data to Bundle for processing + val bundle = Bundle().apply { + data.forEach { (key, value) -> + putString(key, value) + } + } + + // Process the notification + try { + val notification = CustomPushNotification(this, bundle) + notification.onReceived() + } catch (e: Exception) { + Log.e(TAG, "Error processing FCM message", e) + } + } + + override fun onNewToken(token: String) { + Log.d(TAG, "FCM token refreshed") + // Token handling is done by expo-notifications JS layer + // which uses getDevicePushTokenAsync() + super.onNewToken(token) + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java b/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java index f07f99a1b18..345596cfd98 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java @@ -81,6 +81,7 @@ protected void replyToMessage(final Ejson ejson, final int notId, final CharSequ Request request = new Request.Builder() .header("x-auth-token", ejson.token()) .header("x-user-id", ejson.userId()) + .header("User-Agent", NotificationHelper.getUserAgent()) .url(String.format("%s/api/v1/chat.sendMessage", serverURL)) .post(body) .build(); diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.java b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.java deleted file mode 100644 index ec94c17db5e..00000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.java +++ /dev/null @@ -1,81 +0,0 @@ -package chat.rocket.reactnative.notification; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.util.Log; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; - -import java.util.HashMap; -import java.util.Map; - -import chat.rocket.reactnative.MainActivity; - -/** - * Handles video conference notification actions (accept/decline). - * Stores the action for the JS layer to process when the app opens. - */ -public class VideoConfBroadcast extends BroadcastReceiver { - private static final String TAG = "RocketChat.VideoConf"; - - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - Bundle extras = intent.getExtras(); - - if (action == null || extras == null) { - Log.w(TAG, "Received broadcast with null action or extras"); - return; - } - - Log.d(TAG, "Received video conf action: " + action); - - String event = null; - if (VideoConfNotification.ACTION_ACCEPT.equals(action)) { - event = "accept"; - } else if (VideoConfNotification.ACTION_DECLINE.equals(action)) { - event = "decline"; - } - - if (event == null) { - Log.w(TAG, "Unknown action: " + action); - return; - } - - // Cancel the notification - int notificationId = extras.getInt("notificationId", 0); - if (notificationId != 0) { - VideoConfNotification.cancelById(context, notificationId); - } - - // Build data for JS layer - Map data = new HashMap<>(); - data.put("notificationType", extras.getString("notificationType", "videoconf")); - data.put("rid", extras.getString("rid", "")); - data.put("event", event); - - // Add caller info - Map caller = new HashMap<>(); - caller.put("_id", extras.getString("callerId", "")); - caller.put("name", extras.getString("callerName", "")); - data.put("caller", caller); - - // Store action for the JS layer to pick up - Gson gson = new GsonBuilder().create(); - String jsonData = gson.toJson(data); - - VideoConfModule.storePendingAction(context, jsonData); - - Log.d(TAG, "Stored video conf action: " + event + " for rid: " + extras.getString("rid")); - - // Launch the app - Intent launchIntent = new Intent(context, MainActivity.class); - launchIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); - launchIntent.putExtras(extras); - launchIntent.putExtra("event", event); - context.startActivity(launchIntent); - } -} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.kt new file mode 100644 index 00000000000..d5aa014fe8c --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.kt @@ -0,0 +1,73 @@ +package chat.rocket.reactnative.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import chat.rocket.reactnative.MainActivity +import com.google.gson.GsonBuilder + +/** + * Handles video conference notification actions (accept/decline). + * Stores the action for the JS layer to process when the app opens. + */ +class VideoConfBroadcast : BroadcastReceiver() { + + companion object { + private const val TAG = "RocketChat.VideoConf" + } + + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + val extras = intent.extras + + if (action == null || extras == null) { + Log.w(TAG, "Received broadcast with null action or extras") + return + } + + Log.d(TAG, "Received video conf action: $action") + + val event = when (action) { + VideoConfNotification.ACTION_ACCEPT -> "accept" + VideoConfNotification.ACTION_DECLINE -> "decline" + else -> { + Log.w(TAG, "Unknown action: $action") + return + } + } + + // Cancel the notification + val notificationId = extras.getInt("notificationId", 0) + if (notificationId != 0) { + VideoConfNotification.cancelById(context, notificationId) + } + + // Build data for JS layer + val data = mapOf( + "notificationType" to (extras.getString("notificationType") ?: "videoconf"), + "rid" to (extras.getString("rid") ?: ""), + "event" to event, + "caller" to mapOf( + "_id" to (extras.getString("callerId") ?: ""), + "name" to (extras.getString("callerName") ?: "") + ) + ) + + // Store action for the JS layer to pick up + val gson = GsonBuilder().create() + val jsonData = gson.toJson(data) + + VideoConfModule.storePendingAction(context, jsonData) + + Log.d(TAG, "Stored video conf action: $event for rid: ${extras.getString("rid")}") + + // Launch the app + val launchIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtras(extras) + putExtra("event", event) + } + context.startActivity(launchIntent) + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.java b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.java deleted file mode 100644 index 9b6139b00c4..00000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.java +++ /dev/null @@ -1,78 +0,0 @@ -package chat.rocket.reactnative.notification; - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.annotation.NonNull; - -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.module.annotations.ReactModule; - -/** - * Native module to expose video conference notification actions to JavaScript. - * Used to retrieve pending video conf actions when the app opens. - */ -@ReactModule(name = VideoConfModule.NAME) -public class VideoConfModule extends ReactContextBaseJavaModule { - public static final String NAME = "VideoConfModule"; - private static final String PREFS_NAME = "RocketChatPrefs"; - private static final String KEY_VIDEO_CONF_ACTION = "videoConfAction"; - - public VideoConfModule(ReactApplicationContext reactContext) { - super(reactContext); - } - - @Override - @NonNull - public String getName() { - return NAME; - } - - /** - * Gets any pending video conference action. - * Returns null if no pending action. - */ - @ReactMethod - public void getPendingAction(Promise promise) { - try { - Context context = getReactApplicationContext(); - SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - String action = prefs.getString(KEY_VIDEO_CONF_ACTION, null); - - // Clear the action after reading - if (action != null) { - prefs.edit().remove(KEY_VIDEO_CONF_ACTION).apply(); - } - - promise.resolve(action); - } catch (Exception e) { - promise.reject("ERROR", e.getMessage()); - } - } - - /** - * Clears any pending video conference action. - */ - @ReactMethod - public void clearPendingAction() { - try { - Context context = getReactApplicationContext(); - SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - prefs.edit().remove(KEY_VIDEO_CONF_ACTION).apply(); - } catch (Exception e) { - // Ignore errors - } - } - - /** - * Stores a video conference action. - * Called from native code when user interacts with video conf notification. - */ - public static void storePendingAction(Context context, String actionJson) { - SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - prefs.edit().putString(KEY_VIDEO_CONF_ACTION, actionJson).apply(); - } -} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.kt new file mode 100644 index 00000000000..87059ec0cc7 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.kt @@ -0,0 +1,66 @@ +package chat.rocket.reactnative.notification + +import android.content.Context +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod + +/** + * Native module to expose video conference notification actions to JavaScript. + * Used to retrieve pending video conf actions when the app opens. + */ +class VideoConfModule(reactContext: ReactApplicationContext) : NativeVideoConfSpec(reactContext) { + + companion object { + private const val PREFS_NAME = "RocketChatPrefs" + private const val KEY_VIDEO_CONF_ACTION = "videoConfAction" + + /** + * Stores a video conference action. + * Called from native code when user interacts with video conf notification. + */ + @JvmStatic + fun storePendingAction(context: Context, actionJson: String) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_VIDEO_CONF_ACTION, actionJson) + .apply() + } + } + + /** + * Gets any pending video conference action. + * Returns null if no pending action. + */ + @ReactMethod + override fun getPendingAction(promise: Promise) { + try { + val prefs = reactApplicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val action = prefs.getString(KEY_VIDEO_CONF_ACTION, null) + + // Clear the action after reading + action?.let { + prefs.edit().remove(KEY_VIDEO_CONF_ACTION).apply() + } + + promise.resolve(action) + } catch (e: Exception) { + promise.reject("ERROR", e.message) + } + } + + /** + * Clears any pending video conference action. + */ + @ReactMethod + override fun clearPendingAction() { + try { + reactApplicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .remove(KEY_VIDEO_CONF_ACTION) + .apply() + } catch (e: Exception) { + // Ignore errors + } + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.java b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.java deleted file mode 100644 index 9bcfdd32b3d..00000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.java +++ /dev/null @@ -1,236 +0,0 @@ -package chat.rocket.reactnative.notification; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.res.Resources; -import android.media.AudioAttributes; -import android.media.RingtoneManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.util.Log; - -import androidx.core.app.NotificationCompat; - -import chat.rocket.reactnative.MainActivity; - -/** - * Handles video conference call notifications with call-style UI. - * Displays incoming call notifications with Accept/Decline actions. - */ -public class VideoConfNotification { - private static final String TAG = "RocketChat.VideoConf"; - - public static final String CHANNEL_ID = "video-conf-call"; - public static final String CHANNEL_NAME = "Video Calls"; - - public static final String ACTION_ACCEPT = "chat.rocket.reactnative.ACTION_VIDEO_CONF_ACCEPT"; - public static final String ACTION_DECLINE = "chat.rocket.reactnative.ACTION_VIDEO_CONF_DECLINE"; - public static final String EXTRA_NOTIFICATION_DATA = "notification_data"; - - private final Context context; - private final NotificationManager notificationManager; - - public VideoConfNotification(Context context) { - this.context = context; - this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - createNotificationChannel(); - } - - /** - * Creates the notification channel for video calls with high importance and ringtone sound. - */ - private void createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel channel = new NotificationChannel( - CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_HIGH - ); - - channel.setDescription("Incoming video conference calls"); - channel.enableLights(true); - channel.enableVibration(true); - channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); - - // Set ringtone sound - Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); - AudioAttributes audioAttributes = new AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) - .build(); - channel.setSound(ringtoneUri, audioAttributes); - - if (notificationManager != null) { - notificationManager.createNotificationChannel(channel); - } - } - } - - /** - * Displays an incoming video call notification. - * - * @param bundle The notification data bundle - * @param ejson The parsed notification payload - */ - public void showIncomingCall(Bundle bundle, Ejson ejson) { - String rid = ejson.rid; - // Video conf uses 'caller' field, regular messages use 'sender' - String callerId = ""; - String callerName = "Unknown"; - - if (ejson.caller != null) { - callerId = ejson.caller._id != null ? ejson.caller._id : ""; - callerName = ejson.caller.name != null ? ejson.caller.name : "Unknown"; - } else if (ejson.sender != null) { - // Fallback to sender if caller is not present - callerId = ejson.sender._id != null ? ejson.sender._id : ""; - callerName = ejson.sender.name != null ? ejson.sender.name : (ejson.senderName != null ? ejson.senderName : "Unknown"); - } - - // Generate unique notification ID from rid + callerId - String notificationIdStr = (rid + callerId).replaceAll("[^A-Za-z0-9]", ""); - int notificationId = notificationIdStr.hashCode(); - - Log.d(TAG, "Showing incoming call notification from: " + callerName); - - // Create intent data for actions - include all required fields for JS - Bundle intentData = new Bundle(); - intentData.putString("rid", rid != null ? rid : ""); - intentData.putString("notificationType", "videoconf"); - intentData.putString("callerId", callerId); - intentData.putString("callerName", callerName); - intentData.putString("host", ejson.host != null ? ejson.host : ""); - intentData.putString("callId", ejson.callId != null ? ejson.callId : ""); - intentData.putString("ejson", bundle.getString("ejson", "{}")); - intentData.putInt("notificationId", notificationId); - - Log.d(TAG, "Intent data - rid: " + rid + ", callerId: " + callerId + ", host: " + ejson.host + ", callId: " + ejson.callId); - - // Full screen intent - opens app when notification is tapped - Intent fullScreenIntent = new Intent(context, MainActivity.class); - fullScreenIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); - fullScreenIntent.putExtras(intentData); - fullScreenIntent.putExtra("event", "default"); - - PendingIntent fullScreenPendingIntent; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - fullScreenPendingIntent = PendingIntent.getActivity( - context, notificationId, fullScreenIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE - ); - } else { - fullScreenPendingIntent = PendingIntent.getActivity( - context, notificationId, fullScreenIntent, - PendingIntent.FLAG_UPDATE_CURRENT - ); - } - - // Accept action - directly opens MainActivity (Android 12+ blocks trampoline pattern) - Intent acceptIntent = new Intent(context, MainActivity.class); - acceptIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); - acceptIntent.putExtras(intentData); - acceptIntent.putExtra("event", "accept"); - acceptIntent.putExtra("videoConfAction", true); - acceptIntent.setAction(ACTION_ACCEPT + "_" + notificationId); // Unique action to differentiate intents - - PendingIntent acceptPendingIntent; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - acceptPendingIntent = PendingIntent.getActivity( - context, notificationId + 1, acceptIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE - ); - } else { - acceptPendingIntent = PendingIntent.getActivity( - context, notificationId + 1, acceptIntent, - PendingIntent.FLAG_UPDATE_CURRENT - ); - } - - // Decline action - directly opens MainActivity - Intent declineIntent = new Intent(context, MainActivity.class); - declineIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); - declineIntent.putExtras(intentData); - declineIntent.putExtra("event", "decline"); - declineIntent.putExtra("videoConfAction", true); - declineIntent.setAction(ACTION_DECLINE + "_" + notificationId); // Unique action to differentiate intents - - PendingIntent declinePendingIntent; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - declinePendingIntent = PendingIntent.getActivity( - context, notificationId + 2, declineIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE - ); - } else { - declinePendingIntent = PendingIntent.getActivity( - context, notificationId + 2, declineIntent, - PendingIntent.FLAG_UPDATE_CURRENT - ); - } - - // Get icons - Resources res = context.getResources(); - String packageName = context.getPackageName(); - int smallIconResId = res.getIdentifier("ic_notification", "drawable", packageName); - - // Build notification - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(smallIconResId) - .setContentTitle("Incoming call") - .setContentText("Video call from " + callerName) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_CALL) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setAutoCancel(false) - .setOngoing(true) - .setFullScreenIntent(fullScreenPendingIntent, true) - .setContentIntent(fullScreenPendingIntent) - .addAction(0, "Decline", declinePendingIntent) - .addAction(0, "Accept", acceptPendingIntent); - - // Set sound for pre-O devices - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); - builder.setSound(ringtoneUri); - } - - // Show notification - if (notificationManager != null) { - notificationManager.notify(notificationId, builder.build()); - Log.d(TAG, "Video call notification displayed with ID: " + notificationId); - } - } - - /** - * Cancels a video call notification. - * - * @param rid The room ID - * @param callerId The caller's user ID - */ - public void cancelCall(String rid, String callerId) { - String notificationIdStr = (rid + callerId).replaceAll("[^A-Za-z0-9]", ""); - int notificationId = notificationIdStr.hashCode(); - - if (notificationManager != null) { - notificationManager.cancel(notificationId); - Log.d(TAG, "Video call notification cancelled with ID: " + notificationId); - } - } - - /** - * Cancels a video call notification by notification ID. - * - * @param notificationId The notification ID - */ - public static void cancelById(Context context, int notificationId) { - NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - if (manager != null) { - manager.cancel(notificationId); - Log.d(TAG, "Video call notification cancelled with ID: " + notificationId); - } - } -} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.kt new file mode 100644 index 00000000000..b783e33e1b0 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.kt @@ -0,0 +1,210 @@ +package chat.rocket.reactnative.notification + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.AudioAttributes +import android.media.RingtoneManager +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.core.app.NotificationCompat +import chat.rocket.reactnative.MainActivity + +/** + * Handles video conference call notifications with call-style UI. + * Displays incoming call notifications with Accept/Decline actions. + */ +class VideoConfNotification(private val context: Context) { + + companion object { + private const val TAG = "RocketChat.VideoConf" + + const val CHANNEL_ID = "video-conf-call" + const val CHANNEL_NAME = "Video Calls" + + const val ACTION_ACCEPT = "chat.rocket.reactnative.ACTION_VIDEO_CONF_ACCEPT" + const val ACTION_DECLINE = "chat.rocket.reactnative.ACTION_VIDEO_CONF_DECLINE" + const val EXTRA_NOTIFICATION_DATA = "notification_data" + + /** + * Cancels a video call notification by notification ID. + */ + @JvmStatic + fun cancelById(context: Context, notificationId: Int) { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + manager?.cancel(notificationId) + Log.d(TAG, "Video call notification cancelled with ID: $notificationId") + } + } + + private val notificationManager: NotificationManager? = + context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + + init { + createNotificationChannel() + } + + /** + * Creates the notification channel for video calls with high importance and ringtone sound. + */ + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Incoming video conference calls" + enableLights(true) + enableVibration(true) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + + // Set ringtone sound + val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + val audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .build() + setSound(ringtoneUri, audioAttributes) + } + + notificationManager?.createNotificationChannel(channel) + } + } + + /** + * Displays an incoming video call notification. + * + * @param bundle The notification data bundle + * @param ejson The parsed notification payload + */ + fun showIncomingCall(bundle: Bundle, ejson: Ejson) { + val rid = ejson.rid + // Video conf uses 'caller' field, regular messages use 'sender' + val callerId: String + val callerName: String + + if (ejson.caller != null) { + callerId = ejson.caller._id ?: "" + callerName = ejson.caller.name ?: "Unknown" + } else if (ejson.sender != null) { + // Fallback to sender if caller is not present + callerId = ejson.sender._id ?: "" + callerName = ejson.sender.name ?: ejson.senderName ?: "Unknown" + } else { + callerId = "" + callerName = "Unknown" + } + + // Generate unique notification ID from rid + callerId + val notificationIdStr = (rid + callerId).replace(Regex("[^A-Za-z0-9]"), "") + val notificationId = notificationIdStr.hashCode() + + Log.d(TAG, "Showing incoming call notification from: $callerName") + + // Create intent data for actions - include all required fields for JS + val intentData = Bundle().apply { + putString("rid", rid ?: "") + putString("notificationType", "videoconf") + putString("callerId", callerId) + putString("callerName", callerName) + putString("host", ejson.host ?: "") + putString("callId", ejson.callId ?: "") + putString("ejson", bundle.getString("ejson", "{}")) + putInt("notificationId", notificationId) + } + + Log.d(TAG, "Intent data - rid: $rid, callerId: $callerId, host: ${ejson.host}, callId: ${ejson.callId}") + + // Full screen intent - opens app when notification is tapped + val fullScreenIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtras(intentData) + putExtra("event", "default") + } + + val fullScreenPendingIntent = createPendingIntent(notificationId, fullScreenIntent) + + // Accept action - directly opens MainActivity (Android 12+ blocks trampoline pattern) + val acceptIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtras(intentData) + putExtra("event", "accept") + putExtra("videoConfAction", true) + action = "${ACTION_ACCEPT}_$notificationId" // Unique action to differentiate intents + } + + val acceptPendingIntent = createPendingIntent(notificationId + 1, acceptIntent) + + // Decline action - directly opens MainActivity + val declineIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtras(intentData) + putExtra("event", "decline") + putExtra("videoConfAction", true) + action = "${ACTION_DECLINE}_$notificationId" // Unique action to differentiate intents + } + + val declinePendingIntent = createPendingIntent(notificationId + 2, declineIntent) + + // Get icons + val packageName = context.packageName + val smallIconResId = context.resources.getIdentifier("ic_notification", "drawable", packageName) + + // Build notification + val builder = NotificationCompat.Builder(context, CHANNEL_ID).apply { + setSmallIcon(smallIconResId) + setContentTitle("Incoming call") + setContentText("Video call from $callerName") + priority = NotificationCompat.PRIORITY_HIGH + setCategory(NotificationCompat.CATEGORY_CALL) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + setAutoCancel(false) + setOngoing(true) + setFullScreenIntent(fullScreenPendingIntent, true) + setContentIntent(fullScreenPendingIntent) + addAction(0, "Decline", declinePendingIntent) + addAction(0, "Accept", acceptPendingIntent) + } + + // Set sound for pre-O devices + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + builder.setSound(ringtoneUri) + } + + // Show notification + notificationManager?.notify(notificationId, builder.build()) + Log.d(TAG, "Video call notification displayed with ID: $notificationId") + } + + /** + * Creates a PendingIntent with appropriate flags for the Android version. + */ + private fun createPendingIntent(requestCode: Int, intent: Intent): PendingIntent { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + return PendingIntent.getActivity(context, requestCode, intent, flags) + } + + /** + * Cancels a video call notification. + * + * @param rid The room ID + * @param callerId The caller's user ID + */ + fun cancelCall(rid: String, callerId: String) { + val notificationIdStr = (rid + callerId).replace(Regex("[^A-Za-z0-9]"), "") + val notificationId = notificationIdStr.hashCode() + + notificationManager?.cancel(notificationId) + Log.d(TAG, "Video call notification cancelled with ID: $notificationId") + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfPackage.java b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfPackage.java deleted file mode 100644 index 0575365d14b..00000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfPackage.java +++ /dev/null @@ -1,32 +0,0 @@ -package chat.rocket.reactnative.notification; - -import androidx.annotation.NonNull; - -import com.facebook.react.ReactPackage; -import com.facebook.react.bridge.NativeModule; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.uimanager.ViewManager; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** - * React Native package for video conference notification module. - */ -public class VideoConfPackage implements ReactPackage { - - @NonNull - @Override - public List createNativeModules(@NonNull ReactApplicationContext reactContext) { - List modules = new ArrayList<>(); - modules.add(new VideoConfModule(reactContext)); - return modules; - } - - @NonNull - @Override - public List createViewManagers(@NonNull ReactApplicationContext reactContext) { - return Collections.emptyList(); - } -} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfTurboPackage.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfTurboPackage.kt new file mode 100644 index 00000000000..42e3f3eb211 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfTurboPackage.kt @@ -0,0 +1,37 @@ +package chat.rocket.reactnative.notification + +import com.facebook.react.TurboReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +/** + * React Native TurboModule package for video conference notification module. + */ +class VideoConfTurboPackage : TurboReactPackage() { + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == NativeVideoConfSpec.NAME) { + VideoConfModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + mapOf( + NativeVideoConfSpec.NAME to ReactModuleInfo( + NativeVideoConfSpec.NAME, + NativeVideoConfSpec.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + true // isTurboModule + ) + ) + } + } +} diff --git a/app/lib/native/NativeVideoConfAndroid.ts b/app/lib/native/NativeVideoConfAndroid.ts new file mode 100644 index 00000000000..90346d97cb8 --- /dev/null +++ b/app/lib/native/NativeVideoConfAndroid.ts @@ -0,0 +1,9 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +export interface Spec extends TurboModule { + getPendingAction(): Promise; + clearPendingAction(): void; +} + +export default TurboModuleRegistry.get('VideoConfModule'); diff --git a/app/lib/notifications/videoConf/getInitialNotification.ts b/app/lib/notifications/videoConf/getInitialNotification.ts index 12f92031389..25acc7cfe79 100644 --- a/app/lib/notifications/videoConf/getInitialNotification.ts +++ b/app/lib/notifications/videoConf/getInitialNotification.ts @@ -1,11 +1,10 @@ import * as Notifications from 'expo-notifications'; import EJSON from 'ejson'; -import { NativeModules, Platform } from 'react-native'; +import { Platform } from 'react-native'; import { deepLinkingClickCallPush } from '../../../actions/deepLinking'; import { store } from '../../store/auxStore'; - -const { VideoConfModule } = NativeModules; +import NativeVideoConfModule from '../../native/NativeVideoConfAndroid'; /** * Check for pending video conference actions from native notification handling. @@ -13,9 +12,9 @@ const { VideoConfModule } = NativeModules; */ export const getInitialNotification = async (): Promise => { // Android: Check native module for pending action - if (Platform.OS === 'android' && VideoConfModule) { + if (Platform.OS === 'android' && NativeVideoConfModule) { try { - const pendingAction = await VideoConfModule.getPendingAction(); + const pendingAction = await NativeVideoConfModule.getPendingAction(); if (pendingAction) { const data = JSON.parse(pendingAction); if (data?.notificationType === 'videoconf') { From 7f98e86279f940ca47325f583f8dc61d3b79f019 Mon Sep 17 00:00:00 2001 From: diegolmello Date: Thu, 11 Dec 2025 18:37:02 +0000 Subject: [PATCH 12/12] chore: format code and fix lint issues [skip ci] --- app/sagas/deepLinking.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index d1d57dedf44..7afcadc7b92 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -46,7 +46,7 @@ const waitForNavigation = () => { if (Navigation.navigationRef.current) { return Promise.resolve(); } - return new Promise(resolve => { + return new Promise((resolve) => { const listener = () => { emitter.off('navigationReady', listener); resolve();