Add microphone foreground service to keep Android DM calls alive under lock screen

This commit is contained in:
v.lagerev 2026-04-23 00:04:16 +03:00
parent fab533e762
commit e8188d7361
8 changed files with 343 additions and 1 deletions

View file

@ -73,6 +73,11 @@
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".CallForegroundService"
android:exported="false"
android:foregroundServiceType="microphone" />
<receiver <receiver
android:name=".CallCancelReceiver" android:name=".CallCancelReceiver"
android:exported="false" /> android:exported="false" />
@ -96,4 +101,10 @@
FLAG_FSI_REQUESTED_BUT_DENIED so the gate passes, even though we FLAG_FSI_REQUESTED_BUT_DENIED so the gate passes, even though we
never call setFullScreenIntent(). See ADR 2.5-heads-up. --> never call setFullScreenIntent(). See ADR 2.5-heads-up. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- DM call lock-screen retention: CallForegroundService keeps the call
process foregrounded under lock so AppOps doesn't revoke RECORD_AUDIO
and netd doesn't block background network. Context in
docs/plans/dm_calls_techdebt.md §2.2. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
</manifest> </manifest>

View file

@ -0,0 +1,85 @@
package chat.vojo.app;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.util.Log;
import androidx.core.content.ContextCompat;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/**
* JS Android bridge for CallForegroundService lifecycle.
*
* start / stop map onto startForegroundService / stopService.
*
* RECORD_AUDIO permission is re-verified here before dispatch: the JS caller
* (useAndroidCallForegroundSync) gates on the widget's JoinCall signal, which
* implies getUserMedia has run and the grant is in place but the plugin
* checks defensively so the service never attempts startForeground with
* TYPE_MICROPHONE without the permission. The manifest declares the service
* as foregroundServiceType="microphone" only (no fallback type), so on API
* 34+ starting without RECORD_AUDIO would throw ForegroundServiceTypeException.
*/
@CapacitorPlugin(name = "CallForegroundService")
public class CallForegroundPlugin extends Plugin {
private static final String TAG = "CallFgsPlugin";
@PluginMethod
public void start(PluginCall call) {
String title = call.getString("title");
String body = call.getString("body");
Context ctx = getContext();
// Defense-in-depth: starting the microphone-typed FGS without
// RECORD_AUDIO granted is invalid on API 34+. JS side already gates
// on JoinCall (see useAndroidCallForegroundSync) so this should never
// fire in practice. If it does, resolve cleanly without starting
// the call will run without retention, which is the same fate as
// the first-ever-call window before getUserMedia prompt answered.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
int micPerm = ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO);
if (micPerm != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "start: RECORD_AUDIO not granted, skipping FGS (would fail on TYPE_MICROPHONE)");
call.resolve();
return;
}
}
Intent intent = new Intent(ctx, CallForegroundService.class);
if (title != null) intent.putExtra(CallForegroundService.EXTRA_TITLE, title);
if (body != null) intent.putExtra(CallForegroundService.EXTRA_BODY, body);
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent);
} else {
ctx.startService(intent);
}
Log.d(TAG, "start: service started");
call.resolve();
} catch (Throwable t) {
Log.e(TAG, "start: failed to start service", t);
call.reject("start_failed: " + t.getClass().getSimpleName() + ": " + t.getMessage());
}
}
@PluginMethod
public void stop(PluginCall call) {
Context ctx = getContext();
try {
ctx.stopService(new Intent(ctx, CallForegroundService.class));
Log.d(TAG, "stop: stopService dispatched");
call.resolve();
} catch (Throwable t) {
Log.w(TAG, "stop: stopService threw", t);
call.reject("stop_failed: " + t.getClass().getSimpleName() + ": " + t.getMessage());
}
}
}

View file

@ -0,0 +1,143 @@
package chat.vojo.app;
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.util.Log;
import androidx.core.app.NotificationCompat;
/**
* Foreground service kept alive for the duration of an active DM call. Its
* sole job is to promote the process to PROCESS_STATE_FOREGROUND_SERVICE so
* Android doesn't:
* - revoke RECORD_AUDIO via AppOps (API 31+ while-in-use gating);
* - apply background network firewall via netd.
*
* Both revocations were observed in Phase 0 capture on Samsung OneUI API 36:
* mic went Active=false ~5s after screen-off, netd isBlocked=true ~13s after,
* causing Element Call inside the hidden WebView to tear down the LiveKit
* session and the call to drop. Full context in docs/plans/dm_calls_techdebt.md §2.2.
*
* Preconditions enforced by callers:
* - RECORD_AUDIO runtime permission granted (plugin-side check in
* CallForegroundPlugin.start). The manifest declares
* foregroundServiceType="microphone" only, so TYPE_NONE is not a valid
* fallback on API 34+ we never attempt one.
* - JS side gates on useCallJoined so the widget's getUserMedia has already
* prompted for and received the grant by the time we start.
*/
public class CallForegroundService extends Service {
public static final String EXTRA_TITLE = "title";
public static final String EXTRA_BODY = "body";
private static final String CHANNEL_ID = "vojo_calls_ongoing";
// Stable id, distinct from VojoFirebaseMessagingService.SUMMARY_NOTIFICATION_ID
// (Integer.MIN_VALUE) and from per-room call ids (String.hashCode of "call_<roomId>").
private static final int NOTIFICATION_ID = 0x766F6A6F; // "vojo" as ASCII bytes
private static final String TAG = "CallFgs";
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String title = intent != null ? intent.getStringExtra(EXTRA_TITLE) : null;
String body = intent != null ? intent.getStringExtra(EXTRA_BODY) : null;
if (title == null || title.isEmpty()) title = "Активный звонок";
if (body == null) body = "";
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null) {
Log.w(TAG, "onStartCommand: NotificationManager null, cannot start FGS");
stopSelf(startId);
return START_NOT_STICKY;
}
ensureOngoingChannel(nm);
Intent launchIntent = new Intent(this, MainActivity.class)
.setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
int piFlags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
PendingIntent launchPI = PendingIntent.getActivity(this, 0, launchIntent, piFlags);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setOngoing(true)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentIntent(launchPI);
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// API 30+: FOREGROUND_SERVICE_TYPE_MICROPHONE constant exists
// and 3-arg startForeground is available. API 34+ REQUIRES
// the type to match the manifest we declared `microphone`
// and always pass it. RECORD_AUDIO grant is ensured by
// CallForegroundPlugin before this code runs.
startForeground(NOTIFICATION_ID, builder.build(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
Log.d(TAG, "startForeground ok type=microphone");
} else {
// API 24-29: 2-arg form; manifest foregroundServiceType attribute
// is enough for the OS to classify the service correctly.
startForeground(NOTIFICATION_ID, builder.build());
Log.d(TAG, "startForeground ok (pre-R, manifest-driven type)");
}
} catch (Throwable t) {
// If startForeground with TYPE_MICROPHONE throws despite our
// precondition checks (unexpected OEM behavior, race, manifest
// drift), we intentionally do NOT retry with TYPE_NONE that is
// invalid on API 34+ when the manifest declares `microphone`.
// Better to surface the failure and let the call proceed without
// retention than to silently crash with ForegroundServiceTypeException.
Log.e(TAG, "startForeground threw, stopping service without retry", t);
stopSelf(startId);
}
return START_NOT_STICKY;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onDestroy() {
Log.d(TAG, "onDestroy");
// Belt-and-suspenders: if the service is being stopped via stopService
// and the FGS flag is still up, make sure the notification goes away.
// Idempotent if stopForeground was already called elsewhere.
stopForeground(STOP_FOREGROUND_REMOVE);
super.onDestroy();
}
private void ensureOngoingChannel(NotificationManager nm) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"Активные звонки",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("Уведомление во время активного звонка Vojo");
channel.setShowBadge(false);
channel.enableLights(false);
channel.enableVibration(false);
channel.setSound(null, null);
nm.createNotificationChannel(channel);
}
}

View file

@ -13,6 +13,7 @@ public class MainActivity extends BridgeActivity {
// can wire them into the WebView bridge on load. Registering after // can wire them into the WebView bridge on load. Registering after
// super.onCreate would make the plugin invisible to JS until the next relaunch. // super.onCreate would make the plugin invisible to JS until the next relaunch.
registerPlugin(FullScreenIntentPlugin.class); registerPlugin(FullScreenIntentPlugin.class);
registerPlugin(CallForegroundPlugin.class);
EdgeToEdge.enable(this); EdgeToEdge.enable(this);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
} }

View file

@ -1,4 +1,4 @@
import React, { ReactNode, useCallback, useRef } from 'react'; import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { useAtomValue, useSetAtom, useStore } from 'jotai';
import { import {
CallEmbedContextProvider, CallEmbedContextProvider,
@ -12,6 +12,7 @@ import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call'; import { CallEmbed } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useAndroidCallForegroundSync } from '../hooks/useAndroidCallForegroundSync';
function CallUtils({ embed }: { embed: CallEmbed }) { function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbed = useSetAtom(callEmbedAtom);
@ -27,6 +28,17 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
}, [store, embed, setCallEmbed]) }, [store, embed, setCallEmbed])
); );
// Widget failed to prepare (bad network, iframe load error). Without this,
// the embed lingers with joined=false forever, and the Android FGS (keyed
// to callEmbedAtom presence) stays up as a ghost ongoing notification.
useEffect(() => {
const unsubscribe = embed.onPreparingError(() => {
if (store.get(callEmbedAtom) !== embed) return;
setCallEmbed(undefined);
});
return unsubscribe;
}, [embed, store, setCallEmbed]);
return null; return null;
} }
@ -38,6 +50,8 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callEmbedRef = useRef<HTMLDivElement>(null); const callEmbedRef = useRef<HTMLDivElement>(null);
const joined = useCallJoined(callEmbed); const joined = useCallJoined(callEmbed);
useAndroidCallForegroundSync();
const selectedRoom = useSelectedRoom(); const selectedRoom = useSelectedRoom();
const chat = useAtomValue(callChatAtom); const chat = useAtomValue(callChatAtom);
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();

View file

@ -0,0 +1,53 @@
// Keep an Android foreground service alive while a DM call is actively joined.
//
// Lifecycle is keyed to the widget's JoinCall signal (via useCallJoined), not
// the mere presence of callEmbedAtom. Rationale:
//
// 1. RECORD_AUDIO runtime grant. By the time JoinCall fires, Element Call
// inside the iframe has already called getUserMedia, which prompted the
// OS permission dialog (if needed) and user granted. Starting the FGS
// earlier (during `preparing`, before getUserMedia) means RECORD_AUDIO
// may not be granted yet — and on API 34+ startForeground with
// TYPE_MICROPHONE throws SecurityException without it, while a
// fallback to TYPE_NONE is not a valid subset of the manifest-declared
// `microphone` service type. Gating on joined sidesteps the whole
// fallback question.
//
// 2. No ghost notification during preparingError. If the widget fails to
// load, JoinCall never fires, the service never starts, nothing to
// clean up.
//
// Tradeoff: a tiny (1-3s) window between embed creation and JoinCall has no
// retention. The call isn't actually live in that window — media hasn't
// started — so lock-screen there is a non-event; user can retry.
//
// Android-only. Context: docs/plans/dm_calls_techdebt.md §2.2.
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { callEmbedAtom } from '../state/callEmbed';
import { callForegroundService } from '../plugins/call/callForegroundService';
import { useCallJoined } from './useCallEmbed';
import { isAndroidPlatform } from '../utils/capacitor';
export const useAndroidCallForegroundSync = (): void => {
const callEmbed = useAtomValue(callEmbedAtom);
const joined = useCallJoined(callEmbed);
useEffect(() => {
if (!isAndroidPlatform()) return undefined;
if (!joined) return undefined;
callForegroundService.start({ title: 'Активный звонок' }).catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[call-fgs] start failed', err);
});
return () => {
callForegroundService.stop().catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[call-fgs] stop failed', err);
});
};
}, [joined]);
};

View file

@ -0,0 +1,33 @@
// Typed wrapper around the CallForegroundService Capacitor plugin.
//
// The native service (CallForegroundService.java + CallForegroundPlugin.java)
// holds an Android foreground service with foregroundServiceType="microphone"
// for the duration of an active DM call. Without it, Android 14+ revokes the
// mic AppOp and applies background data firewall when the app goes to
// background (e.g. screen lock), killing the call.
//
// Context: docs/plans/dm_calls_techdebt.md §2.2.
import { registerPlugin } from '@capacitor/core';
import { isAndroidPlatform } from '../../utils/capacitor';
interface CallForegroundServicePlugin {
start(options?: { title?: string; body?: string }): Promise<void>;
stop(): Promise<void>;
}
const plugin = registerPlugin<CallForegroundServicePlugin>('CallForegroundService');
// Android-only — the native implementation lives in CallForegroundService.java
// and CallForegroundPlugin.java. Gating on platform here (not just isNativePlatform)
// avoids calling a non-existent plugin path on iOS or any future native target.
export const callForegroundService = {
start(options?: { title?: string; body?: string }): Promise<void> {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.start(options);
},
stop(): Promise<void> {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.stop();
},
};

View file

@ -3,6 +3,8 @@ import { Browser } from '@capacitor/browser';
export const isNativePlatform = (): boolean => Capacitor.isNativePlatform(); export const isNativePlatform = (): boolean => Capacitor.isNativePlatform();
export const isAndroidPlatform = (): boolean => Capacitor.getPlatform() === 'android';
export const openExternalUrl = async (url: string): Promise<void> => { export const openExternalUrl = async (url: string): Promise<void> => {
if (isNativePlatform()) { if (isNativePlatform()) {
await Browser.open({ url }); await Browser.open({ url });