From ac072a1ddc40e0d78935bfe23e46e53b62843ed7 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Sun, 17 May 2026 17:39:19 +0300 Subject: [PATCH] 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) --- .../java/chat/vojo/app/AvatarBitmapCache.java | 65 ++++ .../main/java/chat/vojo/app/AvatarLoader.java | 368 ++++++++++++++++++ .../chat/vojo/app/ConversationShortcuts.java | 163 ++++++++ .../chat/vojo/app/MarkAsReadReceiver.java | 33 +- .../java/chat/vojo/app/PollingPlugin.java | 32 +- .../java/chat/vojo/app/RoomMessageCache.java | 40 +- .../app/VojoFirebaseMessagingService.java | 352 +++++++++++++++-- .../java/chat/vojo/app/VojoPollWorker.java | 19 +- src/app/hooks/usePushNotifications.ts | 70 +++- src/app/plugins/polling.ts | 29 +- 10 files changed, 1124 insertions(+), 47 deletions(-) create mode 100644 android/app/src/main/java/chat/vojo/app/AvatarBitmapCache.java create mode 100644 android/app/src/main/java/chat/vojo/app/AvatarLoader.java create mode 100644 android/app/src/main/java/chat/vojo/app/ConversationShortcuts.java diff --git a/android/app/src/main/java/chat/vojo/app/AvatarBitmapCache.java b/android/app/src/main/java/chat/vojo/app/AvatarBitmapCache.java new file mode 100644 index 00000000..8aaa15db --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/AvatarBitmapCache.java @@ -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 CACHE = + new LruCache(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); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/AvatarLoader.java b/android/app/src/main/java/chat/vojo/app/AvatarLoader.java new file mode 100644 index 00000000..ca3c57c2 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/AvatarLoader.java @@ -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 + * → /_matrix/client/v1/media/thumbnail// + * ?width=96&height=96&method=crop + * + Authorization: Bearer + * + * 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 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 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 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 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); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/ConversationShortcuts.java b/android/app/src/main/java/chat/vojo/app/ConversationShortcuts.java new file mode 100644 index 00000000..147faba3 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/ConversationShortcuts.java @@ -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 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; + } + } +} diff --git a/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java b/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java index 97991a7a..e46b0e85 100644 --- a/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java +++ b/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java @@ -24,19 +24,28 @@ import java.util.concurrent.Executors; * {@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 + * Dismiss policy: OPTIMISTIC. The per-room notification is dismissed + * synchronously in onReceive — before the HTTP receipt PUT is even + * attempted — so the user sees instant feedback. The async receipt POST + * happens on a worker thread afterwards. This mirrors element-android's + * NotificationBroadcastReceiver pattern and matches the user's mental + * model ("I tapped, it should disappear immediately"). * - * 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. + * Failure mode: on any non-2xx or thrown exception we accept that the + * server-side read receipt did not land. We do NOT re-post the + * notification or implement a flusher because: + * - 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 { diff --git a/android/app/src/main/java/chat/vojo/app/PollingPlugin.java b/android/app/src/main/java/chat/vojo/app/PollingPlugin.java index 7ef598e2..ee998b95 100644 --- a/android/app/src/main/java/chat/vojo/app/PollingPlugin.java +++ b/android/app/src/main/java/chat/vojo/app/PollingPlugin.java @@ -105,10 +105,37 @@ public class PollingPlugin extends Plugin { .remove(VojoPollWorker.KEY_DRAIN_TARGET_TS) .remove(VojoPollWorker.KEY_NOTIFIED_IDS) .remove(VojoPollWorker.KEY_ROOM_NAMES) + .remove(VojoPollWorker.KEY_USER_AVATARS) .apply(); 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 public void saveRoomNames(PluginCall call) { 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 // it through `new JSONObject(...)` just to re-serialise. Persist // verbatim. + String serialized = names.toString(); getContext() .getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE) .edit() - .putString(VojoPollWorker.KEY_ROOM_NAMES, names.toString()) + .putString(VojoPollWorker.KEY_ROOM_NAMES, serialized) .apply(); + Log.i(TAG, "saveRoomNames: " + names.length() + " entries, " + + serialized.length() + " bytes"); call.resolve(); } diff --git a/android/app/src/main/java/chat/vojo/app/RoomMessageCache.java b/android/app/src/main/java/chat/vojo/app/RoomMessageCache.java index 13fd4b21..7fe1b9a6 100644 --- a/android/app/src/main/java/chat/vojo/app/RoomMessageCache.java +++ b/android/app/src/main/java/chat/vojo/app/RoomMessageCache.java @@ -66,13 +66,28 @@ final class RoomMessageCache { * record matches element-android's RoomGroupMessageCreator pattern. */ 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 long timestamp; final String senderKey; final String senderName; 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.timestamp = timestamp; this.senderKey = senderKey; @@ -96,9 +111,26 @@ final class RoomMessageCache { final List snapshot = new ArrayList<>(); store.compute(roomId, (key, existing) -> { Deque d = (existing != null) ? existing : new ArrayDeque<>(); - d.addLast(entry); - while (d.size() > MAX_MESSAGES_PER_ROOM) { - d.pollFirst(); + // Dedup by eventId — protects against FCM retry / cross-source + // (FCM + polling Worker) double-delivery that would otherwise + // 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); return d; diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java index 7808b17e..db962ff7 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -9,6 +9,7 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; +import android.graphics.Bitmap; import android.media.AudioAttributes; import android.media.RingtoneManager; import android.net.Uri; @@ -20,6 +21,9 @@ import android.util.Log; import androidx.core.app.NotificationCompat; import androidx.core.app.Person; 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.google.firebase.messaging.RemoteMessage; @@ -318,6 +322,19 @@ public class VojoFirebaseMessagingService extends MessagingService { } String eventId = data.get("event_id"); 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)"); boolean posted = renderMessageNotification( this, data, remoteMessage.getMessageId()); @@ -429,10 +446,21 @@ public class VojoFirebaseMessagingService extends MessagingService { seedCacheFromActiveNotification(ctx, nm, roomId); RoomMessageCache.Entry entry = new RoomMessageCache.Entry( - body, timestamp, senderKey, senderName, /* fromSelf */ false + eventId, body, timestamp, senderKey, senderName, /* fromSelf */ false ); List 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 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 + // localised "You" label come from prefs that JS bridged via // PollingPlugin.saveSession. On a fresh install with a push arriving @@ -449,11 +477,29 @@ public class VojoFirebaseMessagingService extends MessagingService { } 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) { - Person sender = e.fromSelf ? null : new Person.Builder() - .setName(e.senderName != null ? e.senderName : "") - .setKey(e.senderKey != null ? e.senderKey : "") - .build(); + Person sender; + if (e.fromSelf) { + sender = null; + } 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( 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 // doesn't smash the prior intent's extras — but the request code is // 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); launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); launchIntent.putExtra("google.message_id", messageId != null ? messageId : ""); for (Map.Entry en : data.entrySet()) { launchIntent.putExtra(en.getKey(), en.getValue()); } - int flags = PendingIntent.FLAG_UPDATE_CURRENT - | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + 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( 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) .setSmallIcon(R.mipmap.ic_launcher) .setStyle(style) @@ -495,14 +577,57 @@ public class VojoFirebaseMessagingService extends MessagingService { // notification in a room still alerts; subsequent updates are // visual only. .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) .setShowWhen(true) .setPriority(NotificationCompat.PRIORITY_HIGH) .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)); + // 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 // builds a vanilla `m.room.message`, and we have no key material // on the Java side to encrypt. RoomMetadata.isEncrypted defaults @@ -535,6 +660,14 @@ public class VojoFirebaseMessagingService extends MessagingService { .setContentText(PushStrings.messagesFallback(ctx)) .setGroup(GROUP_KEY) .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); nm.notify(SUMMARY_NOTIFICATION_ID, summary.build()); } catch (SecurityException e) { @@ -608,6 +741,14 @@ public class VojoFirebaseMessagingService extends MessagingService { .setContentText(PushStrings.messagesFallback(ctx)) .setGroup(GROUP_KEY) .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); nm.notify(SUMMARY_NOTIFICATION_ID, summary.build()); } catch (SecurityException e) { @@ -743,10 +884,21 @@ public class VojoFirebaseMessagingService extends MessagingService { seedCacheFromActiveNotification(ctx, nm, roomId); 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 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 avatarMxcs = collectAvatarMxcs(avatarPrefs, history, meta); + AvatarLoader.loadAllWithTimeout(ctx, avatarMxcs); + Person selfPerson = buildSelfPerson(ctx); NotificationCompat.MessagingStyle style = new NotificationCompat.MessagingStyle(selfPerson); boolean isGroup = !meta.isDirect; @@ -754,11 +906,24 @@ public class VojoFirebaseMessagingService extends MessagingService { style.setConversationTitle(meta.name); } style.setGroupConversation(isGroup); + Person lastNonSelfSender = null; 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(); + Person sender; + if (e.fromSelf) { + sender = null; + } 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( 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.putExtra("google.message_id", ""); launchIntent.putExtra("room_id", roomId); - int flags = PendingIntent.FLAG_UPDATE_CURRENT - | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + // MUTABLE for conversation-notification bubble eligibility on + // 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( 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) .setSmallIcon(R.mipmap.ic_launcher) .setStyle(style) @@ -783,17 +966,33 @@ public class VojoFirebaseMessagingService extends MessagingService { .setGroup(GROUP_KEY) // Always silent — sending a reply must not re-alert the user. .setOnlyAlertOnce(true) - .setShortcutId(roomId) + .setLocusId(new LocusIdCompat(roomId)) .setWhen(timestamp) .setShowWhen(true) .setPriority(NotificationCompat.PRIORITY_LOW) .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-render; eventId is unknown for an outgoing-only update so // it falls through to the local-dismiss-only branch in the // receiver — acceptable for an optimistic echo. .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) { builder.addAction(buildReplyAction(ctx, roomId)); } @@ -815,10 +1014,90 @@ public class VojoFirebaseMessagingService extends MessagingService { .setName(PushStrings.selfName(ctx)); if (userId != null && !userId.isEmpty()) { b.setKey(userId); + String mxc = lookupUserAvatarMxc(prefs, userId); + if (mxc != null) { + IconCompat icon = iconFromCachedMxc(mxc); + if (icon != null) b.setIcon(icon); + } } 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 collectAvatarMxcs( + SharedPreferences prefs, + java.util.List history, + RoomMetadata meta + ) { + java.util.LinkedHashSet 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 * notification so a re-render after process kill preserves conversation @@ -848,7 +1127,18 @@ public class VojoFirebaseMessagingService extends MessagingService { ? p.getName().toString() : ""; String key = p != null ? p.getKey() : null; 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( + /*eventId*/ null, body, m.getTimestamp(), key, name, /* fromSelf */ p == null )); @@ -873,28 +1163,38 @@ public class VojoFirebaseMessagingService extends MessagingService { final String name; final boolean isDirect; 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.isDirect = isDirect; this.isEncrypted = isEncrypted; + this.avatarMxc = avatarMxc; } } 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()) { - return new RoomMetadata("", true, false); + return new RoomMetadata("", true, true, null); } 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, false); + return new RoomMetadata("", true, true, null); } try { JSONObject map = new JSONObject(raw); 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: // { 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 // direction is to assume encryption. 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, ""); // 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 // encrypted=true (no reply action) to err on the side of // privacy. - return new RoomMetadata(legacyName, true, true); + return new RoomMetadata(legacyName, true, true, null); } catch (Throwable t) { - return new RoomMetadata("", true, true); + return new RoomMetadata("", true, true, null); } } diff --git a/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java index 4b424fba..e550660a 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java +++ b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java @@ -102,6 +102,12 @@ public class VojoPollWorker extends Worker { static final String KEY_DRAIN_TARGET_TS = "drain_target_ts"; static final String KEY_NOTIFIED_IDS = "notified_ids"; 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; // 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 // doWork() so we don't redo the parse for every event in the page (up to // 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 loadRoomNamesMap(SharedPreferences prefs) { Map out = new HashMap<>(); String raw = prefs.getString(KEY_ROOM_NAMES, null); @@ -624,7 +638,10 @@ public class VojoPollWorker extends Worker { for (Iterator it = map.keys(); it.hasNext(); ) { String roomId = it.next(); 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); } } catch (org.json.JSONException je) { diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts index 61138823..3ec22ade 100644 --- a/src/app/hooks/usePushNotifications.ts +++ b/src/app/hooks/usePushNotifications.ts @@ -31,7 +31,7 @@ import { import { getDirectPath, getDirectRoomPath } from '../pages/pathUtils'; import { pendingCallActionAtom } from '../state/pendingCallAction'; 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 { 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 // notification because the Java path has no key material to encrypt // 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] = { name, isDirect: dmRooms.has(room.roomId), isEncrypted: room.hasEncryptionStateEvent(), + ...(avatarMxc ? { avatarMxc } : {}), }; 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 => { + // 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.saveUserAvatars(buildUserAvatarsSnapshot(mx)); }; export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied'; diff --git a/src/app/plugins/polling.ts b/src/app/plugins/polling.ts index adfd81b0..120a4e84 100644 --- a/src/app/plugins/polling.ts +++ b/src/app/plugins/polling.ts @@ -15,16 +15,36 @@ import { isAndroidPlatform } from '../utils/capacitor'; export type RoomMetadataMap = Record< 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; + interface PollingPluginIface { saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise; clearSession(): Promise; // Tolerant of both legacy (`roomId: "Display"`) and new structured shape - // (`roomId: { name, isDirect }`) — the Java side decides the message - // channel (DM vs group) based on the structured value when present. + // (`roomId: { name, isDirect, isEncrypted, avatarMxc }`) — the Java side + // 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; + saveUserAvatars(opts: { avatars: UserAvatarsMap }): Promise; schedule(opts: { intervalMinutes: number }): Promise; cancel(): Promise; dismissRoom(opts: { roomId: string }): Promise; @@ -34,6 +54,7 @@ const noopPlugin: PollingPluginIface = { saveSession: async () => undefined, clearSession: async () => undefined, saveRoomNames: async () => undefined, + saveUserAvatars: async () => undefined, schedule: async () => undefined, cancel: async () => undefined, dismissRoom: async () => undefined, @@ -63,6 +84,8 @@ export const polling = { clearSession: () => guard(() => plugin.clearSession(), undefined), saveRoomNames: (names: RoomMetadataMap) => guard(() => plugin.saveRoomNames({ names }), undefined), + saveUserAvatars: (avatars: UserAvatarsMap) => + guard(() => plugin.saveUserAvatars({ avatars }), undefined), schedule: (intervalMinutes = 15) => guard(() => plugin.schedule({ intervalMinutes }), undefined), cancel: () => guard(() => plugin.cancel(), undefined), dismissRoom: (roomId: string) => guard(() => plugin.dismissRoom({ roomId }), undefined),