Gekidou session expired notification (#6639)

* Fix crash on Android when session expired notification is presented

* react-native-notification patch for schedule fix on android and open local notification on iOS

* Fix android scheduled session notification crash

* patch react-native-navigation to support blur/focus AppState on Android

* remove schedule session expired notification from login entry point

* schedule session expired notification actions

* add session manager

* Handle open session expired notification
This commit is contained in:
Elias Nahum
2022-09-18 06:57:55 -04:00
committed by GitHub
parent af77f74902
commit 4c389a49fa
23 changed files with 541 additions and 222 deletions

View File

@@ -46,6 +46,9 @@ public class CustomPushNotificationHelper {
public static final int MESSAGE_NOTIFICATION_ID = 435345;
public static final String NOTIFICATION_ID = "notificationId";
public static final String NOTIFICATION = "notification";
public static final String PUSH_TYPE_MESSAGE = "message";
public static final String PUSH_TYPE_CLEAR = "clear";
public static final String PUSH_TYPE_SESSION = "session";
private static NotificationChannel mHighImportanceChannel;
private static NotificationChannel mMinImportanceChannel;
@@ -54,17 +57,11 @@ public class CustomPushNotificationHelper {
String message = bundle.getString("message", bundle.getString("body"));
String senderId = bundle.getString("sender_id");
String serverUrl = bundle.getString("server_url");
String type = bundle.getString("type");
if (senderId == null) {
senderId = "sender_id";
}
Bundle userInfoBundle = bundle.getBundle("userInfo");
String senderName = getSenderName(bundle);
if (userInfoBundle != null) {
boolean localPushNotificationTest = userInfoBundle.getBoolean("test");
if (localPushNotificationTest) {
senderName = "Test";
}
}
if (conversationTitle == null || !android.text.TextUtils.isEmpty(senderName.trim())) {
message = removeSenderNameFromMessage(message, senderName);
@@ -75,7 +72,7 @@ public class CustomPushNotificationHelper {
.setKey(senderId)
.setName(senderName);
if (serverUrl != null) {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, senderId))));
} catch (IOException e) {
@@ -246,6 +243,10 @@ public class CustomPushNotificationHelper {
title = bundle.getString("sender_name");
}
if (android.text.TextUtils.isEmpty(title)) {
title = bundle.getString("title", "");
}
return title;
}
@@ -259,14 +260,15 @@ public class CustomPushNotificationHelper {
private static NotificationCompat.MessagingStyle getMessagingStyle(Context context, Bundle bundle) {
NotificationCompat.MessagingStyle messagingStyle;
String senderId = "me";
final String senderId = "me";
final String serverUrl = bundle.getString("server_url");
final String type = bundle.getString("type");
Person.Builder sender = new Person.Builder()
.setKey(senderId)
.setName("Me");
if (serverUrl != null) {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, "me"))));
} catch (IOException e) {
@@ -362,19 +364,6 @@ public class CustomPushNotificationHelper {
}
NotificationChannel notificationChannel = mHighImportanceChannel;
boolean testNotification = false;
boolean localNotification = false;
Bundle userInfoBundle = bundle.getBundle("userInfo");
if (userInfoBundle != null) {
testNotification = userInfoBundle.getBoolean("test");
localNotification = userInfoBundle.getBoolean("local");
}
if (testNotification || localNotification) {
notificationChannel = mMinImportanceChannel;
}
notification.setChannelId(notificationChannel.getId());
}

View File

@@ -26,9 +26,6 @@ import com.wix.reactnativenotifications.core.JsIOHelper;
import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME;
public class CustomPushNotification extends PushNotification {
private static final String PUSH_TYPE_MESSAGE = "message";
private static final String PUSH_TYPE_CLEAR = "clear";
private static final String PUSH_TYPE_SESSION = "session";
private final PushNotificationDataHelper dataHelper;
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
@@ -81,11 +78,11 @@ public class CustomPushNotification extends PushNotification {
}
switch (type) {
case PUSH_TYPE_MESSAGE:
case PUSH_TYPE_SESSION:
case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE:
case CustomPushNotificationHelper.PUSH_TYPE_SESSION:
if (!mAppLifecycleFacade.isAppVisible()) {
boolean createSummary = type.equals(PUSH_TYPE_MESSAGE);
if (type.equals(PUSH_TYPE_MESSAGE)) {
boolean createSummary = type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE);
if (type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE)) {
if (channelId != null) {
Bundle notificationBundle = mNotificationProps.asBundle();
if (serverUrl != null && !isReactInit) {
@@ -107,7 +104,7 @@ public class CustomPushNotification extends PushNotification {
buildNotification(notificationId, createSummary);
}
break;
case PUSH_TYPE_CLEAR:
case CustomPushNotificationHelper.PUSH_TYPE_CLEAR:
NotificationHelper.clearChannelOrThreadNotifications(mContext, mNotificationProps.asBundle());
break;
}

View File

@@ -57,6 +57,12 @@ public class MainActivity extends NavigationActivity {
}
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
getReactGateway().onWindowFocusChanged(hasFocus);
}
/*
https://mattermost.atlassian.net/browse/MM-10601
Required by react-native-hw-keyboard-event

View File

@@ -2,14 +2,11 @@
// See LICENSE.txt for license information.
import {switchToChannelById} from '@actions/remote/channel';
import {getSessions} from '@actions/remote/session';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {setCurrentTeamAndChannelId} from '@queries/servers/system';
import {isTablet} from '@utils/helpers';
import {logWarning} from '@utils/log';
import {scheduleExpiredNotification} from '@utils/notification';
import {deferredAppEntryActions, entry} from './gql_common';
@@ -50,27 +47,6 @@ export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs)
return {error: clData.error};
}
// schedule local push notification if needed
if (clData.config) {
if (clData.config.ExtendSessionLengthWithActivity !== 'true') {
const timeOut = setTimeout(async () => {
clearTimeout(timeOut);
let sessions: Session[]|undefined;
try {
sessions = await getSessions(serverUrl, 'me');
} catch (e) {
logWarning('Failed to get user sessions', e);
return;
}
if (sessions && Array.isArray(sessions)) {
scheduleExpiredNotification(sessions, clData.config?.SiteName || serverUrl, user.locale);
}
}, 500);
}
}
const entryData = await entry(serverUrl, '', '');
if ('error' in entryData) {

View File

@@ -1,18 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DeviceEventEmitter} from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import {DeviceEventEmitter, Platform} from 'react-native';
import {Database, Events} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getServerCredentials} from '@init/credentials';
import PushNotifications from '@init/push_notifications';
import NetworkManager from '@managers/network_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getDeviceToken} from '@queries/app/global';
import {getCurrentUserId, getCommonSystemValues} from '@queries/servers/system';
import {queryServerName} from '@queries/app/servers';
import {getCurrentUserId, getCommonSystemValues, getExpiredSession} from '@queries/servers/system';
import {getCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import {logWarning} from '@utils/log';
import {logWarning, logError} from '@utils/log';
import {scheduleExpiredNotification} from '@utils/notification';
import {getCSRFFromCookie} from '@utils/security';
import {getDeviceTimezone, isTimezoneEnabled} from '@utils/timezone';
@@ -91,7 +96,7 @@ export const forceLogoutIfNecessary = async (serverUrl: string, err: ClientError
return {error: null};
};
export const getSessions = async (serverUrl: string, currentUserId: string) => {
export const fetchSessions = async (serverUrl: string, currentUserId: string) => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
@@ -102,6 +107,7 @@ export const getSessions = async (serverUrl: string, currentUserId: string) => {
try {
return await client.getSessions(currentUserId);
} catch (e) {
logError('fetchSessions', e);
await forceLogoutIfNecessary(serverUrl, e as ClientError);
}
@@ -166,7 +172,7 @@ export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaTo
}
};
export const logout = async (serverUrl: string, skipServerLogout = false, removeServer = false) => {
export const logout = async (serverUrl: string, skipServerLogout = false, removeServer = false, skipEvents = false) => {
if (!skipServerLogout) {
try {
const client = NetworkManager.getClient(serverUrl);
@@ -177,7 +183,65 @@ export const logout = async (serverUrl: string, skipServerLogout = false, remove
}
}
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl, removeServer});
if (!skipEvents) {
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl, removeServer});
}
};
export const cancelSessionNotification = async (serverUrl: string) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const expiredSession = await getExpiredSession(database);
const rechable = (await NetInfo.fetch()).isInternetReachable;
if (expiredSession?.notificationId && rechable) {
PushNotifications.cancelScheduleNotification(parseInt(expiredSession.notificationId, 10));
operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.SESSION_EXPIRATION,
value: '',
}],
prepareRecordsOnly: false,
});
}
} catch (e) {
logError('cancelSessionNotification', e);
}
};
export const scheduleSessionNotification = async (serverUrl: string) => {
try {
const {database: appDatabase} = DatabaseManager.getAppDatabaseAndOperator();
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const sessions = await fetchSessions(serverUrl, 'me');
const user = await getCurrentUser(database);
const serverName = await queryServerName(appDatabase, serverUrl);
await cancelSessionNotification(serverUrl);
if (sessions) {
const session = await findSession(serverUrl, sessions);
if (session) {
const sessionId = session.id;
const notificationId = scheduleExpiredNotification(serverUrl, session, serverName, user?.locale);
operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.SESSION_EXPIRATION,
value: {
id: sessionId,
notificationId,
expiresAt: session.expires_at,
},
}],
prepareRecordsOnly: false,
});
}
}
} catch (e) {
logError('scheduleExpiredNotification', e);
await forceLogoutIfNecessary(serverUrl, e as ClientError);
}
};
export const sendPasswordResetEmail = async (serverUrl: string, email: string) => {
@@ -251,3 +315,47 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser
return {error: error as ClientError, failed: false, time: 0};
}
};
async function findSession(serverUrl: string, sessions: Session[]) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const {database: appDatabase} = DatabaseManager.getAppDatabaseAndOperator();
const expiredSession = await getExpiredSession(database);
const deviceToken = await getDeviceToken(appDatabase);
// First try and find the session by the given identifier hyqddef7jjdktqiyy36gxa8sqy
let session = sessions.find((s) => s.id === expiredSession?.id);
if (session) {
return session;
}
// Next try and find the session by deviceId
if (deviceToken) {
session = sessions.find((s) => s.device_id === deviceToken);
if (session) {
return session;
}
}
// Next try and find the session by the CSRF token
const csrfToken = await getCSRFFromCookie(serverUrl);
if (csrfToken) {
session = sessions.find((s) => s.props?.csrf === csrfToken);
if (session) {
return session;
}
}
// Next try and find the session based on the OS
// if multiple sessions exists with the same os type this can be inaccurate
session = sessions.find((s) => s.props?.os.toLowerCase() === Platform.OS);
if (session) {
return session;
}
} catch (e) {
logError('findSession', e);
}
// At this point we did not find the session
return undefined;
}

View File

@@ -63,6 +63,7 @@ export const SYSTEM_IDENTIFIERS = {
RECENT_CUSTOM_STATUS: 'recentCustomStatus',
RECENT_MENTIONS: 'recentMentions',
RECENT_REACTIONS: 'recentReactions',
SESSION_EXPIRATION: 'sessionExpiration',
TEAM_HISTORY: 'teamHistory',
WEBSOCKET: 'WebSocket',
};

View File

@@ -19,6 +19,7 @@ export default keyMirror({
PAUSE_KEYBOARD_TRACKING_VIEW: null,
SERVER_LOGOUT: null,
SERVER_VERSION_CHANGED: null,
SESSION_EXPIRED: null,
TAB_BAR_VISIBLE: null,
TEAM_LOAD_ERROR: null,
TEAM_SWITCH: null,

View File

@@ -27,6 +27,7 @@ import Post from './post';
import PostDraft from './post_draft';
import Preferences from './preferences';
import Profile from './profile';
import PushNotification from './push_notification';
import PushProxy from './push_proxy';
import Screens from './screens';
import ServerErrors from './server_errors';
@@ -63,6 +64,7 @@ export {
PostDraft,
Preferences,
Profile,
PushNotification,
PushProxy,
Screens,
ServerErrors,

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const CATEGORY = 'CAN_REPLY';
export const REPLY_ACTION = 'REPLY_ACTION';
export const NOTIFICATION_TYPE = {
CLEAR: 'clear',
MESSAGE: 'message',
SESSION: 'session',
};
export default {
CATEGORY,
NOTIFICATION_TYPE,
REPLY_ACTION,
};

View File

@@ -2,11 +2,11 @@
// See LICENSE.txt for license information.
import Emm from '@mattermost/react-native-emm';
import {Alert, Linking, Platform} from 'react-native';
import {Alert, DeviceEventEmitter, Linking, Platform} from 'react-native';
import {Notifications} from 'react-native-notifications';
import {appEntry, pushNotificationEntry, upgradeEntry} from '@actions/remote/entry';
import {Screens, DeepLink, Launch} from '@constants';
import {Screens, DeepLink, Events, Launch, PushNotification} from '@constants';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials';
import {getThemeForCurrentTeam} from '@queries/servers/preference';
@@ -20,6 +20,8 @@ import {parseDeepLink} from '@utils/url';
import type {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkWithData, LaunchProps} from '@typings/launch';
const initialNotificationTypes = [PushNotification.NOTIFICATION_TYPE.MESSAGE, PushNotification.NOTIFICATION_TYPE.SESSION];
export const initialLaunch = async () => {
const deepLinkUrl = await Linking.getInitialURL();
if (deepLinkUrl) {
@@ -29,7 +31,7 @@ export const initialLaunch = async () => {
const notification = await Notifications.getInitialNotification();
let tapped = Platform.select({android: true, ios: false})!;
if (Platform.OS === 'ios') {
if (Platform.OS === 'ios' && notification) {
// when a notification is received on iOS, getInitialNotification, will return the notification
// as the app will initialized cause we are using background fetch,
// that does not necessarily mean that the app was opened cause of the notification was tapped.
@@ -38,8 +40,8 @@ export const initialLaunch = async () => {
const delivered = await Notifications.ios.getDeliveredNotifications();
tapped = delivered.find((d) => (d as unknown as NotificationData).ack_id === notification?.payload.ack_id) == null;
}
if (notification?.payload?.type === 'message' && tapped) {
launchAppFromNotification(convertToNotificationData(notification));
if (initialNotificationTypes.includes(notification?.payload?.type) && tapped) {
launchAppFromNotification(convertToNotificationData(notification!));
return;
}
@@ -67,6 +69,12 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => {
break;
case Launch.Notification: {
serverUrl = props.serverUrl;
const extra = props.extra as NotificationWithData;
const sessionExpiredNotification = Boolean(props.serverUrl && extra.payload?.type === PushNotification.NOTIFICATION_TYPE.SESSION);
if (sessionExpiredNotification) {
DeviceEventEmitter.emit(Events.SESSION_EXPIRED, serverUrl);
return '';
}
break;
}
default:
@@ -110,17 +118,15 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => {
},
}],
);
return;
return '';
}
}
launchToHome({...props, launchType, serverUrl});
return;
return launchToHome({...props, launchType, serverUrl});
}
}
launchToServer(props, resetNavigation);
return launchToServer(props, resetNavigation);
};
const launchToHome = async (props: LaunchProps) => {
@@ -156,25 +162,26 @@ const launchToHome = async (props: LaunchProps) => {
if (nTeams) {
logInfo('Launch app in Home screen');
resetToHome(props);
} else {
logInfo('Launch app in Select Teams screen');
resetToTeams();
return resetToHome(props);
}
logInfo('Launch app in Select Teams screen');
return resetToTeams();
};
const launchToServer = (props: LaunchProps, resetNavigation: Boolean) => {
if (resetNavigation) {
resetToSelectServer(props);
return;
return resetToSelectServer(props);
}
// This is being called for Deeplinks, but needs to be revisited when
// the implementation of deep links is complete
const title = '';
goToScreen(Screens.SERVER, title, {...props});
return goToScreen(Screens.SERVER, title, {...props});
};
export const relaunchApp = (props: LaunchProps, resetNavigation = false) => {
launchApp(props, resetNavigation);
return launchApp(props, resetNavigation);
};
export const getLaunchPropsFromDeepLink = (deepLinkUrl: string): LaunchProps => {

View File

@@ -18,7 +18,7 @@ import {storeDeviceToken} from '@actions/app/global';
import {markChannelAsViewed} from '@actions/local/channel';
import {backgroundNotification, openNotification} from '@actions/remote/notifications';
import {markThreadAsRead} from '@actions/remote/thread';
import {Device, Events, Navigation, Screens} from '@constants';
import {Device, Events, Navigation, PushNotification, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
import NativeNotifications from '@notifications';
@@ -32,14 +32,6 @@ import {isTablet} from '@utils/helpers';
import {logInfo} from '@utils/log';
import {convertToNotificationData} from '@utils/notification';
const CATEGORY = 'CAN_REPLY';
const REPLY_ACTION = 'REPLY_ACTION';
const NOTIFICATION_TYPE = {
CLEAR: 'clear',
MESSAGE: 'message',
SESSION: 'session',
};
class PushNotifications {
configured = false;
@@ -56,15 +48,15 @@ class PushNotifications {
const replyButton = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.button'));
const replyPlaceholder = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.placeholder'));
const replyTextInput: NotificationTextInput = {buttonTitle: replyButton, placeholder: replyPlaceholder};
const replyAction = new NotificationAction(REPLY_ACTION, 'background', replyTitle, true, replyTextInput);
return new NotificationCategory(CATEGORY, [replyAction]);
const replyAction = new NotificationAction(PushNotification.REPLY_ACTION, 'background', replyTitle, true, replyTextInput);
return new NotificationCategory(PushNotification.CATEGORY, [replyAction]);
};
getServerUrlFromNotification = async (notification: NotificationWithData) => {
const {payload} = notification;
if (!payload?.channel_id && (!payload?.server_url || !payload.server_id)) {
return undefined;
return payload?.server_url;
}
let serverUrl = payload.server_url;
@@ -156,7 +148,11 @@ class PushNotifications {
const serverUrl = await this.getServerUrlFromNotification(notification);
if (serverUrl) {
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl});
if (notification.userInteraction) {
DeviceEventEmitter.emit(Events.SESSION_EXPIRED, serverUrl);
} else {
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl});
}
}
};
@@ -165,13 +161,13 @@ class PushNotifications {
if (payload) {
switch (payload.type) {
case NOTIFICATION_TYPE.CLEAR:
case PushNotification.NOTIFICATION_TYPE.CLEAR:
this.handleClearNotification(notification);
break;
case NOTIFICATION_TYPE.MESSAGE:
case PushNotification.NOTIFICATION_TYPE.MESSAGE:
this.handleMessageNotification(notification);
break;
case NOTIFICATION_TYPE.SESSION:
case PushNotification.NOTIFICATION_TYPE.SESSION:
this.handleSessionNotification(notification);
break;
}
@@ -259,8 +255,14 @@ class PushNotifications {
notification.fireDate = new Date(notification.fireDate).toISOString();
}
Notifications.postLocalNotification(notification);
return Notifications.postLocalNotification(notification);
}
return 0;
};
cancelScheduleNotification = (notificationId: number) => {
Notifications.cancelLocalNotification(notificationId);
};
}

View File

@@ -1,40 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import CookieManager, {Cookie} from '@react-native-cookies/cookies';
import {Alert, DeviceEventEmitter, Linking, Platform} from 'react-native';
import FastImage from 'react-native-fast-image';
import {Alert, DeviceEventEmitter, Linking} from 'react-native';
import semver from 'semver';
import LocalConfig from '@assets/config.json';
import {Events, Sso, Launch} from '@constants';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getTranslations, resetMomentLocale, t} from '@i18n';
import {getServerCredentials, removeServerCredentials} from '@init/credentials';
import {Events, Sso} from '@constants';
import {DEFAULT_LOCALE, getTranslations, t} from '@i18n';
import {getServerCredentials} from '@init/credentials';
import {getLaunchPropsFromDeepLink, relaunchApp} from '@init/launch';
import PushNotifications from '@init/push_notifications';
import * as analytics from '@managers/analytics';
import NetworkManager from '@managers/network_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import {deleteFileCache, deleteFileCacheByDir} from '@utils/file';
import type {jsAndNativeErrorHandler} from '@typings/global/error_handling';
import type {LaunchType} from '@typings/launch';
type LinkingCallbackArg = {url: string};
type LogoutCallbackArg = {
serverUrl: string;
removeServer: boolean;
}
class GlobalEventHandler {
JavascriptAndNativeErrorHandler: jsAndNativeErrorHandler | undefined;
constructor() {
DeviceEventEmitter.addListener(Events.SERVER_LOGOUT, this.onLogout);
DeviceEventEmitter.addListener(Events.SERVER_VERSION_CHANGED, this.onServerVersionChanged);
DeviceEventEmitter.addListener(Events.CONFIG_CHANGED, this.onServerConfigChanged);
@@ -68,75 +52,6 @@ class GlobalEventHandler {
}
};
clearCookies = async (serverUrl: string, webKit: boolean) => {
try {
const cookies = await CookieManager.get(serverUrl, webKit);
const values = Object.values(cookies);
values.forEach((cookie: Cookie) => {
CookieManager.clearByName(serverUrl, cookie.name, webKit);
});
} catch (error) {
// Nothing to clear
}
};
clearCookiesForServer = async (serverUrl: string) => {
this.clearCookies(serverUrl, false);
if (Platform.OS === 'ios') {
// Also delete any cookies that were set by react-native-webview
this.clearCookies(serverUrl, true);
} else if (Platform.OS === 'android') {
CookieManager.flush();
}
};
onLogout = async ({serverUrl, removeServer}: LogoutCallbackArg) => {
await removeServerCredentials(serverUrl);
PushNotifications.removeServerNotifications(serverUrl);
NetworkManager.invalidateClient(serverUrl);
WebsocketManager.invalidateClient(serverUrl);
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
const activeServerDisplayName = await DatabaseManager.getActiveServerDisplayName();
if (removeServer) {
await DatabaseManager.destroyServerDatabase(serverUrl);
} else {
await DatabaseManager.deleteServerDatabase(serverUrl);
}
const analyticsClient = analytics.get(serverUrl);
if (analyticsClient) {
analyticsClient.reset();
analytics.invalidate(serverUrl);
}
this.resetLocale();
this.clearCookiesForServer(serverUrl);
FastImage.clearDiskCache();
deleteFileCache(serverUrl);
deleteFileCacheByDir('mmPasteInput');
deleteFileCacheByDir('thumbnails');
if (Platform.OS === 'android') {
deleteFileCacheByDir('image_cache');
}
if (activeServerUrl === serverUrl) {
let displayName = '';
let launchType: LaunchType = Launch.AddServer;
if (!Object.keys(DatabaseManager.serverDatabases).length) {
EphemeralStore.theme = undefined;
launchType = Launch.Normal;
if (activeServerDisplayName) {
displayName = activeServerDisplayName;
}
}
relaunchApp({launchType, serverUrl, displayName}, true);
}
};
onServerConfigChanged = ({serverUrl, config}: {serverUrl: string; config: ClientConfig}) => {
this.configureAnalytics(serverUrl, config);
};
@@ -162,21 +77,11 @@ class GlobalEventHandler {
}
};
resetLocale = async () => {
if (Object.keys(DatabaseManager.serverDatabases).length) {
const serverDatabase = await DatabaseManager.getActiveServerDatabase();
const user = await getCurrentUser(serverDatabase!);
resetMomentLocale(user?.locale);
} else {
resetMomentLocale();
}
};
serverUpgradeNeeded = async (serverUrl: string) => {
const credentials = await getServerCredentials(serverUrl);
if (credentials) {
this.onLogout({serverUrl, removeServer: false});
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl, removeServer: false});
}
};
}

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import CookieManager, {Cookie} from '@react-native-cookies/cookies';
import {AppState, AppStateStatus, DeviceEventEmitter, Platform} from 'react-native';
import FastImage from 'react-native-fast-image';
import {cancelSessionNotification, logout, scheduleSessionNotification} from '@actions/remote/session';
import {resetMomentLocale} from '@app/i18n';
import {deleteFileCache, deleteFileCacheByDir} from '@app/utils/file';
import {Events, Launch} from '@constants';
import DatabaseManager from '@database/manager';
import {getAllServerCredentials, removeServerCredentials} from '@init/credentials';
import {relaunchApp} from '@init/launch';
import PushNotifications from '@init/push_notifications';
import * as analytics from '@managers/analytics';
import NetworkManager from '@managers/network_manager';
import WebsocketManager from '@managers/websocket_manager';
import {queryServerName} from '@queries/app/servers';
import {getCurrentUser} from '@queries/servers/user';
import {getThemeFromState} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {addNewServer} from '@utils/server';
import type {LaunchType} from '@typings/launch';
type LogoutCallbackArg = {
serverUrl: string;
removeServer: boolean;
}
class SessionManager {
private previousAppState: AppStateStatus;
private scheduling = false;
private terminatingSessionUrl: undefined|string;
constructor() {
if (Platform.OS === 'android') {
AppState.addEventListener('blur', () => {
this.onAppStateChange('inactive');
});
AppState.addEventListener('focus', () => {
this.onAppStateChange('active');
});
} else {
AppState.addEventListener('change', this.onAppStateChange);
}
DeviceEventEmitter.addListener(Events.SERVER_LOGOUT, this.onLogout);
DeviceEventEmitter.addListener(Events.SESSION_EXPIRED, this.onSessionExpired);
this.previousAppState = AppState.currentState;
}
init() {
this.cancelAll();
}
private cancelAll = async () => {
const serverCredentials = await getAllServerCredentials();
for (const {serverUrl} of serverCredentials) {
cancelSessionNotification(serverUrl);
}
};
private clearCookies = async (serverUrl: string, webKit: boolean) => {
try {
const cookies = await CookieManager.get(serverUrl, webKit);
const values = Object.values(cookies);
values.forEach((cookie: Cookie) => {
CookieManager.clearByName(serverUrl, cookie.name, webKit);
});
} catch (error) {
// Nothing to clear
}
};
private clearCookiesForServer = async (serverUrl: string) => {
this.clearCookies(serverUrl, false);
if (Platform.OS === 'ios') {
// Also delete any cookies that were set by react-native-webview
this.clearCookies(serverUrl, true);
} else if (Platform.OS === 'android') {
CookieManager.flush();
}
};
private scheduleAll = async () => {
if (!this.scheduling) {
this.scheduling = true;
const serverCredentials = await getAllServerCredentials();
const promises: Array<Promise<void>> = [];
for (const {serverUrl} of serverCredentials) {
promises.push(scheduleSessionNotification(serverUrl));
}
await Promise.all(promises);
this.scheduling = false;
}
};
private resetLocale = async () => {
if (Object.keys(DatabaseManager.serverDatabases).length) {
const serverDatabase = await DatabaseManager.getActiveServerDatabase();
const user = await getCurrentUser(serverDatabase!);
resetMomentLocale(user?.locale);
} else {
resetMomentLocale();
}
};
private terminateSession = async (serverUrl: string, removeServer: boolean) => {
cancelSessionNotification(serverUrl);
await removeServerCredentials(serverUrl);
PushNotifications.removeServerNotifications(serverUrl);
NetworkManager.invalidateClient(serverUrl);
WebsocketManager.invalidateClient(serverUrl);
if (removeServer) {
await DatabaseManager.destroyServerDatabase(serverUrl);
} else {
await DatabaseManager.deleteServerDatabase(serverUrl);
}
const analyticsClient = analytics.get(serverUrl);
if (analyticsClient) {
analyticsClient.reset();
analytics.invalidate(serverUrl);
}
this.resetLocale();
this.clearCookiesForServer(serverUrl);
FastImage.clearDiskCache();
deleteFileCache(serverUrl);
deleteFileCacheByDir('mmPasteInput');
deleteFileCacheByDir('thumbnails');
if (Platform.OS === 'android') {
deleteFileCacheByDir('image_cache');
}
};
private onAppStateChange = async (appState: AppStateStatus) => {
if (appState === this.previousAppState) {
return;
}
this.previousAppState = appState;
switch (appState) {
case 'active':
setTimeout(this.cancelAll, 750);
break;
case 'inactive':
this.scheduleAll();
break;
}
};
private onLogout = async ({serverUrl, removeServer}: LogoutCallbackArg) => {
if (this.terminatingSessionUrl === serverUrl) {
return;
}
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
const activeServerDisplayName = await DatabaseManager.getActiveServerDisplayName();
await this.terminateSession(serverUrl, removeServer);
if (activeServerUrl === serverUrl) {
let displayName = '';
let launchType: LaunchType = Launch.AddServer;
if (!Object.keys(DatabaseManager.serverDatabases).length) {
EphemeralStore.theme = undefined;
launchType = Launch.Normal;
if (activeServerDisplayName) {
displayName = activeServerDisplayName;
}
}
relaunchApp({launchType, serverUrl, displayName}, true);
}
};
private onSessionExpired = async (serverUrl: string) => {
this.terminatingSessionUrl = serverUrl;
await logout(serverUrl, false, false, true);
await this.terminateSession(serverUrl, false);
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
const appDatabase = DatabaseManager.appDatabase?.database;
const serverDisplayName = appDatabase ? await queryServerName(appDatabase, serverUrl) : undefined;
await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}, true);
if (activeServerUrl) {
addNewServer(getThemeFromState(), serverUrl, serverDisplayName);
} else {
EphemeralStore.theme = undefined;
}
this.terminatingSessionUrl = undefined;
};
}
export default new SessionManager();

View File

@@ -422,3 +422,12 @@ export const observeAllowedThemesKeys = (database: Database) => {
}),
);
};
export const getExpiredSession = async (database: Database) => {
try {
const session = await database.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.SESSION_EXPIRATION);
return (session?.value || {}) as SessionExpiration;
} catch {
return undefined;
}
};

View File

@@ -54,6 +54,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
borderLeftWidth: 1,
borderLeftColor: changeOpacity(theme.centerChannelColor, 0.16),
},
totalHeight: {height: '100%'},
};
});
@@ -112,6 +113,7 @@ const AccountScreen = ({currentUser, enableCustomUserStatuses, customStatusExpir
<ScrollView
alwaysBounceVertical={false}
style={tabletSidebarStyle}
contentContainerStyle={styles.totalHeight}
>
<AccountUserInfo
user={currentUser}

View File

@@ -158,7 +158,7 @@ Appearance.addChangeListener(() => {
}
});
function getThemeFromState(): Theme {
export function getThemeFromState(): Theme {
if (EphemeralStore.theme) {
return EphemeralStore.theme;
}
@@ -193,7 +193,7 @@ export function resetToHome(passProps: LaunchProps = {launchType: Launch.Normal}
dismissModal({componentId: Screens.SSO});
dismissModal({componentId: Screens.BOTTOM_SHEET});
DeviceEventEmitter.emit(Events.FETCHING_POSTS, false);
return;
return '';
}
NavigationStore.clearNavigationComponents();
@@ -228,7 +228,7 @@ export function resetToHome(passProps: LaunchProps = {launchType: Launch.Normal}
}],
};
Navigation.setRoot({
return Navigation.setRoot({
root: {stack},
});
}
@@ -272,7 +272,7 @@ export function resetToSelectServer(passProps: LaunchProps) {
},
}];
Navigation.setRoot({
return Navigation.setRoot({
root: {
stack: {
children,
@@ -288,7 +288,7 @@ export function resetToTeams() {
NavigationStore.clearNavigationComponents();
Navigation.setRoot({
return Navigation.setRoot({
root: {
stack: {
children: [{
@@ -324,7 +324,7 @@ export function resetToTeams() {
export function goToScreen(name: string, title: string, passProps = {}, options = {}) {
if (!isScreenRegistered(name)) {
return;
return '';
}
const theme = getThemeFromState();
@@ -361,7 +361,7 @@ export function goToScreen(name: string, title: string, passProps = {}, options
},
};
Navigation.push(componentId, {
return Navigation.push(componentId, {
component: {
id: name,
name,

View File

@@ -9,7 +9,6 @@ import {Events} from '@constants';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import PushNotifications from '@init/push_notifications';
import {popToRoot} from '@screens/navigation';
import {sortByNewest} from '@utils/general';
export const convertToNotificationData = (notification: Notification, tapped = true): NotificationWithData => {
if (!notification.payload) {
@@ -25,7 +24,7 @@ export const convertToNotificationData = (notification: Notification, tapped = t
channel_name: payload.channel_name,
identifier: payload.identifier || notification.identifier,
from_webhook: payload.from_webhook,
message: ((payload.type === 'message') ? payload.message || notification.body : undefined),
message: ((payload.type === 'message') ? payload.message || notification.body : payload.body),
override_icon_url: payload.override_icon_url,
override_username: payload.override_username,
post_id: payload.post_id,
@@ -75,26 +74,28 @@ export const emitNotificationError = (type: 'Team' | 'Channel') => {
}, 500);
};
export const scheduleExpiredNotification = async (sessions: Session[], siteName: string, locale = DEFAULT_LOCALE) => {
const session = sessions.sort(sortByNewest)[0];
export const scheduleExpiredNotification = (serverUrl: string, session: Session, serverName: string, locale = DEFAULT_LOCALE) => {
const expiresAt = session?.expires_at || 0;
const expiresInDays = Math.ceil(Math.abs(moment.duration(moment().diff(moment(expiresAt))).asDays()));
const intl = createIntl({locale, messages: getTranslations(locale)});
const body = intl.formatMessage({
id: 'mobile.session_expired',
defaultMessage: 'Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.',
}, {siteName, daysCount: expiresInDays});
defaultMessage: 'Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.',
}, {siteName: serverName, daysCount: expiresInDays});
const title = intl.formatMessage({id: 'mobile.session_expired.title', defaultMessage: 'Session Expired'});
if (expiresAt && body) {
//@ts-expect-error: Does not need to set all Notification properties
PushNotifications.scheduleNotification({
if (expiresAt) {
return PushNotifications.scheduleNotification({
fireDate: expiresAt,
body,
payload: {
userInfo: {
local: true,
},
},
title,
// @ts-expect-error need to be included in the notification payload
ack_id: serverUrl,
server_url: serverUrl,
type: 'session',
});
}
return 0;
};

View File

@@ -576,7 +576,8 @@
"mobile.server_url.deeplink.emm.denied": "This app is controlled by an EMM and the DeepLink server url does not match the EMM allowed server",
"mobile.server_url.empty": "Please enter a valid server URL",
"mobile.server_url.invalid_format": "URL must start with http:// or https://",
"mobile.session_expired": "Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.",
"mobile.session_expired": "Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.",
"mobile.session_expired.title": "Session Expired",
"mobile.set_status.away": "Away",
"mobile.set_status.dnd": "Do Not Disturb",
"mobile.set_status.offline": "Offline",

View File

@@ -15,6 +15,7 @@ import ManagedApp from './app/init/managed_app';
import PushNotifications from './app/init/push_notifications';
import GlobalEventHandler from './app/managers/global_event_handler';
import NetworkManager from './app/managers/network_manager';
import SessionManager from './app/managers/session_manager';
import WebsocketManager from './app/managers/websocket_manager';
import {registerScreens} from './app/screens';
import NavigationStore from './app/store/navigation_store';
@@ -75,6 +76,7 @@ Navigation.events().registerAppLaunchedListener(async () => {
await NetworkManager.init(serverCredentials);
await WebsocketManager.init(serverCredentials);
PushNotifications.init();
SessionManager.init();
}
initialLaunch();

View File

@@ -84,6 +84,23 @@ index a34598c..b035a76 100644
if (navigationActivity != null) {
navigationActivity.onCatalystInstanceDestroy();
}
diff --git a/node_modules/react-native-navigation/lib/android/app/src/reactNative68/java/com/reactnativenavigation/react/ReactGateway.java b/node_modules/react-native-navigation/lib/android/app/src/reactNative68/java/com/reactnativenavigation/react/ReactGateway.java
index 035ec31..38109c1 100644
--- a/node_modules/react-native-navigation/lib/android/app/src/reactNative68/java/com/reactnativenavigation/react/ReactGateway.java
+++ b/node_modules/react-native-navigation/lib/android/app/src/reactNative68/java/com/reactnativenavigation/react/ReactGateway.java
@@ -48,6 +48,12 @@ public class ReactGateway {
}
}
+ public void onWindowFocusChanged(boolean hasFocus) {
+ if (host.hasInstance()) {
+ host.getReactInstanceManager().onWindowFocusChange(hasFocus);
+ }
+ }
+
public void onActivityPaused(NavigationActivity activity) {
initializer.onActivityPaused(activity);
jsDevReloadHandler.onActivityPaused(activity);
diff --git a/node_modules/react-native-navigation/lib/ios/RNNOverlayWindow.m b/node_modules/react-native-navigation/lib/ios/RNNOverlayWindow.m
index 934e7e7..19169a3 100644
--- a/node_modules/react-native-navigation/lib/ios/RNNOverlayWindow.m

View File

@@ -376,7 +376,7 @@ index 0000000..58ff887
+ }
+}
diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java
index a14089f..a339934 100644
index a14089f..5238c75 100644
--- a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java
+++ b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java
@@ -2,9 +2,15 @@ package com.wix.reactnativenotifications.core.notificationdrawer;
@@ -395,10 +395,26 @@ index a14089f..a339934 100644
public class PushNotificationsDrawer implements IPushNotificationsDrawer {
@@ -62,4 +68,31 @@ public class PushNotificationsDrawer implements IPushNotificationsDrawer {
@@ -49,17 +55,50 @@ public class PushNotificationsDrawer implements IPushNotificationsDrawer {
public void onNotificationClearRequest(int id) {
final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(id);
+ cancelScheduledNotification(String.valueOf(id));
}
@Override
public void onNotificationClearRequest(String tag, int id) {
final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(tag, id);
+ cancelScheduledNotification(String.valueOf(id));
}
@Override
public void onAllNotificationsClearRequest() {
final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancelAll();
}
+ cancelAllScheduledNotifications();
+ }
+
+ protected void cancelAllScheduledNotifications() {
+ Log.i(LOGTAG, "Cancelling all scheduled notifications");
@@ -413,6 +429,9 @@ index a14089f..a339934 100644
+ Log.i(LOGTAG, "Cancelling scheduled notification: " + notificationId);
+
+ ScheduleNotificationHelper helper = ScheduleNotificationHelper.getInstance(mContext);
+ if (!helper.getPreferencesKeys().contains(notificationId)) {
+ return;
+ }
+
+ // Remove it from the alarm manger schedule
+ Bundle bundle = new Bundle();
@@ -425,7 +444,7 @@ index a14089f..a339934 100644
+ // Remove it from the notification center
+ final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.cancel(Integer.parseInt(notificationId));
+ }
}
}
diff --git a/node_modules/react-native-notifications/lib/dist/DTO/Notification.d.ts b/node_modules/react-native-notifications/lib/dist/DTO/Notification.d.ts
index 7b2b3b1..3a2f872 100644
@@ -462,3 +481,46 @@ index afd5c73..6036dda 100644
}];
}
diff --git a/node_modules/react-native-notifications/lib/ios/RNNotificationEventHandler.m b/node_modules/react-native-notifications/lib/ios/RNNotificationEventHandler.m
index 5c8dd0b..1c7e575 100644
--- a/node_modules/react-native-notifications/lib/ios/RNNotificationEventHandler.m
+++ b/node_modules/react-native-notifications/lib/ios/RNNotificationEventHandler.m
@@ -6,11 +6,13 @@
@implementation RNNotificationEventHandler {
RNNotificationsStore* _store;
+ NSDate* wakeTime;
}
- (instancetype)initWithStore:(RNNotificationsStore *)store {
self = [super init];
_store = store;
+ wakeTime = [[NSDate alloc] init];
return self;
}
@@ -31,6 +33,15 @@
- (void)didReceiveNotificationResponse:(UNNotificationResponse *)response completionHandler:(void (^)(void))completionHandler {
[_store setActionCompletionHandler:completionHandler withCompletionKey:response.notification.request.identifier];
[RNEventEmitter sendEvent:RNNotificationOpened body:[RNNotificationParser parseNotificationResponse:response]];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ NSDate* now = [[NSDate alloc] init];
+ double interval = [now timeIntervalSinceDate:self->wakeTime];
+ BOOL background = [[UIApplication sharedApplication] applicationState] == UIApplicationStateBackground;
+ NSDictionary* userInfo = response.notification.request.content.userInfo;
+ if (interval < 1.0 && !background) {
+ [self->_store setInitialNotification:userInfo];
+ }
+ });
}
- (void)didReceiveBackgroundNotification:(NSDictionary *)userInfo withCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
@@ -38,7 +49,7 @@
NSString *uuid = [[NSUUID UUID] UUIDString];
__block BOOL completionHandlerCalled = NO;
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
- [_store setBackgroundActionCompletionHandler:^(UIBackgroundFetchResult result) {
+ [self->_store setBackgroundActionCompletionHandler:^(UIBackgroundFetchResult result) {
dispatch_async(dispatch_get_main_queue(), ^{
completionHandler(result);
});

View File

@@ -7,6 +7,10 @@ interface Session {
device_id?: string;
expires_at: number;
user_id: string;
props?: {
os: string;
csrf: string;
};
}
interface LoginActionResponse {

View File

@@ -52,6 +52,12 @@ type ReactionsPerPost = {
reactions: Reaction[];
}
type SessionExpiration = {
id: string;
notificationId: string;
expiresAt: number;
}
type IdValue = {
id: string;
value: unknown;