dm calls mvp: phase 5.35 polish: cancel notif on null session, redact token-leak in JSON parse log, guard drain from resume race

This commit is contained in:
v.lagerev 2026-04-20 23:41:29 +03:00
parent 2c1063f4c9
commit 6000596c54
2 changed files with 28 additions and 11 deletions

View file

@ -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()) {

View file

@ -28,9 +28,18 @@ export const usePendingDeclinesFlusher = (): void => {
if (!isNativePlatform()) return undefined;
let disposed = false;
let inFlight = false;
let resumeHandle: { remove: () => Promise<void> } | 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;
}
};