From cf5cbb53598d89fb7e35c8f7f0d05ae56e707782 Mon Sep 17 00:00:00 2001 From: Ahmed Hamouda Date: Tue, 26 Aug 2025 11:55:11 +0200 Subject: [PATCH 1/2] feat(rtn-web-browser): add auth support for chromebook --- .../src/apis/openAuthSessionAsync.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/rtn-web-browser/src/apis/openAuthSessionAsync.ts b/packages/rtn-web-browser/src/apis/openAuthSessionAsync.ts index 7ddee8ec30f..ff847256ea7 100644 --- a/packages/rtn-web-browser/src/apis/openAuthSessionAsync.ts +++ b/packages/rtn-web-browser/src/apis/openAuthSessionAsync.ts @@ -5,6 +5,7 @@ import { AppState, Linking, NativeEventSubscription, + NativeModules, Platform, } from 'react-native'; @@ -13,6 +14,32 @@ import { nativeModule } from '../nativeModule'; let appStateListener: NativeEventSubscription | undefined; let redirectListener: NativeEventSubscription | undefined; +export async function isChromebook(): Promise { + // expo go + try { + const Device = require('expo-device'); + 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[], @@ -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); } }; @@ -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), + ]); + + if (redirectUrl) Linking.openURL(redirectUrl); + + return redirectUrl; + } finally { + removeAppStateListener(); + removeRedirectListener(); + } +}; + const getAppStatePromise = (): Promise => new Promise(resolve => { // remove any stray listeners before creating new ones From fafcea28e381a3387527ff86ee8cdc36bfdb757d Mon Sep 17 00:00:00 2001 From: Ahmed Hamouda Date: Fri, 29 Aug 2025 15:05:23 +0200 Subject: [PATCH 2/2] chore(rtn-web-browser): add initial unit tests (#14524) Co-authored-by: Ahmed Hamouda --- packages/rtn-web-browser/jest.config.js | 8 +- packages/rtn-web-browser/package.json | 2 +- .../__tests__/openAuthSessionAsync.test.ts | 78 +++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 packages/rtn-web-browser/src/apis/__tests__/openAuthSessionAsync.test.ts diff --git a/packages/rtn-web-browser/jest.config.js b/packages/rtn-web-browser/jest.config.js index 3a0e3340f9f..fd22133a060 100644 --- a/packages/rtn-web-browser/jest.config.js +++ b/packages/rtn-web-browser/jest.config.js @@ -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, }, }, }; diff --git a/packages/rtn-web-browser/package.json b/packages/rtn-web-browser/package.json index 3a9d7917bd2..929a8988a41 100644 --- a/packages/rtn-web-browser/package.json +++ b/packages/rtn-web-browser/package.json @@ -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", diff --git a/packages/rtn-web-browser/src/apis/__tests__/openAuthSessionAsync.test.ts b/packages/rtn-web-browser/src/apis/__tests__/openAuthSessionAsync.test.ts new file mode 100644 index 00000000000..afbb034ff6e --- /dev/null +++ b/packages/rtn-web-browser/src/apis/__tests__/openAuthSessionAsync.test.ts @@ -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; +const mockNativeModule = nativeModule as jest.Mocked; + +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, + ); + }); + }); +});