diff --git a/PARITY.md b/PARITY.md index 4802ecb..f38dd5e 100644 --- a/PARITY.md +++ b/PARITY.md @@ -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 from a restored backup. Hiding somebody's own posts from them reads as the posts 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. 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 e3e4e05..0adfd48 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -996,6 +996,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 463be4f..eafb828 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -277,6 +277,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). * @@ -291,7 +294,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. @@ -474,6 +478,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) + } +}