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>
109 lines
3.7 KiB
Kotlin
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)
|
|
}
|
|
}
|