dm calls mvp: phase 5.35: decline via BroadcastReceiver with session bridge, pending-declines flusher, allowBackup=false
This commit is contained in:
parent
53a8bda18f
commit
63e9c65dd2
11 changed files with 395 additions and 5 deletions
|
|
@ -2,8 +2,14 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
|
@ -53,6 +59,10 @@
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".CallCancelReceiver"
|
android:name=".CallCancelReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".CallDeclineReceiver"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- 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(
|
PendingIntent answerPI = buildActionPI(
|
||||||
answerReq, "answer", roomId, notifEventId, messageId
|
answerReq, "answer", roomId, notifEventId, messageId
|
||||||
);
|
);
|
||||||
PendingIntent declinePI = buildActionPI(
|
// Decline goes through a BroadcastReceiver so the WebView never boots
|
||||||
declineReq, "decline", roomId, notifEventId, messageId
|
// — 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(
|
PendingIntent launchPI = buildActionPI(
|
||||||
launchReq, null, roomId, notifEventId, messageId
|
launchReq, null, roomId, notifEventId, messageId
|
||||||
|
|
@ -277,6 +282,24 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
return PendingIntent.getActivity(this, requestCode, intent, flags);
|
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
|
// Mirrors the JS-side createChannel in usePushNotifications.ts. Lazy creation
|
||||||
// from the service covers the fresh-install + killed-process race: FCM may
|
// 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
|
// 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/browser": "8.0.3",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "8.3.0",
|
"@capacitor/core": "8.3.0",
|
||||||
|
"@capacitor/preferences": "8.0.1",
|
||||||
"@capacitor/push-notifications": "8.0.3",
|
"@capacitor/push-notifications": "8.0.3",
|
||||||
"@capacitor/toast": "8.0.1",
|
"@capacitor/toast": "8.0.1",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
|
|
@ -1917,6 +1918,15 @@
|
||||||
"tslib": "^2.1.0"
|
"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": {
|
"node_modules/@capacitor/push-notifications": {
|
||||||
"version": "8.0.3",
|
"version": "8.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.0.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@
|
||||||
"@capacitor/browser": "8.0.3",
|
"@capacitor/browser": "8.0.3",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "8.3.0",
|
"@capacitor/core": "8.3.0",
|
||||||
|
"@capacitor/preferences": "8.0.1",
|
||||||
"@capacitor/push-notifications": "8.0.3",
|
"@capacitor/push-notifications": "8.0.3",
|
||||||
"@capacitor/toast": "8.0.1",
|
"@capacitor/toast": "8.0.1",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,17 @@ export const usePendingCallActionConsumer = (): void => {
|
||||||
setIncoming({ type: 'REMOVE_BY_ROOM', roomId: pending.roomId });
|
setIncoming({ type: 'REMOVE_BY_ROOM', roomId: pending.roomId });
|
||||||
startDmCall(pending.roomId);
|
startDmCall(pending.roomId);
|
||||||
} else {
|
} 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 });
|
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId: pending.notifEventId });
|
||||||
// Fire-and-minimize: dispatch the decline then minimize the app once the
|
// Fire-and-minimize: dispatch the decline then minimize the app once the
|
||||||
// request settles (success OR failure). Minimizing before sendRtcDecline
|
// request settles (success OR failure). Minimizing before sendRtcDecline
|
||||||
// resolves risks the WebView getting paused mid-request on slower devices;
|
// resolves risks the WebView getting paused mid-request on slower devices;
|
||||||
// waiting for settlement gives the network call the tick it needs.
|
// 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 = () => {
|
const minimize = () => {
|
||||||
if (!isNativePlatform()) return;
|
if (!isNativePlatform()) return;
|
||||||
App.minimizeApp().catch(() => {
|
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 { PushPermissionPrompt } from '../../components/push-permission-prompt';
|
||||||
import { FullScreenIntentPrompt } from '../../components/full-screen-intent-prompt';
|
import { FullScreenIntentPrompt } from '../../components/full-screen-intent-prompt';
|
||||||
import { useAndroidBackButton } from '../../hooks/useAndroidBackButton';
|
import { useAndroidBackButton } from '../../hooks/useAndroidBackButton';
|
||||||
|
import { usePendingDeclinesFlusher } from '../../hooks/usePendingDeclinesFlusher';
|
||||||
|
|
||||||
function SystemEmojiFeature() {
|
function SystemEmojiFeature() {
|
||||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||||
|
|
@ -158,6 +159,11 @@ function AndroidBackButtonFeature() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PendingDeclinesFlusherFeature() {
|
||||||
|
usePendingDeclinesFlusher();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -169,6 +175,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||||
<PushPermissionPrompt />
|
<PushPermissionPrompt />
|
||||||
<FullScreenIntentPrompt />
|
<FullScreenIntentPrompt />
|
||||||
<AndroidBackButtonFeature />
|
<AndroidBackButtonFeature />
|
||||||
|
<PendingDeclinesFlusherFeature />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
import { getFallbackSession } from '../../state/sessions';
|
import { getFallbackSession } from '../../state/sessions';
|
||||||
import { AutoDiscovery } from './AutoDiscovery';
|
import { AutoDiscovery } from './AutoDiscovery';
|
||||||
import { AuthSplashScreen } from '../auth/AuthSplashScreen';
|
import { AuthSplashScreen } from '../auth/AuthSplashScreen';
|
||||||
|
import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridge';
|
||||||
|
|
||||||
function ClientRootLoading() {
|
function ClientRootLoading() {
|
||||||
return <AuthSplashScreen />;
|
return <AuthSplashScreen />;
|
||||||
|
|
@ -118,6 +119,10 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
|
||||||
const useLogoutListener = (mx?: MatrixClient) => {
|
const useLogoutListener = (mx?: MatrixClient) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
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();
|
mx?.stopClient();
|
||||||
await mx?.clearStores();
|
await mx?.clearStores();
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
|
@ -166,6 +171,14 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||||
}
|
}
|
||||||
}, [mx, startMatrix]);
|
}, [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(
|
useSyncState(
|
||||||
mx,
|
mx,
|
||||||
useCallback((state) => {
|
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,
|
unregisterPusher,
|
||||||
} from '../app/utils/push';
|
} from '../app/utils/push';
|
||||||
import { isNativePlatform } from '../app/utils/capacitor';
|
import { isNativePlatform } from '../app/utils/capacitor';
|
||||||
|
import { clearSessionBridge } from '../app/utils/sessionBridge';
|
||||||
|
|
||||||
type Session = {
|
type Session = {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
@ -94,6 +95,10 @@ export const logoutClient = async (mx: MatrixClient) => {
|
||||||
clearPusherIds();
|
clearPusherIds();
|
||||||
setPushEnabled(false);
|
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();
|
mx.stopClient();
|
||||||
try {
|
try {
|
||||||
await mx.logout();
|
await mx.logout();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue