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
@@ -307,6 +307,9 @@ data class Content(
*/
const val DISAPPEAR = "ttl"
/** A screenshot notice (§6.27). */
const val SCREENSHOT = "shot"
/**
* A replacement for the text of an earlier message (§6.15).
*
@@ -321,7 +324,8 @@ data class Content(
* client sends today, and rendering one puts raw JSON in somebody's
* chat.
*/
val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR, DISAPPEAR)
val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR, DISAPPEAR,
SCREENSHOT)
/**
* Every kind this build can act on.
@@ -567,6 +571,61 @@ data class Content(
// JSON, and the timer was never applied at that end.
"{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":1,\"secs\":$seconds}"
/**
* Tell somebody their disappearing message was screenshotted (§6.27).
*
* 🔴 **A courtesy from the taker's own client, and nothing else.** A
* modified build, a second phone photographing the screen, or the
* platform's own recorder all produce nothing. A notice means one was
* probably taken; no notice means NOTHING — see [screenshotDisclaimer].
*/
fun screenshot(atSeconds: Long): String =
"{\"fnc\":1,\"kind\":${quote(Kind.SCREENSHOT)},\"ctl\":1,\"at\":$atSeconds}"
/** When a [Kind.SCREENSHOT] envelope says it happened, or null. */
fun screenshotAt(content: Content): Long? =
if (content.kind == Kind.SCREENSHOT) {
content.raw?.optLong("at", 0L)?.takeIf { it > 0 }
} else null
/**
* 🔴 **What may never be inferred from the absence of notices.**
* Presenting "no screenshots" as a fact — a count of zero, a tick, a
* reassuring summary — would be inventing evidence out of silence.
*
* ⚠️ 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. Claiming a symmetry
* with iOS that does not exist would be worse than the gap, because
* somebody would rely on it.
*/
const val screenshotDisclaimer =
"You are told when their app can tell. It cannot always: another phone " +
"photographing the screen, a screen recorder, or a modified app leave " +
"no trace at all. On Android this needs a recent version, and even " +
"then it misses screen recording. Nothing here means a conversation " +
"was not captured."
/**
* Whether to send a notice now (§6.27).
*
* ⚠️ **Only where a timer is set.** Everywhere else this would be a
* general surveillance feature rather than the narrow one it is.
*
* ⚠️ The timer check comes FIRST, so a rate-limit window cannot make
* an untimed conversation start reporting.
*/
fun shouldAnnounceScreenshot(
disappearSeconds: Int,
lastAnnouncedMillis: Long?,
nowMillis: Long,
windowMillis: Long = 5_000,
): Boolean {
if (disappearSeconds <= 0) return false
val last = lastAnnouncedMillis ?: return true
return nowMillis - last >= windowMillis
}
/** The timer a [Kind.DISAPPEAR] envelope carries, or null if it is not one. */
fun disappearSeconds(content: Content): Int? =
if (content.kind == Kind.DISAPPEAR) content.raw?.optInt("secs", 0) else null
@@ -0,0 +1,108 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Telling somebody their disappearing message was screenshotted (§6.27, ios#36).
*
* 🔴 **A courtesy from the taker's own client, and nothing else.** A modified
* build, a second phone photographing the screen, or the platform's own
* recorder all produce nothing. A notice means one was probably taken; no
* notice means NOTHING — and most of these tests are about not letting the
* interface say otherwise.
*/
class ScreenshotNoticeTest {
@Test
fun `the notice carries when it happened`() {
val content = Content.parse(Content.screenshot(1_788_000_000))
assertEquals(1_788_000_000, Content.screenshotAt(content))
}
/** ⚠️ Control traffic, so an older build stays quiet instead of showing JSON. */
@Test
fun `the notice is marked as control traffic`() {
val json = JSONObject(Content.screenshot(1))
assertEquals(1, json.getInt("ctl"))
assertEquals(Content.Kind.SCREENSHOT, json.getString("kind"))
assertTrue(Content.Kind.SCREENSHOT in Content.Kind.CONTROL)
}
@Test
fun `the marker stays first`() {
assertTrue(Content.screenshot(1).startsWith("{\"fnc\":1"))
}
/** A notice is never a message. */
@Test
fun `the notice carries no text`() {
assertTrue(Content.parse(Content.screenshot(1)).text.isEmpty())
}
/** Reading the stamp off anything else must yield nothing. */
@Test
fun `an ordinary message has no screenshot stamp`() {
assertNull(Content.screenshotAt(Content.parse(Content.text("hi", messageId = "m"))))
}
// -- When one is sent -----------------------------------------------------
/** 🔴 Only where a timer is set. */
@Test
fun `no notice in a conversation with no timer`() {
assertFalse(Content.shouldAnnounceScreenshot(0, null, 1_000))
}
@Test
fun `a notice is sent in a timed conversation`() {
assertTrue(Content.shouldAnnounceScreenshot(3600, null, 1_000))
}
/** ⚠️ One screenshot often fires several platform events. */
@Test
fun `a second event inside the window is suppressed`() {
assertFalse(Content.shouldAnnounceScreenshot(3600, 1_000, 2_000))
}
@Test
fun `a genuine second screenshot later is announced`() {
assertTrue(Content.shouldAnnounceScreenshot(3600, 1_000, 10_000))
}
/** ⚠️ The timer check comes first, or a window would enable an untimed chat. */
@Test
fun `the window does not override the timer rule`() {
assertFalse(Content.shouldAnnounceScreenshot(0, 1_000, 999_000))
}
// -- What may be claimed --------------------------------------------------
/**
* 🔴 **The most important test here.** Presenting the absence of notices as
* a fact would be inventing evidence out of silence.
*/
@Test
fun `the disclaimer refuses to turn silence into evidence`() {
val t = Content.screenshotDisclaimer
assertTrue(t.contains("when their app can tell"), t)
assertTrue(t.contains("cannot always"), t)
assertTrue(t.contains("was not captured"), t)
}
/**
* ⚠️ Android's half of the asymmetry has to be stated, not hidden. Claiming
* a symmetry with iOS that does not exist is worse than the gap, because
* somebody would rely on it.
*/
@Test
fun `the disclaimer states the android limitation`() {
val t = Content.screenshotDisclaimer
assertTrue(t.contains("recent version"), t)
assertTrue(t.contains("screen recording"), t)
}
}