Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
8 changes: 4 additions & 4 deletions packages/rtn-web-browser/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ module.exports = {
...require('../../jest.config'),
coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
branches: 25,
functions: 25,
lines: 35,
statements: 35,
},
},
};
2 changes: 1 addition & 1 deletion packages/rtn-web-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"access": "public"
},
"scripts": {
"test": "echo 'no-op'",
"test": "jest -w 1 --coverage --logHeapUsage",
"test:android": "./android/gradlew test -p ./android",
"build-with-test": "npm run clean && npm test && tsc",
"build:esm-cjs": "rollup --forceExit -c rollup.config.mjs",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { Platform } from 'react-native';

import { nativeModule } from '../../nativeModule';
import { isChromebook, openAuthSessionAsync } from '../openAuthSessionAsync';

jest.mock('react-native', () => ({
Platform: { OS: 'ios' },
AppState: { addEventListener: jest.fn() },
Linking: { addEventListener: jest.fn() },
NativeModules: {},
}));

jest.mock('../../nativeModule', () => ({
nativeModule: { openAuthSessionAsync: jest.fn() },
}));

const mockPlatform = Platform as jest.Mocked<typeof Platform>;
const mockNativeModule = nativeModule as jest.Mocked<typeof nativeModule>;

describe('openAuthSessionAsync', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPlatform.OS = 'ios';
});

describe('isChromebook', () => {
it('returns false by default', async () => {
const result = await isChromebook();
expect(result).toBe(false);
});
});

describe('openAuthSessionAsync', () => {
it('enforces HTTPS on URLs', async () => {
mockNativeModule.openAuthSessionAsync.mockResolvedValue('result');

await openAuthSessionAsync('http://example.com', ['myapp://callback']);

expect(mockNativeModule.openAuthSessionAsync).toHaveBeenCalledWith(
'https://example.com',
'myapp://callback',
false,
);
});

it('calls iOS implementation', async () => {
mockNativeModule.openAuthSessionAsync.mockResolvedValue('result');

const result = await openAuthSessionAsync(
'https://example.com',
['myapp://callback'],
true,
);

expect(result).toBe('result');
expect(mockNativeModule.openAuthSessionAsync).toHaveBeenCalledWith(
'https://example.com',
'myapp://callback',
true,
);
});

it('finds first non-web URL for redirect', async () => {
const redirectUrls = ['https://web.com', 'myapp://deep'];

await openAuthSessionAsync('https://example.com', redirectUrls);

expect(mockNativeModule.openAuthSessionAsync).toHaveBeenCalledWith(
'https://example.com',
'myapp://deep',
false,
);
});
});
});
57 changes: 57 additions & 0 deletions packages/rtn-web-browser/src/apis/openAuthSessionAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AppState,
Linking,
NativeEventSubscription,
NativeModules,
Platform,
} from 'react-native';

Expand All @@ -13,6 +14,32 @@ import { nativeModule } from '../nativeModule';
let appStateListener: NativeEventSubscription | undefined;
let redirectListener: NativeEventSubscription | undefined;

export async function isChromebook(): Promise<boolean> {
// expo go
try {
const Device = require('expo-device');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand.

do we expect users to install expo-device optionally on their own?

why not include it as a dependency? and if we cannot include it as a dependency, why is this logic needed at all?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't require expo-device as a dependency. However, customers are free to use it with their Amplify react-native apps, so this line is just to support device detection in case customers are using expo. If customers are not using expo, we fallback to NativeModules on line 36

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it. thanks for the explanation

if (Device?.hasPlatformFeatureAsync) {
if (await Device.hasPlatformFeatureAsync('org.chromium.arc')) return true;
if (
await Device.hasPlatformFeatureAsync(
'org.chromium.arc.device_management',
)
)
return true;
}
} catch {
// not using Expo
}

// fallback to native module
try {
const nm = (NativeModules as any)?.ChromeOS;
if (nm?.isChromeOS) return !!(await nm.isChromeOS());
} catch {}

return false;
}

export const openAuthSessionAsync = async (
url: string,
redirectUrls: string[],
Expand All @@ -25,6 +52,15 @@ export const openAuthSessionAsync = async (
}

if (Platform.OS === 'android') {
try {
const isChromebookRes = await isChromebook();
if (isChromebookRes) {
return openAuthSessionChromeOs(httpsUrl, redirectUrls);
}
} catch {
// ignore and fallback to android
}

return openAuthSessionAndroid(httpsUrl, redirectUrls);
}
};
Expand Down Expand Up @@ -66,6 +102,27 @@ const openAuthSessionAndroid = async (url: string, redirectUrls: string[]) => {
}
};

const openAuthSessionChromeOs = async (url: string, redirectUrls: string[]) => {
try {
const [redirectUrl] = await Promise.all([
Promise.race([
// wait for app to redirect, resulting in a redirectUrl
getRedirectPromise(redirectUrls),
// wait for app to return some other way, resulting in null
getAppStatePromise(),
]),
Linking.openURL(url),
]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is hard to follow.

this races between, 2 promises

  1. whenever a "linking" event happens
  2. when the app leaves inactive state and becomes active.

and then this race is part of an all, which resolves when the race resolved and an url has been opened.


doesn't Linking.openURL automatically mean that getRedirectPromise gets resolved.
as a result the same URL, which was just opened gets returned by this Promise construct
because:

if (redirectUrls.some(url => event.url.startsWith(url))) {
				resolve(event.url);
}

as

  1. getRedirectPromise sets up a listener on "Linking"-url change and resolves with that URL when this happens
  2. getAppStatePromise sets up a listener on "going active" and resolves with null
  3. openURL - navigates

so that.

  1. navigate.
  2. getRedirectPromise-emits
  3. redirectUrl === url

and then this makes another call to openURL with the same URL

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved this offline.

the solution uses Linking instead of native navigation if the latter fails to open the URL


if (redirectUrl) Linking.openURL(redirectUrl);

return redirectUrl;
} finally {
removeAppStateListener();
removeRedirectListener();
}
};

const getAppStatePromise = (): Promise<null> =>
new Promise(resolve => {
// remove any stray listeners before creating new ones
Expand Down
Loading