From 75022b9331283087b347a52f3f5161b543e84f82 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Sun, 17 May 2026 02:29:20 +0300 Subject: [PATCH] feat(push): inline RemoteInput reply on per-room notifications for cleartext rooms with optimistic local echo and encryption-state re-dump --- android/app/src/main/AndroidManifest.xml | 4 + .../main/java/chat/vojo/app/PushStrings.java | 12 ++ .../java/chat/vojo/app/ReplyReceiver.java | 200 ++++++++++++++++++ .../app/VojoFirebaseMessagingService.java | 188 +++++++++++++++- public/locales/en.json | 5 +- public/locales/ru.json | 5 +- scripts/gen-push-strings.mjs | 3 + src/app/hooks/usePushNotifications.ts | 23 +- src/app/plugins/polling.ts | 5 +- 9 files changed, 430 insertions(+), 15 deletions(-) create mode 100644 android/app/src/main/java/chat/vojo/app/ReplyReceiver.java diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d6bb5178..3deb0cf9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -117,6 +117,10 @@ + + diff --git a/android/app/src/main/java/chat/vojo/app/PushStrings.java b/android/app/src/main/java/chat/vojo/app/PushStrings.java index 17baedb6..08ed2955 100644 --- a/android/app/src/main/java/chat/vojo/app/PushStrings.java +++ b/android/app/src/main/java/chat/vojo/app/PushStrings.java @@ -82,6 +82,18 @@ final class PushStrings { return forAppLocale(ctx).getString(R.string.push_action_mark_as_read); } + static String replyAction(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_action_reply); + } + + static String replyHint(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_reply_hint); + } + + static String replyFailed(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_reply_failed); + } + /** * Build the invite-notification body from inviter + room name, falling * back through four variants when one or both are absent. The res IDs diff --git a/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java b/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java new file mode 100644 index 00000000..083a2b93 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java @@ -0,0 +1,200 @@ +package chat.vojo.app; + +import android.app.NotificationManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.RemoteInput; + +import org.json.JSONObject; + +import java.io.IOException; +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; + +/** + * Handles the inline-reply RemoteInput action on a per-room MessagingStyle + * notification. + * + * Flow: + * 1. User taps reply, types text, presses send → broadcast fires here. + * 2. We immediately append the outgoing message to RoomMessageCache and + * re-post the notification (instant UX feedback — the message appears + * as a self-Person bubble in the conversation while the HTTP is in + * flight). + * 3. PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId} + * with {msgtype: "m.text", body}. Uses the vojo_poll_state token (same + * storage as Worker / MarkAsReadReceiver — single credential lifecycle). + * 4. On 2xx: nothing further; the JS sync echo will eventually replace + * the local-echo bubble in-app. + * 5. On non-2xx or thrown: post a small error notification "Could not + * send your reply" so the user knows to retry from in-app — better + * than silently swallowing the message. + * + * E2EE rooms are guarded UP-STREAM in VojoFirebaseMessagingService. + * renderMessageNotification: we don't even attach the reply action when + * RoomMetadata.isEncrypted is true. So this receiver never has to encrypt. + * Defense in depth: if a stale notification with the action ever survives + * an encryption flip we still detect the failure as a non-2xx HTTP and + * surface the error notification rather than sending cleartext (which + * Synapse would in any case reject for an encrypted room). + * + * Null-credential edge case: post the error notification so the user + * notices and retries in-app. Same logic as a network failure. + */ +public class ReplyReceiver extends BroadcastReceiver { + + public static final String ACTION_REPLY = "chat.vojo.app.REPLY"; + public static final String EXTRA_ROOM_ID = "room_id"; + public static final String KEY_TEXT_REPLY = "vojo.text_reply"; + + private static final int CONNECT_TIMEOUT_MS = 8_000; + private static final int READ_TIMEOUT_MS = 8_000; + private static final String TAG = "ReplyRcvr"; + + 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); + if (roomId == null || roomId.isEmpty()) { + Log.w(TAG, "onReceive: missing room_id, abort"); + return; + } + + Bundle remote = RemoteInput.getResultsFromIntent(intent); + if (remote == null) { + Log.w(TAG, "onReceive: no RemoteInput results"); + return; + } + CharSequence reply = remote.getCharSequence(KEY_TEXT_REPLY); + if (reply == null) { + Log.w(TAG, "onReceive: RemoteInput missing text"); + return; + } + final String text = reply.toString().trim(); + if (text.isEmpty()) return; + + final Context appContext = context.getApplicationContext(); + + // Optimistic local echo — appends a self-Person message to the + // conversation and re-posts, so the user sees their reply in the + // shade before the HTTP completes. + long now = System.currentTimeMillis(); + VojoFirebaseMessagingService.appendOutgoingMessage(appContext, roomId, text, now); + + final SharedPreferences prefs = appContext.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null); + final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null); + if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) { + Log.w(TAG, "onReceive: no credentials in prefs, surfacing error notif"); + postReplyError(appContext, roomId); + return; + } + + final PendingResult pendingResult = goAsync(); + final String txnId = "vojo-reply-" + UUID.randomUUID(); + EXECUTOR.execute(() -> { + try { + int status = sendReply(homeserver, token, roomId, txnId, text); + if (status >= 200 && status < 300) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "reply ok status=" + status + " room=" + roomId); + } + } else { + Log.w(TAG, "reply non-2xx status=" + status + " room=" + roomId); + postReplyError(appContext, roomId); + } + } catch (Throwable t) { + Log.w(TAG, "reply threw room=" + roomId, t); + postReplyError(appContext, roomId); + } finally { + pendingResult.finish(); + } + }); + } + + private int sendReply( + String baseUrl, + String accessToken, + String roomId, + String txnId, + String text + ) throws IOException { + String url = trimTrailingSlash(baseUrl) + + "/_matrix/client/v3/rooms/" + + URLEncoder.encode(roomId, "UTF-8") + + "/send/m.room.message/" + + URLEncoder.encode(txnId, "UTF-8"); + + JSONObject body; + try { + body = new JSONObject(); + body.put("msgtype", "m.text"); + body.put("body", text); + } catch (org.json.JSONException je) { + // JSONObject.put only throws on NaN/Inf doubles, neither of + // which we use — but keep the type contract honest. + throw new IOException("payload encode failed", je); + } + byte[] payload = body.toString().getBytes("UTF-8"); + + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + try { + conn.setRequestMethod("PUT"); + conn.setRequestProperty("Authorization", "Bearer " + accessToken); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setDoOutput(true); + conn.setFixedLengthStreamingMode(payload.length); + try (OutputStream os = conn.getOutputStream()) { + os.write(payload); + } + return conn.getResponseCode(); + } finally { + conn.disconnect(); + } + } + + /** + * Surface a short error notification when the reply HTTP fails so the + * user knows the message did NOT land server-side and can retry from + * within the app. Posted on the DM channel as a one-shot. Unique notif + * id per room so it can't clobber the room's conversation slot. + */ + private static void postReplyError(Context ctx, String roomId) { + NotificationManager nm = (NotificationManager) + ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) return; + try { + String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM_PUBLIC; + NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, channel) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(PushStrings.replyFailed(ctx)) + .setContentText(PushStrings.replyFailed(ctx)) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT); + int errId = ("replyErr_" + roomId).hashCode(); + nm.notify(errId, b.build()); + } catch (Throwable t) { + Log.w(TAG, "reply error notif failed", t); + } + } + + 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 f6f7fffc..a7208e65 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -19,6 +19,7 @@ import android.util.Log; import androidx.core.app.NotificationCompat; import androidx.core.app.Person; +import androidx.core.app.RemoteInput; import com.capacitorjs.plugins.pushnotifications.MessagingService; import com.google.firebase.messaging.RemoteMessage; @@ -67,6 +68,10 @@ public class VojoFirebaseMessagingService extends MessagingService { private static final String MESSAGE_CHANNEL_GROUP_ID = "vojo_messages_v1"; private static final String CHANNEL_ID_DM = "vojo_messages_dm_v1"; private static final String CHANNEL_ID_GROUP = "vojo_messages_group_v1"; + // Package-visible mirror of CHANNEL_ID_DM so ReplyReceiver can post its + // one-shot "reply failed" error notification on the same channel without + // duplicating the literal. + static final String CHANNEL_ID_DM_PUBLIC = CHANNEL_ID_DM; private static final String GROUP_KEY = "vojo_messages"; // NotificationChannel settings (vibration pattern, sound, importance) are @@ -491,6 +496,15 @@ public class VojoFirebaseMessagingService extends MessagingService { .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) .addAction(buildMarkAsReadAction(ctx, roomId, eventId)); + // Inline reply is only safe in cleartext rooms — the receiver + // builds a vanilla `m.room.message`, and we have no key material + // on the Java side to encrypt. RoomMetadata.isEncrypted defaults + // to true on missing/legacy snapshots (privacy-first), so reply + // is opt-in via a confirmed cleartext flag rather than opt-out. + if (!meta.isEncrypted) { + builder.addAction(buildReplyAction(ctx, roomId)); + } + dlog("msg: posting notif id=" + notifId + " channel=" + channelId + " historySize=" + history.size() + " notifsEnabled=" + nm.areNotificationsEnabled()); @@ -655,6 +669,137 @@ public class VojoFirebaseMessagingService extends MessagingService { .build(); } + /** + * Inline reply action with RemoteInput. Tapping the action opens an + * in-shade text field; submit fires the receiver and PUTs the message + * as `m.room.message` from the cleartext path. Only attached for + * non-encrypted rooms (see caller gate); E2EE replies need keys the + * Java side does not have. + * + * MutabilityCompat: RemoteInput requires the PendingIntent to be + * MUTABLE so the input bundle can be attached at submit time. We OR + * FLAG_MUTABLE on API 31+ where the immutable default would otherwise + * cause the receiver to fire without any RemoteInput payload. + */ + private static NotificationCompat.Action buildReplyAction(Context ctx, String roomId) { + Intent intent = new Intent(ctx, ReplyReceiver.class) + .setAction(ReplyReceiver.ACTION_REPLY) + .putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId); + int requestCode = ("reply_" + roomId).hashCode(); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags |= PendingIntent.FLAG_MUTABLE; + } + PendingIntent pi = PendingIntent.getBroadcast(ctx, requestCode, intent, flags); + RemoteInput remoteInput = new RemoteInput.Builder(ReplyReceiver.KEY_TEXT_REPLY) + .setLabel(PushStrings.replyHint(ctx)) + .build(); + return new NotificationCompat.Action.Builder( + R.mipmap.ic_launcher, + PushStrings.replyAction(ctx), + pi + ) + .addRemoteInput(remoteInput) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .setShowsUserInterface(false) + // Allow Wear / Android Auto auto-replies for accessibility. + .setAllowGeneratedReplies(true) + .build(); + } + + /** + * Append a self-Person message to the room's MessagingStyle cache and + * re-post the notification with the new entry visible. Used by + * ReplyReceiver for instant optimistic feedback before the HTTP PUT + * completes — the user sees their text in the conversation bubble + * immediately, the live sync echo eventually replaces it with the + * server-authoritative version when they next open the app. + * + * Returns true iff the notification re-render succeeded. + */ + static boolean appendOutgoingMessage( + Context ctx, String roomId, String body, long timestamp + ) { + if (roomId == null || roomId.isEmpty() || body == null || body.isEmpty()) { + return false; + } + NotificationManager nm = (NotificationManager) + ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) return false; + RoomMetadata meta = loadRoomMetadata(ctx, roomId); + String channelId = meta.isDirect ? CHANNEL_ID_DM : CHANNEL_ID_GROUP; + ensureMessageChannels(ctx, nm); + + // Re-seed from active notification before append so the new self + // message lands at the tail of any in-flight conversation history + // (covers the cold-render-after-kill case for outgoing replies). + seedCacheFromActiveNotification(ctx, nm, roomId); + + RoomMessageCache.Entry self = new RoomMessageCache.Entry( + body, timestamp, /*senderKey*/ null, /*senderName*/ "", /*fromSelf*/ true + ); + java.util.List history = RoomMessageCache.append(roomId, self); + + Person selfPerson = buildSelfPerson(ctx); + NotificationCompat.MessagingStyle style = new NotificationCompat.MessagingStyle(selfPerson); + boolean isGroup = !meta.isDirect; + if (isGroup && !meta.name.isEmpty()) { + style.setConversationTitle(meta.name); + } + style.setGroupConversation(isGroup); + for (RoomMessageCache.Entry e : history) { + Person sender = e.fromSelf ? null : new Person.Builder() + .setName(e.senderName != null ? e.senderName : "") + .setKey(e.senderKey != null ? e.senderKey : "") + .build(); + style.addMessage(new NotificationCompat.MessagingStyle.Message( + e.body != null ? e.body : "", e.timestamp, sender + )); + } + + Intent launchIntent = new Intent(ctx, MainActivity.class); + launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + launchIntent.putExtra("google.message_id", ""); + launchIntent.putExtra("room_id", roomId); + int flags = PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent contentIntent = PendingIntent.getActivity( + ctx, ("open_" + roomId).hashCode(), launchIntent, flags + ); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, channelId) + .setSmallIcon(R.mipmap.ic_launcher) + .setStyle(style) + .setAutoCancel(true) + .setContentIntent(contentIntent) + .setDeleteIntent(buildDismissPendingIntent(ctx, roomId)) + .setGroup(GROUP_KEY) + // Always silent — sending a reply must not re-alert the user. + .setOnlyAlertOnce(true) + .setShortcutId(roomId) + .setWhen(timestamp) + .setShowWhen(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + // Re-attach mark-as-read so the action set stays consistent on + // re-render; eventId is unknown for an outgoing-only update so + // it falls through to the local-dismiss-only branch in the + // receiver — acceptable for an optimistic echo. + .addAction(buildMarkAsReadAction(ctx, roomId, null)); + if (!meta.isEncrypted) { + builder.addAction(buildReplyAction(ctx, roomId)); + } + + try { + nm.notify(roomNotifId(roomId), builder.build()); + return true; + } catch (SecurityException e) { + Log.e(TAG, "outgoing: nm.notify threw SecurityException", e); + return false; + } + } + private static Person buildSelfPerson(Context ctx) { SharedPreferences prefs = ctx.getSharedPreferences( VojoPollWorker.PREFS, Context.MODE_PRIVATE); @@ -712,36 +857,54 @@ public class VojoFirebaseMessagingService extends MessagingService { /** * Snapshot of per-room metadata bridged from JS via * PollingPlugin.saveRoomNames (which now accepts both a legacy - * room_id → name string map AND a structured shape including isDirect). + * room_id → name string map AND a structured shape including isDirect + * and isEncrypted). isEncrypted controls whether the inline reply + * action is offered — we can't sign + encrypt without keys on the Java + * side, so encrypted rooms get a read-only notification. */ private static final class RoomMetadata { final String name; final boolean isDirect; + final boolean isEncrypted; - RoomMetadata(String name, boolean isDirect) { + RoomMetadata(String name, boolean isDirect, boolean isEncrypted) { this.name = name == null ? "" : name; this.isDirect = isDirect; + this.isEncrypted = isEncrypted; } } private static RoomMetadata loadRoomMetadata(Context ctx, String roomId) { - if (roomId == null || roomId.isEmpty()) return new RoomMetadata("", true); + if (roomId == null || roomId.isEmpty()) { + return new RoomMetadata("", true, false); + } SharedPreferences prefs = ctx.getSharedPreferences( VojoPollWorker.PREFS, Context.MODE_PRIVATE); String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null); - if (raw == null || raw.isEmpty()) return new RoomMetadata("", true); + if (raw == null || raw.isEmpty()) { + return new RoomMetadata("", true, false); + } try { JSONObject map = new JSONObject(raw); - if (!map.has(roomId) || map.isNull(roomId)) return new RoomMetadata("", true); + if (!map.has(roomId) || map.isNull(roomId)) { + return new RoomMetadata("", true, false); + } // Legacy shape: { roomId: "Display name" }. New shape: - // { roomId: { name: "Display name", isDirect: bool } }. Parse - // tolerantly so an APK that still has the old map written to - // prefs from a previous boot doesn't lose channel routing. + // { roomId: { name: "Display name", isDirect: bool, + // isEncrypted: bool } }. Parse tolerantly so an APK + // that still has the old map written to prefs from a previous + // boot doesn't lose channel routing. JSONObject obj = map.optJSONObject(roomId); if (obj != null) { String name = obj.optString("name", ""); boolean isDirect = obj.optBoolean("isDirect", true); - return new RoomMetadata(name, isDirect); + // Default encrypted=true on missing flag: refusing to offer + // reply on a falsely-flagged-encrypted room is harmless; + // offering reply on a truly-encrypted room sends cleartext + // into the timeline, which is a privacy leak. The conservative + // direction is to assume encryption. + boolean isEncrypted = obj.optBoolean("isEncrypted", true); + return new RoomMetadata(name, isDirect, isEncrypted); } String legacyName = map.optString(roomId, ""); // Default to DM when we have no isDirect signal. DM is the @@ -750,9 +913,12 @@ public class VojoFirebaseMessagingService extends MessagingService { // bad failure than under-alerting on a misclassified DM, which // would silently swallow a personal message during the brief // post-fresh-install window before the JS bridge dumps metadata. - return new RoomMetadata(legacyName, true); + // Legacy shape predates the encryption flag, so assume + // encrypted=true (no reply action) to err on the side of + // privacy. + return new RoomMetadata(legacyName, true, true); } catch (Throwable t) { - return new RoomMetadata("", true); + return new RoomMetadata("", true, true); } } diff --git a/public/locales/en.json b/public/locales/en.json index 8477d8e4..9912b342 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -893,7 +893,10 @@ "channel_group_room": "Group chats", "channel_group_room_description": "New messages from group chats and channels", "self_name": "You", - "action_mark_as_read": "Mark as read" + "action_mark_as_read": "Mark as read", + "action_reply": "Reply", + "reply_hint": "Reply…", + "reply_failed": "Could not send your reply" }, "Bots": { "not_connected_title": "{{name}} is not connected", diff --git a/public/locales/ru.json b/public/locales/ru.json index 3bd72cff..adb18d75 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -909,7 +909,10 @@ "channel_group_room": "Групповые чаты", "channel_group_room_description": "Новые сообщения из групповых чатов и каналов", "self_name": "Я", - "action_mark_as_read": "Прочитано" + "action_mark_as_read": "Прочитано", + "action_reply": "Ответить", + "reply_hint": "Ответ…", + "reply_failed": "Не удалось отправить ответ" }, "Bots": { "not_connected_title": "{{name}} не подключён", diff --git a/scripts/gen-push-strings.mjs b/scripts/gen-push-strings.mjs index 38c162d7..03d97340 100644 --- a/scripts/gen-push-strings.mjs +++ b/scripts/gen-push-strings.mjs @@ -60,6 +60,9 @@ const ANDROID_KEYS = [ 'channel_group_room_description', 'self_name', 'action_mark_as_read', + 'action_reply', + 'reply_hint', + 'reply_failed', ]; // i18next uses named placeholders ({{inviter}}); Android string resources diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts index 99f34ba1..61138823 100644 --- a/src/app/hooks/usePushNotifications.ts +++ b/src/app/hooks/usePushNotifications.ts @@ -51,9 +51,15 @@ const buildRoomMetadataSnapshot = (mx: MatrixClient): RoomMetadataMap => { return mx.getRooms().reduce((acc, room) => { const { name } = room; if (typeof name !== 'string' || name.length === 0) return acc; + // hasEncryptionStateEvent reads the m.room.encryption state event from + // the live timeline — synchronous and cheap. Used by the Java side to + // gate the inline reply action: encrypted rooms get a read-only + // notification because the Java path has no key material to encrypt + // outgoing replies with. acc[room.roomId] = { name, isDirect: dmRooms.has(room.roomId), + isEncrypted: room.hasEncryptionStateEvent(), }; return acc; }, {}); @@ -418,7 +424,15 @@ export function usePushNotificationsLifecycle(): void { // Re-dump the room metadata snapshot whenever m.direct changes — without // this, freshly-created DMs would land on the louder group-message // channel until the next visibilitychange re-bridge. The dump is small - // (room id + name + isDirect bool) and account-data updates are rare. + // (room id + name + isDirect + isEncrypted) and account-data updates are + // rare. + // + // The same effect also re-dumps on m.room.encryption state events so the + // inline-reply action is dropped within seconds of a room being switched + // to E2EE. Without this re-dump, a cleartext-by-prefs notification could + // post the reply action onto a freshly-encrypted room and the receiver + // would send a cleartext reply (Synapse does not enforce the + // "encrypted-only" rule, so the leak is real). useEffect(() => { if (!isAndroidPlatform()) return undefined; if (!pushEnabled) return undefined; @@ -427,10 +441,17 @@ export function usePushNotificationsLifecycle(): void { if (event.getType() !== AccountDataEvent.Direct) return; dumpRoomNamesToNative(mx).catch(noop); }; + const handleTimeline = (event: MatrixEvent) => { + if (event.getType() === 'm.room.encryption') { + dumpRoomNamesToNative(mx).catch(noop); + } + }; mx.on(ClientEvent.AccountData, handleAccountData); + mx.on(RoomEvent.Timeline, handleTimeline); return () => { mx.removeListener(ClientEvent.AccountData, handleAccountData); + mx.removeListener(RoomEvent.Timeline, handleTimeline); }; }, [mx, pushEnabled]); diff --git a/src/app/plugins/polling.ts b/src/app/plugins/polling.ts index d110cdcd..adfd81b0 100644 --- a/src/app/plugins/polling.ts +++ b/src/app/plugins/polling.ts @@ -13,7 +13,10 @@ import { registerPlugin } from '@capacitor/core'; import { isAndroidPlatform } from '../utils/capacitor'; -export type RoomMetadataMap = Record; +export type RoomMetadataMap = Record< + string, + string | { name: string; isDirect: boolean; isEncrypted: boolean } +>; interface PollingPluginIface { saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise;