// Keep an Android foreground service alive while a DM call is actively joined. // // Lifecycle is keyed to the widget's JoinCall signal (via useCallJoined), not // the mere presence of callEmbedAtom. Rationale: // // 1. RECORD_AUDIO runtime grant. By the time JoinCall fires, Element Call // inside the iframe has already called getUserMedia, which prompted the // OS permission dialog (if needed) and user granted. Starting the FGS // earlier (during `preparing`, before getUserMedia) means RECORD_AUDIO // may not be granted yet — and on API 34+ startForeground with // TYPE_MICROPHONE throws SecurityException without it, while a // fallback to TYPE_NONE is not a valid subset of the manifest-declared // `microphone` service type. Gating on joined sidesteps the whole // fallback question. // // 2. No ghost notification during preparingError. If the widget fails to // load, JoinCall never fires, the service never starts, nothing to // clean up. // // Tradeoff: a tiny (1-3s) window between embed creation and JoinCall has no // retention. The call isn't actually live in that window — media hasn't // started — so lock-screen there is a non-event; user can retry. // // Android-only. import { useEffect, useRef } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { callEmbedAtom, callSpeakerAtom } from '../state/callEmbed'; import { callForegroundService } from '../plugins/call/callForegroundService'; import { useCallJoined } from './useCallEmbed'; import { isAndroidPlatform } from '../utils/capacitor'; export const useAndroidCallForegroundSync = (): void => { const callEmbed = useAtomValue(callEmbedAtom); const joined = useCallJoined(callEmbed); const setSpeaker = useSetAtom(callSpeakerAtom); const hadEmbedRef = useRef(false); // Backstop teardown keyed on the embed lifecycle, NOT on `joined`. An answered // call (answer-from-killed) starts the phoneCall FGS natively at ring time but // may never reach JoinCall (widget preparingError / EC Close / LiveKit fail). // The joined-gated cleanup below never runs in that case, so the FGS would leak // until the process dies. When an embed that existed goes away, stop the FGS // unconditionally (idempotent with the joined cleanup for the normal path). useEffect(() => { if (!isAndroidPlatform()) return; if (callEmbed) { hadEmbedRef.current = true; return; } if (hadEmbedRef.current) { hadEmbedRef.current = false; callForegroundService.stop().catch(() => undefined); } }, [callEmbed]); useEffect(() => { if (!isAndroidPlatform()) return undefined; if (!joined) return undefined; // Tag the active-call FGS with both types: microphone for §2.2 mic // retention under lock, and phoneCall so it matches the ring-time phoneCall // FGS (clean handoff on answer-from-killed) and stays restartable if the // process is rebuilt mid-call. No title → native fills the localized // "Ongoing call" (PushStrings), instead of a hardcoded Russian string (F32). callForegroundService.start({ phoneCall: true }).catch((err: unknown) => { // eslint-disable-next-line no-console console.warn('[call-fgs] start failed', err); }); return () => { callForegroundService.stop().catch((err: unknown) => { // eslint-disable-next-line no-console console.warn('[call-fgs] stop failed', err); }); // Telecom owns the audio route + its cleanup; we only reset the UI atom so // the next call's speaker toggle starts from earpiece. setSpeaker(false); }; }, [joined, setSpeaker]); };