docs(android): document MessagingStyle pipeline, channel split, callId-session dedup and edit-collapse defer rationale
This commit is contained in:
parent
de348eb4fc
commit
4b4454fa1d
1 changed files with 66 additions and 2 deletions
|
|
@ -71,7 +71,7 @@ Components:
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Worker | [`VojoPollWorker.java`](../../android/app/src/main/java/chat/vojo/app/VojoPollWorker.java) | Periodic fetch of `/notifications`, flattens response into Sygnal-shape `Map<String,String>`, routes message/invite → `renderMessageNotification`, RTC ring → `renderMissedCallNotification`. Skips events that are `read=true`, push-rule-suppressed (`actions` lacks `notify`), in NotificationDedup, or with `ts < watermark`. Foreground-gated: doesn't render system notifications while `MainActivity.isInForeground` (still consumes state). Saves a drain cursor when capped at `MAX_PAGES_PER_RUN`. |
|
| Worker | [`VojoPollWorker.java`](../../android/app/src/main/java/chat/vojo/app/VojoPollWorker.java) | Periodic fetch of `/notifications`, flattens response into Sygnal-shape `Map<String,String>`, routes message/invite → `renderMessageNotification`, RTC ring → `renderMissedCallNotification`. Skips events that are `read=true`, push-rule-suppressed (`actions` lacks `notify`), in NotificationDedup, or with `ts < watermark`. Foreground-gated: doesn't render system notifications while `MainActivity.isInForeground` (still consumes state). Saves a drain cursor when capped at `MAX_PAGES_PER_RUN`. |
|
||||||
| Bridge | [`PollingPlugin.java`](../../android/app/src/main/java/chat/vojo/app/PollingPlugin.java) | Capacitor plugin. JS calls `saveSession` (token + homeserver, seeds watermark on first use to skip historical backlog), `schedule(15)` (unique periodic worker), `saveRoomNames` (room-id → name cache), `cancel` (awaits WorkManager Operation completion) + `clearSession` on disable/logout. |
|
| Bridge | [`PollingPlugin.java`](../../android/app/src/main/java/chat/vojo/app/PollingPlugin.java) | Capacitor plugin. JS calls `saveSession` (token + homeserver, seeds watermark on first use to skip historical backlog), `schedule(15)` (unique periodic worker), `saveRoomNames` (room-id → name cache), `cancel` (awaits WorkManager Operation completion) + `clearSession` on disable/logout. |
|
||||||
| Renderers | [`VojoFirebaseMessagingService.java::renderMessageNotification`, `::renderMissedCallNotification`](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java) | Static, Context-parameterised so the Worker can post into the same notification id space as FCM. `eventId.hashCode()` slot — Android replaces in place when both paths deliver the same event AND both surfaces are still visible. After successful `nm.notify`, mark the event in NotificationDedup so the polling Worker doesn't re-surface it after the user dismisses an FCM-delivered one. |
|
| Renderers | [`VojoFirebaseMessagingService.java::renderMessageNotification`, `::renderMissedCallNotification`](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java) | Static, Context-parameterised so the Worker can post into the same notification id space as FCM. Message path uses a **per-room** `roomId.hashCode()` slot — every new event in a room appends to a MessagingStyle conversation rather than stacking as a separate card (see [MessagingStyle pipeline](#messagingstyle-pipeline) below). Missed-call path uses per-event slots so multiple missed rings stack. After successful `nm.notify`, mark the event in NotificationDedup so the polling Worker doesn't re-surface it after the user dismisses an FCM-delivered one. |
|
||||||
| Dedup | [`NotificationDedup.java`](../../android/app/src/main/java/chat/vojo/app/NotificationDedup.java) | Thread-safe shared LRU set of rendered event_ids. Written by both FCM service (background renders AND foreground-skipped events) and Worker (after successful render or foreground-skip). Bounded at 500 entries to comfortably exceed a single Worker run's worst case (`MAX_PAGES_PER_RUN × PAGE_LIMIT = 250`), persisted in `vojo_poll_state` SharedPreferences. |
|
| Dedup | [`NotificationDedup.java`](../../android/app/src/main/java/chat/vojo/app/NotificationDedup.java) | Thread-safe shared LRU set of rendered event_ids. Written by both FCM service (background renders AND foreground-skipped events) and Worker (after successful render or foreground-skip). Bounded at 500 entries to comfortably exceed a single Worker run's worst case (`MAX_PAGES_PER_RUN × PAGE_LIMIT = 250`), persisted in `vojo_poll_state` SharedPreferences. |
|
||||||
| JS plugin | [`src/app/plugins/polling.ts`](../../src/app/plugins/polling.ts) | `registerPlugin<PollingPluginIface>('Polling', { web: noop })`. Web has no analogue (SW already wakes for push) — fallback is a no-op. |
|
| JS plugin | [`src/app/plugins/polling.ts`](../../src/app/plugins/polling.ts) | `registerPlugin<PollingPluginIface>('Polling', { web: noop })`. Web has no analogue (SW already wakes for push) — fallback is a no-op. |
|
||||||
| Lifecycle | [`src/app/hooks/usePushNotifications.ts::usePushNotificationsLifecycle`](../../src/app/hooks/usePushNotifications.ts) | Reactive to `usePushEnabled()`. On mount with push enabled: `saveSession` + `schedule` + initial room-name dump. On `visibilitychange → visible`: re-`saveSession` (recovers a 401-cleared credentials slot without remount) + re-dump room names. On unmount or push disable: `cancel` + `clearSession`. |
|
| Lifecycle | [`src/app/hooks/usePushNotifications.ts::usePushNotificationsLifecycle`](../../src/app/hooks/usePushNotifications.ts) | Reactive to `usePushEnabled()`. On mount with push enabled: `saveSession` + `schedule` + initial room-name dump. On `visibilitychange → visible`: re-`saveSession` (recovers a 401-cleared credentials slot without remount) + re-dump room names. On unmount or push disable: `cancel` + `clearSession`. |
|
||||||
|
|
@ -171,7 +171,71 @@ Cleanups invoked symmetrically across every logout path:
|
||||||
the `SessionLoggedOut` listener, and the lifecycle effect's unmount all
|
the `SessionLoggedOut` listener, and the lifecycle effect's unmount all
|
||||||
call `polling.cancel()` + `polling.clearSession()`.
|
call `polling.cancel()` + `polling.clearSession()`.
|
||||||
|
|
||||||
## ADB wireless workflow
|
## MessagingStyle pipeline
|
||||||
|
|
||||||
|
Background-rendered message notifications use
|
||||||
|
`NotificationCompat.MessagingStyle` so multiple events in one room collapse
|
||||||
|
into an expandable conversation card (WhatsApp / Telegram convention)
|
||||||
|
rather than each event posting a separate banner. Notification id is
|
||||||
|
**per-room** (`roomId.hashCode()`), not per-event.
|
||||||
|
|
||||||
|
Components:
|
||||||
|
|
||||||
|
| Layer | File | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| Cache | [`RoomMessageCache.java`](../../android/app/src/main/java/chat/vojo/app/RoomMessageCache.java) | Thread-safe `ConcurrentHashMap<String, ArrayDeque<Entry>>` bounded at 20 messages × 200 rooms. Snapshot is taken INSIDE `compute()` so a concurrent FCM + Worker append on the same room can't race the copy. Mutated by both `VojoFirebaseMessagingService.renderMessageNotification` (FCM service path AND Worker path through the same static helper) and `appendOutgoingMessage` (ReplyReceiver echo). |
|
||||||
|
| Channels | `vojo_messages_dm_v1` (IMPORTANCE_HIGH) + `vojo_messages_group_v1` (IMPORTANCE_DEFAULT) under `NotificationChannelGroup("vojo_messages_v1")`. Legacy `vojo_messages` is deleted on first creation of v1. Channel split lets users mute group-room noise in OS settings without losing DM alerts. |
|
||||||
|
| Metadata snapshot | JS bridges `{roomId: {name, isDirect, isEncrypted}}` via `polling.saveRoomNames` → `KEY_ROOM_NAMES` in `vojo_poll_state`. `loadRoomMetadata` parses tolerantly (legacy `roomId: "name"` falls back to `isDirect=true, isEncrypted=true` for safety). Re-dump triggers: mount, visibility-change, `ClientEvent.AccountData` for `m.direct`, `RoomEvent.Timeline` filtered to `m.room.encryption`. |
|
||||||
|
| Process-kill recovery | On cache miss, `seedCacheFromActiveNotification` calls `NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification` on the on-shade `StatusBarNotification` to rebuild prior history. Survives process kill; fails gracefully to single-message conversation if the notification was also dismissed. |
|
||||||
|
| Receivers | [`MarkAsReadReceiver`](../../android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java) (POST `/_matrix/client/v3/rooms/{roomId}/receipt/m.read/{eventId}` + dismiss), [`NotificationDismissReceiver`](../../android/app/src/main/java/chat/vojo/app/NotificationDismissReceiver.java) (swipe → clear cache so the next push starts fresh), [`ReplyReceiver`](../../android/app/src/main/java/chat/vojo/app/ReplyReceiver.java) (RemoteInput → PUT `m.room.message` with `m.text` body + optimistic local echo). All read credentials from `vojo_poll_state` SharedPreferences (same lifecycle as `VojoPollWorker`). |
|
||||||
|
| Receipt-driven dismiss | JS `mx.on(RoomEvent.Receipt)` filters own-user receipts, checks `room.getUnreadNotificationCount(Total) === 0`, calls `polling.dismissRoom(roomId)` → native `nm.cancel + RoomMessageCache.clear`. Mirrors element-web's `Notifier.onRoomReceipt`. Killed-process dismiss is not covered (no JS context to observe the receipt) — acceptable: the next FCM push to that room renders a fresh conversation from cache-empty state. |
|
||||||
|
|
||||||
|
Why MessagingStyle vs the old per-event flow: 5 messages in one DM previously
|
||||||
|
produced 5 separate cards in the shade with redundant title/avatar. The
|
||||||
|
MessagingStyle conversation matches WhatsApp/Telegram UX and is the documented
|
||||||
|
Android pattern for messaging apps. See element-android's
|
||||||
|
`RoomGroupMessageCreator` for the canonical reference.
|
||||||
|
|
||||||
|
Why two channels (DM + group) and not per-conversation channels (the
|
||||||
|
fluffychat approach): per-conversation works for low-room-count clients but
|
||||||
|
proliferates user-visible settings entries on a Matrix client with dozens of
|
||||||
|
active rooms. Element-android sidesteps the question with a NOISY/SILENT
|
||||||
|
split based on push rules; we picked a middle ground — bucketed by DM vs
|
||||||
|
group room — which mirrors fluffychat's `directChats`/`groupChats`
|
||||||
|
NotificationChannelGroup setup.
|
||||||
|
|
||||||
|
Why reply action is gated on `!isEncrypted`: the Java path has no key
|
||||||
|
material to sign + encrypt outgoing replies with, so an inline reply in an
|
||||||
|
E2EE room would send cleartext (Synapse does not enforce the
|
||||||
|
"encrypted-only" rule, so the leak is real). The snapshot defaults to
|
||||||
|
`isEncrypted=true` on cache miss and the JS side re-dumps on
|
||||||
|
`m.room.encryption` state events so the action is dropped within seconds of
|
||||||
|
a room being switched to E2EE.
|
||||||
|
|
||||||
|
Why call-session composite dedup
|
||||||
|
(`compositeCallDedupKey(roomId, sessionId)`): the legacy per-eventId dedup
|
||||||
|
misses re-rings of the same call session because each ring is a fresh
|
||||||
|
`m.rtc.notification` event with a new event_id. We extract the parent call
|
||||||
|
event_id from `content.m.relates_to.event_id` (Worker JSON parse) /
|
||||||
|
`content_m.relates_to_event_id` (FCM Sygnal-flatten) and mark the composite
|
||||||
|
in NotificationDedup the moment we post the first CallStyle. Subsequent
|
||||||
|
ring events for the same session see the mark and skip silently. Mirrors
|
||||||
|
element-web's `getIncomingCallToastKey` pattern.
|
||||||
|
|
||||||
|
Why edit-collapse (`m.replace`) is **NOT implemented**: requires parsing
|
||||||
|
`content.m.relates_to.rel_type == "m.replace"` + finding the original event
|
||||||
|
in the per-room cache and replacing in place. The complication: FCM
|
||||||
|
payloads (Sygnal-flattened) encode nested keys inconsistently across
|
||||||
|
deployments (`content_m.relates_to_rel_type` vs
|
||||||
|
`content_m_relates_to_rel_type` vs dot-preserved variants), and the Worker
|
||||||
|
parses raw JSON cleanly while FCM hits one of the flattened shapes.
|
||||||
|
Asymmetric handling (Worker only) creates user-visible drift between
|
||||||
|
delivery paths. Real-world impact is low — users rarely edit
|
||||||
|
notification-flagged messages in the seconds-long window before they're
|
||||||
|
read — so the feature is deferred until we have a uniform key shape from
|
||||||
|
Sygnal config or until a real-world report justifies the parser complexity.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1. On the phone, enable Wireless debugging, tap "Pair device with pairing code" — note IP, port, 6-digit code.
|
1. On the phone, enable Wireless debugging, tap "Pair device with pairing code" — note IP, port, 6-digit code.
|
||||||
2. `adb pair <ip>:<pair-port> <code>`
|
2. `adb pair <ip>:<pair-port> <code>`
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue