diff --git a/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java b/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java index 52692419..be34fe46 100644 --- a/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java +++ b/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java @@ -36,11 +36,11 @@ import java.util.concurrent.Executors; * - On non-2xx or exception: leave tombstone; flusher drains on next resume. * * Null-session edge case (fresh reinstall + first push before first login): - * we deliberately do NOT cancel the notification so the ring keeps playing. - * The user will unlock, MainActivity will boot, and the existing JS-path - * consumer (pendingCallActionAtom) can still handle the decline through the - * `pushNotificationActionPerformed` listener — silently dropping the ring - * here would be worse UX than preserving it. See techdebt §5.35 Item 6. + * we cannot send the decline at all — there's no access token, and the + * 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 + * 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. * * Note on idempotency: the flusher's retry generates a new txnId, so on a * split-success-fail sequence (receiver HTTP timed out, flusher succeeds) @@ -89,11 +89,15 @@ public class CallDeclineReceiver extends BroadcastReceiver { final String sessionJson = prefs.getString(SESSION_KEY, null); if (sessionJson == null) { - // Fresh reinstall / logged-out state. Do NOT cancel the notification: - // the ring keeps playing, user unlocks, MainActivity boots, and the - // JS-path consumer handles the decline. Silent-drop here would leave - // A-side spinning to no-answer timeout with no feedback on B. - Log.w(TAG, "onReceive: no session in prefs, leaving ring intact"); + // Fresh reinstall / logged-out: no access token, no Matrix client. + // We can't send m.call.decline and neither can the JS-path (no + // session to decline from). Cancel the notif so the user isn't + // stuck on a ring they can't action. Skip the tombstone — a + // retry without a session would be equally impotent. + Log.w(TAG, "onReceive: no session in prefs, cancelling notif without HTTP"); + if (notifTag != null && notifId != -1) { + NotificationManagerCompat.from(appContext).cancel(notifTag, notifId); + } return; } @@ -104,7 +108,9 @@ public class CallDeclineReceiver extends BroadcastReceiver { accessToken = session.optString("accessToken", null); baseUrl = session.optString("baseUrl", null); } catch (Throwable t) { - Log.e(TAG, "onReceive: prefs JSON parse failed", t); + // Do NOT pass the Throwable — JSONException.getMessage() embeds + // the malformed input, which here contains the access token. + Log.e(TAG, "onReceive: prefs JSON parse failed: " + t.getClass().getSimpleName()); return; } if (accessToken == null || accessToken.isEmpty() || baseUrl == null || baseUrl.isEmpty()) { diff --git a/src/app/hooks/usePendingDeclinesFlusher.ts b/src/app/hooks/usePendingDeclinesFlusher.ts index dcca443b..50794f9d 100644 --- a/src/app/hooks/usePendingDeclinesFlusher.ts +++ b/src/app/hooks/usePendingDeclinesFlusher.ts @@ -28,9 +28,18 @@ export const usePendingDeclinesFlusher = (): void => { if (!isNativePlatform()) return undefined; let disposed = false; + let inFlight = false; let resumeHandle: { remove: () => Promise } | undefined; + // Mount + an immediate `resume` (e.g. receiver fired, app foregrounded + // right after the hook remounts) can both call drain() before the first + // run finishes its per-key `get → sendRtcDecline → remove` loop. Without + // this lock both passes observe the same tombstone and fire parallel + // sendRtcDecline requests — benign at the server (idempotent on the + // rel event_id) but it doubles the timeline noise. const drain = async () => { + if (inFlight) return; + inFlight = true; try { const { Preferences } = await import('@capacitor/preferences'); const { keys } = await Preferences.keys(); @@ -60,6 +69,8 @@ export const usePendingDeclinesFlusher = (): void => { } } catch { /* preferences import failed — non-fatal */ + } finally { + inFlight = false; } };