diff --git a/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java b/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java index 083a2b93..ecac6b2e 100644 --- a/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java +++ b/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java @@ -12,6 +12,7 @@ import androidx.core.app.NotificationCompat; import androidx.core.app.RemoteInput; import org.json.JSONObject; +import org.json.JSONException; import java.io.IOException; import java.io.OutputStream; @@ -88,12 +89,11 @@ public class ReplyReceiver extends BroadcastReceiver { 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); - + // Pre-flight validation BEFORE the optimistic echo. Posting a self + // bubble first and then immediately stacking an error notif on top + // is jarring UX; for predictable failures (logged out, freshly + // encrypted room) we'd rather skip the echo and only surface the + // error. final SharedPreferences prefs = appContext.getSharedPreferences( VojoPollWorker.PREFS, Context.MODE_PRIVATE); final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null); @@ -104,6 +104,28 @@ public class ReplyReceiver extends BroadcastReceiver { return; } + // Race guard for E2EE flip: the per-room metadata snapshot is + // refreshed by JS on m.room.encryption Timeline events, but a push + // delivered in the narrow window between the encryption state + // landing and the dump completing could still expose the reply + // action on a freshly-encrypted room. Re-read the snapshot + // synchronously here — Synapse does NOT enforce "no cleartext in + // encrypted rooms" at the spec level, so without this guard we'd + // leak the user's reply into an E2EE timeline as plaintext. + if (isRoomEncryptedAtSendTime(prefs, roomId)) { + Log.w(TAG, "onReceive: room flipped to encrypted between render and send, abort"); + postReplyError(appContext, roomId); + return; + } + + // 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. Only happens after pre-flight + // checks pass so the user doesn't see an echo for a reply we know + // will fail. + long now = System.currentTimeMillis(); + VojoFirebaseMessagingService.appendOutgoingMessage(appContext, roomId, text, now); + final PendingResult pendingResult = goAsync(); final String txnId = "vojo-reply-" + UUID.randomUUID(); EXECUTOR.execute(() -> { @@ -180,7 +202,7 @@ public class ReplyReceiver extends BroadcastReceiver { ctx.getSystemService(Context.NOTIFICATION_SERVICE); if (nm == null) return; try { - String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM_PUBLIC; + String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM; NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, channel) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(PushStrings.replyFailed(ctx)) @@ -197,4 +219,30 @@ public class ReplyReceiver extends BroadcastReceiver { private static String trimTrailingSlash(String s) { return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s; } + + /** + * Synchronous re-check of the room's encryption flag at send time. + * Mirrors VojoFirebaseMessagingService.loadRoomMetadata's tolerant + * parse: legacy string-shape entries and missing flags both default + * to encrypted=true (privacy-first — refusing a reply on a falsely- + * flagged room is harmless; sending cleartext into a truly encrypted + * room is a privacy leak). + */ + private static boolean isRoomEncryptedAtSendTime(SharedPreferences prefs, String roomId) { + String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null); + if (raw == null || raw.isEmpty()) return true; + try { + JSONObject map = new JSONObject(raw); + if (!map.has(roomId) || map.isNull(roomId)) return true; + JSONObject obj = map.optJSONObject(roomId); + if (obj == null) { + // Legacy string-shape predates the encryption flag — + // assume encrypted to err on the side of privacy. + return true; + } + return obj.optBoolean("isEncrypted", true); + } catch (JSONException je) { + return true; + } + } } 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 a7208e65..7808b17e 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -66,12 +66,11 @@ public class VojoFirebaseMessagingService extends MessagingService { // after creation on API 26+, so any future config change (sound, vibration // pattern) bumps the version. private static final String MESSAGE_CHANNEL_GROUP_ID = "vojo_messages_v1"; - private static final String CHANNEL_ID_DM = "vojo_messages_dm_v1"; + // Package-visible: ReplyReceiver reuses CHANNEL_ID_DM for its "reply + // failed" one-shot notification. Was previously a separate _PUBLIC + // alias; collapsed to a single package-private constant. + 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 @@ -105,8 +104,12 @@ public class VojoFirebaseMessagingService extends MessagingService { * m.rtc.notification events that share the parent — re-ring for * participant rejoin, FCM retry-with-fresh-event-id — see the mark and * silently skip the post. + * + * Package-private so VojoPollWorker can build the same key shape from + * its flatten path without duplicating the literal — keeps the FCM and + * polling paths trivially in sync. */ - private static String compositeCallDedupKey(String roomId, String callSessionId) { + static String compositeCallDedupKey(String roomId, String callSessionId) { return "call:" + roomId + ":" + callSessionId; } @@ -121,8 +124,12 @@ public class VojoFirebaseMessagingService extends MessagingService { * deployment config. We probe both shapes — and `content_call_id` as * a third candidate for legacy MSC2746 calls — so the dedup works * regardless of which encoding the homeserver's Sygnal happens to use. + * + * Package-private: VojoPollWorker's flatten path writes the + * `content_m.relates_to_event_id` form, so this same helper resolves + * both FCM and polling payloads. */ - private static String extractCallSessionId(Map data) { + static String extractCallSessionId(Map data) { String[] candidates = new String[] { "content_m.relates_to_event_id", "content_m_relates_to_event_id", diff --git a/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java index 953cf88c..4b424fba 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java +++ b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java @@ -291,13 +291,16 @@ public class VojoPollWorker extends Worker { // missed-call. Without this, a session with one // FCM live-alert ring + one re-ring through // polling would surface as both a CallStyle and - // a missed-call card. + // a missed-call card. Helpers live in + // VojoFirebaseMessagingService so the key shape + // stays in lock-step across FCM and polling. String roomIdField = flattened.get("room_id"); - String sessionId = flattened.get("content_m.relates_to_event_id"); - if (sessionId == null) sessionId = flattened.get("content_call_id"); + String sessionId = VojoFirebaseMessagingService + .extractCallSessionId(flattened); + String composite = null; if (roomIdField != null && sessionId != null) { - String composite = - "call:" + roomIdField + ":" + sessionId; + composite = VojoFirebaseMessagingService + .compositeCallDedupKey(roomIdField, sessionId); if (NotificationDedup.wasNotified(ctx, composite)) { if (ts > highestTsSeen) highestTsSeen = ts; treatAsNotRenderable = true; @@ -311,12 +314,11 @@ public class VojoPollWorker extends Worker { // CallStyle. posted = VojoFirebaseMessagingService .renderMissedCallNotification(ctx, flattened); - if (posted && roomIdField != null && sessionId != null) { + if (posted && composite != null) { // Mark the composite so the next polling // cycle observing a re-ring for the same // session doesn't double-post. - NotificationDedup.markNotified(ctx, - "call:" + roomIdField + ":" + sessionId); + NotificationDedup.markNotified(ctx, composite); } } } else if (isRtcType) {