From 63e9c65dd2c478e145f1e2a32f98e1d695793809 Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 20 Apr 2026 22:23:44 +0300 Subject: [PATCH] dm calls mvp: phase 5.35: decline via BroadcastReceiver with session bridge, pending-declines flusher, allowBackup=false --- android/app/src/main/AndroidManifest.xml | 12 +- .../chat/vojo/app/CallDeclineReceiver.java | 199 ++++++++++++++++++ .../app/VojoFirebaseMessagingService.java | 27 ++- package-lock.json | 10 + package.json | 1 + src/app/hooks/usePendingCallActionConsumer.ts | 8 +- src/app/hooks/usePendingDeclinesFlusher.ts | 80 +++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 7 + src/app/pages/client/ClientRoot.tsx | 13 ++ src/app/utils/sessionBridge.ts | 38 ++++ src/client/initMatrix.ts | 5 + 11 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java create mode 100644 src/app/hooks/usePendingDeclinesFlusher.ts create mode 100644 src/app/utils/sessionBridge.ts diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9a1bcd77..371818f9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,8 +2,14 @@ + + + diff --git a/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java b/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java new file mode 100644 index 00000000..52692419 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java @@ -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; + } +} diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java index 21895eeb..f0c0c45f 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -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 diff --git a/package-lock.json b/package-lock.json index d760a9e2..e4afbe2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a2e54fd2..5da33dbe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/hooks/usePendingCallActionConsumer.ts b/src/app/hooks/usePendingCallActionConsumer.ts index b734cf84..937f6932 100644 --- a/src/app/hooks/usePendingCallActionConsumer.ts +++ b/src/app/hooks/usePendingCallActionConsumer.ts @@ -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(() => { diff --git a/src/app/hooks/usePendingDeclinesFlusher.ts b/src/app/hooks/usePendingDeclinesFlusher.ts new file mode 100644 index 00000000..dcca443b --- /dev/null +++ b/src/app/hooks/usePendingDeclinesFlusher.ts @@ -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 } | 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]); +}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 121cdb47..408d14a4 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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) { + {children} ); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 7b1647c3..04a712f8 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -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 ; @@ -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) => { diff --git a/src/app/utils/sessionBridge.ts b/src/app/utils/sessionBridge.ts new file mode 100644 index 00000000..3985c7ed --- /dev/null +++ b/src/app/utils/sessionBridge.ts @@ -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 => { + 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 => { + if (!isNativePlatform()) return; + try { + const { Preferences } = await import('@capacitor/preferences'); + await Preferences.remove({ key: SESSION_KEY }); + } catch { + /* ignore */ + } +}; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 757451a0..9187ada6 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -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();