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.
81 lines
3.6 KiB
TypeScript
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]);
|
|
};
|