Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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 protected]#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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 protected]#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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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);
});
});
Loading