diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -18,6 +18,7 @@ import { type IdentityPlatformDetails, identityDeviceTypes, + type RawDeviceList, } from '../types/identity-service-types.js'; import type { RelativeMemberInfo, @@ -278,6 +279,19 @@ }, ); +const getOwnRawDeviceList: (state: BaseAppState<>) => ?RawDeviceList = + createSelector( + (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, + (state: BaseAppState<>) => + state.currentUserInfo && state.currentUserInfo.id, + (auxUserInfos: AuxUserInfos, currentUserID: ?string): ?RawDeviceList => { + if (!currentUserID) { + return null; + } + return auxUserInfos[currentUserID]?.deviceList; + }, + ); + function getKeyserverDeviceID( devices: $ReadOnlyArray, ): ?string { @@ -340,4 +354,5 @@ getAllPeerDevices, getAllPeerUserIDAndDeviceIDs, getOwnPrimaryDeviceID, + getOwnRawDeviceList, }; diff --git a/native/backup/backup-handler.js b/native/backup/backup-handler.js --- a/native/backup/backup-handler.js +++ b/native/backup/backup-handler.js @@ -2,17 +2,23 @@ import * as React from 'react'; +import { setPeerDeviceListsActionType } from 'lib/actions/aux-user-actions.js'; import { createUserKeysBackupActionTypes } from 'lib/actions/backup-actions.js'; import { useCurrentIdentityUserState, type CurrentIdentityUserState, } from 'lib/hooks/peer-list-hooks.js'; -import { useDeviceKind } from 'lib/hooks/primary-device-hooks.js'; -import { isLoggedIn } from 'lib/selectors/user-selectors.js'; +import { usePersistedStateLoaded } from 'lib/selectors/app-state-selectors.js'; +import { + getOwnRawDeviceList, + isLoggedIn, +} from 'lib/selectors/user-selectors.js'; import { useStaffAlert } from 'lib/shared/staff-utils.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; +import { rawDeviceListFromSignedList } from 'lib/utils/device-list-utils.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; +import { useDispatch } from 'lib/utils/redux-utils.js'; import { usingRestoreFlow } from 'lib/utils/services-utils.js'; import { useClientBackup } from './use-client-backup.js'; @@ -22,182 +28,196 @@ import { useStaffCanSee } from '../utils/staff-utils.js'; function BackupHandler(): null { - const loggedIn = useSelector(isLoggedIn); const staffCanSee = useStaffCanSee(); - const isBackground = useSelector( - state => state.lifecycleState === 'background', - ); - const canPerformBackupOperation = loggedIn && !isBackground; - const deviceKind = useDeviceKind(); const { showAlertToStaff } = useStaffAlert(); - const latestBackupInfo = useSelector( - state => state.backupStore.latestBackupInfo, - ); - const dispatchActionPromise = useDispatchActionPromise(); - const { createUserKeysBackup } = useClientBackup(); - const backupUploadInProgress = React.useRef(false); - const startingBackupHandlerInProgress = React.useRef(false); - const [handlerStarted, setHandlerStarted] = React.useState(false); const getCurrentIdentityUserState = useCurrentIdentityUserState(); - const migrateToNewFlow = useMigrationToNewFlow(); const { socketState } = useTunnelbroker(); + const migrateToNewFlow = useMigrationToNewFlow(); + const { createUserKeysBackup } = useClientBackup(); + const dispatchActionPromise = useDispatchActionPromise(); + const dispatch = useDispatch(); - const startBackupHandler = React.useCallback(() => { - try { - commCoreModule.startBackupHandler(); - setHandlerStarted(true); - } catch (err) { - const message = getMessageForException(err) ?? 'unknown error'; - showAlertToStaff('Error starting backup handler', message); - console.log('Error starting backup handler:', message); - } - }, [showAlertToStaff]); - - const stopBackupHandler = React.useCallback(() => { - try { - commCoreModule.stopBackupHandler(); - setHandlerStarted(false); - } catch (err) { - const message = getMessageForException(err) ?? 'unknown error'; - showAlertToStaff('Error stopping backup handler', message); - console.log('Error stopping backup handler:', message); - } - }, [showAlertToStaff]); - - React.useEffect(() => { - if ( - !staffCanSee || - startingBackupHandlerInProgress.current || - deviceKind !== 'primary' - ) { - return; - } - - if (!handlerStarted && canPerformBackupOperation) { - startBackupHandler(); - } + const persistedStateLoaded = usePersistedStateLoaded(); + const ownRawDeviceList = useSelector(getOwnRawDeviceList); + const latestBackupInfo = useSelector( + state => state.backupStore.latestBackupInfo, + ); + const loggedIn = useSelector(isLoggedIn); + const isBackground = useSelector( + state => state.lifecycleState === 'background', + ); + const canPerformBackupOperation = loggedIn && !isBackground; - if (handlerStarted && !canPerformBackupOperation) { - stopBackupHandler(); - } - }, [ - canPerformBackupOperation, - deviceKind, - handlerStarted, - staffCanSee, - startBackupHandler, - stopBackupHandler, - ]); + // State to force re-render. + const [renderCount, setRenderCount] = React.useState(0); + const completionRef = React.useRef({ running: false, required: false }); + const handlerStartedRef = React.useRef(false); const performMigrationToNewFlow = React.useCallback( async (currentIdentityUserState: CurrentIdentityUserState) => { - try { - const promise = migrateToNewFlow(currentIdentityUserState); - void dispatchActionPromise(createUserKeysBackupActionTypes, promise); - await promise; - } catch (err) { - const errorMessage = getMessageForException(err) ?? 'unknown error'; - showAlertToStaff( - 'Error migrating to signed device lists', - errorMessage, - ); - console.log('Error migrating to signed device lists', errorMessage); - } + const promise = migrateToNewFlow(currentIdentityUserState); + void dispatchActionPromise(createUserKeysBackupActionTypes, promise); + await promise; }, - [dispatchActionPromise, migrateToNewFlow, showAlertToStaff], + [dispatchActionPromise, migrateToNewFlow], ); const performBackupUpload = React.useCallback(async () => { - try { - const promise = (async () => { - const backupID = await createUserKeysBackup(); - return { - backupID, - timestamp: Date.now(), - }; - })(); - void dispatchActionPromise(createUserKeysBackupActionTypes, promise); - await promise; - } catch (err) { - const errorMessage = getMessageForException(err) ?? 'unknown error'; - showAlertToStaff('Error creating User Keys backup', errorMessage); - console.log('Error creating User Keys backup', errorMessage); - } - }, [createUserKeysBackup, dispatchActionPromise, showAlertToStaff]); + const promise = (async () => { + const backupID = await createUserKeysBackup(); + return { + backupID, + timestamp: Date.now(), + }; + })(); + void dispatchActionPromise(createUserKeysBackupActionTypes, promise); + await promise; + }, [createUserKeysBackup, dispatchActionPromise]); - React.useEffect(() => { - if ( - !staffCanSee || - !canPerformBackupOperation || - deviceKind === 'unknown' - ) { + const startBackupHandler = React.useCallback(() => { + if (handlerStartedRef.current) { return; } - // In case of primary we need to wait for starting the handler. - // In case of secondary, we want to proceed and start handler - // on demand. - if (deviceKind === 'primary' && !handlerStarted) { + commCoreModule.startBackupHandler(); + handlerStartedRef.current = true; + }, []); + + const stopBackupHandler = React.useCallback(() => { + if (!handlerStartedRef.current) { return; } - void (async () => { - // CurrentIdentityUserState is required to check if migration to - // new flow is needed. - let currentIdentityUserState: ?CurrentIdentityUserState = null; - try { - currentIdentityUserState = await getCurrentIdentityUserState(); - } catch (err) { - const message = getMessageForException(err) ?? 'unknown error'; - showAlertToStaff('Error fetching current device list:', message); - console.log('Error fetching current device list:', message); + commCoreModule.stopBackupHandler(); + handlerStartedRef.current = false; + }, []); + + React.useEffect(() => { + if (!canPerformBackupOperation) { + stopBackupHandler(); + } + }, [canPerformBackupOperation, stopBackupHandler]); + + const process = React.useCallback(async () => { + let step = 'starting backup handler'; + try { + if (!canPerformBackupOperation) { return; } + // It is important to use Identity Service as a source of truth + // in case of a migration process because local device lists + // might be out-of-date. + step = 'fetching current device list'; + const currentIdentityUserState = await getCurrentIdentityUserState(); + const { + currentDeviceList, + currentUserPlatformDetails, + userID, + deviceID, + } = currentIdentityUserState; + + // After fetching, it is worth checking if the local state is the same, + // in case of inconsistency perform the update. + step = 'validating current device list consistency'; + const currentRawDeviceList = + rawDeviceListFromSignedList(currentDeviceList); + if ( + JSON.stringify(ownRawDeviceList?.devices) !== + JSON.stringify(currentRawDeviceList.devices) + ) { + console.log( + "Local device list state wasn't updated yet - updating it now", + ); + dispatch({ + type: setPeerDeviceListsActionType, + payload: { + deviceLists: { + [userID]: currentRawDeviceList, + }, + usersPlatformDetails: { + [userID]: currentUserPlatformDetails, + }, + }, + }); + } + + // Based on the response from Identity determine if the device + // is primary. + step = 'checking if device is primary'; + const isPrimary = + currentRawDeviceList.devices.length > 0 && + currentRawDeviceList.devices[0] === deviceID; + + step = 'computing conditions'; const shouldDoMigration = - usingRestoreFlow && - !currentIdentityUserState.currentDeviceList.curPrimarySignature; + usingRestoreFlow && !currentDeviceList.curPrimarySignature; + const shouldUploadBackup = isPrimary && !latestBackupInfo; + // Tunnelbroker connection is required to broadcast + // device list updates after migration. if (shouldDoMigration && !socketState.isAuthorized) { return; } - try { - if (backupUploadInProgress.current) { - return; - } - - backupUploadInProgress.current = true; - - if (shouldDoMigration && deviceKind === 'primary') { - await performMigrationToNewFlow(currentIdentityUserState); - } else if (shouldDoMigration && deviceKind === 'secondary') { - startBackupHandler(); - await performMigrationToNewFlow(currentIdentityUserState); - } else if (deviceKind === 'primary' && !latestBackupInfo) { - await performBackupUpload(); - } - } catch (e) { - console.log(e); - } finally { - backupUploadInProgress.current = false; + // Migration or backup upload are not needed. + if (!shouldDoMigration && !shouldUploadBackup) { + return; } - })(); + + step = 'starting backup handler'; + startBackupHandler(); + + // Migration is performed regardless of device kind. + if (shouldDoMigration) { + step = 'migrating to signed device lists'; + await performMigrationToNewFlow(currentIdentityUserState); + } else if (shouldUploadBackup) { + step = 'creating User Keys backup'; + await performBackupUpload(); + } + } catch (err) { + const errorMessage = getMessageForException(err) ?? 'unknown error'; + const title = `Error ${step}`; + showAlertToStaff(title, errorMessage); + console.log(title, errorMessage); + } finally { + completionRef.current.running = false; + if (completionRef.current.required) { + // Incrementing to trigger re-render. Using state instead of ref + // to make sure the next execution is using updated version + // of the `process()` callback. + setRenderCount(prev => prev + 1); + } + } }, [ canPerformBackupOperation, - deviceKind, + dispatch, getCurrentIdentityUserState, - handlerStarted, latestBackupInfo, + ownRawDeviceList, performBackupUpload, performMigrationToNewFlow, showAlertToStaff, socketState.isAuthorized, - staffCanSee, startBackupHandler, ]); + React.useEffect(() => { + if (!staffCanSee || !persistedStateLoaded) { + return; + } + + if (completionRef.current.running) { + completionRef.current.required = true; + } else { + completionRef.current.running = true; + completionRef.current.required = false; + void process(); + } + }, [persistedStateLoaded, process, renderCount, staffCanSee]); + return null; }