Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement deep link functionality and enable it for android. #4775

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
4 changes: 1 addition & 3 deletions android/app/src/main/AndroidManifest.xml
Expand Up @@ -55,9 +55,7 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="login"
android:scheme="zulip" />
<data android:scheme="zulip" />
</intent-filter>
</activity>

Expand Down
15 changes: 15 additions & 0 deletions docs/howto/testing.md
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions src/boot/AppEventHandlers.js
Expand Up @@ -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';

Expand Down Expand Up @@ -117,6 +118,7 @@ class AppEventHandlers extends PureComponent<Props> {

notificationListener = new NotificationListener(this.props.dispatch);
shareListener = new ShareReceivedListener(this.props.dispatch);
urlListener = new UrlListener(this.props.dispatch);

handleMemoryWarning = () => {
// Release memory here
Expand All @@ -126,13 +128,15 @@ class AppEventHandlers extends PureComponent<Props> {
const { dispatch } = this.props;
handleInitialNotification(dispatch);
handleInitialShare(dispatch);
handleInitialUrl(dispatch);

this.netInfoDisconnectCallback = NetInfo.addEventListener(this.handleConnectivityChange);
AppState.addEventListener('change', this.handleAppStateChange);
AppState.addEventListener('memoryWarning', this.handleMemoryWarning);
ScreenOrientation.addOrientationChangeListener(this.handleOrientationChange);
this.notificationListener.start();
this.shareListener.start();
this.urlListener.start();
}

componentWillUnmount() {
Expand All @@ -145,6 +149,7 @@ class AppEventHandlers extends PureComponent<Props> {
ScreenOrientation.removeOrientationChangeListeners();
this.notificationListener.stop();
this.shareListener.stop();
this.urlListener.stop();
}

render() {
Expand Down
59 changes: 59 additions & 0 deletions src/deeplink/index.js
@@ -0,0 +1,59 @@
/* @flow strict-local */
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) {
handleUrl(new URL(initialUrl), dispatch);
}
};

export class UrlListener {
dispatch: Dispatch;
unsubs: Array<() => void> = [];

constructor(dispatch: Dispatch) {
this.dispatch = dispatch;
}

/** Private. */
handleUrlEvent(event: LinkingEvent) {
handleUrl(new URL(event.url), this.dispatch);
}

/** Private. */
listen(handler: (event: LinkingEvent) => void | Promise<void>) {
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();
}
}
50 changes: 50 additions & 0 deletions 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));
}
};
2 changes: 1 addition & 1 deletion src/message/messagesActions.js
Expand Up @@ -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';
Expand Down
55 changes: 4 additions & 51 deletions 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';

Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -175,30 +175,8 @@ type Props = $ReadOnly<{|
realm: URL,
|}>;

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<Props> {
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,
Expand All @@ -209,31 +187,6 @@ class AuthScreen extends PureComponent<Props> {
}
};

componentWillUnmount = () => {
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) => {
otp = await webAuth.generateOtp();
webAuth.openBrowser(new URL(url, this.props.realm).toString(), otp);
};

endWebAuth = (event: LinkingEvent) => {
webAuth.closeBrowser();

const { dispatch, realm } = this.props;
const auth = webAuth.authFromCallbackUrl(event.url, otp, realm);
if (auth) {
dispatch(loginSuccess(auth.realm, auth.email, auth.apiKey));
}
};

handleDevAuth = () => {
NavigationService.dispatch(navigateToDevAuth({ realm: this.props.realm }));
};
Expand Down Expand Up @@ -262,7 +215,7 @@ class AuthScreen extends PureComponent<Props> {
throw new Error('`state` mismatch');
}

otp = await webAuth.generateOtp();
const otp = await webAuth.generateOtp();

const params = encodeParamsForUrl({
mobile_flow_otp: otp,
Expand Down Expand Up @@ -307,7 +260,7 @@ class AuthScreen extends PureComponent<Props> {
} else if (method.name === 'apple' && (await this.canUseNativeAppleFlow())) {
this.handleNativeAppleAuth();
} else {
this.beginWebAuth(action.url);
webAuth.beginWebAuth(action.url, this.props.realm);
}
};

Expand Down
6 changes: 3 additions & 3 deletions src/start/__tests__/webAuth-test.js
Expand Up @@ -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',
Expand All @@ -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);
});
});