From 24e76293cc2b1b3be9dacbeaf9092181ab6325bc Mon Sep 17 00:00:00 2001 From: Akash Date: Thu, 3 Jun 2021 15:29:38 +0530 Subject: [PATCH 01/10] deeplink [nfc]: Move type `LinkingEvent` to `src/types.js`. LinkingEvent is going to be used in multiple files in the following commits and hence it is better to export it from a common file. --- src/start/AuthScreen.js | 15 +-------------- src/types.js | 13 +++++++++++++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/start/AuthScreen.js b/src/start/AuthScreen.js index eb55689ba70..054c1bf9f74 100644 --- a/src/start/AuthScreen.js +++ b/src/start/AuthScreen.js @@ -14,7 +14,7 @@ import type { RouteProp } from '../react-navigation'; import type { AppNavigationProp } from '../nav/AppNavigator'; import * as NavigationService from '../nav/NavigationService'; import config from '../config'; -import type { Dispatch } from '../types'; +import type { Dispatch, LinkingEvent } from '../types'; import { IconApple, IconPrivate, @@ -177,19 +177,6 @@ type Props = $ReadOnly<{| let otp = ''; -/** - * An event emitted by `Linking`. - * - * Determined by reading the implementation source code, and documentation: - * https://reactnative.dev/docs/linking - * - * TODO move this to a libdef, and/or get an explicit type into upstream. - */ -type LinkingEvent = { - url: string, - ... -}; - class AuthScreen extends PureComponent { componentDidMount = () => { Linking.addEventListener('url', this.endWebAuth); diff --git a/src/types.js b/src/types.js index baf1fa6aaf3..5e8a1fe60b7 100644 --- a/src/types.js +++ b/src/types.js @@ -394,3 +394,16 @@ export type SharedFile = {| * The data we get when the user "shares" to Zulip from another app. */ export type SharedData = SharedText | SharedImage | SharedFile; + +/** + * An event emitted by `Linking`. + * + * Determined by reading the implementation source code, and documentation: + * https://reactnative.dev/docs/linking + * + * TODO move this to a libdef, and/or get an explicit type into upstream. + */ +export type LinkingEvent = { + url: string, + ... +}; From 80fb6cd2757382115e4636efa5f80fe5baf2cb7c Mon Sep 17 00:00:00 2001 From: Akash Date: Wed, 2 Jun 2021 22:52:44 +0530 Subject: [PATCH 02/10] auth [nfc]: Move global `otp` from `AuthScreen` to `webAuth`. This will help us in moving `webAuthBegin`, `webAuthEnd` from `AuthScreen` to `webAuth.js` which is a better place to put them from a reusability point of view. This commit also involves modifying the associated code in `AuthScreen`, `webAuth.js` and `webAuth-test.js`. Specifically, these changes are being made to implement deep link functionality in the following commits. --- src/start/AuthScreen.js | 10 ++++------ src/start/__tests__/webAuth-test.js | 6 +++--- src/start/webAuth.js | 24 ++++++++++++++++++------ 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/start/AuthScreen.js b/src/start/AuthScreen.js index 054c1bf9f74..551b91db941 100644 --- a/src/start/AuthScreen.js +++ b/src/start/AuthScreen.js @@ -175,8 +175,6 @@ type Props = $ReadOnly<{| realm: URL, |}>; -let otp = ''; - class AuthScreen extends PureComponent { componentDidMount = () => { Linking.addEventListener('url', this.endWebAuth); @@ -207,15 +205,15 @@ class AuthScreen extends PureComponent { * `external_authentication_method` object from `/server_settings`. */ beginWebAuth = async (url: string) => { - otp = await webAuth.generateOtp(); - webAuth.openBrowser(new URL(url, this.props.realm).toString(), otp); + await webAuth.generateOtp(); + webAuth.openBrowser(new URL(url, this.props.realm).toString()); }; endWebAuth = (event: LinkingEvent) => { webAuth.closeBrowser(); const { dispatch, realm } = this.props; - const auth = webAuth.authFromCallbackUrl(event.url, otp, realm); + const auth = webAuth.authFromCallbackUrl(event.url, realm); if (auth) { dispatch(loginSuccess(auth.realm, auth.email, auth.apiKey)); } @@ -249,7 +247,7 @@ class AuthScreen extends PureComponent { throw new Error('`state` mismatch'); } - otp = await webAuth.generateOtp(); + const otp = await webAuth.generateOtp(); const params = encodeParamsForUrl({ mobile_flow_otp: otp, diff --git a/src/start/__tests__/webAuth-test.js b/src/start/__tests__/webAuth-test.js index d3395e955f5..e30fbbb35e8 100644 --- a/src/start/__tests__/webAuth-test.js +++ b/src/start/__tests__/webAuth-test.js @@ -7,7 +7,7 @@ describe('authFromCallbackUrl', () => { test('success', () => { const url = `zulip://login?realm=${eg.realm.toString()}&email=a@b&otp_encrypted_api_key=2636fdeb`; - expect(authFromCallbackUrl(url, otp, eg.realm)).toEqual({ + expect(authFromCallbackUrl(url, eg.realm, otp)).toEqual({ realm: eg.realm, email: 'a@b', apiKey: '5af4', @@ -17,13 +17,13 @@ describe('authFromCallbackUrl', () => { test('wrong realm', () => { const url = 'zulip://login?realm=https://other.example.org&email=a@b&otp_encrypted_api_key=2636fdeb'; - expect(authFromCallbackUrl(url, otp, eg.realm)).toEqual(null); + expect(authFromCallbackUrl(url, eg.realm, otp)).toEqual(null); }); test('not login', () => { // Hypothetical link that isn't a login... but somehow with all the same // query params, for extra confusion for good measure. const url = `zulip://message?realm=${eg.realm.toString()}&email=a@b&otp_encrypted_api_key=2636fdeb`; - expect(authFromCallbackUrl(url, otp, eg.realm)).toEqual(null); + expect(authFromCallbackUrl(url, eg.realm, otp)).toEqual(null); }); }); diff --git a/src/start/webAuth.js b/src/start/webAuth.js index c308f6c3521..954f5d2857b 100644 --- a/src/start/webAuth.js +++ b/src/start/webAuth.js @@ -27,6 +27,8 @@ import { base64ToHex, hexToAscii, xorHexStrings } from '../utils/encoding'; https://chat.zulip.org/#narrow/stream/16-desktop/topic/desktop.20app.20OAuth/near/803919 */ +let otp = ''; + export const generateRandomToken = async (): Promise => { if (Platform.OS === 'android') { return new Promise((resolve, reject) => { @@ -45,9 +47,12 @@ export const generateRandomToken = async (): Promise => { // Generate a one time pad (OTP) which the server XORs the API key with // in its response to protect against credentials intercept -export const generateOtp = async (): Promise => generateRandomToken(); +export const generateOtp = async (): Promise => { + otp = await generateRandomToken(); + return otp; +}; -export const openBrowser = (url: string, otp: string) => { +export const openBrowser = (url: string) => { openLinkEmbedded(`${url}?mobile_flow_otp=${otp}`); }; @@ -64,9 +69,16 @@ export const closeBrowser = () => { * * Corresponds to `otp_decrypt_api_key` on the server. */ -const extractApiKey = (encoded: string, otp: string) => hexToAscii(xorHexStrings(encoded, otp)); - -export const authFromCallbackUrl = (callbackUrl: string, otp: string, realm: URL): Auth | null => { +const extractApiKey = (encoded: string) => hexToAscii(xorHexStrings(encoded, otp)); + +export const authFromCallbackUrl = ( + callbackUrl: string, + realm: URL, + customOtp?: string, +): Auth | null => { + if (customOtp !== undefined) { + otp = customOtp; + } // callback format expected: zulip://login?realm={}&email={}&otp_encrypted_api_key={} const url = tryParseUrl(callbackUrl); if (!url) { @@ -92,7 +104,7 @@ export const authFromCallbackUrl = (callbackUrl: string, otp: string, realm: URL && otpEncryptedApiKey !== null && otpEncryptedApiKey.length === otp.length ) { - const apiKey = extractApiKey(otpEncryptedApiKey, otp); + const apiKey = extractApiKey(otpEncryptedApiKey); return { realm, email, apiKey }; } From 7cd1211e408a03934976e3e6e11654073e12f5f0 Mon Sep 17 00:00:00 2001 From: Akash Date: Wed, 2 Jun 2021 23:39:56 +0530 Subject: [PATCH 03/10] auth [nfc]: Move `beginWebAuth` and `endWebAuth` to `webAuth.js` This change is done to centralise the handling of urls received by our app which will be required to implement deep linking. With this modification webAuth can be initiated from any component with appropriate parameters. Note that this commit also stops exporting some functions from `webAuth.js` as this is no longer needed, and updates the jsdocs of `beginWebAuth` and `endWebAuth`. --- src/start/AuthScreen.js | 25 +++------------------- src/start/webAuth.js | 47 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/start/AuthScreen.js b/src/start/AuthScreen.js index 551b91db941..f66dee138df 100644 --- a/src/start/AuthScreen.js +++ b/src/start/AuthScreen.js @@ -30,7 +30,7 @@ import { Centerer, Screen, ZulipButton } from '../common'; import RealmInfo from './RealmInfo'; import { encodeParamsForUrl } from '../utils/url'; import * as webAuth from './webAuth'; -import { loginSuccess, navigateToDevAuth, navigateToPasswordAuth } from '../actions'; +import { navigateToDevAuth, navigateToPasswordAuth } from '../actions'; import IosCompliantAppleAuthButton from './IosCompliantAppleAuthButton'; import { openLinkEmbedded } from '../utils/openLink'; @@ -198,26 +198,7 @@ class AuthScreen extends PureComponent { Linking.removeEventListener('url', this.endWebAuth); }; - /** - * Hand control to the browser for an external auth method. - * - * @param url The `login_url` string, a relative URL, from an - * `external_authentication_method` object from `/server_settings`. - */ - beginWebAuth = async (url: string) => { - await webAuth.generateOtp(); - webAuth.openBrowser(new URL(url, this.props.realm).toString()); - }; - - endWebAuth = (event: LinkingEvent) => { - webAuth.closeBrowser(); - - const { dispatch, realm } = this.props; - const auth = webAuth.authFromCallbackUrl(event.url, realm); - if (auth) { - dispatch(loginSuccess(auth.realm, auth.email, auth.apiKey)); - } - }; + endWebAuth = (event: LinkingEvent) => webAuth.endWebAuth(event, this.props.dispatch); handleDevAuth = () => { NavigationService.dispatch(navigateToDevAuth({ realm: this.props.realm })); @@ -292,7 +273,7 @@ class AuthScreen extends PureComponent { } else if (method.name === 'apple' && (await this.canUseNativeAppleFlow())) { this.handleNativeAppleAuth(); } else { - this.beginWebAuth(action.url); + webAuth.beginWebAuth(action.url, this.props.realm); } }; diff --git a/src/start/webAuth.js b/src/start/webAuth.js index 954f5d2857b..884c2f41a4d 100644 --- a/src/start/webAuth.js +++ b/src/start/webAuth.js @@ -2,10 +2,12 @@ import { NativeModules, Platform } from 'react-native'; import SafariView from 'react-native-safari-view'; -import type { Auth } from '../types'; +import invariant from 'invariant'; +import type { Auth, Dispatch, LinkingEvent } from '../types'; import { openLinkEmbedded } from '../utils/openLink'; import { tryParseUrl } from '../utils/url'; import { base64ToHex, hexToAscii, xorHexStrings } from '../utils/encoding'; +import { loginSuccess } from '../account/accountActions'; /* Logic for authenticating the user to Zulip through a browser. @@ -52,11 +54,11 @@ export const generateOtp = async (): Promise => { return otp; }; -export const openBrowser = (url: string) => { +const openBrowser = (url: string) => { openLinkEmbedded(`${url}?mobile_flow_otp=${otp}`); }; -export const closeBrowser = () => { +const closeBrowser = () => { if (Platform.OS === 'android') { NativeModules.CloseAllCustomTabsAndroid.closeAll(); } else { @@ -110,3 +112,42 @@ export const authFromCallbackUrl = ( return null; }; + +/** + * Hand control to the browser for an external auth method. + * + * @param url The `login_url` string, a relative URL, from an + * `external_authentication_method` object from `/server_settings`. + * @param realm URL of the realm for which webAuth needs to begin. + */ +export const beginWebAuth = async (url: string, realm: URL) => { + await generateOtp(); + openBrowser(new URL(url, realm).toString()); +}; + +/** + * Meant to be triggered by incoming app link that contains auth + * information. + * + * @param event React Native Linking 'url' event. + * @param dispatch function to dispatch action. + */ +export const endWebAuth = (event: LinkingEvent, dispatch: Dispatch) => { + closeBrowser(); + + const encodedRealm = event.url + .split('?') + .pop() + .split('&') + .map(param => param.split('=')) + .find(x => x[0] === 'realm'); + invariant( + encodedRealm !== undefined, + 'URL received from web auth must contain realm as parameter.', + ); + const decodedRealm = new URL(decodeURIComponent(encodedRealm[1])); + const auth = authFromCallbackUrl(event.url, decodedRealm); + if (auth) { + dispatch(loginSuccess(auth.realm, auth.email, auth.apiKey)); + } +}; From 105174218878b57dbf986b8b66a587346aba19cf Mon Sep 17 00:00:00 2001 From: Akash Date: Thu, 3 Jun 2021 00:15:05 +0530 Subject: [PATCH 04/10] deeplink [nfc]: Implement `handleInitialUrl` and `UrlListener`. --- src/deeplink/index.js | 48 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/deeplink/index.js diff --git a/src/deeplink/index.js b/src/deeplink/index.js new file mode 100644 index 00000000000..0ab0ac59609 --- /dev/null +++ b/src/deeplink/index.js @@ -0,0 +1,48 @@ +/* @flow strict-local */ +import { Linking } from 'react-native'; +import * as webAuth from '../start/webAuth'; +import type { Dispatch, LinkingEvent } from '../types'; + +export const handleInitialUrl = async (dispatch: Dispatch) => { + const initialUrl: ?string = await Linking.getInitialURL(); + if (initialUrl != null) { + webAuth.endWebAuth({ url: initialUrl }, dispatch); + } +}; + +export class UrlListener { + dispatch: Dispatch; + unsubs: Array<() => void> = []; + + constructor(dispatch: Dispatch) { + this.dispatch = dispatch; + } + + /** Private. */ + handleUrlEvent(event: LinkingEvent) { + webAuth.endWebAuth(event, this.dispatch); + } + + /** Private. */ + listen(handler: (event: LinkingEvent) => void | Promise) { + Linking.addEventListener('url', handler); + this.unsubs.push(() => Linking.removeEventListener('url', handler)); + } + + /** Private. */ + unlistenAll() { + while (this.unsubs.length > 0) { + this.unsubs.pop()(); + } + } + + /** Start listening. Don't call twice without intervening `stop`. */ + start() { + this.listen((event: LinkingEvent) => this.handleUrlEvent(event)); + } + + /** Stop listening. */ + stop() { + this.unlistenAll(); + } +} From ce6fc4753b8fd13ba9d9c4a7a4dc6b0f57d9c919 Mon Sep 17 00:00:00 2001 From: Akash Date: Thu, 3 Jun 2021 00:24:20 +0530 Subject: [PATCH 05/10] deeplink: Use `urlListener` to handle `endWebAuth`. This commit shifts the logic that handles auth urls; from `AuthScreen` to `AppEventHandlers`. --- src/boot/AppEventHandlers.js | 5 +++++ src/start/AuthScreen.js | 17 ++--------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/boot/AppEventHandlers.js b/src/boot/AppEventHandlers.js index 071e091225e..bc13bf78bd6 100644 --- a/src/boot/AppEventHandlers.js +++ b/src/boot/AppEventHandlers.js @@ -16,6 +16,7 @@ import { notificationOnAppActive, } from '../notification'; import { ShareReceivedListener, handleInitialShare } from '../sharing'; +import { UrlListener, handleInitialUrl } from '../deeplink'; import { appOnline, appOrientation } from '../actions'; import PresenceHeartbeat from '../presence/PresenceHeartbeat'; @@ -117,6 +118,7 @@ class AppEventHandlers extends PureComponent { notificationListener = new NotificationListener(this.props.dispatch); shareListener = new ShareReceivedListener(this.props.dispatch); + urlListener = new UrlListener(this.props.dispatch); handleMemoryWarning = () => { // Release memory here @@ -126,6 +128,7 @@ class AppEventHandlers extends PureComponent { const { dispatch } = this.props; handleInitialNotification(dispatch); handleInitialShare(dispatch); + handleInitialUrl(dispatch); this.netInfoDisconnectCallback = NetInfo.addEventListener(this.handleConnectivityChange); AppState.addEventListener('change', this.handleAppStateChange); @@ -133,6 +136,7 @@ class AppEventHandlers extends PureComponent { ScreenOrientation.addOrientationChangeListener(this.handleOrientationChange); this.notificationListener.start(); this.shareListener.start(); + this.urlListener.start(); } componentWillUnmount() { @@ -145,6 +149,7 @@ class AppEventHandlers extends PureComponent { ScreenOrientation.removeOrientationChangeListeners(); this.notificationListener.stop(); this.shareListener.stop(); + this.urlListener.stop(); } render() { diff --git a/src/start/AuthScreen.js b/src/start/AuthScreen.js index f66dee138df..c19db2c5665 100644 --- a/src/start/AuthScreen.js +++ b/src/start/AuthScreen.js @@ -1,7 +1,7 @@ /* @flow strict-local */ import React, { PureComponent } from 'react'; -import { Linking, Platform } from 'react-native'; +import { Platform } from 'react-native'; import type { AppleAuthenticationCredential } from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication'; @@ -14,7 +14,7 @@ import type { RouteProp } from '../react-navigation'; import type { AppNavigationProp } from '../nav/AppNavigator'; import * as NavigationService from '../nav/NavigationService'; import config from '../config'; -import type { Dispatch, LinkingEvent } from '../types'; +import type { Dispatch } from '../types'; import { IconApple, IconPrivate, @@ -177,13 +177,6 @@ type Props = $ReadOnly<{| class AuthScreen extends PureComponent { componentDidMount = () => { - Linking.addEventListener('url', this.endWebAuth); - Linking.getInitialURL().then((initialUrl: ?string) => { - if (initialUrl !== null && initialUrl !== undefined) { - this.endWebAuth({ url: initialUrl }); - } - }); - const { serverSettings } = this.props.route.params; const authList = activeAuthentications( serverSettings.authentication_methods, @@ -194,12 +187,6 @@ class AuthScreen extends PureComponent { } }; - componentWillUnmount = () => { - Linking.removeEventListener('url', this.endWebAuth); - }; - - endWebAuth = (event: LinkingEvent) => webAuth.endWebAuth(event, this.props.dispatch); - handleDevAuth = () => { NavigationService.dispatch(navigateToDevAuth({ realm: this.props.realm })); }; From f4658f0d07f3d04cc978ac3d9957ece57d81bd74 Mon Sep 17 00:00:00 2001 From: Akash Date: Thu, 3 Jun 2021 12:00:07 +0530 Subject: [PATCH 06/10] utils: Update `internalLink.js` to support deeplink urls. `internalLink.js` provides us many function that can also be utilised to process deep links. Doing this however required few simple modifications. This commit adds `isDeepLink` function, modifies getLinkType in order to be compatible with deep links and renames `internalLink.js` to `linkProcessors.js`. --- src/message/messagesActions.js | 2 +- .../{internalLinks-test.js => linkProcessors-test.js} | 6 +++--- src/utils/{internalLinks.js => linkProcessors.js} | 8 +++++--- 3 files changed, 9 insertions(+), 7 deletions(-) rename src/utils/__tests__/{internalLinks-test.js => linkProcessors-test.js} (99%) rename src/utils/{internalLinks.js => linkProcessors.js} (96%) diff --git a/src/message/messagesActions.js b/src/message/messagesActions.js index 7daaf5f0783..52ee847c976 100644 --- a/src/message/messagesActions.js +++ b/src/message/messagesActions.js @@ -2,7 +2,7 @@ import * as NavigationService from '../nav/NavigationService'; import type { Narrow, Dispatch, GetState } from '../types'; import { getAuth } from '../selectors'; -import { getMessageIdFromLink, getNarrowFromLink } from '../utils/internalLinks'; +import { getMessageIdFromLink, getNarrowFromLink } from '../utils/linkProcessors'; import { openLinkWithUserPreference } from '../utils/openLink'; import { navigateToChat } from '../nav/navActions'; import { FIRST_UNREAD_ANCHOR } from '../anchor'; diff --git a/src/utils/__tests__/internalLinks-test.js b/src/utils/__tests__/linkProcessors-test.js similarity index 99% rename from src/utils/__tests__/internalLinks-test.js rename to src/utils/__tests__/linkProcessors-test.js index b01769e6a7c..7f6f8529a6d 100644 --- a/src/utils/__tests__/internalLinks-test.js +++ b/src/utils/__tests__/linkProcessors-test.js @@ -8,7 +8,7 @@ import { getNarrowFromLink, getMessageIdFromLink, decodeHashComponent, -} from '../internalLinks'; +} from '../linkProcessors'; import * as eg from '../../__tests__/lib/exampleData'; const realm = new URL('https://example.com'); @@ -174,8 +174,8 @@ describe('isMessageLink', () => { }); describe('getLinkType', () => { - test('links to a different domain are of "external" type', () => { - expect(getLinkType('https://google.com/some-path', realm)).toBe('external'); + test('links to a different domain are of "other" type', () => { + expect(getLinkType('https://google.com/some-path', realm)).toBe('other'); }); test('only in-app link containing "stream" is a stream link', () => { diff --git a/src/utils/internalLinks.js b/src/utils/linkProcessors.js similarity index 96% rename from src/utils/internalLinks.js rename to src/utils/linkProcessors.js index bdabaec5dd9..6ef38ea09c3 100644 --- a/src/utils/internalLinks.js +++ b/src/utils/linkProcessors.js @@ -44,6 +44,8 @@ export const isInternalLink = (url: string, realm: URL): boolean => { ); }; +const isDeepLink = (url: string): boolean => url.startsWith('zulip://'); + /** * PRIVATE -- exported only for tests. * @@ -56,7 +58,7 @@ export const isInternalLink = (url: string, realm: URL): boolean => { export const isMessageLink = (url: string, realm: URL): boolean => isInternalLink(url, realm) && url.includes('near'); -type LinkType = 'external' | 'home' | 'pm' | 'topic' | 'stream' | 'special'; +type LinkType = 'other' | 'home' | 'pm' | 'topic' | 'stream' | 'special'; /** * PRIVATE -- exported only for tests. @@ -68,8 +70,8 @@ type LinkType = 'external' | 'home' | 'pm' | 'topic' | 'stream' | 'special'; // TODO: Work out what this does, write a jsdoc for its interface, and // reimplement using URL object (not just for the realm) export const getLinkType = (url: string, realm: URL): LinkType => { - if (!isInternalLink(url, realm)) { - return 'external'; + if (!isInternalLink(url, realm) && !isDeepLink(url)) { + return 'other'; } const paths = getPathsFromUrl(url, realm); From 24a93b24eb4766e188077082165799c4b568efa5 Mon Sep 17 00:00:00 2001 From: Akash Date: Thu, 3 Jun 2021 12:21:18 +0530 Subject: [PATCH 07/10] deeplink: implement `navigateViaDeepLink` action. This action will be responsible to generate a narrow from a valid navigation deep link url, and then navigate to it. Limitation of current Implementation: - if the link points to a different account, we only switch the account and don't actually navigate to it. (similar to #4630) --- src/deeplink/urlActions.js | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/deeplink/urlActions.js diff --git a/src/deeplink/urlActions.js b/src/deeplink/urlActions.js new file mode 100644 index 00000000000..f602e5752a8 --- /dev/null +++ b/src/deeplink/urlActions.js @@ -0,0 +1,50 @@ +/* @flow strict-local */ +import type { Dispatch, GetState, Narrow } from '../types'; + +import * as NavigationService from '../nav/NavigationService'; +import { getNarrowFromLink } from '../utils/linkProcessors'; +import { getStreamsById } from '../subscriptions/subscriptionSelectors'; +import { getOwnUserId } from '../users/userSelectors'; +import { navigateToChat, navigateToRealmInputScreen } from '../nav/navActions'; +import { getAccountStatuses } from '../account/accountsSelectors'; +import { accountSwitch } from '../account/accountActions'; + +/** Navigate to the given narrow. */ +const doNarrow = (narrow: Narrow) => (dispatch: Dispatch, getState: GetState) => { + NavigationService.dispatch(navigateToChat(narrow)); +}; + +/** + * Navigates to a screen (of any logged in account) based on the deep link url. + * + * @param url deep link url of the form + * `zulip://example.com/?email=example@example.com#narrow/valid-narrow` + * + */ +export const navigateViaDeepLink = (url: URL) => async (dispatch: Dispatch, getState: GetState) => { + const state = getState(); + const account = getAccountStatuses(state); + const index = account.findIndex( + x => x.realm.hostname === url.hostname && x.email === url.searchParams.get('email'), + ); + if (index === -1) { + NavigationService.dispatch(navigateToRealmInputScreen()); + return; + } + if (index > 0) { + dispatch(accountSwitch(index)); + // TODO navigate to the screen pointed by deep link in new account. + return; + } + + const streamsById = getStreamsById(getState()); + const ownUserId = getOwnUserId(state); + + // For the current use case of the "realm" variable set below, it doesn't + // matter if it is hosted on `http` or `https` hence choosing one arbitrarily. + const realm = new URL(`http://${url.hostname}/`); + const narrow = getNarrowFromLink(url.toString(), realm, streamsById, ownUserId); + if (narrow) { + dispatch(doNarrow(narrow)); + } +}; From def383fa53167584f1ce053c6d000587d6d2cdb1 Mon Sep 17 00:00:00 2001 From: Akash Date: Thu, 3 Jun 2021 12:32:59 +0530 Subject: [PATCH 08/10] deeplink: Handle deep links that navigate to particular screens. This involves creating a `handleUrl` function that checks if deep link url is a login url or a navigation url and forwards the execution accordingly. --- src/deeplink/index.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/deeplink/index.js b/src/deeplink/index.js index 0ab0ac59609..c487b42c930 100644 --- a/src/deeplink/index.js +++ b/src/deeplink/index.js @@ -2,11 +2,22 @@ import { Linking } from 'react-native'; import * as webAuth from '../start/webAuth'; import type { Dispatch, LinkingEvent } from '../types'; +import { navigateViaDeepLink } from './urlActions'; + +const handleUrl = (url: URL, dispatch: Dispatch) => { + switch (url.hostname) { + case 'login': + webAuth.endWebAuth({ url: url.toString() }, dispatch); + break; + default: + dispatch(navigateViaDeepLink(url)); + } +}; export const handleInitialUrl = async (dispatch: Dispatch) => { const initialUrl: ?string = await Linking.getInitialURL(); if (initialUrl != null) { - webAuth.endWebAuth({ url: initialUrl }, dispatch); + handleUrl(new URL(initialUrl), dispatch); } }; @@ -20,7 +31,7 @@ export class UrlListener { /** Private. */ handleUrlEvent(event: LinkingEvent) { - webAuth.endWebAuth(event, this.dispatch); + handleUrl(new URL(event.url), this.dispatch); } /** Private. */ From e1df57dab88e061a6a1b59c08545eed6cce7cf2f Mon Sep 17 00:00:00 2001 From: Akash Date: Thu, 3 Jun 2021 12:37:29 +0530 Subject: [PATCH 09/10] deeplink: Enable deeplink navigation in android. Caveats: - current navigation does not work across accounts. Fixes-part-of: #4751 --- android/app/src/main/AndroidManifest.xml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 070b54313d5..fa803eb8daf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -55,9 +55,7 @@ - + From dc71cfb1e1002ee8bdfdee0fec4862ae3b9c5889 Mon Sep 17 00:00:00 2001 From: Akash Date: Thu, 3 Jun 2021 12:52:13 +0530 Subject: [PATCH 10/10] deeplink [nfc]: Add documentation to manually test deep link. - In this commit, instructions are only provided for android. --- docs/howto/testing.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/howto/testing.md b/docs/howto/testing.md index 11955d5da7e..ad7b4ef33b5 100644 --- a/docs/howto/testing.md +++ b/docs/howto/testing.md @@ -169,3 +169,18 @@ find something in its docs, it's worth [flow-typed]: https://github.com/flowtype/flow-typed [flow-issues]: https://github.com/facebook/flow/issues?q=is%3Aissue [flow-cheat-sheet]: https://www.saltycrane.com/flow-type-cheat-sheet/latest/ + +## Manual Testing + +### Deep Link + +Testing deep link url is much more productive when one uses cli instead of going to the browser and typing the link. + +#### Android + +To send a deeplink event to android (debug build) use the following: +```bash +adb shell am start -W -a android.intent.action.VIEW -d "zulip://test.realm.com/?email=test@example.com#narrow/valid-narrow" com.zulipmobile.debug +``` + +Make sure to change the domain name, email parameter and narrow as required. \ No newline at end of file