mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-05 02:18:56 +00:00
* MM-69457: Stop logging PII in exportable app logs App logs are written to rolling files users can export via "Report a Problem" and the same args are forwarded as Sentry breadcrumbs. Several call sites logged PII into both destinations. Fix the known high-risk sites and prevent regressions: - push token, incoming-call payload (callerName), channel display names, and raw websocket url/event are no longer logged - wrap stray raw error/event logging with getFullErrorMessage() - add a no-restricted-syntax ESLint rule flagging PII (names/emails/ usernames) and credentials (tokens/passwords/secrets) in log* calls - document Logging & PII guidance in CLAUDE.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAfpPfhVfZSv3veGDQfPYQ * Update websocket error test for PII-safe logging The error handler no longer logs the raw url/event; assert it logs code/reason instead and verify the url is not logged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAfpPfhVfZSv3veGDQfPYQ * fix: address PR review feedback on PII-safe logging - Add server URL to websocket error log for multi-server debuggability - Redact device token to first 4 chars instead of omitting entirely - Update websocket error test to match new log signature Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address PR review feedback on PII-safe logging - files.ts: add hasRejectionReason boolean to initial event log - websocket/index.ts: replace this.url (full WS URL with connection_id) with this.serverUrl in all log calls; update tests to match - calls_native.ts: split combined serverId/channelId guard into two separate if blocks for clearer per-field log messages - push_notifications.ts: expand redacted token prefix from 4 to 16 chars Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: revert websocket lifecycle logs back to full URL The full WebSocket URL (with path and connection_id) provides more diagnostic value than just the server base URL for lifecycle events (connect, reconnect, close, missed events). Only the onError handler keeps this.serverUrl, which was the original targeted fix. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * cleanup --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
368 lines
15 KiB
TypeScript
368 lines
15 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 {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 DatabaseManager from '@database/manager';
|
|
import {DEFAULT_LOCALE, getLocalizedMessage} from '@i18n';
|
|
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 {isBetaApp} from '@utils/general';
|
|
import {isMainActivity, isTablet} from '@utils/helpers';
|
|
import {logDebug, 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 {
|
|
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl});
|
|
}
|
|
}
|
|
};
|
|
|
|
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;
|