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>
111 lines
3.5 KiB
TypeScript
111 lines
3.5 KiB
TypeScript
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();
|
|
});
|
|
});
|