feat(push): real sender+room avatars via MXC bridge with adaptive shortcut icons, plus review fixes (GROUP_ALERT_ALL, eventId dedup, isEncrypted privacy default, structured roomname parse, mark-as-read optimistic docs)

This commit is contained in:
heaven 2026-05-17 17:39:19 +03:00
parent 4b4454fa1d
commit 5dbe83aa9d
10 changed files with 1124 additions and 47 deletions

View file

@ -0,0 +1,65 @@
package chat.vojo.app;
import android.graphics.Bitmap;
import android.util.LruCache;
/**
* In-memory LRU cache of decoded avatar bitmaps keyed by MXC URL string.
*
* Sized as a process-singleton (~4 MB) so the FCM service, polling Worker
* and ReplyReceiver all share one pool. 96×96 ARGB_8888 bitmap is about
* 36 KB, so a 4 MB cache holds ~110 avatars enough for the active
* conversation set on a typical user. LruCache evicts the least-recently-
* read entry when full; this is the right shape for "rooms the user is
* actively talking in stay warm, dormant rooms reload on demand".
*
* Thread-safety: LruCache itself is synchronized internally on every
* get/put/remove. We don't need an outer lock for normal operation. The
* AvatarLoader funnels all puts through this class.
*
* Process death: cache is in-memory only. After a kill, the first push
* to any room cold-renders without avatars and re-renders once the
* loader populates the cache (see AvatarLoader.loadAllWithTimeout).
*/
final class AvatarBitmapCache {
// Heap budget: bytes. 4 MB is generous against ARGB_8888 96×96 bitmaps
// (~36 KB each) and stays comfortably under the 1/8-of-heap Android
// recommendation on every device we ship to (minSdk 24 at least
// 96 MB heap on a low-end phone).
private static final int MAX_SIZE_BYTES = 4 * 1024 * 1024;
private static final LruCache<String, Bitmap> CACHE =
new LruCache<String, Bitmap>(MAX_SIZE_BYTES) {
@Override
protected int sizeOf(String key, Bitmap value) {
return value.getByteCount();
}
};
private AvatarBitmapCache() {}
/**
* Returns the cached bitmap for an MXC URL, or null on miss.
*
* Bitmap references are NOT defensively copied the cache hands out
* the same reference to every caller. This is safe because no code
* path in the app calls Bitmap.recycle() on a cached bitmap (the
* intermediate square / source bitmaps inside AvatarLoader.
* toCircularBitmap ARE recycled, but the circular output that lands
* here is held until LRU evicts it). LRU eviction simply drops the
* cache's reference, and the GC reclaims memory only after every
* Notification that referenced the bitmap is also released by the
* system. Adding a defensive copy here would halve the effective
* cache size for no real-world benefit.
*/
static Bitmap get(String mxc) {
if (mxc == null || mxc.isEmpty()) return null;
return CACHE.get(mxc);
}
static void put(String mxc, Bitmap bitmap) {
if (mxc == null || mxc.isEmpty() || bitmap == null) return;
CACHE.put(mxc, bitmap);
}
}

View file

@ -0,0 +1,368 @@
package chat.vojo.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Shader;
import android.util.Log;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* Fetches and decodes avatar bitmaps from MXC URLs, populating
* {@link AvatarBitmapCache}.
*
* URL resolution mirrors matrix-js-sdk's auth-media v1.11+ pattern:
* mxc://server/mediaId
* <homeserver>/_matrix/client/v1/media/thumbnail/<server>/<mediaId>
* ?width=96&height=96&method=crop
* + Authorization: Bearer <accessToken>
*
* The legacy unauthenticated `/_matrix/media/v3/thumbnail/...` endpoint is
* NOT used every Synapse the Vojo audience runs against (vanilla, v1.11+
* by deployment policy, see docs/ai/server-side.md) speaks auth media.
* Removing the legacy fallback keeps the loader off the deprecated path
* and avoids leaking the access token to a server route that doesn't
* require it.
*
* Concurrency: each MXC URL is fetched at most once concurrently the
* `inFlight` set short-circuits duplicate requests from rapid
* append-rebuild cycles on the same conversation. Loads happen on a
* shared 4-thread pool; bigger than 1 so 5 senders in a group chat can
* load in parallel, capped to keep socket pressure under the typical
* mobile network budget.
*
* Two entry points:
* - {@link #loadAllWithTimeout}: synchronous wait, used by the render
* path to populate the cache before building the MessagingStyle so the
* first post already has avatars. Timeout-bounded to keep FCM thread
* responsive (Android budgets ~10s; we use 800 ms).
* - {@link #prefetch}: fire-and-forget, used for warm-up scenarios.
* Not currently called but kept for the room-metadata bridge to
* eventually warm the cache on visibility resume.
*/
final class AvatarLoader {
private static final String TAG = "AvatarLoader";
private static final int AVATAR_SIZE_PX = 96;
private static final int CONNECT_TIMEOUT_MS = 5_000;
private static final int READ_TIMEOUT_MS = 5_000;
private static final int RENDER_BLOCK_TIMEOUT_MS = 800;
// Cap decoded bitmap byte count a malicious / huge avatar shouldn't
// OOM the FCM service. 96×96 ARGB_8888 is ~36 KB; we accept up to
// 4× that (~140 KB) to allow some downscaling slack on servers that
// return slightly oversized thumbnails.
private static final int MAX_DECODED_BYTES = 144 * 1024;
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);
// MXC URL CountDownLatch that fires when the in-flight download
// completes (success or failure). A second caller observing an
// already-pending mxc waits on the SAME latch instead of either
// returning empty-handed or kicking off a duplicate fetch. Latches
// are removed by the worker task in its finally block; the same task
// that put the entry is the only one allowed to remove it, so a slow
// remove() race is harmless.
private static final ConcurrentHashMap<String, CountDownLatch> inFlight =
new ConcurrentHashMap<>();
private AvatarLoader() {}
/**
* Block the caller for up to {@link #RENDER_BLOCK_TIMEOUT_MS} while
* fetching any of the given MXC URLs that are not yet in
* {@link AvatarBitmapCache}. Cache hits are no-ops. Already-in-flight
* URLs are awaited via the shared latch duplicate concurrent
* fetches do not happen.
*
* Designed to be called inline from the render path: after this
* returns, {@link AvatarBitmapCache#get} will be non-null for every
* MXC that loaded successfully within the budget. Failures are
* silent the render then falls back to a Person without icon
* (Android renders initials/blank).
*
* Returns the count of avatars that landed in the cache during this
* call (purely informational useful for logs).
*/
static int loadAllWithTimeout(Context ctx, Collection<String> mxcs) {
if (mxcs == null || mxcs.isEmpty()) {
Log.i(TAG, "loadAll: empty input, skip");
return 0;
}
SharedPreferences prefs = ctx.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
// No credentials yet (fresh install + first push). We can't
// resolve MXC URLs without an access token. Falling back to
// no-icon Person renderer is the correct behaviour here.
Log.i(TAG, "loadAll: no credentials in prefs, skip"
+ " hasToken=" + (token != null && !token.isEmpty())
+ " hasHs=" + (homeserver != null && !homeserver.isEmpty()));
return 0;
}
// De-duplicate and filter to misses only; if the cache already has
// an entry, no work is needed.
Set<String> toLoad = new LinkedHashSet<>();
for (String mxc : mxcs) {
if (mxc == null || mxc.isEmpty()) continue;
if (!mxc.startsWith("mxc://")) continue;
if (AvatarBitmapCache.get(mxc) != null) continue;
toLoad.add(mxc);
}
if (toLoad.isEmpty()) return 0;
// Per-mxc latches shared across concurrent callers a second
// caller arriving while we're already mid-fetch waits on the
// SAME latch instead of forcing a duplicate HTTP or returning
// immediately empty-handed (which was the previous bug see
// git blame for the race description).
java.util.List<CountDownLatch> waits = new java.util.ArrayList<>(toLoad.size());
for (String mxc : toLoad) {
CountDownLatch myLatch = new CountDownLatch(1);
CountDownLatch existing = inFlight.putIfAbsent(mxc, myLatch);
if (existing != null) {
// Already in flight share the original latch.
waits.add(existing);
continue;
}
// We own this fetch; kick off the worker that will fire
// myLatch when done.
waits.add(myLatch);
final String capturedMxc = mxc;
final String capturedHomeserver = homeserver;
final String capturedToken = token;
EXECUTOR.execute(() -> {
try {
Bitmap bmp = fetchAndDecode(capturedMxc, capturedHomeserver, capturedToken);
if (bmp != null) AvatarBitmapCache.put(capturedMxc, bmp);
} catch (Throwable t) {
Log.w(TAG, "fetch threw mxc=" + capturedMxc, t);
} finally {
// Remove BEFORE countDown so a freshly-arriving caller
// doesn't observe a stale latch for an already-loaded
// mxc (would block until the next call with no fetch
// actually pending). Cache.get() on the post-await
// side covers the race where remove+put-cache happens
// between two latch waits.
inFlight.remove(capturedMxc);
myLatch.countDown();
}
});
}
// Single budget for the whole batch wait for all latches OR
// hit the timeout. Latches that fire early just return await()
// immediately; the slowest one consumes the remainder of the
// budget.
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(RENDER_BLOCK_TIMEOUT_MS);
try {
for (CountDownLatch latch : waits) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) break;
latch.await(remaining, TimeUnit.NANOSECONDS);
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
// Count how many actually landed in the cache during this call
// includes both items we fetched and items that finished after our
// timeout (which won't be reflected in this count but are still
// usable on the next render).
int hits = 0;
for (String mxc : toLoad) {
if (AvatarBitmapCache.get(mxc) != null) hits += 1;
}
Log.i(TAG, "loadAll: requested=" + mxcs.size()
+ " toLoad=" + toLoad.size() + " hits=" + hits);
return hits;
}
/**
* Resolve an `mxc://server/mediaId` URL to a 96×96 thumbnail via the
* authenticated v1.11+ media endpoint and decode the response into a
* Bitmap. Returns null on any non-2xx, decode failure, or oversized
* payload (see {@link #MAX_DECODED_BYTES}).
*/
private static Bitmap fetchAndDecode(String mxc, String homeserver, String token)
throws IOException {
Parsed parsed = parseMxc(mxc);
if (parsed == null) {
Log.w(TAG, "fetch: malformed mxc=" + mxc);
return null;
}
// Server + mediaId are NOT URL-encoded matches matrix-js-sdk's
// content-repo.ts (it concatenates verbatim via `new URL()`).
// URLEncoder would turn `example.com:8448` into `example.com%3A8448`,
// which Synapse's media router rejects as an unknown server.
// mediaId is base64-ish per spec (URL-safe alphabet) so no
// encoding is needed there either.
StringBuilder url = new StringBuilder(homeserver);
if (!homeserver.endsWith("/")) url.append('/');
url.append("_matrix/client/v1/media/thumbnail/")
.append(parsed.server)
.append('/')
.append(parsed.mediaId)
.append("?width=").append(AVATAR_SIZE_PX)
.append("&height=").append(AVATAR_SIZE_PX)
.append("&method=crop");
HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection();
try {
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Bearer " + token);
conn.setRequestProperty("Accept", "image/*");
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
int code = conn.getResponseCode();
Log.i(TAG, "fetch: mxc=" + mxc + " status=" + code);
if (code < 200 || code >= 300) return null;
int contentLength = conn.getContentLength();
if (contentLength > MAX_DECODED_BYTES) {
Log.w(TAG, "fetch: oversized contentLength=" + contentLength + " mxc=" + mxc);
return null;
}
try (InputStream in = conn.getInputStream()) {
BitmapFactory.Options opts = new BitmapFactory.Options();
// Stick with ARGB_8888 even on low-mem devices RGB_565
// would lose alpha (group avatars often have a
// transparent corner) and the cache cap (4 MB) already
// bounds total memory. inJustDecodeBounds + sample-size
// dance is overkill at 96×96.
opts.inPreferredConfig = Bitmap.Config.ARGB_8888;
Bitmap bmp = BitmapFactory.decodeStream(in, null, opts);
if (bmp == null) {
Log.w(TAG, "fetch: decodeStream returned null mxc=" + mxc);
return null;
}
if (bmp.getByteCount() > MAX_DECODED_BYTES) {
Log.w(TAG, "fetch: decoded oversized "
+ bmp.getByteCount() + " bytes mxc=" + mxc);
bmp.recycle();
return null;
}
// Crop into a circle BEFORE caching IconCompat.createWithBitmap
// renders the bitmap verbatim, with no shape mask, so a
// square thumbnail from the homeserver lands as a square
// tile in the shade (visible on Android 12+ where
// conversation Person icons used to be auto-rounded by the
// OS this changed). Pre-cropping guarantees a round
// visual on every API level instead of relying on the
// SystemUI of the day. The original square bitmap is
// recycled once the circular copy is in hand.
return toCircularBitmap(bmp);
}
} finally {
conn.disconnect();
}
}
/**
* Re-encode a circular avatar as an adaptive-icon-shaped bitmap:
* embeds the avatar inside a transparent canvas whose total size is
* 1.5× the avatar so Android's adaptive-icon safe zone (66% of total)
* covers the entire avatar without clipping.
*
* Required for conversation-shortcut icons per docs at
* developer.android.com/develop/ui/views/notifications/conversations:
* *"To avoid unintentional clipping of your shortcut avatar, provide
* an AdaptiveIconDrawable for the shortcut's icon."*
*
* Without this padding, IconCompat.createWithAdaptiveBitmap would
* crop ~17% off every edge of the avatar to fit the safe zone a
* visible mutilation. With it, the shortcut icon renders pixel-
* identical to the circular avatar inside the system shade's
* conversation slot.
*/
static Bitmap toAdaptivePaddedBitmap(Bitmap circularAvatar) {
int avatarSize = Math.min(circularAvatar.getWidth(), circularAvatar.getHeight());
// Pad to 150% so the adaptive safe-zone (66% of canvas = avatarSize)
// covers the full avatar. Rounded up to keep the canvas even.
int canvasSize = (int) Math.ceil(avatarSize / 0.66f);
if (canvasSize % 2 != 0) canvasSize += 1;
Bitmap output = Bitmap.createBitmap(canvasSize, canvasSize, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
int offset = (canvasSize - avatarSize) / 2;
canvas.drawBitmap(circularAvatar, offset, offset, null);
return output;
}
/**
* Return a circular ARGB_8888 bitmap of the source centre-cropped to
* a square if non-square, then masked with a circular path so the
* corners are transparent. The source bitmap is recycled.
*
* Anti-aliased edges via Paint.setAntiAlias on the circle draw the
* BitmapShader copies the source's pixels into the circular region in
* a single drawCircle call, which keeps allocation to one output
* bitmap (vs the naive "decode → square crop → mask compose" path
* that touches three intermediate bitmaps).
*/
private static Bitmap toCircularBitmap(Bitmap source) {
int size = Math.min(source.getWidth(), source.getHeight());
Bitmap squareSource;
if (source.getWidth() == size && source.getHeight() == size) {
squareSource = source;
} else {
int x = (source.getWidth() - size) / 2;
int y = (source.getHeight() - size) / 2;
squareSource = Bitmap.createBitmap(source, x, y, size, size);
source.recycle();
}
Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setShader(new BitmapShader(
squareSource, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
float radius = size / 2f;
canvas.drawCircle(radius, radius, radius, paint);
if (squareSource != source) {
squareSource.recycle();
}
return output;
}
private static final class Parsed {
final String server;
final String mediaId;
Parsed(String server, String mediaId) {
this.server = server;
this.mediaId = mediaId;
}
}
/**
* Split an `mxc://server/mediaId` URL into its two components. Returns
* null on any malformed input caller drops the avatar silently.
*/
private static Parsed parseMxc(String mxc) {
if (mxc == null) return null;
final String prefix = "mxc://";
if (!mxc.startsWith(prefix)) return null;
int slash = mxc.indexOf('/', prefix.length());
if (slash < 0 || slash == prefix.length()) return null;
String server = mxc.substring(prefix.length(), slash);
String mediaId = mxc.substring(slash + 1);
if (server.isEmpty() || mediaId.isEmpty()) return null;
return new Parsed(server, mediaId);
}
}

View file

@ -0,0 +1,163 @@
package chat.vojo.app;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.os.Build;
import android.util.Log;
import androidx.core.content.LocusIdCompat;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import java.util.Collections;
import java.util.Set;
/**
* Publish a long-lived sharing shortcut for a Matrix room so the system
* treats per-room MessagingStyle notifications as conversations on
* Android 11+ (API 30+).
*
* Without a published shortcut whose id matches the notification's
* setShortcutId(), Android falls back to the app icon for the collapsed-
* preview avatar regardless of Person.setIcon / Builder.setLargeIcon
* Person icons are only consulted by the Conversation styling layer,
* which activates exclusively for notifications backed by a real
* ShortcutInfoCompat marked Long Lived + the SHORTCUT_CATEGORY_CONVERSATION
* sharing category.
*
* Idempotent: republishing the same shortcut id is the documented "update"
* path; ShortcutManagerCompat handles dedup internally. Cheap to call
* from the render hot path (~ms on warm system, indistinguishable from a
* SharedPreferences write at our scale).
*/
final class ConversationShortcuts {
private static final String TAG = "ConvShortcuts";
private ConversationShortcuts() {}
/**
* Publish or refresh the shortcut backing a room's conversation
* notification. No-op on API < 30 Conversation styling is an
* Android 11+ feature; older OS versions render the notification
* fine without the shortcut, and the largeIcon/Person.setIcon
* pipeline is the primary avatar source on them.
*
* @param ctx Context for the shortcut manager binding.
* @param roomId Matrix room id, used as the shortcut id so it
* matches NotificationCompat.Builder.setShortcutId.
* @param isDirect Whether the room is a DM; flips the shortcut
* category so launchers can group DMs separately.
* @param label Short visible label, typically the room name (or
* the peer's display name for a DM).
* @param avatar Optional cached avatar bitmap. Null falls through
* to the app launcher icon still publishes the
* shortcut so the conversation styling activates.
*/
/**
* Returns the published ShortcutInfoCompat so the caller can attach
* it directly to the notification via setShortcutInfo() this is
* the documented "atomic publish + bind" path that avoids the race
* where the notification posts before the shortcut publish has
* settled and Android sees an orphan shortcut id. Null on API < 30,
* null on failure (notification still posts cleanly).
*/
static ShortcutInfoCompat publishForRoom(
Context ctx,
String roomId,
boolean isDirect,
String label,
Bitmap avatar
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return null;
}
if (roomId == null || roomId.isEmpty()) return null;
try {
// Conversation shortcut icon MUST be adaptive official docs:
// "To avoid unintentional clipping of your shortcut avatar,
// provide an AdaptiveIconDrawable for the shortcut's icon."
// Without this, Android silently falls back to the app's
// launcher icon for the collapsed-shade conversation avatar
// slot, even though shortcut publish + bind succeed.
// Resource icons (mipmap.ic_launcher) already ship with
// adaptive layers in the manifest; bitmap avatars need padding
// so the safe zone doesn't crop them.
IconCompat icon;
if (avatar != null) {
Bitmap padded = AvatarLoader.toAdaptivePaddedBitmap(avatar);
icon = IconCompat.createWithAdaptiveBitmap(padded);
} else {
icon = IconCompat.createWithResource(ctx, R.mipmap.ic_launcher);
}
// Intent the shortcut launches when tapped from the launcher
// long-press menu or share sheet opens MainActivity and
// delivers the same `room_id` extra the notification tap
// path uses, so the existing pushNotificationActionPerformed
// listener navigates correctly.
Intent launchIntent = new Intent(ctx, MainActivity.class)
.setAction(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra("room_id", roomId)
// Capacitor PushNotificationsPlugin gates its action
// delivery on bundle.containsKey("google.message_id"); we
// attach an empty value so a launcher-initiated open
// takes the same path as a push-tap.
.putExtra("google.message_id", "");
// Constant value of androidx.core's
// ShortcutInfoCompat.SHORTCUT_CATEGORY_CONVERSATION. Hardcoded
// verbatim because older androidx.core in our dependency
// graph doesn't export the constant; the string itself is
// platform-stable per the Android shortcut category contract.
Set<String> categories =
Collections.singleton("android.shortcut.conversation");
ShortcutInfoCompat.Builder b = new ShortcutInfoCompat.Builder(ctx, roomId)
.setShortLabel(label != null && !label.isEmpty() ? label : "Vojo")
.setLongLabel(label != null && !label.isEmpty() ? label : "Vojo")
.setIntent(launchIntent)
.setIcon(icon)
.setLongLived(true)
.setCategories(categories)
// LocusId mirrors the shortcut id; the OS uses it to
// attribute the notification to a specific conversation
// for digital-wellbeing dashboards and bubble grouping.
.setLocusId(new LocusIdCompat(roomId))
// Marks isDirect so launchers / share sheet can present
// person-style affordances on DMs.
.setIsConversation();
// setPerson is only needed for one-on-one conversations to
// unlock direct-share suggestions, but for a DM we also want
// it to anchor the shortcut on the peer's identity. Skipped
// for groups (single Person doesn't represent the room).
if (isDirect) {
b.setPerson(new androidx.core.app.Person.Builder()
// setKey must match the Person.key used in the
// MessagingStyle so Android's conversation
// attribution matches the shortcut to the
// notification on the same identity.
.setKey(roomId)
.setName(label != null ? label : "")
.setIcon(icon)
.build());
}
ShortcutInfoCompat shortcut = b.build();
boolean ok = ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut);
Log.i(TAG, "publish room=" + roomId + " label=" + label
+ " hasAvatar=" + (avatar != null) + " ok=" + ok);
return shortcut;
} catch (Throwable t) {
// Shortcut publish is best-effort UX a failure must not
// sink the notification. Worst case: collapsed preview
// falls back to app icon (same as before the shortcut path
// existed at all).
Log.w(TAG, "publish failed room=" + roomId, t);
return null;
}
}
}

View file

@ -24,19 +24,28 @@ import java.util.concurrent.Executors;
* {@link RoomMessageCache} is cleared so the next push to that room starts a * {@link RoomMessageCache} is cleared so the next push to that room starts a
* fresh conversation rather than re-appending to the prior history. * fresh conversation rather than re-appending to the prior history.
* *
* Failure mode: on any non-2xx or thrown exception we silently leave the * Dismiss policy: OPTIMISTIC. The per-room notification is dismissed
* notification on the shade. We do not implement a flusher (unlike * synchronously in onReceive before the HTTP receipt PUT is even
* CallDeclineReceiver) because: * attempted so the user sees instant feedback. The async receipt POST
* - the user can just dismiss with a swipe or open the room * happens on a worker thread afterwards. This mirrors element-android's
* - a stale read receipt isn't user-visible: when the user opens the room, * NotificationBroadcastReceiver pattern and matches the user's mental
* the in-app read-marker logic re-sends with a fresher eventId * model ("I tapped, it should disappear immediately").
* - 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 * Failure mode: on any non-2xx or thrown exception we accept that the
* bridge): no token to use, we just dismiss the notification locally so the * server-side read receipt did not land. We do NOT re-post the
* user isn't stuck looking at a "stuck" Mark-as-read button. The next normal * notification or implement a flusher because:
* read-marker write from the JS side covers the server view. * - the next room open from the JS app issues a fresh read-receipt
* for the latest visible event, catching up the server state
* - the in-app read-marker logic is the authoritative path; this
* receiver is a convenience for the shade-tap shortcut
* - accumulating tombstones in prefs (the CallDeclineReceiver pattern)
* would risk 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 still dismiss the notification
* locally so the user isn't stuck looking at a "stuck" Mark-as-read
* button. The next room open from JS covers the server view.
*/ */
public class MarkAsReadReceiver extends BroadcastReceiver { public class MarkAsReadReceiver extends BroadcastReceiver {

View file

@ -105,10 +105,37 @@ public class PollingPlugin extends Plugin {
.remove(VojoPollWorker.KEY_DRAIN_TARGET_TS) .remove(VojoPollWorker.KEY_DRAIN_TARGET_TS)
.remove(VojoPollWorker.KEY_NOTIFIED_IDS) .remove(VojoPollWorker.KEY_NOTIFIED_IDS)
.remove(VojoPollWorker.KEY_ROOM_NAMES) .remove(VojoPollWorker.KEY_ROOM_NAMES)
.remove(VojoPollWorker.KEY_USER_AVATARS)
.apply(); .apply();
call.resolve(); call.resolve();
} }
/**
* user_id MXC avatar URL snapshot. Mirrors {@link #saveRoomNames}
* stored as a JSON blob in vojo_poll_state for the FCM service /
* polling Worker / ReplyReceiver to consult via
* VojoFirebaseMessagingService.lookupUserAvatarMxc. JS dumps on the
* same lifecycle triggers as room names (mount, visibility resume,
* m.direct change, m.room.encryption flip).
*/
@PluginMethod
public void saveUserAvatars(PluginCall call) {
JSObject avatars = call.getObject("avatars");
if (avatars == null) {
call.reject("missing_avatars");
return;
}
String serialized = avatars.toString();
getContext()
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
.edit()
.putString(VojoPollWorker.KEY_USER_AVATARS, serialized)
.apply();
Log.i(TAG, "saveUserAvatars: " + avatars.length() + " entries, "
+ serialized.length() + " bytes");
call.resolve();
}
@PluginMethod @PluginMethod
public void saveRoomNames(PluginCall call) { public void saveRoomNames(PluginCall call) {
JSObject names = call.getObject("names"); JSObject names = call.getObject("names");
@ -122,11 +149,14 @@ public class PollingPlugin extends Plugin {
// valid JSON serialisation of validated values no need to re-parse // valid JSON serialisation of validated values no need to re-parse
// it through `new JSONObject(...)` just to re-serialise. Persist // it through `new JSONObject(...)` just to re-serialise. Persist
// verbatim. // verbatim.
String serialized = names.toString();
getContext() getContext()
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE) .getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
.edit() .edit()
.putString(VojoPollWorker.KEY_ROOM_NAMES, names.toString()) .putString(VojoPollWorker.KEY_ROOM_NAMES, serialized)
.apply(); .apply();
Log.i(TAG, "saveRoomNames: " + names.length() + " entries, "
+ serialized.length() + " bytes");
call.resolve(); call.resolve();
} }

View file

@ -66,13 +66,28 @@ final class RoomMessageCache {
* record matches element-android's RoomGroupMessageCreator pattern. * record matches element-android's RoomGroupMessageCreator pattern.
*/ */
static final class Entry { static final class Entry {
// Matrix event_id when known (incoming pushes always carry one;
// outgoing optimistic-echo entries pass null). Used by append() to
// suppress duplicate appends when FCM retries / cross-source
// delivery hands the same event in twice without this the
// MessagingStyle conversation would render the same message N
// times in the shade.
final String eventId;
final String body; final String body;
final long timestamp; final long timestamp;
final String senderKey; final String senderKey;
final String senderName; final String senderName;
final boolean fromSelf; final boolean fromSelf;
Entry(String body, long timestamp, String senderKey, String senderName, boolean fromSelf) { Entry(
String eventId,
String body,
long timestamp,
String senderKey,
String senderName,
boolean fromSelf
) {
this.eventId = eventId;
this.body = body; this.body = body;
this.timestamp = timestamp; this.timestamp = timestamp;
this.senderKey = senderKey; this.senderKey = senderKey;
@ -96,9 +111,26 @@ final class RoomMessageCache {
final List<Entry> snapshot = new ArrayList<>(); final List<Entry> snapshot = new ArrayList<>();
store.compute(roomId, (key, existing) -> { store.compute(roomId, (key, existing) -> {
Deque<Entry> d = (existing != null) ? existing : new ArrayDeque<>(); Deque<Entry> d = (existing != null) ? existing : new ArrayDeque<>();
d.addLast(entry); // Dedup by eventId protects against FCM retry / cross-source
while (d.size() > MAX_MESSAGES_PER_ROOM) { // (FCM + polling Worker) double-delivery that would otherwise
d.pollFirst(); // append the same event twice. Only applies when both the new
// entry and a prior one carry a non-empty eventId; outgoing
// self-echo entries have null eventId by design and never
// collide.
boolean isDup = false;
if (entry.eventId != null && !entry.eventId.isEmpty()) {
for (Entry prior : d) {
if (entry.eventId.equals(prior.eventId)) {
isDup = true;
break;
}
}
}
if (!isDup) {
d.addLast(entry);
while (d.size() > MAX_MESSAGES_PER_ROOM) {
d.pollFirst();
}
} }
snapshot.addAll(d); snapshot.addAll(d);
return d; return d;

View file

@ -9,6 +9,7 @@ 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.content.SharedPreferences;
import android.graphics.Bitmap;
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,6 +21,9 @@ import android.util.Log;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.app.Person; import androidx.core.app.Person;
import androidx.core.app.RemoteInput; import androidx.core.app.RemoteInput;
import androidx.core.content.LocusIdCompat;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.graphics.drawable.IconCompat;
import com.capacitorjs.plugins.pushnotifications.MessagingService; import com.capacitorjs.plugins.pushnotifications.MessagingService;
import com.google.firebase.messaging.RemoteMessage; import com.google.firebase.messaging.RemoteMessage;
@ -318,6 +322,19 @@ public class VojoFirebaseMessagingService extends MessagingService {
} }
String eventId = data.get("event_id"); String eventId = data.get("event_id");
if (!MainActivity.isInForeground) { if (!MainActivity.isInForeground) {
// Cross-source dedup gate mirrors VojoPollWorker.doWork:
// if NotificationDedup already saw this eventId (FCM
// retry, or the polling Worker rendered it first), skip
// re-rendering. Without this gate an FCM retry would
// re-append the same message into the per-room
// MessagingStyle conversation (RoomMessageCache.append
// now also dedupes by eventId as belt-and-suspenders, but
// skipping render entirely is cheaper).
if (eventId != null && !eventId.isEmpty()
&& NotificationDedup.wasNotified(this, eventId)) {
dlog("route: message-branch dedup skip event=" + eventId);
return;
}
dlog("route: message-branch (background)"); dlog("route: message-branch (background)");
boolean posted = renderMessageNotification( boolean posted = renderMessageNotification(
this, data, remoteMessage.getMessageId()); this, data, remoteMessage.getMessageId());
@ -429,10 +446,21 @@ public class VojoFirebaseMessagingService extends MessagingService {
seedCacheFromActiveNotification(ctx, nm, roomId); seedCacheFromActiveNotification(ctx, nm, roomId);
RoomMessageCache.Entry entry = new RoomMessageCache.Entry( RoomMessageCache.Entry entry = new RoomMessageCache.Entry(
body, timestamp, senderKey, senderName, /* fromSelf */ false eventId, body, timestamp, senderKey, senderName, /* fromSelf */ false
); );
List<RoomMessageCache.Entry> history = RoomMessageCache.append(roomId, entry); List<RoomMessageCache.Entry> history = RoomMessageCache.append(roomId, entry);
// Pre-warm AvatarBitmapCache before building Person objects so the
// first nm.notify already includes icons rather than re-rendering
// after async loads. Blocks up to 800ms on cold cache; cache hits
// are no-ops. Failures land as no-icon Persons (Android fallback).
SharedPreferences avatarPrefs = ctx.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
java.util.List<String> avatarMxcs = collectAvatarMxcs(avatarPrefs, history, meta);
Log.i(TAG, "msg: collected " + avatarMxcs.size() + " avatar mxcs"
+ " (history=" + history.size() + " roomMxc=" + (meta.avatarMxc != null) + ")");
AvatarLoader.loadAllWithTimeout(ctx, avatarMxcs);
// Self Person anchors the MessagingStyle constructor. Real user_id + // Self Person anchors the MessagingStyle constructor. Real user_id +
// localised "You" label come from prefs that JS bridged via // localised "You" label come from prefs that JS bridged via
// PollingPlugin.saveSession. On a fresh install with a push arriving // PollingPlugin.saveSession. On a fresh install with a push arriving
@ -449,11 +477,29 @@ public class VojoFirebaseMessagingService extends MessagingService {
} }
style.setGroupConversation(isGroup); style.setGroupConversation(isGroup);
// Track the most-recent non-self sender so we can attach it via
// builder.addPerson() below Android attributes the conversation
// preview using addPerson() Persons, not the MessagingStyle ones
// (the two have to agree, but the avatar source for the
// collapsed-shade preview is the addPerson set).
Person lastNonSelfSender = null;
for (RoomMessageCache.Entry e : history) { for (RoomMessageCache.Entry e : history) {
Person sender = e.fromSelf ? null : new Person.Builder() Person sender;
.setName(e.senderName != null ? e.senderName : "") if (e.fromSelf) {
.setKey(e.senderKey != null ? e.senderKey : "") sender = null;
.build(); } else {
Person.Builder sb = new Person.Builder()
.setName(e.senderName != null ? e.senderName : "")
.setKey(e.senderKey != null ? e.senderKey : "");
String senderMxc = lookupUserAvatarMxc(avatarPrefs, e.senderKey);
IconCompat senderIcon = iconFromCachedMxc(senderMxc);
if (senderIcon != null) sb.setIcon(senderIcon);
sender = sb.build();
lastNonSelfSender = sender;
Log.i(TAG, "msg: sender Person key=" + e.senderKey
+ " mxc=" + senderMxc
+ " iconAttached=" + (senderIcon != null));
}
style.addMessage(new NotificationCompat.MessagingStyle.Message( style.addMessage(new NotificationCompat.MessagingStyle.Message(
e.body != null ? e.body : "", e.timestamp, sender e.body != null ? e.body : "", e.timestamp, sender
)); ));
@ -465,18 +511,54 @@ public class VojoFirebaseMessagingService extends MessagingService {
// PendingIntent must be distinct per re-render so FLAG_UPDATE_CURRENT // PendingIntent must be distinct per re-render so FLAG_UPDATE_CURRENT
// doesn't smash the prior intent's extras but the request code is // doesn't smash the prior intent's extras but the request code is
// stable per room so we don't leak intents. // stable per room so we don't leak intents.
//
// Mutability: conversation notifications on Android 11+ that carry
// both a setShortcutInfo and an addPerson trigger the system's
// "may bubble" path, and the NMS disqualifying-features check
// (NotificationManagerService.checkDisqualifyingFeatures) rejects
// any such notification whose contentIntent is FLAG_IMMUTABLE
// with "PendingIntents attached to bubbles must be mutable" even
// if we never explicitly set a BubbleMetadata. The notification
// gets DROPPED entirely. So this PI must be MUTABLE on API 31+.
// The intent only carries roomId/eventId/google.message_id (no
// secrets), so granting the system the ability to fill in extras
// is acceptable.
Intent launchIntent = new Intent(ctx, MainActivity.class); Intent launchIntent = new Intent(ctx, MainActivity.class);
launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
launchIntent.putExtra("google.message_id", messageId != null ? messageId : ""); launchIntent.putExtra("google.message_id", messageId != null ? messageId : "");
for (Map.Entry<String, String> en : data.entrySet()) { for (Map.Entry<String, String> en : data.entrySet()) {
launchIntent.putExtra(en.getKey(), en.getValue()); launchIntent.putExtra(en.getKey(), en.getValue());
} }
int flags = PendingIntent.FLAG_UPDATE_CURRENT int flags = PendingIntent.FLAG_UPDATE_CURRENT;
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags |= PendingIntent.FLAG_MUTABLE;
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags |= PendingIntent.FLAG_IMMUTABLE;
}
PendingIntent contentIntent = PendingIntent.getActivity( PendingIntent contentIntent = PendingIntent.getActivity(
ctx, ("open_" + roomId).hashCode(), launchIntent, flags ctx, ("open_" + roomId).hashCode(), launchIntent, flags
); );
// Resolve room avatar (used both for largeIcon AND as the shortcut
// icon keeping them in sync gives the conversation avatar a
// single source of truth).
android.graphics.Bitmap roomBitmap = meta.avatarMxc != null
? AvatarBitmapCache.get(meta.avatarMxc) : null;
// Publish the conversation shortcut BEFORE building the
// notification so we can attach the returned ShortcutInfoCompat
// via setShortcutInfo() that's the documented atomic-bind path
// and is what flips Android's shade into conversation styling
// (shows Person/large icon in collapsed preview instead of app
// icon).
ShortcutInfoCompat shortcut = ConversationShortcuts.publishForRoom(
ctx,
roomId,
meta.isDirect,
firstNonEmpty(data.get("room_name"), meta.name, "Vojo"),
roomBitmap
);
NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, channelId) NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, channelId)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setStyle(style) .setStyle(style)
@ -495,14 +577,57 @@ public class VojoFirebaseMessagingService extends MessagingService {
// notification in a room still alerts; subsequent updates are // notification in a room still alerts; subsequent updates are
// visual only. // visual only.
.setOnlyAlertOnce(history.size() > 1) .setOnlyAlertOnce(history.size() > 1)
.setShortcutId(roomId) // LocusId is the second half of conversation attribution
// setShortcutId alone tells the system "this notification
// belongs to this shortcut", but LocusId is what flips on
// the conversation-grouping / bubbles / wellbeing integration.
// Element-android attaches both.
.setLocusId(new LocusIdCompat(roomId))
.setWhen(timestamp) .setWhen(timestamp)
.setShowWhen(true) .setShowWhen(true)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) // GROUP_ALERT_ALL (default not explicitly set) lets the
// per-room notification raise its own heads-up/sound the
// first time it appears, while setOnlyAlertOnce above
// suppresses re-alert on subsequent updates within the same
// room. The companion group summary is built with
// setOnlyAlertOnce(true) so it never doubles up the alert.
// Previously this was GROUP_ALERT_SUMMARY which silenced the
// child entirely, so DM messages with rich sender info never
// produced a heads-up only a generic "Vojo / New messages"
// summary banner did.
.addAction(buildMarkAsReadAction(ctx, roomId, eventId)); .addAction(buildMarkAsReadAction(ctx, roomId, eventId));
// Bind notification to shortcut: prefer setShortcutInfo (full
// ShortcutInfoCompat) when we successfully published, else fall
// back to setShortcutId (id-only still useful on older OS that
// ignore Conversation styling anyway).
if (shortcut != null) {
builder.setShortcutInfo(shortcut);
} else {
builder.setShortcutId(roomId);
}
// Attribution Person on the builder itself Android's
// conversation-styling layer uses addPerson() to pick the avatar
// for the collapsed shade preview, NOT the Person objects inside
// MessagingStyle messages (those drive the per-message bubble
// avatars in the expanded view). Without addPerson the preview
// falls back to the small icon (= app icon) even when MessagingStyle
// has rich Person.setIcon entries on every message.
if (lastNonSelfSender != null) {
builder.addPerson(lastNonSelfSender);
}
if (roomBitmap != null) {
builder.setLargeIcon(roomBitmap);
}
Log.i(TAG, "msg: largeIcon attached=" + (roomBitmap != null)
+ " shortcutBound=" + (shortcut != null)
+ " roomMxc=" + meta.avatarMxc);
// Inline reply is only safe in cleartext rooms the receiver // Inline reply is only safe in cleartext rooms the receiver
// builds a vanilla `m.room.message`, and we have no key material // builds a vanilla `m.room.message`, and we have no key material
// on the Java side to encrypt. RoomMetadata.isEncrypted defaults // on the Java side to encrypt. RoomMetadata.isEncrypted defaults
@ -535,6 +660,14 @@ public class VojoFirebaseMessagingService extends MessagingService {
.setContentText(PushStrings.messagesFallback(ctx)) .setContentText(PushStrings.messagesFallback(ctx))
.setGroup(GROUP_KEY) .setGroup(GROUP_KEY)
.setGroupSummary(true) .setGroupSummary(true)
// The per-room child already alerts on first post (default
// GROUP_ALERT_ALL); this summary must stay silent on every
// post or every additional room would re-alert through the
// generic "New messages" banner. setOnlyAlertOnce is per
// notification id (here SUMMARY_NOTIFICATION_ID is
// constant), so the first post still alerts once if the
// child is suppressed which never happens in our setup.
.setOnlyAlertOnce(true)
.setAutoCancel(true); .setAutoCancel(true);
nm.notify(SUMMARY_NOTIFICATION_ID, summary.build()); nm.notify(SUMMARY_NOTIFICATION_ID, summary.build());
} catch (SecurityException e) { } catch (SecurityException e) {
@ -608,6 +741,14 @@ public class VojoFirebaseMessagingService extends MessagingService {
.setContentText(PushStrings.messagesFallback(ctx)) .setContentText(PushStrings.messagesFallback(ctx))
.setGroup(GROUP_KEY) .setGroup(GROUP_KEY)
.setGroupSummary(true) .setGroupSummary(true)
// The per-room child already alerts on first post (default
// GROUP_ALERT_ALL); this summary must stay silent on every
// post or every additional room would re-alert through the
// generic "New messages" banner. setOnlyAlertOnce is per
// notification id (here SUMMARY_NOTIFICATION_ID is
// constant), so the first post still alerts once if the
// child is suppressed which never happens in our setup.
.setOnlyAlertOnce(true)
.setAutoCancel(true); .setAutoCancel(true);
nm.notify(SUMMARY_NOTIFICATION_ID, summary.build()); nm.notify(SUMMARY_NOTIFICATION_ID, summary.build());
} catch (SecurityException e) { } catch (SecurityException e) {
@ -743,10 +884,21 @@ public class VojoFirebaseMessagingService extends MessagingService {
seedCacheFromActiveNotification(ctx, nm, roomId); seedCacheFromActiveNotification(ctx, nm, roomId);
RoomMessageCache.Entry self = new RoomMessageCache.Entry( RoomMessageCache.Entry self = new RoomMessageCache.Entry(
body, timestamp, /*senderKey*/ null, /*senderName*/ "", /*fromSelf*/ true /*eventId*/ null, body, timestamp,
/*senderKey*/ null, /*senderName*/ "", /*fromSelf*/ true
); );
java.util.List<RoomMessageCache.Entry> history = RoomMessageCache.append(roomId, self); java.util.List<RoomMessageCache.Entry> history = RoomMessageCache.append(roomId, self);
// Pre-warm avatars for the same reason as renderMessageNotification:
// the optimistic-echo re-render should already include sender icons
// rather than flashing without and re-posting. The cache is shared
// across both paths so a freshly-received message warming the
// cache primes a later reply re-render.
SharedPreferences avatarPrefs = ctx.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
java.util.List<String> avatarMxcs = collectAvatarMxcs(avatarPrefs, history, meta);
AvatarLoader.loadAllWithTimeout(ctx, avatarMxcs);
Person selfPerson = buildSelfPerson(ctx); Person selfPerson = buildSelfPerson(ctx);
NotificationCompat.MessagingStyle style = new NotificationCompat.MessagingStyle(selfPerson); NotificationCompat.MessagingStyle style = new NotificationCompat.MessagingStyle(selfPerson);
boolean isGroup = !meta.isDirect; boolean isGroup = !meta.isDirect;
@ -754,11 +906,24 @@ public class VojoFirebaseMessagingService extends MessagingService {
style.setConversationTitle(meta.name); style.setConversationTitle(meta.name);
} }
style.setGroupConversation(isGroup); style.setGroupConversation(isGroup);
Person lastNonSelfSender = null;
for (RoomMessageCache.Entry e : history) { for (RoomMessageCache.Entry e : history) {
Person sender = e.fromSelf ? null : new Person.Builder() Person sender;
.setName(e.senderName != null ? e.senderName : "") if (e.fromSelf) {
.setKey(e.senderKey != null ? e.senderKey : "") sender = null;
.build(); } else {
Person.Builder sb = new Person.Builder()
.setName(e.senderName != null ? e.senderName : "")
.setKey(e.senderKey != null ? e.senderKey : "");
String senderMxc = lookupUserAvatarMxc(avatarPrefs, e.senderKey);
IconCompat senderIcon = iconFromCachedMxc(senderMxc);
if (senderIcon != null) sb.setIcon(senderIcon);
sender = sb.build();
lastNonSelfSender = sender;
Log.i(TAG, "msg: sender Person key=" + e.senderKey
+ " mxc=" + senderMxc
+ " iconAttached=" + (senderIcon != null));
}
style.addMessage(new NotificationCompat.MessagingStyle.Message( style.addMessage(new NotificationCompat.MessagingStyle.Message(
e.body != null ? e.body : "", e.timestamp, sender e.body != null ? e.body : "", e.timestamp, sender
)); ));
@ -768,12 +933,30 @@ public class VojoFirebaseMessagingService extends MessagingService {
launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
launchIntent.putExtra("google.message_id", ""); launchIntent.putExtra("google.message_id", "");
launchIntent.putExtra("room_id", roomId); launchIntent.putExtra("room_id", roomId);
int flags = PendingIntent.FLAG_UPDATE_CURRENT // MUTABLE for conversation-notification bubble eligibility on
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); // API 31+ same rationale as in renderMessageNotification.
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags |= PendingIntent.FLAG_MUTABLE;
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags |= PendingIntent.FLAG_IMMUTABLE;
}
PendingIntent contentIntent = PendingIntent.getActivity( PendingIntent contentIntent = PendingIntent.getActivity(
ctx, ("open_" + roomId).hashCode(), launchIntent, flags ctx, ("open_" + roomId).hashCode(), launchIntent, flags
); );
android.graphics.Bitmap roomBitmap = meta.avatarMxc != null
? AvatarBitmapCache.get(meta.avatarMxc) : null;
// Mirror renderMessageNotification: publish shortcut FIRST,
// attach via setShortcutInfo, set LocusId. Same conversation
// styling on the reply-echo re-render as on the receive render.
ShortcutInfoCompat shortcut = ConversationShortcuts.publishForRoom(
ctx, roomId, meta.isDirect,
meta.name.isEmpty() ? "Vojo" : meta.name,
roomBitmap
);
NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, channelId) NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, channelId)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setStyle(style) .setStyle(style)
@ -783,17 +966,33 @@ public class VojoFirebaseMessagingService extends MessagingService {
.setGroup(GROUP_KEY) .setGroup(GROUP_KEY)
// Always silent sending a reply must not re-alert the user. // Always silent sending a reply must not re-alert the user.
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setShortcutId(roomId) .setLocusId(new LocusIdCompat(roomId))
.setWhen(timestamp) .setWhen(timestamp)
.setShowWhen(true) .setShowWhen(true)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) // setOnlyAlertOnce(true) above already keeps the outgoing
// re-render silent; default GROUP_ALERT_ALL applies. Previously
// GROUP_ALERT_SUMMARY was set here for symmetry with the
// receive path, but that also silenced the child outside of
// the alert-once window removed for the same reason as in
// renderMessageNotification.
// Re-attach mark-as-read so the action set stays consistent on // Re-attach mark-as-read so the action set stays consistent on
// re-render; eventId is unknown for an outgoing-only update so // re-render; eventId is unknown for an outgoing-only update so
// it falls through to the local-dismiss-only branch in the // it falls through to the local-dismiss-only branch in the
// receiver acceptable for an optimistic echo. // receiver acceptable for an optimistic echo.
.addAction(buildMarkAsReadAction(ctx, roomId, null)); .addAction(buildMarkAsReadAction(ctx, roomId, null));
if (shortcut != null) {
builder.setShortcutInfo(shortcut);
} else {
builder.setShortcutId(roomId);
}
if (roomBitmap != null) {
builder.setLargeIcon(roomBitmap);
}
if (lastNonSelfSender != null) {
builder.addPerson(lastNonSelfSender);
}
if (!meta.isEncrypted) { if (!meta.isEncrypted) {
builder.addAction(buildReplyAction(ctx, roomId)); builder.addAction(buildReplyAction(ctx, roomId));
} }
@ -815,10 +1014,90 @@ public class VojoFirebaseMessagingService extends MessagingService {
.setName(PushStrings.selfName(ctx)); .setName(PushStrings.selfName(ctx));
if (userId != null && !userId.isEmpty()) { if (userId != null && !userId.isEmpty()) {
b.setKey(userId); b.setKey(userId);
String mxc = lookupUserAvatarMxc(prefs, userId);
if (mxc != null) {
IconCompat icon = iconFromCachedMxc(mxc);
if (icon != null) b.setIcon(icon);
}
} }
return b.build(); return b.build();
} }
/**
* Lookup avatar MXC for a user_id from the JS-bridged userAvatars
* snapshot in vojo_poll_state. Returns null when the user is not in
* the snapshot (cap of 500 entries) or when prefs are empty (cold
* start before any bridge has run).
*/
private static String lookupUserAvatarMxc(SharedPreferences prefs, String userId) {
if (userId == null || userId.isEmpty()) return null;
String raw = prefs.getString(VojoPollWorker.KEY_USER_AVATARS, null);
if (raw == null || raw.isEmpty()) {
Log.i(TAG, "avatar lookup: prefs empty for userId=" + userId);
return null;
}
try {
JSONObject map = new JSONObject(raw);
if (!map.has(userId) || map.isNull(userId)) {
Log.i(TAG, "avatar lookup: miss for userId=" + userId
+ " (snapshot has " + map.length() + " entries)");
return null;
}
String mxc = map.optString(userId, null);
if (mxc == null || mxc.isEmpty()) return null;
return mxc;
} catch (Throwable t) {
Log.w(TAG, "avatar lookup: JSON parse failed", t);
return null;
}
}
/**
* Wrap a cache-hit bitmap into an IconCompat for Person.setIcon /
* NotificationCompat.setLargeIcon. Returns null on cache miss the
* caller renders the Person without an icon and Android falls back to
* its monogram / blank circle.
*/
private static IconCompat iconFromCachedMxc(String mxc) {
if (mxc == null || mxc.isEmpty()) return null;
android.graphics.Bitmap bmp = AvatarBitmapCache.get(mxc);
if (bmp == null) return null;
return IconCompat.createWithBitmap(bmp);
}
/**
* Collect every MXC URL needed to render a per-room MessagingStyle
* conversation: self avatar (if known), each historical sender's
* avatar, plus the room avatar for the largeIcon. Used to pre-warm
* AvatarBitmapCache via AvatarLoader.loadAllWithTimeout BEFORE the
* Person / largeIcon construction. Skips entries already in cache so
* the loader can short-circuit.
*/
private static java.util.List<String> collectAvatarMxcs(
SharedPreferences prefs,
java.util.List<RoomMessageCache.Entry> history,
RoomMetadata meta
) {
java.util.LinkedHashSet<String> out = new java.util.LinkedHashSet<>();
String selfUserId = prefs.getString(VojoPollWorker.KEY_USER_ID, null);
if (selfUserId != null && !selfUserId.isEmpty()) {
String selfMxc = lookupUserAvatarMxc(prefs, selfUserId);
if (selfMxc != null) out.add(selfMxc);
}
if (history != null) {
for (RoomMessageCache.Entry e : history) {
if (e.fromSelf) continue;
if (e.senderKey == null || e.senderKey.isEmpty()) continue;
String mxc = lookupUserAvatarMxc(prefs, e.senderKey);
if (mxc != null) out.add(mxc);
}
}
if (meta != null && meta.avatarMxc != null && !meta.avatarMxc.isEmpty()) {
out.add(meta.avatarMxc);
}
return new java.util.ArrayList<>(out);
}
/** /**
* Try to populate RoomMessageCache from an already-posted MessagingStyle * Try to populate RoomMessageCache from an already-posted MessagingStyle
* notification so a re-render after process kill preserves conversation * notification so a re-render after process kill preserves conversation
@ -848,7 +1127,18 @@ public class VojoFirebaseMessagingService extends MessagingService {
? p.getName().toString() : ""; ? p.getName().toString() : "";
String key = p != null ? p.getKey() : null; String key = p != null ? p.getKey() : null;
String body = m.getText() != null ? m.getText().toString() : ""; String body = m.getText() != null ? m.getText().toString() : "";
// eventId is not preserved through MessagingStyle's
// extras-based round-trip recovered entries seed
// with null. The append-dedup is then no-op for these,
// which is acceptable: process-kill recovery is rare,
// and the very next push that arrives will compare
// against the seeded entry by sender+timestamp+body
// mostly fortuitously (no risk of OUR own re-render
// doubling). The only theoretical regression is a
// single FCM duplicate retry landing right after the
// recovery uncommon enough to accept.
entries.add(new RoomMessageCache.Entry( entries.add(new RoomMessageCache.Entry(
/*eventId*/ null,
body, m.getTimestamp(), key, name, body, m.getTimestamp(), key, name,
/* fromSelf */ p == null /* fromSelf */ p == null
)); ));
@ -873,28 +1163,38 @@ public class VojoFirebaseMessagingService extends MessagingService {
final String name; final String name;
final boolean isDirect; final boolean isDirect;
final boolean isEncrypted; final boolean isEncrypted;
final String avatarMxc;
RoomMetadata(String name, boolean isDirect, boolean isEncrypted) { RoomMetadata(String name, boolean isDirect, boolean isEncrypted, String avatarMxc) {
this.name = name == null ? "" : name; this.name = name == null ? "" : name;
this.isDirect = isDirect; this.isDirect = isDirect;
this.isEncrypted = isEncrypted; this.isEncrypted = isEncrypted;
this.avatarMxc = avatarMxc;
} }
} }
private static RoomMetadata loadRoomMetadata(Context ctx, String roomId) { private static RoomMetadata loadRoomMetadata(Context ctx, String roomId) {
// Privacy-first defaults on every "no information" path: isEncrypted
// = true so the inline reply action is NOT offered when we can't
// confirm the room is cleartext. Reviewer correctly flagged that
// previously these defaults were `false`, which means an unknown
// room rendered the reply affordance and the receiver's
// server-side guard would silently reject the send, leaving the
// user with a dead button. With isEncrypted=true the action is
// simply absent until JS bridges fresh metadata.
if (roomId == null || roomId.isEmpty()) { if (roomId == null || roomId.isEmpty()) {
return new RoomMetadata("", true, false); return new RoomMetadata("", true, true, null);
} }
SharedPreferences prefs = ctx.getSharedPreferences( SharedPreferences prefs = ctx.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE); VojoPollWorker.PREFS, Context.MODE_PRIVATE);
String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null); String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null);
if (raw == null || raw.isEmpty()) { if (raw == null || raw.isEmpty()) {
return new RoomMetadata("", true, false); return new RoomMetadata("", true, true, null);
} }
try { try {
JSONObject map = new JSONObject(raw); JSONObject map = new JSONObject(raw);
if (!map.has(roomId) || map.isNull(roomId)) { if (!map.has(roomId) || map.isNull(roomId)) {
return new RoomMetadata("", true, false); return new RoomMetadata("", true, true, null);
} }
// Legacy shape: { roomId: "Display name" }. New shape: // Legacy shape: { roomId: "Display name" }. New shape:
// { roomId: { name: "Display name", isDirect: bool, // { roomId: { name: "Display name", isDirect: bool,
@ -911,7 +1211,9 @@ public class VojoFirebaseMessagingService extends MessagingService {
// into the timeline, which is a privacy leak. The conservative // into the timeline, which is a privacy leak. The conservative
// direction is to assume encryption. // direction is to assume encryption.
boolean isEncrypted = obj.optBoolean("isEncrypted", true); boolean isEncrypted = obj.optBoolean("isEncrypted", true);
return new RoomMetadata(name, isDirect, isEncrypted); String avatarMxc = obj.optString("avatarMxc", null);
if (avatarMxc != null && avatarMxc.isEmpty()) avatarMxc = null;
return new RoomMetadata(name, isDirect, isEncrypted, avatarMxc);
} }
String legacyName = map.optString(roomId, ""); String legacyName = map.optString(roomId, "");
// Default to DM when we have no isDirect signal. DM is the // Default to DM when we have no isDirect signal. DM is the
@ -923,9 +1225,9 @@ public class VojoFirebaseMessagingService extends MessagingService {
// Legacy shape predates the encryption flag, so assume // Legacy shape predates the encryption flag, so assume
// encrypted=true (no reply action) to err on the side of // encrypted=true (no reply action) to err on the side of
// privacy. // privacy.
return new RoomMetadata(legacyName, true, true); return new RoomMetadata(legacyName, true, true, null);
} catch (Throwable t) { } catch (Throwable t) {
return new RoomMetadata("", true, true); return new RoomMetadata("", true, true, null);
} }
} }

View file

@ -102,6 +102,12 @@ public class VojoPollWorker extends Worker {
static final String KEY_DRAIN_TARGET_TS = "drain_target_ts"; static final String KEY_DRAIN_TARGET_TS = "drain_target_ts";
static final String KEY_NOTIFIED_IDS = "notified_ids"; static final String KEY_NOTIFIED_IDS = "notified_ids";
static final String KEY_ROOM_NAMES = "room_names"; static final String KEY_ROOM_NAMES = "room_names";
// user_id MXC avatar URL, JSON-encoded, bridged from JS via
// PollingPlugin.saveUserAvatars. Consumed by
// VojoFirebaseMessagingService.lookupUserAvatarMxc for per-sender
// Person.setIcon in MessagingStyle conversations. Bounded at 500
// entries on the JS side; read tolerantly here.
static final String KEY_USER_AVATARS = "user_avatars";
private static final int HTTP_TIMEOUT_MS = 30_000; private static final int HTTP_TIMEOUT_MS = 30_000;
// Cap pages-per-fire so an unexpectedly large backlog (server-side bug, // Cap pages-per-fire so an unexpectedly large backlog (server-side bug,
@ -615,6 +621,14 @@ public class VojoPollWorker extends Worker {
// Parse the SharedPreferences-stored room-name JSON snapshot once per // Parse the SharedPreferences-stored room-name JSON snapshot once per
// doWork() so we don't redo the parse for every event in the page (up to // doWork() so we don't redo the parse for every event in the page (up to
// PAGE_LIMIT × MAX_PAGES_PER_RUN = 250 events). // PAGE_LIMIT × MAX_PAGES_PER_RUN = 250 events).
//
// The snapshot shape evolved: legacy was {roomId: "Display name"}, current
// is {roomId: {name, isDirect, isEncrypted, avatarMxc?}}. We parse both
// tolerantly for the structured shape we extract `name`, for the legacy
// shape we use the string verbatim. A naive optString on the structured
// entry serialises the whole object as JSON ("{name:Alice,...}") and that
// string leaked into the missed-call / message title on the polling
// path visible bug.
private static Map<String, String> loadRoomNamesMap(SharedPreferences prefs) { private static Map<String, String> loadRoomNamesMap(SharedPreferences prefs) {
Map<String, String> out = new HashMap<>(); Map<String, String> out = new HashMap<>();
String raw = prefs.getString(KEY_ROOM_NAMES, null); String raw = prefs.getString(KEY_ROOM_NAMES, null);
@ -624,7 +638,10 @@ public class VojoPollWorker extends Worker {
for (Iterator<String> it = map.keys(); it.hasNext(); ) { for (Iterator<String> it = map.keys(); it.hasNext(); ) {
String roomId = it.next(); String roomId = it.next();
if (map.isNull(roomId)) continue; if (map.isNull(roomId)) continue;
String name = map.optString(roomId, null); JSONObject obj = map.optJSONObject(roomId);
String name = obj != null
? obj.optString("name", null)
: map.optString(roomId, null);
if (name != null && !name.isEmpty()) out.put(roomId, name); if (name != null && !name.isEmpty()) out.put(roomId, name);
} }
} catch (org.json.JSONException je) { } catch (org.json.JSONException je) {

View file

@ -31,7 +31,7 @@ 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, type RoomMetadataMap } from '../plugins/polling'; import { polling, type RoomMetadataMap, type UserAvatarsMap } from '../plugins/polling';
import { getAccountData, getMDirects } from '../utils/room'; import { getAccountData, getMDirects } from '../utils/room';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
@ -56,17 +56,85 @@ const buildRoomMetadataSnapshot = (mx: MatrixClient): RoomMetadataMap => {
// gate the inline reply action: encrypted rooms get a read-only // gate the inline reply action: encrypted rooms get a read-only
// notification because the Java path has no key material to encrypt // notification because the Java path has no key material to encrypt
// outgoing replies with. // outgoing replies with.
//
// Room avatar: prefer the explicit m.room.avatar mxc; for a DM with no
// configured room avatar, fall through to the "other member" fallback
// (mirrors getDirectRoomAvatarUrl). Lets the Java MessagingStyle
// notification show the conversation avatar as largeIcon, the same
// shape WhatsApp / Element use.
const roomMxc = room.getMxcAvatarUrl();
const fallbackMxc = dmRooms.has(room.roomId)
? room.getAvatarFallbackMember()?.getMxcAvatarUrl()
: undefined;
const avatarMxc = roomMxc ?? fallbackMxc ?? undefined;
acc[room.roomId] = { acc[room.roomId] = {
name, name,
isDirect: dmRooms.has(room.roomId), isDirect: dmRooms.has(room.roomId),
isEncrypted: room.hasEncryptionStateEvent(), isEncrypted: room.hasEncryptionStateEvent(),
...(avatarMxc ? { avatarMxc } : {}),
}; };
return acc; return acc;
}, {}); }, {});
}; };
const MAX_BRIDGED_USER_AVATARS = 500;
const buildUserAvatarsSnapshot = (mx: MatrixClient): UserAvatarsMap => {
// user_id → mxc avatar URL, gathered from every room the user is in. The
// Java FCM/WorkManager renderers can't fetch profiles themselves (would
// need synchronous HTTP in the receive path), so this snapshot is the
// sole source of per-sender avatar data on the native side. Bounded at
// MAX_BRIDGED_USER_AVATARS to cap the serialised JSON; truncation drops
// the latest iteration entries (essentially arbitrary which rooms get
// their members covered, but the self user is always included first to
// protect the MessagingStyle self-anchor icon).
const out: UserAvatarsMap = {};
const myUserId = mx.getUserId();
if (myUserId) {
const myMxc = mx.getUser(myUserId)?.avatarUrl;
if (myMxc) out[myUserId] = myMxc;
}
// Sort by roomId for STABLE iteration across dumps. mx.getRooms()
// returns rooms in matrix-js-sdk's internal Map order which is
// insertion-time and can flip on a /sync that re-orders state. Without
// a stable sort, the 500-entry cap could include peer X on dump A,
// exclude X on dump B (different rooms iterated first), and the
// notification for room R would show X's avatar one push and lose it
// the next — visible flicker.
const compareRoomId = (a: Room, b: Room): number => {
if (a.roomId < b.roomId) return -1;
if (a.roomId > b.roomId) return 1;
return 0;
};
// `.some` is the eslint-friendly way to express "iterate with early
// exit" — returning true from the callback aborts the walk. Each room
// appends its joined-member avatars into `out` until we hit the cap;
// we abort the whole walk when the cap is reached. Members are added
// imperatively into `out` (idiomatic for a bounded accumulator that
// skips already-seen keys); the cap check is a simple length read.
[...mx.getRooms()].sort(compareRoomId).some((room) => {
room.getJoinedMembers().some((member) => {
if (Object.keys(out).length >= MAX_BRIDGED_USER_AVATARS) return true;
if (out[member.userId]) return false;
const mxc = member.getMxcAvatarUrl();
if (mxc) out[member.userId] = mxc;
return false;
});
return Object.keys(out).length >= MAX_BRIDGED_USER_AVATARS;
});
return out;
};
const dumpRoomNamesToNative = async (mx: MatrixClient): Promise<void> => { const dumpRoomNamesToNative = async (mx: MatrixClient): Promise<void> => {
// Bridge both the room metadata snapshot AND the user avatars map. They
// travel together because every trigger that invalidates one (visibility
// resume, m.direct change, m.room.encryption flip) typically invalidates
// the other — a fresh DM appears in m.direct alongside the new peer in
// user avatars; a freshly-joined member affects both. Two sequential
// bridge calls instead of bundling because the native plugin keeps the
// two payloads in separate SharedPreferences keys for parsing simplicity.
await polling.saveRoomNames(buildRoomMetadataSnapshot(mx)); await polling.saveRoomNames(buildRoomMetadataSnapshot(mx));
await polling.saveUserAvatars(buildUserAvatarsSnapshot(mx));
}; };
export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied'; export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied';

View file

@ -15,16 +15,36 @@ import { isAndroidPlatform } from '../utils/capacitor';
export type RoomMetadataMap = Record< export type RoomMetadataMap = Record<
string, string,
string | { name: string; isDirect: boolean; isEncrypted: boolean } | string
| {
name: string;
isDirect: boolean;
isEncrypted: boolean;
// MXC URL for the room's avatar (or, for a DM with no explicit room
// avatar, the other member's avatar). Optional — empty for rooms
// without an avatar configured. Java side resolves via auth-media
// v1.11 + LRU bitmap cache.
avatarMxc?: string;
}
>; >;
/**
* user_id MXC avatar URL. Bridged to the Java side so the FCM /
* WorkManager renderers can attach IconCompat icons to per-sender Person
* objects in MessagingStyle (and to the self-Person anchor). Senders not
* in this map fall through to Android's default initials/blank circle.
*/
export type UserAvatarsMap = Record<string, string>;
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>;
// Tolerant of both legacy (`roomId: "Display"`) and new structured shape // Tolerant of both legacy (`roomId: "Display"`) and new structured shape
// (`roomId: { name, isDirect }`) — the Java side decides the message // (`roomId: { name, isDirect, isEncrypted, avatarMxc }`) — the Java side
// channel (DM vs group) based on the structured value when present. // decides the message channel (DM vs group) and resolves the room large
// icon based on the structured value when present.
saveRoomNames(opts: { names: RoomMetadataMap }): Promise<void>; saveRoomNames(opts: { names: RoomMetadataMap }): Promise<void>;
saveUserAvatars(opts: { avatars: UserAvatarsMap }): 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>; dismissRoom(opts: { roomId: string }): Promise<void>;
@ -34,6 +54,7 @@ const noopPlugin: PollingPluginIface = {
saveSession: async () => undefined, saveSession: async () => undefined,
clearSession: async () => undefined, clearSession: async () => undefined,
saveRoomNames: async () => undefined, saveRoomNames: async () => undefined,
saveUserAvatars: async () => undefined,
schedule: async () => undefined, schedule: async () => undefined,
cancel: async () => undefined, cancel: async () => undefined,
dismissRoom: async () => undefined, dismissRoom: async () => undefined,
@ -63,6 +84,8 @@ export const polling = {
clearSession: () => guard(() => plugin.clearSession(), undefined), clearSession: () => guard(() => plugin.clearSession(), undefined),
saveRoomNames: (names: RoomMetadataMap) => saveRoomNames: (names: RoomMetadataMap) =>
guard(() => plugin.saveRoomNames({ names }), undefined), guard(() => plugin.saveRoomNames({ names }), undefined),
saveUserAvatars: (avatars: UserAvatarsMap) =>
guard(() => plugin.saveUserAvatars({ avatars }), 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), dismissRoom: (roomId: string) => guard(() => plugin.dismissRoom({ roomId }), undefined),