diff --git a/keyserver/.well-known/apple-app-site-association b/keyserver/.well-known/apple-app-site-association index 13b2b687f..6ed7ee5a6 100644 --- a/keyserver/.well-known/apple-app-site-association +++ b/keyserver/.well-known/apple-app-site-association @@ -1,22 +1,25 @@ { "webcredentials": { "apps": [ "H98Y8MH53M.app.comm" ] }, "applinks": { "apps": [], "details": [ { "appIDs": [ "H98Y8MH53M.app.comm" ], "components": [ { "/": "/invite/*" + }, + { + "/": "/qr-code/*" } ] }, { "appID": "H98Y8MH53M.app.comm", - "paths": [ "/invite/*" ] + "paths": [ "/invite/*", "/qr-code/*" ] } ] } } diff --git a/lib/facts/links.js b/lib/facts/links.js index 09328d86b..9de231343 100644 --- a/lib/facts/links.js +++ b/lib/facts/links.js @@ -1,7 +1,22 @@ // @flow function inviteLinkUrl(secret: string): string { return `https://comm.app/invite/${secret}`; } -export { inviteLinkUrl }; +function qrCodeLinkUrl(aes256Param: string, ed25519Param: string): string { + const keys = { + aes256: aes256Param, + ed25519: ed25519Param, + }; + const keysString = encodeURIComponent(JSON.stringify(keys)); + return `comm://qr-code/${keysString}`; +} + +function parseKeysFromQRCodeURL(url: string): ?string { + const urlRegex = /qr-code\/(\S+)$/; + const match = urlRegex.exec(url); + return match?.[1]; +} + +export { inviteLinkUrl, qrCodeLinkUrl, parseKeysFromQRCodeURL }; diff --git a/native/android/app/src/main/AndroidManifest.xml b/native/android/app/src/main/AndroidManifest.xml index 9acb1b4ba..edbad645d 100644 --- a/native/android/app/src/main/AndroidManifest.xml +++ b/native/android/app/src/main/AndroidManifest.xml @@ -1,99 +1,104 @@ + diff --git a/native/navigation/navigation-handler.react.js b/native/navigation/navigation-handler.react.js index 46e016530..cd0a24689 100644 --- a/native/navigation/navigation-handler.react.js +++ b/native/navigation/navigation-handler.react.js @@ -1,86 +1,88 @@ // @flow import * as React from 'react'; import { cookieSelector } from 'lib/selectors/keyserver-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { logInActionType, logOutActionType } from './action-types.js'; import ModalPruner from './modal-pruner.react.js'; import NavFromReduxHandler from './nav-from-redux-handler.react.js'; import { useIsAppLoggedIn } from './nav-selectors.js'; import { NavContext, type NavAction } from './navigation-context.js'; import PolicyAcknowledgmentHandler from './policy-acknowledgment-handler.react.js'; +import QRCodeLinkHandler from './qr-code-link-handler.react.js'; import ThreadScreenTracker from './thread-screen-tracker.react.js'; import DevTools from '../redux/dev-tools.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { usePersistedStateLoaded } from '../selectors/app-state-selectors.js'; const NavigationHandler: React.ComponentType<{}> = React.memo<{}>( function NavigationHandler() { const navContext = React.useContext(NavContext); const persistedStateLoaded = usePersistedStateLoaded(); const devTools = __DEV__ ? : null; if (!navContext || !persistedStateLoaded) { if (__DEV__) { return ( <> {devTools} > ); } else { return null; } } const { dispatch } = navContext; return ( <> + {devTools} > ); }, ); NavigationHandler.displayName = 'NavigationHandler'; type LogInHandlerProps = { +dispatch: (action: NavAction) => void, }; const LogInHandler = React.memo(function LogInHandler( props: LogInHandlerProps, ) { const { dispatch } = props; const hasCurrentUserInfo = useSelector(isLoggedIn); const cookie = useSelector(cookieSelector); const hasUserCookie = !!(cookie && cookie.startsWith('user=')); const loggedIn = hasCurrentUserInfo && hasUserCookie; const navLoggedIn = useIsAppLoggedIn(); const prevLoggedInRef = React.useRef(); React.useEffect(() => { if (loggedIn === prevLoggedInRef.current) { return; } prevLoggedInRef.current = loggedIn; if (loggedIn && !navLoggedIn) { dispatch({ type: (logInActionType: 'LOG_IN') }); } else if (!loggedIn && navLoggedIn) { dispatch({ type: (logOutActionType: 'LOG_OUT') }); } }, [navLoggedIn, loggedIn, dispatch]); return null; }); LogInHandler.displayName = 'LogInHandler'; export default NavigationHandler; diff --git a/native/navigation/qr-code-link-handler.react.js b/native/navigation/qr-code-link-handler.react.js new file mode 100644 index 000000000..2f9c9070d --- /dev/null +++ b/native/navigation/qr-code-link-handler.react.js @@ -0,0 +1,51 @@ +// @flow + +import { useNavigation } from '@react-navigation/native'; +import * as React from 'react'; +import { Linking } from 'react-native'; + +import { parseKeysFromQRCodeURL } from 'lib/facts/links.js'; +import { isLoggedIn } from 'lib/selectors/user-selectors.js'; + +import { SecondaryDeviceQRCodeScannerRouteName } from './route-names.js'; +import { useSelector } from '../redux/redux-utils.js'; + +function QRCodeLinkHandler(): null { + const [currentLink, setCurrentLink] = React.useState(null); + + React.useEffect(() => { + const subscription = Linking.addEventListener('url', ({ url }) => + setCurrentLink(url), + ); + (async () => { + const initialURL = await Linking.getInitialURL(); + if (initialURL) { + setCurrentLink(initialURL); + } + })(); + + return () => subscription.remove(); + }, []); + + const loggedIn = useSelector(isLoggedIn); + const { navigate } = useNavigation(); + + React.useEffect(() => { + if (!loggedIn || !currentLink) { + return; + } + + setCurrentLink(null); + + const keys = parseKeysFromQRCodeURL(currentLink); + if (!keys) { + return; + } + + navigate(SecondaryDeviceQRCodeScannerRouteName); + }, [currentLink, loggedIn, navigate]); + + return null; +} + +export default QRCodeLinkHandler; diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js index a630221e5..7fbbe619c 100644 --- a/native/profile/secondary-device-qr-code-scanner.react.js +++ b/native/profile/secondary-device-qr-code-scanner.react.js @@ -1,110 +1,125 @@ // @flow import { useNavigation } from '@react-navigation/native'; import { BarCodeScanner, type BarCodeEvent } from 'expo-barcode-scanner'; import * as React from 'react'; import { View } from 'react-native'; +import { parseKeysFromQRCodeURL } from 'lib/facts/links.js'; + import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; const barCodeTypes = [BarCodeScanner.Constants.BarCodeType.qr]; // eslint-disable-next-line no-unused-vars function SecondaryDeviceQRCodeScanner(props: { ... }): React.Node { const [hasPermission, setHasPermission] = React.useState(null); const [scanned, setScanned] = React.useState(false); const styles = useStyles(unboundStyles); const navigation = useNavigation(); React.useEffect(() => { (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(); } })(); }, [navigation]); const onConnect = React.useCallback((barCodeEvent: BarCodeEvent) => { - const { type, data } = barCodeEvent; + const { data } = barCodeEvent; + const keysMatch = parseKeysFromQRCodeURL(data); + + if (!keysMatch) { + Alert.alert( + 'Scan failed', + 'QR code does not contain a valid pair of keys.', + [{ text: 'OK' }], + ); + return; + } + + const keys = JSON.parse(decodeURIComponent(keysMatch)); + Alert.alert( 'Scan successful', - `Bar code with type ${type} and data ${data} has been scanned!`, + `QR code contains the following keys: ${JSON.stringify(keys)}`, [{ text: 'OK' }], ); }, []); 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 ; } // 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: { flex: 1, flexDirection: 'column', justifyContent: 'center', }, scanner: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, }; export default SecondaryDeviceQRCodeScanner; diff --git a/native/qr-code/qr-code-screen.react.js b/native/qr-code/qr-code-screen.react.js index 23f1e18f6..f0edbf9de 100644 --- a/native/qr-code/qr-code-screen.react.js +++ b/native/qr-code/qr-code-screen.react.js @@ -1,86 +1,90 @@ // @flow import * as React from 'react'; import { View, Text } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; +import { qrCodeLinkUrl } from 'lib/facts/links.js'; + import type { QRCodeSignInNavigationProp } from './qr-code-sign-in-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type QRCodeScreenProps = { +navigation: QRCodeSignInNavigationProp<'QRCodeScreen'>, +route: NavigationRoute<'QRCodeScreen'>, }; +const qrCodeValue = qrCodeLinkUrl('random_aes256_key', 'device_ed25519_key'); + // eslint-disable-next-line no-unused-vars function QRCodeScreen(props: QRCodeScreenProps): React.Node { const styles = useStyles(unboundStyles); return ( Log in to Comm Open the Comm app on your phone and scan the QR code below - + How to find the scanner: Go to Profile Select Linked devices Click Add on the top right ); } const unboundStyles = { container: { flex: 1, alignItems: 'center', marginTop: 125, }, heading: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 12, }, headingSubtext: { fontSize: 12, color: 'panelForegroundLabel', paddingBottom: 30, }, instructionsBox: { alignItems: 'center', width: 300, marginTop: 40, padding: 15, borderColor: 'panelForegroundLabel', borderWidth: 2, borderRadius: 8, }, instructionsTitle: { fontSize: 12, color: 'panelForegroundLabel', paddingBottom: 15, }, instructionsStep: { fontSize: 12, padding: 1, color: 'panelForegroundLabel', }, instructionsBold: { fontWeight: 'bold', }, }; export default QRCodeScreen; diff --git a/web/account/qr-code-login.react.js b/web/account/qr-code-login.react.js index df75c1f4a..12110f9bd 100644 --- a/web/account/qr-code-login.react.js +++ b/web/account/qr-code-login.react.js @@ -1,37 +1,36 @@ // @flow import { QRCodeSVG } from 'qrcode.react'; import * as React from 'react'; +import { qrCodeLinkUrl } from 'lib/facts/links.js'; + import css from './qr-code-login.css'; +const qrCodeValue = qrCodeLinkUrl('random_aes256_key', 'device_ed25519_key'); + function QrCodeLogin(): React.Node { return ( Log in to Comm Open the Comm app on your phone and scan the QR code below - + How to find the scanner: Go to Profile Select Linked devices Click Add on the top right ); } export default QrCodeLogin;