diff --git a/lib/actions/backup-actions.js b/lib/actions/backup-actions.js --- a/lib/actions/backup-actions.js +++ b/lib/actions/backup-actions.js @@ -28,10 +28,17 @@ const resetBackupRestoreStateActionType = 'RESET_BACKUP_RESTOTE_STATE'; const markBackupAsRestoredActionType = 'MARK_BACKUP_AS_RESTORED'; +const sendBackupDataToSecondaryActionTypes = Object.freeze({ + started: 'SEND_BACKUP_DATA_TO_SECONDARY_STARTED', + success: 'SEND_BACKUP_DATA_TO_SECONDARY_SUCCESS', + failed: 'SEND_BACKUP_DATA_TO_SECONDARY_FAILED', +}); + export { createUserKeysBackupActionTypes, createUserDataBackupActionTypes, restoreUserDataStepActionTypes, resetBackupRestoreStateActionType, markBackupAsRestoredActionType, + sendBackupDataToSecondaryActionTypes, }; diff --git a/lib/reducers/backup-reducer.js b/lib/reducers/backup-reducer.js --- a/lib/reducers/backup-reducer.js +++ b/lib/reducers/backup-reducer.js @@ -8,6 +8,7 @@ restoreUserDataStepActionTypes, markBackupAsRestoredActionType, resetBackupRestoreStateActionType, + sendBackupDataToSecondaryActionTypes, } from '../actions/backup-actions.js'; import { changeIdentityUserPasswordActionTypes, @@ -57,6 +58,14 @@ ...store, latestBackupInfo: null, }; + } else if (action.type === sendBackupDataToSecondaryActionTypes.success) { + const recipients = action.payload; + return { + ...store, + ownDevicesWithoutBackup: store.ownDevicesWithoutBackup?.filter( + deviceID => !recipients.includes(deviceID), + ), + }; } return store; } diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js --- a/lib/types/redux-types.js +++ b/lib/types/redux-types.js @@ -1687,6 +1687,22 @@ +type: 'MARK_BACKUP_AS_RESTORED', +payload?: void, } + | { + +type: 'SEND_BACKUP_DATA_TO_SECONDARY_STARTED', + +loadingInfo: LoadingInfo, + +payload?: void, + } + | { + +type: 'SEND_BACKUP_DATA_TO_SECONDARY_FAILED', + +error: true, + +payload: Error, + +loadingInfo: LoadingInfo, + } + | { + +type: 'SEND_BACKUP_DATA_TO_SECONDARY_SUCCESS', + +payload: $ReadOnlyArray, + +loadingInfo: LoadingInfo, + } | { +type: 'SAVE_UNSUPPORTED_DM_OPERATION', +payload: SaveUnsupportedOperationPayload, diff --git a/lib/types/tunnelbroker/user-actions-peer-to-peer-message-types.js b/lib/types/tunnelbroker/user-actions-peer-to-peer-message-types.js --- a/lib/types/tunnelbroker/user-actions-peer-to-peer-message-types.js +++ b/lib/types/tunnelbroker/user-actions-peer-to-peer-message-types.js @@ -2,7 +2,9 @@ import t, { type TInterface, type TUnion } from 'tcomb'; -import { tShape, tString } from '../../utils/validation-utils.js'; +import type { QRAuthBackupData } from './qr-code-auth-message-types.js'; +import { qrAuthBackupDataValidator } from './qr-code-auth-message-types.js'; +import { tShape, tString, tUserID } from '../../utils/validation-utils.js'; import { type DMOperation, dmOperationValidator } from '../dm-ops.js'; export const userActionsP2PMessageTypes = Object.freeze({ @@ -10,6 +12,7 @@ LOG_OUT_SECONDARY_DEVICE: 'LOG_OUT_SECONDARY_DEVICE', ACCOUNT_DELETION: 'ACCOUNT_DELETION', DM_OPERATION: 'DM_OPERATION', + BACKUP_DATA: 'BACKUP_DATA', }); export type DeviceLogoutP2PMessage = { @@ -47,11 +50,28 @@ op: dmOperationValidator, }); +// Used when the primary wants to send backup keys after uploading the backup +// for the first time +export type BackupDataP2PMessage = { + +type: 'BACKUP_DATA', + +userID: string, + +primaryDeviceID: string, + +backupData: QRAuthBackupData, +}; +export const backupDataP2PMessageValidator: TInterface = + tShape({ + type: tString(userActionsP2PMessageTypes.BACKUP_DATA), + userID: tUserID, + primaryDeviceID: t.String, + backupData: t.maybe(qrAuthBackupDataValidator), + }); + export type UserActionP2PMessage = | DeviceLogoutP2PMessage | SecondaryDeviceLogoutP2PMessage | AccountDeletionP2PMessage - | DMOperationP2PMessage; + | DMOperationP2PMessage + | BackupDataP2PMessage; export const userActionP2PMessageValidator: TUnion = t.union([ @@ -59,4 +79,5 @@ secondaryDeviceLogoutP2PMessageValidator, accountDeletionP2PMessageValidator, dmOperationP2PMessageValidator, + backupDataP2PMessageValidator, ]); diff --git a/native/backup/secondary-devices-backup-handler.react.js b/native/backup/secondary-devices-backup-handler.react.js new file mode 100644 --- /dev/null +++ b/native/backup/secondary-devices-backup-handler.react.js @@ -0,0 +1,120 @@ +// @flow + +import * as React from 'react'; + +import { sendBackupDataToSecondaryActionTypes } from 'lib/actions/backup-actions.js'; +import { getOwnPeerDevices } from 'lib/selectors/user-selectors.js'; +import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; +import { usePeerToPeerCommunication } from 'lib/tunnelbroker/peer-to-peer-context.js'; +import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; +import { + type BackupDataP2PMessage, + userActionsP2PMessageTypes, +} from 'lib/types/tunnelbroker/user-actions-peer-to-peer-message-types.js'; +import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; +import { fullBackupSupport } from 'lib/utils/services-utils.js'; + +import { commCoreModule } from '../native-modules.js'; +import { useSelector } from '../redux/redux-utils.js'; + +function SecondaryDevicesBackupHandler(): React.Node { + const identityContext = React.useContext(IdentityClientContext); + if (!identityContext) { + throw new Error('Identity service client is not initialized'); + } + const { getAuthMetadata } = identityContext; + + const ownDevicesWithoutBackup = useSelector( + state => state.backupStore.ownDevicesWithoutBackup, + ); + const ownDevices = useSelector(getOwnPeerDevices); + const { broadcastEphemeralMessage } = usePeerToPeerCommunication(); + const dispatchActionPromise = useDispatchActionPromise(); + const { socketState } = useTunnelbroker(); + const restoreBackupState = useSelector(state => state.restoreBackupState); + + const sendBackupDataToSecondaryDevices = React.useCallback(async (): Promise< + $ReadOnlyArray, + > => { + if (!ownDevicesWithoutBackup || !ownDevicesWithoutBackup.length) { + return []; + } + + // Avoid sending to devices that are removed since last backup upload + const removedDevices = ownDevicesWithoutBackup.filter( + deviceID => + !ownDevices.find(ownDevice => ownDevice.deviceID === deviceID), + ); + + const backupData = await commCoreModule.getQRAuthBackupData(); + + const authMetadata = await getAuthMetadata(); + const { userID: thisUserID, deviceID: thisDeviceID } = authMetadata; + if (!thisDeviceID || !thisUserID) { + throw new Error('No auth metadata'); + } + + const backupDataP2PMessage: BackupDataP2PMessage = { + type: userActionsP2PMessageTypes.BACKUP_DATA, + userID: thisUserID, + primaryDeviceID: thisDeviceID, + backupData, + }; + const rawPayload = JSON.stringify(backupDataP2PMessage); + + const recipients = + ownDevicesWithoutBackup + ?.filter(deviceID => !removedDevices.includes(deviceID)) + ?.map(deviceID => ({ + deviceID, + userID: thisUserID, + })) ?? []; + + const devicesThatReceivedMessage = await broadcastEphemeralMessage( + rawPayload, + recipients, + authMetadata, + ); + return [...removedDevices, ...devicesThatReceivedMessage]; + }, [ + broadcastEphemeralMessage, + getAuthMetadata, + ownDevices, + ownDevicesWithoutBackup, + ]); + + const executed = React.useRef(false); + React.useEffect(() => { + if ( + !fullBackupSupport || + executed.current || + !socketState.isAuthorized || + !ownDevicesWithoutBackup || + !ownDevicesWithoutBackup.length + ) { + return; + } + + // This condition implies that this device is primary. + if (restoreBackupState.status !== 'user_data_backup_success') { + return; + } + + void dispatchActionPromise( + sendBackupDataToSecondaryActionTypes, + sendBackupDataToSecondaryDevices(), + ); + + executed.current = true; + }, [ + dispatchActionPromise, + ownDevicesWithoutBackup, + restoreBackupState.status, + sendBackupDataToSecondaryDevices, + socketState.isAuthorized, + ]); + + return null; +} + +export { SecondaryDevicesBackupHandler }; diff --git a/native/root.react.js b/native/root.react.js --- a/native/root.react.js +++ b/native/root.react.js @@ -56,6 +56,7 @@ import { RegistrationContextProvider } from './account/registration/registration-context-provider.react.js'; import NativeEditThreadAvatarProvider from './avatars/native-edit-thread-avatar-provider.react.js'; import BackupHandlerContextProvider from './backup/backup-handler-context-provider.js'; +import { SecondaryDevicesBackupHandler } from './backup/secondary-devices-backup-handler.react.js'; import { BottomSheetProvider } from './bottom-sheet/bottom-sheet-provider.react.js'; import ChatContextProvider from './chat/chat-context-provider.react.js'; import MessageEditingContextProvider from './chat/message-editing-context-provider.react.js'; @@ -410,6 +411,7 @@ + {navigation}