diff --git a/keyserver/src/push/crypto.js b/keyserver/src/push/crypto.js index cb4516822..faad2bfd7 100644 --- a/keyserver/src/push/crypto.js +++ b/keyserver/src/push/crypto.js @@ -1,502 +1,508 @@ // @flow import type { EncryptResult } from '@commapp/olm'; import apn from '@parse/node-apn'; import crypto from 'crypto'; import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep.js'; import type { PlainTextWebNotification, PlainTextWebNotificationPayload, WebNotification, PlainTextWNSNotification, PlainTextWNSNotificationPayload, WNSNotification, } from 'lib/types/notif-types.js'; import { toBase64URL } from 'lib/utils/base64.js'; import type { AndroidNotification, AndroidNotificationPayload, AndroidNotificationRescind, NotificationTargetDevice, } from './types.js'; import { encryptAndUpdateOlmSession } from '../updaters/olm-session-updater.js'; import { encrypt, generateKey } from '../utils/aes-crypto-utils.js'; import { getOlmUtility } from '../utils/olm-utils.js'; async function encryptAPNsNotification( cookieID: string, notification: apn.Notification, codeVersion?: ?number, notificationSizeValidator?: apn.Notification => boolean, blobHolder?: ?string, ): Promise<{ +notification: apn.Notification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, +encryptionOrder?: number, }> { invariant( !notification.collapseId, `Collapse ID can't be directly stored in apn.Notification object due ` + `to security reasons. Please put it in payload property`, ); const encryptedNotification = new apn.Notification(); encryptedNotification.id = notification.id; encryptedNotification.payload.id = notification.id; if (blobHolder) { encryptedNotification.payload.blobHolder = blobHolder; } encryptedNotification.payload.keyserverID = notification.payload.keyserverID; encryptedNotification.topic = notification.topic; encryptedNotification.sound = notification.aps.sound; encryptedNotification.pushType = 'alert'; encryptedNotification.mutableContent = true; const { id, keyserverID, ...payloadSansUnencryptedData } = notification.payload; const unencryptedPayload = { ...payloadSansUnencryptedData, badge: notification.aps.badge.toString(), merged: notification.body, }; try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); let dbPersistCondition; if (notificationSizeValidator) { dbPersistCondition = ({ serializedPayload, }: { +[string]: EncryptResult, }) => { const notifCopy = _cloneDeep(encryptedNotification); notifCopy.payload.encryptedPayload = serializedPayload.body; return notificationSizeValidator(notifCopy); }; } const { encryptedMessages: { serializedPayload }, dbPersistConditionViolated, encryptionOrder, } = await encryptAndUpdateOlmSession( cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }, dbPersistCondition, ); encryptedNotification.payload.encryptedPayload = serializedPayload.body; if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) { encryptedNotification.aps = { alert: { body: 'ENCRYPTED' }, ...encryptedNotification.aps, }; } const encryptedPayloadHash = getOlmUtility().sha256(serializedPayload.body); return { notification: encryptedNotification, payloadSizeExceeded: !!dbPersistConditionViolated, encryptedPayloadHash, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed: ' + e); encryptedNotification.body = notification.body; encryptedNotification.threadId = notification.payload.threadID; invariant( typeof notification.aps.badge === 'number', 'Unencrypted notification must have badge as a number', ); encryptedNotification.badge = notification.aps.badge; encryptedNotification.payload = { ...encryptedNotification.payload, ...notification.payload, encryptionFailed: 1, }; return { notification: encryptedNotification, payloadSizeExceeded: notificationSizeValidator ? notificationSizeValidator(_cloneDeep(encryptedNotification)) : false, }; } } async function encryptAndroidNotificationPayload( cookieID: string, unencryptedPayload: T, payloadSizeValidator?: (T | { +encryptedPayload: string }) => boolean, ): Promise<{ +resultPayload: T | { +encryptedPayload: string }, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }> { try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); if (!unencryptedSerializedPayload) { return { resultPayload: unencryptedPayload, payloadSizeExceeded: payloadSizeValidator ? payloadSizeValidator(unencryptedPayload) : false, }; } let dbPersistCondition; if (payloadSizeValidator) { dbPersistCondition = ({ serializedPayload, }: { +[string]: EncryptResult, }) => payloadSizeValidator({ encryptedPayload: serializedPayload.body }); } const { encryptedMessages: { serializedPayload }, dbPersistConditionViolated, encryptionOrder, } = await encryptAndUpdateOlmSession( cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }, dbPersistCondition, ); return { resultPayload: { encryptedPayload: serializedPayload.body }, payloadSizeExceeded: !!dbPersistConditionViolated, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed: ' + e); const resultPayload = { encryptionFailed: '1', ...unencryptedPayload, }; return { resultPayload, payloadSizeExceeded: payloadSizeValidator ? payloadSizeValidator(resultPayload) : false, }; } } async function encryptAndroidNotification( cookieID: string, notification: AndroidNotification, notificationSizeValidator?: AndroidNotification => boolean, + blobHolder?: ?string, ): Promise<{ +notification: AndroidNotification, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }> { - const { id, keyserverID, badgeOnly, ...unencryptedPayload } = - notification.data; + const { id, keyserverID, badgeOnly, ...rest } = notification.data; let unencryptedData = { badgeOnly, keyserverID }; if (id) { unencryptedData = { ...unencryptedData, id }; } + let unencryptedPayload = rest; + if (blobHolder) { + unencryptedPayload = { ...unencryptedPayload, blobHolder }; + } + let payloadSizeValidator; if (notificationSizeValidator) { payloadSizeValidator = ( payload: AndroidNotificationPayload | { +encryptedPayload: string }, ) => { return notificationSizeValidator({ data: { ...unencryptedData, ...payload }, }); }; } const { resultPayload, payloadSizeExceeded, encryptionOrder } = await encryptAndroidNotificationPayload( cookieID, unencryptedPayload, payloadSizeValidator, ); return { notification: { data: { ...unencryptedData, ...resultPayload, }, }, payloadSizeExceeded, encryptionOrder, }; } async function encryptAndroidNotificationRescind( cookieID: string, notification: AndroidNotificationRescind, ): Promise { // We don't validate payload size for rescind // since they are expected to be small and // never exceed any FCM limit const { keyserverID, ...unencryptedPayload } = notification.data; const { resultPayload } = await encryptAndroidNotificationPayload( cookieID, unencryptedPayload, ); return { data: { keyserverID, ...resultPayload }, }; } async function encryptBasicPayload( cookieID: string, basicPayload: T, ): Promise< | { +encryptedPayload: string, +encryptionOrder?: number } | { ...T, +encryptionFailed: '1' }, > { const unencryptedSerializedPayload = JSON.stringify(basicPayload); if (!unencryptedSerializedPayload) { return { ...basicPayload, encryptionFailed: '1' }; } try { const { encryptedMessages: { serializedPayload }, encryptionOrder, } = await encryptAndUpdateOlmSession(cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }); return { encryptedPayload: serializedPayload.body, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed: ' + e); return { ...basicPayload, encryptionFailed: '1', }; } } async function encryptWebNotification( cookieID: string, notification: PlainTextWebNotification, ): Promise<{ +notification: WebNotification, +encryptionOrder?: number }> { const { id, keyserverID, ...payloadSansId } = notification; const { encryptionOrder, ...encryptionResult } = await encryptBasicPayload( cookieID, payloadSansId, ); return { notification: { id, keyserverID, ...encryptionResult }, encryptionOrder, }; } async function encryptWNSNotification( cookieID: string, notification: PlainTextWNSNotification, ): Promise<{ +notification: WNSNotification, +encryptionOrder?: number }> { const { keyserverID, ...payloadSansKeyserverID } = notification; const { encryptionOrder, ...encryptionResult } = await encryptBasicPayload( cookieID, payloadSansKeyserverID, ); return { notification: { keyserverID, ...encryptionResult }, encryptionOrder, }; } function prepareEncryptedAPNsNotifications( devices: $ReadOnlyArray, notification: apn.Notification, codeVersion?: ?number, notificationSizeValidator?: apn.Notification => boolean, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: apn.Notification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ cookieID, deviceToken, blobHolder }) => { const notif = await encryptAPNsNotification( cookieID, notification, codeVersion, notificationSizeValidator, blobHolder, ); return { cookieID, deviceToken, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedIOSNotificationRescind( devices: $ReadOnlyArray, notification: apn.Notification, codeVersion?: ?number, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: apn.Notification, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const { notification: notif } = await encryptAPNsNotification( cookieID, notification, codeVersion, ); return { deviceToken, cookieID, notification: notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidNotifications( devices: $ReadOnlyArray, notification: AndroidNotification, notificationSizeValidator?: (notification: AndroidNotification) => boolean, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: AndroidNotification, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( - async ({ deviceToken, cookieID }) => { + async ({ deviceToken, cookieID, blobHolder }) => { const notif = await encryptAndroidNotification( cookieID, notification, notificationSizeValidator, + blobHolder, ); return { deviceToken, cookieID, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidNotificationRescinds( devices: $ReadOnlyArray, notification: AndroidNotificationRescind, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: AndroidNotificationRescind, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const notif = await encryptAndroidNotificationRescind( cookieID, notification, ); return { deviceToken, cookieID, notification: notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedWebNotifications( devices: $ReadOnlyArray, notification: PlainTextWebNotification, ): Promise< $ReadOnlyArray<{ +deviceToken: string, +notification: WebNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const notif = await encryptWebNotification(cookieID, notification); return { ...notif, deviceToken }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedWNSNotifications( devices: $ReadOnlyArray, notification: PlainTextWNSNotification, ): Promise< $ReadOnlyArray<{ +deviceToken: string, +notification: WNSNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const notif = await encryptWNSNotification(cookieID, notification); return { ...notif, deviceToken }; }, ); return Promise.all(notificationPromises); } async function encryptBlobPayload(payload: string): Promise<{ +encryptionKey: string, +encryptedPayload: Blob, +encryptedPayloadHash: string, }> { const encryptionKey = await generateKey(); const encryptedPayload = await encrypt( encryptionKey, new TextEncoder().encode(payload), ); const encryptedPayloadBuffer = Buffer.from(encryptedPayload); const blobHashBase64 = await crypto .createHash('sha256') .update(encryptedPayloadBuffer) .digest('base64'); const blobHash = toBase64URL(blobHashBase64); const payloadBlob = new Blob([encryptedPayloadBuffer]); const encryptionKeyString = Buffer.from(encryptionKey).toString('base64'); return { encryptionKey: encryptionKeyString, encryptedPayload: payloadBlob, encryptedPayloadHash: blobHash, }; } export { prepareEncryptedAPNsNotifications, prepareEncryptedIOSNotificationRescind, prepareEncryptedAndroidNotifications, prepareEncryptedAndroidNotificationRescinds, prepareEncryptedWebNotifications, prepareEncryptedWNSNotifications, encryptBlobPayload, }; diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js index fb60aa677..263acc372 100644 --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -1,1932 +1,1944 @@ // @flow import type { ResponseFailure } from '@parse/node-apn'; import apn from '@parse/node-apn'; import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep.js'; import _flow from 'lodash/fp/flow.js'; import _groupBy from 'lodash/fp/groupBy.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _pickBy from 'lodash/fp/pickBy.js'; import type { QueryResults } from 'mysql'; import t from 'tcomb'; import uuidv4 from 'uuid/v4.js'; import { oldValidUsernameRegex } from 'lib/shared/account-utils.js'; import { isUserMentioned } from 'lib/shared/mention-utils.js'; import { createMessageInfo, shimUnsupportedRawMessageInfos, sortMessageInfoList, } from 'lib/shared/message-utils.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { notifTextsForMessageInfo } from 'lib/shared/notif-utils.js'; import { rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion, NEXT_CODE_VERSION, } from 'lib/shared/version-utils.js'; import type { Platform, PlatformDetails } from 'lib/types/device-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type MessageData, type RawMessageInfo, rawMessageInfoValidator, } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ResolvedNotifTexts } from 'lib/types/notif-types.js'; import { resolvedNotifTextsValidator } from 'lib/types/notif-types.js'; import type { ServerThreadInfo } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { type GlobalUserInfo } from 'lib/types/user-types.js'; import { values } from 'lib/utils/objects.js'; import { tID, tPlatformDetails, tShape } from 'lib/utils/validation-utils.js'; import { prepareEncryptedAndroidNotifications, prepareEncryptedAPNsNotifications, prepareEncryptedWebNotifications, prepareEncryptedWNSNotifications, } from './crypto.js'; import { getAPNsNotificationTopic } from './providers.js'; import { rescindPushNotifs } from './rescind.js'; import type { AndroidNotification, NotificationTargetDevice, TargetedAndroidNotification, TargetedAPNsNotification, TargetedWebNotification, TargetedWNSNotification, } from './types.js'; import { apnMaxNotificationPayloadByteSize, apnPush, fcmMaxNotificationPayloadByteSize, fcmPush, getUnreadCounts, webPush, type WebPushError, wnsMaxNotificationPayloadByteSize, wnsPush, type WNSPushError, blobServiceUpload, } from './utils.js'; import createIDs from '../creators/id-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, mergeOrConditions, SQL } from '../database/database.js'; import type { CollapsableNotifInfo } from '../fetchers/message-fetchers.js'; import { fetchCollapsableNotifs } from '../fetchers/message-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { thisKeyserverID } from '../user/identity.js'; import { getENSNames } from '../utils/ens-cache.js'; import { validateOutput } from '../utils/validation-utils.js'; export type Device = { +platform: Platform, +deviceToken: string, +cookieID: string, +codeVersion: ?number, +stateVersion: ?number, +majorDesktopVersion: ?number, }; export type PushUserInfo = { +devices: Device[], // messageInfos and messageDatas have the same key +messageInfos: RawMessageInfo[], +messageDatas: MessageData[], }; type Delivery = PushDelivery | { collapsedInto: string }; type NotificationRow = { +dbID: string, +userID: string, +threadID?: ?string, +messageID?: ?string, +collapseKey?: ?string, +deliveries: Delivery[], }; export type PushInfo = { [userID: string]: PushUserInfo }; async function sendPushNotifs(pushInfo: PushInfo) { if (Object.keys(pushInfo).length === 0) { return; } const keyserverID = await thisKeyserverID(); const [ unreadCounts, { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }, dbIDs, ] = await Promise.all([ getUnreadCounts(Object.keys(pushInfo)), fetchInfos(pushInfo), createDBIDs(pushInfo), ]); const preparePromises: Array>> = []; const notifications: Map = new Map(); for (const userID in usersToCollapsableNotifInfo) { const threadInfos = _flow( _mapValues((serverThreadInfo: ServerThreadInfo) => { const rawThreadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, userID, { minimallyEncodePermissions: true }, ); if (!rawThreadInfo) { return null; } invariant( rawThreadInfo.minimallyEncoded, 'rawThreadInfo from rawThreadInfoFromServerThreadInfo must be ' + 'minimallyEncoded when minimallyEncodePermissions option is set', ); return threadInfoFromRawThreadInfo(rawThreadInfo, userID, userInfos); }), _pickBy(threadInfo => threadInfo), )(serverThreadInfos); for (const notifInfo of usersToCollapsableNotifInfo[userID]) { preparePromises.push( preparePushNotif({ keyserverID, notifInfo, userID, pushUserInfo: pushInfo[userID], unreadCount: unreadCounts[userID], threadInfos, userInfos, dbIDs, rowsToSave: notifications, }), ); } } const prepareResults = await Promise.all(preparePromises); const flattenedPrepareResults = prepareResults.filter(Boolean).flat(); const deliveryResults = await deliverPushNotifsInEncryptionOrder( flattenedPrepareResults, ); const cleanUpPromise = (async () => { if (dbIDs.length === 0) { return; } const query = SQL`DELETE FROM ids WHERE id IN (${dbIDs})`; await dbQuery(query); })(); await Promise.all([ cleanUpPromise, saveNotifResults(deliveryResults, notifications, true), ]); } type PreparePushResult = { +platform: Platform, +notificationInfo: NotificationInfo, +notification: | TargetedAPNsNotification | TargetedAndroidNotification | TargetedWebNotification | TargetedWNSNotification, }; async function preparePushNotif(input: { keyserverID: string, notifInfo: CollapsableNotifInfo, userID: string, pushUserInfo: PushUserInfo, unreadCount: number, threadInfos: { +[threadID: string]: ThreadInfo, }, userInfos: { +[userID: string]: GlobalUserInfo }, dbIDs: string[], // mutable rowsToSave: Map, // mutable }): Promise> { const { keyserverID, notifInfo, userID, pushUserInfo, unreadCount, threadInfos, userInfos, dbIDs, rowsToSave, } = input; const hydrateMessageInfo = (rawMessageInfo: RawMessageInfo) => createMessageInfo(rawMessageInfo, userID, userInfos, threadInfos); const newMessageInfos = []; const newRawMessageInfos = []; for (const newRawMessageInfo of notifInfo.newMessageInfos) { const newMessageInfo = hydrateMessageInfo(newRawMessageInfo); if (newMessageInfo) { newMessageInfos.push(newMessageInfo); newRawMessageInfos.push(newRawMessageInfo); } } if (newMessageInfos.length === 0) { return null; } const existingMessageInfos = notifInfo.existingMessageInfos .map(hydrateMessageInfo) .filter(Boolean); const allMessageInfos = sortMessageInfoList([ ...newMessageInfos, ...existingMessageInfos, ]); const [firstNewMessageInfo, ...remainingNewMessageInfos] = newMessageInfos; const { threadID } = firstNewMessageInfo; const threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; const updateBadge = threadInfo.currentUser.subscription.home; const displayBanner = threadInfo.currentUser.subscription.pushNotifs; const username = userInfos[userID] && userInfos[userID].username; let resolvedUsername; if (getENSNames) { const userInfosWithENSNames = await getENSNames([userInfos[userID]]); resolvedUsername = userInfosWithENSNames[0].username; } const userWasMentioned = username && threadInfo.currentUser.role && oldValidUsernameRegex.test(username) && newMessageInfos.some(newMessageInfo => { const unwrappedMessageInfo = newMessageInfo.type === messageTypes.SIDEBAR_SOURCE ? newMessageInfo.sourceMessage : newMessageInfo; return ( unwrappedMessageInfo.type === messageTypes.TEXT && (isUserMentioned(username, unwrappedMessageInfo.text) || (resolvedUsername && isUserMentioned(resolvedUsername, unwrappedMessageInfo.text))) ); }); if (!updateBadge && !displayBanner && !userWasMentioned) { return null; } const badgeOnly = !displayBanner && !userWasMentioned; const notifTargetUserInfo = { id: userID, username }; const notifTexts = await notifTextsForMessageInfo( allMessageInfos, threadInfo, parentThreadInfo, notifTargetUserInfo, getENSNames, ); if (!notifTexts) { return null; } const dbID = dbIDs.shift(); invariant(dbID, 'should have sufficient DB IDs'); const byPlatform = getDevicesByPlatform(pushUserInfo.devices); const firstMessageID = firstNewMessageInfo.id; invariant(firstMessageID, 'RawMessageInfo.id should be set on server'); const notificationInfo = { source: 'new_message', dbID, userID, threadID, messageID: firstMessageID, collapseKey: notifInfo.collapseKey, }; const preparePromises: Array>> = []; const iosVersionsToTokens = byPlatform.get('ios'); if (iosVersionsToTokens) { for (const [versionKey, devices] of iosVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails: PlatformDetails = { platform: 'ios', codeVersion, stateVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAPNsNotification( { keyserverID, notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'ios', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const androidVersionsToTokens = byPlatform.get('android'); if (androidVersionsToTokens) { for (const [versionKey, devices] of androidVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'android', codeVersion, stateVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAndroidNotification( { keyserverID, notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, unreadCount, platformDetails, dbID, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'android', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const webVersionsToTokens = byPlatform.get('web'); if (webVersionsToTokens) { for (const [versionKey, devices] of webVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'web', codeVersion, stateVersion, }; const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareWebNotification( { notifTexts, threadID: threadInfo.id, keyserverID, unreadCount, platformDetails, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'web', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [versionKey, devices] of macosVersionsToTokens) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'macos', codeVersion, stateVersion, majorDesktopVersion, }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareAPNsNotification( { keyserverID, notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount, platformDetails, }, devices, ); return targetedNotifications.map(notification => ({ notification, platform: 'macos', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const windowsVersionsToTokens = byPlatform.get('windows'); if (windowsVersionsToTokens) { for (const [versionKey, devices] of windowsVersionsToTokens) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const platformDetails = { platform: 'windows', codeVersion, stateVersion, majorDesktopVersion, }; const preparePromise: Promise<$ReadOnlyArray> = (async () => { const targetedNotifications = await prepareWNSNotification(devices, { notifTexts, threadID: threadInfo.id, keyserverID, unreadCount, platformDetails, }); return targetedNotifications.map(notification => ({ notification, platform: 'windows', notificationInfo: { ...notificationInfo, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } for (const newMessageInfo of remainingNewMessageInfos) { const newDBID = dbIDs.shift(); invariant(newDBID, 'should have sufficient DB IDs'); const messageID = newMessageInfo.id; invariant(messageID, 'RawMessageInfo.id should be set on server'); rowsToSave.set(newDBID, { dbID: newDBID, userID, threadID: newMessageInfo.threadID, messageID, collapseKey: notifInfo.collapseKey, deliveries: [{ collapsedInto: dbID }], }); } const prepareResults = await Promise.all(preparePromises); return prepareResults.flat(); } // For better readability we don't differentiate between // encrypted and unencrypted notifs and order them together function compareEncryptionOrder( pushNotif1: PreparePushResult, pushNotif2: PreparePushResult, ): number { const order1 = pushNotif1.notification.encryptionOrder ?? 0; const order2 = pushNotif2.notification.encryptionOrder ?? 0; return order1 - order2; } async function deliverPushNotifsInEncryptionOrder( preparedPushNotifs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const deliveryPromises: Array>> = []; const groupedByDevice = _groupBy( preparedPushNotif => preparedPushNotif.deviceToken, )(preparedPushNotifs); for (const preparedPushNotifsForDevice of values(groupedByDevice)) { const orderedPushNotifsForDevice = preparedPushNotifsForDevice.sort( compareEncryptionOrder, ); const deviceDeliveryPromise = (async () => { const deliveries = []; for (const preparedPushNotif of orderedPushNotifsForDevice) { const { platform, notification, notificationInfo } = preparedPushNotif; let delivery: PushResult; if (platform === 'ios' || platform === 'macos') { delivery = await sendAPNsNotification( platform, [notification], notificationInfo, ); } else if (platform === 'android') { delivery = await sendAndroidNotification( [notification], notificationInfo, ); } else if (platform === 'web') { delivery = await sendWebNotifications( [notification], notificationInfo, ); } else if (platform === 'windows') { delivery = await sendWNSNotification( [notification], notificationInfo, ); } if (delivery) { deliveries.push(delivery); } } return deliveries; })(); deliveryPromises.push(deviceDeliveryPromise); } const deliveryResults = await Promise.all(deliveryPromises); return deliveryResults.flat(); } async function sendRescindNotifs(rescindInfo: PushInfo) { if (Object.keys(rescindInfo).length === 0) { return; } const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(rescindInfo); const promises = []; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const existingMessageInfo of notifInfo.existingMessageInfos) { const rescindCondition = SQL` n.user = ${userID} AND n.thread = ${existingMessageInfo.threadID} AND n.message = ${existingMessageInfo.id} `; promises.push(rescindPushNotifs(rescindCondition)); } } } await Promise.all(promises); } // The results in deliveryResults will be combined with the rows // in rowsToSave and then written to the notifications table async function saveNotifResults( deliveryResults: $ReadOnlyArray, inputRowsToSave: Map, rescindable: boolean, ) { const rowsToSave = new Map(inputRowsToSave); const allInvalidTokens = []; for (const deliveryResult of deliveryResults) { const { info, delivery, invalidTokens } = deliveryResult; const { dbID, userID } = info; const curNotifRow = rowsToSave.get(dbID); if (curNotifRow) { curNotifRow.deliveries.push(delivery); } else { // Ternary expressions for Flow const threadID = info.threadID ? info.threadID : null; const messageID = info.messageID ? info.messageID : null; const collapseKey = info.collapseKey ? info.collapseKey : null; rowsToSave.set(dbID, { dbID, userID, threadID, messageID, collapseKey, deliveries: [delivery], }); } if (invalidTokens) { allInvalidTokens.push({ userID, tokens: invalidTokens, }); } } const notificationRows = []; for (const notification of rowsToSave.values()) { notificationRows.push([ notification.dbID, notification.userID, notification.threadID, notification.messageID, notification.collapseKey, JSON.stringify(notification.deliveries), Number(!rescindable), ]); } const dbPromises: Array> = []; if (allInvalidTokens.length > 0) { dbPromises.push(removeInvalidTokens(allInvalidTokens)); } if (notificationRows.length > 0) { const query = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${notificationRows} `; dbPromises.push(dbQuery(query)); } if (dbPromises.length > 0) { await Promise.all(dbPromises); } } async function fetchInfos(pushInfo: PushInfo) { const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(pushInfo); const threadIDs = new Set(); const threadWithChangedNamesToMessages = new Map>(); const addThreadIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { const threadID = rawMessageInfo.threadID; threadIDs.add(threadID); const messageSpec = messageSpecs[rawMessageInfo.type]; if (messageSpec.threadIDs) { for (const id of messageSpec.threadIDs(rawMessageInfo)) { threadIDs.add(id); } } if ( rawMessageInfo.type === messageTypes.CHANGE_SETTINGS && rawMessageInfo.field === 'name' ) { const messages = threadWithChangedNamesToMessages.get(threadID); if (messages) { messages.push(rawMessageInfo.id); } else { threadWithChangedNamesToMessages.set(threadID, [rawMessageInfo.id]); } } }; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } } } // These threadInfos won't have currentUser set const threadPromise = fetchServerThreadInfos({ threadIDs }); const oldNamesPromise: Promise = (async () => { if (threadWithChangedNamesToMessages.size === 0) { return undefined; } const typesThatAffectName = [ messageTypes.CHANGE_SETTINGS, messageTypes.CREATE_THREAD, ]; const oldNameQuery = SQL` SELECT IF( JSON_TYPE(JSON_EXTRACT(m.content, "$.name")) = 'NULL', "", JSON_UNQUOTE(JSON_EXTRACT(m.content, "$.name")) ) AS name, m.thread FROM ( SELECT MAX(id) AS id FROM messages WHERE type IN (${typesThatAffectName}) AND JSON_EXTRACT(content, "$.name") IS NOT NULL AND`; const threadClauses = []; for (const [threadID, messages] of threadWithChangedNamesToMessages) { threadClauses.push( SQL`(thread = ${threadID} AND id NOT IN (${messages}))`, ); } oldNameQuery.append(mergeOrConditions(threadClauses)); oldNameQuery.append(SQL` GROUP BY thread ) x LEFT JOIN messages m ON m.id = x.id `); return await dbQuery(oldNameQuery); })(); const [threadResult, oldNames] = await Promise.all([ threadPromise, oldNamesPromise, ]); const serverThreadInfos = { ...threadResult.threadInfos }; if (oldNames) { const [result] = oldNames; for (const row of result) { const threadID = row.thread.toString(); serverThreadInfos[threadID] = { ...serverThreadInfos[threadID], name: row.name, }; } } const userInfos = await fetchNotifUserInfos( serverThreadInfos, usersToCollapsableNotifInfo, ); return { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }; } async function fetchNotifUserInfos( serverThreadInfos: { +[threadID: string]: ServerThreadInfo }, usersToCollapsableNotifInfo: { +[userID: string]: CollapsableNotifInfo[] }, ) { const missingUserIDs = new Set(); for (const threadID in serverThreadInfos) { const serverThreadInfo = serverThreadInfos[threadID]; for (const member of serverThreadInfo.members) { missingUserIDs.add(member.id); } } const addUserIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { missingUserIDs.add(rawMessageInfo.creatorID); const userIDs = messageSpecs[rawMessageInfo.type].userIDs?.(rawMessageInfo) ?? []; for (const userID of userIDs) { missingUserIDs.add(userID); } }; for (const userID in usersToCollapsableNotifInfo) { missingUserIDs.add(userID); for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } } } return await fetchUserInfos([...missingUserIDs]); } async function createDBIDs(pushInfo: PushInfo): Promise { let numIDsNeeded = 0; for (const userID in pushInfo) { numIDsNeeded += pushInfo[userID].messageInfos.length; } return await createIDs('notifications', numIDsNeeded); } type VersionKey = { +codeVersion: number, +stateVersion: number, +majorDesktopVersion?: number, }; const versionKeyRegex: RegExp = new RegExp(/^-?\d+\|-?\d+(\|-?\d+)?$/); function versionKeyToString(versionKey: VersionKey): string { const baseStringVersionKey = `${versionKey.codeVersion}|${versionKey.stateVersion}`; if (!versionKey.majorDesktopVersion) { return baseStringVersionKey; } return `${baseStringVersionKey}|${versionKey.majorDesktopVersion}`; } function stringToVersionKey(versionKeyString: string): VersionKey { invariant( versionKeyRegex.test(versionKeyString), 'should pass correct version key string', ); const [codeVersion, stateVersion, majorDesktopVersion] = versionKeyString .split('|') .map(Number); return { codeVersion, stateVersion, majorDesktopVersion }; } function getDevicesByPlatform( devices: $ReadOnlyArray, ): Map>> { const byPlatform = new Map< Platform, Map>, >(); for (const device of devices) { let innerMap = byPlatform.get(device.platform); if (!innerMap) { innerMap = new Map>(); byPlatform.set(device.platform, innerMap); } const codeVersion: number = device.codeVersion !== null && device.codeVersion !== undefined ? device.codeVersion : -1; const stateVersion: number = device.stateVersion ?? -1; let versionsObject = { codeVersion, stateVersion }; if (device.majorDesktopVersion) { versionsObject = { ...versionsObject, majorDesktopVersion: device.majorDesktopVersion, }; } const versionKey = versionKeyToString(versionsObject); let innerMostArrayTmp: ?Array = innerMap.get(versionKey); if (!innerMostArrayTmp) { innerMostArrayTmp = []; innerMap.set(versionKey, innerMostArrayTmp); } const innerMostArray = innerMostArrayTmp; innerMostArray.push({ cookieID: device.cookieID, deviceToken: device.deviceToken, }); } return byPlatform; } type CommonNativeNotifInputData = { +keyserverID: string, +notifTexts: ResolvedNotifTexts, +newRawMessageInfos: RawMessageInfo[], +threadID: string, +collapseKey: ?string, +unreadCount: number, +platformDetails: PlatformDetails, }; const commonNativeNotifInputDataValidator = tShape({ keyserverID: t.String, notifTexts: resolvedNotifTextsValidator, newRawMessageInfos: t.list(rawMessageInfoValidator), threadID: tID, collapseKey: t.maybe(t.String), unreadCount: t.Number, platformDetails: tPlatformDetails, }); type APNsNotifInputData = { ...CommonNativeNotifInputData, +badgeOnly: boolean, }; const apnsNotifInputDataValidator = tShape({ ...commonNativeNotifInputDataValidator.meta.props, badgeOnly: t.Boolean, }); async function prepareAPNsNotification( inputData: APNsNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, apnsNotifInputDataValidator, inputData, ); const { keyserverID, notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails, } = convertedData; const canDecryptNonCollapsibleTextIOSNotifs = platformDetails.codeVersion && platformDetails.codeVersion > 222; const isNonCollapsibleTextNotification = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllIOSNotifs = platformDetails.codeVersion && platformDetails.codeVersion >= 267; const canDecryptIOSNotif = platformDetails.platform === 'ios' && (canDecryptAllIOSNotifs || (isNonCollapsibleTextNotification && canDecryptNonCollapsibleTextIOSNotifs)); const canDecryptMacOSNotifs = platformDetails.platform === 'macos' && hasMinCodeVersion(platformDetails, { web: 47, majorDesktop: 9, }); const shouldBeEncrypted = canDecryptIOSNotif || canDecryptMacOSNotifs; const uniqueID = uuidv4(); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic(platformDetails); const { merged, ...rest } = notifTexts; // We don't include alert's body on macos because we // handle displaying the notification ourselves and // we don't want macOS to display it automatically. if (!badgeOnly && platformDetails.platform !== 'macos') { notification.body = merged; notification.sound = 'default'; } notification.payload = { ...notification.payload, ...rest, }; notification.badge = unreadCount; notification.threadId = threadID; notification.id = uniqueID; notification.pushType = 'alert'; notification.payload.id = uniqueID; notification.payload.threadID = threadID; notification.payload.keyserverID = keyserverID; if (platformDetails.codeVersion && platformDetails.codeVersion > 198) { notification.mutableContent = true; } if (collapseKey && (canDecryptAllIOSNotifs || canDecryptMacOSNotifs)) { notification.payload.collapseID = collapseKey; } else if (collapseKey) { notification.collapseId = collapseKey; } const messageInfos = JSON.stringify(newRawMessageInfos); // We make a copy before checking notification's length, because calling // length compiles the notification and makes it immutable. Further // changes to its properties won't be reflected in the final plaintext // data that is sent. const copyWithMessageInfos = _cloneDeep(notification); copyWithMessageInfos.payload = { ...copyWithMessageInfos.payload, messageInfos, }; const notificationSizeValidator = (notif: apn.Notification) => notif.length() <= apnMaxNotificationPayloadByteSize; if (!shouldBeEncrypted) { const notificationToSend = notificationSizeValidator( _cloneDeep(copyWithMessageInfos), ) ? copyWithMessageInfos : notification; return devices.map(({ deviceToken }) => ({ notification: notificationToSend, deviceToken, })); } // The `messageInfos` field in notification payload is // not used on MacOS so we can return early. if (platformDetails.platform === 'macos') { const macOSNotifsWithoutMessageInfos = await prepareEncryptedAPNsNotifications( devices, notification, platformDetails.codeVersion, ); return macOSNotifsWithoutMessageInfos.map( ({ notification: notif, deviceToken }) => ({ notification: notif, deviceToken, }), ); } const notifsWithMessageInfos = await prepareEncryptedAPNsNotifications( devices, copyWithMessageInfos, platformDetails.codeVersion, notificationSizeValidator, ); const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ deviceToken, cookieID }) => ({ deviceToken, cookieID })); if (devicesWithExcessiveSizeNoHolders.length === 0) { return notifsWithMessageInfos.map( ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }), ); } const canQueryBlobService = hasMinCodeVersion(platformDetails, { native: NEXT_CODE_VERSION, }); let blobHash, blobHolders, encryptionKey, blobUploadError; if (canQueryBlobService) { ({ blobHash, blobHolders, encryptionKey, blobUploadError } = await blobServiceUpload( copyWithMessageInfos.compile(), devicesWithExcessiveSizeNoHolders.length, )); } if (blobUploadError) { console.warn( `Failed to upload payload of notification: ${uniqueID} ` + `due to error: ${blobUploadError}`, ); } let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; if ( blobHash && encryptionKey && blobHolders && blobHolders.length === devicesWithExcessiveSize.length ) { notification.payload = { ...notification.payload, blobHash, encryptionKey, }; devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ ...devicesWithExcessiveSize[idx], blobHolder: holder, })); } const notifsWithoutMessageInfos = await prepareEncryptedAPNsNotifications( devicesWithExcessiveSize, notification, platformDetails.codeVersion, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map( ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }), ); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deviceToken, encryptedPayloadHash, encryptionOrder, }), ); return [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; } type AndroidNotifInputData = { ...CommonNativeNotifInputData, +dbID: string, }; const androidNotifInputDataValidator = tShape({ ...commonNativeNotifInputDataValidator.meta.props, dbID: t.String, }); async function prepareAndroidNotification( inputData: AndroidNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, androidNotifInputDataValidator, inputData, ); const { keyserverID, notifTexts, newRawMessageInfos, threadID, collapseKey, unreadCount, platformDetails, dbID, } = convertedData; const canDecryptNonCollapsibleTextNotifs = hasMinCodeVersion( platformDetails, { native: 228 }, ); const isNonCollapsibleTextNotif = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllNotifTypes = hasMinCodeVersion(platformDetails, { native: 267, }); const shouldBeEncrypted = canDecryptAllNotifTypes || (canDecryptNonCollapsibleTextNotifs && isNonCollapsibleTextNotif); const { merged, ...rest } = notifTexts; const notification = { data: { keyserverID, badge: unreadCount.toString(), ...rest, threadID, }, }; let notifID; if (collapseKey && canDecryptAllNotifTypes) { notifID = dbID; notification.data = { ...notification.data, collapseKey, }; } else if (collapseKey) { notifID = collapseKey; } else { notifID = dbID; } notification.data = { ...notification.data, id: notifID, badgeOnly: '0', }; const messageInfos = JSON.stringify(newRawMessageInfos); const copyWithMessageInfos = { ...notification, data: { ...notification.data, messageInfos }, }; if (!shouldBeEncrypted) { const notificationToSend = Buffer.byteLength(JSON.stringify(copyWithMessageInfos)) <= fcmMaxNotificationPayloadByteSize ? copyWithMessageInfos : notification; return devices.map(({ deviceToken }) => ({ notification: notificationToSend, deviceToken, })); } const notificationsSizeValidator = (notif: AndroidNotification) => { const serializedNotif = JSON.stringify(notif); return ( !serializedNotif || Buffer.byteLength(serializedNotif) <= fcmMaxNotificationPayloadByteSize ); }; const notifsWithMessageInfos = await prepareEncryptedAndroidNotifications( devices, copyWithMessageInfos, notificationsSizeValidator, ); - const devicesWithExcessiveSize = notifsWithMessageInfos + const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ cookieID, deviceToken }) => ({ cookieID, deviceToken })); - if (devicesWithExcessiveSize.length === 0) { + if (devicesWithExcessiveSizeNoHolders.length === 0) { return notifsWithMessageInfos.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); } const canQueryBlobService = hasMinCodeVersion(platformDetails, { native: NEXT_CODE_VERSION, }); - let blobHash, encryptionKey, blobUploadError; + let blobHash, blobHolders, encryptionKey, blobUploadError; if (canQueryBlobService) { - ({ blobHash, encryptionKey, blobUploadError } = await blobServiceUpload( - JSON.stringify(copyWithMessageInfos.data), - 1, - )); + ({ blobHash, blobHolders, encryptionKey, blobUploadError } = + await blobServiceUpload( + JSON.stringify(copyWithMessageInfos.data), + devicesWithExcessiveSizeNoHolders.length, + )); } if (blobUploadError) { console.warn( `Failed to upload payload of notification: ${notifID} ` + `due to error: ${blobUploadError}`, ); } - if (blobHash && encryptionKey) { + let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; + if ( + blobHash && + encryptionKey && + blobHolders && + blobHolders.length === devicesWithExcessiveSizeNoHolders.length + ) { notification.data = { ...notification.data, blobHash, encryptionKey, }; + + devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ + ...devicesWithExcessiveSize[idx], + blobHolder: holder, + })); } const notifsWithoutMessageInfos = await prepareEncryptedAndroidNotifications( devicesWithExcessiveSize, notification, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map(({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, })); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); return [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; } type WebNotifInputData = { +notifTexts: ResolvedNotifTexts, +threadID: string, +keyserverID: string, +unreadCount: number, +platformDetails: PlatformDetails, }; const webNotifInputDataValidator = tShape({ notifTexts: resolvedNotifTextsValidator, threadID: tID, keyserverID: t.String, unreadCount: t.Number, platformDetails: tPlatformDetails, }); async function prepareWebNotification( inputData: WebNotifInputData, devices: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, webNotifInputDataValidator, inputData, ); const { notifTexts, threadID, unreadCount, keyserverID } = convertedData; const id = uuidv4(); const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, id, threadID, keyserverID, }; const shouldBeEncrypted = hasMinCodeVersion(convertedData.platformDetails, { web: 43, }); if (!shouldBeEncrypted) { return devices.map(({ deviceToken }) => ({ deviceToken, notification })); } return prepareEncryptedWebNotifications(devices, notification); } type WNSNotifInputData = { +notifTexts: ResolvedNotifTexts, +threadID: string, +keyserverID: string, +unreadCount: number, +platformDetails: PlatformDetails, }; const wnsNotifInputDataValidator = tShape({ notifTexts: resolvedNotifTextsValidator, threadID: tID, keyserverID: t.String, unreadCount: t.Number, platformDetails: tPlatformDetails, }); async function prepareWNSNotification( devices: $ReadOnlyArray, inputData: WNSNotifInputData, ): Promise<$ReadOnlyArray> { const convertedData = await validateOutput( inputData.platformDetails, wnsNotifInputDataValidator, inputData, ); const { notifTexts, threadID, unreadCount, keyserverID } = convertedData; const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, threadID, keyserverID, }; if ( Buffer.byteLength(JSON.stringify(notification)) > wnsMaxNotificationPayloadByteSize ) { console.warn('WNS notification exceeds size limit'); } const shouldBeEncrypted = hasMinCodeVersion(inputData.platformDetails, { majorDesktop: 10, }); if (!shouldBeEncrypted) { return devices.map(({ deviceToken }) => ({ deviceToken, notification, })); } return await prepareEncryptedWNSNotifications(devices, notification); } type NotificationInfo = | { +source: 'new_message', +dbID: string, +userID: string, +threadID: string, +messageID: string, +collapseKey: ?string, +codeVersion: number, +stateVersion: number, } | { +source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', +dbID: string, +userID: string, +codeVersion: number, +stateVersion: number, }; type APNsDelivery = { +source: $PropertyType, +deviceType: 'ios' | 'macos', +iosID: string, +deviceTokens: $ReadOnlyArray, +codeVersion: number, +stateVersion: number, +errors?: $ReadOnlyArray, +encryptedPayloadHashes?: $ReadOnlyArray, +deviceTokensToPayloadHash?: { +[deviceToken: string]: string, }, }; type APNsResult = { info: NotificationInfo, delivery: APNsDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendAPNsNotification( platform: 'ios' | 'macos', targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await apnPush({ targetedNotifications, platformDetails: { platform, codeVersion }, }); invariant( new Set(targetedNotifications.map(({ notification }) => notification.id)) .size === 1, 'Encrypted versions of the same notification must share id value', ); const iosID = targetedNotifications[0].notification.id; const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); let delivery: APNsDelivery = { source, deviceType: platform, iosID, deviceTokens, codeVersion, stateVersion, }; if (response.errors) { delivery = { ...delivery, errors: response.errors, }; } const deviceTokensToPayloadHash: { [string]: string } = {}; for (const targetedNotification of targetedNotifications) { if (targetedNotification.encryptedPayloadHash) { deviceTokensToPayloadHash[targetedNotification.deviceToken] = targetedNotification.encryptedPayloadHash; } } if (Object.keys(deviceTokensToPayloadHash).length !== 0) { delivery = { ...delivery, deviceTokensToPayloadHash, }; } const result: APNsResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type PushResult = AndroidResult | APNsResult | WebResult | WNSResult; type PushDelivery = AndroidDelivery | APNsDelivery | WebDelivery | WNSDelivery; type AndroidDelivery = { source: $PropertyType, deviceType: 'android', androidIDs: $ReadOnlyArray, deviceTokens: $ReadOnlyArray, codeVersion: number, stateVersion: number, errors?: $ReadOnlyArray, }; type AndroidResult = { info: NotificationInfo, delivery: AndroidDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendAndroidNotification( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const collapseKey = notificationInfo.collapseKey ? notificationInfo.collapseKey : null; // for Flow... const { source, codeVersion, stateVersion } = notificationInfo; const response = await fcmPush({ targetedNotifications, collapseKey, codeVersion, }); const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); const androidIDs = response.fcmIDs ? response.fcmIDs : []; const delivery: AndroidDelivery = { source, deviceType: 'android', androidIDs, deviceTokens, codeVersion, stateVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: AndroidResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type WebDelivery = { +source: $PropertyType, +deviceType: 'web', +deviceTokens: $ReadOnlyArray, +codeVersion?: number, +stateVersion: number, +errors?: $ReadOnlyArray, }; type WebResult = { +info: NotificationInfo, +delivery: WebDelivery, +invalidTokens?: $ReadOnlyArray, }; async function sendWebNotifications( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await webPush(targetedNotifications); const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); const delivery: WebDelivery = { source, deviceType: 'web', deviceTokens, codeVersion, errors: response.errors, stateVersion, }; const result: WebResult = { info: notificationInfo, delivery, invalidTokens: response.invalidTokens, }; return result; } type WNSDelivery = { +source: $PropertyType, +deviceType: 'windows', +wnsIDs: $ReadOnlyArray, +deviceTokens: $ReadOnlyArray, +codeVersion?: number, +stateVersion: number, +errors?: $ReadOnlyArray, }; type WNSResult = { +info: NotificationInfo, +delivery: WNSDelivery, +invalidTokens?: $ReadOnlyArray, }; async function sendWNSNotification( targetedNotifications: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion, stateVersion } = notificationInfo; const response = await wnsPush(targetedNotifications); const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); const wnsIDs = response.wnsIDs ?? []; const delivery: WNSDelivery = { source, deviceType: 'windows', wnsIDs, deviceTokens, codeVersion, errors: response.errors, stateVersion, }; const result: WNSResult = { info: notificationInfo, delivery, invalidTokens: response.invalidTokens, }; return result; } type InvalidToken = { +userID: string, +tokens: $ReadOnlyArray, }; async function removeInvalidTokens( invalidTokens: $ReadOnlyArray, ): Promise { const sqlTuples = invalidTokens.map( invalidTokenUser => SQL`( user = ${invalidTokenUser.userID} AND device_token IN (${invalidTokenUser.tokens}) )`, ); const sqlCondition = mergeOrConditions(sqlTuples); const selectQuery = SQL` SELECT id, user, device_token FROM cookies WHERE `; selectQuery.append(sqlCondition); const [result] = await dbQuery(selectQuery); const userCookiePairsToInvalidDeviceTokens = new Map>(); for (const row of result) { const userCookiePair = `${row.user}|${row.id}`; const existing = userCookiePairsToInvalidDeviceTokens.get(userCookiePair); if (existing) { existing.add(row.device_token); } else { userCookiePairsToInvalidDeviceTokens.set( userCookiePair, new Set([row.device_token]), ); } } const time = Date.now(); const promises: Array> = []; for (const entry of userCookiePairsToInvalidDeviceTokens) { const [userCookiePair, deviceTokens] = entry; const [userID, cookieID] = userCookiePair.split('|'); const updateDatas = [...deviceTokens].map(deviceToken => ({ type: updateTypes.BAD_DEVICE_TOKEN, userID, time, deviceToken, targetCookie: cookieID, })); promises.push(createUpdates(updateDatas)); } const updateQuery = SQL` UPDATE cookies SET device_token = NULL WHERE `; updateQuery.append(sqlCondition); promises.push(dbQuery(updateQuery)); await Promise.all(promises); } async function updateBadgeCount( viewer: Viewer, source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', ) { const { userID } = viewer; const deviceTokenQuery = SQL` SELECT platform, device_token, versions, id FROM cookies WHERE user = ${userID} AND device_token IS NOT NULL `; if (viewer.data.cookieID) { deviceTokenQuery.append(SQL`AND id != ${viewer.cookieID} `); } const [unreadCounts, [deviceTokenResult], [dbID], keyserverID] = await Promise.all([ getUnreadCounts([userID]), dbQuery(deviceTokenQuery), createIDs('notifications', 1), thisKeyserverID(), ]); const unreadCount = unreadCounts[userID]; const devices = deviceTokenResult.map(row => { const versions = JSON.parse(row.versions); return { platform: row.platform, cookieID: row.id, deviceToken: row.device_token, codeVersion: versions?.codeVersion, stateVersion: versions?.stateVersion, }; }); const byPlatform = getDevicesByPlatform(devices); const preparePromises: Array>> = []; const iosVersionsToTokens = byPlatform.get('ios'); if (iosVersionsToTokens) { for (const [versionKey, deviceInfos] of iosVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'ios', codeVersion, stateVersion, }); notification.badge = unreadCount; notification.pushType = 'alert'; notification.payload.keyserverID = keyserverID; const preparePromise: Promise = (async () => { let targetedNotifications: $ReadOnlyArray; if (codeVersion > 222) { const notificationsArray = await prepareEncryptedAPNsNotifications( deviceInfos, notification, codeVersion, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); } else { targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ notification, deviceToken, })); } return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'ios', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const androidVersionsToTokens = byPlatform.get('android'); if (androidVersionsToTokens) { for (const [versionKey, deviceInfos] of androidVersionsToTokens) { const { codeVersion, stateVersion } = stringToVersionKey(versionKey); const notificationData = codeVersion < 69 ? { badge: unreadCount.toString() } : { badge: unreadCount.toString(), badgeOnly: '1' }; const notification = { data: { ...notificationData, keyserverID }, }; const preparePromise: Promise = (async () => { let targetedNotifications: $ReadOnlyArray; if (codeVersion > 222) { const notificationsArray = await prepareEncryptedAndroidNotifications( deviceInfos, notification, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); } else { targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ deviceToken, notification, })); } return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'android', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [versionKey, deviceInfos] of macosVersionsToTokens) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'macos', codeVersion, stateVersion, majorDesktopVersion, }); notification.badge = unreadCount; notification.pushType = 'alert'; notification.payload.keyserverID = keyserverID; const preparePromise: Promise = (async () => { const shouldBeEncrypted = hasMinCodeVersion(viewer.platformDetails, { web: 47, majorDesktop: 9, }); let targetedNotifications: $ReadOnlyArray; if (shouldBeEncrypted) { const notificationsArray = await prepareEncryptedAPNsNotifications( deviceInfos, notification, codeVersion, ); targetedNotifications = notificationsArray.map( ({ notification: notif, deviceToken, encryptionOrder }) => ({ notification: notif, deviceToken, encryptionOrder, }), ); } else { targetedNotifications = deviceInfos.map(({ deviceToken }) => ({ deviceToken, notification, })); } return targetedNotifications.map(targetedNotification => ({ notification: targetedNotification, platform: 'macos', notificationInfo: { source, dbID, userID, codeVersion, stateVersion, }, })); })(); preparePromises.push(preparePromise); } } const prepareResults = await Promise.all(preparePromises); const flattenedPrepareResults = prepareResults.filter(Boolean).flat(); const deliveryResults = await deliverPushNotifsInEncryptionOrder( flattenedPrepareResults, ); await saveNotifResults(deliveryResults, new Map(), false); } export { sendPushNotifs, sendRescindNotifs, updateBadgeCount }; diff --git a/native/android/app/build.gradle b/native/android/app/build.gradle index 9d0ec0164..109022da3 100644 --- a/native/android/app/build.gradle +++ b/native/android/app/build.gradle @@ -1,748 +1,756 @@ apply plugin: "com.android.application" import com.android.build.OutputFile import de.undercouch.gradle.tasks.download.Download import app.comm.gradle.tasks.GitModules /** * The react.gradle file registers a task for each build variant (e.g. bundleDebugJsAndAssets * and bundleReleaseJsAndAssets). * These basically call `react-native bundle` with the correct arguments during the Android build * cycle. By default, bundleDebugJsAndAssets is skipped, as in debug/dev mode we prefer to load the * bundle directly from the development server. Below you can see all the possible configurations * and their defaults. If you decide to add a configuration block, make sure to add it before the * `apply from: "../../node_modules/react-native/react.gradle"` line. * * project.ext.react = [ * // the name of the generated asset file containing your JS bundle * bundleAssetName: "index.android.bundle", * * // the entry file for bundle generation. If none specified and * // "index.android.js" exists, it will be used. Otherwise "index.js" is * // default. Can be overridden with ENTRY_FILE environment variable. * entryFile: "index.android.js", * * // https://reactnative.dev/docs/performance#enable-the-ram-format * bundleCommand: "ram-bundle", * * // whether to bundle JS and assets in debug mode * bundleInDebug: false, * * // whether to bundle JS and assets in release mode * bundleInRelease: true, * * // whether to bundle JS and assets in another build variant (if configured). * // See http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Build-Variants * // The configuration property can be in the following formats * // 'bundleIn${productFlavor}${buildType}' * // 'bundleIn${buildType}' * // bundleInFreeDebug: true, * // bundleInPaidRelease: true, * // bundleInBeta: true, * * // whether to disable dev mode in custom build variants (by default only disabled in release) * // for example: to disable dev mode in the staging build type (if configured) * devDisabledInStaging: true, * // The configuration property can be in the following formats * // 'devDisabledIn${productFlavor}${buildType}' * // 'devDisabledIn${buildType}' * * // the root of your project, i.e. where "package.json" lives * root: "../../", * * // where to put the JS bundle asset in debug mode * jsBundleDirDebug: "$buildDir/intermediates/assets/debug", * * // where to put the JS bundle asset in release mode * jsBundleDirRelease: "$buildDir/intermediates/assets/release", * * // where to put drawable resources / React Native assets, e.g. the ones you use via * // require('./image.png')), in debug mode * resourcesDirDebug: "$buildDir/intermediates/res/merged/debug", * * // where to put drawable resources / React Native assets, e.g. the ones you use via * // require('./image.png')), in release mode * resourcesDirRelease: "$buildDir/intermediates/res/merged/release", * * // by default the gradle tasks are skipped if none of the JS files or assets change; this means * // that we don't look at files in android/ or ios/ to determine whether the tasks are up to * // date; if you have any other folders that you want to ignore for performance reasons (gradle * // indexes the entire tree), add them here. Alternatively, if you have JS files in android/ * // for example, you might want to remove it from here. * inputExcludes: ["android/**", "ios/**"], * * // override which node gets called and with what additional arguments * nodeExecutableAndArgs: ["node"], * * // supply additional arguments to the packager * extraPackagerArgs: [] * ] */ project.ext.react = [ enableHermes: true, // clean and rebuild if changing cliPath: ["node", "-e", "console.log(require('react-native/cli').bin);"].execute([], projectDir).text.trim(), ] apply from: new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../react.gradle") /** * Set this to true to create two separate APKs instead of one: * - An APK that only works on ARM devices * - An APK that only works on x86 devices * The advantage is the size of the APK is reduced by about 4MB. * Upload all the APKs to the Play Store and people will download * the correct one based on the CPU architecture of their device. */ def enableSeparateBuildPerCPUArchitecture = false /** * Run Proguard to shrink the Java bytecode in release builds. */ def enableProguardInReleaseBuilds = false /** * The preferred build flavor of JavaScriptCore. * * For example, to use the international variant, you can use: * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` * * The international variant includes ICU i18n library and necessary data * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that * give correct results when using with locales other than en-US. Note that * this variant is about 6MiB larger per architecture than default. */ def jscFlavor = 'org.webkit:android-jsc:+' /** * Whether to enable the Hermes VM. * * This should be set on project.ext.react and that value will be read here. If it is not set * on project.ext.react, JavaScript will not be compiled to Hermes Bytecode * and the benefits of using Hermes will therefore be sharply reduced. */ def enableHermes = project.ext.react.get("enableHermes", false) /** * Architectures to build native code for. */ def reactNativeArchitectures() { def value = project.getProperties().get("reactNativeArchitectures") return value ? value.split(",") : ["armeabi-v7a", "x86_64", "arm64-v8a"] } def customDownloadsDir = System.getenv("REACT_NATIVE_DOWNLOADS_DIR") def dependenciesPath = System.getenv("REACT_NATIVE_DEPENDENCIES") def downloadsDir = customDownloadsDir ? new File(customDownloadsDir) : new File("$buildDir/downloads") def thirdPartyNdkDir = new File("$buildDir/third-party-ndk") task createNativeDepsDirectories { downloadsDir.mkdirs() thirdPartyNdkDir.mkdirs() } def REACT_NATIVE_DIR = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).parent def reactNativeThirdParty = new File("$REACT_NATIVE_DIR/ReactAndroid/src/main/jni/third-party") def reactProperties = new Properties() file("$REACT_NATIVE_DIR/ReactAndroid/gradle.properties").withInputStream { reactProperties.load(it) } def FOLLY_VERSION = reactProperties.getProperty("FOLLY_VERSION") def BOOST_VERSION = reactProperties.getProperty("BOOST_VERSION") def DOUBLE_CONVERSION_VERSION = reactProperties.getProperty("DOUBLE_CONVERSION_VERSION") // FOLLY task downloadFolly(dependsOn: createNativeDepsDirectories, type: Download) { src("https://github.com/facebook/folly/archive/v${FOLLY_VERSION}.tar.gz") onlyIfNewer(true) overwrite(false) dest(new File(downloadsDir, "folly-${FOLLY_VERSION}.tar.gz")) } task prepareFolly(dependsOn: [downloadFolly], type: Copy) { from(tarTree(downloadFolly.dest)) from("$reactNativeThirdParty/folly/Android.mk") include("folly-${FOLLY_VERSION}/folly/**/*", "Android.mk") eachFile { fname -> fname.path = (fname.path - "folly-${FOLLY_VERSION}/") } includeEmptyDirs = false into("$thirdPartyNdkDir/folly") } // GLOG task downloadGlog(dependsOn: createNativeDepsDirectories, type: Download) { src("https://github.com/google/glog/archive/v${GLOG_VERSION}.tar.gz") onlyIfNewer(true) overwrite(false) dest(new File(downloadsDir, "glog-${GLOG_VERSION}.tar.gz")) } task prepareGlog(dependsOn: dependenciesPath ? [] : [downloadGlog], type: Copy) { from(dependenciesPath ?: tarTree(downloadGlog.dest)) include("glog-${GLOG_VERSION}/**/*") includeEmptyDirs = false into("$thirdPartyNdkDir/glog") } // BOOST // The Boost library is a very large download (>100MB). // If Boost is already present on your system, define the REACT_NATIVE_BOOST_PATH env variable // and the build will use that. def boostPath = dependenciesPath ?: System.getenv("REACT_NATIVE_BOOST_PATH") task downloadBoost(dependsOn: createNativeDepsDirectories, type: Download) { src("https://archives.boost.io/release/${BOOST_VERSION.replace("_", ".")}/source/boost_${BOOST_VERSION}.tar.gz") onlyIfNewer(true) overwrite(false) dest(new File(downloadsDir, "boost_${BOOST_VERSION}.tar.gz")) } task prepareBoost(dependsOn: [downloadBoost], type: Copy) { from(tarTree(resources.gzip(downloadBoost.dest))) from("$reactNativeThirdParty/boost/Android.mk") include("Android.mk", "boost_${BOOST_VERSION}/boost/**/*.hpp", "boost/boost/**/*.hpp") includeEmptyDirs = false into("$thirdPartyNdkDir/boost") doLast { file("$thirdPartyNdkDir/boost/boost").renameTo("$thirdPartyNdkDir/boost/boost_${BOOST_VERSION}") } } // DOUBLE-CONVERSION task downloadDoubleConversion(dependsOn: createNativeDepsDirectories, type: Download) { src("https://github.com/google/double-conversion/archive/v${DOUBLE_CONVERSION_VERSION}.tar.gz") onlyIfNewer(true) overwrite(false) dest(new File(downloadsDir, "double-conversion-${DOUBLE_CONVERSION_VERSION}.tar.gz")) } task prepareDoubleConversion(dependsOn: [downloadDoubleConversion], type: Copy) { from(tarTree(downloadDoubleConversion.dest)) from("$reactNativeThirdParty/double-conversion/Android.mk") include("double-conversion-${DOUBLE_CONVERSION_VERSION}/src/**/*", "Android.mk") filesMatching("*/src/**/*", { fname -> fname.path = "double-conversion/${fname.name}" }) includeEmptyDirs = false into("$thirdPartyNdkDir/double-conversion") } // OPENSSL def hostSystem = System.getProperty('os.name').toLowerCase(Locale.ROOT) def hostTag = hostSystem.contains('mac') ? 'darwin-x86_64' : 'linux-x86_64' task downloadOpenSSL(dependsOn: createNativeDepsDirectories, type: Download) { src("https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz") onlyIfNewer(true) overwrite(false) dest(new File(downloadsDir, "openssl-${OPENSSL_VERSION}.tar.gz")) } task prepareOpenSSL( dependsOn: dependenciesPath ? [] : [downloadOpenSSL], type: Copy ) { from(dependenciesPath ?: tarTree(downloadOpenSSL.dest)) include("openssl-${OPENSSL_VERSION}/**/*") includeEmptyDirs = false into("${thirdPartyNdkDir}/openssl") } task prebuildOpenSSL(dependsOn: dependenciesPath ? [] : [prepareOpenSSL]) { inputs.properties([ 'openssl.version': OPENSSL_VERSION, 'openssl.abis': getBuildTypeABIs(reactNativeArchitectures()) ]) outputs.dir("${thirdPartyNdkDir}/openssl/openssl-${OPENSSL_VERSION}/build/") .withPropertyName('openssl.output') doFirst { getBuildTypeABIs(reactNativeArchitectures()).each { buildABI -> logger.info("Building OpenSSL library for the ${buildABI}") exec { commandLine './bash/build_openssl.sh', // OPENSSL_SUBMODULE_PATH "${thirdPartyNdkDir}/openssl/openssl-${OPENSSL_VERSION}/", // HOST_TAG hostTag, // ANDROID_ARCH_ABI "${buildABI}", // MIN_SDK_VERSION rootProject.ext.minSdkVersion, // ANDROID_NDK_HOME android.ndkDirectory.absolutePath, // PARALLEL_THREADS Runtime.getRuntime().availableProcessors() } } } } // JNI def REACT_NATIVE_AAR = "${buildDir}/react-native.aar" def extractReactNativeAAR = { buildType -> def suffix = buildType == 'Debug' ? '-debug' : '-release' def rnAARs = fileTree(REACT_NATIVE_DIR).matching { include "**/react-native/**/*${suffix}.aar" } if (rnAARs.isEmpty()) { rnAARs = fileTree(REACT_NATIVE_DIR).matching { include "**/react-native/**/*.aar" } } if (rnAARs.any() && rnAARs.size() > 1) { logger.error("More than one React Native AAR file has been found:") rnAARs.each { println(it) } throw new GradleException( "Multiple React Native AARs found:\n${rnAARs.join("\n")}" + "\nRemove the old ones and try again" ) } def rnAAR = rnAARs.singleFile def file = rnAAR.absoluteFile def packageName = file.name.tokenize('-')[0] copy { from zipTree(file) into REACT_NATIVE_AAR include "jni/**/*" } } task extractReactNativeAARRelease { doLast { extractReactNativeAAR('Release') } } task extractReactNativeAARDebug { doLast { extractReactNativeAAR('Debug') } } task extractAARHeaders { doLast { configurations.extractHeaders.files.each { def file = it.absoluteFile copy { from zipTree(file) into "$buildDir/$file.name" include "**/*.h" } } } } task extractJNIFiles { doLast { configurations.extractJNI.files.each { def file = it.absoluteFile copy { from zipTree(file) into "$buildDir/$file.name" include "jni/**/*" } } } } tasks.whenTaskAdded { task -> if ( !task.name.contains("Clean") && (task.name.contains('externalNativeBuild') || task.name.startsWith('configureCMake') || task.name.startsWith('buildCMake')) ) { def buildType = task.name.endsWith('Debug') ? 'Debug' : 'Release' task.dependsOn(extractAARHeaders) task.dependsOn(extractJNIFiles) task.dependsOn("extractReactNativeAAR${buildType}") } } // EXTERNAL LIBS task prepareExternalLibs { dependsOn prepareFolly dependsOn prepareGlog dependsOn prepareBoost dependsOn prepareDoubleConversion dependsOn prebuildOpenSSL } // Removes the '.cxx' directory to prevent running // ninja clean when the 'clean' command is executed task removeCXX(type: Exec) { commandLine 'rm', '-rf', '.cxx' } // Add cross-compilation targets to Rust toolchain task updateRustToolchain(type: Exec) { commandLine "rustup", "target", "add", "aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android" } def nativeRustLibraryDir = "../../native_rust_library" def nativeRustLibraryManifestPath = "${nativeRustLibraryDir}/Cargo.toml" def cxxBridgeBindingDir = "${nativeRustLibraryDir}/target/cxxbridge/native_rust_library/src" def cxxBridgeCommonDir = "${nativeRustLibraryDir}/target/cxxbridge/rust" task buildNativeRustLibrary(type: Exec) { commandLine "cargo", "build", "--manifest-path", nativeRustLibraryManifestPath } task copyNativeRustLibraryFiles(dependsOn: buildNativeRustLibrary, type: Copy) { from(cxxBridgeBindingDir) { include 'lib.rs.h' include 'lib.rs.cc' } from(cxxBridgeCommonDir) { include 'cxx.h' } into nativeRustLibraryDir } // Bind preBuild dependencies only if not 'clean' running if (!isCleanRunning()) { afterEvaluate { preBuild.dependsOn(prepareExternalLibs, updateRustToolchain, copyNativeRustLibraryFiles) } } // Run removing CXX task before the clean execution beforeEvaluate { clean.dependsOn(removeCXX) } // Detects are we running the 'clean' commands def isCleanRunning() { gradle.startParameter.taskRequests.any { !it.args.isEmpty() && it.args.first().startsWith('clean') } } // Release keystore via macOS Keychain Access def getPassword(String keyLabel) { if (System.getenv('ANDROID_SIGNING_PASSWORD')) { return System.getenv('ANDROID_SIGNING_PASSWORD') } def stdout = new ByteArrayOutputStream() exec { commandLine 'security', 'find-generic-password', '-wl', keyLabel, '-a', System.properties['user.name'] standardOutput = stdout ignoreExitValue true } return stdout.toString().strip() } // Returns all ABIs architectures for the 'bundleRelease' // or only from 'adb devices' if running debug release. def getBuildTypeABIs(nativeArchitectures) { if (System.getenv("BUILDKITE") == "true") { return ["arm64-v8a"] } def isBundleRelease = gradle.startParameter.taskRequests.any { !it.args.isEmpty() && it.args.first().contains("bundleRelease") } if (isBundleRelease) { // All of the supported ABIs // https://developer.android.com/ndk/guides/abis.html#sa final allAbis = ["armeabi-v7a", "arm64-v8a", "x86_64"] logger.info("Using all architectures to build: ${allAbis}") return allAbis } if (nativeArchitectures) { return nativeArchitectures } // Get current 'adb devices' architectures def commandOutput = new ByteArrayOutputStream() exec { commandLine "./bash/detect_abis.sh" standardOutput = commandOutput } final detectedAbis = commandOutput.toString("UTF-8").trim().tokenize() logger.info("Detected architectures to build: ${detectedAbis}") return detectedAbis } def REACT_NATIVE_SO_DIR = "${REACT_NATIVE_AAR}/jni" android { buildFeatures { prefab true } configurations { all*.exclude module: 'fbjni-java-only' extractHeaders extractJNI } dependencies { implementation 'com.facebook.fbjni:fbjni:0.2.2' compileOnly 'com.facebook.fbjni:fbjni:0.2.2' extractHeaders 'com.facebook.fbjni:fbjni:0.2.2:headers' extractJNI 'com.facebook.fbjni:fbjni:0.2.2' } ndkVersion rootProject.ext.ndkVersion compileSdkVersion rootProject.ext.compileSdkVersion defaultConfig { applicationId 'app.comm.android' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 330 versionName '1.0.330' buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() if (isNewArchitectureEnabled()) { // We configure the CMake build only if you decide to opt-in for the New Architecture. externalNativeBuild { cmake { arguments "-DPROJECT_BUILD_DIR=$buildDir", "-DREACT_ANDROID_DIR=$rootDir/../node_modules/react-native/ReactAndroid", "-DREACT_ANDROID_BUILD_DIR=$rootDir/../node_modules/react-native/ReactAndroid/build", "-DNODE_MODULES_DIR=$rootDir/../node_modules", "-DANDROID_STL=c++_shared" } } if (!enableSeparateBuildPerCPUArchitecture) { ndk { abiFilters (*reactNativeArchitectures()) } } } missingDimensionStrategy 'react-native-camera', 'general' multiDexEnabled true } if (isNewArchitectureEnabled()) { // We configure the NDK build only if you decide to opt-in for the New Architecture. externalNativeBuild { cmake { path "$projectDir/src/main/jni/CMakeLists.txt" } } def reactAndroidProjectDir = project(':ReactAndroid').projectDir def packageReactNdkDebugLibs = tasks.register("packageReactNdkDebugLibs", Copy) { dependsOn(":ReactAndroid:packageReactNdkDebugLibsForBuck") from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib") into("$buildDir/react-ndk/exported") } def packageReactNdkReleaseLibs = tasks.register("packageReactNdkReleaseLibs", Copy) { dependsOn(":ReactAndroid:packageReactNdkReleaseLibsForBuck") from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib") into("$buildDir/react-ndk/exported") } afterEvaluate { // If you wish to add a custom TurboModule or component locally, // you should uncomment this line. // preBuild.dependsOn("generateCodegenArtifactsFromSchema") preDebugBuild.dependsOn(packageReactNdkDebugLibs) preReleaseBuild.dependsOn(packageReactNdkReleaseLibs) // Due to a bug inside AGP, we have to explicitly set a dependency // between configureCMakeDebug* tasks and the preBuild tasks. // This can be removed once this is solved: https://issuetracker.google.com/issues/207403732 configureCMakeRelWithDebInfo.dependsOn(preReleaseBuild) configureCMakeDebug.dependsOn(preDebugBuild) reactNativeArchitectures().each { architecture -> tasks.findByName("configureCMakeDebug[${architecture}]")?.configure { dependsOn("preDebugBuild") } tasks.findByName("configureCMakeRelWithDebInfo[${architecture}]")?.configure { dependsOn("preReleaseBuild") } } } } splits { abi { reset() enable enableSeparateBuildPerCPUArchitecture universalApk false // If true, also generate a universal APK include (*reactNativeArchitectures()) } } signingConfigs { debug { storeFile file('debug.keystore') storePassword 'android' keyAlias 'androiddebugkey' keyPassword 'android' } release { if (project.hasProperty('COMM_UPLOAD_STORE_FILE')) { def password = getPassword('CommAndroidKeyPassword') storeFile file(COMM_UPLOAD_STORE_FILE) storePassword password keyAlias COMM_UPLOAD_KEY_ALIAS keyPassword password } } } buildTypes { final buildABIs = getBuildTypeABIs(reactNativeArchitectures()) release { if (project.hasProperty('COMM_UPLOAD_STORE_FILE')) { signingConfig signingConfigs.release } else { signingConfig signingConfigs.debug } minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" ndk { abiFilters = [] abiFilters.addAll(buildABIs) } } debug { signingConfig signingConfigs.debug ndk { abiFilters = [] abiFilters.addAll(buildABIs) } } } packagingOptions { pickFirst "**/libc++_shared.so" pickFirst "**/libfbjni.so" } defaultConfig { externalNativeBuild { cmake { arguments "-DANDROID_STL=c++_shared", "-DGLOG_VERSION=" + GLOG_VERSION, "-DOPENSSL_VERSION=" + OPENSSL_VERSION, "-DNDK_VERSION=" + rootProject.ext.ndkVersion, "-DREACT_NATIVE_SO_DIR=${REACT_NATIVE_SO_DIR}", "-DBOOST_VERSION=${BOOST_VERSION}", "-DCI=${System.getenv('CI')}" targets "comm_jni_module", "turbomodulejsijni" } } } externalNativeBuild { cmake { path "CMakeLists.txt" version "3.18.1" } } // applicationVariants are e.g. debug, release applicationVariants.all { variant -> variant.outputs.each { output -> // For each separate APK per architecture, set a unique version code as described here: // https://developer.android.com/studio/build/configure-apk-splits.html // Example: versionCode 1 will generate 1001 for armeabi-v7a, 1002 for x86, etc. def versionCodes = ["armeabi-v7a": 1, "arm64-v8a": 3, "x86_64": 4] def abi = output.getFilter(OutputFile.ABI) if (abi != null) { // null for the universal-debug, universal-release variants output.versionCodeOverride = defaultConfig.versionCode * 1000 + versionCodes.get(abi) } } } afterEvaluate { extractAARHeaders.dependsOn(prepareExternalLibs) extractJNIFiles.dependsOn(prepareExternalLibs) } } dependencies { implementation 'com.tencent:mmkv:1.3.3' implementation fileTree(dir: "libs", include: ["*.jar"]) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10" //noinspection GradleDynamicVersion implementation("com.facebook.react:react-native:+") def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; // If your app supports Android versions before Ice Cream Sandwich (API level 14) // All fresco packages should use the same version if (isGifEnabled || isWebpEnabled) { implementation 'com.facebook.fresco:fresco:2.5.0' implementation 'com.facebook.fresco:imagepipeline-okhttp3:2.5.0' } if (isGifEnabled) { // For animated gif support implementation 'com.facebook.fresco:animated-gif:2.5.0' } if (isWebpEnabled) { // For webp support implementation 'com.facebook.fresco:webpsupport:2.5.0' if (isWebpAnimatedEnabled) { // Animated webp support implementation 'com.facebook.fresco:animated-webp:2.5.0' } } implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" implementation "com.google.android.gms:play-services-base:16.1.0" implementation "com.google.firebase:firebase-core:21.1.0" implementation "com.google.firebase:firebase-messaging:21.1.0" implementation "me.leolin:ShortcutBadger:1.1.21@aar" implementation project(':reactnativekeyboardinput') implementation "androidx.multidex:multidex:2.0.1" implementation "androidx.lifecycle:lifecycle-process:2.5.1" implementation 'com.facebook.fresco:fresco:2.5.0' implementation 'com.facebook.fresco:animated-gif:2.5.0' implementation 'com.facebook.fresco:animated-webp:2.5.0' implementation 'com.facebook.fresco:webpsupport:2.5.0' implementation 'org.conscrypt:conscrypt-android:2.0.0' if (enableHermes) { //noinspection GradleDynamicVersion implementation("com.facebook.react:hermes-engine:+") { // From node_modules exclude group:'com.facebook.fbjni' } } else { implementation jscFlavor } + + def work_version = "2.8.1" + // (Java only) + implementation "androidx.work:work-runtime:$work_version" + // Guava for listenable future to solve the bug: + // https://stackoverflow.com/questions/64290141/android-studio-class-file-for-com-google-common-util-concurrent-listenablefuture + // https://github.com/google/ExoPlayer/issues/7993 + implementation "com.google.guava:guava:31.0.1-android" } if (isNewArchitectureEnabled()) { // If new architecture is enabled, we let you build RN from source // Otherwise we fallback to a prebuilt .aar bundled in the NPM package. // This will be applied to all the imported transtitive dependency. configurations.all { resolutionStrategy.dependencySubstitution { substitute(module("com.facebook.react:react-native")) .using(project(":ReactAndroid")) .because("On New Architecture we're building React Native from source") substitute(module("com.facebook.react:hermes-engine")) .using(project(":ReactAndroid:hermes-engine")) .because("On New Architecture we're building Hermes from source") } } } // Run this once to be able to run the application with BUCK // puts all compile dependencies into folder libs for BUCK to use task copyDownloadableDepsToLibs(type: Copy) { from configurations.implementation into 'libs' } apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json')"].execute(null, rootDir).text.trim(), "../native_modules.gradle"); applyNativeModulesAppBuildGradle(project) def isNewArchitectureEnabled() { // To opt-in for the New Architecture, you can either: // - Set `newArchEnabled` to true inside the `gradle.properties` file // - Invoke gradle with `-newArchEnabled=true` // - Set an environment variable `ORG_GRADLE_PROJECT_newArchEnabled=true` return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true" } apply plugin: 'com.google.gms.google-services' diff --git a/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidBlobClient.java b/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidBlobClient.java index 00e45b6b1..fe9af2862 100644 --- a/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidBlobClient.java +++ b/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidBlobClient.java @@ -1,75 +1,106 @@ package app.comm.android.commservices; +import android.content.Context; +import androidx.work.Constraints; +import androidx.work.Data; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; +import androidx.work.WorkRequest; import app.comm.android.BuildConfig; import app.comm.android.fbjni.CommSecureStore; import java.io.IOException; import java.lang.OutOfMemoryError; import java.util.Base64; import java.util.concurrent.TimeUnit; import okhttp3.Call; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import org.json.JSONException; import org.json.JSONObject; public class CommAndroidBlobClient { - private static final String BLOB_SERVICE_URL = BuildConfig.DEBUG - ? "https://blob.staging.commtechnologies.org" - : "https://blob.commtechnologies.org"; // The FirebaseMessagingService docs state that message // processing should complete within at most 20 seconds // window. Therefore we limit http time call to 15 seconds // https://firebase.google.com/docs/cloud-messaging/android/receive#handling_messages private static final int NOTIF_PROCESSING_TIME_LIMIT_SECONDS = 15; // OkHttp docs advise to share OkHttpClient instances // https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/#okhttpclients-should-be-shared - private static final OkHttpClient httpClient = + public static final OkHttpClient httpClient = new OkHttpClient.Builder() .callTimeout(NOTIF_PROCESSING_TIME_LIMIT_SECONDS, TimeUnit.SECONDS) .build(); + public static final String BLOB_SERVICE_URL = BuildConfig.DEBUG + ? "https://blob.staging.commtechnologies.org" + : "https://blob.commtechnologies.org"; + public static final String BLOB_HASH_KEY = "blob_hash"; + public static final String BLOB_HOLDER_KEY = "holder"; + public byte[] getBlobSync(String blobHash) throws IOException, JSONException { String authToken = getAuthToken(); Request request = new Request.Builder() .get() .url(BLOB_SERVICE_URL + "/blob/" + blobHash) .header("Authorization", authToken) .build(); Response response = httpClient.newCall(request).execute(); if (!response.isSuccessful()) { throw new RuntimeException( "Failed to download blob from blob service. Response error code: " + response); } return response.body().bytes(); } - private String getAuthToken() throws JSONException { + public void scheduleDeferredBlobDeletion( + String blobHash, + String blobHolder, + Context context) { + Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + WorkRequest deleteBlobWorkRequest = + new OneTimeWorkRequest.Builder(CommAndroidDeleteBlobWork.class) + .setConstraints(constraints) + .setInitialDelay( + NOTIF_PROCESSING_TIME_LIMIT_SECONDS, TimeUnit.SECONDS) + .setInputData(new Data.Builder() + .putString(BLOB_HASH_KEY, blobHash) + .putString(BLOB_HOLDER_KEY, blobHolder) + .build()) + .build(); + + WorkManager.getInstance(context).enqueue(deleteBlobWorkRequest); + } + + public static String getAuthToken() throws JSONException { // Authentication data are retrieved on every request // since they might change while CommNotificationsHandler // thread is running so we should not rely on caching // them in memory. String userID = CommSecureStore.get("userID"); String accessToken = CommSecureStore.get("accessToken"); String deviceID = CommSecureStore.get("deviceID"); userID = userID == null ? "" : userID; accessToken = accessToken == null ? "" : accessToken; deviceID = deviceID == null ? "" : deviceID; String authObjectJsonBody = new JSONObject() .put("userID", userID) .put("accessToken", accessToken) .put("deviceID", deviceID) .toString(); String encodedAuthObjectJsonBody = Base64.getEncoder().encodeToString(authObjectJsonBody.getBytes()); return "Bearer " + encodedAuthObjectJsonBody; } } diff --git a/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidDeleteBlobWork.java b/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidDeleteBlobWork.java new file mode 100644 index 000000000..7283bf149 --- /dev/null +++ b/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidDeleteBlobWork.java @@ -0,0 +1,96 @@ +package app.comm.android.commservices; + +import android.content.Context; +import android.util.Log; +import androidx.work.Data; +import androidx.work.ListenableWorker.Result; +import androidx.work.Worker; +import androidx.work.WorkerParameters; +import java.io.IOException; +import java.util.function.Consumer; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.json.JSONException; +import org.json.JSONObject; + +public class CommAndroidDeleteBlobWork extends Worker { + + private static final int MAX_RETRY_ATTEMPTS = 10; + + public CommAndroidDeleteBlobWork(Context context, WorkerParameters params) { + super(context, params); + } + + @Override + public Result doWork() { + String blobHash = + getInputData().getString(CommAndroidBlobClient.BLOB_HASH_KEY); + String blobHolder = + getInputData().getString(CommAndroidBlobClient.BLOB_HOLDER_KEY); + + String jsonBody; + try { + jsonBody = new JSONObject() + .put(CommAndroidBlobClient.BLOB_HASH_KEY, blobHash) + .put(CommAndroidBlobClient.BLOB_HOLDER_KEY, blobHolder) + .toString(); + } catch (JSONException e) { + // This should never happen since the code + // throwing is just simple JSON creation. + // If it happens there is no way to retry + // so we fail immediately. + Log.w( + "COMM", + "Failed to create JSON from blob hash and holder provided.", + e); + return Result.failure(); + } + + String authToken; + try { + authToken = CommAndroidBlobClient.getAuthToken(); + } catch (JSONException e) { + // In this case however it may happen that + // auth metadata got corrupted but will be + // fixed soon by event emitter. Therefore + // we should retry in this case. + return Result.retry(); + } + + RequestBody requestBody = + RequestBody.create(MediaType.parse("application/json"), jsonBody); + Request request = new Request.Builder() + .delete(requestBody) + .url(CommAndroidBlobClient.BLOB_SERVICE_URL + "/blob") + .header("Authorization", authToken) + .build(); + + try { + Response response = + CommAndroidBlobClient.httpClient.newCall(request).execute(); + if (response.isSuccessful()) { + return Result.success(); + } + Log.w( + "COMM", + "Failed to execute blob deletion request. HTTP code:" + + response.code() + " status: " + response.message()); + return retryOrFail(); + } catch (IOException e) { + Log.w( + "COMM", + "IO exception occurred while issuing blob deletion request.", + e); + return retryOrFail(); + } + } + + private Result retryOrFail() { + if (getRunAttemptCount() > MAX_RETRY_ATTEMPTS) { + return Result.failure(); + } + return Result.retry(); + } +} diff --git a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java index 4ec1b4533..f117df1e7 100644 --- a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java +++ b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java @@ -1,614 +1,619 @@ package app.comm.android.notifications; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Bundle; import android.service.notification.StatusBarNotification; import android.util.JsonReader; import android.util.Log; import androidx.core.app.NotificationCompat; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.ProcessLifecycleOwner; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import app.comm.android.ExpoUtils; import app.comm.android.MainActivity; import app.comm.android.R; import app.comm.android.aescrypto.AESCryptoModuleCompat; import app.comm.android.commservices.CommAndroidBlobClient; import app.comm.android.fbjni.CommMMKV; import app.comm.android.fbjni.CommSecureStore; import app.comm.android.fbjni.GlobalDBSingleton; import app.comm.android.fbjni.MessageOperationsUtilities; import app.comm.android.fbjni.NetworkModule; import app.comm.android.fbjni.NotificationsCryptoModule; import app.comm.android.fbjni.StaffUtils; import app.comm.android.fbjni.ThreadOperations; import com.google.firebase.messaging.FirebaseMessagingService; import com.google.firebase.messaging.RemoteMessage; import java.io.File; import java.io.IOException; import java.lang.OutOfMemoryError; import java.lang.StringBuilder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import me.leolin.shortcutbadger.ShortcutBadger; import org.json.JSONException; import org.json.JSONObject; public class CommNotificationsHandler extends FirebaseMessagingService { private static final String BADGE_KEY = "badge"; private static final String BADGE_ONLY_KEY = "badgeOnly"; private static final String SET_UNREAD_STATUS_KEY = "setUnreadStatus"; private static final String NOTIF_ID_KEY = "id"; private static final String ENCRYPTED_PAYLOAD_KEY = "encryptedPayload"; private static final String ENCRYPTION_FAILED_KEY = "encryptionFailed"; private static final String BLOB_HASH_KEY = "blobHash"; + private static final String BLOB_HOLDER_KEY = "blobHolder"; private static final String AES_ENCRYPTION_KEY_LABEL = "encryptionKey"; private static final String GROUP_NOTIF_IDS_KEY = "groupNotifIDs"; private static final String COLLAPSE_ID_KEY = "collapseKey"; private static final String KEYSERVER_ID_KEY = "keyserverID"; private static final String CHANNEL_ID = "default"; private static final long[] VIBRATION_SPEC = {500, 500}; // Those and future MMKV-related constants should match // similar constants in NotificationService.mm private static final String MMKV_KEY_SEPARATOR = "."; private static final String MMKV_KEYSERVER_PREFIX = "KEYSERVER"; private static final String MMKV_UNREAD_COUNT_SUFFIX = "UNREAD_COUNT"; private Bitmap displayableNotificationLargeIcon; private NotificationManager notificationManager; private LocalBroadcastManager localBroadcastManager; private CommAndroidBlobClient blobClient; private AESCryptoModuleCompat aesCryptoModule; public static final String RESCIND_KEY = "rescind"; public static final String RESCIND_ID_KEY = "rescindID"; public static final String TITLE_KEY = "title"; public static final String PREFIX_KEY = "prefix"; public static final String BODY_KEY = "body"; public static final String MESSAGE_INFOS_KEY = "messageInfos"; public static final String THREAD_ID_KEY = "threadID"; public static final String TOKEN_EVENT = "TOKEN_EVENT"; public static final String MESSAGE_EVENT = "MESSAGE_EVENT"; @Override public void onCreate() { super.onCreate(); CommSecureStore.getInstance().initialize( ExpoUtils.createExpoSecureStoreSupplier(this.getApplicationContext())); notificationManager = (NotificationManager)this.getSystemService( Context.NOTIFICATION_SERVICE); localBroadcastManager = LocalBroadcastManager.getInstance(this); displayableNotificationLargeIcon = BitmapFactory.decodeResource( this.getApplicationContext().getResources(), R.mipmap.ic_launcher); blobClient = new CommAndroidBlobClient(); aesCryptoModule = new AESCryptoModuleCompat(); } @Override public void onNewToken(String token) { Intent intent = new Intent(TOKEN_EVENT); intent.putExtra("token", token); localBroadcastManager.sendBroadcast(intent); } @Override public void onMessageReceived(RemoteMessage message) { if (message.getData().get(KEYSERVER_ID_KEY) == null) { displayErrorMessageNotification( "Received notification without keyserver ID.", "Missing keyserver ID.", null); return; } String senderKeyserverID = message.getData().get(KEYSERVER_ID_KEY); if (message.getData().get(ENCRYPTED_PAYLOAD_KEY) != null) { try { message = this.olmDecryptRemoteMessage(message, senderKeyserverID); } catch (JSONException e) { Log.w("COMM", "Malformed notification JSON payload.", e); return; } catch (IllegalStateException e) { Log.w("COMM", "Android notification type violation.", e); return; } catch (Exception e) { Log.w("COMM", "Notification decryption failure.", e); return; } } else if ("1".equals(message.getData().get(ENCRYPTION_FAILED_KEY))) { Log.w( "COMM", "Received unencrypted notification for client with existing olm session for notifications"); } String rescind = message.getData().get(RESCIND_KEY); if ("true".equals(rescind) && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { handleNotificationRescind(message); } try { handleUnreadCountUpdate(message); } catch (Exception e) { Log.w("COMM", "Unread count update failure.", e); } String badgeOnly = message.getData().get(BADGE_ONLY_KEY); if ("1".equals(badgeOnly)) { return; } if (message.getData().get(MESSAGE_INFOS_KEY) != null) { handleMessageInfosPersistence(message); } if (message.getData().get(BLOB_HASH_KEY) != null && - message.getData().get(AES_ENCRYPTION_KEY_LABEL) != null) { + message.getData().get(AES_ENCRYPTION_KEY_LABEL) != null && + message.getData().get(BLOB_HOLDER_KEY) != null) { handleLargeNotification(message); } Intent intent = new Intent(MESSAGE_EVENT); intent.putExtra( "message", serializeMessageDataForIntentAttachment(message)); localBroadcastManager.sendBroadcast(intent); if (this.isAppInForeground()) { return; } this.displayNotification(message); } private boolean isAppInForeground() { return ProcessLifecycleOwner.get().getLifecycle().getCurrentState() == Lifecycle.State.RESUMED; } private boolean notificationGroupingSupported() { // Comm doesn't support notification grouping for clients running // Android versions older than 23 return android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.M; } private void handleNotificationRescind(RemoteMessage message) { String setUnreadStatus = message.getData().get(SET_UNREAD_STATUS_KEY); String threadID = message.getData().get(THREAD_ID_KEY); if ("true".equals(setUnreadStatus)) { File sqliteFile = this.getApplicationContext().getDatabasePath("comm.sqlite"); if (sqliteFile.exists()) { GlobalDBSingleton.scheduleOrRun(() -> { ThreadOperations.updateSQLiteUnreadStatus( sqliteFile.getPath(), threadID, false); }); } else { Log.w( "COMM", "Database not existing yet. Skipping thread status update."); } } String rescindID = message.getData().get(RESCIND_ID_KEY); boolean groupSummaryPresent = false; boolean threadGroupPresent = false; for (StatusBarNotification notification : notificationManager.getActiveNotifications()) { String tag = notification.getTag(); boolean isGroupMember = threadID.equals(notification.getNotification().getGroup()); boolean isGroupSummary = (notification.getNotification().flags & Notification.FLAG_GROUP_SUMMARY) == Notification.FLAG_GROUP_SUMMARY; if (tag != null && tag.equals(rescindID)) { notificationManager.cancel(notification.getTag(), notification.getId()); } else if ( isGroupMember && isGroupSummary && StaffUtils.isStaffRelease()) { groupSummaryPresent = true; removeNotificationFromGroupSummary(threadID, rescindID, notification); } else if (isGroupMember && isGroupSummary) { groupSummaryPresent = true; } else if (isGroupMember) { threadGroupPresent = true; } else if (isGroupSummary && StaffUtils.isStaffRelease()) { checkForUnmatchedRescind(threadID, rescindID, notification); } } if (groupSummaryPresent && !threadGroupPresent) { notificationManager.cancel(threadID, threadID.hashCode()); } } private void handleUnreadCountUpdate(RemoteMessage message) { String badge = message.getData().get(BADGE_KEY); if (badge == null) { return; } if (message.getData().get(KEYSERVER_ID_KEY) == null) { throw new RuntimeException("Received badge update without keyserver ID."); } String senderKeyserverID = message.getData().get(KEYSERVER_ID_KEY); String senderKeyserverUnreadCountKey = String.join( MMKV_KEY_SEPARATOR, MMKV_KEYSERVER_PREFIX, senderKeyserverID, MMKV_UNREAD_COUNT_SUFFIX); int senderKeyserverUnreadCount; try { senderKeyserverUnreadCount = Integer.parseInt(badge); } catch (NumberFormatException e) { Log.w("COMM", "Invalid badge count", e); return; } CommMMKV.setInt(senderKeyserverUnreadCountKey, senderKeyserverUnreadCount); int totalUnreadCount = 0; String[] allKeys = CommMMKV.getAllKeys(); for (String key : allKeys) { if (!key.startsWith(MMKV_KEYSERVER_PREFIX) || !key.endsWith(MMKV_UNREAD_COUNT_SUFFIX)) { continue; } Integer unreadCount = CommMMKV.getInt(key, -1); if (unreadCount == null) { continue; } totalUnreadCount += unreadCount; } if (totalUnreadCount > 0) { ShortcutBadger.applyCount(this, totalUnreadCount); } else { ShortcutBadger.removeCount(this); } } private void handleMessageInfosPersistence(RemoteMessage message) { String rawMessageInfosString = message.getData().get(MESSAGE_INFOS_KEY); File sqliteFile = this.getApplicationContext().getDatabasePath("comm.sqlite"); if (rawMessageInfosString != null && sqliteFile.exists()) { GlobalDBSingleton.scheduleOrRun(() -> { MessageOperationsUtilities.storeMessageInfos( sqliteFile.getPath(), rawMessageInfosString); }); } else if (rawMessageInfosString != null) { Log.w("COMM", "Database not existing yet. Skipping notification"); } } private void handleLargeNotification(RemoteMessage message) { String blobHash = message.getData().get(BLOB_HASH_KEY); + String blobHolder = message.getData().get(BLOB_HOLDER_KEY); try { byte[] largePayload = blobClient.getBlobSync(blobHash); message = aesDecryptRemoteMessage(message, largePayload); handleMessageInfosPersistence(message); } catch (Exception e) { Log.w("COMM", "Failure when handling large notification.", e); } + blobClient.scheduleDeferredBlobDeletion( + blobHash, blobHolder, this.getApplicationContext()); } private void addToThreadGroupAndDisplay( String notificationID, NotificationCompat.Builder notificationBuilder, String threadID) { notificationBuilder.setGroup(threadID).setGroupAlertBehavior( NotificationCompat.GROUP_ALERT_CHILDREN); NotificationCompat.Builder groupSummaryNotificationBuilder = new NotificationCompat.Builder(this.getApplicationContext()) .setChannelId(CHANNEL_ID) .setSmallIcon(R.drawable.notif_icon) .setContentIntent( this.createStartMainActivityAction(threadID, threadID)) .setGroup(threadID) .setGroupSummary(true) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); if (StaffUtils.isStaffRelease()) { ArrayList groupNotifIDs = recordNotificationInGroupSummary(threadID, notificationID); String notificationSummaryBody = "Notif IDs: " + String.join(System.lineSeparator(), groupNotifIDs); Bundle data = new Bundle(); data.putStringArrayList(GROUP_NOTIF_IDS_KEY, groupNotifIDs); groupSummaryNotificationBuilder .setContentTitle("Summary for thread id " + threadID) .setExtras(data) .setStyle(new NotificationCompat.BigTextStyle().bigText( notificationSummaryBody)) .setAutoCancel(false); } else { groupSummaryNotificationBuilder.setAutoCancel(true); } notificationManager.notify( notificationID, notificationID.hashCode(), notificationBuilder.build()); notificationManager.notify( threadID, threadID.hashCode(), groupSummaryNotificationBuilder.build()); } private void displayNotification(RemoteMessage message) { if (message.getData().get(RESCIND_KEY) != null) { // don't attempt to display rescinds return; } String id = message.getData().get(NOTIF_ID_KEY); String collapseKey = message.getData().get(COLLAPSE_ID_KEY); String notificationID = id; if (collapseKey != null) { notificationID = collapseKey; } String title = message.getData().get(TITLE_KEY); String prefix = message.getData().get(PREFIX_KEY); String body = message.getData().get(BODY_KEY); String threadID = message.getData().get(THREAD_ID_KEY); if (prefix != null) { body = prefix + " " + body; } Bundle data = new Bundle(); data.putString(THREAD_ID_KEY, threadID); NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this.getApplicationContext()) .setDefaults(Notification.DEFAULT_ALL) .setContentText(body) .setExtras(data) .setChannelId(CHANNEL_ID) .setVibrate(VIBRATION_SPEC) .setSmallIcon(R.drawable.notif_icon) .setLargeIcon(displayableNotificationLargeIcon) .setAutoCancel(true); if (title != null) { notificationBuilder.setContentTitle(title); } if (threadID != null) { notificationBuilder.setContentIntent( this.createStartMainActivityAction(id, threadID)); } if (!this.notificationGroupingSupported() || threadID == null) { notificationManager.notify( notificationID, notificationID.hashCode(), notificationBuilder.build()); return; } this.addToThreadGroupAndDisplay( notificationID, notificationBuilder, threadID); } private PendingIntent createStartMainActivityAction(String notificationID, String threadID) { Intent intent = new Intent(this.getApplicationContext(), MainActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); intent.putExtra("threadID", threadID); return PendingIntent.getActivity( this.getApplicationContext(), notificationID.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); } private RemoteMessage updateRemoteMessageWithDecryptedPayload( RemoteMessage message, String decryptedSerializedPayload) throws JSONException, IllegalStateException { JSONObject decryptedPayload = new JSONObject(decryptedSerializedPayload); ((Iterable)() -> decryptedPayload.keys()) .forEach(payloadFieldName -> { if (decryptedPayload.optJSONArray(payloadFieldName) != null || decryptedPayload.optJSONObject(payloadFieldName) != null) { throw new IllegalStateException( "Notification payload JSON is not {[string]: string} type."); } String payloadFieldValue = decryptedPayload.optString(payloadFieldName); message.getData().put(payloadFieldName, payloadFieldValue); }); return message; } private RemoteMessage olmDecryptRemoteMessage(RemoteMessage message, String senderKeyserverID) throws JSONException, IllegalStateException { String encryptedSerializedPayload = message.getData().get(ENCRYPTED_PAYLOAD_KEY); String decryptedSerializedPayload = NotificationsCryptoModule.decrypt( senderKeyserverID, encryptedSerializedPayload, NotificationsCryptoModule.olmEncryptedTypeMessage()); return updateRemoteMessageWithDecryptedPayload( message, decryptedSerializedPayload); } private RemoteMessage aesDecryptRemoteMessage(RemoteMessage message, byte[] blob) throws JSONException, IllegalStateException { String aesEncryptionKey = message.getData().get(AES_ENCRYPTION_KEY_LABEL); // On the keyserver AES key is generated as raw bytes // so to send it in JSON it is encoded to Base64 string. byte[] aesEncryptionKeyBytes = Base64.getDecoder().decode(aesEncryptionKey); // On the keyserver notification is a string so it is // first encoded into UTF8 bytes. Therefore bytes // obtained from blob decryption are correct UTF8 bytes. String decryptedSerializedPayload = new String( aesCryptoModule.decrypt(aesEncryptionKeyBytes, blob), StandardCharsets.UTF_8); return updateRemoteMessageWithDecryptedPayload( message, decryptedSerializedPayload); } private Bundle serializeMessageDataForIntentAttachment(RemoteMessage message) { Bundle bundle = new Bundle(); message.getData().forEach(bundle::putString); return bundle; } private void displayErrorMessageNotification( String errorMessage, String errorTitle, String largeErrorData) { NotificationCompat.Builder errorNotificationBuilder = new NotificationCompat.Builder(this.getApplicationContext()) .setDefaults(Notification.DEFAULT_ALL) .setChannelId(CHANNEL_ID) .setSmallIcon(R.drawable.notif_icon) .setLargeIcon(displayableNotificationLargeIcon); if (errorMessage != null) { errorNotificationBuilder.setContentText(errorMessage); } if (errorTitle != null) { errorNotificationBuilder.setContentTitle(errorTitle); } if (largeErrorData != null) { errorNotificationBuilder.setStyle( new NotificationCompat.BigTextStyle().bigText(largeErrorData)); } notificationManager.notify( errorMessage, errorMessage.hashCode(), errorNotificationBuilder.build()); } private boolean isGroupSummary(StatusBarNotification notification, String threadID) { boolean isAnySummary = (notification.getNotification().flags & Notification.FLAG_GROUP_SUMMARY) != 0; if (threadID == null) { return isAnySummary; } return isAnySummary && threadID.equals(notification.getNotification().getGroup()); } private ArrayList recordNotificationInGroupSummary(String threadID, String notificationID) { ArrayList groupNotifIDs = Arrays.stream(notificationManager.getActiveNotifications()) .filter(notif -> isGroupSummary(notif, threadID)) .findFirst() .map( notif -> notif.getNotification().extras.getStringArrayList( GROUP_NOTIF_IDS_KEY)) .orElse(new ArrayList<>()); groupNotifIDs.add(notificationID); return groupNotifIDs; } private void removeNotificationFromGroupSummary( String threadID, String notificationID, StatusBarNotification groupSummaryNotification) { ArrayList groupNotifIDs = groupSummaryNotification.getNotification().extras.getStringArrayList( GROUP_NOTIF_IDS_KEY); if (groupNotifIDs == null) { displayErrorMessageNotification( "Empty summary notif for thread ID " + threadID, "Empty Summary Notif", "Summary notification for thread ID " + threadID + " had empty body when rescinding " + notificationID); } boolean notificationRemoved = groupNotifIDs.removeIf(notifID -> notifID.equals(notificationID)); if (!notificationRemoved) { displayErrorMessageNotification( "Notif with ID " + notificationID + " not in " + threadID, "Unrecorded Notif", "Rescinded notification with id " + notificationID + " not found in group summary for thread id " + threadID); return; } String notificationSummaryBody = "Notif IDs: " + String.join(System.lineSeparator(), groupNotifIDs); Bundle data = new Bundle(); data.putStringArrayList(GROUP_NOTIF_IDS_KEY, groupNotifIDs); NotificationCompat.Builder groupSummaryNotificationBuilder = new NotificationCompat.Builder(this.getApplicationContext()) .setChannelId(CHANNEL_ID) .setSmallIcon(R.drawable.notif_icon) .setContentIntent( this.createStartMainActivityAction(threadID, threadID)) .setContentTitle("Summary for thread id " + threadID) .setExtras(data) .setStyle(new NotificationCompat.BigTextStyle().bigText( notificationSummaryBody)) .setGroup(threadID) .setGroupSummary(true) .setAutoCancel(false) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); notificationManager.notify( threadID, threadID.hashCode(), groupSummaryNotificationBuilder.build()); } private void checkForUnmatchedRescind( String threadID, String notificationID, StatusBarNotification anySummaryNotification) { ArrayList anyGroupNotifIDs = anySummaryNotification.getNotification().extras.getStringArrayList( GROUP_NOTIF_IDS_KEY); if (anyGroupNotifIDs == null) { return; } String groupID = anySummaryNotification.getNotification().getGroup(); for (String notifID : anyGroupNotifIDs) { if (!notificationID.equals(notifID)) { continue; } displayErrorMessageNotification( "Summary for thread id " + groupID + "has " + notifID, "Rescind Mismatch", "Summary notif for thread id " + groupID + " contains notif id " + notifID + " which was received in rescind with thread id " + threadID); } } }