From 38d24e552789ca548424b123e00c0002617ec6db Mon Sep 17 00:00:00 2001 From: heaven Date: Sun, 17 May 2026 02:06:21 +0300 Subject: [PATCH] feat(push): group room messages into a per-room MessagingStyle conversation with DM/group channels, mark-as-read action and receipt-driven dismiss --- android/app/src/main/AndroidManifest.xml | 8 + .../chat/vojo/app/MarkAsReadReceiver.java | 138 +++++ .../vojo/app/NotificationDismissReceiver.java | 37 ++ .../java/chat/vojo/app/PollingPlugin.java | 18 + .../main/java/chat/vojo/app/PushStrings.java | 28 + .../java/chat/vojo/app/RoomMessageCache.java | 144 +++++ .../app/VojoFirebaseMessagingService.java | 564 +++++++++++++++--- public/locales/en.json | 9 +- public/locales/ru.json | 9 +- scripts/gen-push-strings.mjs | 7 + src/app/hooks/usePushNotifications.ts | 140 +++-- src/app/plugins/polling.ts | 12 +- 12 files changed, 975 insertions(+), 139 deletions(-) create mode 100644 android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java create mode 100644 android/app/src/main/java/chat/vojo/app/NotificationDismissReceiver.java create mode 100644 android/app/src/main/java/chat/vojo/app/RoomMessageCache.java diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 812752b8..d6bb5178 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -109,6 +109,14 @@ + + + + diff --git a/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java b/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java new file mode 100644 index 00000000..97991a7a --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java @@ -0,0 +1,138 @@ +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 java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Handles the per-notification "Mark as read" action. + * + * Posts {@code POST /_matrix/client/v3/rooms/{roomId}/receipt/m.read/{eventId}} + * using the access token saved by the polling lifecycle in + * {@code vojo_poll_state} SharedPreferences (same storage VojoPollWorker uses; + * keeps the credential lifecycle single-sourced). After a successful 2xx the + * per-room MessagingStyle notification is dismissed and the + * {@link RoomMessageCache} is cleared so the next push to that room starts a + * fresh conversation rather than re-appending to the prior history. + * + * Failure mode: on any non-2xx or thrown exception we silently leave the + * notification on the shade. We do not implement a flusher (unlike + * CallDeclineReceiver) because: + * - the user can just dismiss with a swipe or open the room + * - a stale read receipt isn't user-visible: when the user opens the room, + * the in-app read-marker logic re-sends with a fresher eventId + * - the alternative — accumulating tombstones in prefs — risks leaking + * historical eventIds the JS side would re-issue on app resume anyway + * + * Null-credential edge case (fresh install + first push before any saveSession + * bridge): no token to use, we just dismiss the notification locally so the + * user isn't stuck looking at a "stuck" Mark-as-read button. The next normal + * read-marker write from the JS side covers the server view. + */ +public class MarkAsReadReceiver extends BroadcastReceiver { + + public static final String ACTION_MARK_AS_READ = "chat.vojo.app.MARK_AS_READ"; + public static final String EXTRA_ROOM_ID = "room_id"; + public static final String EXTRA_EVENT_ID = "event_id"; + + private static final int CONNECT_TIMEOUT_MS = 8_000; + private static final int READ_TIMEOUT_MS = 8_000; + private static final String TAG = "MarkAsReadRcvr"; + + 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 eventId = intent.getStringExtra(EXTRA_EVENT_ID); + if (roomId == null || roomId.isEmpty()) { + Log.w(TAG, "onReceive: missing room_id, abort"); + return; + } + + final Context appContext = context.getApplicationContext(); + // Dismiss first for instant UX feedback — HTTP latency is irrelevant + // to the perceived "marked as read" action. + VojoFirebaseMessagingService.dismissRoomNotification(appContext, roomId); + + 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, local dismiss only"); + return; + } + if (eventId == null || eventId.isEmpty()) { + // Without an eventId we cannot issue a receipt PUT — the JS-side + // read-marker handler will catch this up on the next room open. + Log.w(TAG, "onReceive: no event_id, local dismiss only"); + return; + } + + final PendingResult pendingResult = goAsync(); + EXECUTOR.execute(() -> { + try { + int status = sendReceipt(homeserver, token, roomId, eventId); + if (status >= 200 && status < 300) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "receipt ok status=" + status + " room=" + roomId); + } + } else { + Log.w(TAG, "receipt non-2xx status=" + status + " room=" + roomId); + } + } catch (Throwable t) { + Log.w(TAG, "receipt threw room=" + roomId, t); + } finally { + pendingResult.finish(); + } + }); + } + + private int sendReceipt( + String baseUrl, + String accessToken, + String roomId, + String eventId + ) throws IOException { + String url = trimTrailingSlash(baseUrl) + + "/_matrix/client/v3/rooms/" + + URLEncoder.encode(roomId, "UTF-8") + + "/receipt/m.read/" + + URLEncoder.encode(eventId, "UTF-8"); + + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + try { + conn.setRequestMethod("POST"); + conn.setRequestProperty("Authorization", "Bearer " + accessToken); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setDoOutput(true); + // Empty JSON body per spec; setFixedLengthStreamingMode keeps the + // connection on the cached path instead of chunked-transfer fallback. + byte[] payload = "{}".getBytes("UTF-8"); + conn.setFixedLengthStreamingMode(payload.length); + try (java.io.OutputStream os = conn.getOutputStream()) { + os.write(payload); + } + return conn.getResponseCode(); + } finally { + 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/NotificationDismissReceiver.java b/android/app/src/main/java/chat/vojo/app/NotificationDismissReceiver.java new file mode 100644 index 00000000..4689a46c --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/NotificationDismissReceiver.java @@ -0,0 +1,37 @@ +package chat.vojo.app; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * Fires when the user swipes a per-room MessagingStyle notification away. + * + * Without this hook, RoomMessageCache would still hold the prior messages + * for that room — and the next push would append onto that history and + * re-surface the messages the user just dismissed. With it, swipe clears + * the cache so the next push starts a fresh conversation for the room. + * + * NOTE: this only fires for user-driven dismissals — programmatic + * nm.cancel calls (mark-as-read, receipt-driven dismiss, channel migration) + * already call RoomMessageCache.clear themselves and do NOT fire the + * delete intent. There's no double-clear risk. + */ +public class NotificationDismissReceiver extends BroadcastReceiver { + + public static final String ACTION_NOTIFICATION_DISMISSED = + "chat.vojo.app.NOTIFICATION_DISMISSED"; + public static final String EXTRA_ROOM_ID = "room_id"; + + private static final String TAG = "DismissRcvr"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) return; + String roomId = intent.getStringExtra(EXTRA_ROOM_ID); + if (roomId == null || roomId.isEmpty()) return; + if (BuildConfig.DEBUG) Log.d(TAG, "swipe clear cache room=" + roomId); + RoomMessageCache.clear(roomId); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/PollingPlugin.java b/android/app/src/main/java/chat/vojo/app/PollingPlugin.java index 9c3cfb32..7ef598e2 100644 --- a/android/app/src/main/java/chat/vojo/app/PollingPlugin.java +++ b/android/app/src/main/java/chat/vojo/app/PollingPlugin.java @@ -161,6 +161,24 @@ public class PollingPlugin extends Plugin { } } + /** + * Dismiss the per-room MessagingStyle notification + clear the in-memory + * RoomMessageCache for the room. Called from the JS receipt listener when + * a server-side read receipt zeroes the unread count (the user read on + * another device / tab). No-op if the notification was never posted or + * has already been swiped away. + */ + @PluginMethod + public void dismissRoom(PluginCall call) { + String roomId = call.getString("roomId"); + if (roomId == null || roomId.isEmpty()) { + call.reject("missing_roomId"); + return; + } + VojoFirebaseMessagingService.dismissRoomNotification(getContext(), roomId); + call.resolve(); + } + @PluginMethod public void cancel(PluginCall call) { try { 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 267dbfb1..17baedb6 100644 --- a/android/app/src/main/java/chat/vojo/app/PushStrings.java +++ b/android/app/src/main/java/chat/vojo/app/PushStrings.java @@ -54,6 +54,34 @@ final class PushStrings { return forAppLocale(ctx).getString(R.string.push_missed_call_body, safeCaller); } + static String channelGroup(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_channel_group); + } + + static String channelDm(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_channel_dm); + } + + static String channelDmDescription(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_channel_dm_description); + } + + static String channelGroupRoom(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_channel_group_room); + } + + static String channelGroupRoomDescription(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_channel_group_room_description); + } + + static String selfName(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_self_name); + } + + static String markAsReadAction(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_action_mark_as_read); + } + /** * 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/RoomMessageCache.java b/android/app/src/main/java/chat/vojo/app/RoomMessageCache.java new file mode 100644 index 00000000..13fd4b21 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/RoomMessageCache.java @@ -0,0 +1,144 @@ +package chat.vojo.app; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.Person; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Per-room MessagingStyle history cache. + * + * Stores the last N messages observed for each room so renderMessageNotification + * can rebuild a NotificationCompat.MessagingStyle with conversation context on + * every new event instead of posting a fresh single-message notification per + * event. Without this every 5-message DM produced 5 distinct entries in the + * shade; with it the user sees one expandable conversation per room — the + * WhatsApp/Telegram convention. + * + * Thread-safety: ConcurrentHashMap + per-key synchronized mutation via the + * compute() / get() pattern. Both VojoFirebaseMessagingService.onMessageReceived + * (Firebase-managed thread) and VojoPollWorker.doWork (WorkManager executor) + * mutate the cache; without serialization a same-room FCM + polling race could + * lose a message. Mutations are short — only deque append + bounded trim. + * + * Persistence: in-memory only. After process kill the cache is empty, and + * renderMessageNotification falls back to extractMessagingStyleFromNotification + * to recover history from the live system shade. If the user dismissed the + * notification too, the conversation legitimately starts fresh — no signal we + * could recover from there anyway. + * + * Eviction: bounded at MAX_MESSAGES_PER_ROOM per room, with FIFO eviction + * (oldest message at the head of the deque is dropped via pollFirst when the + * append would exceed the cap). Map itself is unbounded; in practice the + * dump from dismissRoom (when a server-side read receipt clears unread) keeps + * the room count proportional to active conversations. For safety against + * runaway growth from rooms the user never reads, we cap the map at MAX_ROOMS. + */ +final class RoomMessageCache { + + // Element-android keeps a similar in-memory queue (NotificationEventQueue); + // 20 messages per room is generous enough for an active group chat while + // staying well under Android's MessagingStyle render budget — Android only + // shows the last ~7 messages in the shade anyway. + private static final int MAX_MESSAGES_PER_ROOM = 20; + + // Hard cap on the map size so a long-running session that touches many + // rooms without ever clearing receipts can't slowly leak memory. + // Eviction is approximate (oldest-touched first via insertion order from + // ConcurrentHashMap is NOT guaranteed, so we just clear the oldest by + // arbitrary entry on overflow — acceptable for an LRU at this scale). + private static final int MAX_ROOMS = 200; + + private static final ConcurrentHashMap> store = + new ConcurrentHashMap<>(); + + private RoomMessageCache() {} + + /** + * Snapshot of a single rendered message. We can't store + * NotificationCompat.MessagingStyle.Message directly because Person's + * Icon field is not safely shareable across threads / not cheap to + * rebuild on every poll. Building the Message at render time from this + * record matches element-android's RoomGroupMessageCreator pattern. + */ + static final class Entry { + final String body; + final long timestamp; + final String senderKey; + final String senderName; + final boolean fromSelf; + + Entry(String body, long timestamp, String senderKey, String senderName, boolean fromSelf) { + this.body = body; + this.timestamp = timestamp; + this.senderKey = senderKey; + this.senderName = senderName; + this.fromSelf = fromSelf; + } + } + + /** + * Append a message to the room's history and return an ordered snapshot + * including the newly-added entry. Snapshot is taken INSIDE the atomic + * compute() so a concurrent append for the same room can't mutate the + * deque between our addLast and our copy. Returning the deque reference + * and copying outside is unsafe — ConcurrentHashMap.compute serialises + * only the lambda body per key, not subsequent reads of the value. + */ + static List append(String roomId, Entry entry) { + if (roomId == null || roomId.isEmpty() || entry == null) { + return java.util.Collections.emptyList(); + } + final List snapshot = new ArrayList<>(); + store.compute(roomId, (key, existing) -> { + Deque d = (existing != null) ? existing : new ArrayDeque<>(); + d.addLast(entry); + while (d.size() > MAX_MESSAGES_PER_ROOM) { + d.pollFirst(); + } + snapshot.addAll(d); + return d; + }); + // Bound the map. Iteration order of ConcurrentHashMap is unspecified + // and the size() check is racy with concurrent puts; we accept ±1 + // eviction precision at the 200-room cap as an acceptable approximation + // of LRU (the alternative is a global lock on every append which is + // far more expensive than letting the cache drift by one). + if (store.size() > MAX_ROOMS) { + java.util.Iterator it = store.keySet().iterator(); + while (it.hasNext() && store.size() > MAX_ROOMS) { + String key = it.next(); + if (!key.equals(roomId)) it.remove(); + } + } + return snapshot; + } + + /** + * Seed the room's history from an already-posted MessagingStyle (recovered + * via NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification + * after process kill). Idempotent: if the room already has cached entries + * we leave them alone — they are by construction at least as recent. + */ + static void seedIfAbsent(String roomId, List entries) { + if (roomId == null || roomId.isEmpty() || entries == null || entries.isEmpty()) return; + store.computeIfAbsent(roomId, key -> { + Deque d = new ArrayDeque<>(); + for (Entry e : entries) { + d.addLast(e); + while (d.size() > MAX_MESSAGES_PER_ROOM) d.pollFirst(); + } + return d; + }); + } + + /** Drop all cached messages for a room (e.g. on receipt-driven dismiss). */ + static void clear(String roomId) { + if (roomId == null || roomId.isEmpty()) return; + store.remove(roomId); + } +} 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 0f505c86..7147d1ad 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -1,11 +1,14 @@ package chat.vojo.app; import android.app.AlarmManager; +import android.app.Notification; import android.app.NotificationChannel; +import android.app.NotificationChannelGroup; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.media.AudioAttributes; import android.media.RingtoneManager; import android.net.Uri; @@ -20,8 +23,11 @@ import androidx.core.app.Person; import com.capacitorjs.plugins.pushnotifications.MessagingService; import com.google.firebase.messaging.RemoteMessage; +import org.json.JSONObject; + import java.util.HashMap; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -47,7 +53,21 @@ import java.util.concurrent.ConcurrentHashMap; */ public class VojoFirebaseMessagingService extends MessagingService { - private static final String CHANNEL_ID = "vojo_messages"; + // Legacy channel ID (single bucket for everything). Kept as a constant so + // the v1 channel creation path can delete it cleanly on first run after + // upgrade. Do not post into this channel any more. + private static final String LEGACY_MESSAGE_CHANNEL_ID = "vojo_messages"; + + // Channel-group + per-bucket channels. Group lets the OS Settings UI + // collapse both message channels under one "Chats" header so the user can + // toggle DM vs group rooms independently without spelunking. v1 suffix + // mirrors the vojo_calls_v2 strategy — channel settings are immutable + // 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"; + private static final String CHANNEL_ID_GROUP = "vojo_messages_group_v1"; + private static final String GROUP_KEY = "vojo_messages"; // NotificationChannel settings (vibration pattern, sound, importance) are // immutable after creation on API 26+. Bump this ID whenever the channel @@ -263,18 +283,20 @@ public class VojoFirebaseMessagingService extends MessagingService { * * Static + Context-parameterised so the Worker — which has no Service * lifetime — can post into the same notification id space. Identity is - * derived as `(eventId ?? roomId ?? "vojo").hashCode()` (see the - * `uniqueKey` computation below); in normal operation both code paths - * always carry event_id, so the slot collapses an FCM-then-polling - * double-delivery while both surfaces are still visible. Once dismissed - * the slot is empty and Android wouldn't collapse anymore — that gap is - * covered by NotificationDedup, the shared cross-source LRU written - * from both paths after a successful nm.notify. + * derived as `roomId.hashCode()` — one MessagingStyle conversation per + * room, appended on each new event. WhatsApp/Telegram convention: five + * messages in one DM coalesce into one expandable entry instead of five + * stacked banners. NotificationDedup is the cross-source LRU that prevents + * FCM and polling from double-counting the same event into the conversation. + * + * History source: in-memory RoomMessageCache (bounded per-room deque). + * Process-kill recovery: extractMessagingStyleFromNotification reads the + * existing on-shade notification and seeds the cache, so a re-render after + * cold-start preserves prior messages instead of starting empty. * * Both call sites pre-gate the "should I render" decision: FCM gates on - * `!isInForeground` (foreground hands UX to the live timeline), polling - * gates on its own watermark + NotificationDedup. This method just - * renders. + * `!isInForeground`, polling gates on its own watermark + NotificationDedup. + * This method just renders. */ static boolean renderMessageNotification( Context ctx, @@ -292,75 +314,125 @@ public class VojoFirebaseMessagingService extends MessagingService { boolean isInvite = "m.room.member".equals(data.get("type")) && "invite".equals(data.get("content_membership")); - String title; - String body; + // Invites pre-date conversation context and don't benefit from + // MessagingStyle (no body, no sender thread). Render them with the + // original BigTextStyle path so the invite-tap navigation still lands + // on the /direct/ panel via the existing JS listener. if (isInvite) { - // m.room.member invites carry no content.body — the generic - // path would show "New message" and drop the invite semantic. - // Title marks the category ("Invitation"); inviter + room land - // in body (WhatsApp/Telegram convention; keeps shade scannable). - title = PushStrings.inviteTitle(ctx); - body = PushStrings.inviteBody(ctx, humanInviter(data), data.get("room_name")); - } else { - // For the polling fallback, sender_display_name is never present - // (the /notifications endpoint returns the raw event with no - // server-side profile resolution). The chain therefore lands on - // `sender` for fresh rooms — strip the MXID to its local-part so - // the title reads "alice" instead of "@alice:hs.tld". Mirrors - // humanInviter() below. - title = firstNonEmpty( - data.get("room_name"), - data.get("sender_display_name"), - mxidLocalPart(data.get("sender")), - "Vojo" - ); - body = firstNonEmpty( - data.get("content_body"), - PushStrings.messageFallback(ctx) - ); + return renderInviteNotification(ctx, data, messageId); } - // Reuse Capacitor plugin's intent shape so its handleOnNewIntent() fires - // `pushNotificationActionPerformed` and the existing JS listener navigates. - Intent launchIntent = new Intent(ctx, MainActivity.class); - launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); - launchIntent.putExtra("google.message_id", messageId != null ? messageId : ""); - for (Map.Entry e : data.entrySet()) { - launchIntent.putExtra(e.getKey(), e.getValue()); + if (roomId == null || roomId.isEmpty()) { + Log.w(TAG, "msg: missing room_id, drop"); + return false; } - // Unique requestCode per event so each notification's PendingIntent is distinct - String uniqueKey = eventId != null ? eventId : (roomId != null ? roomId : "vojo"); - int requestCode = uniqueKey.hashCode(); - - int flags = PendingIntent.FLAG_UPDATE_CURRENT - | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); - PendingIntent pendingIntent = PendingIntent.getActivity(ctx, requestCode, launchIntent, flags); - - NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, CHANNEL_ID) - .setSmallIcon(R.mipmap.ic_launcher) - .setContentTitle(title) - .setContentText(body) - .setStyle(new NotificationCompat.BigTextStyle().bigText(body)) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .setGroup(GROUP_KEY) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_MESSAGE); - NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); if (nm == null) { Log.w(TAG, "msg: NotificationManager is null, abort"); return false; } - ensureMessageChannel(ctx, nm); + RoomMetadata meta = loadRoomMetadata(ctx, roomId); + String channelId = meta.isDirect ? CHANNEL_ID_DM : CHANNEL_ID_GROUP; + ensureMessageChannels(ctx, nm); - // Unique notification id per event — each message shows separately in the shade. - // Guard against the (rare) hashCode collision with the reserved summary id. - int notifId = uniqueKey.hashCode(); - if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1; - dlog("msg: posting notif id=" + notifId + " channel=" + CHANNEL_ID + String senderName = firstNonEmpty( + data.get("sender_display_name"), + mxidLocalPart(data.get("sender")), + "Vojo" + ); + String senderKey = firstNonEmpty(data.get("sender"), senderName); + String body = firstNonEmpty( + data.get("content_body"), + PushStrings.messageFallback(ctx) + ); + long timestamp = parseLong(data.get("content_sender_ts"), System.currentTimeMillis()); + if (timestamp <= 0L) timestamp = System.currentTimeMillis(); + + // Process-kill recovery: if our in-memory cache is empty for this + // room, try to recover the conversation history from the on-shade + // notification posted by a prior process. Without this a cold-start + // after kill would shrink the conversation back to a single message. + seedCacheFromActiveNotification(ctx, nm, roomId); + + RoomMessageCache.Entry entry = new RoomMessageCache.Entry( + body, timestamp, senderKey, senderName, /* fromSelf */ false + ); + List history = RoomMessageCache.append(roomId, entry); + + // Self Person anchors the MessagingStyle constructor. Real user_id + + // localised "You" label come from prefs that JS bridged via + // PollingPlugin.saveSession. On a fresh install with a push arriving + // before the JS bridge has ever run we fall through to a generic key + // — MessagingStyle still renders correctly, just without an account- + // specific self anchor. + Person self = buildSelfPerson(ctx); + + NotificationCompat.MessagingStyle style = new NotificationCompat.MessagingStyle(self); + String roomName = firstNonEmpty(data.get("room_name"), meta.name); + boolean isGroup = !meta.isDirect; + if (isGroup && !roomName.isEmpty()) { + style.setConversationTitle(roomName); + } + 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 + )); + } + + // Per-room id slot — every event in this room replaces in place. + int notifId = roomNotifId(roomId); + + // PendingIntent must be distinct per re-render so FLAG_UPDATE_CURRENT + // doesn't smash the prior intent's extras — but the request code is + // stable per room so we don't leak intents. + Intent launchIntent = new Intent(ctx, MainActivity.class); + launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + launchIntent.putExtra("google.message_id", messageId != null ? messageId : ""); + for (Map.Entry en : data.entrySet()) { + launchIntent.putExtra(en.getKey(), en.getValue()); + } + 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) + // Hook user-swipe so RoomMessageCache.clear runs and the next + // push to this room starts a fresh conversation — without this, + // the dismissed messages would re-surface inside the next + // MessagingStyle re-render. + .setDeleteIntent(buildDismissPendingIntent(ctx, roomId)) + .setGroup(GROUP_KEY) + // Suppress re-alerting on every appended message — Android would + // otherwise vibrate + sound + heads-up for each new event in an + // active conversation, which is the exact UX regression + // per-room MessagingStyle was supposed to fix. The first + // notification in a room still alerts; subsequent updates are + // visual only. + .setOnlyAlertOnce(history.size() > 1) + .setShortcutId(roomId) + .setWhen(timestamp) + .setShowWhen(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .addAction(buildMarkAsReadAction(ctx, roomId, eventId)); + + dlog("msg: posting notif id=" + notifId + " channel=" + channelId + + " historySize=" + history.size() + " notifsEnabled=" + nm.areNotificationsEnabled()); boolean posted = false; try { @@ -370,12 +442,13 @@ public class VojoFirebaseMessagingService extends MessagingService { Log.e(TAG, "msg: nm.notify threw SecurityException", e); } - // Summary notification for the group (Android shows this when 4+ notifications stack). - // Wrapped in try/catch matching the main notify above so a permission-revoked - // SecurityException here does not propagate up into VojoPollWorker.doWork's - // Throwable catch and trigger an unnecessary Result.retry() loop. + // Group summary keeps the per-room notifications collapsing under + // a single shade entry on Android pre-N + the launcher unread dot + // works off the summary on every Android. Posted on the DM channel + // so it inherits the lighter-weight settings; the per-room + // notification can still alert independently from its own channel. try { - NotificationCompat.Builder summary = new NotificationCompat.Builder(ctx, CHANNEL_ID) + NotificationCompat.Builder summary = new NotificationCompat.Builder(ctx, channelId) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle("Vojo") .setContentText(PushStrings.messagesFallback(ctx)) @@ -389,6 +462,240 @@ public class VojoFirebaseMessagingService extends MessagingService { return posted; } + /** + * Pre-MessagingStyle render path for m.room.member invites. They predate + * the room they would belong to (the conversation doesn't exist yet, no + * sender thread to anchor), so the BigTextStyle invite-card is still the + * right shape. The per-event id slot is preserved so multiple invites + * stack as separate entries. + */ + private static boolean renderInviteNotification( + Context ctx, + Map data, + String messageId + ) { + String roomId = data.get("room_id"); + String eventId = data.get("event_id"); + + String title = PushStrings.inviteTitle(ctx); + String body = PushStrings.inviteBody(ctx, humanInviter(data), data.get("room_name")); + + Intent launchIntent = new Intent(ctx, MainActivity.class); + launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + launchIntent.putExtra("google.message_id", messageId != null ? messageId : ""); + for (Map.Entry e : data.entrySet()) { + launchIntent.putExtra(e.getKey(), e.getValue()); + } + String uniqueKey = eventId != null ? eventId : (roomId != null ? roomId : "vojo"); + int requestCode = uniqueKey.hashCode(); + int flags = PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent pendingIntent = PendingIntent.getActivity(ctx, requestCode, launchIntent, flags); + + NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) { + Log.w(TAG, "invite: NotificationManager is null, abort"); + return false; + } + // Invites route to the DM channel — they almost always graduate into + // a DM and the lighter settings match their relatively quiet UX. + ensureMessageChannels(ctx, nm); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, CHANNEL_ID_DM) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(body) + .setStyle(new NotificationCompat.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setGroup(GROUP_KEY) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE); + + int notifId = uniqueKey.hashCode(); + if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1; + try { + nm.notify(notifId, builder.build()); + } catch (SecurityException e) { + Log.e(TAG, "invite: nm.notify threw SecurityException", e); + return false; + } + try { + NotificationCompat.Builder summary = new NotificationCompat.Builder(ctx, CHANNEL_ID_DM) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Vojo") + .setContentText(PushStrings.messagesFallback(ctx)) + .setGroup(GROUP_KEY) + .setGroupSummary(true) + .setAutoCancel(true); + nm.notify(SUMMARY_NOTIFICATION_ID, summary.build()); + } catch (SecurityException e) { + Log.e(TAG, "invite: summary notify threw SecurityException", e); + } + return true; + } + + /** + * Dismiss the per-room MessagingStyle notification and drop the in-memory + * cache. Called from MarkAsReadReceiver after a successful read receipt + * PUT and from PollingPlugin.dismissRoom when JS observes a server-side + * receipt taking unread count to zero on another device. + */ + static void dismissRoomNotification(Context ctx, String roomId) { + if (roomId == null || roomId.isEmpty()) return; + RoomMessageCache.clear(roomId); + NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) return; + try { + nm.cancel(roomNotifId(roomId)); + } catch (Throwable t) { + Log.w(TAG, "dismiss: nm.cancel threw room=" + roomId, t); + } + } + + static int roomNotifId(String roomId) { + int id = roomId.hashCode(); + // SUMMARY_NOTIFICATION_ID is reserved (Integer.MIN_VALUE); empty + // String hashCode() = 0 also leaves no native ambiguity but we + // bump it anyway so the slot is deterministic. + if (id == SUMMARY_NOTIFICATION_ID) id += 1; + return id; + } + + private static PendingIntent buildDismissPendingIntent(Context ctx, String roomId) { + Intent intent = new Intent(ctx, NotificationDismissReceiver.class) + .setAction(NotificationDismissReceiver.ACTION_NOTIFICATION_DISMISSED) + .putExtra(NotificationDismissReceiver.EXTRA_ROOM_ID, roomId); + int requestCode = ("dismiss_" + roomId).hashCode(); + int flags = PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + return PendingIntent.getBroadcast(ctx, requestCode, intent, flags); + } + + private static NotificationCompat.Action buildMarkAsReadAction( + Context ctx, String roomId, String eventId + ) { + Intent intent = new Intent(ctx, MarkAsReadReceiver.class) + .setAction(MarkAsReadReceiver.ACTION_MARK_AS_READ) + .putExtra(MarkAsReadReceiver.EXTRA_ROOM_ID, roomId); + if (eventId != null && !eventId.isEmpty()) { + intent.putExtra(MarkAsReadReceiver.EXTRA_EVENT_ID, eventId); + } + int requestCode = ("read_" + roomId).hashCode(); + int flags = PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent pi = PendingIntent.getBroadcast(ctx, requestCode, intent, flags); + return new NotificationCompat.Action.Builder( + R.mipmap.ic_launcher, + PushStrings.markAsReadAction(ctx), + pi + ) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .setShowsUserInterface(false) + .build(); + } + + private static Person buildSelfPerson(Context ctx) { + SharedPreferences prefs = ctx.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + String userId = prefs.getString(VojoPollWorker.KEY_USER_ID, null); + Person.Builder b = new Person.Builder() + .setName(PushStrings.selfName(ctx)); + if (userId != null && !userId.isEmpty()) { + b.setKey(userId); + } + return b.build(); + } + + /** + * Try to populate RoomMessageCache from an already-posted MessagingStyle + * notification so a re-render after process kill preserves conversation + * history instead of collapsing back to a single message. No-op when the + * cache already has entries for this room (the in-memory store is the + * source of truth in the normal hot path). + */ + private static void seedCacheFromActiveNotification( + Context ctx, NotificationManager nm, String roomId + ) { + try { + StatusBarNotification[] active = nm.getActiveNotifications(); + int slot = roomNotifId(roomId); + for (StatusBarNotification sbn : active) { + if (sbn.getTag() != null) continue; + if (sbn.getId() != slot) continue; + Notification posted = sbn.getNotification(); + if (posted == null) continue; + NotificationCompat.MessagingStyle existing = + NotificationCompat.MessagingStyle + .extractMessagingStyleFromNotification(posted); + if (existing == null) return; + List entries = new java.util.ArrayList<>(); + for (NotificationCompat.MessagingStyle.Message m : existing.getMessages()) { + Person p = m.getPerson(); + String name = p != null && p.getName() != null + ? p.getName().toString() : ""; + String key = p != null ? p.getKey() : null; + String body = m.getText() != null ? m.getText().toString() : ""; + entries.add(new RoomMessageCache.Entry( + body, m.getTimestamp(), key, name, + /* fromSelf */ p == null + )); + } + RoomMessageCache.seedIfAbsent(roomId, entries); + return; + } + } catch (Throwable t) { + Log.w(TAG, "seed: extractMessagingStyleFromNotification failed", t); + } + } + + /** + * 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). + */ + private static final class RoomMetadata { + final String name; + final boolean isDirect; + + RoomMetadata(String name, boolean isDirect) { + this.name = name == null ? "" : name; + this.isDirect = isDirect; + } + } + + private static RoomMetadata loadRoomMetadata(Context ctx, String roomId) { + if (roomId == null || roomId.isEmpty()) return new RoomMetadata("", true); + 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); + try { + JSONObject map = new JSONObject(raw); + if (!map.has(roomId) || map.isNull(roomId)) return new RoomMetadata("", true); + // 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. + JSONObject obj = map.optJSONObject(roomId); + if (obj != null) { + String name = obj.optString("name", ""); + boolean isDirect = obj.optBoolean("isDirect", true); + return new RoomMetadata(name, isDirect); + } + String legacyName = map.optString(roomId, ""); + // Default to DM when we have no isDirect signal. DM is the + // higher-importance channel (vojo_messages_dm_v1 is HIGH, group + // is DEFAULT) — over-alerting on a misclassified group is a less + // 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); + } catch (Throwable t) { + return new RoomMetadata("", true); + } + } + /** * Missed-call notification renderer for polling fallback delivery. By the * time VojoPollWorker observes an `m.rtc.notification` ring event (15-min @@ -397,9 +704,12 @@ public class VojoFirebaseMessagingService extends MessagingService { * notification "Пропущенный звонок от X" so the user knows somebody tried * to call. * - * Notification id space is shared with renderMessageNotification (same - * `eventId.hashCode()` slot), so if FCM later catches up and delivers the - * same ring event, Android replaces in place. + * Notification id is per-event (eventId.hashCode()), distinct from the + * per-room slot renderMessageNotification uses. Each missed ring stacks + * as its own entry — a single room could have multiple missed calls + * shown side-by-side, matching the WhatsApp/Telegram missed-call shade + * UX. Cross-source dedup (FCM → polling) is covered by NotificationDedup + * at the eventId level, not by an id-slot collapse. */ static boolean renderMissedCallNotification(Context ctx, Map data) { String roomId = data.get("room_id"); @@ -415,11 +725,15 @@ public class VojoFirebaseMessagingService extends MessagingService { return false; } - // Reuse the message channel — missed-call surfaces as a regular shade + // Reuse a message channel — missed-call surfaces as a regular shade // entry, not a live ring. Routing it to vojo_calls_v2 would inherit // the bypass-DnD + ringtone + 20-pulse vibration channel settings, - // which is wrong for a stale post-facto miss. - ensureMessageChannel(ctx, nm); + // which is wrong for a stale post-facto miss. We pick the DM channel + // because Vojo's only call surface today is 1-to-1 DM calls; if + // group calls land in the future this should mirror the + // renderMessageNotification routing. + ensureMessageChannels(ctx, nm); + String missedCallChannel = CHANNEL_ID_DM; String callerName = firstNonEmpty( data.get("sender_display_name"), @@ -448,7 +762,7 @@ public class VojoFirebaseMessagingService extends MessagingService { int notifId = eventId.hashCode(); if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1; - NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, CHANNEL_ID) + NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, missedCallChannel) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(title) .setContentText(body) @@ -998,25 +1312,79 @@ public class VojoFirebaseMessagingService extends MessagingService { return PendingIntent.getBroadcast(ctx, 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 - // never ran), in which case the channel doesn't exist yet and nm.notify - // would silently drop. Same race covers VojoPollWorker — Workers can fire - // before MainActivity ever runs after a reboot. - private static void ensureMessageChannel(Context ctx, NotificationManager nm) { + /** + * Create both message channels (DM + group rooms) and the umbrella channel + * group, idempotently. 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 never ran), in which + * case the channels don't exist yet and nm.notify would silently drop. + * Same race covers VojoPollWorker — Workers can fire before MainActivity + * ever runs after a reboot. + * + * Splitting DM and group into separate channels lets users mute group + * chat noise via OS settings without losing personal-message alerts — + * the WhatsApp/Telegram convention. Both channels live under a shared + * NotificationChannelGroup so Settings → Notifications collapses them + * under one "Chats" header. + * + * Legacy migration: the single-bucket `vojo_messages` channel is deleted + * on first creation of the v1 channels so it doesn't linger in OS + * settings. Users who had customised the legacy channel (e.g. muted it) + * lose that preference on upgrade — acceptable because the new split + * channels start from sensible defaults and the OS surfaces them as a + * group for easy re-customisation. Bumped to _v2 in the future if any + * setting changes (channel config is immutable post-creation on API 26+). + */ + private static void ensureMessageChannels(Context ctx, NotificationManager nm) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; - if (nm.getNotificationChannel(CHANNEL_ID) != null) return; - dlog("msg: creating channel " + CHANNEL_ID); - NotificationChannel channel = new NotificationChannel( - CHANNEL_ID, - "Messages", - NotificationManager.IMPORTANCE_HIGH - ); - channel.setDescription("New chat messages and invites"); - channel.enableVibration(true); - channel.enableLights(true); - nm.createNotificationChannel(channel); + + // One-shot legacy delete. Skipped after first run because the + // getNotificationChannel call cheaply returns null on subsequent + // invocations. + if (nm.getNotificationChannel(LEGACY_MESSAGE_CHANNEL_ID) != null) { + dlog("msg: deleting legacy channel " + LEGACY_MESSAGE_CHANNEL_ID); + nm.deleteNotificationChannel(LEGACY_MESSAGE_CHANNEL_ID); + } + + // Group must exist before channels are bound to it; createNotificationChannelGroup + // is idempotent for the same id. + try { + NotificationChannelGroup grp = new NotificationChannelGroup( + MESSAGE_CHANNEL_GROUP_ID, + PushStrings.channelGroup(ctx) + ); + nm.createNotificationChannelGroup(grp); + } catch (Throwable t) { + Log.w(TAG, "msg: createNotificationChannelGroup threw", t); + } + + if (nm.getNotificationChannel(CHANNEL_ID_DM) == null) { + dlog("msg: creating channel " + CHANNEL_ID_DM); + NotificationChannel dm = new NotificationChannel( + CHANNEL_ID_DM, + PushStrings.channelDm(ctx), + NotificationManager.IMPORTANCE_HIGH + ); + dm.setDescription(PushStrings.channelDmDescription(ctx)); + dm.setGroup(MESSAGE_CHANNEL_GROUP_ID); + dm.enableVibration(true); + dm.enableLights(true); + nm.createNotificationChannel(dm); + } + + if (nm.getNotificationChannel(CHANNEL_ID_GROUP) == null) { + dlog("msg: creating channel " + CHANNEL_ID_GROUP); + NotificationChannel group = new NotificationChannel( + CHANNEL_ID_GROUP, + PushStrings.channelGroupRoom(ctx), + NotificationManager.IMPORTANCE_DEFAULT + ); + group.setDescription(PushStrings.channelGroupRoomDescription(ctx)); + group.setGroup(MESSAGE_CHANNEL_GROUP_ID); + group.enableVibration(true); + group.enableLights(true); + nm.createNotificationChannel(group); + } } private static void ensureCallChannel(Context ctx, NotificationManager nm) { diff --git a/public/locales/en.json b/public/locales/en.json index 3743d690..8477d8e4 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -886,7 +886,14 @@ "invite_body_no_inviter": "Invited you to {{roomName}}", "invite_body_generic": "New invitation", "missed_call": "Missed call", - "missed_call_body": "{{caller}} tried to call you" + "missed_call_body": "{{caller}} tried to call you", + "channel_group": "Chats", + "channel_dm": "Direct messages", + "channel_dm_description": "New messages from direct chats", + "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" }, "Bots": { "not_connected_title": "{{name}} is not connected", diff --git a/public/locales/ru.json b/public/locales/ru.json index 04bb80dc..3bd72cff 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -902,7 +902,14 @@ "invite_body_no_inviter": "Приглашение в {{roomName}}", "invite_body_generic": "Новое приглашение", "missed_call": "Пропущенный звонок", - "missed_call_body": "{{caller}} пытался вам дозвониться" + "missed_call_body": "{{caller}} пытался вам дозвониться", + "channel_group": "Чаты", + "channel_dm": "Личные сообщения", + "channel_dm_description": "Новые сообщения из личных переписок", + "channel_group_room": "Групповые чаты", + "channel_group_room_description": "Новые сообщения из групповых чатов и каналов", + "self_name": "Я", + "action_mark_as_read": "Прочитано" }, "Bots": { "not_connected_title": "{{name}} не подключён", diff --git a/scripts/gen-push-strings.mjs b/scripts/gen-push-strings.mjs index 374e5d4a..38c162d7 100644 --- a/scripts/gen-push-strings.mjs +++ b/scripts/gen-push-strings.mjs @@ -53,6 +53,13 @@ const ANDROID_KEYS = [ 'invite_body_generic', 'missed_call', 'missed_call_body', + 'channel_group', + 'channel_dm', + 'channel_dm_description', + 'channel_group_room', + 'channel_group_room_description', + 'self_name', + 'action_mark_as_read', ]; // i18next uses named placeholders ({{inviter}}); Android string resources diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts index 931af8f6..99f34ba1 100644 --- a/src/app/hooks/usePushNotifications.ts +++ b/src/app/hooks/usePushNotifications.ts @@ -1,7 +1,14 @@ import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSetAtom } from 'jotai'; -import type { MatrixClient } from 'matrix-js-sdk'; +import { + ClientEvent, + MatrixClient, + MatrixEvent, + NotificationCountType, + Room, + RoomEvent, +} from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useClientConfig } from './useClientConfig'; import { isAndroidPlatform, isNativePlatform } from '../utils/capacitor'; @@ -24,21 +31,36 @@ import { import { getDirectPath, getDirectRoomPath } from '../pages/pathUtils'; import { pendingCallActionAtom } from '../state/pendingCallAction'; import { useRoomNavigate } from './useRoomNavigate'; -import { polling } from '../plugins/polling'; +import { polling, type RoomMetadataMap } from '../plugins/polling'; +import { getAccountData, getMDirects } from '../utils/room'; +import { AccountDataEvent } from '../../types/matrix/accountData'; const noop = (): void => undefined; +const buildRoomMetadataSnapshot = (mx: MatrixClient): RoomMetadataMap => { + // m.direct lists DMs per peer-user; flattened into a Set the Java side can + // consult per room when picking the DM-vs-group notification channel. + // Falls back to an empty Set when account data is not yet hydrated — in + // that brief post-login window every room is treated as a group (quieter + // channel). The next m.direct sync re-dumps via our ClientEvent.AccountData + // listener with correct isDirect values, so the under-alerting is bounded + // to seconds. + const mDirectEvent = getAccountData(mx, AccountDataEvent.Direct); + const dmRooms = mDirectEvent ? getMDirects(mDirectEvent) : new Set(); + + return mx.getRooms().reduce((acc, room) => { + const { name } = room; + if (typeof name !== 'string' || name.length === 0) return acc; + acc[room.roomId] = { + name, + isDirect: dmRooms.has(room.roomId), + }; + return acc; + }, {}); +}; + const dumpRoomNamesToNative = async (mx: MatrixClient): Promise => { - const names: Record = mx - .getRooms() - .reduce>((acc, room) => { - const { name } = room; - if (typeof name === 'string' && name.length > 0) { - acc[room.roomId] = name; - } - return acc; - }, {}); - await polling.saveRoomNames(names); + await polling.saveRoomNames(buildRoomMetadataSnapshot(mx)); }; export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied'; @@ -350,6 +372,68 @@ export function usePushNotificationsLifecycle(): void { }; }, [navigate, navigateRoom, register]); + // Receipt-driven dismiss: when a server-side read receipt drops a room's + // unread count to zero, cancel the room's MessagingStyle notification + // (and clear its native cache) so the shade matches reality. Mirrors + // element-web's onRoomReceipt (src/Notifier.ts:486-500). Works only when + // JS is alive — for killed-process / FCM-blocked scenarios this branch + // is silent; acceptable because there's no client-visible read signal a + // dead JS context could observe from server-side anyway. + useEffect(() => { + if (!isAndroidPlatform()) return undefined; + + const handleReceipt = (ev: MatrixEvent, room: Room) => { + // Mirror useBindRoomToUnreadAtom: only act when the receipt is from + // the current user. SDK fires this for every participant; we don't + // want a peer reading on their device to dismiss our notification. + const myUserId = mx.getUserId(); + if (!myUserId) return; + const content = ev.getContent() as Record< + string, + Record> + >; + const isMyReceipt = Object.keys(content ?? {}).some((eventId) => + Object.keys(content[eventId] ?? {}).some( + (rType) => myUserId in (content[eventId]?.[rType] ?? {}) + ) + ); + if (!isMyReceipt) return; + + // `room.getUnreadNotificationCount()` is post-receipt; element-web + // gates dismissal on it == 0. Per-thread unread does not split: Total + // sums across main + threads, which is the right "everything read" + // signal for the shade dismiss. + const total = room.getUnreadNotificationCount(NotificationCountType.Total); + if (total === 0) { + polling.dismissRoom(room.roomId).catch(noop); + } + }; + + mx.on(RoomEvent.Receipt, handleReceipt); + return () => { + mx.removeListener(RoomEvent.Receipt, handleReceipt); + }; + }, [mx]); + + // 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. + useEffect(() => { + if (!isAndroidPlatform()) return undefined; + if (!pushEnabled) return undefined; + + const handleAccountData = (event: MatrixEvent) => { + if (event.getType() !== AccountDataEvent.Direct) return; + dumpRoomNamesToNative(mx).catch(noop); + }; + + mx.on(ClientEvent.AccountData, handleAccountData); + return () => { + mx.removeListener(ClientEvent.AccountData, handleAccountData); + }; + }, [mx, pushEnabled]); + // WorkManager-based polling fallback for users where FCM is blocked. // Runs in parallel with FCM — the renderer dedupes by event_id.hashCode() // notification id, so a double-delivery (FCM in seconds + polling at the @@ -522,31 +606,13 @@ export function usePushNotificationsLifecycle(): void { const { PushNotifications } = await pnPromise; if (cancelled) return; - // Android 8+ requires a notification channel before any system notification can appear, - // and apps with no channels aren't listed in Settings → Notifications. Sygnal sends - // data-only FCM (event_id_only format), so the plugin's auto-channel-on-notification-payload - // path never triggers — we must create the channel explicitly. - try { - await PushNotifications.createChannel({ - id: 'vojo_messages', - name: 'Messages', - description: 'New chat messages and invites', - importance: 5, - visibility: 1, - sound: 'default', - vibration: true, - lights: true, - }); - } catch { - /* channel may already exist */ - } - - // The call channel (vojo_calls_v2) is created lazily from the native - // VojoFirebaseMessagingService.ensureCallChannel() on first ring. Creating - // it here from JS would race with Java — whichever call wins freezes the - // vibration pattern / sound for the lifetime of the channel (immutable - // after creation on API 26+), and the Capacitor API can't set a long - // repeating vibrationPattern. Let Java own this channel exclusively. + // All notification channels (vojo_messages_dm_v1, vojo_messages_group_v1, + // vojo_calls_v2) are created lazily from native code on first use — + // VojoFirebaseMessagingService.ensureMessageChannels / ensureCallChannel. + // Creating them here from JS would race with Java; whichever call wins + // freezes the channel config for the lifetime of the channel (immutable + // after creation on API 26+) and the Capacitor API can't express the + // long repeating vibration pattern the call channel needs. // Persistent listener: update pusher on the server with the (possibly rotated) token. // MUST NOT call the full register() flow here — that would call diff --git a/src/app/plugins/polling.ts b/src/app/plugins/polling.ts index 3fc8c4a7..d110cdcd 100644 --- a/src/app/plugins/polling.ts +++ b/src/app/plugins/polling.ts @@ -13,12 +13,18 @@ import { registerPlugin } from '@capacitor/core'; import { isAndroidPlatform } from '../utils/capacitor'; +export type RoomMetadataMap = Record; + interface PollingPluginIface { saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise; clearSession(): Promise; - saveRoomNames(opts: { names: Record }): Promise; + // Tolerant of both legacy (`roomId: "Display"`) and new structured shape + // (`roomId: { name, isDirect }`) — the Java side decides the message + // channel (DM vs group) based on the structured value when present. + saveRoomNames(opts: { names: RoomMetadataMap }): Promise; schedule(opts: { intervalMinutes: number }): Promise; cancel(): Promise; + dismissRoom(opts: { roomId: string }): Promise; } const noopPlugin: PollingPluginIface = { @@ -27,6 +33,7 @@ const noopPlugin: PollingPluginIface = { saveRoomNames: async () => undefined, schedule: async () => undefined, cancel: async () => undefined, + dismissRoom: async () => undefined, }; const plugin = registerPlugin('Polling', { @@ -51,8 +58,9 @@ export const polling = { saveSession: (opts: { accessToken: string; homeserverUrl: string; userId?: string }) => guard(() => plugin.saveSession(opts), undefined), clearSession: () => guard(() => plugin.clearSession(), undefined), - saveRoomNames: (names: Record) => + saveRoomNames: (names: RoomMetadataMap) => guard(() => plugin.saveRoomNames({ names }), undefined), schedule: (intervalMinutes = 15) => guard(() => plugin.schedule({ intervalMinutes }), undefined), cancel: () => guard(() => plugin.cancel(), undefined), + dismissRoom: (roomId: string) => guard(() => plugin.dismissRoom({ roomId }), undefined), };