mattermost-mobile-upstream/app/init/push_notifications.ts
Carlos Garcia ad6b834d34
Mm 68296 audit ephemeral mode events (#10139)
* consider all error paths when wiping server artifacts

* log events related to ephemeral mode to keep an audit log server side

* adds playbook run delete count to cleanup audit events

* sends signature as proof for auditing wipe events

* fixes some missed data in audit events

* throw and log errors while enqueuing audit events

* report exact deleted count on audit event

* test terminate session prunes audit event queue in all cases

* assert on queue lenght in tests

* fetch count inside write transaction to return accurate value

* remove delete count expectations from tests since unsafe where clauses are not supported by loki
2026-09-23 13:48:14 +02:00

396 lines
17 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import RNUtils from '@mattermost/rnutils/src';
import {defineMessages} from 'react-intl';
import {AppState, DeviceEventEmitter, Platform, type EmitterSubscription} from 'react-native';
import {
Notification,
NotificationAction,
NotificationBackgroundFetchResult,
NotificationCategory,
type NotificationCompletion,
Notifications,
type NotificationTextInput,
type Registered,
type RegistrationError,
} from 'react-native-notifications';
import {requestNotifications} from 'react-native-permissions';
import {storeDeviceToken} from '@actions/app/global';
import {markChannelAsViewed} from '@actions/local/channel';
import {enqueueAuditEvent} from '@actions/local/ephemeral_mode/audit_queue';
import {updateThread} from '@actions/local/thread';
import {backgroundNotification, openNotification} from '@actions/remote/notifications';
import {isCallsStartedMessage} from '@calls/utils';
import {Device, Events, PushNotification, Screens} from '@constants';
import {EphemeralModeAuditEventKind} from '@constants/ephemeral_mode';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getLocalizedMessage} from '@i18n';
import EphemeralModeManager from '@managers/ephemeral_mode_manager';
import {getServerDisplayName} from '@queries/app/servers';
import {getCurrentChannelId} from '@queries/servers/system';
import {getIsCRTEnabled, getThreadById} from '@queries/servers/thread';
import EphemeralStore from '@store/ephemeral_store';
import InAppNotificationStore from '@store/in_app_notification_store';
import {NavigationStore} from '@store/navigation_store';
import {getFullErrorMessage} from '@utils/errors';
import {isBetaApp} from '@utils/general';
import {isMainActivity, isTablet} from '@utils/helpers';
import {logDebug, logError, logInfo, logWarning} from '@utils/log';
import {convertToNotificationData} from '@utils/notification';
const messages = defineMessages({
replyTitle: {
id: 'mobile.push_notification_reply.title',
defaultMessage: 'Reply',
},
replyButton: {
id: 'mobile.push_notification_reply.button',
defaultMessage: 'Send',
},
replyPlaceholder: {
id: 'mobile.push_notification_reply.placeholder',
defaultMessage: 'Write a reply...',
},
});
class PushNotificationsSingleton {
configured = false;
subscriptions?: EmitterSubscription[];
init(register: boolean) {
logDebug('PushNotifications: Initializing');
this.subscriptions?.forEach((v) => v.remove());
this.subscriptions = [
Notifications.events().registerNotificationOpened(this.onNotificationOpened),
Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered),
Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground),
Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground),
Notifications.events().registerRemoteNotificationsRegistrationFailed(this.NotificationsRegistrationFailed),
Notifications.events().registerRemoteNotificationsRegistrationDenied(this.onRemoteNotificationsRegistrationDenied),
];
if (register) {
this.registerIfNeeded();
}
}
cleanup() {
this.subscriptions?.forEach((v) => v.remove());
this.subscriptions = [];
}
async registerIfNeeded() {
const isRegistered = await Notifications.isRegisteredForRemoteNotifications();
if (!isRegistered) {
await requestNotifications(['alert', 'sound', 'badge']);
}
Notifications.registerRemoteNotifications();
// Never call Notifications.registerPushKit() — PushKit is owned by
// @mattermost/calls-native; a second PKPushRegistry would break it.
}
createReplyCategory = () => {
const replyTitle = getLocalizedMessage(DEFAULT_LOCALE, messages.replyTitle.id);
const replyButton = getLocalizedMessage(DEFAULT_LOCALE, messages.replyButton.id);
const replyPlaceholder = getLocalizedMessage(DEFAULT_LOCALE, messages.replyPlaceholder.id);
const replyTextInput: NotificationTextInput = {buttonTitle: replyButton, placeholder: replyPlaceholder};
const replyAction = new NotificationAction(PushNotification.REPLY_ACTION, 'background', replyTitle, true, replyTextInput);
return new NotificationCategory(PushNotification.CATEGORY, [replyAction]);
};
getServerUrlFromNotification = async (notification: NotificationWithData) => {
const {payload} = notification;
let serverUrl = payload?.server_url;
if (!serverUrl && payload?.server_id) {
serverUrl = await DatabaseManager.getServerUrlFromIdentifier(payload.server_id);
}
return serverUrl;
};
handleClearNotification = async (notification: NotificationWithData) => {
const {payload} = notification;
const serverUrl = await this.getServerUrlFromNotification(notification);
if (serverUrl && payload?.channel_id) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (database) {
const isCRTEnabled = await getIsCRTEnabled(database);
if (isCRTEnabled && payload.root_id) {
const thread = await getThreadById(database, payload.root_id);
if (thread?.isFollowing) {
const data: Partial<ThreadWithViewedAt> = {
unread_mentions: 0,
unread_replies: 0,
last_viewed_at: Date.now(),
};
await updateThread(serverUrl, payload.root_id, data);
}
} else {
await markChannelAsViewed(serverUrl, payload.channel_id);
}
}
}
};
handleInAppNotification = async (serverUrl: string, notification: NotificationWithData) => {
const {payload} = notification;
// Do not show overlay if this is a call-started message (the call_notification will alert the user)
if (isCallsStartedMessage(payload)) {
return;
}
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (database) {
const isTabletDevice = isTablet();
const displayName = await getServerDisplayName(serverUrl);
const channelId = await getCurrentChannelId(database);
const isCRTEnabled = await getIsCRTEnabled(database);
let serverName;
if (Object.keys(DatabaseManager.serverDatabases).length > 1) {
serverName = displayName;
}
const isThreadNotification = Boolean(payload?.root_id);
const isSameChannelNotification = payload?.channel_id === channelId;
const isSameThreadNotification = isThreadNotification && payload?.root_id === EphemeralStore.getCurrentThreadId();
let isInChannelScreen = NavigationStore.getVisibleScreen() === Screens.CHANNEL;
if (isTabletDevice) {
isInChannelScreen = NavigationStore.getVisibleScreen() === Screens.CHANNEL_LIST;
}
const isInThreadScreen = NavigationStore.getVisibleScreen() === Screens.THREAD;
// Conditions:
// 1. If not in channel screen or thread screen, show the notification
const condition1 = !isInChannelScreen && !isInThreadScreen;
// 2. If is in channel screen,
// - Show notification of other channels
// or
// - Show notification if CRT is enabled and it's a thread notification (doesn't matter if it's the same channel)
const condition2 = isInChannelScreen && (!isSameChannelNotification || (isCRTEnabled && isThreadNotification));
// 3. If is in thread screen,
// - Show the notification if it doesn't belong to the thread
const condition3 = isInThreadScreen && !isSameThreadNotification;
if (condition1 || condition2 || condition3) {
InAppNotificationStore.show(notification, serverUrl, serverName);
}
}
};
handleMessageNotification = async (notification: NotificationWithData) => {
const {payload, foreground, userInteraction} = notification;
const serverUrl = await this.getServerUrlFromNotification(notification);
if (serverUrl) {
if (foreground) {
// Move this to a local action
this.handleInAppNotification(serverUrl, notification);
} else if (userInteraction && !payload?.userInfo?.local) {
// Handle notification tapped
openNotification(serverUrl, notification);
} else {
// Awaited so the caller can keep the app alive until the DB write
// completes (see onNotificationReceivedBackground).
await backgroundNotification(serverUrl, notification);
}
}
};
handleSessionNotification = async (notification: NotificationWithData) => {
logInfo('Session expired notification');
const serverUrl = await this.getServerUrlFromNotification(notification);
if (serverUrl) {
if (notification.userInteraction) {
DeviceEventEmitter.emit(Events.SESSION_EXPIRED, serverUrl);
} else {
const auditEventId = await this.enqueueSessionWipeAuditEvent(serverUrl, notification.payload?.signature);
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl, auditEventId});
}
}
};
enqueueSessionWipeAuditEvent = async (serverUrl: string, signature?: string): Promise<string | undefined> => {
if (!EphemeralModeManager.isEphemeralModeEnabled(serverUrl)) {
logDebug('enqueueSessionWipeAuditEvent: ephemeral mode not enabled for', serverUrl);
return undefined;
}
if (!signature) {
logDebug('enqueueSessionWipeAuditEvent: no signature for', serverUrl);
return undefined;
}
try {
return await enqueueAuditEvent(serverUrl, {
kind: EphemeralModeAuditEventKind.SessionWipe,
signature,
occurredAt: Date.now(),
});
} catch (error) {
logError('enqueueSessionWipeAuditEvent', getFullErrorMessage(error));
return undefined;
}
};
processNotification = async (notification: NotificationWithData) => {
const {payload} = notification;
if (payload) {
switch (payload.type) {
case PushNotification.NOTIFICATION_TYPE.CLEAR:
await this.handleClearNotification(notification);
break;
case PushNotification.NOTIFICATION_TYPE.MESSAGE:
await this.handleMessageNotification(notification);
break;
case PushNotification.NOTIFICATION_TYPE.SESSION:
await this.handleSessionNotification(notification);
break;
}
}
};
localNotification = (notification: Notification) => {
Notifications.postLocalNotification(notification);
};
// This triggers when a notification is tapped and the app was in the background (iOS)
onNotificationOpened = (incoming: Notification, completion: () => void) => {
const notification = convertToNotificationData(incoming, false);
notification.userInteraction = true;
this.processNotification(notification);
completion();
};
// This triggers when the app was in the background (iOS)
onNotificationReceivedBackground = async (incoming: Notification, completion: (response: NotificationBackgroundFetchResult) => void) => {
if (incoming.payload.verified === 'false') {
logDebug('not handling background notification because it was not verified, ackId=', incoming.payload.ackId);
// Always finish the iOS background completion handler, or the OS
// keeps the app awake until it times out and then terminates it.
completion(NotificationBackgroundFetchResult.NO_DATA);
return;
}
const notification = convertToNotificationData(incoming, false);
// Wait until the notification is fully processed (including its DB
// read/write) before signaling completion. iOS keeps the app alive
// until the fetch completion handler is called; calling it early lets
// the OS re-suspend the app while a WatermelonDB statement still holds
// the shared App Group SQLite lock, which triggers a RUNNINGBOARD
// 0xdead10cc termination. completion() must run on every path,
// including failures, for the same reason.
try {
await this.processNotification(notification);
completion(NotificationBackgroundFetchResult.NEW_DATA);
} catch (error) {
logWarning('onNotificationReceivedBackground', error);
completion(NotificationBackgroundFetchResult.FAILED);
}
};
// This triggers when the app was in the foreground (Android and iOS)
// Also triggers when the app was in the background (Android)
onNotificationReceivedForeground = (incoming: Notification, completion: (response: NotificationCompletion) => void) => {
if (incoming.payload.verified === 'false') {
logDebug('not handling foreground notification because it was not verified, ackId=', incoming.payload.ackId);
return;
}
const notification = convertToNotificationData(incoming, false);
if (AppState.currentState !== 'inactive') {
notification.foreground = AppState.currentState === 'active' && isMainActivity();
this.processNotification(notification);
}
// Always play a sound, except when this is a foreground notification about a call
const sound = !(notification.foreground && isCallsStartedMessage(notification.payload));
completion({alert: false, sound, badge: true});
};
onRemoteNotificationsRegistered = async (event: Registered) => {
if (!this.configured) {
this.configured = true;
const {deviceToken} = event;
let prefix;
if (Platform.OS === 'ios') {
prefix = Device.PUSH_NOTIFY_APPLE_REACT_NATIVE;
if (isBetaApp) {
prefix = `${prefix}beta`;
}
} else {
prefix = Device.PUSH_NOTIFY_ANDROID_REACT_NATIVE;
}
const token = `${prefix}-v2:${deviceToken}`;
storeDeviceToken(token);
const redactedDeviceId = deviceToken.substring(0, 16);
logDebug('Notification token registered', `${prefix}-v2:${redactedDeviceId}...`);
// Store the device token in the default database
this.requestNotificationReplyPermissions();
}
return null;
};
onRemoteNotificationsRegistrationDenied = () => {
logDebug('Notification registration denied');
};
NotificationsRegistrationFailed = (event: RegistrationError) => {
logDebug('Notification registration failed', event);
};
removeChannelNotifications = async (serverUrl: string, channelId: string) => {
RNUtils.removeChannelNotifications(serverUrl, channelId);
};
removeServerNotifications = (serverUrl: string) => {
RNUtils.removeServerNotifications(serverUrl);
};
removeThreadNotifications = async (serverUrl: string, threadId: string) => {
RNUtils.removeThreadNotifications(serverUrl, threadId);
};
requestNotificationReplyPermissions = () => {
if (Platform.OS === 'ios') {
const replyCategory = this.createReplyCategory();
Notifications.setCategories([replyCategory]);
}
};
scheduleNotification = (notification: Notification) => {
if (notification.fireDate) {
if (Platform.OS === 'ios') {
notification.fireDate = new Date(notification.fireDate).toISOString();
}
return Notifications.postLocalNotification(notification);
}
return 0;
};
cancelScheduleNotification = (notificationId: number) => {
Notifications.cancelLocalNotification(notificationId);
};
}
const PushNotifications = new PushNotificationsSingleton();
export default PushNotifications;