From 4c389a49fa5b99ff2dd60300f331ae73f16ed21c Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Sun, 18 Sep 2022 06:57:55 -0400 Subject: [PATCH] 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 --- .../helpers/CustomPushNotificationHelper.java | 35 ++- .../rnbeta/CustomPushNotification.java | 13 +- .../com/mattermost/rnbeta/MainActivity.java | 6 + app/actions/remote/entry/login.ts | 24 --- app/actions/remote/session.ts | 120 ++++++++++- app/constants/database.ts | 1 + app/constants/events.ts | 1 + app/constants/index.ts | 2 + app/constants/push_notification.ts | 18 ++ app/init/launch.ts | 43 ++-- app/init/push_notifications.ts | 36 ++-- app/managers/global_event_handler.ts | 105 +-------- app/managers/session_manager.ts | 203 ++++++++++++++++++ app/queries/servers/system.ts | 9 + app/screens/home/account/account.tsx | 2 + app/screens/navigation.ts | 14 +- app/utils/notification/index.ts | 29 +-- assets/base/i18n/en.json | 3 +- index.ts | 2 + patches/react-native-navigation+7.29.0.patch | 17 ++ .../react-native-notifications+4.3.1.patch | 70 +++++- types/api/session.d.ts | 4 + types/database/raw_values.d.ts | 6 + 23 files changed, 541 insertions(+), 222 deletions(-) create mode 100644 app/constants/push_notification.ts create mode 100644 app/managers/session_manager.ts diff --git a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java index c8cb0692cc..6a106568d2 100644 --- a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java +++ b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java @@ -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()); } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java index 61a1a53a71..dd63c4cfcd 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java @@ -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; } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java index e4e14d13e8..e9222b4bdf 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java @@ -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 diff --git a/app/actions/remote/entry/login.ts b/app/actions/remote/entry/login.ts index 4b34396fcc..dd141f0d36 100644 --- a/app/actions/remote/entry/login.ts +++ b/app/actions/remote/entry/login.ts @@ -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) { diff --git a/app/actions/remote/session.ts b/app/actions/remote/session.ts index 62f22326ae..3c61ebacc7 100644 --- a/app/actions/remote/session.ts +++ b/app/actions/remote/session.ts @@ -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; +} diff --git a/app/constants/database.ts b/app/constants/database.ts index f3c0c2f399..4ac2f8695e 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -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', }; diff --git a/app/constants/events.ts b/app/constants/events.ts index 3c0291f1be..4a994433a4 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -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, diff --git a/app/constants/index.ts b/app/constants/index.ts index 4305c740d5..bd7d174354 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -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, diff --git a/app/constants/push_notification.ts b/app/constants/push_notification.ts new file mode 100644 index 0000000000..fc918c066a --- /dev/null +++ b/app/constants/push_notification.ts @@ -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, +}; diff --git a/app/init/launch.ts b/app/init/launch.ts index 35f3c1fcca..04aa737a03 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -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 => { diff --git a/app/init/push_notifications.ts b/app/init/push_notifications.ts index 1b42c81a04..b555f44267 100644 --- a/app/init/push_notifications.ts +++ b/app/init/push_notifications.ts @@ -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); }; } diff --git a/app/managers/global_event_handler.ts b/app/managers/global_event_handler.ts index 365093fedc..97a90b7088 100644 --- a/app/managers/global_event_handler.ts +++ b/app/managers/global_event_handler.ts @@ -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}); } }; } diff --git a/app/managers/session_manager.ts b/app/managers/session_manager.ts new file mode 100644 index 0000000000..98411e7764 --- /dev/null +++ b/app/managers/session_manager.ts @@ -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> = []; + 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(); diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index 1542ee7c29..a6216b0275 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -422,3 +422,12 @@ export const observeAllowedThemesKeys = (database: Database) => { }), ); }; + +export const getExpiredSession = async (database: Database) => { + try { + const session = await database.get(SYSTEM).find(SYSTEM_IDENTIFIERS.SESSION_EXPIRATION); + return (session?.value || {}) as SessionExpiration; + } catch { + return undefined; + } +}; diff --git a/app/screens/home/account/account.tsx b/app/screens/home/account/account.tsx index 814eb28187..c4ecff7c01 100644 --- a/app/screens/home/account/account.tsx +++ b/app/screens/home/account/account.tsx @@ -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 { } }); -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, diff --git a/app/utils/notification/index.ts b/app/utils/notification/index.ts index 5b74edcd76..ff5bf7aa65 100644 --- a/app/utils/notification/index.ts +++ b/app/utils/notification/index.ts @@ -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; }; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 956f7520ca..7a5ae8f4b9 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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", diff --git a/index.ts b/index.ts index 6c07e55739..9fcf68eec2 100644 --- a/index.ts +++ b/index.ts @@ -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(); diff --git a/patches/react-native-navigation+7.29.0.patch b/patches/react-native-navigation+7.29.0.patch index 6a0d48a03b..d7480bc95c 100644 --- a/patches/react-native-navigation+7.29.0.patch +++ b/patches/react-native-navigation+7.29.0.patch @@ -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 diff --git a/patches/react-native-notifications+4.3.1.patch b/patches/react-native-notifications+4.3.1.patch index 74e0ec24b6..500d45d823 100644 --- a/patches/react-native-notifications+4.3.1.patch +++ b/patches/react-native-notifications+4.3.1.patch @@ -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); + }); diff --git a/types/api/session.d.ts b/types/api/session.d.ts index fddd6099db..3cd6e8c026 100644 --- a/types/api/session.d.ts +++ b/types/api/session.d.ts @@ -7,6 +7,10 @@ interface Session { device_id?: string; expires_at: number; user_id: string; + props?: { + os: string; + csrf: string; + }; } interface LoginActionResponse { diff --git a/types/database/raw_values.d.ts b/types/database/raw_values.d.ts index 29baa24b9c..5a335a5b47 100644 --- a/types/database/raw_values.d.ts +++ b/types/database/raw_values.d.ts @@ -52,6 +52,12 @@ type ReactionsPerPost = { reactions: Reaction[]; } +type SessionExpiration = { + id: string; + notificationId: string; + expiresAt: number; +} + type IdValue = { id: string; value: unknown;