diff --git a/native/profile/linked-devices-header-right-button.react.js b/native/profile/linked-devices-header-right-button.react.js index e01f12af4..4fe507612 100644 --- a/native/profile/linked-devices-header-right-button.react.js +++ b/native/profile/linked-devices-header-right-button.react.js @@ -1,32 +1,21 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import HeaderRightTextButton from '../navigation/header-right-text-button.react.js'; import { SecondaryDeviceQRCodeScannerRouteName } from '../navigation/route-names.js'; -import Alert from '../utils/alert.js'; -import { deviceIsEmulator } from '../utils/url-utils.js'; function LinkedDevicesHeaderRightButton(): React.Node { const { navigate } = useNavigation(); const navigateToQRCodeScanner = React.useCallback(() => { - if (deviceIsEmulator) { - Alert.alert( - 'Unsupported device', - "You can't access the QR code scanner on a simulator.", - [{ text: 'OK' }], - { cancelable: false }, - ); - return; - } navigate(SecondaryDeviceQRCodeScannerRouteName); }, [navigate]); return ( ); } export default LinkedDevicesHeaderRightButton; diff --git a/native/profile/profile.react.js b/native/profile/profile.react.js index 598bdd62e..d605f4bd4 100644 --- a/native/profile/profile.react.js +++ b/native/profile/profile.react.js @@ -1,252 +1,253 @@ // @flow import type { StackNavigationProp, StackNavigationHelpers, StackHeaderProps, StackHeaderLeftButtonProps, } from '@react-navigation/core'; import { createStackNavigator } from '@react-navigation/stack'; import * as React from 'react'; import { View, useWindowDimensions } from 'react-native'; import AddKeyserver from './add-keyserver.react.js'; import AppearancePreferences from './appearance-preferences.react.js'; import BackupMenu from './backup-menu.react.js'; import BuildInfo from './build-info.react.js'; import DefaultNotificationsPreferences from './default-notifications-preferences.react.js'; import DeleteAccount from './delete-account.react.js'; import DevTools from './dev-tools.react.js'; import EditPassword from './edit-password.react.js'; import EmojiUserAvatarCreation from './emoji-user-avatar-creation.react.js'; import FarcasterAccountSettings from './farcaster-account-settings.react.js'; import KeyserverSelectionListHeaderRightButton from './keyserver-selection-list-header-right-button.react.js'; import KeyserverSelectionList from './keyserver-selection-list.react.js'; import LinkedDevicesHeaderRightButton from './linked-devices-header-right-button.react.js'; import LinkedDevices from './linked-devices.react.js'; import PrivacyPreferences from './privacy-preferences.react.js'; import ProfileHeader from './profile-header.react.js'; import ProfileScreen from './profile-screen.react.js'; import RelationshipList from './relationship-list.react.js'; import SecondaryDeviceQRCodeScanner from './secondary-device-qr-code-scanner.react.js'; import TunnelbrokerMenu from './tunnelbroker-menu.react.js'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react.js'; import CommunityDrawerButton from '../navigation/community-drawer-button.react.js'; import HeaderBackButton from '../navigation/header-back-button.react.js'; import { ProfileScreenRouteName, EditPasswordRouteName, DeleteAccountRouteName, EmojiUserAvatarCreationRouteName, BuildInfoRouteName, DevToolsRouteName, AppearancePreferencesRouteName, PrivacyPreferencesRouteName, FriendListRouteName, DefaultNotificationsPreferencesRouteName, BlockListRouteName, LinkedDevicesRouteName, SecondaryDeviceQRCodeScannerRouteName, BackupMenuRouteName, KeyserverSelectionListRouteName, AddKeyserverRouteName, FarcasterAccountSettingsRouteName, type ScreenParamList, type ProfileParamList, TunnelbrokerMenuRouteName, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useStyles, useColors } from '../themes/colors.js'; +import { deviceIsEmulator } from '../utils/url-utils.js'; const header = (props: StackHeaderProps) => ; const profileScreenOptions = { headerTitle: 'Profile' }; const emojiAvatarCreationOptions = { headerTitle: 'Emoji avatar selection', headerBackTitleVisible: false, }; const editPasswordOptions = { headerTitle: 'Change password' }; const deleteAccountOptions = { headerTitle: 'Delete account' }; const linkedDevicesOptions = { headerTitle: 'Linked devices', headerRight: () => , }; const keyserverSelectionListOptions = { headerTitle: 'Keyservers', headerRight: () => , }; const addKeyserverOptions = { headerTitle: 'Add keyserver' }; const backupMenuOptions = { headerTitle: 'Backup menu' }; const tunnelbrokerMenuOptions = { headerTitle: 'Tunnelbroker menu' }; const secondaryDeviceQRCodeScannerOptions = { - headerTitle: '', + headerTitle: deviceIsEmulator ? 'Link device' : '', headerBackTitleVisible: false, }; const buildInfoOptions = { headerTitle: 'Build info' }; const devToolsOptions = { headerTitle: 'Developer tools' }; const appearanceOptions = { headerTitle: 'Appearance' }; const privacyOptions = { headerTitle: 'Privacy' }; const friendListOptions = { headerTitle: 'Friend list' }; const blockListOptions = { headerTitle: 'Block list' }; const defaultNotificationsOptions = { headerTitle: 'Default Notifications' }; const farcasterSettingsOptions = { headerTitle: 'Farcaster account' }; export type ProfileNavigationProp< RouteName: $Keys = $Keys, > = StackNavigationProp; const Profile = createStackNavigator< ScreenParamList, ProfileParamList, StackNavigationHelpers, >(); type Props = { +navigation: TabNavigationProp<'Profile'>, ... }; function ProfileComponent(props: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); const headerLeftButton = React.useCallback( (headerProps: StackHeaderLeftButtonProps) => headerProps.canGoBack ? ( ) : ( ), [props.navigation], ); const { width: screenWidth } = useWindowDimensions(); const screenOptions = React.useMemo( () => ({ header, headerLeft: headerLeftButton, headerStyle: { backgroundColor: colors.tabBarBackground, shadowOpacity: 0, }, gestureEnabled: true, gestureResponseDistance: screenWidth, }), [colors.tabBarBackground, headerLeftButton, screenWidth], ); return ( ); } const unboundStyles = { keyboardAvoidingView: { flex: 1, }, view: { flex: 1, backgroundColor: 'panelBackground', }, }; export default ProfileComponent; diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js index 46c147417..cb41e0f93 100644 --- a/native/profile/secondary-device-qr-code-scanner.react.js +++ b/native/profile/secondary-device-qr-code-scanner.react.js @@ -1,314 +1,428 @@ // @flow import { useNavigation } from '@react-navigation/native'; import { BarCodeScanner, type BarCodeEvent } from 'expo-barcode-scanner'; import invariant from 'invariant'; import * as React from 'react'; -import { View } from 'react-native'; +import { View, Text } from 'react-native'; import { parseDataFromDeepLink } from 'lib/facts/links.js'; import { useBroadcastDeviceListUpdates, useGetAndUpdateDeviceListsForUsers, } from 'lib/hooks/peer-list-hooks.js'; import { getForeignPeerDevices } from 'lib/selectors/user-selectors.js'; import { addDeviceToDeviceList } from 'lib/shared/device-list-utils.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import { backupKeysValidator, type BackupKeys, } from 'lib/types/backup-types.js'; import { tunnelbrokerMessageTypes, type TunnelbrokerMessage, } from 'lib/types/tunnelbroker/messages.js'; import { peerToPeerMessageTypes, peerToPeerMessageValidator, type PeerToPeerMessage, } from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; import { qrCodeAuthMessageTypes } from 'lib/types/tunnelbroker/qr-code-auth-message-types.js'; import { rawDeviceListFromSignedList } from 'lib/utils/device-list-utils.js'; import { assertWithValidator } from 'lib/utils/validation-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; import { getBackupSecret } from '../backup/use-client-backup.js'; +import TextInput from '../components/text-input.react.js'; import { commCoreModule } from '../native-modules.js'; +import HeaderRightTextButton from '../navigation/header-right-text-button.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { composeTunnelbrokerQRAuthMessage, parseTunnelbrokerQRAuthMessage, } from '../qr-code/qr-code-utils.js'; import { useSelector } from '../redux/redux-utils.js'; -import { useStyles } from '../themes/colors.js'; +import { useStyles, useColors } from '../themes/colors.js'; import Alert from '../utils/alert.js'; +import { deviceIsEmulator } from '../utils/url-utils.js'; const barCodeTypes = [BarCodeScanner.Constants.BarCodeType.qr]; type Props = { +navigation: ProfileNavigationProp<'SecondaryDeviceQRCodeScanner'>, +route: NavigationRoute<'SecondaryDeviceQRCodeScanner'>, }; // eslint-disable-next-line no-unused-vars function SecondaryDeviceQRCodeScanner(props: Props): React.Node { const [hasPermission, setHasPermission] = React.useState(null); const [scanned, setScanned] = React.useState(false); + const [urlInput, setURLInput] = React.useState(''); const styles = useStyles(unboundStyles); - const navigation = useNavigation(); + const { goBack, setOptions } = useNavigation(); const tunnelbrokerContext = useTunnelbroker(); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'identity context not set'); const aes256Key = React.useRef(null); const secondaryDeviceID = React.useRef(null); const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates(); const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers(); const foreignPeerDevices = useSelector(getForeignPeerDevices); + const { panelForegroundTertiaryLabel } = useColors(); + const tunnelbrokerMessageListener = React.useCallback( async (message: TunnelbrokerMessage) => { const encryptionKey = aes256Key.current; const targetDeviceID = secondaryDeviceID.current; if (!encryptionKey || !targetDeviceID) { return; } if (message.type !== tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE) { return; } let innerMessage: PeerToPeerMessage; try { innerMessage = JSON.parse(message.payload); } catch { return; } if ( !peerToPeerMessageValidator.is(innerMessage) || innerMessage.type !== peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE ) { return; } const payload = await parseTunnelbrokerQRAuthMessage( encryptionKey, innerMessage, ); if ( !payload || payload.type !== qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS ) { return; } invariant(identityContext, 'identity context not set'); const { getAuthMetadata, identityClient } = identityContext; const { userID, deviceID } = await getAuthMetadata(); if (!userID || !deviceID) { throw new Error('missing auth metadata'); } const deviceLists = await identityClient.getDeviceListHistoryForUser(userID); invariant(deviceLists.length > 0, 'received empty device list history'); const lastSignedDeviceList = deviceLists[deviceLists.length - 1]; const deviceList = rawDeviceListFromSignedList(lastSignedDeviceList); const ownOtherDevices = deviceList.devices.filter(it => it !== deviceID); await Promise.all([ broadcastDeviceListUpdates( [...ownOtherDevices, ...foreignPeerDevices], lastSignedDeviceList, ), getAndUpdateDeviceListsForUsers([userID]), ]); if (!payload.requestBackupKeys) { Alert.alert('Device added', 'Device registered successfully', [ - { text: 'OK' }, + { text: 'OK', onPress: goBack }, ]); return; } const backupSecret = await getBackupSecret(); const backupKeysResponse = await commCoreModule.retrieveBackupKeys(backupSecret); const backupKeys = assertWithValidator( JSON.parse(backupKeysResponse), backupKeysValidator, ); const backupKeyMessage = await composeTunnelbrokerQRAuthMessage( encryptionKey, { type: qrCodeAuthMessageTypes.BACKUP_DATA_KEY_MESSAGE, ...backupKeys, }, ); await tunnelbrokerContext.sendMessage({ deviceID: targetDeviceID, payload: JSON.stringify(backupKeyMessage), }); Alert.alert('Device added', 'Device registered successfully', [ - { text: 'OK' }, + { text: 'OK', onPress: goBack }, ]); }, [ identityContext, broadcastDeviceListUpdates, foreignPeerDevices, getAndUpdateDeviceListsForUsers, tunnelbrokerContext, + goBack, ], ); React.useEffect(() => { tunnelbrokerContext.addListener(tunnelbrokerMessageListener); return () => { tunnelbrokerContext.removeListener(tunnelbrokerMessageListener); }; }, [tunnelbrokerMessageListener, tunnelbrokerContext]); React.useEffect(() => { void (async () => { const { status } = await BarCodeScanner.requestPermissionsAsync(); setHasPermission(status === 'granted'); if (status !== 'granted') { Alert.alert( 'No access to camera', 'Please allow Comm to access your camera in order to scan the QR code.', [{ text: 'OK' }], ); - navigation.goBack(); + goBack(); } })(); - }, [navigation]); + }, [goBack]); + + const processDeviceListUpdate = React.useCallback(async () => { + try { + const { deviceID: primaryDeviceID, userID } = + await identityContext.getAuthMetadata(); + if (!primaryDeviceID || !userID) { + throw new Error('missing auth metadata'); + } + const encryptionKey = aes256Key.current; + const targetDeviceID = secondaryDeviceID.current; + if (!encryptionKey || !targetDeviceID) { + throw new Error('missing tunnelbroker message data'); + } + await addDeviceToDeviceList( + identityContext.identityClient, + userID, + targetDeviceID, + ); + const message = await composeTunnelbrokerQRAuthMessage(encryptionKey, { + type: qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS, + userID, + primaryDeviceID, + }); + await tunnelbrokerContext.sendMessage({ + deviceID: targetDeviceID, + payload: JSON.stringify(message), + }); + } catch (err) { + console.log('Primary device error:', err); + Alert.alert('Adding device failed', 'Failed to update the device list', [ + { text: 'OK' }, + ]); + goBack(); + } + }, [goBack, identityContext, tunnelbrokerContext]); + + const onPressSave = React.useCallback(async () => { + if (!urlInput) { + return; + } + + const parsedData = parseDataFromDeepLink(urlInput); + const keysMatch = parsedData?.data?.keys; + + if (!parsedData || !keysMatch) { + Alert.alert( + 'Scan failed', + 'QR code does not contain a valid pair of keys.', + [{ text: 'OK' }], + ); + return; + } + + try { + const keys = JSON.parse(decodeURIComponent(keysMatch)); + const { aes256, ed25519 } = keys; + aes256Key.current = aes256; + secondaryDeviceID.current = ed25519; + } catch (err) { + console.log('Failed to decode URI component:', err); + } + await processDeviceListUpdate(); + }, [processDeviceListUpdate, urlInput]); + + const buttonDisabled = !urlInput; + React.useEffect(() => { + if (!deviceIsEmulator) { + return; + } + setOptions({ + headerRight: () => ( + + ), + }); + }, [buttonDisabled, onPressSave, setOptions]); + + const onChangeText = React.useCallback( + (text: string) => setURLInput(text), + [], + ); const onConnect = React.useCallback( async (barCodeEvent: BarCodeEvent) => { const { data } = barCodeEvent; const parsedData = parseDataFromDeepLink(data); const keysMatch = parsedData?.data?.keys; if (!parsedData || !keysMatch) { Alert.alert( 'Scan failed', 'QR code does not contain a valid pair of keys.', [{ text: 'OK' }], ); return; } - const keys = JSON.parse(decodeURIComponent(keysMatch)); - const { aes256, ed25519 } = keys; - aes256Key.current = aes256; - secondaryDeviceID.current = ed25519; - try { - const { deviceID: primaryDeviceID, userID } = - await identityContext.getAuthMetadata(); - if (!primaryDeviceID || !userID) { - throw new Error('missing auth metadata'); - } - await addDeviceToDeviceList( - identityContext.identityClient, - userID, - ed25519, - ); - const message = await composeTunnelbrokerQRAuthMessage(aes256, { - type: qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS, - userID, - primaryDeviceID, - }); - await tunnelbrokerContext.sendMessage({ - deviceID: ed25519, - payload: JSON.stringify(message), - }); + const keys = JSON.parse(decodeURIComponent(keysMatch)); + const { aes256, ed25519 } = keys; + aes256Key.current = aes256; + secondaryDeviceID.current = ed25519; } catch (err) { - console.log('Primary device error:', err); - Alert.alert( - 'Adding device failed', - 'Failed to update the device list', - [{ text: 'OK' }], - ); - navigation.goBack(); + console.log('Failed to decode URI component:', err); } + + await processDeviceListUpdate(); }, - [tunnelbrokerContext, identityContext, navigation], + [processDeviceListUpdate], ); const onCancelScan = React.useCallback(() => setScanned(false), []); const handleBarCodeScanned = React.useCallback( (barCodeEvent: BarCodeEvent) => { setScanned(true); Alert.alert( 'Connect with this device?', 'Are you sure you want to allow this device to log in to your account?', [ { text: 'Cancel', style: 'cancel', onPress: onCancelScan, }, { text: 'Connect', onPress: () => onConnect(barCodeEvent), }, ], { cancelable: false }, ); }, [onCancelScan, onConnect], ); if (hasPermission === null) { return ; } + if (deviceIsEmulator) { + return ( + + QR Code URL + + + + + ); + } // Note: According to the BarCodeScanner Expo docs, we should adhere to two // guidances when using the BarCodeScanner: // 1. We should specify the potential barCodeTypes we want to scan for to // minimize battery usage. // 2. We should set the onBarCodeScanned callback to undefined if it scanned // in order to 'pause' the scanner from continuing to scan while we // process the data from the scan. // See: https://docs.expo.io/versions/latest/sdk/bar-code-scanner return ( - + ); } const unboundStyles = { - container: { + scannerContainer: { flex: 1, flexDirection: 'column', justifyContent: 'center', }, scanner: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, + textInputContainer: { + paddingTop: 8, + }, + header: { + color: 'panelBackgroundLabel', + fontSize: 12, + fontWeight: '400', + paddingBottom: 3, + paddingHorizontal: 24, + }, + inputContainer: { + backgroundColor: 'panelForeground', + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 24, + paddingVertical: 12, + borderBottomWidth: 1, + borderColor: 'panelForegroundBorder', + borderTopWidth: 1, + }, + input: { + color: 'panelForegroundLabel', + flex: 1, + fontFamily: 'Arial', + fontSize: 16, + paddingVertical: 0, + borderBottomColor: 'transparent', + }, }; export default SecondaryDeviceQRCodeScanner;