diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml
index 37bb9aee..76e56cb1 100644
--- a/packages/mobile/android/app/src/main/AndroidManifest.xml
+++ b/packages/mobile/android/app/src/main/AndroidManifest.xml
@@ -24,6 +24,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/mobile/android/app/src/main/res/values/strings.xml b/packages/mobile/android/app/src/main/res/values/strings.xml
index 39bcf2fb..7275cbeb 100644
--- a/packages/mobile/android/app/src/main/res/values/strings.xml
+++ b/packages/mobile/android/app/src/main/res/values/strings.xml
@@ -4,4 +4,9 @@
Resenhacord
com.resenhacord.app
com.resenhacord.app
+ Chamada em andamento
+ Mantem a chamada de voz ativa com o app em segundo plano
+ Resenhacord
+ Em call
+ Sair
diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx
index 6eb29d61..d4ab254f 100644
--- a/packages/web/src/components/layout/AppLayout.tsx
+++ b/packages/web/src/components/layout/AppLayout.tsx
@@ -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). */}
+
@@ -474,6 +476,7 @@ export function AppLayout() {
+
diff --git a/packages/web/src/components/voice/AndroidVoiceService.test.tsx b/packages/web/src/components/voice/AndroidVoiceService.test.tsx
new file mode 100644
index 00000000..45b7a922
--- /dev/null
+++ b/packages/web/src/components/voice/AndroidVoiceService.test.tsx
@@ -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();
+ 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();
+
+ 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();
+ 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();
+
+ 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();
+
+ act(() => {
+ useVoiceStore.setState({ isLiveKitConnected: true, currentVoiceChannelId: 'c-1' });
+ });
+
+ expect(start).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/web/src/components/voice/AndroidVoiceService.tsx b/packages/web/src/components/voice/AndroidVoiceService.tsx
new file mode 100644
index 00000000..c237718b
--- /dev/null
+++ b/packages/web/src/components/voice/AndroidVoiceService.tsx
@@ -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;
+}
diff --git a/packages/web/src/platform/androidVoice.ts b/packages/web/src/platform/androidVoice.ts
new file mode 100644
index 00000000..bef2079d
--- /dev/null
+++ b/packages/web/src/platform/androidVoice.ts
@@ -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;
+ stop: () => Promise;
+ 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?.();
+ };
+}