diff --git a/app/actions/remote/ephemeral_mode/reconnect.test.ts b/app/actions/remote/ephemeral_mode/reconnect.test.ts index 08cbb226741..673e2b31d7f 100644 --- a/app/actions/remote/ephemeral_mode/reconnect.test.ts +++ b/app/actions/remote/ephemeral_mode/reconnect.test.ts @@ -98,7 +98,7 @@ describe('reconnectErasedServer', () => { expect(setActiveServerDatabaseSpy).toHaveBeenCalledWith(serverUrl); expect(updatePersistenceFlagSpy).toHaveBeenCalledWith(serverUrl, ''); expect(determineRouteFromLaunchProps).toHaveBeenCalledWith({launchType: Launch.Normal, serverUrl, coldStart: true}); - expect(router.replace).toHaveBeenCalledWith({pathname: '/(authenticated)/(home)', params: {launchType: Launch.Normal, serverUrl, coldStart: true}}); + expect(router.replace).toHaveBeenCalledWith({pathname: '/(authenticated)/(home)', params: {launchType: Launch.Normal, serverUrl, coldStart: 'true'}}); expect(wipeServerDataSpy).not.toHaveBeenCalled(); expect(result).toEqual({}); }); diff --git a/app/actions/remote/ephemeral_mode/reconnect.ts b/app/actions/remote/ephemeral_mode/reconnect.ts index d76f6c1ecbe..f77514b510a 100644 --- a/app/actions/remote/ephemeral_mode/reconnect.ts +++ b/app/actions/remote/ephemeral_mode/reconnect.ts @@ -13,6 +13,7 @@ import {getServerCredentials} from '@init/credentials'; import {determineRouteFromLaunchProps} from '@init/launch'; import NetworkManager from '@managers/network_manager'; import {setCurrentUserId} from '@queries/servers/system'; +import {propsToParams} from '@screens/navigation'; import {isErrorWithStatusCode} from '@utils/errors'; type Result = {error?: unknown}; @@ -52,7 +53,7 @@ export const reconnectErasedServer = async (serverUrl: string): Promise await DatabaseManager.updatePersistenceFlag(serverUrl, ''); const launchRoute = await determineRouteFromLaunchProps({launchType: Launch.Normal, serverUrl, coldStart: true}); - router.replace({pathname: launchRoute.route, params: launchRoute.params}); + router.replace({pathname: launchRoute.route, params: propsToParams(launchRoute.params)}); return {}; } catch (error) { return {error}; diff --git a/app/managers/session_manager.ts b/app/managers/session_manager.ts index 64661a81dbc..aa24cc9d572 100644 --- a/app/managers/session_manager.ts +++ b/app/managers/session_manager.ts @@ -18,6 +18,7 @@ import OfflinePersistenceManager from '@managers/offline_persistence_manager'; import SecurityManager from '@managers/security_manager'; import {queryGlobalValue} from '@queries/app/global'; import {getAllServers, getServerDisplayName} from '@queries/app/servers'; +import {propsToParams} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import {deleteFileCacheByDir} from '@utils/file'; import {isMainActivity} from '@utils/helpers'; @@ -156,7 +157,7 @@ export class SessionManagerSingleton { const launchRoute = await determineRouteFromLaunchProps({launchType, serverUrl, displayName}); requestAnimationFrame(() => { - router.replace({pathname: launchRoute.route, params: launchRoute.params}); + router.replace({pathname: launchRoute.route, params: propsToParams(launchRoute.params)}); }); } } finally { @@ -181,7 +182,7 @@ export class SessionManagerSingleton { const serverDisplayName = await getServerDisplayName(serverUrl); const launchRoute = await determineRouteFromLaunchProps({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}); - router.replace({pathname: launchRoute.route, params: launchRoute.params}); + router.replace({pathname: launchRoute.route, params: propsToParams(launchRoute.params)}); if (activeServerUrl) { addNewServer(EphemeralStore.getTheme(), serverUrl, serverDisplayName); } else { diff --git a/app/routes/(authenticated)/(home)/index.tsx b/app/routes/(authenticated)/(home)/index.tsx index 3817c652781..eaa461af927 100644 --- a/app/routes/(authenticated)/(home)/index.tsx +++ b/app/routes/(authenticated)/(home)/index.tsx @@ -1,8 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Redirect} from 'expo-router'; +import {Redirect, useLocalSearchParams} from 'expo-router'; export default function HomeIndex() { - return ; + const params = useLocalSearchParams(); + + return ; } diff --git a/app/routes/+native-intent.test.ts b/app/routes/+native-intent.test.ts new file mode 100644 index 00000000000..647708e091a --- /dev/null +++ b/app/routes/+native-intent.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Linking} from 'react-native'; + +import {Sso} from '@constants'; +import {alertInvalidDeepLink, parseAndHandleDeepLink} from '@utils/deep_link'; + +import {addEventListener, redirectSystemPath} from './+native-intent'; + +jest.mock('@utils/deep_link'); + +describe('native-intent', () => { + beforeEach(() => { + jest.clearAllMocks(); + (parseAndHandleDeepLink as jest.Mock).mockResolvedValue({error: false}); + }); + + describe('addEventListener', () => { + it('should subscribe to Linking url events and return an unsubscribe function', () => { + const remove = jest.fn(); + (Linking.addEventListener as jest.Mock).mockReturnValue({remove}); + + const unsubscribe = addEventListener(); + + expect(Linking.addEventListener).toHaveBeenCalledWith('url', expect.any(Function)); + + unsubscribe(); + + expect(remove).toHaveBeenCalled(); + }); + }); + + describe('redirectSystemPath', () => { + it('should return the path unchanged when the url is not handled, regardless of initial', async () => { + const path = 'mailto:someone@example.com'; + + expect(await redirectSystemPath({path, initial: false})).toBe(path); + expect(await redirectSystemPath({path, initial: true})).toBe(path); + expect(parseAndHandleDeepLink).not.toHaveBeenCalled(); + }); + + it('should return null when the url is an SSO redirect, regardless of initial', async () => { + const path = `${Sso.REDIRECT_URL_SCHEME}some/path`; + + expect(await redirectSystemPath({path, initial: false})).toBeNull(); + expect(await redirectSystemPath({path, initial: true})).toBeNull(); + }); + + it('should return null and not alert when the deep link is handled successfully, regardless of initial', async () => { + const path = 'https://community.mattermost.com/team/channels/town-square'; + + expect(await redirectSystemPath({path, initial: false})).toBeNull(); + expect(await redirectSystemPath({path, initial: true})).toBeNull(); + + expect(parseAndHandleDeepLink).toHaveBeenCalledWith(path, undefined, undefined, true); + expect(alertInvalidDeepLink).not.toHaveBeenCalled(); + }); + + it('should return the path and alert when the deep link handling errors, regardless of initial', async () => { + (parseAndHandleDeepLink as jest.Mock).mockResolvedValue({error: true}); + + const path = 'https://community.mattermost.com/team/channels/town-square'; + + expect(await redirectSystemPath({path, initial: false})).toBe(path); + expect(await redirectSystemPath({path, initial: true})).toBe(path); + + expect(alertInvalidDeepLink).toHaveBeenCalledTimes(2); + }); + }); + + describe('handleUrl (via addEventListener subscription callback)', () => { + const getHandler = (): (event: {url: string}) => Promise => { + addEventListener(); + const call = (Linking.addEventListener as jest.Mock).mock.calls[0]; + return call[1]; + }; + + it('should return false for a url with a protocol but no host', async () => { + const handleUrl = getHandler(); + + const result = await handleUrl({url: 'mailto:someone@example.com'}); + + expect(result).toBe(false); + expect(parseAndHandleDeepLink).not.toHaveBeenCalled(); + }); + + it('should return true and skip deep link handling for the SSO redirect scheme', async () => { + const handleUrl = getHandler(); + + const result = await handleUrl({url: `${Sso.REDIRECT_URL_SCHEME}callback`}); + + expect(result).toBe(true); + expect(parseAndHandleDeepLink).not.toHaveBeenCalled(); + }); + + it('should return true and skip deep link handling for the SSO dev redirect scheme', async () => { + const handleUrl = getHandler(); + + const result = await handleUrl({url: `${Sso.REDIRECT_URL_SCHEME_DEV}callback`}); + + expect(result).toBe(true); + expect(parseAndHandleDeepLink).not.toHaveBeenCalled(); + }); + + it('should return true when the deep link is handled successfully', async () => { + const handleUrl = getHandler(); + + const result = await handleUrl({url: 'https://community.mattermost.com/team/channels/town-square'}); + + expect(result).toBe(true); + expect(alertInvalidDeepLink).not.toHaveBeenCalled(); + }); + + it('should alert and return false when the deep link errors', async () => { + (parseAndHandleDeepLink as jest.Mock).mockResolvedValue({error: true}); + const handleUrl = getHandler(); + + const result = await handleUrl({url: 'https://community.mattermost.com/team/channels/town-square'}); + + expect(result).toBe(false); + expect(alertInvalidDeepLink).toHaveBeenCalled(); + }); + + it('should return false when the url is empty', async () => { + const handleUrl = getHandler(); + + const result = await handleUrl({url: ''}); + + expect(result).toBe(false); + expect(parseAndHandleDeepLink).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/routes/+native-intent.ts b/app/routes/+native-intent.ts index f64bc5c5478..76bf14e2d1e 100644 --- a/app/routes/+native-intent.ts +++ b/app/routes/+native-intent.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {Linking} from 'react-native'; +import urlParse from 'url-parse'; import {Sso} from '@constants'; import {DEFAULT_LOCALE} from '@i18n'; @@ -16,33 +17,43 @@ import {getIntlShape} from '@utils/general'; * This prevents the "multiple linking configurations" error */ +const handleUrl = async (event: {url: string}) => { + const parsed = urlParse(event.url); + if (parsed.protocol && !parsed.host) { + return false; + } + + // Ignore SSO redirect URLs + if (event.url?.startsWith(Sso.REDIRECT_URL_SCHEME) || + event.url?.startsWith(Sso.REDIRECT_URL_SCHEME_DEV)) { + return true; + } + + if (event.url) { + const {error} = await parseAndHandleDeepLink( + event.url, + undefined, + undefined, + true, + ); + + if (error) { + alertInvalidDeepLink(getIntlShape(DEFAULT_LOCALE)); + return false; + } + + return true; + } + + return false; +}; + /** * Set up custom deep link event listener * Expo-router calls this function to subscribe to URL events */ export const addEventListener = () => { // Set up our custom deep link listener - const handleUrl = async (event: {url: string}) => { - // Ignore SSO redirect URLs - if (event.url?.startsWith(Sso.REDIRECT_URL_SCHEME) || - event.url?.startsWith(Sso.REDIRECT_URL_SCHEME_DEV)) { - return; - } - - if (event.url) { - const {error} = await parseAndHandleDeepLink( - event.url, - undefined, - undefined, - true, - ); - - if (error) { - alertInvalidDeepLink(getIntlShape(DEFAULT_LOCALE)); - } - } - }; - // Subscribe to URL events const subscription = Linking.addEventListener('url', handleUrl); @@ -60,10 +71,11 @@ export const addEventListener = () => { * SSO screen's own Linking listener. If they reach expo-router they resolve to * an unregistered route. Returning null keeps the app on its current path. */ -export function redirectSystemPath(options: {path: string; initial: boolean}) { - if (options.path?.startsWith(Sso.REDIRECT_URL_SCHEME) || - options.path?.startsWith(Sso.REDIRECT_URL_SCHEME_DEV)) { +export async function redirectSystemPath(options: {path: string; initial: boolean}) { + const handled = await handleUrl({url: options.path}); + if (handled) { return null; } + return options.path; } diff --git a/app/routes/index.tsx b/app/routes/index.tsx index b61eec11aa0..0e063a30e29 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -5,6 +5,7 @@ import {Redirect, type Href} from 'expo-router'; import {useEffect, useState} from 'react'; import {determineInitialExpoRoute, type ExpoRouterLaunchResult} from '@init/launch'; +import {propsToParams} from '@screens/navigation'; export default function RootIndex() { const [launchResult, setLaunchResult] = useState(null); @@ -23,6 +24,6 @@ export default function RootIndex() { } // Redirect to the determined route with params - const href: Href = {pathname: launchResult.route, params: launchResult.params}; + const href: Href = {pathname: launchResult.route, params: propsToParams(launchResult.params)}; return ; }