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
3dd44ebc9a
commit
edd53bc771
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user