From e8188d73613ca369b5bc40621046351e79dcfaf1 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Thu, 23 Apr 2026 00:04:16 +0300 Subject: [PATCH] Add microphone foreground service to keep Android DM calls alive under lock screen --- android/app/src/main/AndroidManifest.xml | 11 ++ .../chat/vojo/app/CallForegroundPlugin.java | 85 +++++++++++ .../chat/vojo/app/CallForegroundService.java | 143 ++++++++++++++++++ .../main/java/chat/vojo/app/MainActivity.java | 1 + src/app/components/CallEmbedProvider.tsx | 16 +- src/app/hooks/useAndroidCallForegroundSync.ts | 53 +++++++ src/app/plugins/call/callForegroundService.ts | 33 ++++ src/app/utils/capacitor.ts | 2 + 8 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java create mode 100644 android/app/src/main/java/chat/vojo/app/CallForegroundService.java create mode 100644 src/app/hooks/useAndroidCallForegroundSync.ts create mode 100644 src/app/plugins/call/callForegroundService.ts diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 082476d2..0a112bca 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -73,6 +73,11 @@ + + @@ -96,4 +101,10 @@ FLAG_FSI_REQUESTED_BUT_DENIED so the gate passes, even though we never call setFullScreenIntent(). See ADR 2.5-heads-up. --> + + + diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java new file mode 100644 index 00000000..9297d0a0 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java @@ -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()); + } + } +} diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundService.java b/android/app/src/main/java/chat/vojo/app/CallForegroundService.java new file mode 100644 index 00000000..e75a2651 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/CallForegroundService.java @@ -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_"). + 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); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/MainActivity.java b/android/app/src/main/java/chat/vojo/app/MainActivity.java index 11ecdb35..7bc28b3d 100644 --- a/android/app/src/main/java/chat/vojo/app/MainActivity.java +++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java @@ -13,6 +13,7 @@ public class MainActivity extends BridgeActivity { // can wire them into the WebView bridge on load. Registering after // super.onCreate would make the plugin invisible to JS until the next relaunch. registerPlugin(FullScreenIntentPlugin.class); + registerPlugin(CallForegroundPlugin.class); EdgeToEdge.enable(this); super.onCreate(savedInstanceState); } diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 7821671a..6f5ab183 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -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 { CallEmbedContextProvider, @@ -12,6 +12,7 @@ import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { CallEmbed } from '../plugins/call'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; +import { useAndroidCallForegroundSync } from '../hooks/useAndroidCallForegroundSync'; function CallUtils({ embed }: { embed: CallEmbed }) { const setCallEmbed = useSetAtom(callEmbedAtom); @@ -27,6 +28,17 @@ function CallUtils({ embed }: { embed: CallEmbed }) { }, [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; } @@ -38,6 +50,8 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { const callEmbedRef = useRef(null); const joined = useCallJoined(callEmbed); + useAndroidCallForegroundSync(); + const selectedRoom = useSelectedRoom(); const chat = useAtomValue(callChatAtom); const screenSize = useScreenSizeContext(); diff --git a/src/app/hooks/useAndroidCallForegroundSync.ts b/src/app/hooks/useAndroidCallForegroundSync.ts new file mode 100644 index 00000000..aac2cf66 --- /dev/null +++ b/src/app/hooks/useAndroidCallForegroundSync.ts @@ -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]); +}; diff --git a/src/app/plugins/call/callForegroundService.ts b/src/app/plugins/call/callForegroundService.ts new file mode 100644 index 00000000..3206ad25 --- /dev/null +++ b/src/app/plugins/call/callForegroundService.ts @@ -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; + stop(): Promise; +} + +const plugin = registerPlugin('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 { + if (!isAndroidPlatform()) return Promise.resolve(); + return plugin.start(options); + }, + stop(): Promise { + if (!isAndroidPlatform()) return Promise.resolve(); + return plugin.stop(); + }, +}; diff --git a/src/app/utils/capacitor.ts b/src/app/utils/capacitor.ts index f3f24e68..2546a67d 100644 --- a/src/app/utils/capacitor.ts +++ b/src/app/utils/capacitor.ts @@ -3,6 +3,8 @@ import { Browser } from '@capacitor/browser'; export const isNativePlatform = (): boolean => Capacitor.isNativePlatform(); +export const isAndroidPlatform = (): boolean => Capacitor.getPlatform() === 'android'; + export const openExternalUrl = async (url: string): Promise => { if (isNativePlatform()) { await Browser.open({ url });