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
408f165f60
commit
38d24e5527
12 changed files with 975 additions and 139 deletions
|
|
@ -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 -->
|
||||||
|
|
|
||||||
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
|
@PluginMethod
|
||||||
public void cancel(PluginCall call) {
|
public void cancel(PluginCall call) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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;
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}} не подключён",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue