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:
parent
ab27244579
commit
23b49d4230
12 changed files with 975 additions and 139 deletions
|
|
@ -109,6 +109,14 @@
|
|||
<receiver
|
||||
android:name=".CallDeclineReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".MarkAsReadReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".NotificationDismissReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
|
|
|||
138
android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java
Normal file
138
android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
144
android/app/src/main/java/chat/vojo/app/RoomMessageCache.java
Normal file
144
android/app/src/main/java/chat/vojo/app/RoomMessageCache.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> 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<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());
|
||||
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<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
|
||||
* 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<String, String> 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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}} не подключён",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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 names: Record<string, string> = 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);
|
||||
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<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.
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -13,12 +13,18 @@
|
|||
import { registerPlugin } from '@capacitor/core';
|
||||
import { isAndroidPlatform } from '../utils/capacitor';
|
||||
|
||||
export type RoomMetadataMap = Record<string, string | { name: string; isDirect: boolean }>;
|
||||
|
||||
interface PollingPluginIface {
|
||||
saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): 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>;
|
||||
cancel(): Promise<void>;
|
||||
dismissRoom(opts: { roomId: string }): Promise<void>;
|
||||
}
|
||||
|
||||
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<PollingPluginIface>('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<string, string>) =>
|
||||
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),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue