Skip to content

Show a placeholder for messages this version can't process, and retain them - #2232

Merged
mpretty-cyro merged 6 commits into
devfrom
unsupported-message-bubble
Oct 8, 2026
Merged

mpretty-cyro merged 6 commits into
devfrom
unsupported-message-bubble

Conversation

@mpretty-cyro

@mpretty-cyro mpretty-cyro commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Contributor checklist

  • I have tested my contribution on these devices:
  • Virtual device Pixel 6, android-37 system image
  • My contribution is fully baked and ready to be merged as is

Description

Shows a placeholder for messages this client positively identifies as something it can't process, and retains the raw data so a newer version (or the future Session 2.0 database import) can process it. Matching changes: iOS session-foundation/session-ios#808, Desktop session-foundation/session-desktop#2025.

The distinction is the feature: only messages we positively identify get anything. A message that simply fails to decrypt still produces nothing, because it can't be attributed to anyone (spam, corruption or an attacker), and a bubble claims someone really sent you something.

What's detected

  • Newer wire format: one-to-one swarm data starting 0x00. v1 data is protobuf, which can never start with a zero byte. It's checked before decryption, in the one-to-one namespace only. Its sender is inside the encryption and the prefix is unauthenticated, so it gets no bubble, only the banner, and its bytes are retained invisibly.
  • Unknown type: the message decrypts, but Content holds a top-level field numbered above 18 and nothing this client can use. Every number up to 18 has been assigned at some point, including 2.0's msgId = 18. The same hand-written tag scan as the other clients runs over the decrypted bytes. Previously such content was rejected as an invalid message, because the "unknown message type" branch couldn't be reached. One-to-one and groups v2 only; communities are excluded.

What the user sees

  • Placeholder bubble: like the deleted-message bubble, muted and italic, with the text "This message can't be displayed. Update Session to view it.". It's incoming, or outgoing for our own synced messages (placed by dataMessage.syncTarget) and in groups. It's only added to a conversation that already exists and is visible: never a new conversation or message request, and it never un-hides one. Placeholders only offer "delete for me". An incoming placeholder notifies with the generic "new message" text.
  • Banner: green (?colorAccent) with black text at the top of Home, with a ✕ to dismiss. It's raised by a newer-format message ("Some messages can't be shown on this device…") or by an unknown type from our own other device that can't be placed ("One of your other devices is using a newer version…"). After dismissal it reappears only for a trigger at least 7 days later.

Storage and replay

  • Table: new unsupported_message, migration lokiV62 (83). Columns are identical across iOS, Android and Desktop so the future import reads one shape. sender and sent_timestamp_ms are stored for unknown types.
  • Limits: capped at 256 MB, oldest first, with newer-format rows evicted first. Rows follow disappearing-message expiry; an AFTER DELETE ON mms trigger removes a row with its placeholder, and markAsDeleted removes it explicitly. Push-delivered rows now use the push's millisecond timestamp and its swarm expiry (z).
  • Unsend: an unsend also deletes retained rows matching its author and timestamp, so a later replay can't restore a message its sender deleted.
  • Replay: retained rows are replayed through the normal receive path after an update. A message that now decodes replaces its placeholder at the same timestamp, without notifying again. Before a replay the row is detached from its placeholder, so a failure, or a crash midway, can't lose it. A failed replay keeps the row, stamped with this version, and logs the reason. The retention limits and the replay run at launch and on every return to the foreground (plus the limits after each insert),, never inside another job's transaction.

Needs doing before merge

  • Crowdin: the placeholder and banner strings are hard-coded English (marked FIXME).

Testing

  • Unit tests: testPlayDebugUnitTest 409/409, including the new ones covering the tag scan, detection, placement, banner state, budget and expiry, deletion via the trigger, the unsend delete, and a detached row surviving placeholder deletion. assemblePlayDebug passes.
  • Manual testing: seeded on a throw-away emulator (local hook, not in this PR) and checked visually.
  • Not covered by unit tests: native libsession can't load in JVM tests, so the decrypt layer and the reprocessor aren't unit-tested.
  • CI: currently failing on every Android PR, before compilation. Gradle can't download the JDK toolchain (api.foojay.io returns 400).

…n them

Messages the client positively identifies as unprocessable are now kept instead of
dropped. Random decryption failures still produce nothing.

- One-to-one data starting 0x00 is a newer wire format. It's retained without
  decrypting and raises an account-level "update" banner on Home, with no bubble,
  since its sender can't be known.
- Decrypted content with a top-level field above 18 and nothing usable gets a
  placeholder bubble in an existing conversation (never a new one), incoming or
  outgoing for our own syncs and group messages. Previously it was rejected as an
  invalid message.
- Raw swarm data is kept in `unsupported_message` (same schema on all clients, for the
  future 2.0 import), capped at 256 MB and expired with disappearing settings. An mms
  delete trigger keeps it in step with placeholder deletion.
- Retained messages are replayed at startup after an update, replacing placeholders in
  place.

The bubble and banner strings are hard-coded English and need adding to Crowdin.
- Push metadata `t` is already milliseconds; stop converting it, and pass
  the swarm expiry `z` through as the server expiry on both push paths.
- No placeholder in a hidden conversation; the record is still retained
  and the conversation stays hidden.
- Add `sender` and `sent_timestamp_ms` to `unsupported_message`, set for
  unknown types and NULL for the newer format.
- An unsend request deletes retained records with the same sender and
  sent timestamp.
- A failed reprocess keeps the record and stamps the version. The
  placeholder is detached before it's deleted, so a failed replay leaves
  the record in place without one.
- Enforce the retention limits and reprocess on launch and on returning
  to the foreground; limit failures are caught and logged.
- Add unsupported_message_stats (seeded single row), kept exact by
  insert/delete/update triggers, and the unsupported_message_kind_id index
- Count each row as length(data) + 256 against the 256 MB budget, evicting
  oldest newerFormat rows first in batches of up to 100
- Cap newerFormat rows at 10,000, evicting the oldest
- Check the caps from the stats row after each insert, with no table scan;
  scheduled maintenance still removes expired rows first
- Stamp newerFormat rows in one UPDATE and replay unknownType rows in pages
  of 50, choosing by stored kind rather than data bytes
- Name the protobuf wire types in the field scan
@mpretty-cyro
mpretty-cyro marked this pull request as ready for review October 8, 2026 03:20
@mpretty-cyro
mpretty-cyro merged commit 3c967f6 into dev Oct 8, 2026
8 of 10 checks passed
@mpretty-cyro
mpretty-cyro deleted the unsupported-message-bubble branch October 8, 2026 03:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant