Replace FCM foreground-skip cache with Java ring registry to fix silent ring on mid-ring backgrounding and eliminate dual dismiss plane
This commit is contained in:
parent
a35dfb1a5b
commit
cf0bf56541
20 changed files with 959 additions and 269 deletions
|
|
@ -26,6 +26,13 @@ android {
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// AGP 8+ requires explicit opt-in for BuildConfig generation. We rely on
|
||||||
|
// BuildConfig.DEBUG to gate Log.d calls that dump privacy-sensitive
|
||||||
|
// identifiers (roomId, eventId) so release builds don't leak them through
|
||||||
|
// logcat / crash-reporter buffers. See dlog() in VojoFirebaseMessagingService.
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,7 @@
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
<!-- DM call lock-screen retention: CallForegroundService keeps the call
|
<!-- DM call lock-screen retention: CallForegroundService keeps the call
|
||||||
process foregrounded under lock so AppOps doesn't revoke RECORD_AUDIO
|
process foregrounded under lock so AppOps doesn't revoke RECORD_AUDIO
|
||||||
and netd doesn't block background network. Context in
|
and netd doesn't block background network. -->
|
||||||
docs/plans/dm_calls_techdebt.md §2.2. -->
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -22,16 +22,23 @@ public class CallCancelReceiver extends BroadcastReceiver {
|
||||||
public static final String ACTION_CANCEL_CALL = "chat.vojo.app.CANCEL_CALL";
|
public static final String ACTION_CANCEL_CALL = "chat.vojo.app.CANCEL_CALL";
|
||||||
public static final String EXTRA_NOTIF_TAG = "notif_tag";
|
public static final String EXTRA_NOTIF_TAG = "notif_tag";
|
||||||
public static final String EXTRA_NOTIF_ID = "notif_id";
|
public static final String EXTRA_NOTIF_ID = "notif_id";
|
||||||
|
// Carried so the receiver can tombstone and drop the matching registry
|
||||||
|
// entry — otherwise a same-eventId re-delivery after expiry would seed the
|
||||||
|
// registry again and render a stale ring on next backgrounding.
|
||||||
|
public static final String EXTRA_NOTIF_EVENT_ID = "notif_event_id";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onReceive(Context context, Intent intent) {
|
public void onReceive(Context context, Intent intent) {
|
||||||
if (intent == null || !ACTION_CANCEL_CALL.equals(intent.getAction())) return;
|
if (intent == null || !ACTION_CANCEL_CALL.equals(intent.getAction())) return;
|
||||||
String tag = intent.getStringExtra(EXTRA_NOTIF_TAG);
|
String tag = intent.getStringExtra(EXTRA_NOTIF_TAG);
|
||||||
int id = intent.getIntExtra(EXTRA_NOTIF_ID, -1);
|
int id = intent.getIntExtra(EXTRA_NOTIF_ID, -1);
|
||||||
|
String notifEventId = intent.getStringExtra(EXTRA_NOTIF_EVENT_ID);
|
||||||
if (tag == null || id == -1) return;
|
if (tag == null || id == -1) return;
|
||||||
NotificationManager nm =
|
NotificationManager nm =
|
||||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
if (nm == null) return;
|
if (nm != null) nm.cancel(tag, id);
|
||||||
nm.cancel(tag, id);
|
if (notifEventId != null) {
|
||||||
|
VojoFirebaseMessagingService.removeIncomingRing(context, notifEventId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,14 +40,13 @@ import java.util.concurrent.Executors;
|
||||||
* JS-path can't cover us either (a logged-out client has no Matrix session
|
* JS-path can't cover us either (a logged-out client has no Matrix session
|
||||||
* to call sendRtcDecline against). Cancelling the notification is the only
|
* to call sendRtcDecline against). Cancelling the notification is the only
|
||||||
* feedback we can give; leaving the ring would trap the user on a call they
|
* feedback we can give; leaving the ring would trap the user on a call they
|
||||||
* can't accept or decline until the A-side times out. See techdebt §5.35.
|
* can't accept or decline until the A-side times out.
|
||||||
*
|
*
|
||||||
* Note on idempotency: the flusher's retry generates a new txnId, so on a
|
* Note on idempotency: the flusher's retry generates a new txnId, so on a
|
||||||
* split-success-fail sequence (receiver HTTP timed out, flusher succeeds)
|
* split-success-fail sequence (receiver HTTP timed out, flusher succeeds)
|
||||||
* we may land two {@code m.call.decline} events in the timeline with the
|
* we may land two {@code m.call.decline} events in the timeline with the
|
||||||
* same {@code rel.event_id}. This is cosmetic — the caller's auto-hangup
|
* same {@code rel.event_id}. This is cosmetic — the caller's auto-hangup
|
||||||
* hook is idempotent and fires on the first decline. See techdebt §5.35
|
* hook is idempotent and fires on the first decline.
|
||||||
* "txnId unification" note.
|
|
||||||
*/
|
*/
|
||||||
public class CallDeclineReceiver extends BroadcastReceiver {
|
public class CallDeclineReceiver extends BroadcastReceiver {
|
||||||
|
|
||||||
|
|
@ -98,6 +97,7 @@ public class CallDeclineReceiver extends BroadcastReceiver {
|
||||||
if (notifTag != null && notifId != -1) {
|
if (notifTag != null && notifId != -1) {
|
||||||
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
|
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
|
||||||
}
|
}
|
||||||
|
VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,10 +111,21 @@ public class CallDeclineReceiver extends BroadcastReceiver {
|
||||||
// Do NOT pass the Throwable — JSONException.getMessage() embeds
|
// Do NOT pass the Throwable — JSONException.getMessage() embeds
|
||||||
// the malformed input, which here contains the access token.
|
// the malformed input, which here contains the access token.
|
||||||
Log.e(TAG, "onReceive: prefs JSON parse failed: " + t.getClass().getSimpleName());
|
Log.e(TAG, "onReceive: prefs JSON parse failed: " + t.getClass().getSimpleName());
|
||||||
|
// Still drop the native and the registry entry — user tapped Decline,
|
||||||
|
// the ring should not re-surface on next backgrounding just because
|
||||||
|
// we can't send the decline over HTTP.
|
||||||
|
if (notifTag != null && notifId != -1) {
|
||||||
|
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
|
||||||
|
}
|
||||||
|
VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (accessToken == null || accessToken.isEmpty() || baseUrl == null || baseUrl.isEmpty()) {
|
if (accessToken == null || accessToken.isEmpty() || baseUrl == null || baseUrl.isEmpty()) {
|
||||||
Log.w(TAG, "onReceive: empty accessToken/baseUrl in session, leaving ring intact");
|
Log.w(TAG, "onReceive: empty accessToken/baseUrl in session, cancelling ring locally");
|
||||||
|
if (notifTag != null && notifId != -1) {
|
||||||
|
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
|
||||||
|
}
|
||||||
|
VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,6 +135,9 @@ public class CallDeclineReceiver extends BroadcastReceiver {
|
||||||
if (notifTag != null && notifId != -1) {
|
if (notifTag != null && notifId != -1) {
|
||||||
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
|
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
|
||||||
}
|
}
|
||||||
|
// Drop the registry entry and tombstone the eventId so the next
|
||||||
|
// onPause renderRegistry can't resurrect the declined ring.
|
||||||
|
VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
|
||||||
|
|
||||||
// 2. Write tombstone BEFORE HTTP so the flusher sees work to do
|
// 2. Write tombstone BEFORE HTTP so the flusher sees work to do
|
||||||
// if we fail/die. Remove only on confirmed 2xx.
|
// if we fail/die. Remove only on confirmed 2xx.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ import com.getcapacitor.PluginCall;
|
||||||
import com.getcapacitor.PluginMethod;
|
import com.getcapacitor.PluginMethod;
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JS → Android bridge for CallForegroundService lifecycle.
|
* JS → Android bridge for CallForegroundService lifecycle.
|
||||||
*
|
*
|
||||||
|
|
@ -82,4 +85,57 @@ public class CallForegroundPlugin extends Plugin {
|
||||||
call.reject("stop_failed: " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
call.reject("stop_failed: " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JS upserts a live incoming ring into the native registry (atom ADD
|
||||||
|
// happy-path). Idempotent with any prior FCM seed for the same eventId —
|
||||||
|
// Java merges metadata fields append-only. See VojoFirebaseMessagingService
|
||||||
|
// for the registry operations contract.
|
||||||
|
@PluginMethod
|
||||||
|
public void upsertIncomingRing(PluginCall call) {
|
||||||
|
String eventId = call.getString("eventId");
|
||||||
|
String roomId = call.getString("roomId");
|
||||||
|
if (eventId == null || eventId.isEmpty() || roomId == null || roomId.isEmpty()) {
|
||||||
|
call.reject("missing_eventId_or_roomId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, String> data = new HashMap<>();
|
||||||
|
data.put("event_id", eventId);
|
||||||
|
data.put("room_id", roomId);
|
||||||
|
String callerName = call.getString("callerName");
|
||||||
|
if (callerName != null && !callerName.isEmpty()) {
|
||||||
|
data.put("sender_display_name", callerName);
|
||||||
|
}
|
||||||
|
// Pass through senderTs/lifetime as strings — registry stores the same
|
||||||
|
// Map<String,String> shape that FCM delivers, and downstream consumers
|
||||||
|
// (scheduleCallNotificationExpiry, isExpired) parseLong them.
|
||||||
|
Long senderTs = call.getLong("senderTs");
|
||||||
|
if (senderTs != null && senderTs > 0) {
|
||||||
|
data.put("content_sender_ts", Long.toString(senderTs));
|
||||||
|
}
|
||||||
|
Long lifetime = call.getLong("lifetime");
|
||||||
|
if (lifetime != null && lifetime > 0) {
|
||||||
|
data.put("content_lifetime", Long.toString(lifetime));
|
||||||
|
}
|
||||||
|
String messageId = call.getString("messageId");
|
||||||
|
// messageId is used as google.message_id in the Answer/Launch PendingIntent
|
||||||
|
// extras — Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
|
||||||
|
// on containsKey. Empty string also satisfies the gate; we pass the
|
||||||
|
// caller's value through verbatim.
|
||||||
|
VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// JS removes a ring from the native registry (atom REMOVE / suppress path /
|
||||||
|
// native action receiver path). Tombstones the eventId to reject late
|
||||||
|
// FCM or /sync re-seeds within the ring lifetime.
|
||||||
|
@PluginMethod
|
||||||
|
public void removeIncomingRing(PluginCall call) {
|
||||||
|
String eventId = call.getString("eventId");
|
||||||
|
if (eventId == null || eventId.isEmpty()) {
|
||||||
|
call.reject("missing_event_id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
VojoFirebaseMessagingService.removeIncomingRing(getContext(), eventId);
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import androidx.core.app.NotificationCompat;
|
||||||
* Both revocations were observed in Phase 0 capture on Samsung OneUI API 36:
|
* Both revocations were observed in Phase 0 capture on Samsung OneUI API 36:
|
||||||
* mic went Active=false ~5s after screen-off, netd isBlocked=true ~13s after,
|
* mic went Active=false ~5s after screen-off, netd isBlocked=true ~13s after,
|
||||||
* causing Element Call inside the hidden WebView to tear down the LiveKit
|
* causing Element Call inside the hidden WebView to tear down the LiveKit
|
||||||
* session and the call to drop. Full context in docs/plans/dm_calls_techdebt.md §2.2.
|
* session and the call to drop.
|
||||||
*
|
*
|
||||||
* Preconditions enforced by callers:
|
* Preconditions enforced by callers:
|
||||||
* - RECORD_AUDIO runtime permission granted (plugin-side check in
|
* - RECORD_AUDIO runtime permission granted (plugin-side check in
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,36 @@
|
||||||
package chat.vojo.app;
|
package chat.vojo.app;
|
||||||
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
import androidx.activity.EdgeToEdge;
|
import androidx.activity.EdgeToEdge;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
public static volatile boolean isInForeground = false;
|
public static volatile boolean isInForeground = false;
|
||||||
|
|
||||||
|
// Short debounce on the onPause→renderRegistry edge so an in-flight JS
|
||||||
|
// removeIncomingRing bridge call (e.g. user accepted/declined, then
|
||||||
|
// immediately pressed Home) has a chance to land before we post native
|
||||||
|
// CallStyle for a ring that's about to be removed. 150ms covers the
|
||||||
|
// strip-accept chain (Capacitor roundtrip + switchOrStartDmCall
|
||||||
|
// resolve + sync-effect → bridge) on mid-range Android; imperceptible
|
||||||
|
// as a silent-ring delay.
|
||||||
|
private static final long RENDER_DEBOUNCE_MS = 150L;
|
||||||
|
// Modest debounce on the onResume→cancelRenderedIncomingRings edge so JS
|
||||||
|
// has a moment to hydrate incomingCallsAtom before the native surface
|
||||||
|
// goes away. Covers warm resume (hook alive, /sync delivers an ADD
|
||||||
|
// within ~100-200ms) cleanly. Cold resume (killed process, Matrix
|
||||||
|
// client rehydration takes 1-3s) still has a "no surface" window —
|
||||||
|
// acceptable tradeoff since tap-native flows carry call_action and
|
||||||
|
// are handled by pendingCallActionConsumer regardless of atom state.
|
||||||
|
private static final long CANCEL_DEBOUNCE_MS = 300L;
|
||||||
|
private final Handler lifecycleHandler = new Handler(Looper.getMainLooper());
|
||||||
|
private final Runnable renderRunnable = () ->
|
||||||
|
VojoFirebaseMessagingService.renderRegistry(this);
|
||||||
|
private final Runnable cancelRunnable = () ->
|
||||||
|
VojoFirebaseMessagingService.cancelRenderedIncomingRings(this);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
// Custom plugins must be registered before super.onCreate so BridgeActivity
|
// Custom plugins must be registered before super.onCreate so BridgeActivity
|
||||||
|
|
@ -22,11 +46,37 @@ public class MainActivity extends BridgeActivity {
|
||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
isInForeground = true;
|
isInForeground = true;
|
||||||
|
// Cancel any pending render: user came back before the debounce fired,
|
||||||
|
// JS strip will own UX, no need to surface native.
|
||||||
|
lifecycleHandler.removeCallbacks(renderRunnable);
|
||||||
|
// Defer the native cancel so JS strip has a moment to hydrate from
|
||||||
|
// incomingCallsAtom. Registry entries persist — they still represent
|
||||||
|
// live rings, just the native surfaces go.
|
||||||
|
lifecycleHandler.removeCallbacks(cancelRunnable);
|
||||||
|
lifecycleHandler.postDelayed(cancelRunnable, CANCEL_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPause() {
|
public void onPause() {
|
||||||
super.onPause();
|
super.onPause();
|
||||||
isInForeground = false;
|
isInForeground = false;
|
||||||
|
// Re-backgrounding: don't cancel a native the user will still need
|
||||||
|
// visible. The render runnable below will re-render if needed.
|
||||||
|
lifecycleHandler.removeCallbacks(cancelRunnable);
|
||||||
|
// Schedule render — user is backgrounding, JS audio gate is about to
|
||||||
|
// close, native CallStyle must surface or the ring goes silent. Debounce
|
||||||
|
// absorbs the bridge-call race: if onResume fires within RENDER_DEBOUNCE_MS
|
||||||
|
// (user bounce), the render is cancelled.
|
||||||
|
lifecycleHandler.removeCallbacks(renderRunnable);
|
||||||
|
lifecycleHandler.postDelayed(renderRunnable, RENDER_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
// Drop any pending render/cancel so runnables — which capture `this` —
|
||||||
|
// can't fire post-destroy and land an nm.notify / nm.cancel against a
|
||||||
|
// dead Activity context on config change (rotation) or process teardown.
|
||||||
|
lifecycleHandler.removeCallbacksAndMessages(null);
|
||||||
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import android.media.AudioAttributes;
|
||||||
import android.media.RingtoneManager;
|
import android.media.RingtoneManager;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.service.notification.StatusBarNotification;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.core.app.NotificationCompat;
|
import androidx.core.app.NotificationCompat;
|
||||||
|
|
@ -18,7 +20,10 @@ import androidx.core.app.Person;
|
||||||
import com.capacitorjs.plugins.pushnotifications.MessagingService;
|
import com.capacitorjs.plugins.pushnotifications.MessagingService;
|
||||||
import com.google.firebase.messaging.RemoteMessage;
|
import com.google.firebase.messaging.RemoteMessage;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Iterator;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sygnal delivers Matrix pushes as data-only FCM messages (no `notification`
|
* Sygnal delivers Matrix pushes as data-only FCM messages (no `notification`
|
||||||
|
|
@ -29,14 +34,12 @@ import java.util.Map;
|
||||||
* Message branch: builds a system notification when the activity is NOT in
|
* Message branch: builds a system notification when the activity is NOT in
|
||||||
* the foreground — covering both "backgrounded" and "killed" cases.
|
* the foreground — covering both "backgrounded" and "killed" cases.
|
||||||
*
|
*
|
||||||
* Call branch: when the app is backgrounded we show a CallStyle incoming-call
|
* Call branch: funnels every observed DM ring through the native ring
|
||||||
* notification with Answer/Decline actions + full-screen intent that wakes the
|
* registry (see below). FCM arrival either seeds the registry (foreground,
|
||||||
* device and launches MainActivity over the lockscreen. When the app is already
|
* JS strip will own UX) or seeds + renders immediately (background). The
|
||||||
* foregrounded, JS owns the UX via the in-app incoming-call strip, so we must
|
* registry is authoritative for "which rings are currently live"; onPause
|
||||||
* NOT also surface a system banner. The FSI is also what satisfies AOSP
|
* renders whatever the registry still holds, onResume cancels native
|
||||||
* NotificationManagerService.checkDisqualifyingFeatures on API 31+; without it
|
* surfaces we raised.
|
||||||
* CallStyle throws IAE and the notification is silently dropped. See
|
|
||||||
* docs/plans/dm_calls.md ADR 2.5-fsi.
|
|
||||||
*/
|
*/
|
||||||
public class VojoFirebaseMessagingService extends MessagingService {
|
public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
|
|
||||||
|
|
@ -58,13 +61,102 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
private static final long RTC_DEFAULT_LIFETIME_MS = 30_000L;
|
private static final long RTC_DEFAULT_LIFETIME_MS = 30_000L;
|
||||||
private static final long RTC_LIFETIME_GRACE_MS = 2_000L;
|
private static final long RTC_LIFETIME_GRACE_MS = 2_000L;
|
||||||
|
|
||||||
|
// Extra attached to every CallStyle notification posted from the registry;
|
||||||
|
// onResume ownership check matches this against the currently-rendered
|
||||||
|
// StatusBarNotification so we don't cancel a foreign ring that happens to
|
||||||
|
// share the same room-scoped tag/id slot.
|
||||||
|
private static final String EXTRA_RING_EVENT_ID = "vojo_ring_event_id";
|
||||||
|
|
||||||
private static final String TAG = "VojoFCM";
|
private static final String TAG = "VojoFCM";
|
||||||
|
|
||||||
|
// Debug-only log helper. Release builds strip these calls at compile time
|
||||||
|
// (BuildConfig.DEBUG is a compile-time constant; the javac dead-code
|
||||||
|
// eliminator drops the whole statement). Use dlog() for anything that
|
||||||
|
// includes privacy-sensitive identifiers (roomId, eventId, sender);
|
||||||
|
// use Log.w / Log.e directly for error paths that must surface in release.
|
||||||
|
private static void dlog(String msg) {
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Native Ring Registry
|
||||||
|
//
|
||||||
|
// Single source of truth on the Java side for "which rings are currently
|
||||||
|
// live". Populated by FCM arrival (fg or bg) and by JS bridge upsert on
|
||||||
|
// incomingCallsAtom ADD. Removed by JS bridge on atom REMOVE and suppress
|
||||||
|
// paths, by native decline/cancel receivers, and by expiry sweep.
|
||||||
|
//
|
||||||
|
// Key: notifEventId (MSC4075 m.rtc.notification event_id). Same eventId
|
||||||
|
// observed by both FCM and /sync → idempotent upsert. Remove-wins
|
||||||
|
// consistency is enforced via tombstones: a late FCM seed arriving after
|
||||||
|
// JS suppressed the ring is rejected by a tombstone for the ring lifetime.
|
||||||
|
//
|
||||||
|
// Native notification identity remains room-scoped (tag=call_<roomId>,
|
||||||
|
// id=hash(tag)). If a same-room second ring arrives before the first was
|
||||||
|
// dismissed, both live in the registry by distinct eventIds; renderRegistry
|
||||||
|
// posts each but nm.notify replaces in the one room-scoped slot. Only
|
||||||
|
// one of them is user-visible at a time — same-room latest-wins matches
|
||||||
|
// the UX contract for a single active ring per DM.
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static final ConcurrentHashMap<String, IncomingRing> ringRegistry =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// eventId → absolute-ms when tombstone expires. Block upserts during this
|
||||||
|
// window so a late FCM re-delivery or late /sync cannot resurrect a ring
|
||||||
|
// the user already accepted/declined/ignored.
|
||||||
|
private static final ConcurrentHashMap<String, Long> ringTombstones =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Single lock covering every registry+tombstone mutation so remove-wins and
|
||||||
|
// latest-wins-per-room stay atomic. ConcurrentHashMap would linearize each
|
||||||
|
// call individually, but the invariants ("don't upsert if tombstoned",
|
||||||
|
// "evict older same-room on upsert") span both maps and must see a
|
||||||
|
// consistent snapshot. Critical sections stay short; no Android Binder
|
||||||
|
// calls (NotificationManager, AlarmManager) run under the lock.
|
||||||
|
private static final Object registryLock = new Object();
|
||||||
|
|
||||||
|
private static final class IncomingRing {
|
||||||
|
final Map<String, String> data;
|
||||||
|
// Not final — a JS-first upsert seeds a null messageId; when FCM
|
||||||
|
// arrives later with a real messageId we want to adopt it so the
|
||||||
|
// Answer PendingIntent carries a non-empty google.message_id. Merge
|
||||||
|
// rule in upsertIncomingRing: overwrite only when existing is null.
|
||||||
|
volatile String messageId;
|
||||||
|
// Wall-clock at seed time. Fallback baseline for expiry checks when the
|
||||||
|
// payload lacks `content_sender_ts`.
|
||||||
|
final long seededAt;
|
||||||
|
// 0 if not currently posted as a native CallStyle. Set by renderOne on
|
||||||
|
// successful post; reset by cancelRenderedIncomingRings. Used by
|
||||||
|
// renderRegistry to avoid double-posting while the native is live.
|
||||||
|
volatile long renderedAt;
|
||||||
|
// Wall-clock of the most recent ALERTING post (non-silent). Used by
|
||||||
|
// renderOne to decide whether a re-render within the cooldown should
|
||||||
|
// be silent — rapid bg↔fg↔bg toggles otherwise cancel+repost the
|
||||||
|
// CallStyle and fresh-alert on each cycle. Preserved across cancel
|
||||||
|
// so the cooldown spans the bg→fg→bg window; setOnlyAlertOnce alone
|
||||||
|
// doesn't help because cancel+post is a fresh notification, not an update.
|
||||||
|
volatile long lastAlertedAt;
|
||||||
|
|
||||||
|
IncomingRing(Map<String, String> data, String messageId, long seededAt) {
|
||||||
|
this.data = data;
|
||||||
|
this.messageId = messageId;
|
||||||
|
this.seededAt = seededAt;
|
||||||
|
this.renderedAt = 0L;
|
||||||
|
this.lastAlertedAt = 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-alert cooldown. Within this many ms of the last alerting post, a
|
||||||
|
// render goes out silently (setSilent(true)) — visual only, no
|
||||||
|
// ringtone/vibration. After the window, next render alerts fresh.
|
||||||
|
private static final long RE_ALERT_COOLDOWN_MS = 3_000L;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onMessageReceived(RemoteMessage remoteMessage) {
|
public void onMessageReceived(RemoteMessage remoteMessage) {
|
||||||
super.onMessageReceived(remoteMessage);
|
super.onMessageReceived(remoteMessage);
|
||||||
Map<String, String> data = remoteMessage.getData();
|
Map<String, String> data = remoteMessage.getData();
|
||||||
Log.d(TAG, "recv: type=" + data.get("type")
|
dlog("recv: type=" + data.get("type")
|
||||||
+ " cn_type=" + data.get("content_notification_type")
|
+ " cn_type=" + data.get("content_notification_type")
|
||||||
+ " room=" + data.get("room_id")
|
+ " room=" + data.get("room_id")
|
||||||
+ " event=" + data.get("event_id")
|
+ " event=" + data.get("event_id")
|
||||||
|
|
@ -72,19 +164,49 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
try {
|
try {
|
||||||
if (RTC_NOTIFICATION_TYPE.equals(data.get("type"))
|
if (RTC_NOTIFICATION_TYPE.equals(data.get("type"))
|
||||||
&& "ring".equals(data.get("content_notification_type"))) {
|
&& "ring".equals(data.get("content_notification_type"))) {
|
||||||
if (MainActivity.isInForeground) {
|
String eventId = data.get("event_id");
|
||||||
Log.d(TAG, "route: skip call notif (foreground, JS strip owns UX)");
|
String roomId = data.get("room_id");
|
||||||
|
if (eventId == null || roomId == null) {
|
||||||
|
Log.w(TAG, "route: call missing eventId/roomId, drop");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Log.d(TAG, "route: call-branch (background)");
|
// Snapshot the payload — FCM internals may recycle the map reference.
|
||||||
showIncomingCallNotification(remoteMessage);
|
Map<String, String> snapshot = new HashMap<>(data);
|
||||||
|
boolean seeded = upsertIncomingRing(snapshot, remoteMessage.getMessageId());
|
||||||
|
if (!seeded) {
|
||||||
|
dlog("route: call tombstoned, skipping native (event=" + eventId + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (MainActivity.isInForeground) {
|
||||||
|
dlog("route: call seeded (foreground, JS strip owns UX) event=" + eventId);
|
||||||
|
// Race guard: MainActivity.onPause may have run its render
|
||||||
|
// between our fg check and the upsert above. If fg flipped
|
||||||
|
// to false meanwhile, render now so the seeded entry isn't
|
||||||
|
// left unrendered until the next backgrounding cycle.
|
||||||
|
if (!MainActivity.isInForeground) {
|
||||||
|
dlog("route: race-detected (fg→bg during seed), render now");
|
||||||
|
renderRegistry(this);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dlog("route: call seeded + render (background) event=" + eventId);
|
||||||
|
IncomingRing entry = ringRegistry.get(eventId);
|
||||||
|
// Guard against late-delivered FCM (doze / retry past lifetime):
|
||||||
|
// renderRegistry filters expired entries, but the bg direct-render
|
||||||
|
// path skipped that check. Without this guard a dead ring would
|
||||||
|
// briefly surface and then be dismissed by the expiry alarm.
|
||||||
|
if (entry != null && !isExpired(entry, System.currentTimeMillis())) {
|
||||||
|
renderOne(this, entry);
|
||||||
|
} else if (entry != null) {
|
||||||
|
dlog("route: call bg drop expired event=" + eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!MainActivity.isInForeground) {
|
if (!MainActivity.isInForeground) {
|
||||||
Log.d(TAG, "route: message-branch (background)");
|
dlog("route: message-branch (background)");
|
||||||
showSystemNotification(remoteMessage);
|
showSystemNotification(remoteMessage);
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "route: skip (foreground, non-call)");
|
dlog("route: skip (foreground, non-call)");
|
||||||
}
|
}
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
// Don't let any notification-construction bug crash the FCM service — if we
|
// Don't let any notification-construction bug crash the FCM service — if we
|
||||||
|
|
@ -151,7 +273,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
// Guard against the (rare) hashCode collision with the reserved summary id.
|
// Guard against the (rare) hashCode collision with the reserved summary id.
|
||||||
int notifId = uniqueKey.hashCode();
|
int notifId = uniqueKey.hashCode();
|
||||||
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
|
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
|
||||||
Log.d(TAG, "msg: posting notif id=" + notifId + " channel=" + CHANNEL_ID
|
dlog("msg: posting notif id=" + notifId + " channel=" + CHANNEL_ID
|
||||||
+ " notifsEnabled=" + nm.areNotificationsEnabled());
|
+ " notifsEnabled=" + nm.areNotificationsEnabled());
|
||||||
try {
|
try {
|
||||||
nm.notify(notifId, builder.build());
|
nm.notify(notifId, builder.build());
|
||||||
|
|
@ -170,22 +292,397 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
nm.notify(SUMMARY_NOTIFICATION_ID, summary.build());
|
nm.notify(SUMMARY_NOTIFICATION_ID, summary.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showIncomingCallNotification(RemoteMessage message) {
|
// ────────────────────────────────────────────────────────────────────
|
||||||
Map<String, String> data = message.getData();
|
// Registry operations (public to package)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed or refresh a registry entry. Returns true if the entry was stored,
|
||||||
|
* false if rejected by a live tombstone (remove-wins consistency).
|
||||||
|
*
|
||||||
|
* Idempotent when the eventId is already present. Metadata merge is
|
||||||
|
* append-only: FCM payload typically carries richer fields
|
||||||
|
* (sender_display_name, content_sender_ts, content_lifetime) than a JS
|
||||||
|
* MatrixEvent-derived upsert, so a later upsert for the same eventId
|
||||||
|
* preserves fields the first-observed payload already had.
|
||||||
|
*
|
||||||
|
* Latest-wins per room: native notification identity is room-scoped
|
||||||
|
* (tag=call_<roomId>), so a second upsert for a different eventId in
|
||||||
|
* the same DM evicts the prior entry and tombstones it — the newer
|
||||||
|
* ring owns the single slot and its Answer/Decline PendingIntents bind
|
||||||
|
* to its eventId, not to the stale one's.
|
||||||
|
*
|
||||||
|
* All reads and mutations of ringRegistry + ringTombstones run under
|
||||||
|
* 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) {
|
||||||
|
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");
|
||||||
|
synchronized (registryLock) {
|
||||||
|
purgeExpiredTombstones();
|
||||||
|
Long tombstoneExpiry = ringTombstones.get(eventId);
|
||||||
|
if (tombstoneExpiry != null && tombstoneExpiry > System.currentTimeMillis()) {
|
||||||
|
dlog("upsert: tombstoned event=" + eventId + " until=" + tombstoneExpiry);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest-wins per room, compared by content_sender_ts. A late
|
||||||
|
// FCM retry / reordered /sync / decrypt lag can deliver an older
|
||||||
|
// ring AFTER a newer same-room ring already settled in registry;
|
||||||
|
// pure last-observed-wins would let the stale event evict the
|
||||||
|
// newer one. We compare sender_ts: newer always wins; if the
|
||||||
|
// incoming event is older than an existing same-room entry we
|
||||||
|
// drop the incoming and tombstone its eventId so re-deliveries
|
||||||
|
// stay rejected. When either side has no sender_ts (malformed
|
||||||
|
// push payload) we fall back to last-observed (evict existing),
|
||||||
|
// matching the prior behavior for that corner.
|
||||||
|
if (roomId != null) {
|
||||||
|
long newSenderTs = parseLong(data.get("content_sender_ts"), -1L);
|
||||||
|
Iterator<Map.Entry<String, IncomingRing>> it = ringRegistry.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Map.Entry<String, IncomingRing> e = it.next();
|
||||||
|
if (e.getKey().equals(eventId)) continue;
|
||||||
|
IncomingRing other = e.getValue();
|
||||||
|
if (!roomId.equals(other.data.get("room_id"))) continue;
|
||||||
|
long otherSenderTs = parseLong(other.data.get("content_sender_ts"), -1L);
|
||||||
|
if (newSenderTs > 0 && otherSenderTs > 0 && newSenderTs < otherSenderTs) {
|
||||||
|
// Incoming is strictly older. Drop + tombstone it;
|
||||||
|
// keep existing newer ring intact.
|
||||||
|
long newLifetime = parseLong(
|
||||||
|
data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
|
||||||
|
ringTombstones.put(eventId,
|
||||||
|
System.currentTimeMillis() + 2 * newLifetime + RTC_LIFETIME_GRACE_MS);
|
||||||
|
dlog("upsert: drop stale same-room event=" + eventId
|
||||||
|
+ " room=" + roomId + " incomingTs=" + newSenderTs
|
||||||
|
+ " existingTs=" + otherSenderTs);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Incoming is newer (or ts unknown on either side) — evict
|
||||||
|
// existing and tombstone its eventId.
|
||||||
|
long otherLifetime = parseLong(
|
||||||
|
other.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
|
||||||
|
ringTombstones.put(e.getKey(),
|
||||||
|
System.currentTimeMillis() + 2 * otherLifetime + RTC_LIFETIME_GRACE_MS);
|
||||||
|
dlog("upsert: evict older same-room entry event="
|
||||||
|
+ e.getKey() + " room=" + roomId + " supersededBy=" + eventId);
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IncomingRing existing = ringRegistry.get(eventId);
|
||||||
|
if (existing != null) {
|
||||||
|
boolean mergedAny = false;
|
||||||
|
for (Map.Entry<String, String> e : data.entrySet()) {
|
||||||
|
if (!existing.data.containsKey(e.getKey()) && e.getValue() != null) {
|
||||||
|
existing.data.put(e.getKey(), e.getValue());
|
||||||
|
mergedAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// messageId sits on the IncomingRing, not in data. JS seeds
|
||||||
|
// with null; when FCM arrives later with a real messageId we
|
||||||
|
// want to adopt it so the Answer PendingIntent's
|
||||||
|
// google.message_id is non-empty. Only overwrite when
|
||||||
|
// existing is absent — the first-observed FCM messageId wins.
|
||||||
|
if (existing.messageId == null && messageId != null) {
|
||||||
|
existing.messageId = messageId;
|
||||||
|
mergedAny = true;
|
||||||
|
}
|
||||||
|
if (mergedAny) {
|
||||||
|
dlog("upsert: merged fields event=" + eventId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ringRegistry.put(eventId, new IncomingRing(data, messageId, System.currentTimeMillis()));
|
||||||
|
dlog("upsert: seed event=" + eventId + " room=" + roomId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render every registry entry that is still live and not yet rendered.
|
||||||
|
* Called from MainActivity.onPause. Idempotent: entries with renderedAt>0
|
||||||
|
* are skipped.
|
||||||
|
*/
|
||||||
|
static void renderRegistry(Context ctx) {
|
||||||
|
java.util.List<IncomingRing> snapshot;
|
||||||
|
long now;
|
||||||
|
// Lock window: purge + snapshot only. renderOne posts Binder-heavy
|
||||||
|
// notifications, which must never run under registryLock or a slow
|
||||||
|
// NotificationManager callback could stall upserts.
|
||||||
|
synchronized (registryLock) {
|
||||||
|
if (ringRegistry.isEmpty()) return;
|
||||||
|
now = System.currentTimeMillis();
|
||||||
|
purgeExpiredTombstones();
|
||||||
|
purgeExpiredEntries(now);
|
||||||
|
snapshot = new java.util.ArrayList<>(ringRegistry.values());
|
||||||
|
}
|
||||||
|
dlog("render: walk registry size=" + snapshot.size());
|
||||||
|
for (IncomingRing entry : snapshot) {
|
||||||
|
// Skip entries already posted — renderOne only sets renderedAt on
|
||||||
|
// a successful nm.notify, and cancelRenderedIncomingRings resets
|
||||||
|
// it on onResume. Cooldown-driven "silent" re-alert is handled
|
||||||
|
// inside renderOne via lastAlertedAt so we still post visual
|
||||||
|
// (just silent) instead of dropping the surface entirely.
|
||||||
|
if (entry.renderedAt > 0) continue;
|
||||||
|
if (isExpired(entry, now)) continue;
|
||||||
|
renderOne(ctx, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel native surfaces for all rendered entries and clear their
|
||||||
|
* renderedAt flag. Registry entries persist — they represent live ring
|
||||||
|
* state, not render history. Called from MainActivity.onResume. Uses an
|
||||||
|
* extras-based ownership check to avoid cancelling a same-slot native
|
||||||
|
* raised by a foreign path (different eventId in the same DM).
|
||||||
|
*/
|
||||||
|
static void cancelRenderedIncomingRings(Context ctx) {
|
||||||
|
java.util.List<IncomingRing> snapshot;
|
||||||
|
java.util.Set<String> liveEventIds;
|
||||||
|
synchronized (registryLock) {
|
||||||
|
snapshot = new java.util.ArrayList<>(ringRegistry.values());
|
||||||
|
liveEventIds = new java.util.HashSet<>(ringRegistry.keySet());
|
||||||
|
}
|
||||||
|
NotificationManager nm =
|
||||||
|
(NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
|
if (nm == null) return;
|
||||||
|
StatusBarNotification[] active;
|
||||||
|
try {
|
||||||
|
active = nm.getActiveNotifications();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "cancelRendered: getActiveNotifications threw", t);
|
||||||
|
active = new StatusBarNotification[0];
|
||||||
|
}
|
||||||
|
// 1) Cancel entries the registry knows it rendered and reset
|
||||||
|
// renderedAt so the next onPause re-renders the surface (bg→fg→bg
|
||||||
|
// toggle scenarios where the ring is still live). lastAlertedAt is
|
||||||
|
// preserved — renderOne uses it as a cooldown to suppress ringtone
|
||||||
|
// on a re-render within RE_ALERT_COOLDOWN_MS of the previous alert.
|
||||||
|
for (IncomingRing entry : snapshot) {
|
||||||
|
if (entry.renderedAt == 0) continue;
|
||||||
|
String eventId = entry.data.get("event_id");
|
||||||
|
String roomId = entry.data.get("room_id");
|
||||||
|
if (eventId == null || roomId == null) {
|
||||||
|
entry.renderedAt = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String tag = "call_" + roomId;
|
||||||
|
int notifId = tag.hashCode();
|
||||||
|
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
|
||||||
|
cancelWithOwnershipCheck(nm, active, tag, notifId, eventId);
|
||||||
|
entry.renderedAt = 0;
|
||||||
|
}
|
||||||
|
// 2) Orphan sweep: cancel any active CallStyle carrying our
|
||||||
|
// EXTRA_RING_EVENT_ID whose eventId is NOT in the current registry.
|
||||||
|
// Covers the process-kill case where the static registry was cleared
|
||||||
|
// but NotificationManager kept the notification visible, and the
|
||||||
|
// foreign-owner-leftover case where an evicted entry's native is still
|
||||||
|
// on screen. Foreign rings posted by other apps are ignored (no
|
||||||
|
// matching extra), and our own rings still live in registry are left
|
||||||
|
// for the owned-cancel loop above.
|
||||||
|
for (StatusBarNotification sbn : active) {
|
||||||
|
String tag = sbn.getTag();
|
||||||
|
if (tag == null || !tag.startsWith("call_")) continue;
|
||||||
|
Bundle extras = sbn.getNotification() != null
|
||||||
|
? sbn.getNotification().extras : null;
|
||||||
|
String slotEventId = extras != null
|
||||||
|
? extras.getString(EXTRA_RING_EVENT_ID) : null;
|
||||||
|
if (slotEventId == null) continue;
|
||||||
|
if (liveEventIds.contains(slotEventId)) continue;
|
||||||
|
try {
|
||||||
|
nm.cancel(tag, sbn.getId());
|
||||||
|
dlog("cancelRendered: orphan swept tag=" + tag
|
||||||
|
+ " eventId=" + slotEventId);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "cancelRendered: orphan cancel threw tag=" + tag, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a registry entry, tombstone the eventId, and cancel any native
|
||||||
|
* surface we rendered for it. Called from JS bridge (atom REMOVE /
|
||||||
|
* suppress paths), CallDeclineReceiver (native Decline action), and
|
||||||
|
* CallCancelReceiver (expiry alarm).
|
||||||
|
*
|
||||||
|
* Tombstone window = 2 × entry lifetime + grace when the removed entry
|
||||||
|
* is known (covers per-event content_lifetime variation); falls back to
|
||||||
|
* 2 × RTC_DEFAULT_LIFETIME_MS + grace for remove-before-upsert calls
|
||||||
|
* that have no entry to read lifetime from. Covers FCM retry window and
|
||||||
|
* sender-clock skew.
|
||||||
|
*
|
||||||
|
* Registry+tombstone mutation runs under registryLock; Binder calls
|
||||||
|
* (NotificationManager.getActiveNotifications, nm.cancel) run outside
|
||||||
|
* the lock so a slow system callback cannot stall concurrent upserts.
|
||||||
|
*/
|
||||||
|
static void removeIncomingRing(Context ctx, String eventId) {
|
||||||
|
if (eventId == null || eventId.isEmpty()) return;
|
||||||
|
IncomingRing removed;
|
||||||
|
long tombstoneWindow;
|
||||||
|
synchronized (registryLock) {
|
||||||
|
// Opportunistic purge on every remove — without this, tombstones
|
||||||
|
// grow unbounded if upserts stop flowing.
|
||||||
|
purgeExpiredTombstones();
|
||||||
|
removed = ringRegistry.remove(eventId);
|
||||||
|
long lifetime = removed != null
|
||||||
|
? parseLong(removed.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS)
|
||||||
|
: RTC_DEFAULT_LIFETIME_MS;
|
||||||
|
tombstoneWindow = 2 * lifetime + RTC_LIFETIME_GRACE_MS;
|
||||||
|
ringTombstones.put(eventId, System.currentTimeMillis() + tombstoneWindow);
|
||||||
|
}
|
||||||
|
dlog("remove: event=" + eventId + " had=" + (removed != null)
|
||||||
|
+ " tombstoneWindow=" + tombstoneWindow);
|
||||||
|
if (removed == null || removed.renderedAt == 0) return;
|
||||||
|
String roomId = removed.data.get("room_id");
|
||||||
|
if (roomId == null) return;
|
||||||
|
String tag = "call_" + roomId;
|
||||||
|
int notifId = tag.hashCode();
|
||||||
|
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
|
||||||
|
NotificationManager nm =
|
||||||
|
(NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
|
if (nm == null) return;
|
||||||
|
StatusBarNotification[] active;
|
||||||
|
try {
|
||||||
|
active = nm.getActiveNotifications();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "remove: getActiveNotifications threw", t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelWithOwnershipCheck(nm, active, tag, notifId, eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void cancelWithOwnershipCheck(
|
||||||
|
NotificationManager nm,
|
||||||
|
StatusBarNotification[] active,
|
||||||
|
String tag,
|
||||||
|
int notifId,
|
||||||
|
String expectedEventId
|
||||||
|
) {
|
||||||
|
for (StatusBarNotification sbn : active) {
|
||||||
|
if (!tag.equals(sbn.getTag()) || sbn.getId() != notifId) continue;
|
||||||
|
Bundle extras = sbn.getNotification() != null
|
||||||
|
? sbn.getNotification().extras : null;
|
||||||
|
String slotEventId = extras != null
|
||||||
|
? extras.getString(EXTRA_RING_EVENT_ID) : null;
|
||||||
|
if (expectedEventId.equals(slotEventId)) {
|
||||||
|
try {
|
||||||
|
nm.cancel(tag, notifId);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "cancel threw tag=" + tag, t);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dlog("cancel: foreign-owner slot tag=" + tag
|
||||||
|
+ " slotEventId=" + slotEventId
|
||||||
|
+ " expected=" + expectedEventId + " — leaving intact");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void renderOne(Context ctx, IncomingRing entry) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
// Silent re-render if an alerting post landed less than
|
||||||
|
// RE_ALERT_COOLDOWN_MS ago. lastAlertedAt persists across cancel so
|
||||||
|
// a bg→fg→bg toggle within the cooldown stays silent even though
|
||||||
|
// renderedAt is reset by cancelRenderedIncomingRings.
|
||||||
|
boolean silent = entry.lastAlertedAt > 0
|
||||||
|
&& (now - entry.lastAlertedAt) < RE_ALERT_COOLDOWN_MS;
|
||||||
|
boolean posted = postIncomingCallNotification(
|
||||||
|
ctx, entry.data, entry.messageId,
|
||||||
|
entry.data.get("event_id"), entry.seededAt, silent
|
||||||
|
);
|
||||||
|
// Only mark rendered on a successful nm.notify so the next render
|
||||||
|
// cycle retries on transient Binder failure instead of leaving the
|
||||||
|
// entry permanently "rendered" with no actual native surface.
|
||||||
|
if (posted) {
|
||||||
|
entry.renderedAt = now;
|
||||||
|
// Only advance the cooldown baseline on actually-alerting posts.
|
||||||
|
// Silent posts don't re-trigger ringtone so they don't count
|
||||||
|
// toward the cooldown window; otherwise a chain of silent posts
|
||||||
|
// would indefinitely defer the next allowed alert.
|
||||||
|
if (!silent) entry.lastAlertedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller must hold registryLock.
|
||||||
|
private static void purgeExpiredTombstones() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Iterator<Map.Entry<String, Long>> it = ringTombstones.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
if (it.next().getValue() < now) it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller must hold registryLock.
|
||||||
|
private static void purgeExpiredEntries(long now) {
|
||||||
|
Iterator<Map.Entry<String, IncomingRing>> it = ringRegistry.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Map.Entry<String, IncomingRing> e = it.next();
|
||||||
|
if (isExpired(e.getValue(), now)) {
|
||||||
|
dlog("purge: expired event=" + e.getKey());
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isExpired(IncomingRing entry, long now) {
|
||||||
|
long senderTs = parseLong(entry.data.get("content_sender_ts"), -1L);
|
||||||
|
long lifetime = parseLong(entry.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
|
||||||
|
long baseTs = (senderTs > 0) ? senderTs : entry.seededAt;
|
||||||
|
return baseTs + lifetime + RTC_LIFETIME_GRACE_MS < now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Post path (shared between FCM-direct and registry-render)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package-private, static — posts the CallStyle incoming-call notification.
|
||||||
|
* All entry points (FCM-direct bg, registry-render) funnel through here so
|
||||||
|
* channel creation, action intents, ownership extras, and expiry alarm
|
||||||
|
* stay in lock-step.
|
||||||
|
*
|
||||||
|
* Returns true iff nm.notify succeeded. Callers (renderOne) use this to
|
||||||
|
* gate setting renderedAt — a false return leaves the entry unrendered so
|
||||||
|
* the next onPause cycle retries.
|
||||||
|
*
|
||||||
|
* ringEventId: tagged onto the notification's extras for ownership check
|
||||||
|
* on cancel (same-DM foreign rings must not be killed by our cancels).
|
||||||
|
* fallbackBaseTs: baseline used for expiry alarm when the payload lacks
|
||||||
|
* `content_sender_ts`. Callers pass `now` for FCM-direct (ring just
|
||||||
|
* arrived) or the registry entry's seededAt (ring was seeded earlier,
|
||||||
|
* alarm must target true expiry).
|
||||||
|
* silent: when true, attaches builder.setSilent(true) so the post goes
|
||||||
|
* out without ringtone / vibration / heads-up, keeping the visual
|
||||||
|
* banner. Used by renderOne for the cooldown-gated re-render path.
|
||||||
|
*/
|
||||||
|
static boolean postIncomingCallNotification(
|
||||||
|
Context ctx,
|
||||||
|
Map<String, String> data,
|
||||||
|
String messageId,
|
||||||
|
String ringEventId,
|
||||||
|
long fallbackBaseTs,
|
||||||
|
boolean silent
|
||||||
|
) {
|
||||||
String roomId = data.get("room_id");
|
String roomId = data.get("room_id");
|
||||||
String notifEventId = data.get("event_id");
|
String notifEventId = data.get("event_id");
|
||||||
if (roomId == null || notifEventId == null) {
|
if (roomId == null || notifEventId == null) {
|
||||||
Log.w(TAG, "call: missing roomId/eventId, abort");
|
Log.w(TAG, "call: missing roomId/eventId, abort");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
if (nm == null) {
|
if (nm == null) {
|
||||||
Log.w(TAG, "call: NotificationManager is null, abort");
|
Log.w(TAG, "call: NotificationManager is null, abort");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureCallChannel(nm);
|
ensureCallChannel(ctx, nm);
|
||||||
|
|
||||||
String callerName = firstNonEmpty(
|
String callerName = firstNonEmpty(
|
||||||
data.get("sender_display_name"),
|
data.get("sender_display_name"),
|
||||||
|
|
@ -193,7 +690,6 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
data.get("sender"),
|
data.get("sender"),
|
||||||
"Vojo"
|
"Vojo"
|
||||||
);
|
);
|
||||||
String messageId = message.getMessageId();
|
|
||||||
String tag = "call_" + roomId;
|
String tag = "call_" + roomId;
|
||||||
int notifId = tag.hashCode();
|
int notifId = tag.hashCode();
|
||||||
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
|
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
|
||||||
|
|
@ -205,40 +701,44 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
int launchReq = ("open_" + notifEventId).hashCode();
|
int launchReq = ("open_" + notifEventId).hashCode();
|
||||||
|
|
||||||
PendingIntent answerPI = buildActionPI(
|
PendingIntent answerPI = buildActionPI(
|
||||||
answerReq, "answer", roomId, notifEventId, messageId
|
ctx, answerReq, "answer", roomId, notifEventId, messageId
|
||||||
);
|
);
|
||||||
// Decline goes through a BroadcastReceiver so the WebView never boots
|
|
||||||
// — m.call.decline is sent directly from Java via the stored access
|
|
||||||
// token (see CallDeclineReceiver). MainActivity is only involved on
|
|
||||||
// fresh-reinstall / null-session fallback, where the receiver leaves
|
|
||||||
// the ring intact and the JS-path consumer takes over after unlock.
|
|
||||||
PendingIntent declinePI = buildDeclineBroadcastPI(
|
PendingIntent declinePI = buildDeclineBroadcastPI(
|
||||||
declineReq, roomId, notifEventId, tag, notifId
|
ctx, declineReq, roomId, notifEventId, tag, notifId
|
||||||
);
|
);
|
||||||
PendingIntent launchPI = buildActionPI(
|
PendingIntent launchPI = buildActionPI(
|
||||||
launchReq, null, roomId, notifEventId, messageId
|
ctx, launchReq, null, roomId, notifEventId, messageId
|
||||||
);
|
);
|
||||||
|
|
||||||
Person caller = new Person.Builder().setName(callerName).build();
|
Person caller = new Person.Builder().setName(callerName).build();
|
||||||
|
|
||||||
Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
|
Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
|
||||||
|
|
||||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CALL_CHANNEL_ID)
|
NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, CALL_CHANNEL_ID)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
|
// Suppress re-alert on same-(tag, id) updates (e.g., same-ring
|
||||||
|
// re-render without cancel between). Fresh alert still fires after
|
||||||
|
// a cancel + repost cycle — Android treats that as a new post.
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
.setContentIntent(launchPI)
|
.setContentIntent(launchPI)
|
||||||
// Full-screen wakeup over lockscreen — the "real" incoming-call UX
|
// Full-screen wakeup over lockscreen — the "real" incoming-call UX.
|
||||||
// (WhatsApp/Telegram-style). Also the only reliable way to satisfy
|
// Also the only reliable way to satisfy AOSP's checkDisqualifyingFeatures
|
||||||
// AOSP's checkDisqualifyingFeatures gate for CallStyle on API 31+;
|
// gate for CallStyle on API 31+; Samsung OneUI specifically rejects
|
||||||
// Samsung OneUI specifically rejects CallStyle with `fgs=false` +
|
// CallStyle with `fgs=false` + `FullScreenIntent=null`.
|
||||||
// `FullScreenIntent=null` even when USE_FULL_SCREEN_INTENT is just
|
|
||||||
// declared. Requires the permission in the manifest.
|
|
||||||
.setFullScreenIntent(launchPI, true)
|
.setFullScreenIntent(launchPI, true)
|
||||||
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declinePI, answerPI));
|
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declinePI, answerPI));
|
||||||
|
|
||||||
|
if (ringEventId != null) {
|
||||||
|
Bundle extras = new Bundle();
|
||||||
|
extras.putString(EXTRA_RING_EVENT_ID, ringEventId);
|
||||||
|
builder.addExtras(extras);
|
||||||
|
}
|
||||||
|
if (silent) builder.setSilent(true);
|
||||||
|
|
||||||
// Builder-level sound/vibration are ignored on API 26+ once the
|
// Builder-level sound/vibration are ignored on API 26+ once the
|
||||||
// channel has its own settings — we configure both on the channel in
|
// channel has its own settings — we configure both on the channel in
|
||||||
// ensureCallChannel() and skip the redundant builder calls here.
|
// ensureCallChannel() and skip the redundant builder calls here.
|
||||||
|
|
@ -247,31 +747,36 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
builder.setVibrate(buildRingVibrationPattern());
|
builder.setVibrate(buildRingVibrationPattern());
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "call: posting notif tag=" + tag + " id=" + notifId
|
dlog("call: posting notif tag=" + tag + " id=" + notifId
|
||||||
+ " channel=" + CALL_CHANNEL_ID + " notifsEnabled=" + nm.areNotificationsEnabled());
|
+ " channel=" + CALL_CHANNEL_ID + " notifsEnabled=" + nm.areNotificationsEnabled()
|
||||||
|
+ " ringEventId=" + ringEventId);
|
||||||
try {
|
try {
|
||||||
nm.notify(tag, notifId, builder.build());
|
nm.notify(tag, notifId, builder.build());
|
||||||
Log.d(TAG, "call: nm.notify returned OK");
|
dlog("call: nm.notify returned OK");
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
Log.e(TAG, "call: nm.notify threw", t);
|
Log.e(TAG, "call: nm.notify threw", t);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
scheduleCallNotificationExpiry(data, tag, notifId);
|
scheduleCallNotificationExpiry(ctx, data, tag, notifId, fallbackBaseTs);
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
Log.e(TAG, "call: scheduleCallNotificationExpiry threw", t);
|
Log.e(TAG, "call: scheduleCallNotificationExpiry threw", t);
|
||||||
|
// Not a post failure — alarm is a separate concern, nm.notify
|
||||||
|
// already succeeded. Leave return=true so renderedAt is set.
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private PendingIntent buildActionPI(
|
private static PendingIntent buildActionPI(
|
||||||
|
Context ctx,
|
||||||
int requestCode,
|
int requestCode,
|
||||||
String callAction,
|
String callAction,
|
||||||
String roomId,
|
String roomId,
|
||||||
String notifEventId,
|
String notifEventId,
|
||||||
String messageId
|
String messageId
|
||||||
) {
|
) {
|
||||||
Intent intent = new Intent(this, MainActivity.class)
|
Intent intent = new Intent(ctx, MainActivity.class)
|
||||||
.setAction(Intent.ACTION_VIEW)
|
.setAction(Intent.ACTION_VIEW)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||||
// Capacitor PushNotificationsPlugin gates `pushNotificationActionPerformed`
|
// Capacitor PushNotificationsPlugin gates `pushNotificationActionPerformed`
|
||||||
|
|
@ -283,17 +788,18 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
if (callAction != null) intent.putExtra("call_action", callAction);
|
if (callAction != null) intent.putExtra("call_action", callAction);
|
||||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||||
return PendingIntent.getActivity(this, requestCode, intent, flags);
|
return PendingIntent.getActivity(ctx, requestCode, intent, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
private PendingIntent buildDeclineBroadcastPI(
|
private static PendingIntent buildDeclineBroadcastPI(
|
||||||
|
Context ctx,
|
||||||
int requestCode,
|
int requestCode,
|
||||||
String roomId,
|
String roomId,
|
||||||
String notifEventId,
|
String notifEventId,
|
||||||
String notifTag,
|
String notifTag,
|
||||||
int notifId
|
int notifId
|
||||||
) {
|
) {
|
||||||
Intent intent = new Intent(this, CallDeclineReceiver.class)
|
Intent intent = new Intent(ctx, CallDeclineReceiver.class)
|
||||||
.setAction(CallDeclineReceiver.ACTION_DECLINE_CALL)
|
.setAction(CallDeclineReceiver.ACTION_DECLINE_CALL)
|
||||||
.putExtra(CallDeclineReceiver.EXTRA_ROOM_ID, roomId)
|
.putExtra(CallDeclineReceiver.EXTRA_ROOM_ID, roomId)
|
||||||
.putExtra(CallDeclineReceiver.EXTRA_NOTIF_EVENT_ID, notifEventId)
|
.putExtra(CallDeclineReceiver.EXTRA_NOTIF_EVENT_ID, notifEventId)
|
||||||
|
|
@ -301,7 +807,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
.putExtra(CallDeclineReceiver.EXTRA_NOTIF_ID, notifId);
|
.putExtra(CallDeclineReceiver.EXTRA_NOTIF_ID, notifId);
|
||||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||||
return PendingIntent.getBroadcast(this, requestCode, intent, flags);
|
return PendingIntent.getBroadcast(ctx, requestCode, intent, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors the JS-side createChannel in usePushNotifications.ts. Lazy creation
|
// Mirrors the JS-side createChannel in usePushNotifications.ts. Lazy creation
|
||||||
|
|
@ -312,7 +818,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
private void ensureMessageChannel(NotificationManager nm) {
|
private void ensureMessageChannel(NotificationManager nm) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||||
if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
|
if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
|
||||||
Log.d(TAG, "msg: creating channel " + CHANNEL_ID);
|
dlog("msg: creating channel " + CHANNEL_ID);
|
||||||
NotificationChannel channel = new NotificationChannel(
|
NotificationChannel channel = new NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Messages",
|
"Messages",
|
||||||
|
|
@ -324,16 +830,16 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
nm.createNotificationChannel(channel);
|
nm.createNotificationChannel(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureCallChannel(NotificationManager nm) {
|
private static void ensureCallChannel(Context ctx, NotificationManager nm) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||||
if (nm.getNotificationChannel(CALL_CHANNEL_ID) != null) return;
|
if (nm.getNotificationChannel(CALL_CHANNEL_ID) != null) return;
|
||||||
// Drop the pre-v2 channel on first creation of v2 so it doesn't linger
|
// Drop the pre-v2 channel on first creation of v2 so it doesn't linger
|
||||||
// in Settings → Notifications (user-visible cruft) after the bump.
|
// in Settings → Notifications (user-visible cruft) after the bump.
|
||||||
if (nm.getNotificationChannel(LEGACY_CALL_CHANNEL_ID) != null) {
|
if (nm.getNotificationChannel(LEGACY_CALL_CHANNEL_ID) != null) {
|
||||||
Log.d(TAG, "call: deleting legacy channel " + LEGACY_CALL_CHANNEL_ID);
|
dlog("call: deleting legacy channel " + LEGACY_CALL_CHANNEL_ID);
|
||||||
nm.deleteNotificationChannel(LEGACY_CALL_CHANNEL_ID);
|
nm.deleteNotificationChannel(LEGACY_CALL_CHANNEL_ID);
|
||||||
}
|
}
|
||||||
Log.d(TAG, "call: creating channel " + CALL_CHANNEL_ID);
|
dlog("call: creating channel " + CALL_CHANNEL_ID);
|
||||||
NotificationChannel channel = new NotificationChannel(
|
NotificationChannel channel = new NotificationChannel(
|
||||||
CALL_CHANNEL_ID,
|
CALL_CHANNEL_ID,
|
||||||
"Incoming calls",
|
"Incoming calls",
|
||||||
|
|
@ -360,31 +866,32 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
nm.createNotificationChannel(channel);
|
nm.createNotificationChannel(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void scheduleCallNotificationExpiry(
|
private static void scheduleCallNotificationExpiry(
|
||||||
|
Context ctx,
|
||||||
Map<String, String> data,
|
Map<String, String> data,
|
||||||
String tag,
|
String tag,
|
||||||
int notifId
|
int notifId,
|
||||||
|
long fallbackBaseTs
|
||||||
) {
|
) {
|
||||||
long senderTs = parseLong(data.get("content_sender_ts"), -1L);
|
long senderTs = parseLong(data.get("content_sender_ts"), -1L);
|
||||||
long lifetime = parseLong(data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
|
long lifetime = parseLong(data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
|
||||||
long baseTs = (senderTs > 0) ? senderTs : System.currentTimeMillis();
|
// Callers pass `now` for FCM-direct (ring just arrived) and
|
||||||
|
// entry.seededAt for registry-render (ring was seeded earlier).
|
||||||
|
long baseTs = (senderTs > 0) ? senderTs : fallbackBaseTs;
|
||||||
long triggerAt = baseTs + lifetime + RTC_LIFETIME_GRACE_MS;
|
long triggerAt = baseTs + lifetime + RTC_LIFETIME_GRACE_MS;
|
||||||
|
|
||||||
AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
|
AlarmManager am = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE);
|
||||||
if (am == null) return;
|
if (am == null) return;
|
||||||
|
|
||||||
Intent cancelIntent = new Intent(this, CallCancelReceiver.class)
|
Intent cancelIntent = new Intent(ctx, CallCancelReceiver.class)
|
||||||
.setAction(CallCancelReceiver.ACTION_CANCEL_CALL)
|
.setAction(CallCancelReceiver.ACTION_CANCEL_CALL)
|
||||||
.putExtra(CallCancelReceiver.EXTRA_NOTIF_TAG, tag)
|
.putExtra(CallCancelReceiver.EXTRA_NOTIF_TAG, tag)
|
||||||
.putExtra(CallCancelReceiver.EXTRA_NOTIF_ID, notifId);
|
.putExtra(CallCancelReceiver.EXTRA_NOTIF_ID, notifId)
|
||||||
|
.putExtra(CallCancelReceiver.EXTRA_NOTIF_EVENT_ID, data.get("event_id"));
|
||||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||||
PendingIntent pi = PendingIntent.getBroadcast(this, notifId, cancelIntent, flags);
|
PendingIntent pi = PendingIntent.getBroadcast(ctx, notifId, cancelIntent, flags);
|
||||||
|
|
||||||
// setAndAllowWhileIdle is enough for a 30s-ish dismiss: exactness is
|
|
||||||
// nice-to-have but not worth the SCHEDULE_EXACT_ALARM user grant on API 34+.
|
|
||||||
// Drift of a few minutes on doze-deep devices is acceptable — the JS layer
|
|
||||||
// cancels proactively on decline / self-join anyway.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi);
|
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1022,11 +1022,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
[string, MatrixEvent, number, EventTimelineSet, boolean]
|
[string, MatrixEvent, number, EventTimelineSet, boolean]
|
||||||
>(
|
>(
|
||||||
{
|
{
|
||||||
// Suppress DM-call service events from the timeline (§5.8). In encrypted
|
// Suppress DM-call service events from the timeline. In encrypted
|
||||||
// DMs this takes effect after per-event decryption re-render via
|
// DMs this takes effect after per-event decryption re-render via
|
||||||
// EncryptedContent; the first render shows the "not decrypted" placeholder.
|
// EncryptedContent; the first render shows the "not decrypted" placeholder.
|
||||||
// Hardcoded strings — migrate to EventType.RTCNotification/RTCDecline
|
// Hardcoded strings — migrate to EventType.RTCNotification/RTCDecline
|
||||||
// when MSC4075/MSC4310 stabilize (§5.19).
|
// when MSC4075/MSC4310 stabilize.
|
||||||
'org.matrix.msc4075.rtc.notification': () => null,
|
'org.matrix.msc4075.rtc.notification': () => null,
|
||||||
'org.matrix.msc4310.rtc.decline': () => null,
|
'org.matrix.msc4310.rtc.decline': () => null,
|
||||||
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
|
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
|
||||||
|
|
@ -1129,11 +1129,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
return (
|
return (
|
||||||
<EncryptedContent mEvent={mEvent}>
|
<EncryptedContent mEvent={mEvent}>
|
||||||
{() => {
|
{() => {
|
||||||
// §5.9: after decrypt, DM-call service events still route through
|
// After decrypt, DM-call service events still route through
|
||||||
// this branch (outer typeToRenderer dispatched on the pre-decrypt
|
// this branch (outer typeToRenderer dispatched on the pre-decrypt
|
||||||
// 'm.room.encrypted' type). Drop the whole row instead of falling
|
// 'm.room.encrypted' type). Drop the whole row instead of falling
|
||||||
// through to MessageUnsupportedContent. Keys mirror the hardcoded
|
// through to MessageUnsupportedContent. Keys mirror the hardcoded
|
||||||
// literals in the outer filter — migrate together (§5.19).
|
// literals in the outer filter — migrate together.
|
||||||
const decryptedType = mEvent.getType();
|
const decryptedType = mEvent.getType();
|
||||||
if (decryptedType === 'org.matrix.msc4075.rtc.notification') return null;
|
if (decryptedType === 'org.matrix.msc4075.rtc.notification') return null;
|
||||||
if (decryptedType === 'org.matrix.msc4310.rtc.decline') return null;
|
if (decryptedType === 'org.matrix.msc4310.rtc.decline') return null;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
// retention. The call isn't actually live in that window — media hasn't
|
// retention. The call isn't actually live in that window — media hasn't
|
||||||
// started — so lock-screen there is a non-event; user can retry.
|
// started — so lock-screen there is a non-event; user can retry.
|
||||||
//
|
//
|
||||||
// Android-only. Context: docs/plans/dm_calls_techdebt.md §2.2.
|
// Android-only.
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,16 @@
|
||||||
//
|
//
|
||||||
// Scope despite the name: fires on any DM callEmbed, including when we're the
|
// Scope despite the name: fires on any DM callEmbed, including when we're the
|
||||||
// B-side (answered a ring). Logic is symmetric: "leave if peer left or never
|
// B-side (answered a ring). Logic is symmetric: "leave if peer left or never
|
||||||
// arrived". Rename pending — see dm_calls_techdebt.md §5.15.
|
// arrived". Rename pending.
|
||||||
//
|
//
|
||||||
// Covers four cases (all §5.5):
|
// Covers four cases:
|
||||||
// 1. Peer declines — RTCDecline timeline event → hangup immediately.
|
// 1. Peer declines — RTCDecline timeline event → hangup immediately.
|
||||||
// 2. Peer never joins — no-answer timer fires (lifetime + grace).
|
// 2. Peer never joins — no-answer timer fires (lifetime + grace).
|
||||||
// 3. Peer joins then leaves — memberships go empty → hangup after grace.
|
// 3. Peer joins then leaves — memberships go empty → hangup after grace.
|
||||||
// 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip.
|
// 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip.
|
||||||
//
|
//
|
||||||
// KNOWN GAP (also in dm_calls_techdebt.md):
|
// KNOWN GAP: grace constants are empirically chosen, may need tuning with
|
||||||
// §5.18 Grace constants are empirically chosen, may need tuning with
|
// real-world /sync + LiveKit reconnect metrics.
|
||||||
// real-world /sync + LiveKit reconnect metrics.
|
|
||||||
//
|
//
|
||||||
// Encrypted DMs: RTCDecline arrives as m.room.encrypted first and Timeline
|
// Encrypted DMs: RTCDecline arrives as m.room.encrypted first and Timeline
|
||||||
// does not re-emit post-decrypt (matrix-js-sdk 38.2). We mirror the
|
// does not re-emit post-decrypt (matrix-js-sdk 38.2). We mirror the
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { App } from '@capacitor/app';
|
|
||||||
import { incomingCallsAtom } from '../state/incomingCalls';
|
|
||||||
import { isAndroidPlatform, isNativePlatform } from '../utils/capacitor';
|
|
||||||
|
|
||||||
const SUMMARY_NOTIFICATION_ID = -2147483648;
|
|
||||||
|
|
||||||
// Reproduces java.lang.String#hashCode so JS-side ids match the tag ids the
|
|
||||||
// Java service computed. Must stay in sync with CallCancelReceiver / the
|
|
||||||
// tag.hashCode() call in VojoFirebaseMessagingService.
|
|
||||||
function javaStringHashCode(s: string): number {
|
|
||||||
let h = 0;
|
|
||||||
for (let i = 0; i < s.length; i += 1) {
|
|
||||||
// eslint-disable-next-line no-bitwise
|
|
||||||
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dismissRooms(roomIds: string[]): Promise<void> {
|
|
||||||
if (roomIds.length === 0) return;
|
|
||||||
if (!isNativePlatform()) return;
|
|
||||||
const { PushNotifications } = await import('@capacitor/push-notifications');
|
|
||||||
// Shape mirrors VojoFirebaseMessagingService.showIncomingCallNotification:
|
|
||||||
// tag = "call_" + roomId, id = tag.hashCode(). The Android Capacitor
|
|
||||||
// plugin reads id via JSObject.getInteger (PushNotificationsPlugin.java
|
|
||||||
// line 166), so the id must go over the bridge as a number — the TS
|
|
||||||
// type declares string, hence the cast.
|
|
||||||
const notifications = roomIds.map((roomId) => {
|
|
||||||
const tag = `call_${roomId}`;
|
|
||||||
let id = javaStringHashCode(tag);
|
|
||||||
if (id === SUMMARY_NOTIFICATION_ID) id += 1;
|
|
||||||
return { id, tag };
|
|
||||||
});
|
|
||||||
await PushNotifications.removeDeliveredNotifications({
|
|
||||||
notifications: notifications as unknown as Parameters<
|
|
||||||
typeof PushNotifications.removeDeliveredNotifications
|
|
||||||
>[0]['notifications'],
|
|
||||||
}).catch(() => {
|
|
||||||
/* best-effort — alarm fallback still dismisses at lifetime expiry */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep native CallStyle in sync with the JS-owned incoming-calls atom, enforcing
|
|
||||||
// the "foreground → JS strip, background → platform surface" invariant on the
|
|
||||||
// dismiss side (symmetric to the show-side skip in VojoFirebaseMessagingService
|
|
||||||
// and the JS-audio gate in IncomingCallStripRenderer).
|
|
||||||
// - REMOVE (ring ended: accept/decline/other-device/expiry) → dismiss always.
|
|
||||||
// - ADD while foregrounded → JS strip owns UX → dismiss any stale native.
|
|
||||||
// - ADD while backgrounded → native CallStyle owns UX → keep it, do NOT dismiss.
|
|
||||||
// - background → foreground transition with a live ring → one-shot sweep hands
|
|
||||||
// ownership back to the JS strip.
|
|
||||||
// The AlarmManager fallback in the Java service still handles killed-process
|
|
||||||
// dismiss on lifetime expiry.
|
|
||||||
export const useDismissNativeCallNotifications = (): void => {
|
|
||||||
const incoming = useAtomValue(incomingCallsAtom);
|
|
||||||
const prevRoomsRef = useRef<Set<string>>(new Set());
|
|
||||||
const [appActive, setAppActive] = useState(
|
|
||||||
() => typeof document === 'undefined' || document.visibilityState === 'visible'
|
|
||||||
);
|
|
||||||
|
|
||||||
// App state tracking.
|
|
||||||
// Android: `pause`/`resume` fire inside BridgeActivity.onPause/onResume via
|
|
||||||
// AppPlugin.handleOnPause/Resume — the same Activity callbacks that flip
|
|
||||||
// MainActivity.isInForeground — and reach JS within ms via the WebView
|
|
||||||
// bridge. `appStateChange` on Android fires only in BridgeActivity.onStop,
|
|
||||||
// tens of ms to ~1s after onPause; using it as the gate would leak incoming
|
|
||||||
// rings into a "just backgrounded" window where Java sees background (FCM
|
|
||||||
// shows native CallStyle) but JS still sees active (and the dismiss branch
|
|
||||||
// below would clobber the freshly-raised native ring).
|
|
||||||
// iOS/web keeps `appStateChange` because there it maps to
|
|
||||||
// willResignActive/didBecomeActive (iOS) or document.visibilitychange (web)
|
|
||||||
// — the correct edge. The Capacitor `pause`/`resume` events on iOS bind to
|
|
||||||
// didEnterBackground/willEnterForeground, which is a different semantics.
|
|
||||||
// Race-guard: a real lifecycle event arriving before getState() resolves
|
|
||||||
// must win over the stale snapshot.
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
let sawLifecycleEvent = false;
|
|
||||||
const handles: Array<{ remove: () => void }> = [];
|
|
||||||
const track = (h: { remove: () => void }) => {
|
|
||||||
if (cancelled) h.remove();
|
|
||||||
else handles.push(h);
|
|
||||||
};
|
|
||||||
|
|
||||||
const apply = (isActive: boolean) => {
|
|
||||||
sawLifecycleEvent = true;
|
|
||||||
if (!cancelled) setAppActive(isActive);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isAndroidPlatform()) {
|
|
||||||
App.addListener('pause', () => apply(false)).then(track);
|
|
||||||
App.addListener('resume', () => apply(true)).then(track);
|
|
||||||
} else {
|
|
||||||
App.addListener('appStateChange', ({ isActive }) => apply(isActive)).then(track);
|
|
||||||
}
|
|
||||||
|
|
||||||
App.getState()
|
|
||||||
.then((state) => {
|
|
||||||
if (!cancelled && !sawLifecycleEvent) setAppActive(state.isActive);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* web fallback handled by initial document.visibilityState */
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
handles.forEach((h) => h.remove());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Diff-driven dismiss. prevRoomsRef is the write-site for the current-rooms
|
|
||||||
// snapshot; the handoff-effect below reads it and relies on this effect
|
|
||||||
// running first on shared re-renders (React runs effects in declaration
|
|
||||||
// order — do not reorder).
|
|
||||||
useEffect(() => {
|
|
||||||
const nextRooms = new Set<string>();
|
|
||||||
incoming.forEach((call) => nextRooms.add(call.roomId));
|
|
||||||
|
|
||||||
const added: string[] = [];
|
|
||||||
const removed: string[] = [];
|
|
||||||
prevRoomsRef.current.forEach((roomId) => {
|
|
||||||
if (!nextRooms.has(roomId)) removed.push(roomId);
|
|
||||||
});
|
|
||||||
nextRooms.forEach((roomId) => {
|
|
||||||
if (!prevRoomsRef.current.has(roomId)) added.push(roomId);
|
|
||||||
});
|
|
||||||
prevRoomsRef.current = nextRooms;
|
|
||||||
|
|
||||||
const toDismiss = appActive ? [...removed, ...added] : removed;
|
|
||||||
dismissRooms(toDismiss).catch(() => {
|
|
||||||
/* best-effort */
|
|
||||||
});
|
|
||||||
}, [incoming, appActive]);
|
|
||||||
|
|
||||||
// Handoff sweep on background → foreground. Reads prevRoomsRef populated by
|
|
||||||
// the diff-effect above. Also fires on initial mount when appActive=true
|
|
||||||
// with a non-empty atom (Answer-from-killed cold boot where /sync populated
|
|
||||||
// the atom before first render) — JS now owns ring UX so any native
|
|
||||||
// CallStyle raised by the FCM path must go. Double-dismiss with the
|
|
||||||
// diff-effect on a simultaneous transition+ADD tick is harmless (native
|
|
||||||
// cancel is idempotent).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!appActive) return;
|
|
||||||
if (prevRoomsRef.current.size === 0) return;
|
|
||||||
dismissRooms(Array.from(prevRoomsRef.current)).catch(() => {
|
|
||||||
/* best-effort */
|
|
||||||
});
|
|
||||||
}, [appActive]);
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Incoming DM ring: watches `m.rtc.notification` in the live timeline and
|
// Incoming DM ring: watches `m.rtc.notification` in the live timeline and
|
||||||
// populates `incomingCallsAtom` so the bottom strip can render.
|
// populates `incomingCallsAtom` so the bottom strip can render.
|
||||||
//
|
//
|
||||||
// Encrypted DMs (§5.9): `RoomEvent.Timeline` fires once with
|
// Encrypted DMs: `RoomEvent.Timeline` fires once with
|
||||||
// `ev.getType() === 'm.room.encrypted'` and is never re-emitted after decrypt
|
// `ev.getType() === 'm.room.encrypted'` and is never re-emitted after decrypt
|
||||||
// (matrix-js-sdk 38.2 event-timeline-set.js:563). We listen to both Timeline
|
// (matrix-js-sdk 38.2 event-timeline-set.js:563). We listen to both Timeline
|
||||||
// and `MatrixEventEvent.Decrypted` and kick decryption from the Timeline
|
// and `MatrixEventEvent.Decrypted` and kick decryption from the Timeline
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
// for RTCNotification and on idempotent `removeByNotifId` for RTCDecline, so
|
// for RTCNotification and on idempotent `removeByNotifId` for RTCDecline, so
|
||||||
// double delivery (Timeline for cleartext + Decrypted for encrypted) is safe.
|
// double delivery (Timeline for cleartext + Decrypted for encrypted) is safe.
|
||||||
//
|
//
|
||||||
// The `registryRef` sync-effect below handles an asymmetry §5.6: REMOVE on the
|
// The `registryRef` sync-effect below handles an asymmetry: REMOVE on the
|
||||||
// atom can come from outside the hook (strip buttons), and we need to drop
|
// atom can come from outside the hook (strip buttons), and we need to drop
|
||||||
// timers/listeners for those keys to avoid leaks and to let a fresh ring with
|
// timers/listeners for those keys to avoid leaks and to let a fresh ring with
|
||||||
// the same dedup key re-trigger.
|
// the same dedup key re-trigger.
|
||||||
|
|
@ -44,6 +44,7 @@ import {
|
||||||
isRtcNotificationExpired,
|
isRtcNotificationExpired,
|
||||||
RTC_NOTIFICATION_DEFAULT_LIFETIME,
|
RTC_NOTIFICATION_DEFAULT_LIFETIME,
|
||||||
} from '../utils/rtcNotification';
|
} from '../utils/rtcNotification';
|
||||||
|
import { callForegroundService } from '../plugins/call/callForegroundService';
|
||||||
|
|
||||||
// Returns "" for room-scoped calls (MSC4143/MSC3401v2 — empty call_id means
|
// Returns "" for room-scoped calls (MSC4143/MSC3401v2 — empty call_id means
|
||||||
// "the only call in this room"). Returns undefined when no membership is known.
|
// "the only call in this room"). Returns undefined when no membership is known.
|
||||||
|
|
@ -119,6 +120,10 @@ const resolveCallId = async (
|
||||||
type RegistryEntry = {
|
type RegistryEntry = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
notifEventId: string;
|
notifEventId: string;
|
||||||
|
// Sender-reported origin timestamp (ms since epoch). Used for
|
||||||
|
// latest-wins-by-senderTs on same-room second rings: a late-arriving older
|
||||||
|
// ring must not evict a newer one already seated. See processEvent below.
|
||||||
|
senderTs: number;
|
||||||
timer: ReturnType<typeof setTimeout>;
|
timer: ReturnType<typeof setTimeout>;
|
||||||
unsubMemberships?: () => void;
|
unsubMemberships?: () => void;
|
||||||
};
|
};
|
||||||
|
|
@ -148,9 +153,18 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
}, [callEmbed, setIncoming]);
|
}, [callEmbed, setIncoming]);
|
||||||
|
|
||||||
// Any key dropped from the atom (external REMOVE via strip accept/decline, etc.)
|
// Any key dropped from the atom (external REMOVE via strip accept/decline, etc.)
|
||||||
// must also drop the matching registry entry — otherwise its expiry timer and
|
// must also drop the matching hook-local registry entry — otherwise its
|
||||||
// memberships listener leak, and a fresh ring for the same dedup key would be
|
// expiry timer and memberships listener leak, and a fresh ring for the same
|
||||||
// swallowed by `registry.has(key)` until the timer finally fires.
|
// dedup key would be swallowed by `registry.has(key)` until the timer
|
||||||
|
// finally fires.
|
||||||
|
//
|
||||||
|
// Also catches the external-remove paths for the Android native ring
|
||||||
|
// registry: removals issued by strip buttons, pendingCallActionConsumer,
|
||||||
|
// and the callEmbed join effect go through setIncoming directly without
|
||||||
|
// touching the hook-local registry — this effect is the single place we can
|
||||||
|
// call removeIncomingRing on the bridge for those paths. Internal removals
|
||||||
|
// (removeByKey) call removeIncomingRing inline so they don't rely on this
|
||||||
|
// effect seeing the registry entry.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const registry = registryRef.current;
|
const registry = registryRef.current;
|
||||||
Array.from(registry.keys()).forEach((key) => {
|
Array.from(registry.keys()).forEach((key) => {
|
||||||
|
|
@ -159,6 +173,9 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
clearTimeout(entry.timer);
|
clearTimeout(entry.timer);
|
||||||
entry.unsubMemberships?.();
|
entry.unsubMemberships?.();
|
||||||
|
callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
|
||||||
|
/* best-effort — registry tombstones the eventId regardless */
|
||||||
|
});
|
||||||
registry.delete(key);
|
registry.delete(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -173,6 +190,17 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
clearTimeout(entry.timer);
|
clearTimeout(entry.timer);
|
||||||
entry.unsubMemberships?.();
|
entry.unsubMemberships?.();
|
||||||
|
// Bottom of the internal-removal stack (removeByRoom / removeByNotifId
|
||||||
|
// funnel through here). Bridge the native ring registry remove before
|
||||||
|
// clearing the hook-local registry: the [incoming] sync-effect above
|
||||||
|
// only sees entries still present at atom-change time, so if we
|
||||||
|
// deleted here and then setIncoming, the effect would miss this removal
|
||||||
|
// and the native registry would keep the entry. External paths (strip
|
||||||
|
// buttons, pendingCallActionConsumer) keep registry intact across the
|
||||||
|
// atom change and are handled by the sync-effect.
|
||||||
|
callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
|
||||||
|
/* best-effort — registry tombstones the eventId regardless */
|
||||||
|
});
|
||||||
registry.delete(key);
|
registry.delete(key);
|
||||||
setIncoming({ type: 'REMOVE', key });
|
setIncoming({ type: 'REMOVE', key });
|
||||||
};
|
};
|
||||||
|
|
@ -226,6 +254,15 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
if (rel?.event_id) {
|
if (rel?.event_id) {
|
||||||
rememberDeclined(rel.event_id);
|
rememberDeclined(rel.event_id);
|
||||||
removeByNotifId(rel.event_id);
|
removeByNotifId(rel.event_id);
|
||||||
|
// Explicit forget for the decline-first race: if the notification
|
||||||
|
// processEvent hasn't run yet (or hasn't reached ADD because
|
||||||
|
// resolveCallId is mid-await), registry doesn't own the notifEventId
|
||||||
|
// and removeByNotifId is a no-op. The Java registry may still hold
|
||||||
|
// the FCM seed — a render on next backgrounding would resurrect
|
||||||
|
// an already-declined ring.
|
||||||
|
callForegroundService.removeIncomingRing(rel.event_id).catch(() => {
|
||||||
|
/* best-effort */
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -233,15 +270,42 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
if (ev.getType() !== EventType.RTCNotification) return;
|
if (ev.getType() !== EventType.RTCNotification) return;
|
||||||
if (ev.getSender() === mx.getSafeUserId()) return;
|
if (ev.getSender() === mx.getSafeUserId()) return;
|
||||||
|
|
||||||
|
const evId = ev.getId();
|
||||||
|
|
||||||
|
// The Java-side ring registry may already hold an entry for this
|
||||||
|
// eventId from an FCM seed that landed before /sync delivered the event
|
||||||
|
// to us. Every suppress-return below must remove+tombstone the entry so
|
||||||
|
// the next MainActivity.onPause renderRegistry doesn't surface native
|
||||||
|
// CallStyle for a ring JS decided not to ring. Paths we don't explicitly
|
||||||
|
// remove here (missing evId / malformed rel): registry lifetime check
|
||||||
|
// in isExpired remains the only safety net.
|
||||||
|
const removeFromRegistry = () => {
|
||||||
|
if (evId) {
|
||||||
|
callForegroundService.removeIncomingRing(evId).catch(() => {
|
||||||
|
/* best-effort */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const content = ev.getContent<IRTCNotificationContent>();
|
const content = ev.getContent<IRTCNotificationContent>();
|
||||||
// Only DM ring — group call notifications use 'notification' type and are out of scope.
|
// Only DM ring — group call notifications use 'notification' type and are out of scope.
|
||||||
|
// FCM path only seeds registry for 'ring' content_notification_type, so no entry to remove.
|
||||||
if (content.notification_type !== 'ring') return;
|
if (content.notification_type !== 'ring') return;
|
||||||
if (!mDirectRef.current.has(room.roomId)) return;
|
if (!mDirectRef.current.has(room.roomId)) {
|
||||||
if (isRtcNotificationExpired(ev)) return;
|
removeFromRegistry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRtcNotificationExpired(ev)) {
|
||||||
|
removeFromRegistry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Already participating in the room session → suppress duplicate toast.
|
// Already participating in the room session → suppress duplicate toast.
|
||||||
const session = mx.matrixRTC.getRoomSession(room);
|
const session = mx.matrixRTC.getRoomSession(room);
|
||||||
if (session.memberships.some((m) => m.sender === mx.getUserId())) return;
|
if (session.memberships.some((m) => m.sender === mx.getUserId())) {
|
||||||
|
removeFromRegistry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rel = ev.getRelation();
|
const rel = ev.getRelation();
|
||||||
if (rel?.rel_type !== RelationType.Reference || !rel.event_id) return;
|
if (rel?.rel_type !== RelationType.Reference || !rel.event_id) return;
|
||||||
|
|
@ -249,21 +313,66 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
const sender = ev.getSender();
|
const sender = ev.getSender();
|
||||||
if (!sender) return;
|
if (!sender) return;
|
||||||
|
|
||||||
const evId = ev.getId();
|
|
||||||
if (!evId) return;
|
if (!evId) return;
|
||||||
if (declinedTimers.has(evId)) return;
|
if (declinedTimers.has(evId)) {
|
||||||
|
removeFromRegistry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const callId = await resolveCallId(mx, room, sender, rel.event_id);
|
const callId = await resolveCallId(mx, room, sender, rel.event_id);
|
||||||
if (callId === undefined) return;
|
if (callId === undefined) {
|
||||||
|
removeFromRegistry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Re-check anything that can change during the await. resolveCallId can
|
// Re-check anything that can change during the await. resolveCallId can
|
||||||
// yield for seconds (5s MembershipsChanged wait, then fetchRoomEvent) —
|
// yield for seconds (5s MembershipsChanged wait, then fetchRoomEvent) —
|
||||||
// a membership join or a matching decline can land meanwhile and must be
|
// a membership join or a matching decline can land meanwhile and must be
|
||||||
// observed before we commit the ADD.
|
// observed before we commit the ADD.
|
||||||
if (session.memberships.some((m) => m.sender === mx.getUserId())) return;
|
if (session.memberships.some((m) => m.sender === mx.getUserId())) {
|
||||||
if (declinedTimers.has(evId)) return;
|
removeFromRegistry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (declinedTimers.has(evId)) {
|
||||||
|
removeFromRegistry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const key = getIncomingCallKey(callId, room.roomId);
|
const key = getIncomingCallKey(callId, room.roomId);
|
||||||
|
const senderTs = getNotificationEventSendTs(ev);
|
||||||
|
|
||||||
|
// Latest-wins per-room, compared by senderTs. A late FCM retry /
|
||||||
|
// reordered /sync / decrypt lag can deliver an older ring AFTER a
|
||||||
|
// newer same-room ring already seated in registry. Naive last-observed
|
||||||
|
// wins would let the stale event evict the newer one. Only evict when
|
||||||
|
// the incoming event's senderTs is newer than the existing same-room
|
||||||
|
// entry's; if incoming is older, drop it (return) so the newer ring
|
||||||
|
// keeps the slot. Native registry enforces the symmetric invariant in
|
||||||
|
// VojoFirebaseMessagingService.upsertIncomingRing.
|
||||||
|
const sameRoomStaler = Array.from(registry.values()).filter(
|
||||||
|
(e) => e.roomId === room.roomId && e.notifEventId !== evId
|
||||||
|
);
|
||||||
|
const incomingIsOlder = sameRoomStaler.some(
|
||||||
|
(e) => e.senderTs > 0 && senderTs > 0 && senderTs < e.senderTs
|
||||||
|
);
|
||||||
|
if (incomingIsOlder) {
|
||||||
|
// Stale same-room event — don't ADD, don't evict. Native side will
|
||||||
|
// tombstone via its own check when our upsert hits the bridge; here
|
||||||
|
// we also bridge the removeIncomingRing so any FCM seed for this
|
||||||
|
// stale eventId goes away.
|
||||||
|
callForegroundService.removeIncomingRing(evId).catch(() => {
|
||||||
|
/* best-effort */
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Array.from(registry.entries())
|
||||||
|
.filter(([, entry]) => entry.roomId === room.roomId && entry.notifEventId !== evId)
|
||||||
|
.forEach(([supersededKey]) => removeByKey(supersededKey));
|
||||||
|
|
||||||
|
// Duplicate processEvent invocation (e.g., timeline + decrypted path for
|
||||||
|
// the same event). The earlier one owns the ring lifecycle — do NOT
|
||||||
|
// remove here or we'd tombstone an eventId the first invocation is
|
||||||
|
// about to ADD.
|
||||||
if (registry.has(key)) return;
|
if (registry.has(key)) return;
|
||||||
|
|
||||||
const timer = scheduleExpiry(key, ev);
|
const timer = scheduleExpiry(key, ev);
|
||||||
|
|
@ -272,10 +381,30 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
registry.set(key, {
|
registry.set(key, {
|
||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
notifEventId: evId,
|
notifEventId: evId,
|
||||||
|
senderTs,
|
||||||
timer,
|
timer,
|
||||||
unsubMemberships,
|
unsubMemberships,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Happy-path upsert into the native ring registry. Idempotent
|
||||||
|
// with any prior FCM seed for the same eventId — Java merges metadata
|
||||||
|
// append-only, so we pass only the fields JS has reliable access to.
|
||||||
|
const senderMember = room.getMember(sender);
|
||||||
|
const callerName =
|
||||||
|
senderMember?.rawDisplayName || room.name || sender || 'Vojo';
|
||||||
|
const lifetime = content.lifetime ?? RTC_NOTIFICATION_DEFAULT_LIFETIME;
|
||||||
|
callForegroundService
|
||||||
|
.upsertIncomingRing({
|
||||||
|
eventId: evId,
|
||||||
|
roomId: room.roomId,
|
||||||
|
callerName,
|
||||||
|
senderTs,
|
||||||
|
lifetime,
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* best-effort — FCM seed likely already populated it anyway */
|
||||||
|
});
|
||||||
|
|
||||||
setIncoming({
|
setIncoming({
|
||||||
type: 'ADD',
|
type: 'ADD',
|
||||||
key,
|
key,
|
||||||
|
|
@ -314,8 +443,26 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (ev) => {
|
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (ev) => {
|
||||||
const redacted = ev.event.redacts;
|
// v11+ rooms moved `redacts` into content; matrix-js-sdk mirrors the
|
||||||
if (redacted) removeByNotifId(redacted);
|
// older top-level field when possible but the content fallback is
|
||||||
|
// the safety net.
|
||||||
|
const redacted =
|
||||||
|
ev.event.redacts ??
|
||||||
|
(ev.getContent() as { redacts?: string } | undefined)?.redacts;
|
||||||
|
if (!redacted) return;
|
||||||
|
// Symmetric with RTCDecline: if the redaction arrives before
|
||||||
|
// the notification's processEvent reaches ADD (race via
|
||||||
|
// handleTimeline / handleDecrypted), registry has no entry →
|
||||||
|
// removeByNotifId is a no-op, but the Java ring registry may hold
|
||||||
|
// an FCM seed that would render on next onPause.
|
||||||
|
// - rememberDeclined blocks any late ADD attempt for this eventId.
|
||||||
|
// - bridge removeIncomingRing tombstones the native-side entry.
|
||||||
|
// - removeByNotifId covers the common case where atom already has it.
|
||||||
|
rememberDeclined(redacted);
|
||||||
|
removeByNotifId(redacted);
|
||||||
|
callForegroundService.removeIncomingRing(redacted).catch(() => {
|
||||||
|
/* best-effort */
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSessionEnded = (roomId: string) => {
|
const handleSessionEnded = (roomId: string) => {
|
||||||
|
|
@ -332,9 +479,19 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
mx.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
|
mx.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
|
||||||
mx.removeListener(RoomEvent.Redaction, handleRedaction);
|
mx.removeListener(RoomEvent.Redaction, handleRedaction);
|
||||||
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
|
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
|
||||||
|
// Sync the Java ring registry on teardown: process-global state
|
||||||
|
// outlives this hook, so any live entry left unremoved will be rendered
|
||||||
|
// by the next MainActivity.onPause for a ring no JS owner exists for.
|
||||||
|
// Reasons to hit this path: logout, client swap, provider remount,
|
||||||
|
// hot-reload in dev. Best-effort bridge — registry tombstones each
|
||||||
|
// eventId for the ring lifetime so a late FCM re-delivery can't
|
||||||
|
// resurrect the zombie.
|
||||||
registry.forEach((entry) => {
|
registry.forEach((entry) => {
|
||||||
clearTimeout(entry.timer);
|
clearTimeout(entry.timer);
|
||||||
entry.unsubMemberships?.();
|
entry.unsubMemberships?.();
|
||||||
|
callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
|
||||||
|
/* best-effort */
|
||||||
|
});
|
||||||
});
|
});
|
||||||
registry.clear();
|
registry.clear();
|
||||||
declinedTimers.forEach((timer) => clearTimeout(timer));
|
declinedTimers.forEach((timer) => clearTimeout(timer));
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ export const usePendingCallActionConsumer = (): void => {
|
||||||
|
|
||||||
const { roomId, notifEventId } = pending;
|
const { roomId, notifEventId } = pending;
|
||||||
setPending(undefined);
|
setPending(undefined);
|
||||||
// Unreachable in practice after techdebt §5.35 landed: every Decline
|
// Unreachable in practice: every Decline button press fires
|
||||||
// button press now fires CallDeclineReceiver via PendingIntent.getBroadcast,
|
// CallDeclineReceiver via PendingIntent.getBroadcast,
|
||||||
// so MainActivity never boots and `pushNotificationActionPerformed` never
|
// so MainActivity never boots and `pushNotificationActionPerformed` never
|
||||||
// fires with call_action='decline'. Nothing else queues a decline onto
|
// fires with call_action='decline'. Nothing else queues a decline onto
|
||||||
// pendingCallActionAtom. Kept as a safety-net in case a future JS-path
|
// pendingCallActionAtom. Kept as a safety-net in case a future JS-path
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,8 @@ const PENDING_PREFIX = 'vojo.pendingDeclines.';
|
||||||
// via matrix-js-sdk's sendRtcDecline → cosmetic chance of two decline events
|
// via matrix-js-sdk's sendRtcDecline → cosmetic chance of two decline events
|
||||||
// in the timeline if the receiver actually succeeded but the process was
|
// in the timeline if the receiver actually succeeded but the process was
|
||||||
// killed before clearing the tombstone. A-side auto-hangup is idempotent on
|
// killed before clearing the tombstone. A-side auto-hangup is idempotent on
|
||||||
// the first decline, so this is timeline-clutter only. See techdebt §5.35
|
// the first decline, so this is timeline-clutter only. Fixing requires
|
||||||
// "txnId unification" note — fixing requires plumbing txnId through prefs,
|
// plumbing txnId through prefs, skipped for MVP.
|
||||||
// skipped for MVP.
|
|
||||||
export const usePendingDeclinesFlusher = (): void => {
|
export const usePendingDeclinesFlusher = (): void => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// DM call entry point that unifies start, join and switch flows.
|
// DM call entry point that unifies start, join and switch flows.
|
||||||
//
|
//
|
||||||
// Contract (dm_calls_techdebt.md §5.36):
|
// Contract:
|
||||||
// - no prev embed → start a new DM call
|
// - no prev embed → start a new DM call
|
||||||
// - prev.roomId === arg → no-op (healthy same-room click)
|
// - prev.roomId === arg → no-op (healthy same-room click)
|
||||||
// - prev.roomId !== arg → switch: hangup prev, wait for clean leave,
|
// - prev.roomId !== arg → switch: hangup prev, wait for clean leave,
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,16 @@
|
||||||
// app is foregrounded it suppresses that banner, so strip render itself does
|
// app is foregrounded it suppresses that banner, so strip render itself does
|
||||||
// not need to mirror foreground policy in JS.
|
// not need to mirror foreground policy in JS.
|
||||||
//
|
//
|
||||||
// Ring audio DOES mirror foreground policy — gated on `appActive` (techdebt
|
// Ring audio mirrors foreground policy on Android — gated on `appActive`
|
||||||
// §5.39): when the app/tab is hidden the platform surface (Android CallStyle /
|
// so the native CallStyle ringtone owns UX in background and the JS
|
||||||
// web SW push) owns ringtone UX, and playing the in-app <audio> on top would
|
// <audio> doesn't double-ring during the grace window after backgrounding
|
||||||
// double-ring during the grace window after backgrounding while the WebView
|
// while the WebView still processes /sync. On web / iOS there is no
|
||||||
// still processes /sync.
|
// native ring surface, so audio plays regardless of visibility.
|
||||||
//
|
//
|
||||||
// KNOWN GAP §5.17: if the browser blocks `audio.play()` (cold page load, no
|
// Known gap: if the browser blocks `audio.play()` (cold page load, no user
|
||||||
// user gesture yet), the ring is silent — strip is still visible but user may
|
// gesture yet), the ring is silent — strip is still visible but user may
|
||||||
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) is
|
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications)
|
||||||
// Phase 3 polish.
|
// is Phase 3 polish.
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
|
@ -93,7 +93,16 @@ export function IncomingCallStripRenderer() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
if (hasIncoming && appActive) {
|
// Platform split on the audio gate:
|
||||||
|
// - Android: gate on appActive. When backgrounded the native CallStyle
|
||||||
|
// ringtone (via vojo_calls_v2 channel) takes over, so JS audio must
|
||||||
|
// stop to avoid double-ring.
|
||||||
|
// - web / iOS: no native fallback exists. Gating on visibility here
|
||||||
|
// silenced the only ring source whenever the user switched tabs —
|
||||||
|
// user-reported regression. Keep audio playing regardless of
|
||||||
|
// visibility on non-Android platforms.
|
||||||
|
const platformGatedActive = isAndroidPlatform() ? appActive : true;
|
||||||
|
if (hasIncoming && platformGatedActive) {
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
audio.play().catch(() => {
|
audio.play().catch(() => {
|
||||||
// autoplay blocked — strip UI still visible
|
// autoplay blocked — strip UI still visible
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||||
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
|
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
|
||||||
import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
|
import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
|
||||||
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
|
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
|
||||||
import { useDismissNativeCallNotifications } from '../hooks/useDismissNativeCallNotifications';
|
|
||||||
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
|
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
|
||||||
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
|
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
|
||||||
|
|
||||||
|
|
@ -89,7 +88,12 @@ function IncomingCallsFeature() {
|
||||||
useIncomingRtcNotifications();
|
useIncomingRtcNotifications();
|
||||||
useCallerAutoHangup();
|
useCallerAutoHangup();
|
||||||
usePendingCallActionConsumer();
|
usePendingCallActionConsumer();
|
||||||
useDismissNativeCallNotifications();
|
// Native CallStyle dismissal is owned by the Android ring registry:
|
||||||
|
// VojoFirebaseMessagingService.removeIncomingRing (ownership-checked cancel)
|
||||||
|
// fires on atom REMOVE via bridge, and MainActivity.onResume calls
|
||||||
|
// cancelRenderedIncomingRings for the background→foreground handoff.
|
||||||
|
// A JS-side dismiss hook here is redundant and risks a blind tag/id cancel
|
||||||
|
// hitting a foreign ring in the same room slot.
|
||||||
useAppUrlOpen();
|
useAppUrlOpen();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||||
|
|
||||||
// Mirror {accessToken, baseUrl, userId} into native SharedPreferences so
|
// Mirror {accessToken, baseUrl, userId} into native SharedPreferences so
|
||||||
// CallDeclineReceiver can send m.call.decline without booting the WebView.
|
// CallDeclineReceiver can send m.call.decline without booting the WebView.
|
||||||
// No-op on web. See docs/plans/dm_calls_techdebt.md §5.35.
|
// No-op on web.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mx) return;
|
if (!mx) return;
|
||||||
writeSessionBridge(mx);
|
writeSessionBridge(mx);
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,32 @@
|
||||||
// mic AppOp and applies background data firewall when the app goes to
|
// mic AppOp and applies background data firewall when the app goes to
|
||||||
// background (e.g. screen lock), killing the call.
|
// background (e.g. screen lock), killing the call.
|
||||||
//
|
//
|
||||||
// Context: docs/plans/dm_calls_techdebt.md §2.2.
|
//
|
||||||
|
|
||||||
import { registerPlugin } from '@capacitor/core';
|
import { registerPlugin } from '@capacitor/core';
|
||||||
import { isAndroidPlatform } from '../../utils/capacitor';
|
import { isAndroidPlatform } from '../../utils/capacitor';
|
||||||
|
|
||||||
|
export interface IncomingRingUpsert {
|
||||||
|
eventId: string;
|
||||||
|
roomId: string;
|
||||||
|
callerName?: string;
|
||||||
|
// Milliseconds since epoch of the sender's origin timestamp (as reported by
|
||||||
|
// content.sender_ts). Native uses it to compute the expiry alarm so a
|
||||||
|
// late-rendered entry doesn't live past true lifetime.
|
||||||
|
senderTs?: number;
|
||||||
|
// Ring lifetime in milliseconds (content.lifetime, falls back to 30s native-side).
|
||||||
|
lifetime?: number;
|
||||||
|
// Optional — used as google.message_id in the PendingIntent extras so
|
||||||
|
// Capacitor's pushNotificationActionPerformed fires on Answer tap. Left
|
||||||
|
// blank for JS-originated upserts; Java merges richer FCM data on seed.
|
||||||
|
messageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CallForegroundServicePlugin {
|
interface CallForegroundServicePlugin {
|
||||||
start(options?: { title?: string; body?: string }): Promise<void>;
|
start(options?: { title?: string; body?: string }): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
|
upsertIncomingRing(options: IncomingRingUpsert): Promise<void>;
|
||||||
|
removeIncomingRing(options: { eventId: string }): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin = registerPlugin<CallForegroundServicePlugin>('CallForegroundService');
|
const plugin = registerPlugin<CallForegroundServicePlugin>('CallForegroundService');
|
||||||
|
|
@ -30,4 +48,19 @@ export const callForegroundService = {
|
||||||
if (!isAndroidPlatform()) return Promise.resolve();
|
if (!isAndroidPlatform()) return Promise.resolve();
|
||||||
return plugin.stop();
|
return plugin.stop();
|
||||||
},
|
},
|
||||||
|
// Add/refresh a live incoming ring in the native registry. Called from the
|
||||||
|
// incomingCallsAtom sync effect whenever a key lands in the atom (happy
|
||||||
|
// path). Idempotent with any prior FCM seed for the same eventId —
|
||||||
|
// (atom-sync cleanup + native registry bridge).
|
||||||
|
upsertIncomingRing(entry: IncomingRingUpsert): Promise<void> {
|
||||||
|
if (!isAndroidPlatform()) return Promise.resolve();
|
||||||
|
return plugin.upsertIncomingRing(entry);
|
||||||
|
},
|
||||||
|
// Drop a ring from the native registry and tombstone its eventId so a late
|
||||||
|
// FCM/sync re-seed within ring lifetime cannot resurrect it. Called from
|
||||||
|
// atom REMOVE, suppress paths, and the decline-first branch.
|
||||||
|
removeIncomingRing(eventId: string): Promise<void> {
|
||||||
|
if (!isAndroidPlatform()) return Promise.resolve();
|
||||||
|
return plugin.removeIncomingRing({ eventId });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue