From edd53bc77155cc747349f68cd00e350dd7be213f Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 14:02:25 -0400 Subject: [PATCH] Tell somebody when their disappearing message is screenshotted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/flamenet/messenger/app/Session.kt | 24 ++++ .../main/kotlin/io/flamenet/e2e/Content.kt | 61 +++++++++- .../io/flamenet/e2e/ScreenshotNoticeTest.kt | 108 ++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 3b64bc3..bf98ee0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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() + + /** + * 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 diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 1563b47..41ace17 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -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 diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt new file mode 100644 index 0000000..bd0b14f --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt @@ -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) + } +}