feat(mobile): keep voice alive with the screen off
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>
This commit is contained in:
2026-09-03 13:44:35 -03:00
co-authored by Claude Opus 5
parent 48e8bc537d
commit cc2b8d3d33
10 changed files with 552 additions and 1 deletions
@@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import { InAppNotifications } from '../ui/InAppNotifications';
import { UpdateBanner } from '../ui/UpdateBanner';
import { AndroidVoiceService } from '../voice/AndroidVoiceService';
import { useExpressionStore } from '../../stores/expressionStore';
import { useParams, useNavigate } from 'react-router-dom';
import { SpaceSidebar } from './SpaceSidebar';
@@ -430,6 +431,7 @@ export function AppLayout() {
(e.g. immediately after Join, or after popping voice-full back to
the root). */}
<SoundController />
<AndroidVoiceService />
<UpdateBanner />
<InAppNotifications />
<GlobalAudioRenderer />
@@ -474,6 +476,7 @@ export function AppLayout() {
<ImagePreview />
<PictureInPicture />
<SoundController />
<AndroidVoiceService />
<GlobalAudioRenderer />
<NotificationController />
<UpdateBanner />
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, act, waitFor } from '@testing-library/react';
// A voiceStore arrasta o AudioManager, que carrega um AudioWorklet inexistente
// no jsdom. Mesmo stub que os outros testes que tocam nesta store usam.
vi.mock('../../audio/AudioManager', () => ({
AudioManager: { getInstance: () => ({ resumeContext: vi.fn() }) },
}));
import { AndroidVoiceService } from './AndroidVoiceService';
import { useVoiceStore } from '../../stores/voiceStore';
const start = vi.fn(() => Promise.resolve());
const stop = vi.fn(() => Promise.resolve());
let hangupHandler: (() => void) | null = null;
function installCapacitor(platform = 'android') {
(globalThis as { Capacitor?: unknown }).Capacitor = {
isNativePlatform: () => true,
getPlatform: () => platform,
Plugins: {
VoiceForeground: {
start,
stop,
addListener: (_e: string, h: () => void) => {
hangupHandler = h;
return Promise.resolve({ remove: () => { hangupHandler = null; } });
},
},
},
};
}
describe('AndroidVoiceService', () => {
beforeEach(() => {
start.mockClear();
stop.mockClear();
hangupHandler = null;
useVoiceStore.setState({ isLiveKitConnected: false, currentVoiceChannelId: null });
});
afterEach(() => {
delete (globalThis as { Capacitor?: unknown }).Capacitor;
});
it('starts the service when a call connects and stops it when it ends', async () => {
installCapacitor();
render(<AndroidVoiceService />);
expect(start).not.toHaveBeenCalled();
act(() => {
useVoiceStore.setState({ isLiveKitConnected: true, currentVoiceChannelId: 'c-1' });
});
await waitFor(() => expect(start).toHaveBeenCalledTimes(1));
act(() => {
useVoiceStore.setState({ isLiveKitConnected: false, currentVoiceChannelId: null });
});
await waitFor(() => expect(stop).toHaveBeenCalledTimes(1));
});
it('starts on mount when a call is already in progress', async () => {
// A WebView pode recarregar no meio da chamada. Se o servico so subisse na
// proxima transicao, a call seguiria desprotegida ate alguem sair e entrar
// de novo — e cairia ao trancar a tela.
installCapacitor();
useVoiceStore.setState({ isLiveKitConnected: true, currentVoiceChannelId: 'c-1' });
render(<AndroidVoiceService />);
await waitFor(() => expect(start).toHaveBeenCalledTimes(1));
});
it('leaves the call when the notification hangup is tapped', async () => {
installCapacitor();
const disconnectFn = vi.fn(() => Promise.resolve());
useVoiceStore.setState({ disconnectFn });
render(<AndroidVoiceService />);
await waitFor(() => expect(hangupHandler).not.toBeNull());
act(() => hangupHandler!());
expect(disconnectFn).toHaveBeenCalled();
});
it('does nothing outside the Android app', async () => {
// Mesmo componente montado no navegador e no desktop: nao pode tocar em
// nada nativo nem quebrar.
render(<AndroidVoiceService />);
act(() => {
useVoiceStore.setState({ isLiveKitConnected: true, currentVoiceChannelId: 'c-1' });
});
expect(start).not.toHaveBeenCalled();
expect(stop).not.toHaveBeenCalled();
});
it('ignores a web build of Capacitor', async () => {
installCapacitor('web');
render(<AndroidVoiceService />);
act(() => {
useVoiceStore.setState({ isLiveKitConnected: true, currentVoiceChannelId: 'c-1' });
});
expect(start).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,69 @@
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;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Ponte com o servico de chamada do app Android.
*
* Trancar a tela derruba a call: o Android congela o processo, e uma WebView
* congelada para de rodar o JavaScript que mantem a conexao do LiveKit. O lado
* nativo resolve isso com um servico em primeiro plano, mas so o app web sabe
* quando existe uma chamada — daqui sai esse aviso.
*
* Fora do app Android nada disto existe, e todas as funcoes viram no-op: o
* mesmo codigo roda no navegador e no desktop sem condicional no chamador.
*/
interface VoiceForegroundPlugin {
start: (options: { channel?: string }) => Promise<void>;
stop: () => Promise<void>;
addListener: (event: 'hangup', handler: () => void) => Promise<{ remove: () => void }>;
}
interface CapacitorGlobal {
isNativePlatform?: () => boolean;
getPlatform?: () => string;
Plugins?: { VoiceForeground?: VoiceForegroundPlugin };
}
function capacitor(): CapacitorGlobal | undefined {
return (globalThis as { Capacitor?: CapacitorGlobal }).Capacitor;
}
/** True somente dentro do APK — nao no navegador do celular nem no desktop. */
export function isAndroidApp(): boolean {
const cap = capacitor();
return Boolean(cap?.isNativePlatform?.() && cap.getPlatform?.() === 'android');
}
function plugin(): VoiceForegroundPlugin | undefined {
return isAndroidApp() ? capacitor()?.Plugins?.VoiceForeground : undefined;
}
/**
* Liga o servico. Idempotente do lado nativo: chamar duas vezes so atualiza a
* notificacao, nao empilha servicos.
*/
export function startAndroidVoiceService(channelName?: string): void {
// Um erro aqui nao pode derrubar a entrada na call. O pior caso de falhar em
// silencio e a chamada cair com a tela apagada, que e exatamente o
// comportamento de quem nao tem o servico — nunca pior que isso.
plugin()?.start({ channel: channelName }).catch(() => { /* segue sem o servico */ });
}
export function stopAndroidVoiceService(): void {
plugin()?.stop().catch(() => { /* o servico morre junto com o processo */ });
}
/**
* Escuta o botao "Sair" da notificacao. Devolve uma funcao para cancelar a
* inscricao, ou uma vazia fora do Android.
*/
export function onAndroidHangup(handler: () => void): () => void {
const p = plugin();
if (!p) return () => { /* nada a cancelar */ };
let remove: (() => void) | null = null;
let cancelled = false;
p.addListener('hangup', handler)
.then((h) => {
// A inscricao e assincrona: se o componente desmontar antes dela
// resolver, cancela assim que chegar em vez de vazar o listener.
if (cancelled) h.remove();
else remove = h.remove;
})
.catch(() => { /* sem o plugin, sem botao */ });
return () => {
cancelled = true;
remove?.();
};
}