Tell somebody when their disappearing message is screenshotted

Android half of ios#36, spec §6.27. `shot` joins the control kinds, so a
build that does not know it stays quiet rather than rendering JSON.

🔴 The care is in refusing to let the absence of a notice mean anything.
The device that took the screenshot reports it, so a modified build, a
second phone, or a screen recorder produce nothing. A notice means one
was probably taken; no notice means NOTHING, and nothing counts them.

⚠️ Android's half of the asymmetry is stated rather than hidden. There is
no screenshot callback at all before API 34, and the one added there does
not cover screen recording — so the disclaimer says that, in those words.
Claiming a symmetry with iOS that does not exist would be worse than the
gap, because somebody would rely on it.

⚠️ Only where a timer is set, and the timer check comes before the
rate-limit check so a suppression window cannot make an untimed
conversation start reporting.

⚠️ Last-notice times are in memory. Persisting them would store who
screenshotted whom on disk to enforce a five-second window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-03 19:08:44 -04:00
co-authored by Claude Opus 4.8
parent 3dd44ebc9a
commit edd53bc771
3 changed files with 192 additions and 1 deletions
@@ -1222,6 +1222,30 @@ class Session(
return posted
}
/**
* When a screenshot notice was last sent to each peer (§6.27).
*
* ⚠️ In memory only. The window it enforces is seconds long, so persisting
* it would be storing who screenshotted whom — a record of exactly the
* thing this feature exists to report — on disk, for no benefit.
*/
private val lastScreenshotNotice = mutableMapOf<Long, Long>()
/**
* Tell this peer their disappearing message was screenshotted (§6.27).
*
* ⚠️ Best effort and deliberately silent on failure. A notice that could
* not be sent is not something to surface to the person who took the
* screenshot — they did not ask for it and cannot act on it.
*/
fun announceScreenshot(peerUserId: Long, nowMillis: Long = System.currentTimeMillis()) {
val timer = vault.disappearSeconds(Vault.threadForUser(peerUserId))
if (!Content.shouldAnnounceScreenshot(
timer, lastScreenshotNotice[peerUserId], nowMillis)) return
lastScreenshotNotice[peerUserId] = nowMillis
runCatching { conversations.send(peerUserId, Content.screenshot(nowMillis / 1000)) }
}
/**
* Members of a group we are in, or null if the relay does not list it for
* us (audit H1). Cached for a short window: a poll pass can carry many