import { useEffect, useRef } from 'react'; import { useLocation, useNavigate, useNavigationType } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { App } from '@capacitor/app'; import { Toast } from '@capacitor/toast'; import { isNativePlatform } from '../utils/capacitor'; import { getRouteSectionParent } from '../utils/routeParent'; const EXIT_CONFIRM_WINDOW_MS = 2000; /** * Maps the Android hardware back button to web-equivalent behavior, using * an app-level back stack instead of the WebView's native history. The * WebView's canGoBack includes redirects / replaces / tab-switch entries * that inflate its depth, which is why we maintain our own stack keyed * off react-router's useNavigationType. * * 1. Overlay/modal open → dispatch Escape (closes folds Overlay via * focus-trap-react's escapeDeactivates). * 2. App stack has more than one entry → optimistic pop + navigate(-1). * The optimistic pop, combined with pendingPopsRef suppressing the * duplicate pop in the tracking effect, makes rapid back-presses * see the up-to-date stack length before React commits the new * location. * 3. Cold-start / deep-link into a nested screen → navigate to the * section-root semantic parent (same logic UI back arrow uses), with * replace so the stack doesn't grow in a loop. * 4. At section root with a single-entry stack → require a confirming * second press within EXIT_CONFIRM_WINDOW_MS to exit; first press * shows a native Android toast. Any navigation between presses * resets the window so the prompt can't leak across screens. */ export const useAndroidBackButton = (): void => { const navigate = useNavigate(); const location = useLocation(); const navType = useNavigationType(); const { t } = useTranslation(); const navigateRef = useRef(navigate); navigateRef.current = navigate; const exitPromptRef = useRef(t('App.back_to_exit')); exitPromptRef.current = t('App.back_to_exit'); const stackRef = useRef([location.pathname]); const lastKeyRef = useRef(location.key); const pendingPopsRef = useRef(0); const lastExitPressRef = useRef(0); useEffect(() => { if (location.key === lastKeyRef.current) return; lastKeyRef.current = location.key; lastExitPressRef.current = 0; if (navType === 'PUSH') { stackRef.current = [...stackRef.current, location.pathname]; } else if (navType === 'REPLACE') { stackRef.current = [...stackRef.current.slice(0, -1), location.pathname]; } else if (navType === 'POP') { if (pendingPopsRef.current > 0) { pendingPopsRef.current -= 1; } else { stackRef.current = stackRef.current.slice(0, -1); } } }, [location.key, location.pathname, navType]); useEffect(() => { if (!isNativePlatform()) return undefined; const handlePromise = App.addListener('backButton', () => { const portal = document.getElementById('portalContainer'); if (portal?.firstChild) { document.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) ); return; } if (stackRef.current.length > 1) { stackRef.current = stackRef.current.slice(0, -1); pendingPopsRef.current += 1; navigateRef.current(-1); return; } const current = stackRef.current[0] ?? '/'; const parent = getRouteSectionParent(current); if (parent) { navigateRef.current(parent, { replace: true }); return; } const now = Date.now(); if (now - lastExitPressRef.current < EXIT_CONFIRM_WINDOW_MS) { App.exitApp(); return; } lastExitPressRef.current = now; Toast.show({ text: exitPromptRef.current, duration: 'short', position: 'bottom' }); }); return () => { handlePromise.then((handle) => handle.remove()); }; }, []); };