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:
co-authored by
Claude Opus 4.8
parent
397f4fab04
commit
a43f8a6feb
@@ -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.
|
||||
|
||||
@@ -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<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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user