feat(calls): incoming via Telecom + answer-from-killed (session mgmt, FCM ring bridge, phoneCall FGS, JS sync)
This commit is contained in:
parent
2c3dfcb965
commit
fa09674748
13 changed files with 932 additions and 150 deletions
|
|
@ -97,10 +97,26 @@
|
|||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Phase C: full-screen incoming-call screen raised over the lockscreen
|
||||
by the CallStyle full-screen intent. Separate taskAffinity +
|
||||
excludeFromRecents so it never merges into MainActivity's task or
|
||||
lingers in Recents. showWhenLocked/turnScreenOn (API 27+ manifest
|
||||
attrs; the Activity also sets them in code for older levels). -->
|
||||
<activity
|
||||
android:name=".IncomingCallActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/IncomingCallTheme"
|
||||
android:launchMode="singleTask"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity="chat.vojo.app.incomingcall"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|uiMode|density|smallestScreenSize|screenLayout" />
|
||||
|
||||
<service
|
||||
android:name=".CallForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
android:foregroundServiceType="microphone|phoneCall" />
|
||||
|
||||
<receiver
|
||||
android:name=".CallCancelReceiver"
|
||||
|
|
@ -139,10 +155,7 @@
|
|||
`normal` → auto-granted, no runtime prompt, no Play Permissions
|
||||
Declaration Form, and does NOT require ROLE_DIALER / becoming the
|
||||
default dialer. Required before TelecomManager will bind to our
|
||||
CallsManager / accept addCall. Gated at runtime on SDK_INT >= 26.
|
||||
(FOREGROUND_SERVICE_PHONE_CALL + the phoneCall FGS type are deferred to
|
||||
the killed-answer phase so we don't perturb the proven §2.2 microphone
|
||||
FGS retention before it's validated on-device.) -->
|
||||
CallsManager / accept addCall. Gated at runtime on SDK_INT >= 26. -->
|
||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||
<!-- Required for NotificationCompat.CallStyle on API 31+: NMS's
|
||||
checkDisqualifyingFeatures rejects CallStyle notifications without
|
||||
|
|
@ -153,9 +166,20 @@
|
|||
requires the user-granted special app-op, surfaced to the user via
|
||||
FullScreenIntentPlugin / FullScreenIntentPrompt. -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<!-- IncomingCallActivity holds a short PARTIAL_WAKE_LOCK (bounded) so the CPU
|
||||
stays awake while the full-screen ring screen is up. -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- DM call lock-screen retention: CallForegroundService keeps the call
|
||||
process foregrounded under lock so AppOps doesn't revoke RECORD_AUDIO
|
||||
and netd doesn't block background network. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<!-- Phase C: the phoneCall FGS type is the only call-FGS type startable from
|
||||
a backgrounded/killed process (microphone is while-in-use, phoneCall is
|
||||
not). The ring path starts it phoneCall-only to retain the process for
|
||||
the live Telecom session; the active call upgrades to microphone|phoneCall
|
||||
on JoinCall for §2.2 mic retention. Runtime prerequisite per Android docs
|
||||
is MANAGE_OWN_CALLS (declared above) — no ROLE_DIALER, no active call
|
||||
required. protectionLevel `normal` → auto-granted. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import android.media.RingtoneManager;
|
|||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.service.notification.StatusBarNotification;
|
||||
import android.util.Log;
|
||||
|
||||
|
|
@ -204,6 +206,13 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
// calls (NotificationManager, AlarmManager) run under the lock.
|
||||
private static final Object registryLock = new Object();
|
||||
|
||||
// ── Phase C: ring-time Telecom session bookkeeping ──
|
||||
// eventId of the ring that currently owns the single self-managed Telecom
|
||||
// session + phoneCall foreground service. One at a time (DM single ring +
|
||||
// VojoCallsManager single session). null = none. Guarded by telecomLock.
|
||||
private static volatile String telecomRingEventId = null;
|
||||
private static final Object telecomLock = new Object();
|
||||
|
||||
private static final class IncomingRing {
|
||||
final Map<String, String> data;
|
||||
// Not final — a JS-first upsert seeds a null messageId; when FCM
|
||||
|
|
@ -273,7 +282,8 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
}
|
||||
// Snapshot the payload — FCM internals may recycle the map reference.
|
||||
Map<String, String> snapshot = new HashMap<>(data);
|
||||
boolean seeded = upsertIncomingRing(snapshot, remoteMessage.getMessageId());
|
||||
boolean seeded = upsertIncomingRing(
|
||||
getApplicationContext(), snapshot, remoteMessage.getMessageId());
|
||||
if (!seeded) {
|
||||
dlog("route: call tombstoned, skipping native (event=" + eventId + ")");
|
||||
return;
|
||||
|
|
@ -1342,13 +1352,20 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
* registryLock so tombstone-check → eviction → put stays a single atomic
|
||||
* section relative to concurrent removeIncomingRing callers.
|
||||
*/
|
||||
static boolean upsertIncomingRing(Map<String, String> data, String messageId) {
|
||||
static boolean upsertIncomingRing(Context ctx, Map<String, String> data, String messageId) {
|
||||
String eventId = data.get("event_id");
|
||||
if (eventId == null || eventId.isEmpty()) {
|
||||
Log.w(TAG, "upsert: missing event_id, drop");
|
||||
return false;
|
||||
}
|
||||
String roomId = data.get("room_id");
|
||||
// Same-room rings this upsert supersedes (latest-wins eviction below).
|
||||
// Their Telecom session + phoneCall FGS + full-screen UI are torn down
|
||||
// AFTER the registryLock is released (Binder / telecomLock must never be
|
||||
// taken under registryLock) so the new ring can claim the single session
|
||||
// slot instead of it staying wedged on the evicted eventId (H3).
|
||||
java.util.List<String> evicted = null;
|
||||
boolean result;
|
||||
synchronized (registryLock) {
|
||||
purgeExpiredTombstones();
|
||||
Long tombstoneExpiry = ringTombstones.get(eventId);
|
||||
|
|
@ -1396,6 +1413,8 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
System.currentTimeMillis() + 2 * otherLifetime + RTC_LIFETIME_GRACE_MS);
|
||||
dlog("upsert: evict older same-room entry event="
|
||||
+ e.getKey() + " room=" + roomId + " supersededBy=" + eventId);
|
||||
if (evicted == null) evicted = new java.util.ArrayList<>();
|
||||
evicted.add(e.getKey());
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
|
|
@ -1421,12 +1440,23 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
if (mergedAny) {
|
||||
dlog("upsert: merged fields event=" + eventId);
|
||||
}
|
||||
return true;
|
||||
result = true;
|
||||
} else {
|
||||
ringRegistry.put(eventId, new IncomingRing(data, messageId, System.currentTimeMillis()));
|
||||
dlog("upsert: seed event=" + eventId + " room=" + roomId);
|
||||
result = true;
|
||||
}
|
||||
ringRegistry.put(eventId, new IncomingRing(data, messageId, System.currentTimeMillis()));
|
||||
dlog("upsert: seed event=" + eventId + " room=" + roomId);
|
||||
return true;
|
||||
}
|
||||
// Tear down superseded same-room rings outside registryLock. isAnswered()
|
||||
// inside maybeTeardownTelecomRing keeps an already-answered call; finishFor
|
||||
// dismisses the evicted ring's full-screen incoming screen if shown.
|
||||
if (evicted != null && ctx != null) {
|
||||
for (String evId : evicted) {
|
||||
maybeTeardownTelecomRing(ctx, evId);
|
||||
IncomingCallActivity.finishFor(evId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1563,6 +1593,14 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
}
|
||||
dlog("remove: event=" + eventId + " had=" + (removed != null)
|
||||
+ " tombstoneWindow=" + tombstoneWindow);
|
||||
// Phase C: tear down this ring's Telecom session + phoneCall FGS unless it
|
||||
// was answered (then the active call keeps them). Runs before the
|
||||
// not-rendered early-return so a non-rendered-but-Telecom-owning ring
|
||||
// (defensive) still cleans up. No-op when the flag is off / no session.
|
||||
maybeTeardownTelecomRing(ctx, eventId);
|
||||
// Dismiss the full-screen incoming screen if it's showing this ring
|
||||
// (answer / decline / expiry / supersede). No-op if not shown.
|
||||
IncomingCallActivity.finishFor(eventId);
|
||||
if (removed == null || removed.renderedAt == 0) return;
|
||||
String roomId = removed.data.get("room_id");
|
||||
if (roomId == null) return;
|
||||
|
|
@ -1632,6 +1670,9 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
// toward the cooldown window; otherwise a chain of silent posts
|
||||
// would indefinitely defer the next allowed alert.
|
||||
if (!silent) entry.lastAlertedAt = now;
|
||||
// Phase C: a backgrounded ring just surfaced — register the Telecom
|
||||
// session + phoneCall FGS (idempotent; no-op when the flag is off).
|
||||
ensureTelecomIncoming(ctx, entry);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1663,6 +1704,168 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
return baseTs + lifetime + RTC_LIFETIME_GRACE_MS < now;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Phase C: ring-time Telecom session + phoneCall FGS
|
||||
// (docs/plans/telecom_migration.md)
|
||||
//
|
||||
// When a background CallStyle is rendered for an incoming ring we ALSO
|
||||
// register a self-managed Telecom INCOMING session and start a phoneCall-typed
|
||||
// foreground service. The FGS retains the (possibly freshly FCM-woken) process
|
||||
// so the RINGING session survives until answer, and — because phoneCall is not
|
||||
// a while-in-use type and needs only MANAGE_OWN_CALLS — it is already running
|
||||
// before any answer-load lock, closing the answer-from-killed gap (§5.41). The
|
||||
// Telecom session gives the call real OS presence (GSM interop) and a path for
|
||||
// remote (BT/Auto/Wear) answer.
|
||||
//
|
||||
// Telecom is the sole call backend (minSdk 26 → always available); this runs
|
||||
// unconditionally on every backgrounded DM ring.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Called from renderOne after a successful background CallStyle post.
|
||||
// Idempotent per ring; only one ring owns the single session at a time.
|
||||
private static void ensureTelecomIncoming(Context ctx, IncomingRing entry) {
|
||||
final String eventId = entry.data.get("event_id");
|
||||
final String roomId = entry.data.get("room_id");
|
||||
if (eventId == null || roomId == null) return;
|
||||
final String messageId = entry.messageId;
|
||||
final Context appCtx = ctx.getApplicationContext();
|
||||
VojoCallsManager mgr = VojoCallsManager.getInstance(appCtx);
|
||||
synchronized (telecomLock) {
|
||||
if (eventId.equals(telecomRingEventId)) return; // already started for this ring
|
||||
// Single session: don't disturb an active call or an existing ring
|
||||
// session. A second simultaneous ring stays notification-only.
|
||||
if (telecomRingEventId != null || mgr.hasSession()) {
|
||||
dlog("telecom: session busy, ring stays notification-only event=" + eventId);
|
||||
return;
|
||||
}
|
||||
telecomRingEventId = eventId;
|
||||
}
|
||||
final String callerName = firstNonEmpty(
|
||||
entry.data.get("sender_display_name"),
|
||||
entry.data.get("room_name"),
|
||||
mxidLocalPart(entry.data.get("sender")),
|
||||
"Vojo"
|
||||
);
|
||||
// Telecom INCOMING session + native fallback listener (answer/disconnect
|
||||
// while no JS is attached). VojoCallsManager drives its CallControlScope
|
||||
// on Dispatchers.Main; registerAppWithTelecom/addCall expect the main
|
||||
// thread, so dispatch off the FCM background thread. The phoneCall FGS is
|
||||
// started INSIDE this post, AFTER re-confirming we still own the single
|
||||
// session slot (M13): starting it before the re-check let a concurrent
|
||||
// decline/expiry/supersede leave an orphan FGS (or made the abort branch
|
||||
// stop the wrong call's service). Now the abort path simply returns —
|
||||
// nothing was started.
|
||||
final VojoCallsManager fmgr = mgr;
|
||||
new Handler(Looper.getMainLooper()).post(() -> {
|
||||
// Re-check the slot: a decline/expiry/supersede could have run
|
||||
// maybeTeardownTelecomRing in the post window (clearing the slot).
|
||||
// Creating the session now would orphan it and wedge the single-session
|
||||
// slot forever, so abort instead.
|
||||
synchronized (telecomLock) {
|
||||
if (!eventId.equals(telecomRingEventId)) {
|
||||
dlog("telecom: ring removed before startCall, aborting event=" + eventId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// phoneCall-only FGS — process retention + answer-from-killed pre-arm.
|
||||
try {
|
||||
Intent fgs = new Intent(appCtx, CallForegroundService.class)
|
||||
.putExtra(CallForegroundService.EXTRA_TITLE, callerName)
|
||||
.putExtra(CallForegroundService.EXTRA_TYPE_PHONE_CALL, true)
|
||||
.putExtra(CallForegroundService.EXTRA_TYPE_MICROPHONE, false);
|
||||
appCtx.startForegroundService(fgs);
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "telecom: phoneCall FGS start failed", t);
|
||||
}
|
||||
try {
|
||||
fmgr.setFallbackListener(nativeRingListener(appCtx, eventId, roomId, messageId));
|
||||
fmgr.startCall(roomId, callerName, false /* video */, true /* incoming */);
|
||||
dlog("telecom: incoming session started event=" + eventId);
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "telecom: startCall(incoming) failed", t);
|
||||
synchronized (telecomLock) {
|
||||
if (eventId.equals(telecomRingEventId)) telecomRingEventId = null;
|
||||
}
|
||||
stopRingForegroundService(appCtx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Called from removeIncomingRing. If the removed ring owned the Telecom
|
||||
// session, tear it down too — UNLESS it was answered, in which case the
|
||||
// now-active call keeps the session + FGS (the JS hangup path stops them).
|
||||
private static void maybeTeardownTelecomRing(Context ctx, String eventId) {
|
||||
synchronized (telecomLock) {
|
||||
if (!eventId.equals(telecomRingEventId)) return;
|
||||
telecomRingEventId = null;
|
||||
}
|
||||
VojoCallsManager mgr = VojoCallsManager.getInstance(ctx.getApplicationContext());
|
||||
if (mgr.isAnswered()) {
|
||||
dlog("telecom: ring removed but answered — active call keeps session");
|
||||
return;
|
||||
}
|
||||
// Un-answered ring removed (decline / expiry / suppress) → end Telecom + FGS.
|
||||
try {
|
||||
mgr.setFallbackListener(null);
|
||||
mgr.endCall();
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "telecom: endCall on ring teardown threw", t);
|
||||
}
|
||||
stopRingForegroundService(ctx);
|
||||
}
|
||||
|
||||
private static void stopRingForegroundService(Context ctx) {
|
||||
try {
|
||||
ctx.stopService(new Intent(ctx, CallForegroundService.class));
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "telecom: stop phoneCall FGS threw", t);
|
||||
}
|
||||
}
|
||||
|
||||
// Native handler for a ring's Telecom session created from a process with no
|
||||
// JS attached. VojoCallsManager uses it only while its primary (JS plugin)
|
||||
// listener is null; once the WebView loads, the JS listener takes precedence.
|
||||
private static VojoCallsManager.Listener nativeRingListener(
|
||||
Context appCtx, String eventId, String roomId, String messageId
|
||||
) {
|
||||
return new VojoCallsManager.Listener() {
|
||||
@Override
|
||||
public void onAnswer(boolean video) {
|
||||
// A remote surface (BT/Auto/Wear/system) accepted while no JS is
|
||||
// up. Boot the app via the same Answer intent the CallStyle tap
|
||||
// uses; the JS pendingCallAction consumer then joins the WebView.
|
||||
// NOTE: background activity launch is OEM/version-restricted; the
|
||||
// primary answer-from-killed path is the CallStyle tap (a user
|
||||
// notification interaction, always allowed). Full BT/Auto
|
||||
// answer-from-killed is completed in Phase D.
|
||||
try {
|
||||
buildActionPI(appCtx, ("ans_" + eventId).hashCode(),
|
||||
"answer", roomId, eventId, messageId).send();
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "telecom: native onAnswer launch threw", t);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnect(int causeCode) {
|
||||
// Caller/system ended while no JS attached — clean up ring + FGS.
|
||||
removeIncomingRing(appCtx, eventId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
Log.w(TAG, "telecom: native ring error: " + message);
|
||||
removeIncomingRing(appCtx, eventId);
|
||||
}
|
||||
|
||||
@Override public void onSetActive() { }
|
||||
@Override public void onSetInactive() { }
|
||||
@Override public void onMuteStateChanged(boolean muted) { }
|
||||
@Override public void onEndpointChanged(String route) { }
|
||||
@Override public void onAvailableEndpointsChanged(String[] routes) { }
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Post path (shared between FCM-direct and registry-render)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -1732,9 +1935,10 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
PendingIntent declinePI = buildDeclineBroadcastPI(
|
||||
ctx, declineReq, roomId, notifEventId, tag, notifId
|
||||
);
|
||||
PendingIntent launchPI = buildActionPI(
|
||||
ctx, launchReq, null, roomId, notifEventId, messageId
|
||||
);
|
||||
// Body-tap + full-screen intent → the native full-screen IncomingCallActivity
|
||||
// over the lockscreen (Telecom is the sole call backend now).
|
||||
PendingIntent launchPI =
|
||||
buildIncomingActivityPI(ctx, launchReq, roomId, notifEventId, messageId, callerName);
|
||||
|
||||
Person caller = new Person.Builder().setName(callerName).build();
|
||||
|
||||
|
|
@ -1830,6 +2034,34 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
intent.putExtra("room_id", roomId);
|
||||
intent.putExtra("notif_event_id", notifEventId);
|
||||
if (callAction != null) intent.putExtra("call_action", callAction);
|
||||
// Answering from the CallStyle shade should be consistent with the
|
||||
// full-screen IncomingCallActivity: request over-lock so a locked device
|
||||
// answers without a forced unlock. MainActivity re-checks the keyguard,
|
||||
// so this is a no-op when the device is already unlocked.
|
||||
if ("answer".equals(callAction)) intent.putExtra("over_lock", true);
|
||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||
return PendingIntent.getActivity(ctx, requestCode, intent, flags);
|
||||
}
|
||||
|
||||
// Full-screen-intent target for the native incoming-call screen (Phase C).
|
||||
// FLAG_ACTIVITY_NEW_TASK is required for an FSI activity launch; the separate
|
||||
// taskAffinity (manifest) keeps it out of MainActivity's task.
|
||||
private static PendingIntent buildIncomingActivityPI(
|
||||
Context ctx,
|
||||
int requestCode,
|
||||
String roomId,
|
||||
String notifEventId,
|
||||
String messageId,
|
||||
String callerName
|
||||
) {
|
||||
Intent intent = new Intent(ctx, IncomingCallActivity.class)
|
||||
.setAction(Intent.ACTION_MAIN)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
intent.putExtra("room_id", roomId);
|
||||
intent.putExtra("notif_event_id", notifEventId);
|
||||
intent.putExtra("google.message_id", messageId != null ? messageId : "");
|
||||
intent.putExtra("caller_name", callerName != null ? callerName : "");
|
||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||
return PendingIntent.getActivity(ctx, requestCode, intent, flags);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { UserAvatar } from '../../components/user-avatar';
|
|||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { getCanonicalAliasOrRoomId, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
|
||||
import { telecomCall } from '../../plugins/call/telecomCall';
|
||||
import { getDirectRoomPath } from '../../pages/pathUtils';
|
||||
import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls';
|
||||
import { getIncomingCallKey } from '../../utils/rtcNotification';
|
||||
|
|
@ -55,18 +56,29 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
|||
// same semantic action — switch to the call room — so it should not
|
||||
// grow the back stack either.
|
||||
navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId)), { replace: true });
|
||||
// Move a ring-time Telecom session (Phase C) RINGING → ACTIVE and mark it
|
||||
// answered before the REMOVE below bridges removeIncomingRing, so the active
|
||||
// session + phoneCall FGS survive. No-op when Telecom is off / no session.
|
||||
telecomCall.answer(call.roomId).catch(() => {
|
||||
/* best-effort — the JS join below owns the media */
|
||||
});
|
||||
const dropRing = () => {
|
||||
const evId = call.notifEvent.getId();
|
||||
if (evId) {
|
||||
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId: evId });
|
||||
return;
|
||||
}
|
||||
setIncoming({ type: 'REMOVE', key: callKey });
|
||||
};
|
||||
switchOrStartDmCall(call.roomId)
|
||||
.then(() => {
|
||||
const evId = call.notifEvent.getId();
|
||||
if (evId) {
|
||||
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId: evId });
|
||||
return;
|
||||
}
|
||||
setIncoming({ type: 'REMOVE', key: callKey });
|
||||
})
|
||||
.then(dropRing)
|
||||
.catch((err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[call] strip answer switch/start failed', err);
|
||||
// Join failed after we answered — tear down the Telecom session + FGS
|
||||
// (endCall flips it out of "answered", dropRing ends it via the registry).
|
||||
telecomCall.endCall().catch(() => undefined);
|
||||
dropRing();
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -91,21 +103,11 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
|||
|
||||
const native = isNativePlatform();
|
||||
|
||||
// Decline left, Accept right — one consistent layout with the native
|
||||
// full-screen ring (activity_incoming_call.xml) so the destructive action is
|
||||
// never where the other surface puts Accept (mis-tap risk). (M7)
|
||||
const buttons = (
|
||||
<div className={classNames(css.RingButtons, native && css.RingButtonsFull)}>
|
||||
<Button
|
||||
className={native ? css.RingButtonGrow : undefined}
|
||||
variant="Success"
|
||||
fill="Solid"
|
||||
size={native ? '500' : '400'}
|
||||
radii="400"
|
||||
onClick={handleAnswer}
|
||||
before={<Icon size="200" src={CallPhoneIcon} filled />}
|
||||
>
|
||||
<Text size={native ? 'B500' : 'B400'} truncate>
|
||||
{t('Call.accept')}
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
className={native ? css.RingButtonGrow : undefined}
|
||||
variant="Critical"
|
||||
|
|
@ -119,6 +121,19 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
|||
{t('Call.decline')}
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
className={native ? css.RingButtonGrow : undefined}
|
||||
variant="Success"
|
||||
fill="Solid"
|
||||
size={native ? '500' : '400'}
|
||||
radii="400"
|
||||
onClick={handleAnswer}
|
||||
before={<Icon size="200" src={CallPhoneIcon} filled />}
|
||||
>
|
||||
<Text size={native ? 'B500' : 'B400'} truncate>
|
||||
{t('Call.accept')}
|
||||
</Text>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,26 +1,22 @@
|
|||
// Drive a self-managed Telecom call session alongside the active DM call
|
||||
// (docs/plans/telecom_migration.md, Phase B). Mirror of
|
||||
// useAndroidCallForegroundSync: keyed to the widget's JoinCall signal (via
|
||||
// useCallJoined), not the mere presence of callEmbedAtom.
|
||||
// (docs/plans/telecom_migration.md). Mirror of useAndroidCallForegroundSync:
|
||||
// keyed to the widget's JoinCall signal (via useCallJoined), not the mere
|
||||
// presence of callEmbedAtom.
|
||||
//
|
||||
// What this gives us once TELECOM_ENABLED is flipped on:
|
||||
// What this gives us:
|
||||
// - the Vojo call is registered with the OS as a real call → cellular-call
|
||||
// interop (an incoming GSM call holds/arbitrates against it), audio-focus
|
||||
// arbitration, and a system "ongoing call" presence;
|
||||
// - a remote/system Telecom disconnect (the user takes a GSM call, or a
|
||||
// Bluetooth/Android-Auto "end call") tears the Vojo call down too;
|
||||
// - Telecom owns the audio route, so its active endpoint is mirrored into the
|
||||
// speaker-toggle atom (earpiece / speaker / Bluetooth all reflected).
|
||||
// speaker-toggle atom (earpiece / speaker / Bluetooth all reflected). The
|
||||
// legacy AudioRoute plugin has been retired — Telecom is the only router.
|
||||
//
|
||||
// §4 (validated on Samsung One UI: the call gets system call-audio focus and
|
||||
// the route endpoint flow emits cleanly): Telecom owns routing. The speaker
|
||||
// toggle now drives Telecom endpoints (see useCallSpeaker) and AudioRoutePlugin
|
||||
// is kept off the audio session while a Telecom call is live.
|
||||
//
|
||||
// Android-only, and additionally gated on TELECOM_ENABLED + API>=26 inside
|
||||
// telecomCall — on web / iOS / old Android every call here is a no-op.
|
||||
// Android-only (gated on isAndroidPlatform() inside telecomCall) — on web / iOS
|
||||
// every call here is a no-op.
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||
import { callEmbedAtom, callSpeakerAtom } from '../state/callEmbed';
|
||||
import { telecomCall, type TelecomRoute } from '../plugins/call/telecomCall';
|
||||
|
|
@ -31,12 +27,39 @@ export const useTelecomConnectionSync = (): void => {
|
|||
const joined = useCallJoined(callEmbed);
|
||||
const store = useStore();
|
||||
const setSpeaker = useSetAtom(callSpeakerAtom);
|
||||
const hadEmbedRef = useRef(false);
|
||||
|
||||
// Backstop teardown keyed on the embed lifecycle, NOT on `joined`. An answered
|
||||
// call may never reach JoinCall (widget preparingError / EC Close / LiveKit
|
||||
// fail), so the joined-gated cleanup below never runs and the (answered)
|
||||
// Telecom session would leak — hasSession() stays true and degrades every
|
||||
// later call to notification-only. When an embed that existed goes away, end
|
||||
// the session unconditionally (endCall is idempotent).
|
||||
useEffect(() => {
|
||||
if (!telecomCall.enabled()) return;
|
||||
if (callEmbed) {
|
||||
hadEmbedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (hadEmbedRef.current) {
|
||||
hadEmbedRef.current = false;
|
||||
telecomCall.endCall().catch(() => undefined);
|
||||
}
|
||||
}, [callEmbed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!telecomCall.enabled()) return undefined;
|
||||
if (!callEmbed || !joined) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
// Saved widget mic state at the moment Telecom puts us on hold (interrupting
|
||||
// GSM call), so onSetActive can restore exactly what the user had — not blanket
|
||||
// unmute. null = not currently held.
|
||||
let micBeforeHold: boolean | null = null;
|
||||
// True once Telecom itself muted us (hardware/BT/Auto mute button). Gates the
|
||||
// auto-unmute below so a spurious/initial "not muted" can never force-unmute a
|
||||
// user who joined muted — see the telecomMute listener.
|
||||
let telecomMutedUs = false;
|
||||
const removers: Array<() => void> = [];
|
||||
const track = (p: ReturnType<typeof telecomCall.addListener>) => {
|
||||
p.then((handle) => {
|
||||
|
|
@ -77,12 +100,77 @@ export const useTelecomConnectionSync = (): void => {
|
|||
|
||||
// Telecom owns the route; reflect the active endpoint in the speaker-toggle
|
||||
// atom so the UI tracks reality (speaker vs earpiece/Bluetooth/wired).
|
||||
// Current-embed guard: a late emission from a session that's been superseded
|
||||
// must not write the new call's speaker atom.
|
||||
track(
|
||||
telecomCall.addListener('telecomEndpoint', (data: { route: TelecomRoute }) => {
|
||||
if (store.get(callEmbedAtom) !== callEmbed) return;
|
||||
setSpeaker(data.route === 'SPEAKER');
|
||||
})
|
||||
);
|
||||
|
||||
// Mute mirror (one source of truth). On a self-managed call the OS-level
|
||||
// mute only ever changes via a hardware / Bluetooth / Android Auto mute
|
||||
// button (there is no system call-UI mute affordance), so every change is
|
||||
// user-initiated. Direction is Telecom → widget only: core-telecom exposes
|
||||
// isMuted as a read-only Flow with no setter.
|
||||
//
|
||||
// We mirror MUTE unconditionally, but only auto-UNMUTE the widget when
|
||||
// Telecom is the one that muted it (telecomMutedUs). This means a spurious or
|
||||
// initial "not muted" emission can NEVER force-unmute someone who joined
|
||||
// muted — correct whether or not core-telecom's isMuted replays its current
|
||||
// value (its API type is a plain, non-replaying Flow, the same as
|
||||
// availableEndpoints), so it does not depend on library internals.
|
||||
// setMicrophoneEnabled is idempotent, so mirroring can't bounce.
|
||||
track(
|
||||
telecomCall.addListener('telecomMute', (data: { muted: boolean }) => {
|
||||
if (store.get(callEmbedAtom) !== callEmbed) return;
|
||||
if (data.muted) {
|
||||
telecomMutedUs = true;
|
||||
callEmbed.control.setMicrophoneEnabled(false);
|
||||
} else if (telecomMutedUs) {
|
||||
telecomMutedUs = false;
|
||||
callEmbed.control.setMicrophoneEnabled(true);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// GSM interop: Telecom holds the Vojo call when an interrupting cellular
|
||||
// call goes active (onSetInactive) and resumes it after (onSetActive). The
|
||||
// media lives in the WebView, which Telecom can't pause directly, so we mute
|
||||
// the widget mic on hold (the cellular peer must not hear the Vojo room) and
|
||||
// restore the user's prior mic state on resume.
|
||||
track(
|
||||
telecomCall.addListener('telecomSetInactive', () => {
|
||||
if (store.get(callEmbedAtom) !== callEmbed) return;
|
||||
if (micBeforeHold === null) micBeforeHold = callEmbed.control.microphone;
|
||||
callEmbed.control.setMicrophoneEnabled(false);
|
||||
})
|
||||
);
|
||||
track(
|
||||
telecomCall.addListener('telecomSetActive', () => {
|
||||
if (store.get(callEmbedAtom) !== callEmbed) return;
|
||||
if (micBeforeHold !== null) {
|
||||
callEmbed.control.setMicrophoneEnabled(micBeforeHold);
|
||||
micBeforeHold = null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// A fatal Telecom error (register / addCall failure) means the OS call
|
||||
// backend is gone for this session — tear the Vojo call down rather than
|
||||
// leave it running with no system call presence / audio arbitration.
|
||||
track(
|
||||
telecomCall.addListener('telecomError', (data: { message: string }) => {
|
||||
if (store.get(callEmbedAtom) !== callEmbed) return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[telecom] session error, ending call', data.message);
|
||||
callEmbed.hangup().catch(() => {
|
||||
/* widget already gone */
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
removers.forEach((remove) => remove());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ export interface IncomingRingUpsert {
|
|||
}
|
||||
|
||||
interface CallForegroundServicePlugin {
|
||||
start(options?: { title?: string; body?: string }): Promise<void>;
|
||||
// phoneCall — Telecom mode (Phase C): also tag the FGS with the phoneCall type
|
||||
// so it matches the ring-time service and survives a process restart. Omitted
|
||||
// / false keeps the legacy microphone-only behavior.
|
||||
start(options?: { title?: string; body?: string; phoneCall?: boolean }): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
upsertIncomingRing(options: IncomingRingUpsert): Promise<void>;
|
||||
removeIncomingRing(options: { eventId: string }): Promise<void>;
|
||||
|
|
@ -40,7 +43,7 @@ const plugin = registerPlugin<CallForegroundServicePlugin>('CallForegroundServic
|
|||
// and CallForegroundPlugin.java. Gating on platform here (not just isNativePlatform)
|
||||
// avoids calling a non-existent plugin path on iOS or any future native target.
|
||||
export const callForegroundService = {
|
||||
start(options?: { title?: string; body?: string }): Promise<void> {
|
||||
start(options?: { title?: string; body?: string; phoneCall?: boolean }): Promise<void> {
|
||||
if (!isAndroidPlatform()) return Promise.resolve();
|
||||
return plugin.start(options);
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue