diff --git a/src/app/hooks/useDismissNativeCallNotifications.ts b/src/app/hooks/useDismissNativeCallNotifications.ts index b11cf924..e61af658 100644 --- a/src/app/hooks/useDismissNativeCallNotifications.ts +++ b/src/app/hooks/useDismissNativeCallNotifications.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { useAtomValue } from 'jotai'; import { App } from '@capacitor/app'; import { incomingCallsAtom } from '../state/incomingCalls'; -import { isNativePlatform } from '../utils/capacitor'; +import { isAndroidPlatform, isNativePlatform } from '../utils/capacitor'; const SUMMARY_NOTIFICATION_ID = -2147483648; @@ -60,29 +60,45 @@ export const useDismissNativeCallNotifications = (): void => { () => typeof document === 'undefined' || document.visibilityState === 'visible' ); - // App state tracking. Race-guard: a real appStateChange firing before - // getState() resolves must win, otherwise the stale snapshot re-enables the - // foreground branch while we are actually backgrounded (and we'd dismiss - // the native ring we just raised). + // App state tracking. + // Android: `pause`/`resume` fire inside BridgeActivity.onPause/onResume via + // AppPlugin.handleOnPause/Resume — the same Activity callbacks that flip + // MainActivity.isInForeground — and reach JS within ms via the WebView + // bridge. `appStateChange` on Android fires only in BridgeActivity.onStop, + // tens of ms to ~1s after onPause; using it as the gate would leak incoming + // rings into a "just backgrounded" window where Java sees background (FCM + // shows native CallStyle) but JS still sees active (and the dismiss branch + // below would clobber the freshly-raised native ring). + // iOS/web keeps `appStateChange` because there it maps to + // willResignActive/didBecomeActive (iOS) or document.visibilitychange (web) + // — the correct edge. The Capacitor `pause`/`resume` events on iOS bind to + // didEnterBackground/willEnterForeground, which is a different semantics. + // Race-guard: a real lifecycle event arriving before getState() resolves + // must win over the stale snapshot. useEffect(() => { let cancelled = false; - let sawAppStateEvent = false; - let remove: (() => void) | undefined; + let sawLifecycleEvent = false; + const handles: Array<{ remove: () => void }> = []; + const track = (h: { remove: () => void }) => { + if (cancelled) h.remove(); + else handles.push(h); + }; - App.addListener('appStateChange', ({ isActive }) => { - sawAppStateEvent = true; + const apply = (isActive: boolean) => { + sawLifecycleEvent = true; if (!cancelled) setAppActive(isActive); - }).then((handle) => { - if (cancelled) handle.remove(); - else - remove = () => { - handle.remove(); - }; - }); + }; + + if (isAndroidPlatform()) { + App.addListener('pause', () => apply(false)).then(track); + App.addListener('resume', () => apply(true)).then(track); + } else { + App.addListener('appStateChange', ({ isActive }) => apply(isActive)).then(track); + } App.getState() .then((state) => { - if (!cancelled && !sawAppStateEvent) setAppActive(state.isActive); + if (!cancelled && !sawLifecycleEvent) setAppActive(state.isActive); }) .catch(() => { /* web fallback handled by initial document.visibilityState */ @@ -90,7 +106,7 @@ export const useDismissNativeCallNotifications = (): void => { return () => { cancelled = true; - remove?.(); + handles.forEach((h) => h.remove()); }; }, []); diff --git a/src/app/pages/IncomingCallStripRenderer.tsx b/src/app/pages/IncomingCallStripRenderer.tsx index 2fefdb26..5f177c58 100644 --- a/src/app/pages/IncomingCallStripRenderer.tsx +++ b/src/app/pages/IncomingCallStripRenderer.tsx @@ -27,6 +27,7 @@ import { App } from '@capacitor/app'; import { incomingCallsAtom } from '../state/incomingCalls'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { IncomingCallStrip } from '../features/call-status'; +import { isAndroidPlatform } from '../utils/capacitor'; // eslint-disable-next-line import/no-relative-packages import RingSoundOgg from '../../../public/sound/ring.ogg'; // eslint-disable-next-line import/no-relative-packages @@ -44,27 +45,40 @@ export function IncomingCallStripRenderer() { useEffect(() => { let cancelled = false; - // Listener registration and getState() race: if a real appStateChange - // fires before getState() resolves, the stale getState() snapshot would - // overwrite the fresh event and re-open §5.39 (e.g. background → listener - // sets false → late getState() resolves true → audio re-enabled). - let sawAppStateEvent = false; - let remove: (() => void) | undefined; + // Android: pause/resume fire on BridgeActivity.onPause/onResume via + // AppPlugin.handleOnPause/Resume — same Activity callbacks that flip + // MainActivity.isInForeground — reaching JS within ms via the WebView + // bridge. appStateChange on Android only fires in onStop (tens of ms to + // ~1s after onPause), which would let JS audio race the native ringtone + // in the "just backgrounded" window and double-ring. + // iOS/web keeps appStateChange because there it maps to + // willResignActive/didBecomeActive (iOS) or document.visibilitychange + // (web). The Capacitor pause/resume events on iOS bind to + // didEnterBackground/willEnterForeground — a different edge. + // Race-guard: a real lifecycle event arriving before getState() resolves + // must win over the stale snapshot. + let sawLifecycleEvent = false; + const handles: Array<{ remove: () => void }> = []; + const track = (h: { remove: () => void }) => { + if (cancelled) h.remove(); + else handles.push(h); + }; - App.addListener('appStateChange', ({ isActive }) => { - sawAppStateEvent = true; + const apply = (isActive: boolean) => { + sawLifecycleEvent = true; if (!cancelled) setAppActive(isActive); - }).then((handle) => { - if (cancelled) handle.remove(); - else - remove = () => { - handle.remove(); - }; - }); + }; + + if (isAndroidPlatform()) { + App.addListener('pause', () => apply(false)).then(track); + App.addListener('resume', () => apply(true)).then(track); + } else { + App.addListener('appStateChange', ({ isActive }) => apply(isActive)).then(track); + } App.getState() .then((state) => { - if (!cancelled && !sawAppStateEvent) setAppActive(state.isActive); + if (!cancelled && !sawLifecycleEvent) setAppActive(state.isActive); }) .catch(() => { /* web fallback handled by initial document.visibilityState */ @@ -72,7 +86,7 @@ export function IncomingCallStripRenderer() { return () => { cancelled = true; - remove?.(); + handles.forEach((h) => h.remove()); }; }, []);