diff --git a/app/index.tsx b/app/index.tsx index 88f8347445f..cc8cc801c8b 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -139,7 +139,10 @@ export default class Root extends React.Component<{}, IState> { if ('configured' in notification) { return; } - onNotification(notification); + const result = onNotification(notification); + if (result?.catch) { + result.catch(e => console.warn('app/index.tsx: onNotification error', e)); + } return; } diff --git a/app/lib/notifications/index.ts b/app/lib/notifications/index.ts index 47707cf964c..08f910a9136 100644 --- a/app/lib/notifications/index.ts +++ b/app/lib/notifications/index.ts @@ -1,6 +1,7 @@ import EJSON from 'ejson'; import { Platform } from 'react-native'; +import { isPushVideoConfAlreadyProcessed } from './videoConf/deduplication'; import { appInit } from '../../actions/app'; import { deepLinkingClickCallPush, deepLinkingOpen } from '../../actions/deepLinking'; import { type INotification, SubscriptionType } from '../../definitions'; @@ -16,7 +17,7 @@ interface IEjson { messageId: string; } -export const onNotification = (push: INotification): void => { +export const onNotification = async (push: INotification): Promise => { const identifier = String(push?.payload?.action?.identifier); // Handle video conf notification actions (Accept/Decline buttons) @@ -24,6 +25,10 @@ export const onNotification = (push: INotification): void => { if (push?.payload?.ejson) { try { const notification = EJSON.parse(push.payload.ejson); + const currentId = push.identifier || push.payload?.notId; + if (await isPushVideoConfAlreadyProcessed(currentId)) { + return; + } store.dispatch( deepLinkingClickCallPush({ ...notification, event: identifier === 'ACCEPT_ACTION' ? 'accept' : 'decline' }) ); @@ -40,6 +45,10 @@ export const onNotification = (push: INotification): void => { // Handle video conf notification tap (default action) - treat as accept if (notification?.notificationType === 'videoconf') { + const currentId = push.identifier || push.payload?.notId; + if (await isPushVideoConfAlreadyProcessed(currentId)) { + return; + } store.dispatch(deepLinkingClickCallPush({ ...notification, event: 'accept' })); return; } @@ -122,7 +131,10 @@ export const checkPendingNotification = async (): Promise => { }, identifier: notificationData.notId || '' }; - onNotification(notification); + const result = onNotification(notification); + if (result?.catch) { + result.catch(e => console.warn('[notifications/index.ts] onNotification error:', e)); + } } catch (e) { console.warn('[notifications/index.ts] Failed to parse pending notification:', e); } diff --git a/app/lib/notifications/push.ts b/app/lib/notifications/push.ts index beae1c10db6..9803eef07ef 100644 --- a/app/lib/notifications/push.ts +++ b/app/lib/notifications/push.ts @@ -159,7 +159,9 @@ const registerForPushNotifications = async (): Promise => { } }; -export const pushNotificationConfigure = (onNotification: (notification: INotification) => void): Promise => { +export const pushNotificationConfigure = ( + onNotification: (notification: INotification) => Promise | void +): Promise => { if (configured) { return Promise.resolve({ configured: true }); } @@ -207,10 +209,16 @@ export const pushNotificationConfigure = (onNotification: (notification: INotifi if (isIOS) { const { background } = reduxStore.getState().app; if (background) { - onNotification(notification); + const result = onNotification(notification); + if (result?.catch) { + result.catch((e: any) => console.warn('[push.ts] Notification handler error:', e)); + } } } else { - onNotification(notification); + const result = onNotification(notification); + if (result?.catch) { + result.catch((e: any) => console.warn('[push.ts] Notification handler error:', e)); + } } }); diff --git a/app/lib/notifications/videoConf/deduplication.ts b/app/lib/notifications/videoConf/deduplication.ts new file mode 100644 index 00000000000..341a5f10339 --- /dev/null +++ b/app/lib/notifications/videoConf/deduplication.ts @@ -0,0 +1,31 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const processingIds = new Set(); + +export const isPushVideoConfAlreadyProcessed = async (notificationId?: string | null): Promise => { + if (!notificationId) { + return false; // Can't dedup without an ID + } + + // 1. Synchronously check and lock in-memory to prevent TOCTOU race condition + if (processingIds.has(notificationId)) { + return true; + } + processingIds.add(notificationId); + + // 2. Check persistent storage (for cold boot scenarios) + try { + const lastId = await AsyncStorage.getItem('lastProcessedVideoConfNotificationId'); + if (lastId === notificationId) { + return true; + } + + // 3. Persist new ID + await AsyncStorage.setItem('lastProcessedVideoConfNotificationId', notificationId); + } catch (e) { + // Ignore storage errors, we still have the in-memory lock + console.warn('Error reading/writing video conf dedup state', e); + } + + return false; +}; diff --git a/app/lib/notifications/videoConf/getInitialNotification.ts b/app/lib/notifications/videoConf/getInitialNotification.ts index b70b45b0adc..00b706dbb01 100644 --- a/app/lib/notifications/videoConf/getInitialNotification.ts +++ b/app/lib/notifications/videoConf/getInitialNotification.ts @@ -2,6 +2,7 @@ import * as Notifications from 'expo-notifications'; import EJSON from 'ejson'; import { DeviceEventEmitter, Platform } from 'react-native'; +import { isPushVideoConfAlreadyProcessed } from './deduplication'; import { deepLinkingClickCallPush } from '../../../actions/deepLinking'; import { store } from '../../store/auxStore'; import NativeVideoConfModule from '../../native/NativeVideoConfAndroid'; @@ -66,6 +67,10 @@ export const getInitialNotification = async (): Promise => { if (payload.ejson) { const ejsonData = EJSON.parse(payload.ejson); if (ejsonData?.notificationType === 'videoconf') { + const notificationId = notification.request.identifier; + if (await isPushVideoConfAlreadyProcessed(notificationId)) { + return false; + } // Accept/Decline actions or default tap (treat as accept) let event = 'accept'; if (actionIdentifier === 'DECLINE_ACTION') { diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 7afcadc7b92..9c2c4c74b90 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -1,5 +1,7 @@ import { all, call, delay, put, select, take, takeLatest } from 'redux-saga/effects'; +import { isPushVideoConfAlreadyProcessed } from '../lib/notifications/videoConf/deduplication'; + import { shareSetParams } from '../actions/share'; import * as types from '../actions/actionsTypes'; import { appInit, appStart, appReady } from '../actions/app'; @@ -245,11 +247,17 @@ const handleNavigateCallRoom = function* handleNavigateCallRoom({ params }) { const handleClickCallPush = function* handleClickCallPush({ params }) { let { host } = params; + const notId = params.notId || params.identifier || params.payload?.notId || params.push?.identifier || params.push?.payload?.notId; if (!host) { return; } + const isProcessed = yield call(isPushVideoConfAlreadyProcessed, notId); + if (isProcessed) { + return; + } + if (host.slice(-1) === '/') { host = host.slice(0, host.length - 1); }