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
@@ -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);
}
}
@@ -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>