Skip to content

Commit d3fc951

Browse files
committed
fix(videoconf): add ios push deduplication state checks
Fixes stale duplicate notification handling on cold boot causing users to blindly navigate into ended video conference sessions infinitely. Closes #7015
1 parent 48c5fc2 commit d3fc951

File tree

6 files changed

+73
-6
lines changed

6 files changed

+73
-6
lines changed

app/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,10 @@ export default class Root extends React.Component<{}, IState> {
139139
if ('configured' in notification) {
140140
return;
141141
}
142-
onNotification(notification);
142+
const result = onNotification(notification);
143+
if (result?.catch) {
144+
result.catch(e => console.warn('app/index.tsx: onNotification error', e));
145+
}
143146
return;
144147
}
145148

app/lib/notifications/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import EJSON from 'ejson';
22
import { Platform } from 'react-native';
33

4+
import { isPushVideoConfAlreadyProcessed } from './videoConf/deduplication';
45
import { appInit } from '../../actions/app';
56
import { deepLinkingClickCallPush, deepLinkingOpen } from '../../actions/deepLinking';
67
import { type INotification, SubscriptionType } from '../../definitions';
@@ -16,14 +17,18 @@ interface IEjson {
1617
messageId: string;
1718
}
1819

19-
export const onNotification = (push: INotification): void => {
20+
export const onNotification = async (push: INotification): Promise<void> => {
2021
const identifier = String(push?.payload?.action?.identifier);
2122

2223
// Handle video conf notification actions (Accept/Decline buttons)
2324
if (identifier === 'ACCEPT_ACTION' || identifier === 'DECLINE_ACTION') {
2425
if (push?.payload?.ejson) {
2526
try {
2627
const notification = EJSON.parse(push.payload.ejson);
28+
const currentId = push.identifier || push.payload?.notId;
29+
if (await isPushVideoConfAlreadyProcessed(currentId)) {
30+
return;
31+
}
2732
store.dispatch(
2833
deepLinkingClickCallPush({ ...notification, event: identifier === 'ACCEPT_ACTION' ? 'accept' : 'decline' })
2934
);
@@ -40,6 +45,10 @@ export const onNotification = (push: INotification): void => {
4045

4146
// Handle video conf notification tap (default action) - treat as accept
4247
if (notification?.notificationType === 'videoconf') {
48+
const currentId = push.identifier || push.payload?.notId;
49+
if (await isPushVideoConfAlreadyProcessed(currentId)) {
50+
return;
51+
}
4352
store.dispatch(deepLinkingClickCallPush({ ...notification, event: 'accept' }));
4453
return;
4554
}
@@ -122,7 +131,10 @@ export const checkPendingNotification = async (): Promise<void> => {
122131
},
123132
identifier: notificationData.notId || ''
124133
};
125-
onNotification(notification);
134+
const result = onNotification(notification);
135+
if (result?.catch) {
136+
result.catch(e => console.warn('[notifications/index.ts] onNotification error:', e));
137+
}
126138
} catch (e) {
127139
console.warn('[notifications/index.ts] Failed to parse pending notification:', e);
128140
}

app/lib/notifications/push.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,9 @@ const registerForPushNotifications = async (): Promise<string | null> => {
159159
}
160160
};
161161

162-
export const pushNotificationConfigure = (onNotification: (notification: INotification) => void): Promise<any> => {
162+
export const pushNotificationConfigure = (
163+
onNotification: (notification: INotification) => Promise<void> | void
164+
): Promise<any> => {
163165
if (configured) {
164166
return Promise.resolve({ configured: true });
165167
}
@@ -207,10 +209,16 @@ export const pushNotificationConfigure = (onNotification: (notification: INotifi
207209
if (isIOS) {
208210
const { background } = reduxStore.getState().app;
209211
if (background) {
210-
onNotification(notification);
212+
const result = onNotification(notification);
213+
if (result?.catch) {
214+
result.catch((e: any) => console.warn('[push.ts] Notification handler error:', e));
215+
}
211216
}
212217
} else {
213-
onNotification(notification);
218+
const result = onNotification(notification);
219+
if (result?.catch) {
220+
result.catch((e: any) => console.warn('[push.ts] Notification handler error:', e));
221+
}
214222
}
215223
});
216224

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
3+
const processingIds = new Set<string>();
4+
5+
export const isPushVideoConfAlreadyProcessed = async (notificationId?: string | null): Promise<boolean> => {
6+
if (!notificationId) {
7+
return false; // Can't dedup without an ID
8+
}
9+
10+
// 1. Synchronously check and lock in-memory to prevent TOCTOU race condition
11+
if (processingIds.has(notificationId)) {
12+
return true;
13+
}
14+
processingIds.add(notificationId);
15+
16+
// 2. Check persistent storage (for cold boot scenarios)
17+
try {
18+
const lastId = await AsyncStorage.getItem('lastProcessedVideoConfNotificationId');
19+
if (lastId === notificationId) {
20+
return true;
21+
}
22+
23+
// 3. Persist new ID
24+
await AsyncStorage.setItem('lastProcessedVideoConfNotificationId', notificationId);
25+
} catch (e) {
26+
// Ignore storage errors, we still have the in-memory lock
27+
console.warn('Error reading/writing video conf dedup state', e);
28+
}
29+
30+
return false;
31+
};

app/lib/notifications/videoConf/getInitialNotification.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as Notifications from 'expo-notifications';
22
import EJSON from 'ejson';
33
import { DeviceEventEmitter, Platform } from 'react-native';
44

5+
import { isPushVideoConfAlreadyProcessed } from './deduplication';
56
import { deepLinkingClickCallPush } from '../../../actions/deepLinking';
67
import { store } from '../../store/auxStore';
78
import NativeVideoConfModule from '../../native/NativeVideoConfAndroid';
@@ -66,6 +67,10 @@ export const getInitialNotification = async (): Promise<boolean> => {
6667
if (payload.ejson) {
6768
const ejsonData = EJSON.parse(payload.ejson);
6869
if (ejsonData?.notificationType === 'videoconf') {
70+
const notificationId = notification.request.identifier;
71+
if (await isPushVideoConfAlreadyProcessed(notificationId)) {
72+
return false;
73+
}
6974
// Accept/Decline actions or default tap (treat as accept)
7075
let event = 'accept';
7176
if (actionIdentifier === 'DECLINE_ACTION') {

app/sagas/deepLinking.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { all, call, delay, put, select, take, takeLatest } from 'redux-saga/effects';
22

3+
import { isPushVideoConfAlreadyProcessed } from '../lib/notifications/videoConf/deduplication';
4+
35
import { shareSetParams } from '../actions/share';
46
import * as types from '../actions/actionsTypes';
57
import { appInit, appStart, appReady } from '../actions/app';
@@ -245,11 +247,17 @@ const handleNavigateCallRoom = function* handleNavigateCallRoom({ params }) {
245247

246248
const handleClickCallPush = function* handleClickCallPush({ params }) {
247249
let { host } = params;
250+
const notId = params.notId || params.identifier || params.payload?.notId || params.push?.identifier || params.push?.payload?.notId;
248251

249252
if (!host) {
250253
return;
251254
}
252255

256+
const isProcessed = yield call(isPushVideoConfAlreadyProcessed, notId);
257+
if (isProcessed) {
258+
return;
259+
}
260+
253261
if (host.slice(-1) === '/') {
254262
host = host.slice(0, host.length - 1);
255263
}

0 commit comments

Comments
 (0)