Report a story to whoever runs the server (§6.25)

The reporting path, taken by hand — the original commit also rewrote 318
lines of ChatScreens against a story feed that no longer looks like that.
The UI control comes next; this is the part underneath it.

🔴 **A report is the one thing in this app that leaves readable.** The
relay cannot open a sealed story, so a report about one is only
actionable if the reporter hands over what they saw. That is the point
and the cost, and it means the consent has to name the consequence —
`reportStory` does not ask, and must never be reached from a control
that does not.

⚠️ It sends only what it is passed, and never reaches into the thread
for surrounding context. §6.25 forbids including anything the reporter
did not choose to include.

⚠️ The reasons are enumerated to match the relay. A reason the relay
rejects is a report somebody believed they had filed.

The message afterwards says what will and will not happen: whoever runs
the server sees it, and you are not told what they do. Promising an
outcome we cannot observe would be worse than saying nothing.
This commit is contained in:
Paul Hitt
2026-09-03 19:20:37 -04:00
parent 32e2f6b5df
commit a0bc8e68d2
4 changed files with 154 additions and 0 deletions
@@ -2052,6 +2052,55 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
}
/**
* The reasons a report may give (§6.25).
*
* ⚠️ Enumerated, matching the relay. A reason the relay rejects is a
* report somebody believed they had filed.
*/
enum class ReportReason(val wire: String, val label: String) {
ABUSE("abuse", "Abusive content"),
HARASSMENT("harassment", "Harassment"),
SPAM("spam", "Spam"),
IMPERSONATION("impersonation", "Pretending to be someone"),
CSAM("csam", "Child sexual abuse material"),
OTHER("other", "Something else"),
}
/**
* File a report about a story (§6.25).
*
* 🔴 **`content` leaves the device readable.** That is the point and the
* cost: the relay cannot open a sealed story, so a report about one is
* only actionable if the reporter hands over what they saw. The caller
* must have obtained consent naming that consequence — this function does
* not ask, and must never be reached from a control that does not.
*
* ⚠️ Sends only what it is passed. It does not reach into the thread for
* surrounding context: §6.25 forbids including anything the reporter did
* not choose to include.
*/
fun reportStory(
story: Vault.Story,
reason: ReportReason,
includeContent: Boolean,
note: String,
) = viewModelScope.launch {
val current = session ?: return@launch
val done = withContext(Dispatchers.IO) {
runCatching {
current.reportStory(story, reason.wire, includeContent, note)
}
}
val message = if (done.isSuccess) {
"Report sent. Whoever runs this server will see it; you will not be " +
"told what they do."
} else {
"That report could not be sent."
}
update { it.copy(error = message) }
}
/** Counts per emoji, for the author's own view of their story. */
fun storyTally(storyKey: String): Map<String, Int> =
session?.reactionsFor(storyKey).orEmpty().values
@@ -1139,6 +1139,25 @@ class Session(
return posted.reached
}
/**
* File a report about a story (§6.25).
*
* 🔴 Sends readable content, deliberately and only on the caller's
* instruction. Everything else in this app is sealed from the relay; a
* report is the one thing somebody chooses to hand over, and it has to be
* their choice each time rather than a setting.
*/
fun reportStory(story: Vault.Story, reason: String,
includeContent: Boolean, note: String) {
relay.submitReport(
about = story.authorId,
kind = "story",
reason = reason,
content = if (includeContent) story.caption else "",
note = note,
)
}
fun reactionsFor(storyKey: String): Map<Long, String> = storyReactions[storyKey].orEmpty()
fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId)
@@ -0,0 +1,59 @@
package io.flamenet.messenger.app
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* Reporting a story (spec §6.25, ios#56).
*
* 🔴 **A report is the one thing this app sends that the server can read.**
* Everything else is sealed. The relay cannot open a story, so a report about
* one is only actionable if the reporter hands over what they saw — which makes
* the consent, and the exact set of things that travel, the feature.
*/
class StoryReportTest {
/**
* ⚠️ The wire values must match what the relay accepts. A reason the relay
* rejects is a report somebody believed they had filed.
*/
@Test
fun `every reason matches the relays enumeration`() {
val accepted = setOf("abuse", "harassment", "spam",
"impersonation", "csam", "other")
assertEquals(accepted,
MessengerViewModel.ReportReason.entries.map { it.wire }.toSet())
}
/** A raw wire value in a picker is the wire format leaking into the UI. */
@Test
fun `every reason has words a person can choose between`() {
MessengerViewModel.ReportReason.entries.forEach {
assertTrue(it.label.isNotEmpty(), it.wire)
assertNotEquals(it.wire, it.label)
}
}
/**
* ⚠️ CSAM must be reportable as itself rather than folded into abuse: it
* carries a different obligation, and an operator cannot triage what
* nobody can name.
*/
@Test
fun `csam is its own reason`() {
assertTrue(MessengerViewModel.ReportReason.entries
.any { it == MessengerViewModel.ReportReason.CSAM })
}
/**
* ⚠️ Without one, the picker forces a person to mis-categorise in order to
* report at all.
*/
@Test
fun `there is a reason for anything else`() {
assertTrue(MessengerViewModel.ReportReason.entries
.any { it == MessengerViewModel.ReportReason.OTHER })
}
}
@@ -339,6 +339,33 @@ class RelayClient(
* inside the ratchet; putting it anywhere the relay can read defeats the
* point of encrypting the blob.
*/
/**
* `POST /e2e/moderation/report` — file a report (§6.25).
*
* 🔴 **The one call that deliberately sends readable content to the
* server.** Everything else this client sends is sealed. A relay that
* cannot open a reported story cannot act on an accusation about it, so
* the reporter's own copy travels with the report — and the caller is
* required to have obtained consent naming that, before reaching here.
*/
fun submitReport(
about: Long,
kind: String,
reason: String,
content: String,
note: String,
) {
val body = JSONObject()
.put("about", about)
.put("kind", kind)
.put("reason", reason)
.put("content", content)
.put("note", note)
.toString()
request("POST", url("/e2e/moderation/report"), "/e2e/moderation/report",
body.toRequestBody(JSON)).use { it.close() }
}
fun uploadAttachment(blob: ByteArray): String =
request("POST", url("/e2e/attachments"), "/e2e/attachments", blob.toRequestBody(OCTETS))
.use { json(it).getString("id") }