Skip to content

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

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

mpretty-cyro merged 6 commits into
session-foundation:devfrom
mpretty-cyro:unsupported-message-bubble

Conversation

@mpretty-cyro

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

Copy link
Copy Markdown
Collaborator

Contributor checklist

  • My commits are rebased on the latest dev branch
  • My commits are in nice logical chunks
  • My contribution is fully baked and is ready to be merged as is
  • I have tested my contribution on these devices:
  • iPhone 17 simulator, iOS 26.1

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: Android session-foundation/session-android#2232, 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. SNProtoContent doesn't expose unknown fields, so a small hand-written tag scan runs over the decrypted bytes. 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". The notification extension stays silent for newer-format messages and shows the generic "new message" notification only when a placeholder will be shown.
  • Banner: the green InfoBanner 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 _054. 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; deleting a placeholder (which converts it to a deleted bubble) removes its row, and deleting the conversation cascades.
  • 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. This happens inside one savepoint. If it fails, the placeholder and the row are both kept, stamped with this version, and the reason is logged. The retention limits and the replay run as a startup job on the serial message-receive queue, so on launch and whenever the app becomes active,, 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: UnsupportedMessageSpec 24/24. It covers the tag scan, detection rules, placement, hidden conversations, unsend, deletion, expiry, banner state, and replay: replaced, still unsupported, parse failure, and savepoint rollback.
  • Updated specs: NotificationsManagerSpec and DatabaseSpec were updated for the new type, migration and table, and pass.
  • Manual testing: seeded on a throw-away simulator (local hook, not in this PR); the bubbles, banner and delete-only context menu were checked visually.
  • Pre-existing failure, not caused by this change: SwarmPollerSpec "config message fails to merge on the normal (non-synchronous) poll path" crashes in JobRunner.init. This change doesn't touch that path.

…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, 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.
- 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.
- A startup job replays retained messages after an update and replaces placeholders in
  place.

The bubble and banner strings are hard-coded English and need adding to Crowdin.
Comment thread SessionMessagingKit/Database/Models/Interaction.swift
Comment thread SessionMessagingKit/Jobs/DisappearingMessagesJob.swift Outdated
Comment thread SessionMessagingKit/Jobs/ReprocessUnsupportedMessagesJob.swift
Comment thread SessionMessagingKit/Jobs/ReprocessUnsupportedMessagesJob.swift Outdated
Comment thread SessionMessagingKit/Jobs/ReprocessUnsupportedMessagesJob.swift Outdated
- Start the placeholder variants at 100 to leave room for more standard variants.
- Enforce the retention limits from the reprocess job (launch and becoming active), not
  inside the disappearing-messages delete, and catch its failures.
- A failed replay keeps the retained message (stamped and logged) for a later version
  instead of dropping it.
- Only add a placeholder to a visible conversation, and never un-hide one.
- Record the sender and sent timestamp, so an unsend removes a retained message that has
  no placeholder rather than letting a later replay restore it.
- Add the new migration and table to DatabaseSpec.
The notification extension notified for a hidden conversation, where the main app adds no placeholder, so the notification pointed at nothing. Also covers the replay savepoint rollback in UnsupportedMessageSpec.
- Count each retained row as its data plus 256 bytes against the 256 MB budget, and cap
  newer-format rows at 10,000, so a flood of tiny rows can't outgrow the limit.
- Keep a running total in `unsupported_message_stats` (maintained by triggers) instead of
  summing the table on every insert.
- Stamp newer-format rows as attempted in one update without loading them, and page the
  rest; replay decides by the stored kind, never the first byte.
- 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 e57793c into session-foundation:dev Oct 8, 2026
1 of 4 checks passed
@mpretty-cyro
mpretty-cyro deleted the unsupported-message-bubble branch October 8, 2026 04:15
@mpretty-cyro mpretty-cyro mentioned this pull request Oct 8, 2026
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