CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
A call dropped as soon as the screen locked. The cause is not configuration: Android freezes background processes, and a frozen WebView stops running the JavaScript that holds the LiveKit connection up. The only thing the system accepts as "this app must keep running" is a foreground service, which is why the persistent notification comes with it. Two mechanisms, both needed: the service stops the process from being frozen, and a partial wake lock stops the CPU from sleeping. Either one alone still drops the call. The notification carries a hangup action. The service cannot end the call itself — only the web app knows how to leave a LiveKit room — so it notifies the plugin's listeners and the web side disconnects through the same path the in-app button uses. The web side starts the service on mount when a call is already in progress, not just on transition: the WebView can reload mid-call, and waiting for the next connect would leave that call unprotected with no visible symptom until someone locked their screen. POST_NOTIFICATIONS is requested at launch on Android 13+. Denying it does not drop calls — the service still runs — it only hides the notification. Tests cover connect/disconnect, the already-in-a-call mount, the hangup action, and that the component is inert in the browser and desktop builds. Verified by mutation that dropping the start call fails the first one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
70 lines
2.5 KiB
TypeScript
70 lines
2.5 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
|
|
import { useVoiceStore } from '../../stores/voiceStore';
|
|
import { useSpaceStore } from '../../stores/spaceStore';
|
|
import {
|
|
isAndroidApp,
|
|
onAndroidHangup,
|
|
startAndroidVoiceService,
|
|
stopAndroidVoiceService,
|
|
} from '../../platform/androidVoice';
|
|
|
|
/**
|
|
* Liga e desliga o servico de primeiro plano do Android conforme a chamada
|
|
* comeca e termina. Nao renderiza nada; fora do APK nao faz nada.
|
|
*
|
|
* Assina a store direto, sem seletor de re-render: o efeito colateral e uma
|
|
* chamada ao nativo, e uma re-renderizacao por mudanca de estado da voz seria
|
|
* so custo.
|
|
*/
|
|
export function AndroidVoiceService() {
|
|
const wasConnected = useRef(useVoiceStore.getState().isLiveKitConnected);
|
|
|
|
useEffect(() => {
|
|
if (!isAndroidApp()) return;
|
|
|
|
const channelLabel = (channelId: string | null): string | undefined => {
|
|
if (!channelId) return undefined;
|
|
if (channelId.startsWith('dm-')) return 'Chamada';
|
|
return useSpaceStore.getState().channels.find((c) => c.id === channelId)?.name;
|
|
};
|
|
|
|
// Entrar na call e o gatilho, mas a montagem pode acontecer com uma
|
|
// chamada ja em andamento — uma recarga da WebView, por exemplo. Nesse caso
|
|
// o servico precisa subir agora, nao na proxima transicao.
|
|
const initial = useVoiceStore.getState();
|
|
if (initial.isLiveKitConnected) {
|
|
startAndroidVoiceService(channelLabel(initial.currentVoiceChannelId));
|
|
}
|
|
|
|
const unsubscribe = useVoiceStore.subscribe((state) => {
|
|
const connected = state.isLiveKitConnected;
|
|
if (connected === wasConnected.current) return;
|
|
wasConnected.current = connected;
|
|
|
|
if (connected) startAndroidVoiceService(channelLabel(state.currentVoiceChannelId));
|
|
else stopAndroidVoiceService();
|
|
});
|
|
|
|
const removeHangup = onAndroidHangup(() => {
|
|
// A notificacao so pede para sair; quem sabe encerrar a sessao do LiveKit
|
|
// e o app web. O servico para quando a store refletir a saida, pelo
|
|
// mesmo caminho de quando se sai pela interface.
|
|
const { disconnectFn, leaveVoice } = useVoiceStore.getState();
|
|
if (disconnectFn) void disconnectFn();
|
|
else leaveVoice();
|
|
});
|
|
|
|
return () => {
|
|
unsubscribe();
|
|
removeHangup();
|
|
// Desmontar nao significa sair da call (a arvore de componentes pode
|
|
// remontar), mas deixar o servico ligado sem ninguem para desliga-lo
|
|
// seria pior: uma notificacao permanente que so some matando o app.
|
|
if (!useVoiceStore.getState().isLiveKitConnected) stopAndroidVoiceService();
|
|
};
|
|
}, []);
|
|
|
|
return null;
|
|
}
|