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:
heaven 2026-04-20 23:41:29 +03:00
parent 91e3dd95ec
commit 13ec1e0054
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. * - On non-2xx or exception: leave tombstone; flusher drains on next resume.
* *
* Null-session edge case (fresh reinstall + first push before first login): * Null-session edge case (fresh reinstall + first push before first login):
* we deliberately do NOT cancel the notification so the ring keeps playing. * we cannot send the decline at all there's no access token, and the
* The user will unlock, MainActivity will boot, and the existing JS-path * JS-path can't cover us either (a logged-out client has no Matrix session
* consumer (pendingCallActionAtom) can still handle the decline through the * to call sendRtcDecline against). Cancelling the notification is the only
* `pushNotificationActionPerformed` listener silently dropping the ring * feedback we can give; leaving the ring would trap the user on a call they
* here would be worse UX than preserving it. See techdebt §5.35 Item 6. * 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 * 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)
@ -89,11 +89,15 @@ public class CallDeclineReceiver extends BroadcastReceiver {
final String sessionJson = prefs.getString(SESSION_KEY, null); final String sessionJson = prefs.getString(SESSION_KEY, null);
if (sessionJson == null) { if (sessionJson == null) {
// Fresh reinstall / logged-out state. Do NOT cancel the notification: // Fresh reinstall / logged-out: no access token, no Matrix client.
// the ring keeps playing, user unlocks, MainActivity boots, and the // We can't send m.call.decline and neither can the JS-path (no
// JS-path consumer handles the decline. Silent-drop here would leave // session to decline from). Cancel the notif so the user isn't
// A-side spinning to no-answer timeout with no feedback on B. // stuck on a ring they can't action. Skip the tombstone a
Log.w(TAG, "onReceive: no session in prefs, leaving ring intact"); // 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; return;
} }
@ -104,7 +108,9 @@ public class CallDeclineReceiver extends BroadcastReceiver {
accessToken = session.optString("accessToken", null); accessToken = session.optString("accessToken", null);
baseUrl = session.optString("baseUrl", null); baseUrl = session.optString("baseUrl", null);
} catch (Throwable t) { } 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; return;
} }
if (accessToken == null || accessToken.isEmpty() || baseUrl == null || baseUrl.isEmpty()) { if (accessToken == null || accessToken.isEmpty() || baseUrl == null || baseUrl.isEmpty()) {

View file

@ -28,9 +28,18 @@ export const usePendingDeclinesFlusher = (): void => {
if (!isNativePlatform()) return undefined; if (!isNativePlatform()) return undefined;
let disposed = false; let disposed = false;
let inFlight = false;
let resumeHandle: { remove: () => Promise<void> } | undefined; 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 () => { const drain = async () => {
if (inFlight) return;
inFlight = true;
try { try {
const { Preferences } = await import('@capacitor/preferences'); const { Preferences } = await import('@capacitor/preferences');
const { keys } = await Preferences.keys(); const { keys } = await Preferences.keys();
@ -60,6 +69,8 @@ export const usePendingDeclinesFlusher = (): void => {
} }
} catch { } catch {
/* preferences import failed — non-fatal */ /* preferences import failed — non-fatal */
} finally {
inFlight = false;
} }
}; };