feat(calls): incoming via Telecom + answer-from-killed (session mgmt, FCM ring bridge, phoneCall FGS, JS sync)

This commit is contained in:
heaven 2026-06-24 00:43:39 +03:00
parent 2c3dfcb965
commit fa09674748
13 changed files with 932 additions and 150 deletions

View file

@ -97,10 +97,26 @@
</intent-filter>
</service>
<!-- Phase C: full-screen incoming-call screen raised over the lockscreen
by the CallStyle full-screen intent. Separate taskAffinity +
excludeFromRecents so it never merges into MainActivity's task or
lingers in Recents. showWhenLocked/turnScreenOn (API 27+ manifest
attrs; the Activity also sets them in code for older levels). -->
<activity
android:name=".IncomingCallActivity"
android:exported="false"
android:theme="@style/IncomingCallTheme"
android:launchMode="singleTask"
android:excludeFromRecents="true"
android:taskAffinity="chat.vojo.app.incomingcall"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:configChanges="orientation|keyboardHidden|screenSize|uiMode|density|smallestScreenSize|screenLayout" />
<service
android:name=".CallForegroundService"
android:exported="false"
android:foregroundServiceType="microphone" />
android:foregroundServiceType="microphone|phoneCall" />
<receiver
android:name=".CallCancelReceiver"
@ -139,10 +155,7 @@
`normal` → auto-granted, no runtime prompt, no Play Permissions
Declaration Form, and does NOT require ROLE_DIALER / becoming the
default dialer. Required before TelecomManager will bind to our
CallsManager / accept addCall. Gated at runtime on SDK_INT >= 26.
(FOREGROUND_SERVICE_PHONE_CALL + the phoneCall FGS type are deferred to
the killed-answer phase so we don't perturb the proven §2.2 microphone
FGS retention before it's validated on-device.) -->
CallsManager / accept addCall. Gated at runtime on SDK_INT >= 26. -->
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<!-- Required for NotificationCompat.CallStyle on API 31+: NMS's
checkDisqualifyingFeatures rejects CallStyle notifications without
@ -153,9 +166,20 @@
requires the user-granted special app-op, surfaced to the user via
FullScreenIntentPlugin / FullScreenIntentPrompt. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- IncomingCallActivity holds a short PARTIAL_WAKE_LOCK (bounded) so the CPU
stays awake while the full-screen ring screen is up. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- 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. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- Phase C: the phoneCall FGS type is the only call-FGS type startable from
a backgrounded/killed process (microphone is while-in-use, phoneCall is
not). The ring path starts it phoneCall-only to retain the process for
the live Telecom session; the active call upgrades to microphone|phoneCall
on JoinCall for §2.2 mic retention. Runtime prerequisite per Android docs
is MANAGE_OWN_CALLS (declared above) — no ROLE_DIALER, no active call
required. protectionLevel `normal` → auto-granted. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
</manifest>

View file

@ -25,10 +25,11 @@ import java.util.Map;
* 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.
* checks defensively. The manifest declares foregroundServiceType="microphone|phoneCall";
* CallForegroundService.computeForegroundType() picks the actual subset from the
* EXTRA_TYPE_* flags grants held. A microphone-typed start without RECORD_AUDIO
* would throw on API 34+, so when the grant is missing we skip the start UNLESS a
* phoneCall type was requested (phoneCall needs no RECORD_AUDIO).
*/
@CapacitorPlugin(name = "CallForegroundService")
public class CallForegroundPlugin extends Plugin {
@ -39,18 +40,21 @@ public class CallForegroundPlugin extends Plugin {
public void start(PluginCall call) {
String title = call.getString("title");
String body = call.getString("body");
// Telecom mode (Phase C) adds the phoneCall type so the active-call FGS
// is consistent with the ring-time one and survives a process restart.
boolean phoneCall = Boolean.TRUE.equals(call.getBoolean("phoneCall", false));
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.
// Defense-in-depth: a microphone-typed FGS without RECORD_AUDIO is
// invalid on API 34+. JS gates on JoinCall (useAndroidCallForegroundSync)
// so the grant is normally in place. If it isn't, we can still start a
// phoneCall-only FGS when requested (phoneCall isn't while-in-use and
// needs no RECORD_AUDIO); otherwise there is no valid type to start, so
// resolve cleanly without starting (call runs without retention).
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)");
if (micPerm != PackageManager.PERMISSION_GRANTED && !phoneCall) {
Log.w(TAG, "start: RECORD_AUDIO not granted and no phoneCall type, skipping FGS");
call.resolve();
return;
}
@ -59,6 +63,7 @@ public class CallForegroundPlugin extends Plugin {
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);
intent.putExtra(CallForegroundService.EXTRA_TYPE_PHONE_CALL, phoneCall);
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent);
@ -121,7 +126,8 @@ public class CallForegroundPlugin extends Plugin {
// extras Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
// on containsKey. Empty string also satisfies the gate; we pass the
// caller's value through verbatim.
boolean seeded = VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
boolean seeded = VojoFirebaseMessagingService.upsertIncomingRing(
getContext().getApplicationContext(), data, messageId);
// Mark in NotificationDedup so a polling fire 15 minutes later
// doesn't post a "Missed call" notification for a ring the user
// already saw live via the in-app strip. Mirrors the FCM-arrival

View file

@ -1,17 +1,20 @@
package chat.vojo.app;
import android.Manifest;
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.PackageManager;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
/**
* Foreground service kept alive for the duration of an active DM call. Its
@ -25,18 +28,39 @@ import androidx.core.app.NotificationCompat;
* causing Element Call inside the hidden WebView to tear down the LiveKit
* session and the call to drop.
*
* Foreground service TYPES (Phase C, docs/plans/telecom_migration.md):
* - microphone the §2.2 retention type. Holds RECORD_AUDIO AppOps active and
* lifts the netd background firewall while a call is live. While-in-use:
* can only be started with RECORD_AUDIO granted and the app foreground (or a
* notification-interaction exemption), so it is started on the JoinCall
* signal (getUserMedia done, app foreground).
* - phoneCall startable from a backgrounded/killed process (NOT while-in-use;
* runtime prerequisite is MANAGE_OWN_CALLS, no active call required). The ring
* path starts it phoneCall-only to retain the process for the live Telecom
* incoming session before the user answers (closes §5.41 answer-from-killed).
*
* The runtime type is computed from the EXTRA_TYPE_* flags the grants actually
* held, and must be a subset of the manifest's "microphone|phoneCall". Default
* (no flags) = microphone only, i.e. the pre-Phase-C behavior so the flag-off
* JS path is byte-for-byte unchanged.
*
* 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.
* - For a microphone-typed start, RECORD_AUDIO must be granted (re-checked here
* defensively). The JS side gates on useCallJoined so the widget's
* getUserMedia has already prompted for and received the grant.
* - A phoneCall-typed start needs only MANAGE_OWN_CALLS (manifest, auto-granted).
*/
public class CallForegroundService extends Service {
public static final String EXTRA_TITLE = "title";
public static final String EXTRA_BODY = "body";
// Include FOREGROUND_SERVICE_TYPE_PHONE_CALL. Set by the Telecom-mode JS join
// path and by the native ring path. Default false legacy microphone-only.
public static final String EXTRA_TYPE_PHONE_CALL = "type_phone_call";
// Include FOREGROUND_SERVICE_TYPE_MICROPHONE (gated on RECORD_AUDIO). Default
// true preserves the pre-Phase-C microphone behavior. The ring path passes
// false (no media yet, possibly killed while-in-use can't be satisfied).
public static final String EXTRA_TYPE_MICROPHONE = "type_microphone";
private static final String CHANNEL_ID = "vojo_calls_ongoing";
// Stable id, distinct from VojoFirebaseMessagingService.SUMMARY_NOTIFICATION_ID
@ -82,14 +106,24 @@ public class CallForegroundService extends Service {
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");
// API 30+: 3-arg startForeground with an explicit type bitmask.
// The type is computed from the requested EXTRA_TYPE_* flags
// the grants held, and is always a subset of the manifest's
// "microphone|phoneCall". API 34+ REQUIRES the runtime type to be
// a non-empty subset of the manifest declaration.
int type = computeForegroundType(intent);
if (type == 0) {
// Nothing valid to start (e.g. microphone requested but
// RECORD_AUDIO not granted, and phoneCall not requested). A
// typed FGS with type 0 is invalid on API 34+; do NOT fall
// back to TYPE_NONE (not a subset of the manifest). Mirror the
// historical "skip without retention" behavior.
Log.w(TAG, "onStartCommand: no valid FGS type for request, skipping");
stopSelf(startId);
return START_NOT_STICKY;
}
startForeground(NOTIFICATION_ID, builder.build(), type);
Log.d(TAG, "startForeground ok type=" + type);
} else {
// API 24-29: 2-arg form; manifest foregroundServiceType attribute
// is enough for the OS to classify the service correctly.
@ -97,12 +131,12 @@ public class CallForegroundService extends Service {
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.
// If startForeground throws despite our precondition checks
// (unexpected OEM behavior, race, manifest drift), we intentionally
// do NOT retry with TYPE_NONE invalid on API 34+ when the manifest
// declares typed services. Surface the failure and let the call
// proceed without retention rather than crash with
// ForegroundServiceTypeException.
Log.e(TAG, "startForeground threw, stopping service without retry", t);
stopSelf(startId);
}
@ -110,6 +144,28 @@ public class CallForegroundService extends Service {
return START_NOT_STICKY;
}
// Build the foregroundServiceType bitmask from the request flags grants
// held. Always a subset of the manifest's "microphone|phoneCall".
// - microphone (default ON): added only if RECORD_AUDIO is granted (it is
// while-in-use; an ungranted microphone type throws on API 34+).
// - phoneCall (default OFF): added when requested; needs only the manifest
// MANAGE_OWN_CALLS / FOREGROUND_SERVICE_PHONE_CALL (auto-granted), and is
// not while-in-use so it is safe even from the background.
private int computeForegroundType(Intent intent) {
boolean wantMic = intent == null || intent.getBooleanExtra(EXTRA_TYPE_MICROPHONE, true);
boolean wantPhoneCall = intent != null && intent.getBooleanExtra(EXTRA_TYPE_PHONE_CALL, false);
int type = 0;
if (wantMic
&& ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED) {
type |= ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE;
}
if (wantPhoneCall) {
type |= ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL;
}
return type;
}
@Override
public IBinder onBind(Intent intent) {
return null;

View file

@ -33,6 +33,16 @@ final class PushStrings {
private PushStrings() {}
/**
* Wrap a base context in the in-app language (the user's app-language pick,
* which may differ from the device locale) so an Activity's resources
* layout @string lookups and getString() alike resolve in the same locale
* as the rest of the messenger. Call from {@code attachBaseContext}.
*/
static Context wrap(Context ctx) {
return forAppLocale(ctx);
}
static String messageFallback(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_new_message);
}

View file

@ -16,10 +16,9 @@ import com.getcapacitor.annotation.CapacitorPlugin;
* arguments into the manager and forwards the manager's {@link
* VojoCallsManager.Listener} events back to JS as Capacitor plugin events.
*
* API gate: VojoCallsManager is @RequiresApi(26). Every method short-circuits
* with {@code telecom_unsupported_api} on API 24-25 so the class is never
* loaded there. JS additionally gates on TELECOM_ENABLED + isAndroidPlatform()
* (telecomCall.ts) so on web/iOS/old-Android nothing here is reached.
* minSdk is 26, so VojoCallsManager (@RequiresApi(26)) is always available; the
* supported() guard is kept as defensive belt-and-suspenders. JS gates on
* isAndroidPlatform() (telecomCall.ts) so on web/iOS nothing here is reached.
*
* Events emitted to JS (addListener in telecomCall.ts):
* telecomAnswer { video: boolean }
@ -82,8 +81,13 @@ public class TelecomCallPlugin extends Plugin implements VojoCallsManager.Listen
call.reject("telecom_unsupported_api");
return;
}
String roomId = call.getString("roomId");
if (roomId == null || roomId.isEmpty()) {
call.reject("missing_roomId");
return;
}
boolean video = Boolean.TRUE.equals(call.getBoolean("video", false));
manager().answerCall(video);
manager().answerCall(roomId, video);
call.resolve();
}

View file

@ -15,6 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicLong
/**
* Self-managed Telecom session manager for Vojo DM calls
@ -88,13 +89,57 @@ class VojoCallsManager private constructor(context: Context) {
private val callsManager = CallsManager(appContext)
@Volatile private var registered = false
// Primary listener — the JS plugin (TelecomCallPlugin), set on load while the
// WebView is alive (foreground / answered).
@Volatile private var listener: Listener? = null
// Fallback listener — a native handler the FCM service installs for a
// ring created from a backgrounded/killed process where no JS is attached
// (Phase C). Used only when the primary listener is null, so the plugin
// never has to race the FCM service for the single slot.
@Volatile private var fallbackListener: Listener? = null
// The live call's CallControlScope (a CoroutineScope), captured while the
// session is active. Imperative methods launch their suspend transactions
// onto it; cleared the moment the call ends.
@Volatile private var controlScope: CallControlScope? = null
// Monotonic session id, bumped when a session BEGINS (startCall) and ENDS
// (teardown). The addCall coroutine captures its value as `myGen`; a late
// onDisconnect / addCall finally / addCall-accept belonging to a SUPERSEDED
// session compares against the current value and no-ops (or self-disconnects)
// instead of clobbering the live session. This closes the B1 race class:
// switch (A→B), fast hangup→new-call, two unserialized startCalls, and a
// teardown landing inside the addCall accept window. controlScope is captured
// async (a binder round-trip after addCall), so "no scope yet" must never be
// read as "no session"; the generation is the authority.
private val sessionGen = AtomicLong(0L)
// Serializes the synchronous start/teardown/answer decisions and the addCall
// block's scope publication so concurrent callers (JS on the Capacitor
// thread, the FCM ring on main) can't interleave gen/room/scope reads. The
// launched suspend transactions run OUTSIDE this lock (never held across a
// suspension point).
private val lock = Any()
// Room id of the live session, used to make startCall idempotent: a
// ring-time incoming session and the JS join-time startCall for the same
// answered room must NOT tear down + re-add (that would drop the answered
// call). Also gates answerCall's room match. Cleared on every session end
// (clearSessionState).
@Volatile private var currentRoomId: String? = null
// Set the moment the call leaves RINGING (our own answerCall, a remote/BT
// onAnswer, or going active). removeIncomingRing reads isAnswered() to decide
// whether tearing down the ring should also disconnect Telecom: a ring that
// was answered must keep its now-active session.
@Volatile private var answered: Boolean = false
// A UI answerCall() that arrived before the addCall block assigned
// controlScope (answer-from-killed race): honored once the session goes live.
// Reset on every session end via clearSessionState().
@Volatile private var pendingAnswer: Boolean = false
@Volatile private var pendingAnswerVideo: Boolean = false
// Latest endpoint list, kept current by the availableEndpoints collector.
// requestEndpoint reads this synchronously instead of availableEndpoints
// .first(): that Flow does NOT replay its current value to a fresh
@ -106,6 +151,58 @@ class VojoCallsManager private constructor(context: Context) {
listener = l
}
/** Install the native fallback listener (FCM ring path, no JS attached). */
fun setFallbackListener(l: Listener?) {
fallbackListener = l
}
// Prefer the JS listener; fall back to the native one when no WebView is up.
// Used for audio/route/mute events (single best target). Lifecycle events
// (answer/disconnect/error) use notifyLifecycle instead — see below.
private fun activeListener(): Listener? = listener ?: fallbackListener
// Deliver a lifecycle event (answer / disconnect / error) to BOTH the JS
// listener AND the native fallback when both are set and distinct. Rationale
// (H1): when the process is alive-in-background the JS plugin listener is
// installed (set in TelecomCallPlugin.load, removed only on destroy) and so
// SHADOWS the fallback — but during an unanswered ring there is no active
// callEmbed, so the JS-side telecom listeners aren't subscribed and the event
// is dropped. Notifying the native fallback too guarantees ring cleanup
// (onDisconnect → removeIncomingRing) and remote-answer app-boot (onAnswer)
// still happen. For an answered/active call the fallback's handler is a no-op
// (its ring eventId is already tombstoned), so the double-dispatch is safe.
private fun notifyLifecycle(action: (Listener) -> Unit) {
val js = listener
val fb = fallbackListener
if (js != null) action(js)
if (fb != null && fb !== js) action(fb)
}
/** True while any Telecom session (ringing or active) is live. */
fun hasSession(): Boolean = controlScope != null
/** True once the call left RINGING (answered locally or by a remote surface). */
fun isAnswered(): Boolean = answered
// Reset per-call state IFF [gen] is still the current session — a late
// callback from a SUPERSEDED session must not wipe the live one's state
// (B1). Called from the addCall onDisconnect callback and finally block.
// Does NOT clear fallbackListener: that is owned by the FCM ring path (set
// per ring in ensureTelecomIncoming, cleared in maybeTeardownTelecomRing).
// Clearing it here would wipe a freshly-installed ring listener during
// startCall's pre-emptive teardown, before the addCall block runs (H1).
private fun clearSessionState(gen: Long) {
synchronized(lock) {
if (gen != sessionGen.get()) return
controlScope = null
currentRoomId = null
answered = false
pendingAnswer = false
pendingAnswerVideo = false
availableEndpointList = emptyList()
}
}
private fun ensureRegistered() {
if (registered) return
try {
@ -116,7 +213,7 @@ class VojoCallsManager private constructor(context: Context) {
Log.d(TAG, "registerAppWithTelecom ok")
} catch (t: Throwable) {
Log.e(TAG, "registerAppWithTelecom failed", t)
listener?.onError("register_failed: " + (t.message ?: t.javaClass.simpleName))
activeListener()?.onError("register_failed: " + (t.message ?: t.javaClass.simpleName))
}
}
@ -128,8 +225,33 @@ class VojoCallsManager private constructor(context: Context) {
fun startCall(roomId: String, displayName: String, video: Boolean, incoming: Boolean) {
ensureRegistered()
if (!registered) return
// Single active call invariant: drop any prior session first.
teardown(DisconnectCause.LOCAL)
val myGen: Long
synchronized(lock) {
// Idempotency by ROOM ID alone (M5). currentRoomId is set
// synchronously at session start — even before the addCall block has
// published controlScope (the binder round-trip). A JS join-time
// startCall that arrives in that window (controlScope still null) must
// still match here and NOT teardown + re-add, which would drop the
// just-answered ring. So we no longer gate on controlScope != null.
if (currentRoomId == roomId) {
Log.d(TAG, "startCall: session already live/forming for room=$roomId")
// Ensure an outgoing/join-time call goes active once the scope
// exists; if it isn't published yet the addCall block (outgoing)
// or pendingAnswer (incoming) will activate it.
if (!incoming && controlScope != null) setCallActive()
return
}
// Single active call invariant: supersede any prior (different-room)
// session. teardownLocked bumps the generation so the old session's
// late callbacks no-op; we then claim a fresh generation for this one.
teardownLocked(DisconnectCause.LOCAL)
myGen = sessionGen.incrementAndGet()
currentRoomId = roomId
// Outgoing calls are never "ringing" from our side; incoming stay
// un-answered until answerCall / a remote onAnswer flips this.
answered = !incoming
}
val attributes = CallAttributesCompat(
displayName,
@ -147,76 +269,175 @@ class VojoCallsManager private constructor(context: Context) {
try {
callsManager.addCall(
attributes,
// onAnswer (remote surface accepted): notify JS to join media.
// onAnswer (remote surface accepted): leave RINGING, notify
// the listener(s) to join media (JS in-app, or the native
// fallback which boots the app for a BT/Auto answer). Ignored
// if this session was superseded (stale generation).
{ callType ->
Log.d(TAG, "onAnswer callType=$callType")
listener?.onAnswer(callType == CallAttributesCompat.CALL_TYPE_VIDEO_CALL)
if (myGen == sessionGen.get()) {
answered = true
val isVideo = callType == CallAttributesCompat.CALL_TYPE_VIDEO_CALL
notifyLifecycle { it.onAnswer(isVideo) }
}
},
// onDisconnect (remote/system ended): notify JS to hang up.
// onDisconnect (remote/system ended): clear this session's
// state (gen-guarded) then notify both listeners so a ring
// ended while alive-in-background still gets native cleanup.
// Stale (superseded) disconnects are dropped so they can't
// tear down the NEW call.
{ cause ->
Log.d(TAG, "onDisconnect cause=${cause.code}")
controlScope = null
listener?.onDisconnect(cause.code)
if (myGen == sessionGen.get()) {
clearSessionState(myGen)
notifyLifecycle { it.onDisconnect(cause.code) }
}
},
// onSetActive (resume from hold).
{
Log.d(TAG, "onSetActive")
listener?.onSetActive()
if (myGen == sessionGen.get()) {
answered = true
activeListener()?.onSetActive()
}
},
// onSetInactive (hold, e.g. interrupting cellular call).
{
Log.d(TAG, "onSetInactive")
listener?.onSetInactive()
if (myGen == sessionGen.get()) activeListener()?.onSetInactive()
},
) {
// this: CallControlScope — a CoroutineScope valid for the
// call's lifetime. The block is NON-suspend; launch suspend
// work onto it. addCall keeps the session alive until the
// call disconnects, so there is nothing to await here.
controlScope = this
Log.d(TAG, "addCall accepted: call session live (incoming=$incoming)")
if (!incoming) {
launch { Log.d(TAG, "setActive -> ${setActive()}") }
val superseded: Boolean
synchronized(lock) {
// A newer startCall bumped the generation during our binder
// round-trip — we are an orphan. Don't publish our scope.
superseded = myGen != sessionGen.get()
if (!superseded) controlScope = this
}
// Audio-route + mute mirrors. These collectors are children
// of this scope and cancel automatically when the call ends.
launch {
currentCallEndpoint.collect { ep ->
Log.d(TAG, "endpoint -> ${typeToRoute(ep.type)}")
listener?.onEndpointChanged(typeToRoute(ep.type))
if (superseded) {
Log.d(TAG, "addCall accepted but superseded — self-disconnecting gen=$myGen")
launch {
try {
disconnect(DisconnectCause(DisconnectCause.LOCAL))
} catch (t: Throwable) {
Log.w(TAG, "orphan disconnect failed", t)
}
}
}
launch {
availableEndpoints.collect { list ->
availableEndpointList = list
listener?.onAvailableEndpointsChanged(
list.map { typeToRoute(it.type) }.distinct().toTypedArray()
)
} else {
Log.d(TAG, "addCall accepted: session live (incoming=$incoming) gen=$myGen")
if (!incoming) {
launch { Log.d(TAG, "setActive -> ${setActive()}") }
} else {
// A UI answer that landed before the session was live —
// honor it now (read under the lock to pair with answerCall).
val doAnswer: Boolean
val answerVideo: Boolean
synchronized(lock) {
doAnswer = pendingAnswer
answerVideo = pendingAnswerVideo
if (doAnswer) {
pendingAnswer = false
answered = true
}
}
if (doAnswer) {
val type = if (answerVideo) CallAttributesCompat.CALL_TYPE_VIDEO_CALL
else CallAttributesCompat.CALL_TYPE_AUDIO_CALL
launch {
try {
answer(type)
} catch (t: Throwable) {
Log.w(TAG, "pending answer failed", t)
}
}
}
}
}
launch {
isMuted.collect { muted ->
Log.d(TAG, "mute -> $muted")
listener?.onMuteStateChanged(muted)
// Audio-route + mute mirrors. These collectors are children
// of this scope and cancel automatically when the call ends.
launch {
currentCallEndpoint.collect { ep ->
Log.d(TAG, "endpoint -> ${typeToRoute(ep.type)}")
if (myGen == sessionGen.get()) {
activeListener()?.onEndpointChanged(typeToRoute(ep.type))
}
}
}
launch {
availableEndpoints.collect { list ->
availableEndpointList = list
if (myGen == sessionGen.get()) {
activeListener()?.onAvailableEndpointsChanged(
list.map { typeToRoute(it.type) }.distinct().toTypedArray()
)
}
}
}
launch {
isMuted.collect { muted ->
Log.d(TAG, "mute -> $muted")
if (myGen == sessionGen.get()) {
activeListener()?.onMuteStateChanged(muted)
}
}
}
}
}
} catch (t: Throwable) {
Log.e(TAG, "addCall failed", t)
listener?.onError("addcall_failed: " + (t.message ?: t.javaClass.simpleName))
if (myGen == sessionGen.get()) {
notifyLifecycle {
it.onError("addcall_failed: " + (t.message ?: t.javaClass.simpleName))
}
}
} finally {
controlScope = null
availableEndpointList = emptyList()
// Gen-guarded: only clears state if WE are still the current
// session (a superseded orphan must not wipe the live one).
clearSessionState(myGen)
}
}
}
/** Accept an incoming Telecom call from our own UI (moves RINGING → ACTIVE). */
fun answerCall(video: Boolean) {
val cs = controlScope ?: return
cs.launch {
/**
* Accept an incoming Telecom call from our own UI (moves RINGING ACTIVE).
* [roomId] guards against answering the wrong session: a second simultaneous
* ring is notification-only (no session of its own), so an answer that
* doesn't match the live session's room is ignored rather than poking the
* first ring's call.
*/
fun answerCall(roomId: String, video: Boolean) {
val cs: CallControlScope?
synchronized(lock) {
// Room-aware: only the ring that actually owns the session can be
// answered. currentRoomId is set synchronously by startCall even
// before the session goes live, so this is reliable during the
// answer-from-killed window.
if (currentRoomId != null && currentRoomId != roomId) {
Log.w(TAG, "answerCall: room mismatch (live=$currentRoomId), ignoring")
return
}
// Flip out of RINGING synchronously and unconditionally so a
// removeIncomingRing bridged right after this keeps the (possibly
// not-yet-live) session instead of disconnecting it.
answered = true
cs = controlScope
if (cs == null) {
// Session not live yet (addCall block hasn't run): record the
// answer; the block honors it once controlScope is assigned.
pendingAnswerVideo = video
pendingAnswer = true
return
}
}
// Non-null here (we returned above otherwise); the elvis is only to
// recover the smart-cast Kotlin drops across the synchronized boundary.
val scope = cs ?: return
scope.launch {
try {
cs.answer(
scope.answer(
if (video) CallAttributesCompat.CALL_TYPE_VIDEO_CALL
else CallAttributesCompat.CALL_TYPE_AUDIO_CALL
)
@ -274,8 +495,23 @@ class VojoCallsManager private constructor(context: Context) {
}
private fun teardown(causeCode: Int) {
val cs = controlScope ?: return
synchronized(lock) { teardownLocked(causeCode) }
}
// Caller MUST hold [lock]. Supersede the current session: bump the generation
// (so any in-flight addCall self-cancels and late callbacks no-op), clear the
// current session's state, and launch the disconnect for the captured scope.
// Does NOT clear fallbackListener (FCM ring path owns it — see clearSessionState).
private fun teardownLocked(causeCode: Int) {
val cs = controlScope
sessionGen.incrementAndGet()
controlScope = null
currentRoomId = null
answered = false
pendingAnswer = false
pendingAnswerVideo = false
availableEndpointList = emptyList()
if (cs == null) return
Log.d(TAG, "teardown: disconnect cause=$causeCode")
cs.launch {
try {

View file

@ -15,6 +15,8 @@ import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.service.notification.StatusBarNotification;
import android.util.Log;
@ -204,6 +206,13 @@ public class VojoFirebaseMessagingService extends MessagingService {
// calls (NotificationManager, AlarmManager) run under the lock.
private static final Object registryLock = new Object();
// Phase C: ring-time Telecom session bookkeeping
// eventId of the ring that currently owns the single self-managed Telecom
// session + phoneCall foreground service. One at a time (DM single ring +
// VojoCallsManager single session). null = none. Guarded by telecomLock.
private static volatile String telecomRingEventId = null;
private static final Object telecomLock = new Object();
private static final class IncomingRing {
final Map<String, String> data;
// Not final a JS-first upsert seeds a null messageId; when FCM
@ -273,7 +282,8 @@ public class VojoFirebaseMessagingService extends MessagingService {
}
// Snapshot the payload FCM internals may recycle the map reference.
Map<String, String> snapshot = new HashMap<>(data);
boolean seeded = upsertIncomingRing(snapshot, remoteMessage.getMessageId());
boolean seeded = upsertIncomingRing(
getApplicationContext(), snapshot, remoteMessage.getMessageId());
if (!seeded) {
dlog("route: call tombstoned, skipping native (event=" + eventId + ")");
return;
@ -1342,13 +1352,20 @@ public class VojoFirebaseMessagingService extends MessagingService {
* registryLock so tombstone-check eviction put stays a single atomic
* section relative to concurrent removeIncomingRing callers.
*/
static boolean upsertIncomingRing(Map<String, String> data, String messageId) {
static boolean upsertIncomingRing(Context ctx, Map<String, String> data, String messageId) {
String eventId = data.get("event_id");
if (eventId == null || eventId.isEmpty()) {
Log.w(TAG, "upsert: missing event_id, drop");
return false;
}
String roomId = data.get("room_id");
// Same-room rings this upsert supersedes (latest-wins eviction below).
// Their Telecom session + phoneCall FGS + full-screen UI are torn down
// AFTER the registryLock is released (Binder / telecomLock must never be
// taken under registryLock) so the new ring can claim the single session
// slot instead of it staying wedged on the evicted eventId (H3).
java.util.List<String> evicted = null;
boolean result;
synchronized (registryLock) {
purgeExpiredTombstones();
Long tombstoneExpiry = ringTombstones.get(eventId);
@ -1396,6 +1413,8 @@ public class VojoFirebaseMessagingService extends MessagingService {
System.currentTimeMillis() + 2 * otherLifetime + RTC_LIFETIME_GRACE_MS);
dlog("upsert: evict older same-room entry event="
+ e.getKey() + " room=" + roomId + " supersededBy=" + eventId);
if (evicted == null) evicted = new java.util.ArrayList<>();
evicted.add(e.getKey());
it.remove();
}
}
@ -1421,12 +1440,23 @@ public class VojoFirebaseMessagingService extends MessagingService {
if (mergedAny) {
dlog("upsert: merged fields event=" + eventId);
}
return true;
result = true;
} else {
ringRegistry.put(eventId, new IncomingRing(data, messageId, System.currentTimeMillis()));
dlog("upsert: seed event=" + eventId + " room=" + roomId);
result = true;
}
ringRegistry.put(eventId, new IncomingRing(data, messageId, System.currentTimeMillis()));
dlog("upsert: seed event=" + eventId + " room=" + roomId);
return true;
}
// Tear down superseded same-room rings outside registryLock. isAnswered()
// inside maybeTeardownTelecomRing keeps an already-answered call; finishFor
// dismisses the evicted ring's full-screen incoming screen if shown.
if (evicted != null && ctx != null) {
for (String evId : evicted) {
maybeTeardownTelecomRing(ctx, evId);
IncomingCallActivity.finishFor(evId);
}
}
return result;
}
/**
@ -1563,6 +1593,14 @@ public class VojoFirebaseMessagingService extends MessagingService {
}
dlog("remove: event=" + eventId + " had=" + (removed != null)
+ " tombstoneWindow=" + tombstoneWindow);
// Phase C: tear down this ring's Telecom session + phoneCall FGS unless it
// was answered (then the active call keeps them). Runs before the
// not-rendered early-return so a non-rendered-but-Telecom-owning ring
// (defensive) still cleans up. No-op when the flag is off / no session.
maybeTeardownTelecomRing(ctx, eventId);
// Dismiss the full-screen incoming screen if it's showing this ring
// (answer / decline / expiry / supersede). No-op if not shown.
IncomingCallActivity.finishFor(eventId);
if (removed == null || removed.renderedAt == 0) return;
String roomId = removed.data.get("room_id");
if (roomId == null) return;
@ -1632,6 +1670,9 @@ public class VojoFirebaseMessagingService extends MessagingService {
// toward the cooldown window; otherwise a chain of silent posts
// would indefinitely defer the next allowed alert.
if (!silent) entry.lastAlertedAt = now;
// Phase C: a backgrounded ring just surfaced register the Telecom
// session + phoneCall FGS (idempotent; no-op when the flag is off).
ensureTelecomIncoming(ctx, entry);
}
}
@ -1663,6 +1704,168 @@ public class VojoFirebaseMessagingService extends MessagingService {
return baseTs + lifetime + RTC_LIFETIME_GRACE_MS < now;
}
//
// Phase C: ring-time Telecom session + phoneCall FGS
// (docs/plans/telecom_migration.md)
//
// When a background CallStyle is rendered for an incoming ring we ALSO
// register a self-managed Telecom INCOMING session and start a phoneCall-typed
// foreground service. The FGS retains the (possibly freshly FCM-woken) process
// so the RINGING session survives until answer, and because phoneCall is not
// a while-in-use type and needs only MANAGE_OWN_CALLS it is already running
// before any answer-load lock, closing the answer-from-killed gap (§5.41). The
// Telecom session gives the call real OS presence (GSM interop) and a path for
// remote (BT/Auto/Wear) answer.
//
// Telecom is the sole call backend (minSdk 26 always available); this runs
// unconditionally on every backgrounded DM ring.
//
// Called from renderOne after a successful background CallStyle post.
// Idempotent per ring; only one ring owns the single session at a time.
private static void ensureTelecomIncoming(Context ctx, IncomingRing entry) {
final String eventId = entry.data.get("event_id");
final String roomId = entry.data.get("room_id");
if (eventId == null || roomId == null) return;
final String messageId = entry.messageId;
final Context appCtx = ctx.getApplicationContext();
VojoCallsManager mgr = VojoCallsManager.getInstance(appCtx);
synchronized (telecomLock) {
if (eventId.equals(telecomRingEventId)) return; // already started for this ring
// Single session: don't disturb an active call or an existing ring
// session. A second simultaneous ring stays notification-only.
if (telecomRingEventId != null || mgr.hasSession()) {
dlog("telecom: session busy, ring stays notification-only event=" + eventId);
return;
}
telecomRingEventId = eventId;
}
final String callerName = firstNonEmpty(
entry.data.get("sender_display_name"),
entry.data.get("room_name"),
mxidLocalPart(entry.data.get("sender")),
"Vojo"
);
// Telecom INCOMING session + native fallback listener (answer/disconnect
// while no JS is attached). VojoCallsManager drives its CallControlScope
// on Dispatchers.Main; registerAppWithTelecom/addCall expect the main
// thread, so dispatch off the FCM background thread. The phoneCall FGS is
// started INSIDE this post, AFTER re-confirming we still own the single
// session slot (M13): starting it before the re-check let a concurrent
// decline/expiry/supersede leave an orphan FGS (or made the abort branch
// stop the wrong call's service). Now the abort path simply returns
// nothing was started.
final VojoCallsManager fmgr = mgr;
new Handler(Looper.getMainLooper()).post(() -> {
// Re-check the slot: a decline/expiry/supersede could have run
// maybeTeardownTelecomRing in the post window (clearing the slot).
// Creating the session now would orphan it and wedge the single-session
// slot forever, so abort instead.
synchronized (telecomLock) {
if (!eventId.equals(telecomRingEventId)) {
dlog("telecom: ring removed before startCall, aborting event=" + eventId);
return;
}
}
// phoneCall-only FGS process retention + answer-from-killed pre-arm.
try {
Intent fgs = new Intent(appCtx, CallForegroundService.class)
.putExtra(CallForegroundService.EXTRA_TITLE, callerName)
.putExtra(CallForegroundService.EXTRA_TYPE_PHONE_CALL, true)
.putExtra(CallForegroundService.EXTRA_TYPE_MICROPHONE, false);
appCtx.startForegroundService(fgs);
} catch (Throwable t) {
Log.w(TAG, "telecom: phoneCall FGS start failed", t);
}
try {
fmgr.setFallbackListener(nativeRingListener(appCtx, eventId, roomId, messageId));
fmgr.startCall(roomId, callerName, false /* video */, true /* incoming */);
dlog("telecom: incoming session started event=" + eventId);
} catch (Throwable t) {
Log.w(TAG, "telecom: startCall(incoming) failed", t);
synchronized (telecomLock) {
if (eventId.equals(telecomRingEventId)) telecomRingEventId = null;
}
stopRingForegroundService(appCtx);
}
});
}
// Called from removeIncomingRing. If the removed ring owned the Telecom
// session, tear it down too UNLESS it was answered, in which case the
// now-active call keeps the session + FGS (the JS hangup path stops them).
private static void maybeTeardownTelecomRing(Context ctx, String eventId) {
synchronized (telecomLock) {
if (!eventId.equals(telecomRingEventId)) return;
telecomRingEventId = null;
}
VojoCallsManager mgr = VojoCallsManager.getInstance(ctx.getApplicationContext());
if (mgr.isAnswered()) {
dlog("telecom: ring removed but answered — active call keeps session");
return;
}
// Un-answered ring removed (decline / expiry / suppress) end Telecom + FGS.
try {
mgr.setFallbackListener(null);
mgr.endCall();
} catch (Throwable t) {
Log.w(TAG, "telecom: endCall on ring teardown threw", t);
}
stopRingForegroundService(ctx);
}
private static void stopRingForegroundService(Context ctx) {
try {
ctx.stopService(new Intent(ctx, CallForegroundService.class));
} catch (Throwable t) {
Log.w(TAG, "telecom: stop phoneCall FGS threw", t);
}
}
// Native handler for a ring's Telecom session created from a process with no
// JS attached. VojoCallsManager uses it only while its primary (JS plugin)
// listener is null; once the WebView loads, the JS listener takes precedence.
private static VojoCallsManager.Listener nativeRingListener(
Context appCtx, String eventId, String roomId, String messageId
) {
return new VojoCallsManager.Listener() {
@Override
public void onAnswer(boolean video) {
// A remote surface (BT/Auto/Wear/system) accepted while no JS is
// up. Boot the app via the same Answer intent the CallStyle tap
// uses; the JS pendingCallAction consumer then joins the WebView.
// NOTE: background activity launch is OEM/version-restricted; the
// primary answer-from-killed path is the CallStyle tap (a user
// notification interaction, always allowed). Full BT/Auto
// answer-from-killed is completed in Phase D.
try {
buildActionPI(appCtx, ("ans_" + eventId).hashCode(),
"answer", roomId, eventId, messageId).send();
} catch (Throwable t) {
Log.w(TAG, "telecom: native onAnswer launch threw", t);
}
}
@Override
public void onDisconnect(int causeCode) {
// Caller/system ended while no JS attached clean up ring + FGS.
removeIncomingRing(appCtx, eventId);
}
@Override
public void onError(String message) {
Log.w(TAG, "telecom: native ring error: " + message);
removeIncomingRing(appCtx, eventId);
}
@Override public void onSetActive() { }
@Override public void onSetInactive() { }
@Override public void onMuteStateChanged(boolean muted) { }
@Override public void onEndpointChanged(String route) { }
@Override public void onAvailableEndpointsChanged(String[] routes) { }
};
}
//
// Post path (shared between FCM-direct and registry-render)
//
@ -1732,9 +1935,10 @@ public class VojoFirebaseMessagingService extends MessagingService {
PendingIntent declinePI = buildDeclineBroadcastPI(
ctx, declineReq, roomId, notifEventId, tag, notifId
);
PendingIntent launchPI = buildActionPI(
ctx, launchReq, null, roomId, notifEventId, messageId
);
// Body-tap + full-screen intent the native full-screen IncomingCallActivity
// over the lockscreen (Telecom is the sole call backend now).
PendingIntent launchPI =
buildIncomingActivityPI(ctx, launchReq, roomId, notifEventId, messageId, callerName);
Person caller = new Person.Builder().setName(callerName).build();
@ -1830,6 +2034,34 @@ public class VojoFirebaseMessagingService extends MessagingService {
intent.putExtra("room_id", roomId);
intent.putExtra("notif_event_id", notifEventId);
if (callAction != null) intent.putExtra("call_action", callAction);
// Answering from the CallStyle shade should be consistent with the
// full-screen IncomingCallActivity: request over-lock so a locked device
// answers without a forced unlock. MainActivity re-checks the keyguard,
// so this is a no-op when the device is already unlocked.
if ("answer".equals(callAction)) intent.putExtra("over_lock", true);
int flags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
return PendingIntent.getActivity(ctx, requestCode, intent, flags);
}
// Full-screen-intent target for the native incoming-call screen (Phase C).
// FLAG_ACTIVITY_NEW_TASK is required for an FSI activity launch; the separate
// taskAffinity (manifest) keeps it out of MainActivity's task.
private static PendingIntent buildIncomingActivityPI(
Context ctx,
int requestCode,
String roomId,
String notifEventId,
String messageId,
String callerName
) {
Intent intent = new Intent(ctx, IncomingCallActivity.class)
.setAction(Intent.ACTION_MAIN)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
intent.putExtra("room_id", roomId);
intent.putExtra("notif_event_id", notifEventId);
intent.putExtra("google.message_id", messageId != null ? messageId : "");
intent.putExtra("caller_name", callerName != null ? callerName : "");
int flags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
return PendingIntent.getActivity(ctx, requestCode, intent, flags);

View file

@ -13,6 +13,7 @@ import { UserAvatar } from '../../components/user-avatar';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { getCanonicalAliasOrRoomId, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
import { telecomCall } from '../../plugins/call/telecomCall';
import { getDirectRoomPath } from '../../pages/pathUtils';
import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls';
import { getIncomingCallKey } from '../../utils/rtcNotification';
@ -55,18 +56,29 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
// same semantic action — switch to the call room — so it should not
// grow the back stack either.
navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId)), { replace: true });
// Move a ring-time Telecom session (Phase C) RINGING → ACTIVE and mark it
// answered before the REMOVE below bridges removeIncomingRing, so the active
// session + phoneCall FGS survive. No-op when Telecom is off / no session.
telecomCall.answer(call.roomId).catch(() => {
/* best-effort — the JS join below owns the media */
});
const dropRing = () => {
const evId = call.notifEvent.getId();
if (evId) {
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId: evId });
return;
}
setIncoming({ type: 'REMOVE', key: callKey });
};
switchOrStartDmCall(call.roomId)
.then(() => {
const evId = call.notifEvent.getId();
if (evId) {
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId: evId });
return;
}
setIncoming({ type: 'REMOVE', key: callKey });
})
.then(dropRing)
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[call] strip answer switch/start failed', err);
// Join failed after we answered — tear down the Telecom session + FGS
// (endCall flips it out of "answered", dropRing ends it via the registry).
telecomCall.endCall().catch(() => undefined);
dropRing();
});
};
@ -91,21 +103,11 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
const native = isNativePlatform();
// Decline left, Accept right — one consistent layout with the native
// full-screen ring (activity_incoming_call.xml) so the destructive action is
// never where the other surface puts Accept (mis-tap risk). (M7)
const buttons = (
<div className={classNames(css.RingButtons, native && css.RingButtonsFull)}>
<Button
className={native ? css.RingButtonGrow : undefined}
variant="Success"
fill="Solid"
size={native ? '500' : '400'}
radii="400"
onClick={handleAnswer}
before={<Icon size="200" src={CallPhoneIcon} filled />}
>
<Text size={native ? 'B500' : 'B400'} truncate>
{t('Call.accept')}
</Text>
</Button>
<Button
className={native ? css.RingButtonGrow : undefined}
variant="Critical"
@ -119,6 +121,19 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
{t('Call.decline')}
</Text>
</Button>
<Button
className={native ? css.RingButtonGrow : undefined}
variant="Success"
fill="Solid"
size={native ? '500' : '400'}
radii="400"
onClick={handleAnswer}
before={<Icon size="200" src={CallPhoneIcon} filled />}
>
<Text size={native ? 'B500' : 'B400'} truncate>
{t('Call.accept')}
</Text>
</Button>
</div>
);

View file

@ -23,12 +23,10 @@
//
// Android-only.
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { callEmbedAtom, callSpeakerAtom } from '../state/callEmbed';
import { callForegroundService } from '../plugins/call/callForegroundService';
import { callAudioRoute } from '../plugins/call/callAudioRoute';
import { telecomCall } from '../plugins/call/telecomCall';
import { useCallJoined } from './useCallEmbed';
import { isAndroidPlatform } from '../utils/capacitor';
@ -36,29 +34,48 @@ export const useAndroidCallForegroundSync = (): void => {
const callEmbed = useAtomValue(callEmbedAtom);
const joined = useCallJoined(callEmbed);
const setSpeaker = useSetAtom(callSpeakerAtom);
const hadEmbedRef = useRef(false);
// Backstop teardown keyed on the embed lifecycle, NOT on `joined`. An answered
// call (answer-from-killed) starts the phoneCall FGS natively at ring time but
// may never reach JoinCall (widget preparingError / EC Close / LiveKit fail).
// The joined-gated cleanup below never runs in that case, so the FGS would leak
// until the process dies. When an embed that existed goes away, stop the FGS
// unconditionally (idempotent with the joined cleanup for the normal path).
useEffect(() => {
if (!isAndroidPlatform()) return;
if (callEmbed) {
hadEmbedRef.current = true;
return;
}
if (hadEmbedRef.current) {
hadEmbedRef.current = false;
callForegroundService.stop().catch(() => undefined);
}
}, [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);
});
// Tag the active-call FGS with both types: microphone for §2.2 mic
// retention under lock, and phoneCall so it matches the ring-time phoneCall
// FGS (clean handoff on answer-from-killed) and stays restartable if the
// process is rebuilt mid-call.
callForegroundService
.start({ title: 'Активный звонок', phoneCall: true })
.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);
});
// Restore the platform-default audio route so the next call starts on
// the earpiece and no other app inherits a forced loudspeaker. The
// WebView owns the audio session; we only undo our output override.
// Under Telecom the OS owns route cleanup — calling
// clearCommunicationDevice ourselves would fight it, so skip it there.
if (!telecomCall.enabled()) {
callAudioRoute.clear();
}
// Telecom owns the audio route + its cleanup; we only reset the UI atom so
// the next call's speaker toggle starts from earpiece.
setSpeaker(false);
};
}, [joined, setSpeaker]);

View file

@ -1,11 +1,15 @@
import { useEffect } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { useAtomValue, useSetAtom, useStore } from 'jotai';
import { App } from '@capacitor/app';
import { pendingCallActionAtom } from '../state/pendingCallAction';
import { incomingCallsAtom } from '../state/incomingCalls';
import { callEmbedAtom, overLockCallAtom } from '../state/callEmbed';
import { useMatrixClient } from './useMatrixClient';
import { isNativePlatform } from '../utils/capacitor';
import { useSwitchOrStartDmCall } from './useSwitchOrStartDmCall';
import { telecomCall } from '../plugins/call/telecomCall';
import { overLockCall } from '../plugins/call/overLockCall';
import { callForegroundService } from '../plugins/call/callForegroundService';
// Consumes pending call actions emitted by the native Android push-action
// listener (see usePushNotifications.ts). Must be mounted inside CallEmbedProvider
@ -14,7 +18,9 @@ export const usePendingCallActionConsumer = (): void => {
const pending = useAtomValue(pendingCallActionAtom);
const setPending = useSetAtom(pendingCallActionAtom);
const setIncoming = useSetAtom(incomingCallsAtom);
const setOverLock = useSetAtom(overLockCallAtom);
const switchOrStartDmCall = useSwitchOrStartDmCall();
const store = useStore();
const mx = useMatrixClient();
useEffect(() => {
@ -22,17 +28,89 @@ export const usePendingCallActionConsumer = (): void => {
if (pending.kind === 'answer') {
const { roomId, notifEventId } = pending;
setPending(undefined);
// Move a ring-time Telecom session (created natively from the FCM ring,
// Phase C) RINGING → ACTIVE. This synchronously marks the session answered
// so the REMOVE_BY_* below — which bridges removeIncomingRing — keeps the
// now-active session + its phoneCall FGS instead of tearing them down.
// No-op when Telecom is off or no native ring session exists.
telecomCall.answer(roomId).then(
() => {
// Clear the native ring slot (telecomRingEventId) directly. A
// cold-start answer-from-killed ring may not be in incomingCallsAtom
// yet, so the atom REMOVE_BY_* below would be a no-op and the slot
// would stick ~32s (until the expiry alarm), degrading the next call
// to notification-only (M12). answer() above set the session answered,
// so maybeTeardownTelecomRing keeps the now-active session + its FGS.
if (notifEventId) {
callForegroundService.removeIncomingRing(notifEventId).catch(() => undefined);
}
},
() => {
/* best-effort — JS join below is the source of truth for media */
}
);
// Close the §2.2 mic-retention window on answer-from-killed: the ring-time
// FGS is phoneCall-only, and the microphone type is otherwise added only on
// JoinCall (seconds later) — leaving mic AppOps unprotected under the lock in
// between. Re-issue the FGS with the microphone type NOW. computeForegroundType
// intersects with the RECORD_AUDIO grant natively, so this is upgrade-or-noop
// (stays phoneCall-only if the grant is somehow missing — never throws). (M11)
callForegroundService
.start({ phoneCall: true, title: mx.getRoom(roomId)?.name || undefined })
.catch(() => undefined);
// If the user answered on a locked device, MainActivity is showing over the
// keyguard (over_lock) — enter the full-screen over-lock call screen so the
// rest of the app stays covered (privacy) while audio joins without unlock.
// `left` guards a late getState() from re-showing the overlay after we've
// already exited (join failure / unlock racing the bridge round-trip).
let left = false;
const leaveOverLock = () => {
left = true;
overLockCall.exit().then(
() => setOverLock(false),
() => setOverLock(false)
);
};
overLockCall.getState().then(
(s) => {
if (!left && s.overLock) setOverLock(true);
},
() => undefined
);
const dropRing = () => {
if (notifEventId) {
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId });
return;
}
setIncoming({ type: 'REMOVE_BY_ROOM', roomId });
};
switchOrStartDmCall(roomId)
.then(() => {
if (notifEventId) {
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId });
return;
dropRing();
// switchOrStartDmCall resolves (not rejects) when the room isn't in the
// store yet (cold-start answer-from-killed). With no embed the over-lock
// screen would strand the user on a spinner, so bail out of over-lock.
// No embed means the joined/embed-lifecycle teardowns won't run either,
// so end the Telecom session AND stop the ring-time phoneCall FGS here
// (both leak otherwise — H2).
if (!store.get(callEmbedAtom)) {
telecomCall.endCall().catch(() => undefined);
callForegroundService.stop().catch(() => undefined);
leaveOverLock();
}
setIncoming({ type: 'REMOVE_BY_ROOM', roomId });
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[call] native answer switch/start failed', err);
// Join failed after we answered the Telecom session — tear it down so
// the phoneCall FGS + system call presence don't leak. endCall() flips
// the native session out of "answered"; stop() ends the FGS directly
// (the atom dropRing below is a no-op for a cold-start ring not in the
// atom, so don't depend on it for FGS teardown — H2).
telecomCall.endCall().catch(() => undefined);
callForegroundService.stop().catch(() => undefined);
leaveOverLock();
dropRing();
});
return;
}
@ -64,5 +142,5 @@ export const usePendingCallActionConsumer = (): void => {
minimize();
}
);
}, [pending, setPending, setIncoming, switchOrStartDmCall, mx]);
}, [pending, setPending, setIncoming, setOverLock, switchOrStartDmCall, store, mx]);
};

View file

@ -1,26 +1,22 @@
// Drive a self-managed Telecom call session alongside the active DM call
// (docs/plans/telecom_migration.md, Phase B). Mirror of
// useAndroidCallForegroundSync: keyed to the widget's JoinCall signal (via
// useCallJoined), not the mere presence of callEmbedAtom.
// (docs/plans/telecom_migration.md). Mirror of useAndroidCallForegroundSync:
// keyed to the widget's JoinCall signal (via useCallJoined), not the mere
// presence of callEmbedAtom.
//
// What this gives us once TELECOM_ENABLED is flipped on:
// What this gives us:
// - the Vojo call is registered with the OS as a real call → cellular-call
// interop (an incoming GSM call holds/arbitrates against it), audio-focus
// arbitration, and a system "ongoing call" presence;
// - a remote/system Telecom disconnect (the user takes a GSM call, or a
// Bluetooth/Android-Auto "end call") tears the Vojo call down too;
// - Telecom owns the audio route, so its active endpoint is mirrored into the
// speaker-toggle atom (earpiece / speaker / Bluetooth all reflected).
// speaker-toggle atom (earpiece / speaker / Bluetooth all reflected). The
// legacy AudioRoute plugin has been retired — Telecom is the only router.
//
// §4 (validated on Samsung One UI: the call gets system call-audio focus and
// the route endpoint flow emits cleanly): Telecom owns routing. The speaker
// toggle now drives Telecom endpoints (see useCallSpeaker) and AudioRoutePlugin
// is kept off the audio session while a Telecom call is live.
//
// Android-only, and additionally gated on TELECOM_ENABLED + API>=26 inside
// telecomCall — on web / iOS / old Android every call here is a no-op.
// Android-only (gated on isAndroidPlatform() inside telecomCall) — on web / iOS
// every call here is a no-op.
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useAtomValue, useSetAtom, useStore } from 'jotai';
import { callEmbedAtom, callSpeakerAtom } from '../state/callEmbed';
import { telecomCall, type TelecomRoute } from '../plugins/call/telecomCall';
@ -31,12 +27,39 @@ export const useTelecomConnectionSync = (): void => {
const joined = useCallJoined(callEmbed);
const store = useStore();
const setSpeaker = useSetAtom(callSpeakerAtom);
const hadEmbedRef = useRef(false);
// Backstop teardown keyed on the embed lifecycle, NOT on `joined`. An answered
// call may never reach JoinCall (widget preparingError / EC Close / LiveKit
// fail), so the joined-gated cleanup below never runs and the (answered)
// Telecom session would leak — hasSession() stays true and degrades every
// later call to notification-only. When an embed that existed goes away, end
// the session unconditionally (endCall is idempotent).
useEffect(() => {
if (!telecomCall.enabled()) return;
if (callEmbed) {
hadEmbedRef.current = true;
return;
}
if (hadEmbedRef.current) {
hadEmbedRef.current = false;
telecomCall.endCall().catch(() => undefined);
}
}, [callEmbed]);
useEffect(() => {
if (!telecomCall.enabled()) return undefined;
if (!callEmbed || !joined) return undefined;
let cancelled = false;
// Saved widget mic state at the moment Telecom puts us on hold (interrupting
// GSM call), so onSetActive can restore exactly what the user had — not blanket
// unmute. null = not currently held.
let micBeforeHold: boolean | null = null;
// True once Telecom itself muted us (hardware/BT/Auto mute button). Gates the
// auto-unmute below so a spurious/initial "not muted" can never force-unmute a
// user who joined muted — see the telecomMute listener.
let telecomMutedUs = false;
const removers: Array<() => void> = [];
const track = (p: ReturnType<typeof telecomCall.addListener>) => {
p.then((handle) => {
@ -77,12 +100,77 @@ export const useTelecomConnectionSync = (): void => {
// Telecom owns the route; reflect the active endpoint in the speaker-toggle
// atom so the UI tracks reality (speaker vs earpiece/Bluetooth/wired).
// Current-embed guard: a late emission from a session that's been superseded
// must not write the new call's speaker atom.
track(
telecomCall.addListener('telecomEndpoint', (data: { route: TelecomRoute }) => {
if (store.get(callEmbedAtom) !== callEmbed) return;
setSpeaker(data.route === 'SPEAKER');
})
);
// Mute mirror (one source of truth). On a self-managed call the OS-level
// mute only ever changes via a hardware / Bluetooth / Android Auto mute
// button (there is no system call-UI mute affordance), so every change is
// user-initiated. Direction is Telecom → widget only: core-telecom exposes
// isMuted as a read-only Flow with no setter.
//
// We mirror MUTE unconditionally, but only auto-UNMUTE the widget when
// Telecom is the one that muted it (telecomMutedUs). This means a spurious or
// initial "not muted" emission can NEVER force-unmute someone who joined
// muted — correct whether or not core-telecom's isMuted replays its current
// value (its API type is a plain, non-replaying Flow, the same as
// availableEndpoints), so it does not depend on library internals.
// setMicrophoneEnabled is idempotent, so mirroring can't bounce.
track(
telecomCall.addListener('telecomMute', (data: { muted: boolean }) => {
if (store.get(callEmbedAtom) !== callEmbed) return;
if (data.muted) {
telecomMutedUs = true;
callEmbed.control.setMicrophoneEnabled(false);
} else if (telecomMutedUs) {
telecomMutedUs = false;
callEmbed.control.setMicrophoneEnabled(true);
}
})
);
// GSM interop: Telecom holds the Vojo call when an interrupting cellular
// call goes active (onSetInactive) and resumes it after (onSetActive). The
// media lives in the WebView, which Telecom can't pause directly, so we mute
// the widget mic on hold (the cellular peer must not hear the Vojo room) and
// restore the user's prior mic state on resume.
track(
telecomCall.addListener('telecomSetInactive', () => {
if (store.get(callEmbedAtom) !== callEmbed) return;
if (micBeforeHold === null) micBeforeHold = callEmbed.control.microphone;
callEmbed.control.setMicrophoneEnabled(false);
})
);
track(
telecomCall.addListener('telecomSetActive', () => {
if (store.get(callEmbedAtom) !== callEmbed) return;
if (micBeforeHold !== null) {
callEmbed.control.setMicrophoneEnabled(micBeforeHold);
micBeforeHold = null;
}
})
);
// A fatal Telecom error (register / addCall failure) means the OS call
// backend is gone for this session — tear the Vojo call down rather than
// leave it running with no system call presence / audio arbitration.
track(
telecomCall.addListener('telecomError', (data: { message: string }) => {
if (store.get(callEmbedAtom) !== callEmbed) return;
// eslint-disable-next-line no-console
console.warn('[telecom] session error, ending call', data.message);
callEmbed.hangup().catch(() => {
/* widget already gone */
});
})
);
return () => {
cancelled = true;
removers.forEach((remove) => remove());

View file

@ -151,6 +151,19 @@ export class CallControl extends EventEmitter implements CallControlState {
return this.setMediaState(payload);
}
// Drive the widget mic to a specific state, idempotently. Used to mirror the
// Telecom system mute (hardware / Bluetooth / Auto mute button) onto the
// Element Call mic so the two surfaces never disagree (Phase C). The widget is
// the single source of truth for the actual mic; this only reconciles it to
// what the OS reports. A loop-guard at the call site stops the resulting
// onMediaState echo from bouncing back.
public setMicrophoneEnabled(enabled: boolean): void {
if (this.microphone === enabled) return;
// Swallow the transport rejection: if the iframe is torn down mid-call the
// send rejects, and this is a best-effort mirror, not user-initiated.
this.toggleMicrophone().catch(() => undefined);
}
public toggleVideo() {
const payload: ElementMediaStatePayload = {
audio_enabled: this.microphone,

View file

@ -28,7 +28,10 @@ export interface IncomingRingUpsert {
}
interface CallForegroundServicePlugin {
start(options?: { title?: string; body?: string }): Promise<void>;
// phoneCall — Telecom mode (Phase C): also tag the FGS with the phoneCall type
// so it matches the ring-time service and survives a process restart. Omitted
// / false keeps the legacy microphone-only behavior.
start(options?: { title?: string; body?: string; phoneCall?: boolean }): Promise<void>;
stop(): Promise<void>;
upsertIncomingRing(options: IncomingRingUpsert): Promise<void>;
removeIncomingRing(options: { eventId: string }): Promise<void>;
@ -40,7 +43,7 @@ const plugin = registerPlugin<CallForegroundServicePlugin>('CallForegroundServic
// 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> {
start(options?: { title?: string; body?: string; phoneCall?: boolean }): Promise<void> {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.start(options);
},