Add microphone foreground service to keep Android DM calls alive under lock screen
This commit is contained in:
parent
fab533e762
commit
e8188d7361
8 changed files with 343 additions and 1 deletions
|
|
@ -73,6 +73,11 @@
|
|||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".CallForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
|
||||
<receiver
|
||||
android:name=".CallCancelReceiver"
|
||||
android:exported="false" />
|
||||
|
|
@ -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. -->
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const joined = useCallJoined(callEmbed);
|
||||
|
||||
useAndroidCallForegroundSync();
|
||||
|
||||
const selectedRoom = useSelectedRoom();
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
|
|
|||
53
src/app/hooks/useAndroidCallForegroundSync.ts
Normal file
53
src/app/hooks/useAndroidCallForegroundSync.ts
Normal 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]);
|
||||
};
|
||||
33
src/app/plugins/call/callForegroundService.ts
Normal file
33
src/app/plugins/call/callForegroundService.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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<void> => {
|
||||
if (isNativePlatform()) {
|
||||
await Browser.open({ url });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue