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?.(); + }; +}