Files
flamenet-messenger-android/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt
T
Paul HittandClaude Opus 4.8 a43f8a6feb 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>
2026-09-02 15:50:52 -04:00

109 lines
3.7 KiB
Kotlin

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)
}
}