mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-04 18:08:54 +00:00
* 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
396 lines
17 KiB
TypeScript
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;
|