dm calls mvp: phase 5.35: decline via BroadcastReceiver with session bridge, pending-declines flusher, allowBackup=false

This commit is contained in:
v.lagerev 2026-04-20 22:23:44 +03:00
parent 299107b13a
commit 3290b7f594
11 changed files with 395 additions and 5 deletions

View file

@ -2,8 +2,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- allowBackup=false: CallDeclineReceiver reads the Matrix access_token
from shared_prefs/CapacitorStorage.xml (written by sessionBridge).
With allowBackup=true a rooted device or adb-backup-enabled user
could exfiltrate the cleartext token. Session data is cheap to
recreate via re-login, so excluding ourselves from Auto Backup
is the simpler control vs. fine-grained backup_rules.xml exclusions. -->
<application
android:allowBackup="true"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@ -53,6 +59,10 @@
<receiver
android:name=".CallCancelReceiver"
android:exported="false" />
<receiver
android:name=".CallDeclineReceiver"
android:exported="false" />
</application>
<!-- Permissions -->

View file

@ -0,0 +1,199 @@
package chat.vojo.app;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.core.app.NotificationManagerCompat;
import org.json.JSONObject;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Sends {@code m.call.decline} directly from the notification action tap,
* bypassing the WebView boot entirely. Fires via {@code PendingIntent.getBroadcast}
* from the CallStyle Decline button MainActivity never starts, so the
* lockscreen-unlock-and-app-flash UX from Phase 2.5.3 is gone.
*
* Session data ({@code accessToken}, {@code baseUrl}, {@code userId}) is
* mirrored from JS into {@code shared_prefs/CapacitorStorage.xml} by
* {@code writeSessionBridge()} on client mount.
*
* Recovery contract:
* - Before HTTP: write {@code vojo.pendingDeclines.{notifEventId}} tombstone
* so {@code usePendingDeclinesFlusher} can retry on app resume if our
* HTTP fails (network drop, token invalidated, process killed mid-PUT).
* - On 2xx: remove the tombstone receiver path succeeded, no flusher work.
* - 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.
*
* Note on idempotency: the flusher's retry generates a new txnId, so on a
* split-success-fail sequence (receiver HTTP timed out, flusher succeeds)
* 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
* hook is idempotent and fires on the first decline. See techdebt §5.35
* "txnId unification" note.
*/
public class CallDeclineReceiver extends BroadcastReceiver {
public static final String ACTION_DECLINE_CALL = "chat.vojo.app.DECLINE_CALL";
public static final String EXTRA_ROOM_ID = "room_id";
public static final String EXTRA_NOTIF_EVENT_ID = "notif_event_id";
public static final String EXTRA_NOTIF_TAG = "notif_tag";
public static final String EXTRA_NOTIF_ID = "notif_id";
private static final String PREFS_FILE = "CapacitorStorage";
private static final String SESSION_KEY = "vojo.matrixSession";
private static final String PENDING_DECLINES_PREFIX = "vojo.pendingDeclines.";
private static final int CONNECT_TIMEOUT_MS = 8_000;
private static final int READ_TIMEOUT_MS = 8_000;
private static final String TAG = "CallDeclineReceiver";
// Single reusable executor keeps us off the main thread without spawning
// a fresh one per broadcast declines come rarely enough that a pool of 1
// is fine; a short-lived Thread would also work but is noisier on tracing.
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null) return;
final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
final String notifEventId = intent.getStringExtra(EXTRA_NOTIF_EVENT_ID);
final String notifTag = intent.getStringExtra(EXTRA_NOTIF_TAG);
final int notifId = intent.getIntExtra(EXTRA_NOTIF_ID, -1);
if (roomId == null || notifEventId == null) {
Log.w(TAG, "onReceive: missing extras, abort");
return;
}
final Context appContext = context.getApplicationContext();
final SharedPreferences prefs =
appContext.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE);
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");
return;
}
final String accessToken;
final String baseUrl;
try {
JSONObject session = new JSONObject(sessionJson);
accessToken = session.optString("accessToken", null);
baseUrl = session.optString("baseUrl", null);
} catch (Throwable t) {
Log.e(TAG, "onReceive: prefs JSON parse failed", t);
return;
}
if (accessToken == null || accessToken.isEmpty() || baseUrl == null || baseUrl.isEmpty()) {
Log.w(TAG, "onReceive: empty accessToken/baseUrl in session, leaving ring intact");
return;
}
// 1. Cancel first for instant user feedback. Ringtone stops within
// NotificationManagerCompat's Binder latency (~tens of ms) HTTP
// completion is irrelevant to the perceived UX.
if (notifTag != null && notifId != -1) {
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
}
// 2. Write tombstone BEFORE HTTP so the flusher sees work to do
// if we fail/die. Remove only on confirmed 2xx.
try {
JSONObject tombstone = new JSONObject();
tombstone.put("roomId", roomId);
tombstone.put("ts", System.currentTimeMillis());
prefs.edit()
.putString(PENDING_DECLINES_PREFIX + notifEventId, tombstone.toString())
.apply();
} catch (Throwable t) {
Log.w(TAG, "onReceive: tombstone write failed (non-fatal)", t);
}
// 3. HTTP PUT off-main on goAsync. Receiver stays alive ~10s for us
// to finish; after that Android reclaims the process and the
// pending request dies flusher covers recovery on next resume.
final PendingResult pendingResult = goAsync();
final String txnId = UUID.randomUUID().toString();
EXECUTOR.execute(() -> {
try {
int status = sendDecline(baseUrl, accessToken, roomId, notifEventId, txnId);
if (status >= 200 && status < 300) {
prefs.edit().remove(PENDING_DECLINES_PREFIX + notifEventId).apply();
Log.d(TAG, "decline PUT ok status=" + status + " room=" + roomId);
} else {
Log.w(TAG, "decline PUT non-2xx status=" + status + " room=" + roomId);
}
} catch (Throwable t) {
Log.e(TAG, "decline PUT threw", t);
} finally {
pendingResult.finish();
}
});
}
private int sendDecline(
String baseUrl,
String accessToken,
String roomId,
String notifEventId,
String txnId
) throws Exception {
String url = trimTrailingSlash(baseUrl)
+ "/_matrix/client/v3/rooms/"
+ URLEncoder.encode(roomId, "UTF-8")
+ "/send/org.matrix.msc4310.rtc.decline/"
+ URLEncoder.encode(txnId, "UTF-8");
JSONObject relates = new JSONObject();
relates.put("rel_type", "m.reference");
relates.put("event_id", notifEventId);
JSONObject body = new JSONObject();
body.put("m.relates_to", relates);
byte[] payload = body.toString().getBytes("UTF-8");
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("PUT");
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
conn.setDoOutput(true);
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
conn.setRequestProperty("Content-Type", "application/json");
conn.setFixedLengthStreamingMode(payload.length);
try (OutputStream os = conn.getOutputStream()) {
os.write(payload);
}
return conn.getResponseCode();
} finally {
if (conn != null) conn.disconnect();
}
}
private static String trimTrailingSlash(String s) {
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
}
}

View file

@ -203,8 +203,13 @@ public class VojoFirebaseMessagingService extends MessagingService {
PendingIntent answerPI = buildActionPI(
answerReq, "answer", roomId, notifEventId, messageId
);
PendingIntent declinePI = buildActionPI(
declineReq, "decline", 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(
declineReq, roomId, notifEventId, tag, notifId
);
PendingIntent launchPI = buildActionPI(
launchReq, null, roomId, notifEventId, messageId
@ -277,6 +282,24 @@ public class VojoFirebaseMessagingService extends MessagingService {
return PendingIntent.getActivity(this, requestCode, intent, flags);
}
private PendingIntent buildDeclineBroadcastPI(
int requestCode,
String roomId,
String notifEventId,
String notifTag,
int notifId
) {
Intent intent = new Intent(this, CallDeclineReceiver.class)
.setAction(CallDeclineReceiver.ACTION_DECLINE_CALL)
.putExtra(CallDeclineReceiver.EXTRA_ROOM_ID, roomId)
.putExtra(CallDeclineReceiver.EXTRA_NOTIF_EVENT_ID, notifEventId)
.putExtra(CallDeclineReceiver.EXTRA_NOTIF_TAG, notifTag)
.putExtra(CallDeclineReceiver.EXTRA_NOTIF_ID, notifId);
int flags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
return PendingIntent.getBroadcast(this, requestCode, intent, flags);
}
// Mirrors the JS-side createChannel in usePushNotifications.ts. Lazy creation
// from the service covers the fresh-install + killed-process race: FCM may
// deliver before the app has ever been launched (so the JS lifecycle effect

10
package-lock.json generated
View file

@ -17,6 +17,7 @@
"@capacitor/browser": "8.0.3",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/preferences": "8.0.1",
"@capacitor/push-notifications": "8.0.3",
"@capacitor/toast": "8.0.1",
"@fontsource/inter": "4.5.14",
@ -1917,6 +1918,15 @@
"tslib": "^2.1.0"
}
},
"node_modules/@capacitor/preferences": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz",
"integrity": "sha512-T6no3ebi79XJCk91U3Jp/liJUwgBdvHR+s6vhvPkPxSuch7z3zx5Rv1bdWM6sWruNx+pViuEGqZvbfCdyBvcHQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/push-notifications": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.0.3.tgz",

View file

@ -78,6 +78,7 @@
"@capacitor/browser": "8.0.3",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/preferences": "8.0.1",
"@capacitor/push-notifications": "8.0.3",
"@capacitor/toast": "8.0.1",
"@fontsource/inter": "4.5.14",

View file

@ -25,13 +25,17 @@ export const usePendingCallActionConsumer = (): void => {
setIncoming({ type: 'REMOVE_BY_ROOM', roomId: pending.roomId });
startDmCall(pending.roomId);
} else {
// Unreachable in practice after techdebt §5.35 landed: every Decline
// button press now fires CallDeclineReceiver via PendingIntent.getBroadcast,
// so MainActivity never boots and `pushNotificationActionPerformed` never
// fires with call_action='decline'. Nothing else queues a decline onto
// pendingCallActionAtom. Kept as a safety-net in case a future JS-path
// (in-app banner decline, retry flow, etc.) starts emitting here.
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId: pending.notifEventId });
// Fire-and-minimize: dispatch the decline then minimize the app once the
// request settles (success OR failure). Minimizing before sendRtcDecline
// resolves risks the WebView getting paused mid-request on slower devices;
// waiting for settlement gives the network call the tick it needs.
// Lockscreen flow still requires unlock — known MVP compromise; a proper
// BroadcastReceiver-based decline is tracked in techdebt 5.33.
const minimize = () => {
if (!isNativePlatform()) return;
App.minimizeApp().catch(() => {

View file

@ -0,0 +1,80 @@
import { useEffect } from 'react';
import { App } from '@capacitor/app';
import { isNativePlatform } from '../utils/capacitor';
import { useMatrixClient } from './useMatrixClient';
const PENDING_PREFIX = 'vojo.pendingDeclines.';
// Drains the tombstones CallDeclineReceiver leaves behind when its HTTP PUT
// fails (network drop, 4xx/5xx, process killed mid-request). Matches the
// native-side contract in CallDeclineReceiver.java — the receiver writes the
// tombstone BEFORE the PUT and removes it only on 2xx.
//
// Runs on mount (covers "app opened after a failed receiver path") and on
// Capacitor `App` resume (covers "receiver ran while app was paused, HTTP
// died, user unlocks and brings app to foreground").
//
// Dedup: the receiver generates its own txnId; retries here generate another
// via matrix-js-sdk's sendRtcDecline → cosmetic chance of two decline events
// in the timeline if the receiver actually succeeded but the process was
// 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
// "txnId unification" note — fixing requires plumbing txnId through prefs,
// skipped for MVP.
export const usePendingDeclinesFlusher = (): void => {
const mx = useMatrixClient();
useEffect(() => {
if (!isNativePlatform()) return undefined;
let disposed = false;
let resumeHandle: { remove: () => Promise<void> } | undefined;
const drain = async () => {
try {
const { Preferences } = await import('@capacitor/preferences');
const { keys } = await Preferences.keys();
const tombstones = keys.filter((k) => k.startsWith(PENDING_PREFIX));
for (const key of tombstones) {
if (disposed) return;
const notifEventId = key.slice(PENDING_PREFIX.length);
try {
const { value } = await Preferences.get({ key });
if (!value) {
await Preferences.remove({ key });
continue;
}
const parsed = JSON.parse(value) as { roomId?: string };
if (!parsed.roomId || !notifEventId) {
await Preferences.remove({ key });
continue;
}
await mx.sendRtcDecline(parsed.roomId, notifEventId);
await Preferences.remove({ key });
} catch (err) {
// Leave tombstone for the next resume — could be 401 (token
// rotated) or transient network. Don't spam logs on every resume.
// eslint-disable-next-line no-console
console.warn('[call] pendingDeclines flush failed', notifEventId, err);
}
}
} catch {
/* preferences import failed — non-fatal */
}
};
drain();
App.addListener('resume', () => {
drain();
}).then((h) => {
if (disposed) h.remove();
else resumeHandle = h;
});
return () => {
disposed = true;
resumeHandle?.remove();
};
}, [mx]);
};

View file

@ -22,6 +22,7 @@ import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications'
import { PushPermissionPrompt } from '../../components/push-permission-prompt';
import { FullScreenIntentPrompt } from '../../components/full-screen-intent-prompt';
import { useAndroidBackButton } from '../../hooks/useAndroidBackButton';
import { usePendingDeclinesFlusher } from '../../hooks/usePendingDeclinesFlusher';
function SystemEmojiFeature() {
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
@ -158,6 +159,11 @@ function AndroidBackButtonFeature() {
return null;
}
function PendingDeclinesFlusherFeature() {
usePendingDeclinesFlusher();
return null;
}
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
@ -169,6 +175,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<PushPermissionPrompt />
<FullScreenIntentPrompt />
<AndroidBackButtonFeature />
<PendingDeclinesFlusherFeature />
{children}
</>
);

View file

@ -35,6 +35,7 @@ import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { AutoDiscovery } from './AutoDiscovery';
import { AuthSplashScreen } from '../auth/AuthSplashScreen';
import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridge';
function ClientRootLoading() {
return <AuthSplashScreen />;
@ -118,6 +119,10 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
const useLogoutListener = (mx?: MatrixClient) => {
useEffect(() => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
// Wipe the native session bridge before the reload — otherwise the dead
// access_token lingers in shared_prefs and CallDeclineReceiver spends
// the next login cycle posting 401s until writeSessionBridge overwrites.
await clearSessionBridge();
mx?.stopClient();
await mx?.clearStores();
window.localStorage.clear();
@ -166,6 +171,14 @@ export function ClientRoot({ children }: ClientRootProps) {
}
}, [mx, startMatrix]);
// Mirror {accessToken, baseUrl, userId} into native SharedPreferences so
// CallDeclineReceiver can send m.call.decline without booting the WebView.
// No-op on web. See docs/plans/dm_calls_techdebt.md §5.35.
useEffect(() => {
if (!mx) return;
writeSessionBridge(mx);
}, [mx]);
useSyncState(
mx,
useCallback((state) => {

View file

@ -0,0 +1,38 @@
import { MatrixClient } from 'matrix-js-sdk';
import { isNativePlatform } from './capacitor';
// Mirrors the Java-side key constants in CallDeclineReceiver. Capacitor
// Preferences default group lands in shared_prefs/CapacitorStorage.xml under
// MODE_PRIVATE (see node_modules/@capacitor/preferences PreferencesConfiguration).
const SESSION_KEY = 'vojo.matrixSession';
// Synapse here ships without MSC2918 refresh tokens (docs/ai/server-side.md
// homeserver.yaml has no session_lifetime) and initClient() never wires
// tokenRefreshFunction — access tokens don't rotate. One write at client
// mount + one clear at logout is the whole lifecycle.
export const writeSessionBridge = async (mx: MatrixClient): Promise<void> => {
if (!isNativePlatform()) return;
const accessToken = mx.getAccessToken();
const baseUrl = mx.baseUrl;
const userId = mx.getUserId();
if (!accessToken || !baseUrl || !userId) return;
try {
const { Preferences } = await import('@capacitor/preferences');
await Preferences.set({
key: SESSION_KEY,
value: JSON.stringify({ accessToken, baseUrl, userId }),
});
} catch {
/* non-fatal — receiver falls back to null-prefs path (preserves ring) */
}
};
export const clearSessionBridge = async (): Promise<void> => {
if (!isNativePlatform()) return;
try {
const { Preferences } = await import('@capacitor/preferences');
await Preferences.remove({ key: SESSION_KEY });
} catch {
/* ignore */
}
};

View file

@ -10,6 +10,7 @@ import {
unregisterPusher,
} from '../app/utils/push';
import { isNativePlatform } from '../app/utils/capacitor';
import { clearSessionBridge } from '../app/utils/sessionBridge';
type Session = {
baseUrl: string;
@ -94,6 +95,10 @@ export const logoutClient = async (mx: MatrixClient) => {
clearPusherIds();
setPushEnabled(false);
// Wipe the native session bridge so a re-login with a different user
// can't resurrect the old access_token via CallDeclineReceiver.
await clearSessionBridge();
mx.stopClient();
try {
await mx.logout();