diff --git a/lib/tunnelbroker/tunnelbroker-context.js b/lib/tunnelbroker/tunnelbroker-context.js index 52c3a4ce7..32975d6b8 100644 --- a/lib/tunnelbroker/tunnelbroker-context.js +++ b/lib/tunnelbroker/tunnelbroker-context.js @@ -1,493 +1,512 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import uuid from 'uuid'; import { PeerToPeerProvider } from './peer-to-peer-context.js'; import { PeerToPeerMessageHandler } from './peer-to-peer-message-handler.js'; import type { SecondaryTunnelbrokerConnection } from './secondary-tunnelbroker-connection.js'; import { PeerOlmSessionCreatorProvider } from '../components/peer-olm-session-creator-provider.react.js'; import { tunnnelbrokerURL } from '../facts/tunnelbroker.js'; import { DMOpsQueueHandler } from '../shared/dm-ops/dm-ops-queue-handler.react.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { tunnelbrokerHeartbeatTimeout, tunnelbrokerRequestTimeout, } from '../shared/timeouts.js'; import { isWebPlatform } from '../types/device-types.js'; import type { MessageSentStatus } from '../types/tunnelbroker/device-to-tunnelbroker-request-status-types.js'; import type { MessageToDeviceRequest } from '../types/tunnelbroker/message-to-device-request-types.js'; import type { MessageToTunnelbrokerRequest } from '../types/tunnelbroker/message-to-tunnelbroker-request-types.js'; import { deviceToTunnelbrokerMessageTypes, tunnelbrokerToDeviceMessageTypes, tunnelbrokerToDeviceMessageValidator, type TunnelbrokerToDeviceMessage, type DeviceToTunnelbrokerRequest, } from '../types/tunnelbroker/messages.js'; import type { TunnelbrokerNotif } from '../types/tunnelbroker/notif-types.js'; import type { AnonymousInitializationMessage, ConnectionInitializationMessage, TunnelbrokerInitializationMessage, TunnelbrokerDeviceTypes, } from '../types/tunnelbroker/session-types.js'; import type { Heartbeat } from '../types/websocket/heartbeat-types.js'; import { getConfig } from '../utils/config.js'; import { getContentSigningKey } from '../utils/crypto-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import sleep from '../utils/sleep.js'; export type TunnelbrokerClientMessageToDevice = { +deviceID: string, +payload: string, }; export type TunnelbrokerSocketListener = ( message: TunnelbrokerToDeviceMessage, ) => mixed; type PromiseCallbacks = { +resolve: () => void, +reject: (error: string) => void, }; type Promises = { [clientMessageID: string]: PromiseCallbacks }; type TunnelbrokerSocketState = | { +connected: true, +isAuthorized: boolean, } | { +connected: false, + +retryCount: number, }; type TunnelbrokerContextType = { +sendMessageToDevice: ( message: TunnelbrokerClientMessageToDevice, messageID: ?string, ) => Promise, +sendNotif: (notif: TunnelbrokerNotif) => Promise, +sendMessageToTunnelbroker: (payload: string) => Promise, +addListener: (listener: TunnelbrokerSocketListener) => void, +removeListener: (listener: TunnelbrokerSocketListener) => void, +socketState: TunnelbrokerSocketState, +setUnauthorizedDeviceID: (unauthorizedDeviceID: ?string) => void, }; const TunnelbrokerContext: React.Context = React.createContext(); type Props = { +children: React.Node, +shouldBeClosed?: boolean, +onClose?: () => mixed, +secondaryTunnelbrokerConnection?: SecondaryTunnelbrokerConnection, }; +const clientTunnelbrokerSocketReconnectDelay = 2000; // ms + function getTunnelbrokerDeviceType(): TunnelbrokerDeviceTypes { return isWebPlatform(getConfig().platformDetails.platform) ? 'web' : 'mobile'; } function createAnonymousInitMessage( deviceID: string, ): AnonymousInitializationMessage { return ({ type: 'AnonymousInitializationMessage', deviceID, deviceType: getTunnelbrokerDeviceType(), }: AnonymousInitializationMessage); } function TunnelbrokerProvider(props: Props): React.Node { const { children, shouldBeClosed, onClose, secondaryTunnelbrokerConnection } = props; const accessToken = useSelector(state => state.commServicesAccessToken); const userID = useSelector(state => state.currentUserInfo?.id); const [unauthorizedDeviceID, setUnauthorizedDeviceID] = React.useState(null); const isAuthorized = !unauthorizedDeviceID; const createInitMessage = React.useCallback(async () => { if (shouldBeClosed) { return null; } if (unauthorizedDeviceID) { return createAnonymousInitMessage(unauthorizedDeviceID); } if (!accessToken || !userID) { return null; } const deviceID = await getContentSigningKey(); if (!deviceID) { return null; } return ({ type: 'ConnectionInitializationMessage', deviceID, accessToken, userID, deviceType: getTunnelbrokerDeviceType(), }: ConnectionInitializationMessage); }, [accessToken, shouldBeClosed, unauthorizedDeviceID, userID]); const previousInitMessage = React.useRef(null); const [socketState, setSocketState] = React.useState( - { connected: false }, + { connected: false, retryCount: 0 }, ); const listeners = React.useRef>(new Set()); const socket = React.useRef(null); const socketSessionCounter = React.useRef(0); const promises = React.useRef({}); const heartbeatTimeoutID = React.useRef(); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { identityClient } = identityContext; const stopHeartbeatTimeout = React.useCallback(() => { if (heartbeatTimeoutID.current) { clearTimeout(heartbeatTimeoutID.current); heartbeatTimeoutID.current = null; } }, []); const resetHeartbeatTimeout = React.useCallback(() => { stopHeartbeatTimeout(); heartbeatTimeoutID.current = setTimeout(() => { socket.current?.close(); - setSocketState({ connected: false }); + setSocketState({ connected: false, retryCount: 0 }); }, tunnelbrokerHeartbeatTimeout); }, [stopHeartbeatTimeout]); // determine if the socket is active (not closed or closing) const isSocketActive = socket.current?.readyState === WebSocket.OPEN || socket.current?.readyState === WebSocket.CONNECTING; const connectionChangePromise = React.useRef>(null); // The Tunnelbroker connection can have 4 states: // - DISCONNECTED: isSocketActive = false, connected = false // Should be in this state when initMessage is null // - CONNECTING: isSocketActive = true, connected = false // This lasts until Tunnelbroker sends ConnectionInitializationResponse // - CONNECTED: isSocketActive = true, connected = true // - DISCONNECTING: isSocketActive = false, connected = true // This lasts between socket.close() and socket.onclose() React.useEffect(() => { connectionChangePromise.current = (async () => { await connectionChangePromise.current; try { const initMessage = await createInitMessage(); const initMessageChanged = !_isEqual( previousInitMessage.current, initMessage, ); previousInitMessage.current = initMessage; // when initMessage changes, we need to close the socket // and open a new one if ( (!initMessage || initMessageChanged) && isSocketActive && socket.current ) { socket.current?.close(); return; } // when we're already connected (or pending disconnection), // or there's no init message to start with, we don't need // to do anything if (socketState.connected || !initMessage || socket.current) { return; } const tunnelbrokerSocket = new WebSocket(tunnnelbrokerURL); tunnelbrokerSocket.onopen = () => { tunnelbrokerSocket.send(JSON.stringify(initMessage)); }; tunnelbrokerSocket.onclose = () => { // this triggers the effect hook again and reconnect - setSocketState({ connected: false }); + setSocketState(prev => ({ + connected: false, + retryCount: prev.connected ? 0 : prev.retryCount, + })); onClose?.(); socket.current = null; console.log('Connection to Tunnelbroker closed'); + setTimeout(() => { + if (!socket.current && initMessage) { + console.log( + 'Retrying Tunnelbroker connection. Attempt:', + socketState.retryCount + 1, + ); + setSocketState(prev => ({ + connected: false, + retryCount: prev.connected ? 0 : prev.retryCount + 1, + })); + } + }, clientTunnelbrokerSocketReconnectDelay); }; tunnelbrokerSocket.onerror = e => { console.log('Tunnelbroker socket error:', e.message); }; tunnelbrokerSocket.onmessage = (event: MessageEvent) => { if (typeof event.data !== 'string') { console.log('socket received a non-string message'); return; } let rawMessage; try { rawMessage = JSON.parse(event.data); } catch (e) { console.log('error while parsing Tunnelbroker message:', e.message); return; } if (!tunnelbrokerToDeviceMessageValidator.is(rawMessage)) { console.log('invalid TunnelbrokerMessage'); return; } const message: TunnelbrokerToDeviceMessage = rawMessage; resetHeartbeatTimeout(); for (const listener of listeners.current) { listener(message); } // MESSAGE_TO_DEVICE is handled in PeerToPeerMessageHandler if ( message.type === tunnelbrokerToDeviceMessageTypes.CONNECTION_INITIALIZATION_RESPONSE ) { if (message.status.type === 'Success' && !socketState.connected) { setSocketState({ connected: true, isAuthorized }); console.log( 'session with Tunnelbroker created. isAuthorized:', isAuthorized, ); } else if ( message.status.type === 'Success' && socketState.connected ) { console.log( 'received ConnectionInitializationResponse with status: Success for already connected socket', ); } else { - setSocketState({ connected: false }); + setSocketState({ connected: false, retryCount: 0 }); console.log( 'creating session with Tunnelbroker error:', message.status.data, ); } } else if ( message.type === tunnelbrokerToDeviceMessageTypes.DEVICE_TO_TUNNELBROKER_REQUEST_STATUS ) { for (const status: MessageSentStatus of message.clientMessageIDs) { if (status.type === 'Success') { promises.current[status.data]?.resolve(); delete promises.current[status.data]; } else if (status.type === 'Error') { promises.current[status.data.id]?.reject(status.data.error); delete promises.current[status.data.id]; } else if (status.type === 'SerializationError') { console.log('SerializationError for message: ', status.data); } else if (status.type === 'InvalidRequest') { console.log('Tunnelbroker recorded InvalidRequest'); } } } else if ( message.type === tunnelbrokerToDeviceMessageTypes.HEARTBEAT ) { const heartbeat: Heartbeat = { type: deviceToTunnelbrokerMessageTypes.HEARTBEAT, }; socket.current?.send(JSON.stringify(heartbeat)); } }; socket.current = tunnelbrokerSocket; socketSessionCounter.current = socketSessionCounter.current + 1; } catch (err) { console.log('Tunnelbroker connection error:', err); } })(); }, [ isSocketActive, isAuthorized, resetHeartbeatTimeout, stopHeartbeatTimeout, identityClient, onClose, createInitMessage, socketState.connected, + socketState.retryCount, ]); const sendMessage: (request: DeviceToTunnelbrokerRequest) => Promise = React.useCallback( request => { const resultPromise: Promise = new Promise((resolve, reject) => { const socketActive = socketState.connected && socket.current; if (!shouldBeClosed && !socketActive) { throw new Error('Tunnelbroker not connected'); } promises.current[request.clientMessageID] = { resolve, reject, }; if (socketActive) { socket.current?.send(JSON.stringify(request)); } else { secondaryTunnelbrokerConnection?.sendMessage(request); } }); const timeoutPromise: Promise = (async () => { await sleep(tunnelbrokerRequestTimeout); throw new Error('Tunnelbroker request timed out'); })(); return Promise.race([resultPromise, timeoutPromise]); }, [socketState, secondaryTunnelbrokerConnection, shouldBeClosed], ); const sendMessageToDevice: ( message: TunnelbrokerClientMessageToDevice, messageID: ?string, ) => Promise = React.useCallback( (message: TunnelbrokerClientMessageToDevice, messageID: ?string) => { const clientMessageID = messageID ?? uuid.v4(); const messageToDevice: MessageToDeviceRequest = { type: deviceToTunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST, clientMessageID, deviceID: message.deviceID, payload: message.payload, }; return sendMessage(messageToDevice); }, [sendMessage], ); const sendMessageToTunnelbroker: (payload: string) => Promise = React.useCallback( (payload: string) => { const clientMessageID = uuid.v4(); const messageToTunnelbroker: MessageToTunnelbrokerRequest = { type: deviceToTunnelbrokerMessageTypes.MESSAGE_TO_TUNNELBROKER_REQUEST, clientMessageID, payload, }; return sendMessage(messageToTunnelbroker); }, [sendMessage], ); React.useEffect( () => secondaryTunnelbrokerConnection?.onSendMessage(message => { if (shouldBeClosed) { // We aren't supposed to be handling it return; } void (async () => { try { await sendMessage(message); secondaryTunnelbrokerConnection.setMessageStatus( message.clientMessageID, ); } catch (error) { secondaryTunnelbrokerConnection.setMessageStatus( message.clientMessageID, error, ); } })(); }), [secondaryTunnelbrokerConnection, sendMessage, shouldBeClosed], ); React.useEffect( () => secondaryTunnelbrokerConnection?.onMessageStatus((messageID, error) => { if (error) { promises.current[messageID].reject(error); } else { promises.current[messageID].resolve(); } delete promises.current[messageID]; }), [secondaryTunnelbrokerConnection], ); const addListener = React.useCallback( (listener: TunnelbrokerSocketListener) => { listeners.current.add(listener); }, [], ); const removeListener = React.useCallback( (listener: TunnelbrokerSocketListener) => { listeners.current.delete(listener); }, [], ); const getSessionCounter = React.useCallback( () => socketSessionCounter.current, [], ); const doesSocketExist = React.useCallback(() => !!socket.current, []); const socketSend = React.useCallback((message: string) => { socket.current?.send(message); }, []); const value: TunnelbrokerContextType = React.useMemo( () => ({ sendMessageToDevice, sendMessageToTunnelbroker, sendNotif: sendMessage, socketState, addListener, removeListener, setUnauthorizedDeviceID, }), [ sendMessageToDevice, sendMessage, sendMessageToTunnelbroker, socketState, addListener, removeListener, ], ); return ( {children} ); } function useTunnelbroker(): TunnelbrokerContextType { const context = React.useContext(TunnelbrokerContext); invariant(context, 'TunnelbrokerContext not found'); return context; } export { TunnelbrokerProvider, useTunnelbroker }; diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index 423603a1c..8fdcfd747 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,867 +1,868 @@ // @flow import * as Haptics from 'expo-haptics'; import _groupBy from 'lodash/fp/groupBy.js'; import * as React from 'react'; import { LogBox, Platform } from 'react-native'; import { Notification as InAppNotification } from 'react-native-in-app-message'; import { recordAlertActionType } from 'lib/actions/alert-actions.js'; import { type DeviceTokens, setDeviceTokenActionTypes, type SetDeviceTokenStartedPayload, type SetDeviceTokenActionPayload, useSetDeviceToken, useSetDeviceTokenFanout, } from 'lib/actions/device-actions.js'; import { saveMessagesActionType } from 'lib/actions/message-actions.js'; import { extractKeyserverIDFromIDOptional } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { deviceTokensSelector, allUpdatesCurrentAsOfSelector, allConnectionInfosSelector, } from 'lib/selectors/keyserver-selectors.js'; import { threadInfoSelector, thinThreadsUnreadCountSelector, unreadThickThreadIDsSelector, } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import { alertTypes, type AlertInfo, type RecordAlertActionPayload, } from 'lib/types/alert-types.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import type { ConnectionInfo } from 'lib/types/socket-types.js'; import type { GlobalTheme } from 'lib/types/theme-types.js'; import { convertNonPendingIDToNewSchema, convertNotificationMessageInfoToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { shouldSkipPushPermissionAlert } from 'lib/utils/push-alerts.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import sleep from 'lib/utils/sleep.js'; import { type AndroidMessage, androidNotificationChannelID, CommAndroidNotifications, getCommAndroidNotificationsEventEmitter, handleAndroidMessage, parseAndroidMessage, } from './android.js'; import { CommIOSNotification, type CoreIOSNotificationData, type CoreIOSNotificationDataWithRequestIdentifier, } from './comm-ios-notification.js'; import InAppNotif from './in-app-notif.react.js'; import { CommIOSNotifications, type CoreIOSNotificationBackgroundData, getCommIOSNotificationsEventEmitter, iosPushPermissionResponseReceived, requestIOSPushPermissions, } from './ios.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle.js'; import { commCoreModule } from '../native-modules.js'; import { replaceWithThreadActionType } from '../navigation/action-types.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { RootContext, type RootContextType } from '../root-context.js'; import type { EventSubscription } from '../types/react-native.js'; import Alert from '../utils/alert.js'; LogBox.ignoreLogs([ // react-native-in-app-message 'ForceTouchGestureHandler is not available', ]); type BaseProps = { +navigation: RootNavigationProp<'App'>, }; type Props = { ...BaseProps, // Navigation state +activeThread: ?string, // Redux state +thinThreadsUnreadCount: { +[keyserverID: string]: number }, +unreadThickThreadIDs: $ReadOnlyArray, +connection: { +[keyserverID: string]: ?ConnectionInfo }, +deviceTokens: { +[keyserverID: string]: ?string, }, +threadInfos: { +[id: string]: ThreadInfo, }, +notifPermissionAlertInfo: AlertInfo, +allUpdatesCurrentAsOf: { +[keyserverID: string]: number, }, +activeTheme: ?GlobalTheme, +loggedIn: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +setDeviceToken: ( input: DeviceTokens, ) => Promise, +setDeviceTokenFanout: ( deviceToken: ?string, ) => Promise, // withRootContext +rootContext: ?RootContextType, +localToken: ?string, +tunnelbrokerSocketState: | { +connected: true, +isAuthorized: boolean, } | { +connected: false, + +retryCount: number, }, }; type State = { +inAppNotifProps: ?{ +customComponent: React.Node, +blurType: ?('xlight' | 'dark'), +onPress: () => void, }, }; class PushHandler extends React.PureComponent { state: State = { inAppNotifProps: null, }; currentState: ?string = getCurrentLifecycleState(); appStarted = 0; androidNotificationsEventSubscriptions: Array = []; androidNotificationsPermissionPromise: ?Promise = undefined; initialAndroidNotifHandled = false; openThreadOnceReceived: Set = new Set(); lifecycleSubscription: ?EventSubscription; iosNotificationEventSubscriptions: Array = []; componentDidMount() { this.appStarted = Date.now(); this.lifecycleSubscription = addLifecycleListener( this.handleAppStateChange, ); this.onForeground(); if (Platform.OS === 'ios') { const commIOSNotificationsEventEmitter = getCommIOSNotificationsEventEmitter(); this.iosNotificationEventSubscriptions.push( commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTERED_EVENT, registration => this.registerPushPermissions(registration?.deviceToken), ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTRATION_FAILED_EVENT, this.failedToRegisterPushPermissionsIOS, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_FOREGROUND_EVENT, this.iosForegroundNotificationReceived, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants().NOTIFICATION_OPENED_EVENT, this.iosNotificationOpened, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_BACKGROUND_EVENT, this.iosBackgroundNotificationReceived, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.createChannel( androidNotificationChannelID, 'Default', CommAndroidNotifications.getConstants().NOTIFICATIONS_IMPORTANCE_HIGH, 'Comm notifications channel', ); const commAndroidNotificationsEventEmitter = getCommAndroidNotificationsEventEmitter(); this.androidNotificationsEventSubscriptions.push( commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_TOKEN, this.handleAndroidDeviceToken, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_MESSAGE, this.androidMessageReceived, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED, this.androidNotificationOpened, ), ); } void this.updateBadgeCount(); } componentWillUnmount() { if (this.lifecycleSubscription) { this.lifecycleSubscription.remove(); } if (Platform.OS === 'ios') { for (const iosNotificationEventSubscription of this .iosNotificationEventSubscriptions) { iosNotificationEventSubscription.remove(); } } else if (Platform.OS === 'android') { for (const androidNotificationsEventSubscription of this .androidNotificationsEventSubscriptions) { androidNotificationsEventSubscription.remove(); } this.androidNotificationsEventSubscriptions = []; } } handleAppStateChange = (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = this.currentState; this.currentState = nextState; if (lastState === 'background' && nextState === 'active') { this.onForeground(); this.clearNotifsOfThread(); } }; onForeground() { if (this.props.loggedIn) { void this.ensurePushNotifsEnabled(); } else { // We do this in case there was a crash, so we can clear deviceToken from // any other cookies it might be set for const deviceTokensMap: { [string]: string } = {}; for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken) { deviceTokensMap[keyserverID] = deviceToken; } } this.setDeviceToken(deviceTokensMap, { type: 'nothing_to_set' }); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.activeThread !== prevProps.activeThread) { this.clearNotifsOfThread(); } void this.updateBadgeCount(); for (const threadID of this.openThreadOnceReceived) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, false); this.openThreadOnceReceived.clear(); break; } } if (this.props.loggedIn && !prevProps.loggedIn) { void this.ensurePushNotifsEnabled(); } else if (!this.props.localToken && prevProps.localToken) { void this.ensurePushNotifsEnabled(); } else { for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; const prevDeviceToken = prevProps.deviceTokens[keyserverID]; if (!deviceToken && prevDeviceToken) { void this.ensurePushNotifsEnabled(); break; } } } if (!this.props.loggedIn && prevProps.loggedIn) { this.clearAllNotifs(); void this.resetBadgeCount(); } if ( this.state.inAppNotifProps && this.state.inAppNotifProps !== prevState.inAppNotifProps ) { Haptics.notificationAsync(); InAppNotification.show(); } } async updateBadgeCount() { const curThinUnreadCounts = this.props.thinThreadsUnreadCount; const curConnections = this.props.connection; const currentUnreadThickThreads = this.props.unreadThickThreadIDs; const currentTunnelbrokerConnectionStatus = this.props.tunnelbrokerSocketState.connected; const notifStorageUpdates: Array<{ +id: string, +unreadCount: number, }> = []; const notifsStorageQueries: Array = []; for (const keyserverID in curThinUnreadCounts) { if (curConnections[keyserverID]?.status !== 'connected') { notifsStorageQueries.push(keyserverID); continue; } notifStorageUpdates.push({ id: keyserverID, unreadCount: curThinUnreadCounts[keyserverID], }); } let queriedKeyserverData: $ReadOnlyArray<{ +id: string, +unreadCount: number, }> = []; const handleUnreadThickThreadIDsInNotifsStorage = (async () => { if (currentTunnelbrokerConnectionStatus) { await commCoreModule.updateUnreadThickThreadsInNotifsStorage( currentUnreadThickThreads, ); return currentUnreadThickThreads; } return await commCoreModule.getUnreadThickThreadIDsFromNotifsStorage(); })(); let unreadThickThreadIDs: $ReadOnlyArray; try { [queriedKeyserverData, unreadThickThreadIDs] = await Promise.all([ commCoreModule.getKeyserverDataFromNotifStorage(notifsStorageQueries), handleUnreadThickThreadIDsInNotifsStorage, commCoreModule.updateKeyserverDataInNotifStorage(notifStorageUpdates), ]); } catch (e) { if (__DEV__) { Alert.alert( 'MMKV error', 'Failed to update keyserver data in MMKV.' + e.message, ); } console.log(e); return; } let totalUnreadCount = 0; for (const keyserverData of notifStorageUpdates) { totalUnreadCount += keyserverData.unreadCount; } for (const keyserverData of queriedKeyserverData) { totalUnreadCount += keyserverData.unreadCount; } totalUnreadCount += unreadThickThreadIDs.length; if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(totalUnreadCount); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(totalUnreadCount); } } async resetBadgeCount() { const keyserversDataToRemove = Object.keys( this.props.thinThreadsUnreadCount, ); try { await commCoreModule.removeKeyserverDataFromNotifStorage( keyserversDataToRemove, ); } catch (e) { if (__DEV__) { Alert.alert( 'MMKV error', 'Failed to remove keyserver from MMKV.' + e.message, ); } console.log(e); return; } if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(0); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(0); } } clearAllNotifs() { if (Platform.OS === 'ios') { CommIOSNotifications.removeAllDeliveredNotifications(); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllDeliveredNotifications(); } } clearNotifsOfThread() { const { activeThread } = this.props; if (!activeThread) { return; } if (Platform.OS === 'ios') { CommIOSNotifications.getDeliveredNotifications(notifications => PushHandler.clearDeliveredIOSNotificationsForThread( activeThread, notifications, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllActiveNotificationsForThread( activeThread, ); } } static clearDeliveredIOSNotificationsForThread( threadID: string, notifications: $ReadOnlyArray, ) { const identifiersToClear = []; for (const notification of notifications) { if (notification.threadID === threadID) { identifiersToClear.push(notification.identifier); } } if (identifiersToClear) { CommIOSNotifications.removeDeliveredNotifications(identifiersToClear); } } async ensurePushNotifsEnabled() { if (!this.props.loggedIn) { return; } if (Platform.OS === 'android') { await this.ensureAndroidPushNotifsEnabled(); return; } if (Platform.OS !== 'ios') { return; } let missingDeviceToken = !this.props.localToken; if (!missingDeviceToken) { for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken === null || deviceToken === undefined) { missingDeviceToken = true; break; } } } await requestIOSPushPermissions(missingDeviceToken); } async ensureAndroidPushNotifsEnabled() { const permissionPromisesResult = await Promise.all([ CommAndroidNotifications.hasPermission(), CommAndroidNotifications.canRequestNotificationsPermissionFromUser(), ]); let [hasPermission] = permissionPromisesResult; const [, canRequestPermission] = permissionPromisesResult; if (!hasPermission && canRequestPermission) { const permissionResponse = await (async () => { // We issue a call to sleep to match iOS behavior where prompt // doesn't appear immediately but after logged-out modal disappears await sleep(10); return await this.requestAndroidNotificationsPermission(); })(); hasPermission = permissionResponse; } if (!hasPermission) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); return; } try { const fcmToken = await CommAndroidNotifications.getToken(); await this.handleAndroidDeviceToken(fcmToken); } catch (e) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); } } requestAndroidNotificationsPermission = (): Promise => { if (!this.androidNotificationsPermissionPromise) { this.androidNotificationsPermissionPromise = (async () => { const notifPermission = await CommAndroidNotifications.requestNotificationsPermission(); this.androidNotificationsPermissionPromise = undefined; return notifPermission; })(); } return this.androidNotificationsPermissionPromise; }; handleAndroidDeviceToken = async (deviceToken: string) => { this.registerPushPermissions(deviceToken); await this.handleInitialAndroidNotification(); }; async handleInitialAndroidNotification() { if (this.initialAndroidNotifHandled) { return; } this.initialAndroidNotifHandled = true; const initialNotifThreadID = await CommAndroidNotifications.getInitialNotificationThreadID(); if (initialNotifThreadID) { await this.androidNotificationOpened(initialNotifThreadID); } } registerPushPermissions = (deviceToken: ?string) => { const deviceType = Platform.OS; if (deviceType !== 'android' && deviceType !== 'ios') { return; } if (deviceType === 'ios') { iosPushPermissionResponseReceived(); } const deviceTokensMap: { [string]: ?string } = {}; for (const keyserverID in this.props.deviceTokens) { const keyserverDeviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken !== keyserverDeviceToken) { deviceTokensMap[keyserverID] = deviceToken; } } this.setDeviceToken(deviceTokensMap, { type: 'device_token', deviceToken }); }; setDeviceToken( deviceTokens: DeviceTokens, payload: SetDeviceTokenStartedPayload, ) { void this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceToken(deviceTokens), undefined, payload, ); } setAllDeviceTokensNull = () => { void this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceTokenFanout(null), undefined, { type: 'clear_device_token' }, ); }; failedToRegisterPushPermissionsIOS = () => { this.setAllDeviceTokensNull(); if (!this.props.loggedIn) { return; } iosPushPermissionResponseReceived(); }; failedToRegisterPushPermissionsAndroid = ( shouldShowAlertOnAndroid: boolean, ) => { this.setAllDeviceTokensNull(); if (!this.props.loggedIn) { return; } if (shouldShowAlertOnAndroid) { this.showNotifAlertOnAndroid(); } }; showNotifAlertOnAndroid() { const alertInfo = this.props.notifPermissionAlertInfo; if (shouldSkipPushPermissionAlert(alertInfo)) { return; } const payload: RecordAlertActionPayload = { alertType: alertTypes.NOTIF_PERMISSION, time: Date.now(), }; this.props.dispatch({ type: recordAlertActionType, payload, }); Alert.alert( 'Unable to initialize notifs!', 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.', undefined, { cancelable: true }, ); } navigateToThread(threadInfo: ThreadInfo, clearChatRoutes: boolean) { if (clearChatRoutes) { this.props.navigation.dispatch({ type: replaceWithThreadActionType, payload: { threadInfo }, }); } else { this.props.navigateToThread({ threadInfo }); } } onPressNotificationForThread(threadID: string, clearChatRoutes: boolean) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, clearChatRoutes); } else { this.openThreadOnceReceived.add(threadID); } } saveMessageInfos(rawMessageInfos: ?$ReadOnlyArray) { if (!rawMessageInfos) { return; } const keyserverIDToMessageInfos = _groupBy(messageInfos => extractKeyserverIDFromIDOptional(messageInfos.threadID), )(rawMessageInfos); for (const keyserverID in keyserverIDToMessageInfos) { const updatesCurrentAsOf = this.props.allUpdatesCurrentAsOf[keyserverID]; const messageInfos = keyserverIDToMessageInfos[keyserverID]; if (!updatesCurrentAsOf) { continue; } this.props.dispatch({ type: saveMessagesActionType, payload: { rawMessageInfos: messageInfos, updatesCurrentAsOf }, }); } } iosForegroundNotificationReceived = ( rawNotification: CoreIOSNotificationData, ) => { const notification = new CommIOSNotification(rawNotification); if (Date.now() < this.appStarted + 1500) { // On iOS, when the app is opened from a notif press, for some reason this // callback gets triggered before iosNotificationOpened. In fact this // callback shouldn't be triggered at all. To avoid weirdness we are // ignoring any foreground notification received within the first second // of the app being started, since they are most likely to be erroneous. notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NO_DATA, ); return; } const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); let title = notification.getData().title; let body = notification.getData().body; if (title && body) { ({ title, body } = mergePrefixIntoBody({ title, body })); } else { body = notification.getMessage(); } if (body) { this.showInAppNotification(threadID, body, title); } else { console.log( 'Non-rescind foreground notification without alert received!', ); } notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; iosBackgroundNotificationReceived = ( backgroundData: CoreIOSNotificationBackgroundData, ) => { const convertedMessageInfos = backgroundData.messageInfosArray .flatMap(convertNotificationMessageInfoToNewIDSchema) .filter(Boolean); if (!convertedMessageInfos.length) { return; } this.saveMessageInfos(convertedMessageInfos); }; onPushNotifBootsApp() { if ( this.props.rootContext && this.props.rootContext.detectUnsupervisedBackground ) { this.props.rootContext.detectUnsupervisedBackground(false); } } iosNotificationOpened = (rawNotification: CoreIOSNotificationData) => { const notification = new CommIOSNotification(rawNotification); this.onPushNotifBootsApp(); const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); this.onPressNotificationForThread(threadID, true); notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; showInAppNotification(threadID: string, message: string, title?: ?string) { if (threadID === this.props.activeThread) { return; } this.setState({ inAppNotifProps: { customComponent: ( ), blurType: this.props.activeTheme === 'dark' ? 'xlight' : 'dark', onPress: () => { InAppNotification.hide(); this.onPressNotificationForThread(threadID, false); }, }, }); } androidNotificationOpened = async (threadID: string) => { const convertedThreadID = convertNonPendingIDToNewSchema( threadID, authoritativeKeyserverID, ); this.onPushNotifBootsApp(); this.onPressNotificationForThread(convertedThreadID, true); }; androidMessageReceived = async (message: AndroidMessage) => { const parsedMessage = parseAndroidMessage(message); this.onPushNotifBootsApp(); const { messageInfos } = parsedMessage; this.saveMessageInfos(messageInfos); handleAndroidMessage(parsedMessage, this.handleAndroidNotificationIfActive); }; handleAndroidNotificationIfActive = ( threadID: string, texts: { body: string, title: ?string }, ): boolean => { if (this.currentState !== 'active') { return false; } this.showInAppNotification(threadID, texts.body, texts.title); return true; }; render(): React.Node { return ( ); } } const ConnectedPushHandler: React.ComponentType = React.memo(function ConnectedPushHandler(props: BaseProps) { const navContext = React.useContext(NavContext); const activeThread = activeMessageListSelector(navContext); const thinThreadsUnreadCount = useSelector(thinThreadsUnreadCountSelector); const unreadThickThreadIDs = useSelector(unreadThickThreadIDsSelector); const connection = useSelector(allConnectionInfosSelector); const deviceTokens = useSelector(deviceTokensSelector); const threadInfos = useSelector(threadInfoSelector); const notifPermissionAlertInfo = useSelector( state => state.alertStore.alertInfos[alertTypes.NOTIF_PERMISSION], ); const allUpdatesCurrentAsOf = useSelector(allUpdatesCurrentAsOfSelector); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const loggedIn = useSelector(isLoggedIn); const localToken = useSelector( state => state.tunnelbrokerDeviceToken.localToken, ); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceToken(); const callSetDeviceTokenFanout = useSetDeviceTokenFanout(); const rootContext = React.useContext(RootContext); const { socketState: tunnelbrokerSocketState } = useTunnelbroker(); return ( ); }); export default ConnectedPushHandler;