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-02 15:50:52 -04:00
co-authored by Claude Opus 4.8
parent 397f4fab04
commit a43f8a6feb
4 changed files with 214 additions and 1 deletions
+22
View File
@@ -590,3 +590,25 @@ until their next post, so muting would quietly mean something other than it says
⚠️ Your own stories are never hidden, even if your own id reaches the muted set ⚠️ Your own stories are never hidden, even if your own id reaches the muted set
from a restored backup. Hiding somebody's own posts from them reads as the posts from a restored backup. Hiding somebody's own posts from them reads as the posts
having failed to send. having failed to send.
## Screenshot notices (§6.27, ios#36)
A disappearing-message timer promises the message goes away; a screenshot breaks
that silently. It cannot be prevented on either platform, so it is reported.
🔴 **Both platforms refuse to let the absence of a notice mean anything.** The
device that took the screenshot is the device that reports it, so a modified
build, a second phone photographing the screen, or the platform's own recorder
produce nothing at all. A notice means one was probably taken; **no notice means
nothing**. Neither platform counts notices — a running total would be read as a
measurement, and its zero as "nobody screenshotted", which is exactly the
inference §6.27 forbids.
⚠️ **The platforms genuinely differ, and the copy says so rather than hiding
it.** iOS reports a screenshot to the foreground app reliably. Android has no
callback at all before API 34, and the one added there does not cover screen
recording. So the disclaimer says "you are told when their app can tell", and
the Android text adds its own limitation explicitly. Claiming a symmetry that
does not exist would be worse than the gap, because somebody would rely on it.
⚠️ **Only where a timer is set**, on both. Everywhere else this would be a
general surveillance feature rather than the narrow one it is. The timer check
runs BEFORE the rate-limit check on both, so a suppression window cannot make an
untimed conversation start reporting.
⚠️ **Last-notice times are in memory on both.** Persisting them would store who
screenshotted whom, on disk, to enforce a five-second window.
@@ -996,6 +996,30 @@ class Session(
return posted 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 * 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 * us (audit H1). Cached for a short window: a poll pass can carry many
@@ -277,6 +277,9 @@ data class Content(
*/ */
const val DISAPPEAR = "ttl" const val DISAPPEAR = "ttl"
/** A screenshot notice (§6.27). */
const val SCREENSHOT = "shot"
/** /**
* A replacement for the text of an earlier message (§6.15). * A replacement for the text of an earlier message (§6.15).
* *
@@ -291,7 +294,8 @@ data class Content(
* client sends today, and rendering one puts raw JSON in somebody's * client sends today, and rendering one puts raw JSON in somebody's
* chat. * 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. * Every kind this build can act on.
@@ -474,6 +478,61 @@ data class Content(
// JSON, and the timer was never applied at that end. // JSON, and the timer was never applied at that end.
"{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":1,\"secs\":$seconds}" "{\"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. */ /** The timer a [Kind.DISAPPEAR] envelope carries, or null if it is not one. */
fun disappearSeconds(content: Content): Int? = fun disappearSeconds(content: Content): Int? =
if (content.kind == Kind.DISAPPEAR) content.raw?.optInt("secs", 0) else null 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)
}
}