diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 78f5fdb7..d69c4483 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -97,10 +97,26 @@
+
+
+
+ android:foregroundServiceType="microphone|phoneCall" />
= 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. -->
+
+
+
+
diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java
index 7339e82d..50dce3b5 100644
--- a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java
+++ b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java
@@ -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
diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundService.java b/android/app/src/main/java/chat/vojo/app/CallForegroundService.java
index 03fde957..c8d2f067 100644
--- a/android/app/src/main/java/chat/vojo/app/CallForegroundService.java
+++ b/android/app/src/main/java/chat/vojo/app/CallForegroundService.java
@@ -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;
diff --git a/android/app/src/main/java/chat/vojo/app/PushStrings.java b/android/app/src/main/java/chat/vojo/app/PushStrings.java
index 08ed2955..56835c5d 100644
--- a/android/app/src/main/java/chat/vojo/app/PushStrings.java
+++ b/android/app/src/main/java/chat/vojo/app/PushStrings.java
@@ -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);
}
diff --git a/android/app/src/main/java/chat/vojo/app/TelecomCallPlugin.java b/android/app/src/main/java/chat/vojo/app/TelecomCallPlugin.java
index 07009c71..e1d82742 100644
--- a/android/app/src/main/java/chat/vojo/app/TelecomCallPlugin.java
+++ b/android/app/src/main/java/chat/vojo/app/TelecomCallPlugin.java
@@ -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();
}
diff --git a/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt b/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt
index 585f8317..0c016138 100644
--- a/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt
+++ b/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt
@@ -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 {
diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java
index db962ff7..0bfa9696 100644
--- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java
+++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java
@@ -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 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 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 data, String messageId) {
+ static boolean upsertIncomingRing(Context ctx, Map 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 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);
diff --git a/src/app/features/call-status/IncomingCallStrip.tsx b/src/app/features/call-status/IncomingCallStrip.tsx
index d2d2c937..17a59bde 100644
--- a/src/app/features/call-status/IncomingCallStrip.tsx
+++ b/src/app/features/call-status/IncomingCallStrip.tsx
@@ -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 = (
);
diff --git a/src/app/hooks/useAndroidCallForegroundSync.ts b/src/app/hooks/useAndroidCallForegroundSync.ts
index 7f99d43c..ad28f500 100644
--- a/src/app/hooks/useAndroidCallForegroundSync.ts
+++ b/src/app/hooks/useAndroidCallForegroundSync.ts
@@ -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]);
diff --git a/src/app/hooks/usePendingCallActionConsumer.ts b/src/app/hooks/usePendingCallActionConsumer.ts
index 8ca818b2..3229943c 100644
--- a/src/app/hooks/usePendingCallActionConsumer.ts
+++ b/src/app/hooks/usePendingCallActionConsumer.ts
@@ -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]);
};
diff --git a/src/app/hooks/useTelecomConnectionSync.ts b/src/app/hooks/useTelecomConnectionSync.ts
index 45c72b4b..d6074b32 100644
--- a/src/app/hooks/useTelecomConnectionSync.ts
+++ b/src/app/hooks/useTelecomConnectionSync.ts
@@ -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) => {
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());
diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts
index 01b3d96b..4f474d94 100644
--- a/src/app/plugins/call/CallControl.ts
+++ b/src/app/plugins/call/CallControl.ts
@@ -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,
diff --git a/src/app/plugins/call/callForegroundService.ts b/src/app/plugins/call/callForegroundService.ts
index 3f6080c1..cf1a06c1 100644
--- a/src/app/plugins/call/callForegroundService.ts
+++ b/src/app/plugins/call/callForegroundService.ts
@@ -28,7 +28,10 @@ export interface IncomingRingUpsert {
}
interface CallForegroundServicePlugin {
- start(options?: { title?: string; body?: string }): Promise;
+ // 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;
stop(): Promise;
upsertIncomingRing(options: IncomingRingUpsert): Promise;
removeIncomingRing(options: { eventId: string }): Promise;
@@ -40,7 +43,7 @@ const plugin = registerPlugin('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 {
+ start(options?: { title?: string; body?: string; phoneCall?: boolean }): Promise {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.start(options);
},