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),
};