diff --git a/src/App.js b/src/App.js index 483403170..ede511fc3 100644 --- a/src/App.js +++ b/src/App.js @@ -36,7 +36,8 @@ import { useNetService } from '/libs/services/NetService' import { withSentry } from '/libs/monitoring/Sentry' import { ThemeProvider } from '/app/theme/ThemeProvider' import { useInitI18n } from '/locales/useInitI18n' -import { SharingProvider } from '/app/view/sharing/SharingProvider' +import { SharingProvider } from '/app/view/Sharing/SharingProvider' +import { ErrorProvider } from '/app/view/Error/ErrorProvider' // Polyfill needed for cozy-client connection if (!global.btoa) { @@ -57,7 +58,11 @@ const App = ({ setClient }) => { const { initialRoute, isLoading } = useAppBootstrap(client) - useGlobalAppState() + useGlobalAppState({ + onNavigationRequest: route => { + RootNavigation.navigate(route) + } + }) useSecureBackgroundSplashScreen() useCookieResyncOnResume() useNotifications() @@ -75,24 +80,28 @@ const Nav = ({ client, setClient }) => { return ( - - - - - - - + + + + + + + + + + + ) } @@ -159,9 +168,7 @@ const Wrapper = () => { - - - + diff --git a/src/AppRouter.jsx b/src/AppRouter.jsx index 1787821f4..0ba1c5de3 100644 --- a/src/AppRouter.jsx +++ b/src/AppRouter.jsx @@ -11,7 +11,7 @@ import { ManagerScreen } from '/app/view/Manager/ManagerScreen' import { OnboardingScreen } from '/screens/login/OnboardingScreen' import { WelcomeScreen } from '/screens/welcome/WelcomeScreen' import { routes } from '/constants/routes' -import { SharingScreen } from '/app/view/sharing/SharingScreen' +import { SharingScreen } from '/app/view/Sharing/SharingScreen' import { PasswordPrompt } from '/app/view/Secure/PasswordPrompt' import { PinPrompt } from '/app/view/Secure/PinPrompt' import { SetPasswordView } from '/app/view/Secure/SetPasswordView' diff --git a/src/app/domain/authorization/services/SecurityService.ts b/src/app/domain/authorization/services/SecurityService.ts index cb703fc5d..9db3004ec 100644 --- a/src/app/domain/authorization/services/SecurityService.ts +++ b/src/app/domain/authorization/services/SecurityService.ts @@ -29,6 +29,7 @@ import { navigateToApp } from '/libs/functions/openApp' import { hideSplashScreen } from '/app/theme/SplashScreenService' import { SecurityNavigationService } from '/app/domain/authorization/services/SecurityNavigationService' import { getData, StorageKeys } from '/libs/localStore' +import { SharingIntentStatus } from '/app/domain/sharing/models/SharingState' // Can use mock functions in dev environment const fns = getDevModeFunctions( @@ -79,15 +80,18 @@ export const determineSecurityFlow = async ( href: string slug: string }, - isCallerHandlingSplashscreen?: boolean + isCallerHandlingSplashscreen?: boolean, + sharingIntentStatus?: SharingIntentStatus ): Promise => { + const openedWithSharing = + sharingIntentStatus === SharingIntentStatus.OpenedViaSharing SecurityNavigationService.startListening() const callbackNav = async (): Promise => { try { if (navigationObject) { await navigateToApp(navigationObject) - } else navigate(routes.home) + } else navigate(openedWithSharing ? routes.sharing : routes.home) } catch (error) { devlog('🔏', 'Error navigating to app, defaulting to home', error) navigate(routes.home) diff --git a/src/app/domain/sharing/models/SharingCozyApp.ts b/src/app/domain/sharing/models/SharingCozyApp.ts new file mode 100644 index 000000000..ae995fabb --- /dev/null +++ b/src/app/domain/sharing/models/SharingCozyApp.ts @@ -0,0 +1,21 @@ +export interface SharingCozyApp { + _id: string + _type: string + accept_documents_from_flagship?: { + accepted_mime_types: string[] + max_number_of_files: number + max_size_per_file_in_MB: number + route_to_upload: string + } + accept_from_flagship?: boolean + attributes: { + accept_documents_from_flagship?: { + accepted_mime_types: string[] + max_number_of_files: number + max_size_per_file_in_MB: number + route_to_upload: string + } + accept_from_flagship?: boolean + } + slug: string +} diff --git a/src/app/domain/sharing/models/SharingState.ts b/src/app/domain/sharing/models/SharingState.ts index 9c83755c3..65f0f662c 100644 --- a/src/app/domain/sharing/models/SharingState.ts +++ b/src/app/domain/sharing/models/SharingState.ts @@ -8,14 +8,30 @@ export enum SharingIntentStatus { export enum SharingActionType { SetIntentStatus = 'SET_INTENT_STATUS', - SetFilesToUpload = 'SET_FILES_TO_UPLOAD' + SetFilesToUpload = 'SET_FILES_TO_UPLOAD', + SetRouteToUpload = 'SET_ROUTE_TO_UPLOAD', + SetFlowErrored = 'SET_FLOW_ERRORED', + SetRecoveryState = 'SET_RECOVERY_STATE' } export interface SharingState { sharingIntentStatus: SharingIntentStatus filesToUpload: ReceivedFile[] + routeToUpload?: { href: string; slug: string } + errored: boolean } export type SharingAction = | { type: SharingActionType.SetIntentStatus; payload: SharingIntentStatus } | { type: SharingActionType.SetFilesToUpload; payload: ReceivedFile[] } + | { + type: SharingActionType.SetRouteToUpload + payload: { href: string; slug: string } + } + | { type: SharingActionType.SetFlowErrored; payload: boolean } + | { type: SharingActionType.SetRecoveryState } + +export interface ServiceResponse { + result?: T + error?: string +} diff --git a/src/app/domain/sharing/services/SharingNetwork.spec.ts b/src/app/domain/sharing/services/SharingNetwork.spec.ts new file mode 100644 index 000000000..aeff9a2f2 --- /dev/null +++ b/src/app/domain/sharing/services/SharingNetwork.spec.ts @@ -0,0 +1,59 @@ +import CozyClient from 'cozy-client' + +import { SharingCozyApp } from '/app/domain/sharing/models/SharingCozyApp' +import { getRouteToUpload } from '/app/domain/sharing/services/SharingNetwork' + +describe('getRouteToUpload', () => { + const mockCozyClient = new CozyClient({ + uri: 'http://cozy.local', + capabilities: { flat_subdomains: true } + }) + + it('returns empty object if no client is provided', () => { + const result = getRouteToUpload() + expect(result).toEqual({}) + }) + + it('returns empty object if cozyApps is not an array or empty', () => { + // @ts-expect-error Testing invalid input + const result = getRouteToUpload(null, mockCozyClient) + expect(result).toEqual({}) + }) + + it('returns empty object if no matching app is found', () => { + const cozyApps = [{ slug: 'wrong-app' } as SharingCozyApp] + + const result = getRouteToUpload(cozyApps, mockCozyClient) + expect(result).toEqual({}) + }) + + it('returns the correct href and slug', () => { + const cozyApps = [ + { + slug: 'drive', + accept_documents_from_flagship: { + route_to_upload: '/upload-route' + }, + attributes: { + accept_documents_from_flagship: { + route_to_upload: '/upload-route' + } + } + } as SharingCozyApp + ] + const result = getRouteToUpload(cozyApps, mockCozyClient, 'drive') + expect(result).toEqual({ + result: { + href: 'http://cozy-drive.local/#/upload-route', + slug: 'drive' + } + }) + }) + + it('handles an error gracefully', () => { + const brokenClient = new CozyClient({ uri: undefined }) + const cozyApps = [{ slug: 'drive' } as SharingCozyApp] + const result = getRouteToUpload(cozyApps, brokenClient) + expect(result).toEqual({ error: 'Error determining route to upload.' }) + }) +}) diff --git a/src/app/domain/sharing/services/SharingNetwork.ts b/src/app/domain/sharing/services/SharingNetwork.ts new file mode 100644 index 000000000..59cfccfae --- /dev/null +++ b/src/app/domain/sharing/services/SharingNetwork.ts @@ -0,0 +1,48 @@ +import CozyClient, { generateWebLink, Q } from 'cozy-client' + +import { sharingLogger } from '/app/domain/sharing' +import { SharingCozyApp } from '/app/domain/sharing/models/SharingCozyApp' +import { ServiceResponse } from '/app/domain/sharing/models/SharingState' + +export const fetchSharingCozyApps = { + definition: Q('io.cozy.apps').where({ + 'accept_documents_from_flagship.route_to_upload': { $exists: true }, + accept_from_flagship: true + }), + options: { + as: 'io.cozy.apps/fetchSharingCozyApps' + } +} + +export const getRouteToUpload = ( + cozyApps?: SharingCozyApp[], + client?: CozyClient | null, + appName = 'drive' +): ServiceResponse<{ href: string; slug: string }> => { + try { + if (!client || !Array.isArray(cozyApps) || cozyApps.length === 0) return {} + + const cozyApp = cozyApps.find(cozyApp => cozyApp.slug === appName) + if (!cozyApp) return {} + const hash = + cozyApp.accept_documents_from_flagship?.route_to_upload ?? + cozyApp.attributes.accept_documents_from_flagship?.route_to_upload + const slug = cozyApp.slug + if (!hash || !slug) return {} + + const href = generateWebLink({ + cozyUrl: client.getStackClient().uri, + pathname: '', + slug, + subDomainType: client.capabilities.flat_subdomains ? 'flat' : 'nested', + hash: hash.replace(/^\/?#?\//, ''), + searchParams: [] + }) + + sharingLogger.info('routeToUpload is', { href, slug }) + return { result: { href, slug: cozyApp.slug } } + } catch (error) { + sharingLogger.error('Error when getting routeToUpload', error) + return { error: 'Error determining route to upload.' } + } +} diff --git a/src/app/domain/sharing/services/SharingStatus.ts b/src/app/domain/sharing/services/SharingStatus.ts index 28d00053a..f9f3766ee 100644 --- a/src/app/domain/sharing/services/SharingStatus.ts +++ b/src/app/domain/sharing/services/SharingStatus.ts @@ -33,9 +33,7 @@ export const handleSharing = ( : SharingIntentStatus.NotOpenedViaSharing sharingLogger.info( - `${ - isAndroid ? 'Android' : 'iOS' - } App was opened or resumed via sharing, setting status to ${newStatus}` + `App was opened or resumed via sharing, setting status to ${newStatus}` ) setStatus(newStatus) diff --git a/src/app/view/Error/ErrorProvider.tsx b/src/app/view/Error/ErrorProvider.tsx new file mode 100644 index 000000000..2473de12e --- /dev/null +++ b/src/app/view/Error/ErrorProvider.tsx @@ -0,0 +1,62 @@ +import React, { ReactNode, useCallback, useEffect } from 'react' + +import { ErrorToaster } from '/app/view/Error/ErrorToaster' + +interface ErrorState { + message: string | null +} + +export const ErrorStateContext = React.createContext({ + message: null +}) +export const ErrorDispatchContext = React.createContext< + React.Dispatch> +>(() => { + // eslint-disable-next-line no-console + console.warn('ErrorDispatchContext used outside of ErrorProvider.') +}) + +export const ErrorProvider = ({ + children +}: { + children: ReactNode +}): JSX.Element => { + const [error, setError] = React.useState(null) + + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(null), 3000) + return () => clearTimeout(timer) + } + }, [error]) + + return ( + + + {error ? : null} + {children} + + + ) +} + +interface ErrorHook { + error: ErrorState + setError: React.Dispatch> + handleError: (errorMessage: string, callback?: () => void) => void +} + +export const useError = (): ErrorHook => { + const error = React.useContext(ErrorStateContext) + const setError = React.useContext(ErrorDispatchContext) + + const handleError = useCallback( + (errorMessage: string, callback?: () => void): void => { + setError(errorMessage) + callback?.() + }, + [setError] + ) + + return { error, setError, handleError } +} diff --git a/src/app/view/Error/ErrorToaster.tsx b/src/app/view/Error/ErrorToaster.tsx new file mode 100644 index 000000000..a5a5e36db --- /dev/null +++ b/src/app/view/Error/ErrorToaster.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { Modal, Text, View, StyleSheet } from 'react-native' + +import { useError } from '/app/view/Error/ErrorProvider' + +export const ErrorToaster = (): JSX.Element => { + const { error, setError } = useError() + + return ( + setError(null)} + > + + + {error.message} + + + + ) +} + +const styles = StyleSheet.create({ + centeredView: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + marginTop: 22 + }, + modalView: { + margin: 20, + backgroundColor: 'white', + borderRadius: 20, + padding: 35, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2 + }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5 + }, + modalText: { + marginBottom: 15, + textAlign: 'center' + } +}) diff --git a/src/app/view/sharing/SharingProvider.spec.tsx b/src/app/view/Sharing/SharingProvider.spec.tsx similarity index 73% rename from src/app/view/sharing/SharingProvider.spec.tsx rename to src/app/view/Sharing/SharingProvider.spec.tsx index 65cf79309..6b629103a 100644 --- a/src/app/view/sharing/SharingProvider.spec.tsx +++ b/src/app/view/Sharing/SharingProvider.spec.tsx @@ -1,18 +1,37 @@ -import React from 'react' import { render } from '@testing-library/react-native' +import React from 'react' import { Text } from 'react-native' -import { - SharingProvider, - useSharingState -} from '/app/view/sharing/SharingProvider' +import { useQuery } from 'cozy-client' + +import { SharingProvider } from '/app/view/Sharing/SharingProvider' import { handleReceivedFiles } from '/app/domain/sharing/services/SharingData' import { handleSharing } from '/app/domain/sharing/services/SharingStatus' import { SharingIntentStatus } from '/app/domain/sharing/models/SharingState' import { ReceivedFile } from '/app/domain/sharing/models/ReceivedFile' +import { useSharingState } from '/app/view/Sharing/SharingState' jest.mock('/app/domain/sharing/services/SharingData') jest.mock('/app/domain/sharing/services/SharingStatus') +jest.mock('/app/domain/sharing/services/SharingNetwork') +jest.mock('/app/view/Error/ErrorProvider', () => ({ + useError: jest.fn().mockReturnValue({ + handleError: jest.fn() + }) +})) +jest.mock('cozy-client', () => ({ + useClient: jest.fn(), + useQuery: jest.fn(), + Q: jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + all: jest.fn().mockReturnValue([]) + }) + }) +})) +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), + useNavigationState: jest.fn() +})) describe('SharingProvider', () => { beforeEach(() => { @@ -24,7 +43,7 @@ describe('SharingProvider', () => { it('calls services and updates state correctly on mount', () => { const mockReceivedFilesCallback = jest.fn() const mockSharingCallback = jest.fn() - + ;(useQuery as jest.Mock).mockReturnValue({ data: [] }) ;(handleReceivedFiles as jest.Mock).mockImplementation(callback => { mockReceivedFilesCallback() const mockCallback = callback as (files: ReceivedFile[]) => void @@ -105,4 +124,23 @@ describe('SharingProvider', () => { getByText(SharingIntentStatus.NotOpenedViaSharing.toString()) }) + + it('calls cleanup functions on unmount', () => { + const cleanupReceivedFiles = jest.fn() + const cleanupSharingIntent = jest.fn() + + ;(handleReceivedFiles as jest.Mock).mockReturnValue(cleanupReceivedFiles) + ;(handleSharing as jest.Mock).mockReturnValue(cleanupSharingIntent) + + const { unmount } = render( + + Test + + ) + + unmount() + + expect(cleanupReceivedFiles).toHaveBeenCalledTimes(1) + expect(cleanupSharingIntent).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/app/view/Sharing/SharingProvider.tsx b/src/app/view/Sharing/SharingProvider.tsx new file mode 100644 index 000000000..e6ac44071 --- /dev/null +++ b/src/app/view/Sharing/SharingProvider.tsx @@ -0,0 +1,117 @@ +import { useNavigation, useNavigationState } from '@react-navigation/native' +import React, { useReducer, useEffect, useCallback } from 'react' + +import { useClient, useQuery } from 'cozy-client' + +import { SharingCozyApp } from '/app/domain/sharing/models/SharingCozyApp' +import { handleReceivedFiles } from '/app/domain/sharing/services/SharingData' +import { handleSharing } from '/app/domain/sharing/services/SharingStatus' +import { useError } from '/app/view/Error/ErrorProvider' +import { useI18n } from '/locales/i18n' +import { + SharingIntentStatus, + SharingActionType +} from '/app/domain/sharing/models/SharingState' +import { + fetchSharingCozyApps, + getRouteToUpload +} from '/app/domain/sharing/services/SharingNetwork' +import { + initialState, + SharingDispatchContext, + sharingReducer, + SharingStateContext +} from '/app/view/Sharing/SharingState' +import { routes } from '/constants/routes' + +export const SharingProvider = ({ + children +}: React.PropsWithChildren): JSX.Element => { + const client = useClient() + const [state, dispatch] = useReducer(sharingReducer, initialState) + const { t } = useI18n() + const { handleError } = useError() + const navigationState = useNavigationState(state => state) + const navigation = useNavigation() + const { data } = useQuery( + fetchSharingCozyApps.definition, + fetchSharingCozyApps.options + ) as { data?: SharingCozyApp[] | [] } + + const isProcessed = useCallback( + (): boolean => state.filesToUpload.length > 1 || state.errored, + [state.filesToUpload, state.errored] + ) + const hasData = useCallback( + (): boolean => + Boolean( + state.filesToUpload.length > 0 && client && data && data.length > 0 + ), + [client, data, state.filesToUpload] + ) + + // This effect is triggered at mount and unmount of the provider, + // its role is to listen native events and update the state accordingly + useEffect(() => { + // As soon as we can detect that the app was opened with or without files, + // we can update the state accordingly so the view can react to it + const cleanupSharingIntent = handleSharing( + (status: SharingIntentStatus) => { + dispatch({ type: SharingActionType.SetIntentStatus, payload: status }) + } + ) + + // Pass a callback to the low level function that handles the received files + // We will have access to their paths in the provider state afterwards + const cleanupReceivedFiles = handleReceivedFiles(files => { + dispatch({ type: SharingActionType.SetFilesToUpload, payload: files }) + }) + + return () => { + cleanupReceivedFiles() + cleanupSharingIntent() + } + }, []) + + // Fetches the route of the cozy-app that will handle the sharing intent + useEffect(() => { + if (isProcessed() || !hasData()) return + const { result, error } = getRouteToUpload(data, client) + + if (error) { + dispatch({ type: SharingActionType.SetFlowErrored, payload: true }) + } else if (result !== undefined) { + dispatch({ type: SharingActionType.SetRouteToUpload, payload: result }) + } + }, [client, data, handleError, hasData, isProcessed]) + + // If an error is detected, we handle that by abandoning the flow. + // The user will be redirected to the home screen and the sharing mode is ended until next file sharing. + useEffect(() => { + if (state.errored) { + dispatch({ type: SharingActionType.SetRecoveryState }) + if (navigationState.routes[navigationState.index].name !== routes.lock) { + handleError(t('errors.unknown_error'), () => { + navigation.navigate(routes.home as never) + }) + } + } + }, [ + handleError, + navigation, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + navigationState?.index, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + navigationState?.routes, + state.errored, + t + ]) + + return ( + + + {children} + + + ) +} diff --git a/src/app/view/sharing/SharingScreen.tsx b/src/app/view/Sharing/SharingScreen.tsx similarity index 91% rename from src/app/view/sharing/SharingScreen.tsx rename to src/app/view/Sharing/SharingScreen.tsx index fdb4fc2fe..b66d4e0a5 100644 --- a/src/app/view/sharing/SharingScreen.tsx +++ b/src/app/view/Sharing/SharingScreen.tsx @@ -3,7 +3,7 @@ import React from 'react' import { Container } from '/ui/Container' import { Grid } from '/ui/Grid' import { Typography } from '/ui/Typography' -import { useSharingState } from '/app/view/sharing/SharingProvider' +import { useSharingState } from '/app/view/Sharing/SharingState' export const SharingScreen = (): JSX.Element => { const { filesToUpload } = useSharingState() diff --git a/src/app/view/Sharing/SharingState.ts b/src/app/view/Sharing/SharingState.ts new file mode 100644 index 000000000..70bd4c78c --- /dev/null +++ b/src/app/view/Sharing/SharingState.ts @@ -0,0 +1,71 @@ +import { createContext, Dispatch, useContext } from 'react' + +import { sharingLogger } from '/app/domain/sharing' +import { + SharingState, + SharingAction, + SharingActionType, + SharingIntentStatus +} from '/app/domain/sharing/models/SharingState' + +export const SharingStateContext = createContext( + undefined +) +export const SharingDispatchContext = createContext< + Dispatch | undefined +>(undefined) + +export const sharingReducer = ( + state: SharingState, + action: SharingAction +): SharingState => { + let nextState = state + + switch (action.type) { + case SharingActionType.SetIntentStatus: + if (state.sharingIntentStatus === action.payload) return state + nextState = { ...state, sharingIntentStatus: action.payload } + break + case SharingActionType.SetFilesToUpload: + if (state.filesToUpload === action.payload) return state + nextState = { ...state, filesToUpload: action.payload } + break + case SharingActionType.SetRouteToUpload: + if (state.routeToUpload?.slug === action.payload.slug) return state + nextState = { ...state, routeToUpload: action.payload } + break + case SharingActionType.SetFlowErrored: { + nextState = { ...state, errored: action.payload } + break + } + case SharingActionType.SetRecoveryState: + nextState = { + ...initialState, + sharingIntentStatus: SharingIntentStatus.NotOpenedViaSharing + } + break + default: + break + } + + sharingLogger.info(`sharingReducer handled action "${action.type}"`) + sharingLogger.info('sharingReducer prevState', state) + sharingLogger.info('sharingReducer nextState', nextState) + + return nextState +} + +export const initialState: SharingState = { + sharingIntentStatus: SharingIntentStatus.Undetermined, + filesToUpload: [], + routeToUpload: undefined, + errored: false +} + +export const useSharingState = (): SharingState => { + const context = useContext(SharingStateContext) + if (context === undefined) { + throw new Error('useSharingState must be used within a SharingProvider') + } + return context +} diff --git a/src/app/view/sharing/SharingProvider.tsx b/src/app/view/sharing/SharingProvider.tsx deleted file mode 100644 index d1980311c..000000000 --- a/src/app/view/sharing/SharingProvider.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useReducer, ReactNode, Dispatch, useEffect } from 'react' - -import { - SharingState, - SharingAction, - SharingIntentStatus, - SharingActionType -} from '/app/domain/sharing/models/SharingState' -import { handleReceivedFiles } from '/app/domain/sharing/services/SharingData' -import { handleSharing } from '/app/domain/sharing/services/SharingStatus' - -const assertNever = (action: never): never => { - throw new Error('Unexpected object', action) -} - -export const sharingReducer = ( - state: SharingState, - action: SharingAction -): SharingState => { - switch (action.type) { - case SharingActionType.SetIntentStatus: - return { ...state, sharingIntentStatus: action.payload } - case SharingActionType.SetFilesToUpload: - return { ...state, filesToUpload: action.payload } - default: - return assertNever(action) - } -} - -const SharingStateContext = React.createContext( - undefined -) -const SharingDispatchContext = React.createContext< - Dispatch | undefined ->(undefined) - -interface SharingProviderProps { - children: ReactNode -} - -export const SharingProvider = ({ - children -}: SharingProviderProps): JSX.Element => { - const [state, dispatch] = useReducer(sharingReducer, { - sharingIntentStatus: SharingIntentStatus.Undetermined, - filesToUpload: [] - }) - - useEffect(() => { - const cleanupSharingIntent = handleSharing( - (status: SharingIntentStatus) => { - dispatch({ type: SharingActionType.SetIntentStatus, payload: status }) - } - ) - - const cleanupReceivedFiles = handleReceivedFiles(files => { - dispatch({ type: SharingActionType.SetFilesToUpload, payload: files }) - }) - - return () => { - cleanupReceivedFiles() - cleanupSharingIntent() - } - }, [dispatch]) - - return ( - - - {children} - - - ) -} - -export const useSharingState = (): SharingState => { - const context = React.useContext(SharingStateContext) - if (context === undefined) { - throw new Error('useSharingState must be used within a SharingProvider') - } - return context -} - -export const useSharingDispatch = (): Dispatch => { - const context = React.useContext(SharingDispatchContext) - if (context === undefined) { - throw new Error('useSharingDispatch must be used within a SharingProvider') - } - return context -} diff --git a/src/hooks/useGlobalAppState.ts b/src/hooks/useGlobalAppState.ts index a0749d8b1..c109e5aca 100644 --- a/src/hooks/useGlobalAppState.ts +++ b/src/hooks/useGlobalAppState.ts @@ -1,5 +1,4 @@ -import { useNavigationState } from '@react-navigation/native' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { AppState, AppStateStatus, NativeEventSubscription } from 'react-native' import CozyClient, { useClient } from 'cozy-client' @@ -14,9 +13,8 @@ import { } from '/app/domain/authorization/services/SecurityService' import { devlog } from '/core/tools/env' import { synchronizeDevice } from '/app/domain/authentication/services/SynchronizeService' -import { navigate } from '/libs/RootNavigation' import { routes } from '/constants/routes' -import { useSharingState } from '/app/view/sharing/SharingProvider' +import { useSharingState } from '/app/view/Sharing/SharingState' import { SharingIntentStatus } from '/app/domain/sharing/models/SharingState' const log = Minilog('useGlobalAppState') @@ -35,13 +33,14 @@ const handleSleep = (): void => { const handleWakeUp = async ( client: CozyClient, - sharingIntentStatus: SharingIntentStatus + sharingIntentStatus: SharingIntentStatus, + onNavigationRequest: (route: string) => void ): Promise => { await handleSecurityFlowWakeUp(client) if (sharingIntentStatus === SharingIntentStatus.OpenedViaSharing) { log.info('useGlobalAppState: handleWakeUp, sharing mode') - navigate(routes.sharing) + onNavigationRequest(routes.sharing) } } @@ -54,13 +53,14 @@ const isGoingToWakeUp = (nextAppState: AppStateStatus): boolean => const onStateChange = ( nextAppState: AppStateStatus, client: CozyClient, - sharingIntentStatus: SharingIntentStatus + sharingIntentStatus: SharingIntentStatus, + onNavigationRequest: (route: string) => void ): void => { if (isGoingToSleep(nextAppState)) handleSleep() if (isGoingToWakeUp(nextAppState)) { Promise.all([ - handleWakeUp(client, sharingIntentStatus), + handleWakeUp(client, sharingIntentStatus, onNavigationRequest), synchronizeDevice(client) ]).catch(reason => log.error('Failed when waking up', reason)) } @@ -75,45 +75,31 @@ const onStateChange = ( * Do NOT use it anywhere else than in the component, * for it could create unintended side effects. */ -export const useGlobalAppState = (): void => { - const client = useClient() - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const routeIndex = useNavigationState(state => state?.index) - const { sharingIntentStatus } = useSharingState() - - // On app start - useEffect(() => { - const appStart = async (): Promise => { - if (await getIsSecurityFlowPassed()) { - log.info('useGlobalAppState: app start, security flow passed') - if (sharingIntentStatus === SharingIntentStatus.OpenedViaSharing) { - log.info('useGlobalAppState: app start, sharing mode') - navigate(routes.sharing) - } else { - log.info('useGlobalAppState: app start, not sharing mode') - } - } else { - log.info('useGlobalAppState: app start, security flow not passed') - } - } +interface GlobalAppStateProps { + onNavigationRequest: (route: string) => void +} - log.info('useGlobalAppState: app start') - void appStart() - }, [routeIndex, sharingIntentStatus]) +export const useGlobalAppState = ({ + onNavigationRequest +}: GlobalAppStateProps): void => { + // Ref to track if the logic has already been executed + const hasExecuted = useRef(false) + const client = useClient() + const { sharingIntentStatus, errored } = useSharingState() useEffect(() => { let subscription: NativeEventSubscription | undefined // If there's no client, we don't need to listen to app state changes // because we can't lock the app anyway - if (client && !subscription) { + if (!hasExecuted.current && client && !subscription) { devlog( 'useGlobalAppState: subscribing to AppState changes, synchronizing device' ) subscription = AppState.addEventListener('change', e => - onStateChange(e, client, sharingIntentStatus) + onStateChange(e, client, sharingIntentStatus, onNavigationRequest) ) } @@ -121,5 +107,31 @@ export const useGlobalAppState = (): void => { appState = AppState.currentState subscription?.remove() } - }, [client, sharingIntentStatus]) + }, [client, onNavigationRequest, sharingIntentStatus]) + + // On app start + useEffect(() => { + const appStart = async (): Promise => { + if (await getIsSecurityFlowPassed()) { + log.info('useGlobalAppState: app start, security flow passed') + + if (sharingIntentStatus === SharingIntentStatus.OpenedViaSharing) { + log.info('useGlobalAppState: app start, sharing mode') + !errored && onNavigationRequest(routes.sharing) + } else { + log.info('useGlobalAppState: app start, not sharing mode') + } + } else { + log.info('useGlobalAppState: app start, security flow not passed') + } + } + + if (!hasExecuted.current) { + log.info('useGlobalAppState: app start') + void appStart() + + // Mark the logic as executed + hasExecuted.current = true + } + }, [errored, onNavigationRequest, sharingIntentStatus]) } diff --git a/src/screens/home/components/HomeView.js b/src/screens/home/components/HomeView.js index 311defdd0..965cf3d0b 100644 --- a/src/screens/home/components/HomeView.js +++ b/src/screens/home/components/HomeView.js @@ -22,6 +22,7 @@ import { useHomeStateContext } from '/screens/home/HomeStateProvider' import { launcherEvent } from '/libs/ReactNativeLauncher' import { determineSecurityFlow } from '/app/domain/authorization/services/SecurityService' import { devlog } from '/core/tools/env' +import { useSharingState } from '/app/view/Sharing/SharingState' const log = Minilog('🏠 HomeView') @@ -59,6 +60,7 @@ const HomeView = ({ route, navigation, setLauncherContext, setBarStyle }) => { const session = useSession() const didBlurOnce = useRef(false) const [webviewRef, setParentRef] = useState() + const { sharingIntentStatus } = useSharingState() const mainAppFallbackURLInitialParam = useInitialParam( 'mainAppFallbackURL', route, @@ -238,7 +240,12 @@ const HomeView = ({ route, navigation, setLauncherContext, setBarStyle }) => { `HomeView: setting hasRenderedOnce.current set to "true" and calling determineSecurityFlowHook()` ) hasRenderedOnce.current = true - await determineSecurityFlow(client, navigationObject, true) + await determineSecurityFlow( + client, + navigationObject, + true, + sharingIntentStatus + ) } } @@ -249,7 +256,8 @@ const HomeView = ({ route, navigation, setLauncherContext, setBarStyle }) => { navigation, shouldWaitCozyApp, setShouldWaitCozyApp, - uri + uri, + sharingIntentStatus ]) const handleTrackWebviewInnerUri = webviewInneruri => {