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
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:
@@ -24,6 +24,17 @@
|
||||
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
O tipo "microphone" tem de bater com o que o servico passa para o
|
||||
startForeground e com a permissao FOREGROUND_SERVICE_MICROPHONE. A
|
||||
partir do Android 14 os tres precisam concordar, senao o sistema
|
||||
derruba o app na hora de entrar na call.
|
||||
-->
|
||||
<service
|
||||
android:name=".VoiceForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -53,6 +64,15 @@
|
||||
<!-- Android 13+ nao mostra notificacao nenhuma sem esta permissao. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!--
|
||||
Chamada com a tela apagada. O servico em primeiro plano impede o sistema
|
||||
de congelar o processo; o wake lock impede a CPU de dormir. Sem os dois, a
|
||||
WebView para de rodar o JavaScript e a call cai.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!--
|
||||
O aparelho precisa ter microfone; camera e opcional para nao excluir
|
||||
tablets sem camera frontal da lista de aparelhos compativeis.
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
package com.resenhacord.app;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
public class MainActivity extends BridgeActivity {
|
||||
|
||||
private static final int REQ_NOTIFICATIONS = 100;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
// Plugins que moram no proprio app nao sao descobertos sozinhos, e o
|
||||
// registro tem de acontecer antes do super.onCreate — depois disso a
|
||||
// ponte ja foi montada e o plugin nao aparece para o JavaScript.
|
||||
registerPlugin(VoicePlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
requestNotificationPermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* A partir do Android 13 a notificacao da chamada nao aparece sem esta
|
||||
* permissao. O servico continua rodando de qualquer forma — a chamada nao
|
||||
* cai se o usuario recusar —, mas ele fica sem nada visivel na barra, o que
|
||||
* so parece defeito. Por isso perguntamos, e seguimos com a recusa.
|
||||
*/
|
||||
private void requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return;
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED) return;
|
||||
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQ_NOTIFICATIONS);
|
||||
}
|
||||
}
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package com.resenhacord.app;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
|
||||
/**
|
||||
* Mantem o app vivo enquanto ha uma chamada em andamento.
|
||||
*
|
||||
* Sem isto, trancar a tela derruba a call. O Android congela processos em
|
||||
* segundo plano — e uma WebView congelada para de rodar o JavaScript que
|
||||
* mantem a conexao do LiveKit de pe. Um servico em primeiro plano e a unica
|
||||
* coisa que o sistema aceita como "este app precisa continuar rodando", e ele
|
||||
* exige a notificacao permanente que aparece junto: e o preco combinado, o
|
||||
* usuario sempre ve que algo esta ativo.
|
||||
*
|
||||
* O wake lock parcial e separado e igualmente necessario: o servico impede o
|
||||
* congelamento do processo, mas nao impede a CPU de dormir.
|
||||
*/
|
||||
public class VoiceForegroundService extends Service {
|
||||
|
||||
public static final String ACTION_START = "com.resenhacord.app.VOICE_START";
|
||||
public static final String ACTION_STOP = "com.resenhacord.app.VOICE_STOP";
|
||||
public static final String ACTION_HANGUP = "com.resenhacord.app.VOICE_HANGUP";
|
||||
public static final String EXTRA_CHANNEL = "channel";
|
||||
|
||||
private static final String CHANNEL_ID = "voice_call";
|
||||
private static final int NOTIFICATION_ID = 1;
|
||||
|
||||
private PowerManager.WakeLock wakeLock;
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
String action = intent != null ? intent.getAction() : null;
|
||||
|
||||
if (ACTION_STOP.equals(action)) {
|
||||
stop();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
if (ACTION_HANGUP.equals(action)) {
|
||||
// O botao da notificacao nao desliga a chamada sozinho: quem sabe
|
||||
// como sair da sala e o app web. Avisamos o JavaScript e deixamos
|
||||
// ele encerrar; o servico para quando o JS confirmar a saida.
|
||||
VoicePlugin.emitHangup();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
String channel = intent != null ? intent.getStringExtra(EXTRA_CHANNEL) : null;
|
||||
startForegroundWithNotification(channel);
|
||||
acquireWakeLock();
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
private void startForegroundWithNotification(String channelName) {
|
||||
createNotificationChannel();
|
||||
|
||||
Intent open = new Intent(this, MainActivity.class);
|
||||
open.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent openPending = PendingIntent.getActivity(
|
||||
this, 0, open, PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
Intent hangup = new Intent(this, VoiceForegroundService.class);
|
||||
hangup.setAction(ACTION_HANGUP);
|
||||
PendingIntent hangupPending = PendingIntent.getService(
|
||||
this, 1, hangup, PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
String text = channelName != null && !channelName.isEmpty()
|
||||
? channelName
|
||||
: getString(R.string.voice_notification_text);
|
||||
|
||||
Notification notification = new Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.voice_notification_title))
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.drawable.ic_stat_voice)
|
||||
.setContentIntent(openPending)
|
||||
.addAction(new Notification.Action.Builder(
|
||||
null, getString(R.string.voice_notification_hangup), hangupPending).build())
|
||||
.setOngoing(true)
|
||||
.setUsesChronometer(true)
|
||||
.build();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// O tipo e obrigatorio a partir do Android 14: sem ele o sistema
|
||||
// recusa o servico e derruba o app com uma excecao.
|
||||
startForeground(NOTIFICATION_ID, notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification);
|
||||
}
|
||||
}
|
||||
|
||||
private void createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return;
|
||||
|
||||
// Importancia baixa de proposito: a notificacao existe porque o sistema
|
||||
// exige, nao para chamar atencao. Som ou vibracao aqui tocariam a cada
|
||||
// entrada em call, por cima da propria chamada.
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.voice_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW);
|
||||
channel.setDescription(getString(R.string.voice_channel_description));
|
||||
channel.setShowBadge(false);
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
private void acquireWakeLock() {
|
||||
if (wakeLock != null && wakeLock.isHeld()) return;
|
||||
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Resenhacord::voice");
|
||||
// Sem timeout infinito: se o app morrer de um jeito que nao passe pelo
|
||||
// stop(), o sistema solta o lock sozinho depois de duas horas em vez de
|
||||
// segurar a CPU para sempre.
|
||||
wakeLock.acquire(2 * 60 * 60 * 1000L);
|
||||
}
|
||||
|
||||
private void releaseWakeLock() {
|
||||
if (wakeLock != null && wakeLock.isHeld()) {
|
||||
wakeLock.release();
|
||||
}
|
||||
wakeLock = null;
|
||||
}
|
||||
|
||||
private void stop() {
|
||||
releaseWakeLock();
|
||||
stopForeground(true);
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
releaseWakeLock();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.resenhacord.app;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
/**
|
||||
* Ponte entre o app web e o servico de chamada.
|
||||
*
|
||||
* O app web e quem sabe quando ha uma call: ele fala com o LiveKit. O lado
|
||||
* nativo e quem sabe pedir ao Android para nao congelar o processo. Este plugin
|
||||
* so liga as duas pontas.
|
||||
*/
|
||||
@CapacitorPlugin(name = "VoiceForeground")
|
||||
public class VoicePlugin extends Plugin {
|
||||
|
||||
/**
|
||||
* Referencia fraca de proposito: o servico precisa avisar o JavaScript
|
||||
* quando alguem toca em "Sair" na notificacao, mas ele nao pode ser o
|
||||
* motivo de a instancia do plugin (e a Activity presa a ela) continuar
|
||||
* viva depois de destruida.
|
||||
*/
|
||||
private static WeakReference<VoicePlugin> instance = new WeakReference<>(null);
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
instance = new WeakReference<>(this);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void start(PluginCall call) {
|
||||
Intent intent = new Intent(getContext(), VoiceForegroundService.class);
|
||||
intent.setAction(VoiceForegroundService.ACTION_START);
|
||||
intent.putExtra(VoiceForegroundService.EXTRA_CHANNEL, call.getString("channel"));
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getContext().startForegroundService(intent);
|
||||
} else {
|
||||
getContext().startService(intent);
|
||||
}
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void stop(PluginCall call) {
|
||||
Intent intent = new Intent(getContext(), VoiceForegroundService.class);
|
||||
intent.setAction(VoiceForegroundService.ACTION_STOP);
|
||||
getContext().startService(intent);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** Chamado pelo servico quando o botao "Sair" da notificacao e tocado. */
|
||||
static void emitHangup() {
|
||||
VoicePlugin plugin = instance.get();
|
||||
if (plugin != null) {
|
||||
plugin.notifyListeners("hangup", new JSObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Icone pequeno da notificacao. O Android descarta a cor e usa so o formato
|
||||
(a partir do Lollipop ele e pintado de branco sobre a silhueta), entao aqui
|
||||
vale desenho solido e simples — um microfone.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
|
||||
</vector>
|
||||
@@ -4,4 +4,9 @@
|
||||
<string name="title_activity_main">Resenhacord</string>
|
||||
<string name="package_name">com.resenhacord.app</string>
|
||||
<string name="custom_url_scheme">com.resenhacord.app</string>
|
||||
<string name="voice_channel_name">Chamada em andamento</string>
|
||||
<string name="voice_channel_description">Mantem a chamada de voz ativa com o app em segundo plano</string>
|
||||
<string name="voice_notification_title">Resenhacord</string>
|
||||
<string name="voice_notification_text">Em call</string>
|
||||
<string name="voice_notification_hangup">Sair</string>
|
||||
</resources>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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?.();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user