feat(push): group room messages into a per-room MessagingStyle conversation with DM/group channels, mark-as-read action and receipt-driven dismiss

This commit is contained in:
heaven 2026-05-17 02:06:21 +03:00
parent 408f165f60
commit 38d24e5527
12 changed files with 975 additions and 139 deletions

View file

@ -109,6 +109,14 @@
<receiver <receiver
android:name=".CallDeclineReceiver" android:name=".CallDeclineReceiver"
android:exported="false" /> android:exported="false" />
<receiver
android:name=".MarkAsReadReceiver"
android:exported="false" />
<receiver
android:name=".NotificationDismissReceiver"
android:exported="false" />
</application> </application>
<!-- Permissions --> <!-- Permissions -->

View file

@ -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;
}
}

View file

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

View file

@ -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 @PluginMethod
public void cancel(PluginCall call) { public void cancel(PluginCall call) {
try { try {

View file

@ -54,6 +54,34 @@ final class PushStrings {
return forAppLocale(ctx).getString(R.string.push_missed_call_body, safeCaller); 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 * Build the invite-notification body from inviter + room name, falling
* back through four variants when one or both are absent. The res IDs * back through four variants when one or both are absent. The res IDs

View file

@ -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<String, Deque<Entry>> 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<Entry> append(String roomId, Entry entry) {
if (roomId == null || roomId.isEmpty() || entry == null) {
return java.util.Collections.emptyList();
}
final List<Entry> snapshot = new ArrayList<>();
store.compute(roomId, (key, existing) -> {
Deque<Entry> 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<String> 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<Entry> entries) {
if (roomId == null || roomId.isEmpty() || entries == null || entries.isEmpty()) return;
store.computeIfAbsent(roomId, key -> {
Deque<Entry> 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);
}
}

View file

@ -1,11 +1,14 @@
package chat.vojo.app; package chat.vojo.app;
import android.app.AlarmManager; import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationChannel; import android.app.NotificationChannel;
import android.app.NotificationChannelGroup;
import android.app.NotificationManager; import android.app.NotificationManager;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences;
import android.media.AudioAttributes; import android.media.AudioAttributes;
import android.media.RingtoneManager; import android.media.RingtoneManager;
import android.net.Uri; import android.net.Uri;
@ -20,8 +23,11 @@ import androidx.core.app.Person;
import com.capacitorjs.plugins.pushnotifications.MessagingService; import com.capacitorjs.plugins.pushnotifications.MessagingService;
import com.google.firebase.messaging.RemoteMessage; import com.google.firebase.messaging.RemoteMessage;
import org.json.JSONObject;
import java.util.HashMap; import java.util.HashMap;
import java.util.Iterator; import java.util.Iterator;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@ -47,7 +53,21 @@ import java.util.concurrent.ConcurrentHashMap;
*/ */
public class VojoFirebaseMessagingService extends MessagingService { 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"; private static final String GROUP_KEY = "vojo_messages";
// NotificationChannel settings (vibration pattern, sound, importance) are // NotificationChannel settings (vibration pattern, sound, importance) are
// immutable after creation on API 26+. Bump this ID whenever the channel // 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 * Static + Context-parameterised so the Worker which has no Service
* lifetime can post into the same notification id space. Identity is * lifetime can post into the same notification id space. Identity is
* derived as `(eventId ?? roomId ?? "vojo").hashCode()` (see the * derived as `roomId.hashCode()` one MessagingStyle conversation per
* `uniqueKey` computation below); in normal operation both code paths * room, appended on each new event. WhatsApp/Telegram convention: five
* always carry event_id, so the slot collapses an FCM-then-polling * messages in one DM coalesce into one expandable entry instead of five
* double-delivery while both surfaces are still visible. Once dismissed * stacked banners. NotificationDedup is the cross-source LRU that prevents
* the slot is empty and Android wouldn't collapse anymore that gap is * FCM and polling from double-counting the same event into the conversation.
* covered by NotificationDedup, the shared cross-source LRU written *
* from both paths after a successful nm.notify. * 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 * Both call sites pre-gate the "should I render" decision: FCM gates on
* `!isInForeground` (foreground hands UX to the live timeline), polling * `!isInForeground`, polling gates on its own watermark + NotificationDedup.
* gates on its own watermark + NotificationDedup. This method just * This method just renders.
* renders.
*/ */
static boolean renderMessageNotification( static boolean renderMessageNotification(
Context ctx, Context ctx,
@ -292,75 +314,125 @@ public class VojoFirebaseMessagingService extends MessagingService {
boolean isInvite = "m.room.member".equals(data.get("type")) boolean isInvite = "m.room.member".equals(data.get("type"))
&& "invite".equals(data.get("content_membership")); && "invite".equals(data.get("content_membership"));
String title; // Invites pre-date conversation context and don't benefit from
String body; // 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) { if (isInvite) {
// m.room.member invites carry no content.body the generic return renderInviteNotification(ctx, data, messageId);
// 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)
);
} }
// Reuse Capacitor plugin's intent shape so its handleOnNewIntent() fires if (roomId == null || roomId.isEmpty()) {
// `pushNotificationActionPerformed` and the existing JS listener navigates. Log.w(TAG, "msg: missing room_id, drop");
Intent launchIntent = new Intent(ctx, MainActivity.class); return false;
launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
launchIntent.putExtra("google.message_id", messageId != null ? messageId : "");
for (Map.Entry<String, String> e : data.entrySet()) {
launchIntent.putExtra(e.getKey(), e.getValue());
} }
// 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); NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null) { if (nm == null) {
Log.w(TAG, "msg: NotificationManager is null, abort"); Log.w(TAG, "msg: NotificationManager is null, abort");
return false; 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. String senderName = firstNonEmpty(
// Guard against the (rare) hashCode collision with the reserved summary id. data.get("sender_display_name"),
int notifId = uniqueKey.hashCode(); mxidLocalPart(data.get("sender")),
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1; "Vojo"
dlog("msg: posting notif id=" + notifId + " channel=" + CHANNEL_ID );
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<RoomMessageCache.Entry> 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<String, String> 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()); + " notifsEnabled=" + nm.areNotificationsEnabled());
boolean posted = false; boolean posted = false;
try { try {
@ -370,12 +442,13 @@ public class VojoFirebaseMessagingService extends MessagingService {
Log.e(TAG, "msg: nm.notify threw SecurityException", e); Log.e(TAG, "msg: nm.notify threw SecurityException", e);
} }
// Summary notification for the group (Android shows this when 4+ notifications stack). // Group summary keeps the per-room notifications collapsing under
// Wrapped in try/catch matching the main notify above so a permission-revoked // a single shade entry on Android pre-N + the launcher unread dot
// SecurityException here does not propagate up into VojoPollWorker.doWork's // works off the summary on every Android. Posted on the DM channel
// Throwable catch and trigger an unnecessary Result.retry() loop. // so it inherits the lighter-weight settings; the per-room
// notification can still alert independently from its own channel.
try { try {
NotificationCompat.Builder summary = new NotificationCompat.Builder(ctx, CHANNEL_ID) NotificationCompat.Builder summary = new NotificationCompat.Builder(ctx, channelId)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Vojo") .setContentTitle("Vojo")
.setContentText(PushStrings.messagesFallback(ctx)) .setContentText(PushStrings.messagesFallback(ctx))
@ -389,6 +462,240 @@ public class VojoFirebaseMessagingService extends MessagingService {
return posted; 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<String, String> 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<String, String> 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<RoomMessageCache.Entry> 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 * Missed-call notification renderer for polling fallback delivery. By the
* time VojoPollWorker observes an `m.rtc.notification` ring event (15-min * 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 * notification "Пропущенный звонок от X" so the user knows somebody tried
* to call. * to call.
* *
* Notification id space is shared with renderMessageNotification (same * Notification id is per-event (eventId.hashCode()), distinct from the
* `eventId.hashCode()` slot), so if FCM later catches up and delivers the * per-room slot renderMessageNotification uses. Each missed ring stacks
* same ring event, Android replaces in place. * 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<String, String> data) { static boolean renderMissedCallNotification(Context ctx, Map<String, String> data) {
String roomId = data.get("room_id"); String roomId = data.get("room_id");
@ -415,11 +725,15 @@ public class VojoFirebaseMessagingService extends MessagingService {
return false; 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 // entry, not a live ring. Routing it to vojo_calls_v2 would inherit
// the bypass-DnD + ringtone + 20-pulse vibration channel settings, // the bypass-DnD + ringtone + 20-pulse vibration channel settings,
// which is wrong for a stale post-facto miss. // which is wrong for a stale post-facto miss. We pick the DM channel
ensureMessageChannel(ctx, nm); // 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( String callerName = firstNonEmpty(
data.get("sender_display_name"), data.get("sender_display_name"),
@ -448,7 +762,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
int notifId = eventId.hashCode(); int notifId = eventId.hashCode();
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1; 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) .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
@ -998,25 +1312,79 @@ public class VojoFirebaseMessagingService extends MessagingService {
return PendingIntent.getBroadcast(ctx, requestCode, intent, flags); 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 * Create both message channels (DM + group rooms) and the umbrella channel
// deliver before the app has ever been launched (so the JS lifecycle effect * group, idempotently. Lazy creation from the service covers the
// never ran), in which case the channel doesn't exist yet and nm.notify * fresh-install + killed-process race: FCM may deliver before the app has
// would silently drop. Same race covers VojoPollWorker Workers can fire * ever been launched (so the JS lifecycle effect never ran), in which
// before MainActivity ever runs after a reboot. * case the channels don't exist yet and nm.notify would silently drop.
private static void ensureMessageChannel(Context ctx, NotificationManager nm) { * 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 (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
dlog("msg: creating channel " + CHANNEL_ID); // One-shot legacy delete. Skipped after first run because the
NotificationChannel channel = new NotificationChannel( // getNotificationChannel call cheaply returns null on subsequent
CHANNEL_ID, // invocations.
"Messages", if (nm.getNotificationChannel(LEGACY_MESSAGE_CHANNEL_ID) != null) {
NotificationManager.IMPORTANCE_HIGH dlog("msg: deleting legacy channel " + LEGACY_MESSAGE_CHANNEL_ID);
); nm.deleteNotificationChannel(LEGACY_MESSAGE_CHANNEL_ID);
channel.setDescription("New chat messages and invites"); }
channel.enableVibration(true);
channel.enableLights(true); // Group must exist before channels are bound to it; createNotificationChannelGroup
nm.createNotificationChannel(channel); // 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) { private static void ensureCallChannel(Context ctx, NotificationManager nm) {

View file

@ -886,7 +886,14 @@
"invite_body_no_inviter": "Invited you to {{roomName}}", "invite_body_no_inviter": "Invited you to {{roomName}}",
"invite_body_generic": "New invitation", "invite_body_generic": "New invitation",
"missed_call": "Missed call", "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": { "Bots": {
"not_connected_title": "{{name}} is not connected", "not_connected_title": "{{name}} is not connected",

View file

@ -902,7 +902,14 @@
"invite_body_no_inviter": "Приглашение в {{roomName}}", "invite_body_no_inviter": "Приглашение в {{roomName}}",
"invite_body_generic": "Новое приглашение", "invite_body_generic": "Новое приглашение",
"missed_call": "Пропущенный звонок", "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": { "Bots": {
"not_connected_title": "{{name}} не подключён", "not_connected_title": "{{name}} не подключён",

View file

@ -53,6 +53,13 @@ const ANDROID_KEYS = [
'invite_body_generic', 'invite_body_generic',
'missed_call', 'missed_call',
'missed_call_body', '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 // i18next uses named placeholders ({{inviter}}); Android string resources

View file

@ -1,7 +1,14 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useSetAtom } from 'jotai'; 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 { useMatrixClient } from './useMatrixClient';
import { useClientConfig } from './useClientConfig'; import { useClientConfig } from './useClientConfig';
import { isAndroidPlatform, isNativePlatform } from '../utils/capacitor'; import { isAndroidPlatform, isNativePlatform } from '../utils/capacitor';
@ -24,21 +31,36 @@ import {
import { getDirectPath, getDirectRoomPath } from '../pages/pathUtils'; import { getDirectPath, getDirectRoomPath } from '../pages/pathUtils';
import { pendingCallActionAtom } from '../state/pendingCallAction'; import { pendingCallActionAtom } from '../state/pendingCallAction';
import { useRoomNavigate } from './useRoomNavigate'; 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 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<string>();
return mx.getRooms().reduce<RoomMetadataMap>((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<void> => { const dumpRoomNamesToNative = async (mx: MatrixClient): Promise<void> => {
const names: Record<string, string> = mx await polling.saveRoomNames(buildRoomMetadataSnapshot(mx));
.getRooms()
.reduce<Record<string, string>>((acc, room) => {
const { name } = room;
if (typeof name === 'string' && name.length > 0) {
acc[room.roomId] = name;
}
return acc;
}, {});
await polling.saveRoomNames(names);
}; };
export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied'; export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied';
@ -350,6 +372,68 @@ export function usePushNotificationsLifecycle(): void {
}; };
}, [navigate, navigateRoom, register]); }, [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<string, Record<string, { ts?: number }>>
>;
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. // WorkManager-based polling fallback for users where FCM is blocked.
// Runs in parallel with FCM — the renderer dedupes by event_id.hashCode() // Runs in parallel with FCM — the renderer dedupes by event_id.hashCode()
// notification id, so a double-delivery (FCM in seconds + polling at the // notification id, so a double-delivery (FCM in seconds + polling at the
@ -522,31 +606,13 @@ export function usePushNotificationsLifecycle(): void {
const { PushNotifications } = await pnPromise; const { PushNotifications } = await pnPromise;
if (cancelled) return; if (cancelled) return;
// Android 8+ requires a notification channel before any system notification can appear, // All notification channels (vojo_messages_dm_v1, vojo_messages_group_v1,
// and apps with no channels aren't listed in Settings → Notifications. Sygnal sends // vojo_calls_v2) are created lazily from native code on first use —
// data-only FCM (event_id_only format), so the plugin's auto-channel-on-notification-payload // VojoFirebaseMessagingService.ensureMessageChannels / ensureCallChannel.
// path never triggers — we must create the channel explicitly. // Creating them here from JS would race with Java; whichever call wins
try { // freezes the channel config for the lifetime of the channel (immutable
await PushNotifications.createChannel({ // after creation on API 26+) and the Capacitor API can't express the
id: 'vojo_messages', // long repeating vibration pattern the call channel needs.
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.
// Persistent listener: update pusher on the server with the (possibly rotated) token. // Persistent listener: update pusher on the server with the (possibly rotated) token.
// MUST NOT call the full register() flow here — that would call // MUST NOT call the full register() flow here — that would call

View file

@ -13,12 +13,18 @@
import { registerPlugin } from '@capacitor/core'; import { registerPlugin } from '@capacitor/core';
import { isAndroidPlatform } from '../utils/capacitor'; import { isAndroidPlatform } from '../utils/capacitor';
export type RoomMetadataMap = Record<string, string | { name: string; isDirect: boolean }>;
interface PollingPluginIface { interface PollingPluginIface {
saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise<void>; saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise<void>;
clearSession(): Promise<void>; clearSession(): Promise<void>;
saveRoomNames(opts: { names: Record<string, string> }): Promise<void>; // 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<void>;
schedule(opts: { intervalMinutes: number }): Promise<void>; schedule(opts: { intervalMinutes: number }): Promise<void>;
cancel(): Promise<void>; cancel(): Promise<void>;
dismissRoom(opts: { roomId: string }): Promise<void>;
} }
const noopPlugin: PollingPluginIface = { const noopPlugin: PollingPluginIface = {
@ -27,6 +33,7 @@ const noopPlugin: PollingPluginIface = {
saveRoomNames: async () => undefined, saveRoomNames: async () => undefined,
schedule: async () => undefined, schedule: async () => undefined,
cancel: async () => undefined, cancel: async () => undefined,
dismissRoom: async () => undefined,
}; };
const plugin = registerPlugin<PollingPluginIface>('Polling', { const plugin = registerPlugin<PollingPluginIface>('Polling', {
@ -51,8 +58,9 @@ export const polling = {
saveSession: (opts: { accessToken: string; homeserverUrl: string; userId?: string }) => saveSession: (opts: { accessToken: string; homeserverUrl: string; userId?: string }) =>
guard(() => plugin.saveSession(opts), undefined), guard(() => plugin.saveSession(opts), undefined),
clearSession: () => guard(() => plugin.clearSession(), undefined), clearSession: () => guard(() => plugin.clearSession(), undefined),
saveRoomNames: (names: Record<string, string>) => saveRoomNames: (names: RoomMetadataMap) =>
guard(() => plugin.saveRoomNames({ names }), undefined), guard(() => plugin.saveRoomNames({ names }), undefined),
schedule: (intervalMinutes = 15) => guard(() => plugin.schedule({ intervalMinutes }), undefined), schedule: (intervalMinutes = 15) => guard(() => plugin.schedule({ intervalMinutes }), undefined),
cancel: () => guard(() => plugin.cancel(), undefined), cancel: () => guard(() => plugin.cancel(), undefined),
dismissRoom: (roomId: string) => guard(() => plugin.dismissRoom({ roomId }), undefined),
}; };