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:
parent
4b4454fa1d
commit
5dbe83aa9d
10 changed files with 1124 additions and 47 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
368
android/app/src/main/java/chat/vojo/app/AvatarLoader.java
Normal file
368
android/app/src/main/java/chat/vojo/app/AvatarLoader.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Entry> snapshot = new ArrayList<>();
|
||||
store.compute(roomId, (key, existing) -> {
|
||||
Deque<Entry> d = (existing != null) ? existing : new ArrayDeque<>();
|
||||
d.addLast(entry);
|
||||
while (d.size() > MAX_MESSAGES_PER_ROOM) {
|
||||
d.pollFirst();
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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<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 +
|
||||
// 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<String, String> en : data.entrySet()) {
|
||||
launchIntent.putExtra(en.getKey(), en.getValue());
|
||||
}
|
||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||
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<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);
|
||||
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<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
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, String> loadRoomNamesMap(SharedPreferences prefs) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
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(); ) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<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.saveUserAvatars(buildUserAvatarsSnapshot(mx));
|
||||
};
|
||||
|
||||
export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied';
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
|
||||
interface PollingPluginIface {
|
||||
saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise<void>;
|
||||
clearSession(): Promise<void>;
|
||||
// Tolerant of both legacy (`roomId: "Display"`) and new structured shape
|
||||
// (`roomId: { name, isDirect }`) — the Java side decides the message
|
||||
// channel (DM vs group) based on the structured value when present.
|
||||
// (`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<void>;
|
||||
saveUserAvatars(opts: { avatars: UserAvatarsMap }): Promise<void>;
|
||||
schedule(opts: { intervalMinutes: number }): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
dismissRoom(opts: { roomId: string }): Promise<void>;
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue