Wilting Messages
Disappearing messages and images — reveal-gated, per-recipient countdown, destroy-in-place. The namesake feature: content that wilts.
Overview
A wilting message is a text or image that self-destructs a set number of seconds after the recipient opens it. It arrives behind a tap-to-reveal gate; on reveal a countdown starts; when it lapses the message wilts — its stored ciphertext is destroyed in place and only a "Wilted message" tombstone remains.
Like every disappearing-message system (Signal, Snapchat), wilting is cooperative: a modified client can't be cryptographically forced to destroy content. What makes it meaningful in WiltKey is that the usual retention routes are already closed —
FLAG_SECURE blocks OS screenshots/recording (see PIN & Security Lock) and screen captures require peer consent (see Screenshot request cards below). The UI is honest about this: content "wilts on their device," not "is guaranteed deleted."
How it Works
-
Config on the wire, timing on the device:
The sender can't know when a recipient will open a message, so the OTP envelope carries only the config —
eph: 1andttl: <seconds>(1–60). All timing is resolved locally on the recipient:opened_at/expires_atare stamped the moment they tap to reveal. -
Reveal-gated:
A received wilting message is never decrypted until revealed. The bubble renders a "Tap to see wilting message" gate; the tap calls
revealEphemeral(), which stamps the open/expiry instants, persists them, and arms a countdown timer. Unopened messages persist indefinitely (there is no delivery-side max). - Per-recipient countdown: Each recipient's timer starts on their reveal. In a group, every member wilts on their own schedule. The countdown is a self-ticking bar rendered beside the message.
-
Destroy-in-place:
On expiry,
wiltMessageRow()blanks both ciphertext copies (text_otp='',text_encrypted_master= null), scramblesoffsetto-1, and setswilted = 1. This is true destruction, not a flag: flagging alone would leave recoverable ciphertext on disk that a retained pad could still decrypt. -
Sender copy + confirmation:
When a recipient wilts a message, they emit a
wilt_doneconfirmation on the AES metadata channel (never the OTP pad). In 1-on-1 the sender then wilts their own copy too; in a group the sender accrues awilted_byset ("n/m wilted") and wilts once every other member has confirmed.
The Wilt Engine
state_wilting.dart (an extension AppStateWilting, part of state.dart) is the local engine. Timers live in AppState (wiltTimers) because extensions can't hold state.
revealEphemeral()— stampsopened_at/expires_atonce, persists, and arms the countdown.wiltMessage()— destroys the row, clears the in-memory copy, drops the timer, and (for a received real message only) emitswilt_done.sweepAndArmWilting()— runs once per unlock and again on resume (timers don't survive backgrounding). It destroys anything whose countdown lapsed while the app was closed and re-arms live timers for the rest; unopened messages are left alone.
Because
expires_at is persisted, a message revealed and then force-quit is resolved correctly on next launch: the cold-start sweep wilts it if the time elapsed, or re-arms the countdown if not. A killed timer never means a message silently outlives its lifetime.
Persistence (schema v8)
Six columns were added to the messages table (DB bumped v7 → v8 via ALTER TABLE). Legacy rows default to non-ephemeral. See Persistence & SQLite for the full table.
ephemeral,ttl_seconds— sender config (mirror the envelopeeph/ttl).opened_at,expires_at— recipient-local timing (epoch ms), null until revealed.wilted— 1 once destroyed; the body columns are blanked in place.wilted_by— JSON list of peers who confirmed wilt (sender-side tally), synced like reactions.
saveMessage guards against a late redelivery (offline queue / resync) restoring a destroyed body: if the stored row is already wilted, the re-save keeps it blanked. The resync builder likewise skips wilted messages — their ciphertext is gone, so there is nothing valid to forward.
UI & Composition
- Send text: long-press the send button → a duration sheet (slider 1–60s, default 5). A plain tap still sends a normal message; backing out of the sheet sends nothing. The send button is an
InkWell(not anIconButtonwith a tooltip, whose own long-press recognizer would steal the gesture). - Send image: the compression dialog has a "Wilting image" toggle + slider. Enabling it force-disables the Allow saving and Send hidden toggles — both contradict a self-destructing image (a downloadable wilt isn't wilting, and the reveal gate is the spoiler).
- Reveal → countdown → tombstone: received wilting content renders as a gate, then (on reveal) the content with a draining
_WiltCountdownBar, then a "Wilted message" tombstone. Your own copy shows a small "Wilting message" tag and wilts on the peer's confirmation. - Notifications stay content-free: wilting messages reuse the generic background alert (see Notifications & BG) — never a body preview, so the notification shade is not a retention hole.
Screenshot Request Cards
Consensual screenshots (state_screenshot.dart) also ride the wilt engine. FLAG_SECURE blocks OS captures, so a requester's app renders the chat to an image itself (a RepaintBoundary — a layer-tree render, not a window-buffer read) — but only after the other side(s) consent.
When a request arrives, in addition to the modal consent dialog, an in-history request card is inserted (content_type: 'screenshot_request', id ssreq_<reqId>, attributed to the requester) with Accept / Decline buttons — so a request missed while offline still leaves an answerable record. The card is marked ephemeral and expires via the wilt engine into "Screenshot request expired". Answering (via card or dialog) resolves it to accepted/declined and clears its expiry.
A
screenshot_request card is ephemeral for expiry only. Both wiltMessage and isReceivedEphemeral exclude the screenshot_request content type, so an expiring card never sends a bogus wilt_done to a peer. It is also skipped by resync (it is a local record, not pad content).
Key Files & Symbols
| File Path | Symbol Name | Description |
|---|---|---|
lib/core/state_wilting.dart |
revealEphemeral() / wiltMessage() / sweepAndArmWilting() |
The local engine: reveal-arm, destroy-in-place, and the start-up/resume sweep + timer management. |
lib/core/state_wilting.dart |
_sendWiltConfirmation() / _handleWiltDone() / _handleGroupWiltDone() |
The wilt_done / group_wilt_done confirmation transport on the AES meta channel and its inbound handlers. |
lib/core/models.dart |
ChatMessage.ephemeral / ttlSeconds / openedAt / expiresAt / wilted / wiltedBy |
Model fields + isWilting/isExpired getters and wiltInMemory() (clears content, scrambles offset). |
lib/core/db/wiltkey_db.dart |
wiltMessageRow() / markMessageOpened() / addWiltedBy() / getPendingWiltMessages() |
v8 schema helpers: destroy-in-place, stamp open/expiry, tally confirmations, and the sweep query. |
lib/core/state_screenshot.dart |
_insertScreenshotRequestCard() / respondToScreenshotCard() / _resolveScreenshotCard() |
The in-history screenshot-request card, its Accept/Decline handling, and payload resolution. |
.../widgets/message_bubble.dart |
_WiltCountdownBar / _buildWiltGate / _buildWiltedTombstone / _buildScreenshotCard |
The reveal gate, the draining countdown, the tombstone, and the screenshot-request card renderers. |
.../widgets/wilt_duration_sheet.dart |
showWiltDurationSheet() |
The 1–60s duration picker sheet, shared by 1-on-1 and group send (long-press) and the image dialog. |
Gotchas & Edge Cases
The
wilt_done confirmation is a newer frame. An older peer won't send it, so in a 1-on-1 your own copy will linger (theirs still wilts on their own timer). This resolves as the rollout completes.
A lost
wilt_done frame just means a sender copy lingers; a modified client could retain revealed content. Wilting is a privacy affordance backed by FLAG_SECURE + consensual capture, not a cryptographic guarantee against a hostile recipient. Never present it as one in the UI.