diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 81bcd05..214548f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1953,7 +1953,16 @@ fun ColumnScope.ChatScreen( ) } DropdownMenuItem( - text = { Text(if (line.mine) "Unsend" else "Delete for me") }, + // 🔴 **Said "Unsend" on your own messages and did + // nothing of the kind (android#18).** `deleteMessage` + // reaches `vault.deleteLine` and stops there — no + // envelope leaves the phone, and the recipient's copy + // stays exactly where it is. Somebody who sent to the + // wrong person tapped it, watched the bubble vanish, + // and had no way to find out. "Delete for me" was + // already the other half of this ternary, and it was + // always the accurate half. + text = { Text("Delete for me") }, onClick = { model.deleteMessage(line); menuOpen = false }, modifier = Modifier.testTag("delete-message"), ) @@ -3950,8 +3959,8 @@ internal fun memberLabel( * rather not. The cursor sits at the end for the same reason. * * ⚠️ Saving nothing is refused rather than treated as a delete. Emptying the - * box is the shape of a slip, and "Unsend" is right there in the same menu for - * anybody who meant it. + * box is the shape of a slip, and "Delete for me" is right there in the same + * menu for anybody who meant it. */ @Composable private fun EditMessageSheet( diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/DestructiveLabelsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/DestructiveLabelsTest.kt new file mode 100644 index 0000000..9caae43 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/DestructiveLabelsTest.kt @@ -0,0 +1,89 @@ +package io.flamenet.messenger.app + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * A destructive button must not claim more than it does (android#18). + * + * 🔴 **"Unsend" did not unsend.** The message menu offered it on your own + * messages and called `deleteMessage`, which reaches `vault.deleteLine` and + * stops there. No envelope leaves the phone; the recipient's copy stays exactly + * where it is. Somebody who sent to the wrong person tapped it, watched the + * bubble vanish, and had no way to find out. + * + * ⚠️ **Worse than an overclaiming page**, which is why this is a test and not + * a one-line fix left to stay fixed. A page that overclaims can be read + * sceptically. A button that reports success cannot: the feedback IS the claim, + * and it arrives at the exact moment the person is most relieved to believe it. + * + * A source scan, and coarse on purpose. It cannot prove every label is honest. + * It proves this particular word cannot come back on a local-only action + * without somebody deleting a test that explains why — and it is a plain JVM + * test, so it costs no emulator. + */ +class DestructiveLabelsTest { + + private fun uiSources(): List { + // From `app/build/classes/...` back to the module root, whatever the + // working directory happens to be. Resolving against a relative path + // is how this kind of test silently scans nothing. + var dir = File(System.getProperty("user.dir")) + while (!File(dir, "app/src/main/kotlin").isDirectory && dir.parentFile != null) { + dir = dir.parentFile + } + val root = File(dir, "app/src/main/kotlin") + return root.walkTopDown().filter { it.extension == "kt" }.toList() + } + + /** + * ⚠️ **Comments are stripped, and that is required rather than tidy.** The + * comment explaining this bug necessarily quotes the word it is about, and + * a guard that flags its own explanation is a guard somebody deletes. + */ + private fun codeLines(body: String): List = + body.lines() + .filterNot { it.trimStart().startsWith("//") || it.trimStart().startsWith("*") } + .map { it.substringBefore("//") } + + /** + * 🔴 **The word promises the recipient's copy is gone.** Nothing in this app + * can do that yet: it would take a signed control envelope naming the `mid`, + * which the recipient acts on — and even then it is a *request*, so whatever + * the button eventually says must not promise more than "we asked them to + * remove it." + * + * When that lands, this test is the thing to update, deliberately, in the + * same change. + */ + @Test + fun `no button says Unsend`() { + val files = uiSources() + assertTrue(files.isNotEmpty(), "scanned no sources — the walk found nothing") + for (file in files) { + codeLines(file.readText()).forEachIndexed { i, line -> + assertTrue( + !line.contains("\"Unsend\""), + "${file.name} line ~${i + 1} offers a button labelled \"Unsend\", but " + + "nothing in this app removes a message from the recipient's device. " + + "See android#18. If a real retract now exists, update this test in " + + "the same change and say which envelope carries it.", + ) + } + } + } + + /** + * ⚠️ The walk is the part most likely to rot. A scan that finds no files + * passes every assertion in it, so this pins that it is actually reading + * the screen the bug was on. + */ + @Test + fun `the scan actually reaches the chat screen`() { + assertTrue( + uiSources().any { it.name == "ChatScreens.kt" }, + "the source walk did not reach ChatScreens.kt — this guard is scanning nothing", + ) + } +}