dm calls mvp: phase 5.35: decline via BroadcastReceiver with session bridge, pending-declines flusher, allowBackup=false
This commit is contained in:
parent
299107b13a
commit
3290b7f594
11 changed files with 395 additions and 5 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
199
android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java
Normal file
199
android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
80
src/app/hooks/usePendingDeclinesFlusher.ts
Normal file
80
src/app/hooks/usePendingDeclinesFlusher.ts
Normal 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]);
|
||||
};
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
38
src/app/utils/sessionBridge.ts
Normal file
38
src/app/utils/sessionBridge.ts
Normal 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 */
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue