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