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