vojo/src/app/hooks/useAndroidCallForegroundSync.ts
heaven 71a71fa56b feat(calls): verify Answer-intent token + harden native ring lifecycle
Every Answer PendingIntent now carries a per-install secret (CallActionToken, private prefs); the JS consumer and MainActivity over-lock verify it before auto-joining, blocking a forged hot-mic join from another local app. MainActivity marks the Telecom session answered natively on a shade Answer, and its over-lock onResume backstop waits a settle window. Native ring start runs fully under telecomLock (no orphan slot), a Telecom failure degrades to notification-only instead of killing the ring, the full-screen ring timeout tracks the real lifetime, and IncomingRing.data is a ConcurrentHashMap. FGS notification/channel strings localized via PushStrings; dead Call.accept key dropped. From the internal multi-agent review.
2026-07-04 02:14:08 +03:00

81 lines
3.6 KiB
TypeScript

// 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]);
};