package chat.vojo.app;

import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationChannelGroup;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.media.AudioAttributes;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
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;

import org.json.JSONObject;

import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Sygnal delivers Matrix pushes as data-only FCM messages (no `notification`
 * block), so the FCM SDK never auto-displays anything. The Capacitor plugin's
 * base MessagingService just forwards to JS via `pushNotificationReceived`,
 * but JS listeners detach when the WebView is paused/backgrounded.
 *
 * Message branch: builds a system notification when the activity is NOT in
 * the foreground — covering both "backgrounded" and "killed" cases. The
 * actual render is delegated to the static `renderMessageNotification`
 * helper below, which is also called from VojoPollWorker on the FCM-blocked
 * fallback path. Successful renders are recorded in NotificationDedup so
 * the polling Worker doesn't re-surface them on a later cycle.
 *
 * Call branch: funnels every observed DM ring through the native ring
 * registry (see below). FCM arrival either seeds the registry (foreground,
 * JS strip will own UX) or seeds + renders immediately (background). The
 * registry is authoritative for "which rings are currently live"; onPause
 * renders whatever the registry still holds, onResume cancels native
 * surfaces we raised.
 */
public class VojoFirebaseMessagingService extends MessagingService {

    // Legacy channel ID (single bucket for everything). Kept as a constant so
    // the v1 channel creation path can delete it cleanly on first run after
    // upgrade. Do not post into this channel any more.
    private static final String LEGACY_MESSAGE_CHANNEL_ID = "vojo_messages";

    // Channel-group + per-bucket channels. Group lets the OS Settings UI
    // collapse both message channels under one "Chats" header so the user can
    // toggle DM vs group rooms independently without spelunking. v1 suffix
    // mirrors the vojo_calls_v2 strategy — channel settings are immutable
    // after creation on API 26+, so any future config change (sound, vibration
    // pattern) bumps the version.
    private static final String MESSAGE_CHANNEL_GROUP_ID = "vojo_messages_v1";
    // Package-visible: ReplyReceiver reuses CHANNEL_ID_DM for its "reply
    // failed" one-shot notification. Was previously a separate _PUBLIC
    // alias; collapsed to a single package-private constant.
    static final String CHANNEL_ID_DM = "vojo_messages_dm_v1";
    private static final String CHANNEL_ID_GROUP = "vojo_messages_group_v1";

    private static final String GROUP_KEY = "vojo_messages";
    // NotificationChannel settings (vibration pattern, sound, importance) are
    // immutable after creation on API 26+. Bump this ID whenever the channel
    // config changes so upgrades actually pick up the new settings instead of
    // silently using the stale channel the user already has. v2 = 20-pulse
    // ring vibration + ringtone sound (previously ~2 pulses, silent tail).
    private static final String CALL_CHANNEL_ID = "vojo_calls_v2";
    private static final String LEGACY_CALL_CHANNEL_ID = "vojo_calls";
    // Reserved id for the group summary. Chosen as MIN_VALUE so it can't collide
    // with String.hashCode() of any event/room key (which notoriously returns 0
    // for the empty string and a handful of other inputs).
    private static final int SUMMARY_NOTIFICATION_ID = Integer.MIN_VALUE;

    // Both the unstable MSC4075 prefix that current matrix-js-sdk emits and the
    // stable name peers may upgrade to. Receive-side fallback only — send-side
    // (CallWidgetDriver cleartext bypass) keeps emitting the unstable name until
    // the SDK promotes the constant.
    private static final String RTC_NOTIFICATION_TYPE = "org.matrix.msc4075.rtc.notification";
    private static final String RTC_NOTIFICATION_TYPE_STABLE = "m.rtc.notification";

    private static boolean isRtcNotificationType(String type) {
        return RTC_NOTIFICATION_TYPE.equals(type) || RTC_NOTIFICATION_TYPE_STABLE.equals(type);
    }

    /**
     * Composite dedup key for a single call session (a ring + its retries).
     * Element-web uses the same shape (`call_{callId}_{roomId}` in
     * `getIncomingCallToastKey`). We mark this key in NotificationDedup the
     * moment we post the first CallStyle for the session; later
     * m.rtc.notification events that share the parent — re-ring for
     * participant rejoin, FCM retry-with-fresh-event-id — see the mark and
     * silently skip the post.
     *
     * Package-private so VojoPollWorker can build the same key shape from
     * its flatten path without duplicating the literal — keeps the FCM and
     * polling paths trivially in sync.
     */
    static String compositeCallDedupKey(String roomId, String callSessionId) {
        return "call:" + roomId + ":" + callSessionId;
    }

    /**
     * Pull the parent-call event_id (the "call session" identifier) out of
     * a flattened Sygnal payload. The standard MSC4075 m.rtc.notification
     * references the parent via `content.m.relates_to.event_id`; Sygnal
     * flattens nested content with `_` separator but keeps `m.relates_to`
     * literal, so the observed FCM keys land as either
     * `content_m.relates_to_event_id` (literal dot) or
     * `content_m_relates_to_event_id` (escaped to underscore) depending on
     * deployment config. We probe both shapes — and `content_call_id` as
     * a third candidate for legacy MSC2746 calls — so the dedup works
     * regardless of which encoding the homeserver's Sygnal happens to use.
     *
     * Package-private: VojoPollWorker's flatten path writes the
     * `content_m.relates_to_event_id` form, so this same helper resolves
     * both FCM and polling payloads.
     */
    static String extractCallSessionId(Map<String, String> data) {
        String[] candidates = new String[] {
            "content_m.relates_to_event_id",
            "content_m_relates_to_event_id",
            "content_call_id",
        };
        for (String key : candidates) {
            String v = data.get(key);
            if (v != null && !v.isEmpty()) return v;
        }
        return null;
    }

    private static final long RTC_DEFAULT_LIFETIME_MS = 30_000L;
    private static final long RTC_LIFETIME_GRACE_MS = 2_000L;

    // Extra attached to every CallStyle notification posted from the registry;
    // onResume ownership check matches this against the currently-rendered
    // StatusBarNotification so we don't cancel a foreign ring that happens to
    // share the same room-scoped tag/id slot.
    private static final String EXTRA_RING_EVENT_ID = "vojo_ring_event_id";

    private static final String TAG = "VojoFCM";

    // Debug-only log helper. Release builds strip these calls at compile time
    // (BuildConfig.DEBUG is a compile-time constant; the javac dead-code
    // eliminator drops the whole statement). Use dlog() for anything that
    // includes privacy-sensitive identifiers (roomId, eventId, sender);
    // use Log.w / Log.e directly for error paths that must surface in release.
    private static void dlog(String msg) {
        if (BuildConfig.DEBUG) Log.d(TAG, msg);
    }

    // ────────────────────────────────────────────────────────────────────
    // Native Ring Registry
    //
    // Single source of truth on the Java side for "which rings are currently
    // live". Populated by FCM arrival (fg or bg) and by JS bridge upsert on
    // incomingCallsAtom ADD. Removed by JS bridge on atom REMOVE and suppress
    // paths, by native decline/cancel receivers, and by expiry sweep.
    //
    // Key: notifEventId (MSC4075 m.rtc.notification event_id). Same eventId
    // observed by both FCM and /sync → idempotent upsert. Remove-wins
    // consistency is enforced via tombstones: a late FCM seed arriving after
    // JS suppressed the ring is rejected by a tombstone for the ring lifetime.
    //
    // Native notification identity remains room-scoped (tag=call_<roomId>,
    // id=hash(tag)). If a same-room second ring arrives before the first was
    // dismissed, both live in the registry by distinct eventIds; renderRegistry
    // posts each but nm.notify replaces in the one room-scoped slot. Only
    // one of them is user-visible at a time — same-room latest-wins matches
    // the UX contract for a single active ring per DM.
    // ────────────────────────────────────────────────────────────────────

    private static final ConcurrentHashMap<String, IncomingRing> ringRegistry =
        new ConcurrentHashMap<>();

    // eventId → absolute-ms when tombstone expires. Block upserts during this
    // window so a late FCM re-delivery or late /sync cannot resurrect a ring
    // the user already accepted/declined/ignored.
    private static final ConcurrentHashMap<String, Long> ringTombstones =
        new ConcurrentHashMap<>();

    // Single lock covering every registry+tombstone mutation so remove-wins and
    // latest-wins-per-room stay atomic. ConcurrentHashMap would linearize each
    // call individually, but the invariants ("don't upsert if tombstoned",
    // "evict older same-room on upsert") span both maps and must see a
    // consistent snapshot. Critical sections stay short; no Android Binder
    // calls (NotificationManager, AlarmManager) run under the lock.
    private static final Object registryLock = new Object();

    private static final class IncomingRing {
        final Map<String, String> data;
        // Not final — a JS-first upsert seeds a null messageId; when FCM
        // arrives later with a real messageId we want to adopt it so the
        // Answer PendingIntent carries a non-empty google.message_id. Merge
        // rule in upsertIncomingRing: overwrite only when existing is null.
        volatile String messageId;
        // Wall-clock at seed time. Fallback baseline for expiry checks when the
        // payload lacks `content_sender_ts`.
        final long seededAt;
        // 0 if not currently posted as a native CallStyle. Set by renderOne on
        // successful post; reset by cancelRenderedIncomingRings. Used by
        // renderRegistry to avoid double-posting while the native is live.
        volatile long renderedAt;
        // Wall-clock of the most recent ALERTING post (non-silent). Used by
        // renderOne to decide whether a re-render within the cooldown should
        // be silent — rapid bg↔fg↔bg toggles otherwise cancel+repost the
        // CallStyle and fresh-alert on each cycle. Preserved across cancel
        // so the cooldown spans the bg→fg→bg window; setOnlyAlertOnce alone
        // doesn't help because cancel+post is a fresh notification, not an update.
        volatile long lastAlertedAt;

        IncomingRing(Map<String, String> data, String messageId, long seededAt) {
            this.data = data;
            this.messageId = messageId;
            this.seededAt = seededAt;
            this.renderedAt = 0L;
            this.lastAlertedAt = 0L;
        }
    }

    // Re-alert cooldown. Within this many ms of the last alerting post, a
    // render goes out silently (setSilent(true)) — visual only, no
    // ringtone/vibration. After the window, next render alerts fresh.
    private static final long RE_ALERT_COOLDOWN_MS = 3_000L;

    @Override
    public void onMessageReceived(RemoteMessage remoteMessage) {
        super.onMessageReceived(remoteMessage);
        Map<String, String> data = remoteMessage.getData();
        dlog("recv: type=" + data.get("type")
            + " cn_type=" + data.get("content_notification_type")
            + " room=" + data.get("room_id")
            + " event=" + data.get("event_id")
            + " fg=" + MainActivity.isInForeground);
        try {
            if (isRtcNotificationType(data.get("type"))
                && "ring".equals(data.get("content_notification_type"))) {
                String eventId = data.get("event_id");
                String roomId = data.get("room_id");
                if (eventId == null || roomId == null) {
                    Log.w(TAG, "route: call missing eventId/roomId, drop");
                    return;
                }
                // Composite session dedup — silently drop re-rings (different
                // event_id, same parent call session) that would otherwise
                // re-alert the user for a call they already saw the first
                // ring for. The legacy per-eventId dedup misses this because
                // each re-ring has a fresh m.rtc.notification event_id.
                String callSessionId = extractCallSessionId(data);
                if (callSessionId != null) {
                    String compositeKey = compositeCallDedupKey(roomId, callSessionId);
                    if (NotificationDedup.wasNotified(this, compositeKey)) {
                        dlog("route: call re-ring suppressed session=" + callSessionId);
                        return;
                    }
                }
                // Snapshot the payload — FCM internals may recycle the map reference.
                Map<String, String> snapshot = new HashMap<>(data);
                boolean seeded = upsertIncomingRing(snapshot, remoteMessage.getMessageId());
                if (!seeded) {
                    dlog("route: call tombstoned, skipping native (event=" + eventId + ")");
                    return;
                }
                // Cross-source dedup at seed time, regardless of fg/bg branch.
                // The bg path also marks again via postIncomingCallNotification
                // after a successful nm.notify (defense in depth — markNotified
                // is idempotent). The fg path otherwise wouldn't mark at all,
                // and a polling fire 15 minutes later would resurface the
                // event as a "Missed call" notification even though the user
                // already saw the live JS strip and chose to ignore it.
                NotificationDedup.markNotified(this, eventId);
                // Mark the session composite so a re-ring of the same call
                // session (different event_id) doesn't re-alert. Marked
                // unconditionally — fg path's "JS strip owns UX" means the
                // user has already been alerted in-app even though no native
                // notification was posted.
                if (callSessionId != null) {
                    NotificationDedup.markNotified(this,
                        compositeCallDedupKey(roomId, callSessionId));
                }
                if (MainActivity.isInForeground) {
                    dlog("route: call seeded (foreground, JS strip owns UX) event=" + eventId);
                    // Race guard: MainActivity.onPause may have run its render
                    // between our fg check and the upsert above. If fg flipped
                    // to false meanwhile, render now so the seeded entry isn't
                    // left unrendered until the next backgrounding cycle.
                    if (!MainActivity.isInForeground) {
                        dlog("route: race-detected (fg→bg during seed), render now");
                        renderRegistry(this);
                    }
                } else {
                    dlog("route: call seeded + render (background) event=" + eventId);
                    IncomingRing entry = ringRegistry.get(eventId);
                    // Guard against late-delivered FCM (doze / retry past lifetime):
                    // renderRegistry filters expired entries, but the bg direct-render
                    // path skipped that check. Without this guard a dead ring would
                    // briefly surface and then be dismissed by the expiry alarm.
                    if (entry != null && !isExpired(entry, System.currentTimeMillis())) {
                        renderOne(this, entry);
                    } else if (entry != null) {
                        dlog("route: call bg drop expired event=" + eventId);
                    }
                }
                return;
            }
            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());
                // Cross-source dedup: only mark on a successful nm.notify so
                // a permission-revoked SecurityException doesn't silently
                // hide the event from VojoPollWorker's retry path. The
                // polling Worker writes to the same store from doWork().
                if (posted && eventId != null && !eventId.isEmpty()) {
                    NotificationDedup.markNotified(this, eventId);
                }
            } else {
                dlog("route: skip (foreground, non-call)");
                // Even though we didn't render, the JS timeline already
                // surfaced the event live. Mark it in NotificationDedup so a
                // later poll cycle — fired after the user backgrounds the
                // app but before the server marks the event read — does not
                // resurface it in the shade as a stale "missed" notification.
                if (eventId != null && !eventId.isEmpty()) {
                    NotificationDedup.markNotified(this, eventId);
                }
            }
        } catch (Throwable t) {
            // Don't let any notification-construction bug crash the FCM service — if we
            // do, Android restarts the process for the next push (PID churn visible in
            // logcat) and eventually shows the "app keeps stopping" dialog to the user.
            // Better to swallow + log and lose one notification than kill the process.
            Log.e(TAG, "onMessageReceived: uncaught exception in notification flow", t);
        }
    }

    /**
     * Shared message/invite renderer. Called by the FCM service (instance path,
     * background only) and by VojoPollWorker (background polling path, used as
     * the FCM-blocked fallback delivery channel).
     *
     * Static + Context-parameterised so the Worker — which has no Service
     * lifetime — can post into the same notification id space. Identity is
     * derived as `roomId.hashCode()` — one MessagingStyle conversation per
     * room, appended on each new event. WhatsApp/Telegram convention: five
     * messages in one DM coalesce into one expandable entry instead of five
     * stacked banners. NotificationDedup is the cross-source LRU that prevents
     * FCM and polling from double-counting the same event into the conversation.
     *
     * History source: in-memory RoomMessageCache (bounded per-room deque).
     * Process-kill recovery: extractMessagingStyleFromNotification reads the
     * existing on-shade notification and seeds the cache, so a re-render after
     * cold-start preserves prior messages instead of starting empty.
     *
     * Both call sites pre-gate the "should I render" decision: FCM gates on
     * `!isInForeground`, polling gates on its own watermark + NotificationDedup.
     * This method just renders.
     */
    static boolean renderMessageNotification(
        Context ctx,
        Map<String, String> data,
        String messageId
    ) {
        String roomId = data.get("room_id");
        String eventId = data.get("event_id");

        // Sygnal flattens nested notification fields with `_` separator:
        // sender_display_name, content_body, content_msgtype, etc. The polling
        // fallback (VojoPollWorker) builds the same flattened shape when it
        // parses /_matrix/client/v3/notifications responses, so the rest of
        // this method is source-agnostic.
        boolean isInvite = "m.room.member".equals(data.get("type"))
            && "invite".equals(data.get("content_membership"));

        // Invites pre-date conversation context and don't benefit from
        // MessagingStyle (no body, no sender thread). Render them with the
        // original BigTextStyle path so the invite-tap navigation still lands
        // on the /direct/ panel via the existing JS listener.
        if (isInvite) {
            return renderInviteNotification(ctx, data, messageId);
        }

        if (roomId == null || roomId.isEmpty()) {
            Log.w(TAG, "msg: missing room_id, drop");
            return false;
        }

        NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
        if (nm == null) {
            Log.w(TAG, "msg: NotificationManager is null, abort");
            return false;
        }

        RoomMetadata meta = loadRoomMetadata(ctx, roomId);
        String channelId = meta.isDirect ? CHANNEL_ID_DM : CHANNEL_ID_GROUP;
        ensureMessageChannels(ctx, nm);

        String senderName = firstNonEmpty(
            data.get("sender_display_name"),
            mxidLocalPart(data.get("sender")),
            "Vojo"
        );
        String senderKey = firstNonEmpty(data.get("sender"), senderName);
        String body = firstNonEmpty(
            data.get("content_body"),
            PushStrings.messageFallback(ctx)
        );
        long timestamp = parseLong(data.get("content_sender_ts"), System.currentTimeMillis());
        if (timestamp <= 0L) timestamp = System.currentTimeMillis();

        // Process-kill recovery: if our in-memory cache is empty for this
        // room, try to recover the conversation history from the on-shade
        // notification posted by a prior process. Without this a cold-start
        // after kill would shrink the conversation back to a single message.
        seedCacheFromActiveNotification(ctx, nm, roomId);

        RoomMessageCache.Entry entry = new RoomMessageCache.Entry(
            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
        // before the JS bridge has ever run we fall through to a generic key
        // — MessagingStyle still renders correctly, just without an account-
        // specific self anchor.
        Person self = buildSelfPerson(ctx);

        NotificationCompat.MessagingStyle style = new NotificationCompat.MessagingStyle(self);
        String roomName = firstNonEmpty(data.get("room_name"), meta.name);
        boolean isGroup = !meta.isDirect;
        if (isGroup && !roomName.isEmpty()) {
            style.setConversationTitle(roomName);
        }
        style.setGroupConversation(isGroup);

        // 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;
            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
            ));
        }

        // Per-room id slot — every event in this room replaces in place.
        int notifId = roomNotifId(roomId);

        // PendingIntent must be distinct per re-render so FLAG_UPDATE_CURRENT
        // doesn't smash the prior intent's extras — but the request code is
        // stable per room so we don't leak intents.
        //
        // 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;
        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)
            .setAutoCancel(true)
            .setContentIntent(contentIntent)
            // Hook user-swipe so RoomMessageCache.clear runs and the next
            // push to this room starts a fresh conversation — without this,
            // the dismissed messages would re-surface inside the next
            // MessagingStyle re-render.
            .setDeleteIntent(buildDismissPendingIntent(ctx, roomId))
            .setGroup(GROUP_KEY)
            // Suppress re-alerting on every appended message — Android would
            // otherwise vibrate + sound + heads-up for each new event in an
            // active conversation, which is the exact UX regression
            // per-room MessagingStyle was supposed to fix. The first
            // notification in a room still alerts; subsequent updates are
            // visual only.
            .setOnlyAlertOnce(history.size() > 1)
            // 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)
            // 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
        // to true on missing/legacy snapshots (privacy-first), so reply
        // is opt-in via a confirmed cleartext flag rather than opt-out.
        if (!meta.isEncrypted) {
            builder.addAction(buildReplyAction(ctx, roomId));
        }

        dlog("msg: posting notif id=" + notifId + " channel=" + channelId
            + " historySize=" + history.size()
            + " notifsEnabled=" + nm.areNotificationsEnabled());
        boolean posted = false;
        try {
            nm.notify(notifId, builder.build());
            posted = true;
        } catch (SecurityException e) {
            Log.e(TAG, "msg: nm.notify threw SecurityException", e);
        }

        // Group summary keeps the per-room notifications collapsing under
        // a single shade entry on Android pre-N + the launcher unread dot
        // works off the summary on every Android. Posted on the DM channel
        // so it inherits the lighter-weight settings; the per-room
        // notification can still alert independently from its own channel.
        try {
            NotificationCompat.Builder summary = new NotificationCompat.Builder(ctx, channelId)
                .setSmallIcon(R.mipmap.ic_launcher)
                .setContentTitle("Vojo")
                .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) {
            Log.e(TAG, "msg: summary notify threw SecurityException", e);
        }
        return posted;
    }

    /**
     * Pre-MessagingStyle render path for m.room.member invites. They predate
     * the room they would belong to (the conversation doesn't exist yet, no
     * sender thread to anchor), so the BigTextStyle invite-card is still the
     * right shape. The per-event id slot is preserved so multiple invites
     * stack as separate entries.
     */
    private static boolean renderInviteNotification(
        Context ctx,
        Map<String, String> data,
        String messageId
    ) {
        String roomId = data.get("room_id");
        String eventId = data.get("event_id");

        String title = PushStrings.inviteTitle(ctx);
        String body = PushStrings.inviteBody(ctx, humanInviter(data), data.get("room_name"));

        Intent launchIntent = new Intent(ctx, MainActivity.class);
        launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        launchIntent.putExtra("google.message_id", messageId != null ? messageId : "");
        for (Map.Entry<String, String> e : data.entrySet()) {
            launchIntent.putExtra(e.getKey(), e.getValue());
        }
        String uniqueKey = eventId != null ? eventId : (roomId != null ? roomId : "vojo");
        int requestCode = uniqueKey.hashCode();
        int flags = PendingIntent.FLAG_UPDATE_CURRENT
            | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
        PendingIntent pendingIntent = PendingIntent.getActivity(ctx, requestCode, launchIntent, flags);

        NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
        if (nm == null) {
            Log.w(TAG, "invite: NotificationManager is null, abort");
            return false;
        }
        // Invites route to the DM channel — they almost always graduate into
        // a DM and the lighter settings match their relatively quiet UX.
        ensureMessageChannels(ctx, nm);

        NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, CHANNEL_ID_DM)
            .setSmallIcon(R.mipmap.ic_launcher)
            .setContentTitle(title)
            .setContentText(body)
            .setStyle(new NotificationCompat.BigTextStyle().bigText(body))
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .setGroup(GROUP_KEY)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setCategory(NotificationCompat.CATEGORY_MESSAGE);

        int notifId = uniqueKey.hashCode();
        if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
        try {
            nm.notify(notifId, builder.build());
        } catch (SecurityException e) {
            Log.e(TAG, "invite: nm.notify threw SecurityException", e);
            return false;
        }
        try {
            NotificationCompat.Builder summary = new NotificationCompat.Builder(ctx, CHANNEL_ID_DM)
                .setSmallIcon(R.mipmap.ic_launcher)
                .setContentTitle("Vojo")
                .setContentText(PushStrings.messagesFallback(ctx))
                .setGroup(GROUP_KEY)
                .setGroupSummary(true)
                // 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) {
            Log.e(TAG, "invite: summary notify threw SecurityException", e);
        }
        return true;
    }

    /**
     * Dismiss the per-room MessagingStyle notification and drop the in-memory
     * cache. Called from MarkAsReadReceiver after a successful read receipt
     * PUT and from PollingPlugin.dismissRoom when JS observes a server-side
     * receipt taking unread count to zero on another device.
     */
    static void dismissRoomNotification(Context ctx, String roomId) {
        if (roomId == null || roomId.isEmpty()) return;
        RoomMessageCache.clear(roomId);
        NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
        if (nm == null) return;
        try {
            nm.cancel(roomNotifId(roomId));
        } catch (Throwable t) {
            Log.w(TAG, "dismiss: nm.cancel threw room=" + roomId, t);
        }
    }

    static int roomNotifId(String roomId) {
        int id = roomId.hashCode();
        // SUMMARY_NOTIFICATION_ID is reserved (Integer.MIN_VALUE); empty
        // String hashCode() = 0 also leaves no native ambiguity but we
        // bump it anyway so the slot is deterministic.
        if (id == SUMMARY_NOTIFICATION_ID) id += 1;
        return id;
    }

    private static PendingIntent buildDismissPendingIntent(Context ctx, String roomId) {
        Intent intent = new Intent(ctx, NotificationDismissReceiver.class)
            .setAction(NotificationDismissReceiver.ACTION_NOTIFICATION_DISMISSED)
            .putExtra(NotificationDismissReceiver.EXTRA_ROOM_ID, roomId);
        int requestCode = ("dismiss_" + roomId).hashCode();
        int flags = PendingIntent.FLAG_UPDATE_CURRENT
            | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
        return PendingIntent.getBroadcast(ctx, requestCode, intent, flags);
    }

    private static NotificationCompat.Action buildMarkAsReadAction(
        Context ctx, String roomId, String eventId
    ) {
        Intent intent = new Intent(ctx, MarkAsReadReceiver.class)
            .setAction(MarkAsReadReceiver.ACTION_MARK_AS_READ)
            .putExtra(MarkAsReadReceiver.EXTRA_ROOM_ID, roomId);
        if (eventId != null && !eventId.isEmpty()) {
            intent.putExtra(MarkAsReadReceiver.EXTRA_EVENT_ID, eventId);
        }
        int requestCode = ("read_" + roomId).hashCode();
        int flags = PendingIntent.FLAG_UPDATE_CURRENT
            | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
        PendingIntent pi = PendingIntent.getBroadcast(ctx, requestCode, intent, flags);
        return new NotificationCompat.Action.Builder(
            R.mipmap.ic_launcher,
            PushStrings.markAsReadAction(ctx),
            pi
        )
            .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
            .setShowsUserInterface(false)
            .build();
    }

    /**
     * Inline reply action with RemoteInput. Tapping the action opens an
     * in-shade text field; submit fires the receiver and PUTs the message
     * as `m.room.message` from the cleartext path. Only attached for
     * non-encrypted rooms (see caller gate); E2EE replies need keys the
     * Java side does not have.
     *
     * MutabilityCompat: RemoteInput requires the PendingIntent to be
     * MUTABLE so the input bundle can be attached at submit time. We OR
     * FLAG_MUTABLE on API 31+ where the immutable default would otherwise
     * cause the receiver to fire without any RemoteInput payload.
     */
    private static NotificationCompat.Action buildReplyAction(Context ctx, String roomId) {
        Intent intent = new Intent(ctx, ReplyReceiver.class)
            .setAction(ReplyReceiver.ACTION_REPLY)
            .putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId);
        int requestCode = ("reply_" + roomId).hashCode();
        int flags = PendingIntent.FLAG_UPDATE_CURRENT;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            flags |= PendingIntent.FLAG_MUTABLE;
        }
        PendingIntent pi = PendingIntent.getBroadcast(ctx, requestCode, intent, flags);
        RemoteInput remoteInput = new RemoteInput.Builder(ReplyReceiver.KEY_TEXT_REPLY)
            .setLabel(PushStrings.replyHint(ctx))
            .build();
        return new NotificationCompat.Action.Builder(
            R.mipmap.ic_launcher,
            PushStrings.replyAction(ctx),
            pi
        )
            .addRemoteInput(remoteInput)
            .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
            .setShowsUserInterface(false)
            // Allow Wear / Android Auto auto-replies for accessibility.
            .setAllowGeneratedReplies(true)
            .build();
    }

    /**
     * Append a self-Person message to the room's MessagingStyle cache and
     * re-post the notification with the new entry visible. Used by
     * ReplyReceiver for instant optimistic feedback before the HTTP PUT
     * completes — the user sees their text in the conversation bubble
     * immediately, the live sync echo eventually replaces it with the
     * server-authoritative version when they next open the app.
     *
     * Returns true iff the notification re-render succeeded.
     */
    static boolean appendOutgoingMessage(
        Context ctx, String roomId, String body, long timestamp
    ) {
        if (roomId == null || roomId.isEmpty() || body == null || body.isEmpty()) {
            return false;
        }
        NotificationManager nm = (NotificationManager)
            ctx.getSystemService(Context.NOTIFICATION_SERVICE);
        if (nm == null) return false;
        RoomMetadata meta = loadRoomMetadata(ctx, roomId);
        String channelId = meta.isDirect ? CHANNEL_ID_DM : CHANNEL_ID_GROUP;
        ensureMessageChannels(ctx, nm);

        // Re-seed from active notification before append so the new self
        // message lands at the tail of any in-flight conversation history
        // (covers the cold-render-after-kill case for outgoing replies).
        seedCacheFromActiveNotification(ctx, nm, roomId);

        RoomMessageCache.Entry self = new RoomMessageCache.Entry(
            /*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;
        if (isGroup && !meta.name.isEmpty()) {
            style.setConversationTitle(meta.name);
        }
        style.setGroupConversation(isGroup);
        Person lastNonSelfSender = null;
        for (RoomMessageCache.Entry e : history) {
            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
            ));
        }

        Intent launchIntent = new Intent(ctx, MainActivity.class);
        launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        launchIntent.putExtra("google.message_id", "");
        launchIntent.putExtra("room_id", roomId);
        // 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)
            .setAutoCancel(true)
            .setContentIntent(contentIntent)
            .setDeleteIntent(buildDismissPendingIntent(ctx, roomId))
            .setGroup(GROUP_KEY)
            // Always silent — sending a reply must not re-alert the user.
            .setOnlyAlertOnce(true)
            .setLocusId(new LocusIdCompat(roomId))
            .setWhen(timestamp)
            .setShowWhen(true)
            .setPriority(NotificationCompat.PRIORITY_LOW)
            .setCategory(NotificationCompat.CATEGORY_MESSAGE)
            // 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));
        }

        try {
            nm.notify(roomNotifId(roomId), builder.build());
            return true;
        } catch (SecurityException e) {
            Log.e(TAG, "outgoing: nm.notify threw SecurityException", e);
            return false;
        }
    }

    private static Person buildSelfPerson(Context ctx) {
        SharedPreferences prefs = ctx.getSharedPreferences(
            VojoPollWorker.PREFS, Context.MODE_PRIVATE);
        String userId = prefs.getString(VojoPollWorker.KEY_USER_ID, null);
        Person.Builder b = new Person.Builder()
            .setName(PushStrings.selfName(ctx));
        if (userId != null && !userId.isEmpty()) {
            b.setKey(userId);
            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
     * history instead of collapsing back to a single message. No-op when the
     * cache already has entries for this room (the in-memory store is the
     * source of truth in the normal hot path).
     */
    private static void seedCacheFromActiveNotification(
        Context ctx, NotificationManager nm, String roomId
    ) {
        try {
            StatusBarNotification[] active = nm.getActiveNotifications();
            int slot = roomNotifId(roomId);
            for (StatusBarNotification sbn : active) {
                if (sbn.getTag() != null) continue;
                if (sbn.getId() != slot) continue;
                Notification posted = sbn.getNotification();
                if (posted == null) continue;
                NotificationCompat.MessagingStyle existing =
                    NotificationCompat.MessagingStyle
                        .extractMessagingStyleFromNotification(posted);
                if (existing == null) return;
                List<RoomMessageCache.Entry> entries = new java.util.ArrayList<>();
                for (NotificationCompat.MessagingStyle.Message m : existing.getMessages()) {
                    Person p = m.getPerson();
                    String name = p != null && p.getName() != null
                        ? p.getName().toString() : "";
                    String key = p != null ? p.getKey() : null;
                    String body = m.getText() != null ? m.getText().toString() : "";
                    // 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
                    ));
                }
                RoomMessageCache.seedIfAbsent(roomId, entries);
                return;
            }
        } catch (Throwable t) {
            Log.w(TAG, "seed: extractMessagingStyleFromNotification failed", t);
        }
    }

    /**
     * Snapshot of per-room metadata bridged from JS via
     * PollingPlugin.saveRoomNames (which now accepts both a legacy
     * room_id → name string map AND a structured shape including isDirect
     * and isEncrypted). isEncrypted controls whether the inline reply
     * action is offered — we can't sign + encrypt without keys on the Java
     * side, so encrypted rooms get a read-only notification.
     */
    private static final class RoomMetadata {
        final String name;
        final boolean isDirect;
        final boolean isEncrypted;
        final String avatarMxc;

        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, 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, true, null);
        }
        try {
            JSONObject map = new JSONObject(raw);
            if (!map.has(roomId) || map.isNull(roomId)) {
                return new RoomMetadata("", true, true, null);
            }
            // Legacy shape: { roomId: "Display name" }. New shape:
            // { roomId: { name: "Display name", isDirect: bool,
            //             isEncrypted: bool } }. Parse tolerantly so an APK
            // that still has the old map written to prefs from a previous
            // boot doesn't lose channel routing.
            JSONObject obj = map.optJSONObject(roomId);
            if (obj != null) {
                String name = obj.optString("name", "");
                boolean isDirect = obj.optBoolean("isDirect", true);
                // Default encrypted=true on missing flag: refusing to offer
                // reply on a falsely-flagged-encrypted room is harmless;
                // offering reply on a truly-encrypted room sends cleartext
                // into the timeline, which is a privacy leak. The conservative
                // direction is to assume encryption.
                boolean isEncrypted = obj.optBoolean("isEncrypted", true);
                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
            // higher-importance channel (vojo_messages_dm_v1 is HIGH, group
            // is DEFAULT) — over-alerting on a misclassified group is a less
            // bad failure than under-alerting on a misclassified DM, which
            // would silently swallow a personal message during the brief
            // post-fresh-install window before the JS bridge dumps metadata.
            // 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, null);
        } catch (Throwable t) {
            return new RoomMetadata("", true, true, null);
        }
    }

    /**
     * Missed-call notification renderer for polling fallback delivery. By the
     * time VojoPollWorker observes an `m.rtc.notification` ring event (15-min
     * cadence), the 30-second ring lifetime is always over — rendering a live
     * CallStyle would phantom-ring a long-dead call. Instead we post a regular
     * notification "Пропущенный звонок от X" so the user knows somebody tried
     * to call.
     *
     * Notification id is per-event (eventId.hashCode()), distinct from the
     * per-room slot renderMessageNotification uses. Each missed ring stacks
     * as its own entry — a single room could have multiple missed calls
     * shown side-by-side, matching the WhatsApp/Telegram missed-call shade
     * UX. Cross-source dedup (FCM → polling) is covered by NotificationDedup
     * at the eventId level, not by an id-slot collapse.
     */
    static boolean renderMissedCallNotification(Context ctx, Map<String, String> data) {
        String roomId = data.get("room_id");
        String eventId = data.get("event_id");
        if (roomId == null || eventId == null) {
            Log.w(TAG, "missed-call: missing roomId/eventId, abort");
            return false;
        }

        NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
        if (nm == null) {
            Log.w(TAG, "missed-call: NotificationManager is null, abort");
            return false;
        }

        // Reuse a message channel — missed-call surfaces as a regular shade
        // entry, not a live ring. Routing it to vojo_calls_v2 would inherit
        // the bypass-DnD + ringtone + 20-pulse vibration channel settings,
        // which is wrong for a stale post-facto miss. We pick the DM channel
        // because Vojo's only call surface today is 1-to-1 DM calls; if
        // group calls land in the future this should mirror the
        // renderMessageNotification routing.
        ensureMessageChannels(ctx, nm);
        String missedCallChannel = CHANNEL_ID_DM;

        String callerName = firstNonEmpty(
            data.get("sender_display_name"),
            data.get("room_name"),
            mxidLocalPart(data.get("sender")),
            "Vojo"
        );
        String title = PushStrings.missedCallTitle(ctx);
        String body = PushStrings.missedCallBody(ctx, callerName);

        Intent launchIntent = new Intent(ctx, MainActivity.class);
        launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        // Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
        // on `google.message_id` existence; empty string satisfies the gate.
        launchIntent.putExtra("google.message_id", "");
        launchIntent.putExtra("room_id", roomId);
        // Intentionally NO `notif_event_id` extra — that's the call-tap signal
        // for the live-call routing branch in usePushNotifications (Answer /
        // Decline / FSI). Missed calls just open the room.

        int requestCode = eventId.hashCode();
        int flags = PendingIntent.FLAG_UPDATE_CURRENT
            | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
        PendingIntent pendingIntent = PendingIntent.getActivity(ctx, requestCode, launchIntent, flags);

        int notifId = eventId.hashCode();
        if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;

        NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, missedCallChannel)
            .setSmallIcon(R.mipmap.ic_launcher)
            .setContentTitle(title)
            .setContentText(body)
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .setGroup(GROUP_KEY)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setCategory(NotificationCompat.CATEGORY_MISSED_CALL);

        dlog("missed-call: posting notif id=" + notifId + " caller=" + callerName);
        boolean posted = false;
        try {
            nm.notify(notifId, builder.build());
            posted = true;
        } catch (SecurityException e) {
            Log.e(TAG, "missed-call: nm.notify threw SecurityException", e);
        }
        return posted;
    }

    // ────────────────────────────────────────────────────────────────────
    // Registry operations (public to package)
    // ────────────────────────────────────────────────────────────────────

    /**
     * Seed or refresh a registry entry. Returns true if the entry was stored,
     * false if rejected by a live tombstone (remove-wins consistency).
     *
     * Idempotent when the eventId is already present. Metadata merge is
     * append-only: FCM payload typically carries richer fields
     * (sender_display_name, content_sender_ts, content_lifetime) than a JS
     * MatrixEvent-derived upsert, so a later upsert for the same eventId
     * preserves fields the first-observed payload already had.
     *
     * Latest-wins per room: native notification identity is room-scoped
     * (tag=call_<roomId>), so a second upsert for a different eventId in
     * the same DM evicts the prior entry and tombstones it — the newer
     * ring owns the single slot and its Answer/Decline PendingIntents bind
     * to its eventId, not to the stale one's.
     *
     * All reads and mutations of ringRegistry + ringTombstones run under
     * registryLock so tombstone-check → eviction → put stays a single atomic
     * section relative to concurrent removeIncomingRing callers.
     */
    static boolean upsertIncomingRing(Map<String, String> data, String messageId) {
        String eventId = data.get("event_id");
        if (eventId == null || eventId.isEmpty()) {
            Log.w(TAG, "upsert: missing event_id, drop");
            return false;
        }
        String roomId = data.get("room_id");
        synchronized (registryLock) {
            purgeExpiredTombstones();
            Long tombstoneExpiry = ringTombstones.get(eventId);
            if (tombstoneExpiry != null && tombstoneExpiry > System.currentTimeMillis()) {
                dlog("upsert: tombstoned event=" + eventId + " until=" + tombstoneExpiry);
                return false;
            }

            // Latest-wins per room, compared by content_sender_ts. A late
            // FCM retry / reordered /sync / decrypt lag can deliver an older
            // ring AFTER a newer same-room ring already settled in registry;
            // pure last-observed-wins would let the stale event evict the
            // newer one. We compare sender_ts: newer always wins; if the
            // incoming event is older than an existing same-room entry we
            // drop the incoming and tombstone its eventId so re-deliveries
            // stay rejected. When either side has no sender_ts (malformed
            // push payload) we fall back to last-observed (evict existing),
            // matching the prior behavior for that corner.
            if (roomId != null) {
                long newSenderTs = parseLong(data.get("content_sender_ts"), -1L);
                Iterator<Map.Entry<String, IncomingRing>> it = ringRegistry.entrySet().iterator();
                while (it.hasNext()) {
                    Map.Entry<String, IncomingRing> e = it.next();
                    if (e.getKey().equals(eventId)) continue;
                    IncomingRing other = e.getValue();
                    if (!roomId.equals(other.data.get("room_id"))) continue;
                    long otherSenderTs = parseLong(other.data.get("content_sender_ts"), -1L);
                    if (newSenderTs > 0 && otherSenderTs > 0 && newSenderTs < otherSenderTs) {
                        // Incoming is strictly older. Drop + tombstone it;
                        // keep existing newer ring intact.
                        long newLifetime = parseLong(
                            data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
                        ringTombstones.put(eventId,
                            System.currentTimeMillis() + 2 * newLifetime + RTC_LIFETIME_GRACE_MS);
                        dlog("upsert: drop stale same-room event=" + eventId
                            + " room=" + roomId + " incomingTs=" + newSenderTs
                            + " existingTs=" + otherSenderTs);
                        return false;
                    }
                    // Incoming is newer (or ts unknown on either side) — evict
                    // existing and tombstone its eventId.
                    long otherLifetime = parseLong(
                        other.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
                    ringTombstones.put(e.getKey(),
                        System.currentTimeMillis() + 2 * otherLifetime + RTC_LIFETIME_GRACE_MS);
                    dlog("upsert: evict older same-room entry event="
                        + e.getKey() + " room=" + roomId + " supersededBy=" + eventId);
                    it.remove();
                }
            }

            IncomingRing existing = ringRegistry.get(eventId);
            if (existing != null) {
                boolean mergedAny = false;
                for (Map.Entry<String, String> e : data.entrySet()) {
                    if (!existing.data.containsKey(e.getKey()) && e.getValue() != null) {
                        existing.data.put(e.getKey(), e.getValue());
                        mergedAny = true;
                    }
                }
                // messageId sits on the IncomingRing, not in data. JS seeds
                // with null; when FCM arrives later with a real messageId we
                // want to adopt it so the Answer PendingIntent's
                // google.message_id is non-empty. Only overwrite when
                // existing is absent — the first-observed FCM messageId wins.
                if (existing.messageId == null && messageId != null) {
                    existing.messageId = messageId;
                    mergedAny = true;
                }
                if (mergedAny) {
                    dlog("upsert: merged fields event=" + eventId);
                }
                return true;
            }
            ringRegistry.put(eventId, new IncomingRing(data, messageId, System.currentTimeMillis()));
            dlog("upsert: seed event=" + eventId + " room=" + roomId);
            return true;
        }
    }

    /**
     * Render every registry entry that is still live and not yet rendered.
     * Called from MainActivity.onPause. Idempotent: entries with renderedAt>0
     * are skipped.
     */
    static void renderRegistry(Context ctx) {
        java.util.List<IncomingRing> snapshot;
        long now;
        // Lock window: purge + snapshot only. renderOne posts Binder-heavy
        // notifications, which must never run under registryLock or a slow
        // NotificationManager callback could stall upserts.
        synchronized (registryLock) {
            if (ringRegistry.isEmpty()) return;
            now = System.currentTimeMillis();
            purgeExpiredTombstones();
            purgeExpiredEntries(now);
            snapshot = new java.util.ArrayList<>(ringRegistry.values());
        }
        dlog("render: walk registry size=" + snapshot.size());
        for (IncomingRing entry : snapshot) {
            // Skip entries already posted — renderOne only sets renderedAt on
            // a successful nm.notify, and cancelRenderedIncomingRings resets
            // it on onResume. Cooldown-driven "silent" re-alert is handled
            // inside renderOne via lastAlertedAt so we still post visual
            // (just silent) instead of dropping the surface entirely.
            if (entry.renderedAt > 0) continue;
            if (isExpired(entry, now)) continue;
            renderOne(ctx, entry);
        }
    }

    /**
     * Cancel native surfaces for all rendered entries and clear their
     * renderedAt flag. Registry entries persist — they represent live ring
     * state, not render history. Called from MainActivity.onResume. Uses an
     * extras-based ownership check to avoid cancelling a same-slot native
     * raised by a foreign path (different eventId in the same DM).
     */
    static void cancelRenderedIncomingRings(Context ctx) {
        java.util.List<IncomingRing> snapshot;
        java.util.Set<String> liveEventIds;
        synchronized (registryLock) {
            snapshot = new java.util.ArrayList<>(ringRegistry.values());
            liveEventIds = new java.util.HashSet<>(ringRegistry.keySet());
        }
        NotificationManager nm =
            (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
        if (nm == null) return;
        StatusBarNotification[] active;
        try {
            active = nm.getActiveNotifications();
        } catch (Throwable t) {
            Log.w(TAG, "cancelRendered: getActiveNotifications threw", t);
            active = new StatusBarNotification[0];
        }
        // 1) Cancel entries the registry knows it rendered and reset
        // renderedAt so the next onPause re-renders the surface (bg→fg→bg
        // toggle scenarios where the ring is still live). lastAlertedAt is
        // preserved — renderOne uses it as a cooldown to suppress ringtone
        // on a re-render within RE_ALERT_COOLDOWN_MS of the previous alert.
        for (IncomingRing entry : snapshot) {
            if (entry.renderedAt == 0) continue;
            String eventId = entry.data.get("event_id");
            String roomId = entry.data.get("room_id");
            if (eventId == null || roomId == null) {
                entry.renderedAt = 0;
                continue;
            }
            String tag = "call_" + roomId;
            int notifId = tag.hashCode();
            if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
            cancelWithOwnershipCheck(nm, active, tag, notifId, eventId);
            entry.renderedAt = 0;
        }
        // 2) Orphan sweep: cancel any active CallStyle carrying our
        // EXTRA_RING_EVENT_ID whose eventId is NOT in the current registry.
        // Covers the process-kill case where the static registry was cleared
        // but NotificationManager kept the notification visible, and the
        // foreign-owner-leftover case where an evicted entry's native is still
        // on screen. Foreign rings posted by other apps are ignored (no
        // matching extra), and our own rings still live in registry are left
        // for the owned-cancel loop above.
        for (StatusBarNotification sbn : active) {
            String tag = sbn.getTag();
            if (tag == null || !tag.startsWith("call_")) continue;
            Bundle extras = sbn.getNotification() != null
                ? sbn.getNotification().extras : null;
            String slotEventId = extras != null
                ? extras.getString(EXTRA_RING_EVENT_ID) : null;
            if (slotEventId == null) continue;
            if (liveEventIds.contains(slotEventId)) continue;
            try {
                nm.cancel(tag, sbn.getId());
                dlog("cancelRendered: orphan swept tag=" + tag
                    + " eventId=" + slotEventId);
            } catch (Throwable t) {
                Log.w(TAG, "cancelRendered: orphan cancel threw tag=" + tag, t);
            }
        }
    }

    /**
     * Remove a registry entry, tombstone the eventId, and cancel any native
     * surface we rendered for it. Called from JS bridge (atom REMOVE /
     * suppress paths), CallDeclineReceiver (native Decline action), and
     * CallCancelReceiver (expiry alarm).
     *
     * Tombstone window = 2 × entry lifetime + grace when the removed entry
     * is known (covers per-event content_lifetime variation); falls back to
     * 2 × RTC_DEFAULT_LIFETIME_MS + grace for remove-before-upsert calls
     * that have no entry to read lifetime from. Covers FCM retry window and
     * sender-clock skew.
     *
     * Registry+tombstone mutation runs under registryLock; Binder calls
     * (NotificationManager.getActiveNotifications, nm.cancel) run outside
     * the lock so a slow system callback cannot stall concurrent upserts.
     */
    static void removeIncomingRing(Context ctx, String eventId) {
        if (eventId == null || eventId.isEmpty()) return;
        IncomingRing removed;
        long tombstoneWindow;
        synchronized (registryLock) {
            // Opportunistic purge on every remove — without this, tombstones
            // grow unbounded if upserts stop flowing.
            purgeExpiredTombstones();
            removed = ringRegistry.remove(eventId);
            long lifetime = removed != null
                ? parseLong(removed.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS)
                : RTC_DEFAULT_LIFETIME_MS;
            tombstoneWindow = 2 * lifetime + RTC_LIFETIME_GRACE_MS;
            ringTombstones.put(eventId, System.currentTimeMillis() + tombstoneWindow);
        }
        dlog("remove: event=" + eventId + " had=" + (removed != null)
            + " tombstoneWindow=" + tombstoneWindow);
        if (removed == null || removed.renderedAt == 0) return;
        String roomId = removed.data.get("room_id");
        if (roomId == null) return;
        String tag = "call_" + roomId;
        int notifId = tag.hashCode();
        if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
        NotificationManager nm =
            (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
        if (nm == null) return;
        StatusBarNotification[] active;
        try {
            active = nm.getActiveNotifications();
        } catch (Throwable t) {
            Log.w(TAG, "remove: getActiveNotifications threw", t);
            return;
        }
        cancelWithOwnershipCheck(nm, active, tag, notifId, eventId);
    }

    private static void cancelWithOwnershipCheck(
        NotificationManager nm,
        StatusBarNotification[] active,
        String tag,
        int notifId,
        String expectedEventId
    ) {
        for (StatusBarNotification sbn : active) {
            if (!tag.equals(sbn.getTag()) || sbn.getId() != notifId) continue;
            Bundle extras = sbn.getNotification() != null
                ? sbn.getNotification().extras : null;
            String slotEventId = extras != null
                ? extras.getString(EXTRA_RING_EVENT_ID) : null;
            if (expectedEventId.equals(slotEventId)) {
                try {
                    nm.cancel(tag, notifId);
                } catch (Throwable t) {
                    Log.w(TAG, "cancel threw tag=" + tag, t);
                }
            } else {
                dlog("cancel: foreign-owner slot tag=" + tag
                    + " slotEventId=" + slotEventId
                    + " expected=" + expectedEventId + " — leaving intact");
            }
            return;
        }
    }

    private static void renderOne(Context ctx, IncomingRing entry) {
        long now = System.currentTimeMillis();
        // Silent re-render if an alerting post landed less than
        // RE_ALERT_COOLDOWN_MS ago. lastAlertedAt persists across cancel so
        // a bg→fg→bg toggle within the cooldown stays silent even though
        // renderedAt is reset by cancelRenderedIncomingRings.
        boolean silent = entry.lastAlertedAt > 0
            && (now - entry.lastAlertedAt) < RE_ALERT_COOLDOWN_MS;
        boolean posted = postIncomingCallNotification(
            ctx, entry.data, entry.messageId,
            entry.data.get("event_id"), entry.seededAt, silent
        );
        // Only mark rendered on a successful nm.notify so the next render
        // cycle retries on transient Binder failure instead of leaving the
        // entry permanently "rendered" with no actual native surface.
        if (posted) {
            entry.renderedAt = now;
            // Only advance the cooldown baseline on actually-alerting posts.
            // Silent posts don't re-trigger ringtone so they don't count
            // toward the cooldown window; otherwise a chain of silent posts
            // would indefinitely defer the next allowed alert.
            if (!silent) entry.lastAlertedAt = now;
        }
    }

    // Caller must hold registryLock.
    private static void purgeExpiredTombstones() {
        long now = System.currentTimeMillis();
        Iterator<Map.Entry<String, Long>> it = ringTombstones.entrySet().iterator();
        while (it.hasNext()) {
            if (it.next().getValue() < now) it.remove();
        }
    }

    // Caller must hold registryLock.
    private static void purgeExpiredEntries(long now) {
        Iterator<Map.Entry<String, IncomingRing>> it = ringRegistry.entrySet().iterator();
        while (it.hasNext()) {
            Map.Entry<String, IncomingRing> e = it.next();
            if (isExpired(e.getValue(), now)) {
                dlog("purge: expired event=" + e.getKey());
                it.remove();
            }
        }
    }

    private static boolean isExpired(IncomingRing entry, long now) {
        long senderTs = parseLong(entry.data.get("content_sender_ts"), -1L);
        long lifetime = parseLong(entry.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
        long baseTs = (senderTs > 0) ? senderTs : entry.seededAt;
        return baseTs + lifetime + RTC_LIFETIME_GRACE_MS < now;
    }

    // ────────────────────────────────────────────────────────────────────
    // Post path (shared between FCM-direct and registry-render)
    // ────────────────────────────────────────────────────────────────────

    /**
     * Package-private, static — posts the CallStyle incoming-call notification.
     * All entry points (FCM-direct bg, registry-render) funnel through here so
     * channel creation, action intents, ownership extras, and expiry alarm
     * stay in lock-step.
     *
     * Returns true iff nm.notify succeeded. Callers (renderOne) use this to
     * gate setting renderedAt — a false return leaves the entry unrendered so
     * the next onPause cycle retries.
     *
     * ringEventId: tagged onto the notification's extras for ownership check
     * on cancel (same-DM foreign rings must not be killed by our cancels).
     * fallbackBaseTs: baseline used for expiry alarm when the payload lacks
     * `content_sender_ts`. Callers pass `now` for FCM-direct (ring just
     * arrived) or the registry entry's seededAt (ring was seeded earlier,
     * alarm must target true expiry).
     * silent: when true, attaches builder.setSilent(true) so the post goes
     * out without ringtone / vibration / heads-up, keeping the visual
     * banner. Used by renderOne for the cooldown-gated re-render path.
     */
    static boolean postIncomingCallNotification(
        Context ctx,
        Map<String, String> data,
        String messageId,
        String ringEventId,
        long fallbackBaseTs,
        boolean silent
    ) {
        String roomId = data.get("room_id");
        String notifEventId = data.get("event_id");
        if (roomId == null || notifEventId == null) {
            Log.w(TAG, "call: missing roomId/eventId, abort");
            return false;
        }

        NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
        if (nm == null) {
            Log.w(TAG, "call: NotificationManager is null, abort");
            return false;
        }

        ensureCallChannel(ctx, nm);

        String callerName = firstNonEmpty(
            data.get("sender_display_name"),
            data.get("room_name"),
            mxidLocalPart(data.get("sender")),
            "Vojo"
        );
        String tag = "call_" + roomId;
        int notifId = tag.hashCode();
        if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;

        // Different request codes per action — FLAG_UPDATE_CURRENT would otherwise
        // rewrite the previously-stored extras across answer/decline/launch intents.
        int answerReq = ("ans_" + notifEventId).hashCode();
        int declineReq = ("dec_" + notifEventId).hashCode();
        int launchReq = ("open_" + notifEventId).hashCode();

        PendingIntent answerPI = buildActionPI(
            ctx, answerReq, "answer", roomId, notifEventId, messageId
        );
        PendingIntent declinePI = buildDeclineBroadcastPI(
            ctx, declineReq, roomId, notifEventId, tag, notifId
        );
        PendingIntent launchPI = buildActionPI(
            ctx, launchReq, null, roomId, notifEventId, messageId
        );

        Person caller = new Person.Builder().setName(callerName).build();

        Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);

        NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, CALL_CHANNEL_ID)
            .setSmallIcon(R.mipmap.ic_launcher)
            .setCategory(NotificationCompat.CATEGORY_CALL)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setOngoing(true)
            .setAutoCancel(false)
            // Suppress re-alert on same-(tag, id) updates (e.g., same-ring
            // re-render without cancel between). Fresh alert still fires after
            // a cancel + repost cycle — Android treats that as a new post.
            .setOnlyAlertOnce(true)
            .setContentIntent(launchPI)
            // Full-screen wakeup over lockscreen — the "real" incoming-call UX.
            // Also the only reliable way to satisfy AOSP's checkDisqualifyingFeatures
            // gate for CallStyle on API 31+; Samsung OneUI specifically rejects
            // CallStyle with `fgs=false` + `FullScreenIntent=null`.
            .setFullScreenIntent(launchPI, true)
            .setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declinePI, answerPI));

        if (ringEventId != null) {
            Bundle extras = new Bundle();
            extras.putString(EXTRA_RING_EVENT_ID, ringEventId);
            builder.addExtras(extras);
        }
        if (silent) builder.setSilent(true);

        // Builder-level sound/vibration are ignored on API 26+ once the
        // channel has its own settings — we configure both on the channel in
        // ensureCallChannel() and skip the redundant builder calls here.
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
            if (ringtoneUri != null) builder.setSound(ringtoneUri);
            builder.setVibrate(buildRingVibrationPattern());
        }

        dlog("call: posting notif tag=" + tag + " id=" + notifId
            + " channel=" + CALL_CHANNEL_ID + " notifsEnabled=" + nm.areNotificationsEnabled()
            + " ringEventId=" + ringEventId);
        try {
            nm.notify(tag, notifId, builder.build());
            dlog("call: nm.notify returned OK");
        } catch (Throwable t) {
            Log.e(TAG, "call: nm.notify threw", t);
            return false;
        }

        // Cross-source dedup: mark the ring event in NotificationDedup so the
        // polling Worker — if it later sees the same `m.rtc.notification` in
        // /notifications — does not post a "Missed call" notification for a
        // ring that FCM already surfaced live (answered, declined, or
        // expired). The CallStyle path uses a room-scoped tag/id slot, not
        // eventId.hashCode(), so without this mark Android's NotificationManager
        // replace would not collapse the two surfaces.
        if (ringEventId != null && !ringEventId.isEmpty()) {
            NotificationDedup.markNotified(ctx, ringEventId);
        }
        // Session-level mark for re-ring suppression. Same key shape as
        // onMessageReceived above so the two entry points are consistent.
        String sessionId = extractCallSessionId(data);
        if (sessionId != null) {
            NotificationDedup.markNotified(ctx,
                compositeCallDedupKey(roomId, sessionId));
        }

        try {
            scheduleCallNotificationExpiry(ctx, data, tag, notifId, fallbackBaseTs);
        } catch (Throwable t) {
            Log.e(TAG, "call: scheduleCallNotificationExpiry threw", t);
            // Not a post failure — alarm is a separate concern, nm.notify
            // already succeeded. Leave return=true so renderedAt is set.
        }
        return true;
    }

    private static PendingIntent buildActionPI(
        Context ctx,
        int requestCode,
        String callAction,
        String roomId,
        String notifEventId,
        String messageId
    ) {
        Intent intent = new Intent(ctx, MainActivity.class)
            .setAction(Intent.ACTION_VIEW)
            .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        // Capacitor PushNotificationsPlugin gates `pushNotificationActionPerformed`
        // on bundle.containsKey("google.message_id") — without it the JS listener
        // silently never fires and the pendingCallActionAtom stays unset.
        intent.putExtra("google.message_id", messageId != null ? messageId : "");
        intent.putExtra("room_id", roomId);
        intent.putExtra("notif_event_id", notifEventId);
        if (callAction != null) intent.putExtra("call_action", callAction);
        int flags = PendingIntent.FLAG_UPDATE_CURRENT
            | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
        return PendingIntent.getActivity(ctx, requestCode, intent, flags);
    }

    private static PendingIntent buildDeclineBroadcastPI(
        Context ctx,
        int requestCode,
        String roomId,
        String notifEventId,
        String notifTag,
        int notifId
    ) {
        Intent intent = new Intent(ctx, CallDeclineReceiver.class)
            .setAction(CallDeclineReceiver.ACTION_DECLINE_CALL)
            .putExtra(CallDeclineReceiver.EXTRA_ROOM_ID, roomId)
            .putExtra(CallDeclineReceiver.EXTRA_NOTIF_EVENT_ID, notifEventId)
            .putExtra(CallDeclineReceiver.EXTRA_NOTIF_TAG, notifTag)
            .putExtra(CallDeclineReceiver.EXTRA_NOTIF_ID, notifId);
        int flags = PendingIntent.FLAG_UPDATE_CURRENT
            | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
        return PendingIntent.getBroadcast(ctx, requestCode, intent, flags);
    }

    /**
     * Create both message channels (DM + group rooms) and the umbrella channel
     * group, idempotently. Lazy creation from the service covers the
     * fresh-install + killed-process race: FCM may deliver before the app has
     * ever been launched (so the JS lifecycle effect never ran), in which
     * case the channels don't exist yet and nm.notify would silently drop.
     * Same race covers VojoPollWorker — Workers can fire before MainActivity
     * ever runs after a reboot.
     *
     * Splitting DM and group into separate channels lets users mute group
     * chat noise via OS settings without losing personal-message alerts —
     * the WhatsApp/Telegram convention. Both channels live under a shared
     * NotificationChannelGroup so Settings → Notifications collapses them
     * under one "Chats" header.
     *
     * Legacy migration: the single-bucket `vojo_messages` channel is deleted
     * on first creation of the v1 channels so it doesn't linger in OS
     * settings. Users who had customised the legacy channel (e.g. muted it)
     * lose that preference on upgrade — acceptable because the new split
     * channels start from sensible defaults and the OS surfaces them as a
     * group for easy re-customisation. Bumped to _v2 in the future if any
     * setting changes (channel config is immutable post-creation on API 26+).
     */
    private static void ensureMessageChannels(Context ctx, NotificationManager nm) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;

        // One-shot legacy delete. Skipped after first run because the
        // getNotificationChannel call cheaply returns null on subsequent
        // invocations.
        if (nm.getNotificationChannel(LEGACY_MESSAGE_CHANNEL_ID) != null) {
            dlog("msg: deleting legacy channel " + LEGACY_MESSAGE_CHANNEL_ID);
            nm.deleteNotificationChannel(LEGACY_MESSAGE_CHANNEL_ID);
        }

        // Group must exist before channels are bound to it; createNotificationChannelGroup
        // is idempotent for the same id.
        try {
            NotificationChannelGroup grp = new NotificationChannelGroup(
                MESSAGE_CHANNEL_GROUP_ID,
                PushStrings.channelGroup(ctx)
            );
            nm.createNotificationChannelGroup(grp);
        } catch (Throwable t) {
            Log.w(TAG, "msg: createNotificationChannelGroup threw", t);
        }

        if (nm.getNotificationChannel(CHANNEL_ID_DM) == null) {
            dlog("msg: creating channel " + CHANNEL_ID_DM);
            NotificationChannel dm = new NotificationChannel(
                CHANNEL_ID_DM,
                PushStrings.channelDm(ctx),
                NotificationManager.IMPORTANCE_HIGH
            );
            dm.setDescription(PushStrings.channelDmDescription(ctx));
            dm.setGroup(MESSAGE_CHANNEL_GROUP_ID);
            dm.enableVibration(true);
            dm.enableLights(true);
            nm.createNotificationChannel(dm);
        }

        if (nm.getNotificationChannel(CHANNEL_ID_GROUP) == null) {
            dlog("msg: creating channel " + CHANNEL_ID_GROUP);
            NotificationChannel group = new NotificationChannel(
                CHANNEL_ID_GROUP,
                PushStrings.channelGroupRoom(ctx),
                NotificationManager.IMPORTANCE_DEFAULT
            );
            group.setDescription(PushStrings.channelGroupRoomDescription(ctx));
            group.setGroup(MESSAGE_CHANNEL_GROUP_ID);
            group.enableVibration(true);
            group.enableLights(true);
            nm.createNotificationChannel(group);
        }
    }

    private static void ensureCallChannel(Context ctx, NotificationManager nm) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
        if (nm.getNotificationChannel(CALL_CHANNEL_ID) != null) return;
        // Drop the pre-v2 channel on first creation of v2 so it doesn't linger
        // in Settings → Notifications (user-visible cruft) after the bump.
        if (nm.getNotificationChannel(LEGACY_CALL_CHANNEL_ID) != null) {
            dlog("call: deleting legacy channel " + LEGACY_CALL_CHANNEL_ID);
            nm.deleteNotificationChannel(LEGACY_CALL_CHANNEL_ID);
        }
        dlog("call: creating channel " + CALL_CHANNEL_ID);
        NotificationChannel channel = new NotificationChannel(
            CALL_CHANNEL_ID,
            "Incoming calls",
            NotificationManager.IMPORTANCE_HIGH
        );
        channel.setDescription("Incoming Vojo calls");
        channel.setBypassDnd(true);
        channel.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC);
        channel.enableVibration(true);
        // Channel patterns don't auto-repeat on API 26+ (Android won't loop a
        // NotificationChannel vibrationPattern the way Vibrator.vibrate(repeat=0)
        // would). We pre-expand the pulse sequence to ~30s of alternating
        // on/off so the device keeps vibrating for the full ring lifetime
        // instead of buzzing twice and going silent.
        channel.setVibrationPattern(buildRingVibrationPattern());
        Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
        if (ringtoneUri != null) {
            AudioAttributes attrs = new AudioAttributes.Builder()
                .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
                .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
                .build();
            channel.setSound(ringtoneUri, attrs);
        }
        nm.createNotificationChannel(channel);
    }

    private static void scheduleCallNotificationExpiry(
        Context ctx,
        Map<String, String> data,
        String tag,
        int notifId,
        long fallbackBaseTs
    ) {
        long senderTs = parseLong(data.get("content_sender_ts"), -1L);
        long lifetime = parseLong(data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS);
        // Callers pass `now` for FCM-direct (ring just arrived) and
        // entry.seededAt for registry-render (ring was seeded earlier).
        long baseTs = (senderTs > 0) ? senderTs : fallbackBaseTs;
        long triggerAt = baseTs + lifetime + RTC_LIFETIME_GRACE_MS;

        AlarmManager am = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE);
        if (am == null) return;

        Intent cancelIntent = new Intent(ctx, CallCancelReceiver.class)
            .setAction(CallCancelReceiver.ACTION_CANCEL_CALL)
            .putExtra(CallCancelReceiver.EXTRA_NOTIF_TAG, tag)
            .putExtra(CallCancelReceiver.EXTRA_NOTIF_ID, notifId)
            .putExtra(CallCancelReceiver.EXTRA_NOTIF_EVENT_ID, data.get("event_id"));
        int flags = PendingIntent.FLAG_UPDATE_CURRENT
            | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
        PendingIntent pi = PendingIntent.getBroadcast(ctx, notifId, cancelIntent, flags);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi);
        } else {
            am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi);
        }
    }

    // ~30 seconds of alternating 1000ms on / 500ms off — long enough to cover
    // the RTC_DEFAULT_LIFETIME_MS ring window. Channels can't repeat patterns,
    // so the sequence is pre-expanded.
    private static long[] buildRingVibrationPattern() {
        final int pulses = 20;
        long[] pattern = new long[pulses * 2 + 1];
        pattern[0] = 0;
        for (int i = 0; i < pulses; i++) {
            pattern[i * 2 + 1] = 1000L;
            pattern[i * 2 + 2] = 500L;
        }
        return pattern;
    }

    private static long parseLong(String s, long fallback) {
        if (s == null) return fallback;
        try {
            return Long.parseLong(s);
        } catch (NumberFormatException e) {
            return fallback;
        }
    }

    private static String firstNonEmpty(String... values) {
        for (String v : values) {
            if (v != null && !v.isEmpty()) return v;
        }
        return "";
    }

    // Prefer the Sygnal-flattened display name; if that is absent, strip the
    // MXID to its local-part (`@alice:hs.tld` → `alice`) — a raw MXID in a
    // push body reads worse than a bare name. Returns empty when nothing
    // usable is present; PushStrings.inviteBody falls through to the
    // "no inviter" variant in that case.
    private static String humanInviter(Map<String, String> data) {
        String displayName = data.get("sender_display_name");
        if (displayName != null && !displayName.isEmpty()) return displayName;
        return mxidLocalPart(data.get("sender"));
    }

    // `@alice:hs.tld` → `alice`. Returns null if the input doesn't look like
    // a Matrix user id so callers can fall through to the next branch of
    // their firstNonEmpty chain. Used by the polling fallback's title
    // chain when the homeserver gave us only a raw sender MXID
    // (/notifications has no Sygnal-side profile resolution).
    //
    // Edge cases:
    //   null / ""        → null
    //   "alice"          → "alice" (no @, return verbatim)
    //   "@alice:hs.tld"  → "alice"
    //   "@alice"         → "alice" (no colon, strip sigil only)
    //   "@:host"         → null (empty local-part is not usable)
    //   "@"              → null
    private static String mxidLocalPart(String mxid) {
        if (mxid == null || mxid.isEmpty()) return null;
        if (mxid.charAt(0) != '@') return mxid;
        int colon = mxid.indexOf(':');
        if (colon > 1) return mxid.substring(1, colon);
        if (colon == 1) return null;
        if (mxid.length() > 1) return mxid.substring(1);
        return null;
    }
}
