diff --git a/PARITY.md b/PARITY.md index 341c05f..f101ea2 100644 --- a/PARITY.md +++ b/PARITY.md @@ -93,19 +93,20 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Multi-device fan-out | โœ… one envelope per device, partial delivery reported | | History at rest | โœ… encrypted, keyed by person not device | | Session restore across restarts | โœ… credential and device both survive | -| Reactions | ๐ŸŸก codec done, no UI โ€” blocked on the transcript carrying a `mid` | -| Edit a sent message (ยง6.15) | ๐ŸŸก codec done, in progress โ€” an inbound edit is recognised and not drawn as a duplicate bubble; applying it needs the same `mid` | +| Reactions | โœ… transcript carries a `mid`; long-press gives a five-emoji row, one emoji per person | +| Edit a sent message (ยง6.15) | ๐ŸŸก codec back, and the `mid` it was blocked on now exists โ€” applying an inbound edit and offering the control are UI work | | Drafts that survive a relaunch | โœ… sealed in the vault, not SharedPreferences | -| Forwarding (ยง6.16) | ๐ŸŸก codec done, in progress โ€” an inbound forward is recognised and shown as one; originating needs #10 | +| Forwarding (ยง6.16) | ๐ŸŸก codec back; originating needs the UI | | Group message ids and quotes (ยง6.9) | โœ… transcript stores both; sent and queued copies share one id | -| Mentions (ยง6.18) | ๐ŸŸก codec done, in progress โ€” an inbound mention parses and is bounded; composing and notifying need the UI | +| Mentions (ยง6.18) | โœ… codec, an `@` picker in the group composer, and ids recorded at pick time rather than re-derived from the text | | Per-message timers (ยง6.17) | โœ… combining rule and per-message pruning; composer control in progress | -| Reactions | ๐ŸŸก codec done, no UI | +| Search inside messages | โœ… scanned, never indexed | | Search inside messages | โœ… scanned, never indexed โ€” no plaintext index at rest | -| Pinned message (ยง6.19) | ๐ŸŸก codec done, in progress โ€” an inbound pin is recognised and not drawn as an empty bubble | +| Pinned message (ยง6.19) | ๐ŸŸก codec back; the banner and the control are UI work | | Photos | โœ… send via the system picker, render inline | | Voice notes (record + play) | โœ… AAC/MP4, tap to record, tap to send | -| Stories, and seen-state | โœ… 24h, sent per-contact, expired bytes deleted | +| Stories, and seen-state | โœ… sent per-contact, expired bytes deleted | +| Stories: text-only, lifetime, reactions, replies, mute | โœ… a menu on ๏ผ‹ rather than straight to the picker; lifetime rides inside the sealed envelope | | Groups: create, list, members, leave | โœ… members editable after creation | | Group text and images | โœ… one upload, one envelope per member | | Delivery keys + rotation | โœ… published as a digest, rotatable from Account | @@ -149,6 +150,78 @@ emulator. --- +## Test safety + +| Feature | iOS | Android | Note | +|---|:--:|:--:|---| +| Live suites cannot run by accident | โœ… | โœ… | Android was already ahead here โ€” every live suite gated on `assumeTrue`, and `LiveTarget.refuseProduction` refuses a production host outright | +| No credential written into the source | โœ… | โœ… | both fixed together, ios#62 | +| A guard that fails the build if either regresses | โœ… | โœ… | `Tests/UITestSafetyTests.swift`, `client/.../TestSafetyTest.kt` | + +๐Ÿ”ด **iOS was creating real accounts on production on every plain test run**, with +a literal password from a public repository; Android's equivalent suites were +properly gated but still carried +`const val PASSPHRASE = "a-long-enough-passphrase"` and used it to sign up on +`accounts.flamenet.io` whenever somebody opted in. The gate stops it running by +accident. It does not stop the password being written down. + +Both now read the passphrase from the environment with **no fallback**, and an +unconfigured run reports SKIPPED rather than signing up with a value anybody can +read. `FN_LIVE_PASSPHRASE` on Android, `TEST_RUNNER_FN_DEMO_PASSPHRASE` on iOS. + +โš ๏ธ Both guards are source scans, and both had to be narrowed before they were +worth keeping โ€” the Swift one flagged an email default because "password" +appeared in a trailing comment, the Kotlin one flagged four suites that sign up +against a `MockWebServer`, which is the correct way to test sign-up. A guard +with standing false positives is one somebody switches off. + +--- + +## The 2 September revert + +Twenty-five Android merge requests landed that evening and `aebf41e` +reverted all of them โ€” 5,016 lines across 41 files โ€” for the same reason iOS +was reverted an hour earlier: the branches had never existed together, and +resolving them mechanically produced code that compiled and passed while +being wrong. + +**All twenty-five are back**, reinstated one at a time on +`feat/files-and-wallet-parity`, each built and tested before the next was +started. Six of them could not be cherry-picked at all: the originals were cut +from a tree whose story features looked different from the versions rebuilt in +the meantime, so applying them whole would have left two implementations of +mute, text stories and story replies under different names โ€” no compile error, +and exactly the kind of divergence that rots. Those were taken by hand, feature +by feature. + +โš ๏ธ One deliberate divergence from the originals: the mention wire key stays +`men`, not `ment`, and mentions are `List` rather than `List`. That +is what the iOS engine speaks and what the two were verified interoperating on; +switching to match the reverted commits would have broken iOS silently. + +--- + +## Files and Wallet + +From `Sources/Views/Files/*` and `Sources/Wallet/*`, both of which moved into +the messenger rather than staying separate apps. + +| Feature | Android | +|---|:--:| +| Local vault: add, organise, delete | โšช | +| Bellโ€“LaPadula labels and clearance | โšช | +| Share a file into a chat or group | โšช | +| Wallet: BIP-39 seed, create and restore | โšช | +| Wallet: fresh receive address per receive | โšช | +| Wallet: balance and transactions from a node | โšช | + +โš ๏ธ Nothing here exists on Android yet. The iOS side is built and tested; the +engine work it rests on (BIP-39, SLIP-0010, Base58, the label encoding) is +Swift-only and has no Kotlin counterpart, so this is the largest remaining gap +and it is a build, not a port of a screen. + +--- + ## Settings Every key in `Views/Settings/SettingsKeys.swift` has to exist, because each one diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d757805..92b653f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -225,6 +225,9 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test:runner:1.6.2") + // GrantPermissionRule: permissions have to be granted before the + // activity launches, or its permission dialog steals focus from Compose. + androidTestImplementation("androidx.test:rules:1.6.1") androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-test-manifest") diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt index 3ed6bb9..64fad42 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt @@ -10,7 +10,10 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.printToString +import androidx.test.rule.GrantPermissionRule +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -34,9 +37,44 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ChatFlowTest { - @get:Rule val compose = createAndroidComposeRule() + /** + * ๐Ÿ”ด Granted BEFORE the activity launches, and that ordering is the point. + * + * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls + * `setContent`. The permission dialog is a separate activity and takes + * focus, so the Compose rule attaches to a window that has no hierarchy + * yet and every test dies with "No compose hierarchies found in the app". + * + * `order = 0` puts this outside the Compose rule, so the grant happens + * first and no dialog ever appears. + */ + @get:Rule(order = 0) + val permissions: GrantPermissionRule = GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + ) - private val relay = "relay1.flamenet.io" + @get:Rule(order = 1) + val compose = createAndroidComposeRule() + + /** + * Which relay to drive. + * + * ๐Ÿ”ด Defaults to the production relay, which is what this has always used โ€” + * so an unqualified run still does what it did. Pass `-e relay ` to + * point it somewhere else, and a LOCAL one is what you want while + * developing: these tests create real accounts, and doing that on + * production for every run is a cost nobody chose. + * + * ./gradlew :app:connectedDebugAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810 + */ + private val relay: String + get() = InstrumentationRegistry.getArguments().getString("relay") + ?: "relay1.flamenet.io" + + /** A local relay is plain HTTP; anything else is not. */ + private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") || + relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https" private val peerHandle = "peerjvmone" private lateinit var handle: String @@ -48,7 +86,7 @@ class ChatFlowTest { @Test fun aPersonCanAddAContactAndSendThemAMessage() { - if (!relayReachable()) return + assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) signUp() @@ -97,7 +135,7 @@ class ChatFlowTest { */ @Test fun aReplyFromSomebodyElseAppearsInTheThread() { - if (!relayReachable()) return + assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) signUp() compose.onNodeWithTag("add-contact").performClick() @@ -232,7 +270,7 @@ class ChatFlowTest { }.getOrElse { "(could not read the screen: $it)" } private fun relayReachable(): Boolean = runCatching { - (java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) + (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) .apply { connectTimeout = 8000; readTimeout = 8000 } .responseCode in 200..299 }.getOrDefault(false) diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt index ff7d3e0..6e37e55 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.printToString +import androidx.test.rule.GrantPermissionRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Rule @@ -33,7 +34,24 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CrossPlatformTest { - @get:Rule val compose = createAndroidComposeRule() + /** + * ๐Ÿ”ด Granted BEFORE the activity launches, and that ordering is the point. + * + * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls + * `setContent`. The permission dialog is a separate activity and takes + * focus, so the Compose rule attaches to a window that has no hierarchy + * yet and every test dies with "No compose hierarchies found in the app". + * + * `order = 0` puts this outside the Compose rule, so the grant happens + * first and no dialog ever appears. + */ + @get:Rule(order = 0) + val permissions: GrantPermissionRule = GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + ) + + @get:Rule(order = 1) + val compose = createAndroidComposeRule() private val args get() = InstrumentationRegistry.getArguments() private val relay: String get() = args.getString("relay") ?: "relay1.flamenet.io" diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt index 272f5e3..5cbc15b 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt @@ -10,7 +10,10 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.printToString +import androidx.test.rule.GrantPermissionRule +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -33,9 +36,44 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CrudSurfaceTest { - @get:Rule val compose = createAndroidComposeRule() + /** + * ๐Ÿ”ด Granted BEFORE the activity launches, and that ordering is the point. + * + * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls + * `setContent`. The permission dialog is a separate activity and takes + * focus, so the Compose rule attaches to a window that has no hierarchy + * yet and every test dies with "No compose hierarchies found in the app". + * + * `order = 0` puts this outside the Compose rule, so the grant happens + * first and no dialog ever appears. + */ + @get:Rule(order = 0) + val permissions: GrantPermissionRule = GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + ) - private val relay = "relay1.flamenet.io" + @get:Rule(order = 1) + val compose = createAndroidComposeRule() + + /** + * Which relay to drive. + * + * ๐Ÿ”ด Defaults to the production relay, which is what this has always used โ€” + * so an unqualified run still does what it did. Pass `-e relay ` to + * point it somewhere else, and a LOCAL one is what you want while + * developing: these tests create real accounts, and doing that on + * production for every run is a cost nobody chose. + * + * ./gradlew :app:connectedDebugAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810 + */ + private val relay: String + get() = InstrumentationRegistry.getArguments().getString("relay") + ?: "relay1.flamenet.io" + + /** A local relay is plain HTTP; anything else is not. */ + private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") || + relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https" private val peerHandle = "peerjvmone" private lateinit var handle: String @@ -74,7 +112,7 @@ class CrudSurfaceTest { @Test fun everyContactActionIsReachable() { - if (!relayReachable()) return + assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) signUp() // Presence is a claim about you, so you can set it. @@ -110,7 +148,7 @@ class CrudSurfaceTest { @Test fun theOtherScreensAreReachableAndComplete() { - if (!relayReachable()) return + assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) signUp() // Stories are reachable from the contact list, and say what they are @@ -143,7 +181,7 @@ class CrudSurfaceTest { @Test fun aThreadCanSendTextAndPhotos() { - if (!relayReachable()) return + assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) signUp() compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("new-contact").performTextInput(peerHandle) @@ -282,7 +320,7 @@ class CrudSurfaceTest { }.getOrElse { "(could not read the screen: $it)" } private fun relayReachable(): Boolean = runCatching { - (java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) + (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) .apply { connectTimeout = 8000; readTimeout = 8000 } .responseCode in 200..299 }.getOrDefault(false) diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/FeatureUseTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/FeatureUseTest.kt index e1671d8..ec8e0a3 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/FeatureUseTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/FeatureUseTest.kt @@ -8,7 +8,10 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.printToString +import androidx.test.rule.GrantPermissionRule +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assume.assumeTrue import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -38,9 +41,44 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class FeatureUseTest { - @get:Rule val compose = createAndroidComposeRule() + /** + * ๐Ÿ”ด Granted BEFORE the activity launches, and that ordering is the point. + * + * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls + * `setContent`. The permission dialog is a separate activity and takes + * focus, so the Compose rule attaches to a window that has no hierarchy + * yet and every test dies with "No compose hierarchies found in the app". + * + * `order = 0` puts this outside the Compose rule, so the grant happens + * first and no dialog ever appears. + */ + @get:Rule(order = 0) + val permissions: GrantPermissionRule = GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + ) - private val relay = "relay1.flamenet.io" + @get:Rule(order = 1) + val compose = createAndroidComposeRule() + + /** + * Which relay to drive. + * + * ๐Ÿ”ด Defaults to the production relay, which is what this has always used โ€” + * so an unqualified run still does what it did. Pass `-e relay ` to + * point it somewhere else, and a LOCAL one is what you want while + * developing: these tests create real accounts, and doing that on + * production for every run is a cost nobody chose. + * + * ./gradlew :app:connectedDebugAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810 + */ + private val relay: String + get() = InstrumentationRegistry.getArguments().getString("relay") + ?: "relay1.flamenet.io" + + /** A local relay is plain HTTP; anything else is not. */ + private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") || + relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https" private lateinit var handle: String @Before @@ -59,7 +97,7 @@ class FeatureUseTest { */ @Test fun aVoiceNoteRecordsAndCanBeDiscarded() { - if (!relayReachable()) return + assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) signUpAndOpenAThread() compose.onNodeWithTag("record-voice").performClick() @@ -88,7 +126,7 @@ class FeatureUseTest { */ @Test fun aGroupIsCreatedAndListed() { - if (!relayReachable()) return + assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) signUpAndAddPeer() compose.onNodeWithTag("new-group").performClick() @@ -120,7 +158,7 @@ class FeatureUseTest { */ @Test fun theStorySurfaceOpensAndExplainsItself() { - if (!relayReachable()) return + assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) signUp() compose.onNodeWithTag("stories").assertExists() @@ -143,7 +181,7 @@ class FeatureUseTest { */ @Test fun placingACallRaisesTheBannerAndHangingUpClearsIt() { - if (!relayReachable()) return + assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) signUpAndOpenAThread() compose.onNodeWithTag("start-call").performClick() @@ -256,7 +294,7 @@ class FeatureUseTest { }.getOrElse { "(could not read the screen: $it)" } private fun relayReachable(): Boolean = runCatching { - (java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) + (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) .apply { connectTimeout = 8000; readTimeout = 8000 } .responseCode in 200..299 }.getOrDefault(false) diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt index c0d0eef..6f9ff7d 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt @@ -9,8 +9,10 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.printToString +import androidx.test.rule.GrantPermissionRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -49,7 +51,24 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class LiveSignUpFleetTest { - @get:Rule val compose = createAndroidComposeRule() + /** + * ๐Ÿ”ด Granted BEFORE the activity launches, and that ordering is the point. + * + * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls + * `setContent`. The permission dialog is a separate activity and takes + * focus, so the Compose rule attaches to a window that has no hierarchy + * yet and every test dies with "No compose hierarchies found in the app". + * + * `order = 0` puts this outside the Compose rule, so the grant happens + * first and no dialog ever appears. + */ + @get:Rule(order = 0) + val permissions: GrantPermissionRule = GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + ) + + @get:Rule(order = 1) + val compose = createAndroidComposeRule() private val args get() = InstrumentationRegistry.getArguments() @@ -73,15 +92,28 @@ class LiveSignUpFleetTest { signOutIfSignedIn() } + /** + * ๐Ÿ”ด `assumeTrue`, never a bare `return`. + * + * A bare return leaves the test reporting PASSED having done nothing, and + * a suite of those is indistinguishable from a suite that works โ€” which is + * the whole reason to run it. Assumed-out shows as skipped, so the count + * tells the truth about what was actually exercised. + */ + private fun requireLiveOptIn() = + assumeTrue( + "pass -e live true to run these โ€” they create real accounts on accounts.flamenet.io", + args.getString("live") == "true") + @Test fun signUpOnTheManagedRelay() { - if (args.getString("live") != "true") return + requireLiveOptIn() signUpLive(managed, "m") } @Test fun signUpOnASharedSelfHostedRelay() { - if (args.getString("live") != "true") return + requireLiveOptIn() signUpLive(sharedSelfHosted, "s") } diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt index 84f217c..c6d1379 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt @@ -9,7 +9,10 @@ import androidx.compose.ui.test.printToString import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput +import androidx.test.rule.GrantPermissionRule +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -34,9 +37,44 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SignUpFlowTest { - @get:Rule val compose = createAndroidComposeRule() + /** + * ๐Ÿ”ด Granted BEFORE the activity launches, and that ordering is the point. + * + * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls + * `setContent`. The permission dialog is a separate activity and takes + * focus, so the Compose rule attaches to a window that has no hierarchy + * yet and every test dies with "No compose hierarchies found in the app". + * + * `order = 0` puts this outside the Compose rule, so the grant happens + * first and no dialog ever appears. + */ + @get:Rule(order = 0) + val permissions: GrantPermissionRule = GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + ) - private val relay = "relay1.flamenet.io" + @get:Rule(order = 1) + val compose = createAndroidComposeRule() + + /** + * Which relay to drive. + * + * ๐Ÿ”ด Defaults to the production relay, which is what this has always used โ€” + * so an unqualified run still does what it did. Pass `-e relay ` to + * point it somewhere else, and a LOCAL one is what you want while + * developing: these tests create real accounts, and doing that on + * production for every run is a cost nobody chose. + * + * ./gradlew :app:connectedDebugAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810 + */ + private val relay: String + get() = InstrumentationRegistry.getArguments().getString("relay") + ?: "relay1.flamenet.io" + + /** A local relay is plain HTTP; anything else is not. */ + private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") || + relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https" private lateinit var handle: String @Before @@ -155,14 +193,14 @@ class SignUpFlowTest { }.getOrElse { "(could not read the screen: $it)" } private fun relayReachable(): Boolean = runCatching { - (java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) + (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) .apply { connectTimeout = 8000; readTimeout = 8000 } .responseCode in 200..299 }.getOrDefault(false) @Test fun aPersonCanCreateAnAccountOnASelfHostedRelay() { - if (!relayReachable()) return + assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) compose.onNodeWithTag("relay").performTextClearance() compose.onNodeWithTag("relay").performTextInput(relay) @@ -191,7 +229,7 @@ class SignUpFlowTest { */ @Test fun signingOutReturnsToSignIn() { - if (!relayReachable()) return + assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) aPersonCanCreateAnAccountOnASelfHostedRelay() signOutIfSignedIn() diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..e573324 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..40af9d7 --- /dev/null +++ b/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,22 @@ + + + + + 10.0.2.2 + 127.0.0.1 + localhost + + 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 359f374..543469c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -5,6 +5,8 @@ import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -1618,8 +1620,26 @@ fun ColumnScope.ChatScreen( buddies: List = emptyList(), /** Pictures, so a received bubble can carry its author's. */ avatars: Map = emptyMap(), + /** Ours, so the mention list never offers you yourself. */ + myUserId: Long = 0L, ) { - var draft by remember { mutableStateOf("") } + // โš ๏ธ Keyed on the thread, so switching conversations re-reads rather than + // carrying one person's half-written message into somebody else's composer. + var draft by remember(chat.threadId) { mutableStateOf(model.openDraft()) } + // ๐Ÿ”ด **Every write to `draft` goes through here.** Sending clears the + // composer by assigning to the state directly in three places, and each one + // that forgot to also clear the stored copy would bring the message back the + // next time the conversation was opened โ€” as a draft of something already + // sent, which is worse than not having drafts at all. + val setDraft: (String) -> Unit = { text -> + draft = text + model.rememberDraft(text) + } + // ๐Ÿ”ด Recorded by ID at the moment of picking, never re-derived from the + // text when sending. Deriving it would mean typing somebody's name reached + // them whether or not they were chosen, and renaming a contact would + // silently unmention them. Same rule as iOS. + var pendingMentions by remember { mutableStateOf(emptySet()) } val listState = rememberLazyListState() // Open AT the newest message โ€” a jump, not a tour of the history. @@ -1691,7 +1711,7 @@ fun ColumnScope.ChatScreen( onClick = { model.requestCall(chat.peerId) }, ) } - if (chat.peerId != null) ConversationMenu(model, chat, draft) { draft = "" } + if (chat.peerId != null) ConversationMenu(model, chat, draft) { setDraft("") } chat.group?.let { group -> IconAction( icon = KitIcon.LeaveGroup, @@ -1758,6 +1778,44 @@ fun ColumnScope.ChatScreen( picture = authorId?.let { avatars[it] }, ) DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + // ๐Ÿ”ด The reactions are IN the menu, as its first row, + // rather than behind a "React" item that opens a second + // one. A reaction is a one-tap gesture; making it two taps + // to reach a picker is how the feature stops being used. + // + // โš ๏ธ Only for a message that has an id. Anything sent + // before ids existed has nothing to point at, so the row + // is absent rather than present and inert. + if (line.id != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(Space.xs), + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag("react-row"), + ) { + listOf("โค๏ธ", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ˜ข", "๐Ÿ”ฅ").forEach { emoji -> + val already = line.reactions[emoji]?.contains(myUserId) == true + Text( + emoji, + fontSize = 22.sp, + modifier = Modifier + .clip(RoundedCornerShape(Radius.small)) + .background( + if (already) Palette.Accent.copy(alpha = 0.15f) + else Color.Transparent + ) + .clickable { + // Tapping the one already sent takes it back. + model.reactToMessage( + if (already) "" else emoji, line, chat) + menuOpen = false + } + .padding(4.dp) + .testTag("react-$emoji"), + ) + } + } + } if (line.text.isNotBlank()) { DropdownMenuItem( text = { Text("Copy") }, @@ -1822,6 +1880,65 @@ fun ColumnScope.ChatScreen( // control and the send action on the trailing edge. An outlined text field // with two labelled buttons beside it was the one part of this screen that // still looked like a form. + // ๐Ÿ”ด Groups only. There is nobody to disambiguate in a one-to-one chat, + // and an @ there is just a character. + val mentionable = chat.group?.let { group -> + val query = mentionQuery(draft) + if (query == null) emptyList() + else group.memberIds.filter { id -> + id != myUserId && + (query.isEmpty() || + memberLabel(id, buddies).contains(query, ignoreCase = true)) + } + } ?: emptyList() + + if (mentionable.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(bottom = Space.xs) + .testTag("mentions"), + horizontalArrangement = Arrangement.spacedBy(Space.xs), + ) { + mentionable.forEach { id -> + val label = memberLabel(id, buddies) + Text( + label, + fontSize = 14.sp, + color = Palette.Accent, + modifier = Modifier + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.Accent.copy(alpha = 0.12f)) + .clickable { + val at = draft.lastIndexOf('@') + if (at >= 0) { + draft = draft.substring(0, at) + "@" + label + " " + pendingMentions = pendingMentions + id + } + } + .padding(horizontal = 12.dp, vertical = 6.dp) + .testTag("mention-$id"), + ) + } + } + } + + // โš ๏ธ Only the ones still named in the text. Picking somebody and then + // deleting their name would otherwise still ring them โ€” from their side, a + // notification about a message that does not mention them. + val sendDraft: () -> Unit = { + val named = pendingMentions + .filter { draft.contains("@" + memberLabel(it, buddies)) } + .map { it.toInt() } + model.sendMessage(draft, named) + // โš ๏ธ `setDraft`, not `draft = ""`. Clearing only the composer leaves + // the stored copy behind, and the message comes back next time as a + // draft of something already sent. + setDraft("") + pendingMentions = emptySet() + } + Row( modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm), verticalAlignment = Alignment.CenterVertically, @@ -1848,7 +1965,7 @@ fun ColumnScope.ChatScreen( } BasicTextField( value = draft, - onValueChange = { draft = it }, + onValueChange = setDraft, textStyle = LocalTextStyle.current.copy( fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface, @@ -1860,12 +1977,7 @@ fun ColumnScope.ChatScreen( keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( imeAction = androidx.compose.ui.text.input.ImeAction.Send), keyboardActions = androidx.compose.foundation.text.KeyboardActions( - onSend = { - if (draft.isNotBlank()) { - model.sendMessage(draft) - draft = "" - } - } + onSend = { if (draft.isNotBlank()) sendDraft() } ), modifier = Modifier.fillMaxWidth().testTag("draft"), ) @@ -1920,10 +2032,7 @@ fun ColumnScope.ChatScreen( onClick = { pickImage.launch("image/*") }) } else { Button( - onClick = { - model.sendMessage(draft) - draft = "" - }, + onClick = sendDraft, shape = CircleShape, contentPadding = PaddingValues(0.dp), modifier = Modifier.size(Kit.pillHeight).testTag("send"), @@ -2148,8 +2257,13 @@ private fun Bubble( // to show and a duration that matters; an image has the // reverse. Routing on the kind rather than trying to render // both with one composable. - if (ref.kind == io.flamenet.e2e.Attachment.AUDIO) VoiceBubble(ref, model) - else AttachedImage(ref, model) + when (ref.kind) { + io.flamenet.e2e.Attachment.AUDIO -> VoiceBubble(ref, model) + // A document, not a picture: AttachedImage would download + // a PDF and draw nothing. + io.flamenet.e2e.Attachment.FILE -> FileBubble(ref, line.mine) + else -> AttachedImage(ref, model) + } } if (line.text.isNotEmpty()) { Text( @@ -2162,6 +2276,29 @@ private fun Bubble( } line.link?.let { LinkPreviewCard(it, mine = line.mine) } } + // โš ๏ธ Under the bubble, not inside it. A reaction is something other + // people said ABOUT this message; drawing it inside the bubble makes + // it look like part of what was written. + if (line.reactions.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(Space.xs), + modifier = Modifier + .padding(horizontal = Kit.bubblePadding, vertical = 2.dp) + .testTag("reactions"), + ) { + line.reactions.entries.sortedBy { it.key }.forEach { (emoji, who) -> + Text( + if (who.size == 1) emoji else "$emoji ${who.size}", + fontSize = 13.sp, + color = Palette.SecondaryText, + modifier = Modifier + .clip(RoundedCornerShape(Radius.small)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } } } } @@ -2459,7 +2596,7 @@ fun ColumnScope.StoryStrip( @Composable private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) { val remaining = remember(story.id) { - val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0 + val seconds = story.expires - System.currentTimeMillis() / 1000.0 when { seconds <= 0 -> "expired" seconds < 3600 -> "${(seconds / 60).toInt()}m left" @@ -2470,16 +2607,26 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("story-viewer"), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - AttachedImage(story.ref, model) - if (story.caption.isNotBlank()) Text(story.caption) + val ref = story.ref + if (ref == null) TextStoryCard(story.caption) else AttachedImage(ref, model) + if (story.caption.isNotBlank() && !story.textOnly) Text(story.caption) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text(remaining, style = MaterialTheme.typography.bodySmall) - KitChip(text = "Close", selected = false, - onClick = model::closeStory, tag = "close-story") + Row { + // ios#34 โ€” only on your own, and the rule is + // `Vault.isSaveable`, not this condition. + if (Vault.isSaveable(story.mine, story.ref != null)) { + KitChip(text = "Save", selected = false, + onClick = { model.saveStory(story) }, tag = "save-story") + Spacer(Modifier.size(Space.sm)) + } + KitChip(text = "Close", selected = false, + onClick = model::closeStory, tag = "close-story") + } } } } @@ -3474,3 +3621,115 @@ fun ColumnScope.MailboxesSection( tag = "confirm-add-mailbox", ) } + +/** + * A document that arrived in a conversation. + * + * ๐Ÿ”ด **The classification is shown, and it is a marking.** It says how the + * sender had it filed โ€” it does not mean this device was cleared for it, and + * the row must not imply that. Whoever can read the message already has the + * key. + * + * โš ๏ธ No "Save to Files" here yet: this build has no Files of its own, so + * offering one would be a button that cannot work. It shows what arrived + * rather than pretending to more, which is the honest half of parity while the + * other half is built. + */ +@Composable +private fun FileBubble(ref: io.flamenet.e2e.Attachment.Ref, mine: Boolean) { + val onColour = if (mine) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(vertical = 2.dp).testTag("chat.file"), + ) { + // โš ๏ธ The kit's own icon set, not Material's. This file imports no + // Material icons at all, and the version that reached for one did not + // compile โ€” which went unnoticed because the build was failing on a + // missing ANDROID_HOME before it ever got as far as Kotlin. + Icon( + KitIcon.Attach, + contentDescription = null, + tint = onColour, + modifier = Modifier.size(24.dp), + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + ref.filename.ifEmpty { "A file" }, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = onColour, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + android.text.format.Formatter.formatShortFileSize( + LocalContext.current, ref.bytes.toLong()), + fontSize = 12.sp, + color = onColour.copy(alpha = 0.75f), + ) + // Only when there is something to say: an Unclassified file in + // no areas needs no chip announcing it. + if (ref.classification != 0 || ref.compartments.isNotEmpty()) { + Text( + classificationName(ref.classification).uppercase(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = onColour.copy(alpha = 0.9f), + modifier = Modifier + .background( + onColour.copy(alpha = 0.14f), + RoundedCornerShape(4.dp), + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + .testTag("chat.file.classification"), + ) + } + } + } + } +} + +/** + * โš ๏ธ Hard-coded, matching iOS's local vocabulary, because there is no server to + * ask on either side any more. An ordinal outside the set is shown as itself + * rather than guessed at โ€” inventing "Secret" for a level this deployment calls + * something else would be worse than a number. + */ +private fun classificationName(ordinal: Int): String = when (ordinal) { + 0 -> "Unclassified" + 1 -> "Internal" + 2 -> "Confidential" + 3 -> "Restricted" + else -> "Level $ordinal" +} + +/** + * What is being typed after an unfinished `@`, or null when there isn't one. + * + * ๐Ÿ”ด Driven off the draft rather than a mode flag: somebody deleting their way + * back past the `@` expects the list to go, and a flag has to be remembered to + * be cleared. A space ends it โ€” "@ " is somebody typing an address. + */ +internal fun mentionQuery(draft: String): String? { + val at = draft.lastIndexOf('@') + if (at < 0) return null + val after = draft.substring(at + 1) + if (after.contains(' ') || after.contains('\n')) return null + return after +} + +/** + * A group member by the name you know them by. + * + * โš ๏ธ The relay stores ids and no names, so somebody who is in the group but not + * in your contacts has no name to show. They stay mentionable under their id + * rather than being hidden โ€” being un-mentionable is worse than being ugly. + */ +internal fun memberLabel( + id: Long, + buddies: List, +): String = buddies.firstOrNull { it.userId == id } + ?.let { it.displayName.ifBlank { it.handle } } ?: "user $id" diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index f6f67f8..07188dc 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -232,7 +232,8 @@ private fun Root(model: MessengerViewModel = viewModel()) { val chat = current.openChat if (chat != null) { - ChatScreen(model, chat, current.error, current.buddies, current.avatars) + ChatScreen(model, chat, current.error, current.buddies, current.avatars, + myUserId = current.userId) } else { // ๐Ÿ”ด **The content area is weighted, and the tab bar is // not.** These used to be plain siblings, so the tab bar diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt index e1f066f..a57b87c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt @@ -49,6 +49,18 @@ class MediaSettings(context: Context) { /** Seconds, as [io.flamenet.client.Conversations.postStory] wants it. */ val storyLifetimeSeconds: Double get() = storyLifetimeHours * 60.0 * 60.0 + /** + * Keep your own stories after they expire, in a private archive (ios#54). + * + * ๐Ÿ”ด **Off unless switched on, and an absent value reads as false.** This + * keeps something the interface told everybody would disappear, so it + * cannot be the quiet default โ€” and it only ever holds stories this + * account posted. + */ + var storyArchiveEnabled: Boolean + get() = prefs.getBoolean("stories.archiveEnabled", false) + set(value) { prefs.edit().putBoolean("stories.archiveEnabled", value).apply() } + /** * Refuse to send unless the relay can prove the recipient's keys appear in * the log it publishes. @@ -92,4 +104,31 @@ class MediaSettings(context: Context) { val STORY_LIFETIME_CHOICES = listOf(1, 6, 24, 72, 168) private const val LINK_PREVIEWS = "link_previews" } + + /** + * Refuse to send at all when the send cannot be sealed (ยง14.5, ios#16). + * + * ๐Ÿ”ด **Off by default, and that is the honest default rather than the lazy + * one.** An unsealed send is still end-to-end encrypted; what leaks is that + * this account sent something to that one. Refusing turns a metadata leak + * into an undelivered message, which for most people is the worse outcome + * โ€” so the default degrades and SAYS SO. + */ + var requireSealedSending: Boolean + get() = prefs.getBoolean("privacy.requireSealed", false) + set(value) { prefs.edit().putBoolean("privacy.requireSealed", value).apply() } + + /** Counters for how often sealing degraded (ios#16). */ + var sealingRecord: io.flamenet.e2e.SealingRecord + get() = io.flamenet.e2e.SealingRecord( + sealed = prefs.getInt("privacy.sealing.sealed", 0), + unsealed = prefs.getInt("privacy.sealing.unsealed", 0), + lastUnsealedAt = prefs.getLong("privacy.sealing.lastUnsealedAt", 0L)) + set(value) { + prefs.edit() + .putInt("privacy.sealing.sealed", value.sealed) + .putInt("privacy.sealing.unsealed", value.unsealed) + .putLong("privacy.sealing.lastUnsealedAt", value.lastUnsealedAt) + .apply() + } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt index f66d369..1d36f37 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -131,6 +132,11 @@ fun ColumnScope.MessagesScreen( ) Spacer(Modifier.height(Space.md)) + // โš ๏ธ Above the conversation list. Somebody searching for a word they + // remember is looking for the message, not the person โ€” name matches are + // already covered by the list underneath. + MessageHits(model, query.value) + BuddyCirclesRow(model, phase) Spacer(Modifier.height(Space.sm)) Divider() @@ -294,3 +300,52 @@ private fun UnreadDot() { .background(MaterialTheme.colorScheme.primary), ) } + +/** + * What a search found inside conversations, rather than in their names. + * + * โš ๏ธ Debounced. Each keystroke would otherwise decrypt every thread on the + * device โ€” the cost the iPhone app does not pay, because it keeps the whole + * history in memory. + */ +@Composable +private fun MessageHits(model: MessengerViewModel, query: String) { + var hits by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(query) { + if (query.trim().length < io.flamenet.e2e.MessageSearch.MINIMUM_QUERY) { + hits = emptyList() + return@LaunchedEffect + } + kotlinx.coroutines.delay(250) + hits = model.searchMessages(query) + } + + if (hits.isEmpty()) return + + Text( + "IN MESSAGES", + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = Palette.SecondaryText, + letterSpacing = 0.8.sp, + modifier = Modifier.padding(top = Space.md, bottom = Space.xs), + ) + for (hit in hits) { + Column( + Modifier.fillMaxWidth() + .padding(horizontal = Space.md, vertical = Space.sm) + .testTag("search-hit") + ) { + Text( + hit.snippet, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(Modifier.height(Space.sm)) + Divider() + Spacer(Modifier.height(Space.sm)) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 620f1a1..6c21d79 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -361,6 +361,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { api = MessengerApi( credential.relayBaseUrl, credential.username, credential.appPassword), ) + session?.let { wireSealingPolicy(it) } val fetchedBuddies = runCatching { session!!.roster() } val fetchedGroups = runCatching { session!!.namedGroups() } stored to Phase.SignedIn( @@ -571,6 +572,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { relay = relay, api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential), ) + session?.let { wireSealingPolicy(it) } // ๐Ÿ”ด **Our return address, before the first federated send.** A relay // that accepts a federated envelope records no sender, and our user id @@ -1913,6 +1915,38 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { * would need the relay to know who may read it, which is a list of your * contacts handed to the server for a feature that works without it. */ + /** + * Save a story you posted yourself, before it expires (ios#34). + * + * ๐Ÿ”ด The rule is `Vault.isSaveable`, checked here rather than only where + * the menu item is drawn. Saving somebody else's story keeps a copy of + * something they were told would disappear. + * + * โš ๏ธ The message says the copy has LEFT the disappearing model and where + * it went. "Saved" alone would be true and useless โ€” somebody not told + * that may reasonably assume the expiry still covers it. + */ + fun saveStory(story: Vault.Story) = viewModelScope.launch { + val current = session ?: return@launch + val ref = story.ref + if (!Vault.isSaveable(story.mine, ref != null) || ref == null) return@launch + val saved = withContext(Dispatchers.IO) { + runCatching { + val bytes = current.attachmentBytes(ref, getApplication().cacheDir) + StorySave.save(getApplication(), bytes, ref.mime, ref.id) + }.getOrNull() + } + update { + val message = if (saved != null) { + "Saved to your gallery as $saved. That copy is yours to keep โ€” " + + "it will not disappear when the story does." + } else { + "Could not save that story." + } + it.copy(error = message) + } + } + /** Change who may see this account's stories. */ fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch { val current = session ?: return@launch @@ -1953,6 +1987,169 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { streamUrl?.takeIf { it.isNotBlank() }) } + /** + * Post a story that is only words. + * + * Same audience rule as a picture: the audience decides who is SENT it, + * because there is no copy on the relay that anybody could be denied. + */ + fun postTextStory(text: String) = viewModelScope.launch { + val current = session ?: return@launch + val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() + if (contacts.isEmpty()) { + update { it.copy(error = "Add a contact before posting a story.") } + return@launch + } + val recipients = current.storyAudience().recipients(contacts) + if (recipients.isEmpty()) { + update { + it.copy(error = "Nobody is in your story audience, so this " + + "would go to no one. Change who can see your stories on Account.") + } + return@launch + } + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { + runCatching { + current.postTextStory(text, recipients, + MediaSettings(getApplication()).storyLifetimeSeconds) + } + } + done.onSuccess { posted -> + update { + it.copy(busy = false, stories = current.stories(), + error = if (posted.missed == 0) null + else "Posted to ${posted.reached} of " + + "${posted.reached + posted.missed} contacts.") + } + }.onFailure { failure -> + update { it.copy(busy = false, error = failure.message ?: "Couldn't post that.") } + } + } + + /** + * React to a message, or take it back with an empty [emoji]. + * + * โš ๏ธ The transcript is rewritten by [Session.react] before the send, so + * the tap registers immediately and a failed send puts back what was + * there. All this has to do is re-read the thread afterwards. + */ + fun reactToMessage(emoji: String, line: Session.Line, chat: Phase.Chat) = + viewModelScope.launch { + val current = session ?: return@launch + val mid = line.id ?: return@launch + // Whose message it is, which is what the other side needs to find + // it: our own id for something we sent, theirs for something they did. + val author = if (line.mine) current.userId + else line.author.takeIf { it != 0L } ?: chat.peerId ?: 0L + withContext(Dispatchers.IO) { + runCatching { + current.react(emoji, chat.threadId, mid, author, + peerUserId = chat.peerId, group = chat.group) + } + } + update { + it.copy(openChat = it.openChat?.copy( + lines = current.history(chat.threadId))) + } + } + + /** + * Wire the sealing policy and counters into a fresh session (ios#16). + * + * โš ๏ธ In one place, because two call sites building a `Session` and only + * one of them applying the policy is how a setting silently stops working + * on one of the two sign-in paths. + */ + private fun wireSealingPolicy(session: Session) { + val settings = MediaSettings(getApplication()) + session.conversations.requireSealed = { settings.requireSealedSending } + session.onSendSealed = { wasSealed, at -> + settings.sealingRecord = settings.sealingRecord.recording(wasSealed, at) + } + } + + /** + * 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 = + session?.reactionsFor(storyKey).orEmpty().values + .groupingBy { it }.eachCount() + + fun myStoryReaction(storyKey: String): String? = session?.myReactionTo(storyKey) + + /** React to somebody's story. Goes to the author alone. */ + fun reactToStory(emoji: String, story: Vault.Story) = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { runCatching { current.reactToStory(emoji, story) } } + update { it.copy(stories = current.stories()) } + } + + /** Reply privately to somebody's story, in the ordinary conversation. */ + fun replyToStory(body: String, story: Vault.Story) = viewModelScope.launch { + val current = session ?: return@launch + val done = withContext(Dispatchers.IO) { + runCatching { current.replyToStory(body, story) } + } + done.onFailure { failure -> + update { it.copy(error = failure.message ?: "Couldn't send that reply.") } + } + } + + /** Stop showing somebody's stories on this device. */ + fun setStoriesMuted(authorId: Long, muted: Boolean) = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { current.setStoriesMuted(authorId, muted) } + update { it.copy(stories = current.stories()) } + } + fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?, /** THIS story's audience from the composer; null = the account setting. */ audienceOverride: Session.StoryAudience? = null, @@ -2180,7 +2377,61 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { }.onFailure { failure -> update { it.copy(error = describe(failure)) } } } - fun sendMessage(text: String) = viewModelScope.launch { + /** + * How the open conversation is named in the vault โ€” the same ids the + * transcript uses, so a group, a local contact and somebody on another + * relay cannot collide. + */ + private fun Phase.Chat.vaultThreadId(): String = when { + group != null -> Vault.threadForGroup(group.id) + address != null -> Vault.threadForAddress(address) + peerId != null -> Vault.threadForUser(peerId) + else -> threadId + } + + /** What was half-written in the open conversation, restored on open. */ + fun openDraft(): String { + val chat = signedIn()?.openChat ?: return "" + return vault?.draft(chat.vaultThreadId()).orEmpty() + } + + /** + * Keep, or forget, what is in the composer. + * + * โš ๏ธ Called on every change including back to empty โ€” clearing the + * composer has to clear what was stored, or a draft somebody deliberately + * deleted reappears the next time they open the conversation. + */ + fun rememberDraft(text: String) { + val chat = signedIn()?.openChat ?: return + val vault = vault ?: return + viewModelScope.launch(Dispatchers.IO) { vault.saveDraft(chat.vaultThreadId(), text) } + } + + /** + * Messages whose words match, across every conversation on this device. + * + * ๐Ÿ”ด **Scanned, not indexed.** An index over the decrypted history is + * plaintext by another name and would have to be sealed and taken by + * `forgetEverything` like everything else in the vault. See + * [io.flamenet.e2e.MessageSearch] for the condition under which that stops + * being the right call. + * + * โš ๏ธ Off the main thread, because unlike the iPhone app this decrypts each + * thread on demand rather than reading one already in memory. + * + * โš ๏ธ The query is never persisted. What somebody searched for is often more + * revealing than what they found. + */ + suspend fun searchMessages(query: String): List { + val vault = vault ?: return emptyList() + return withContext(Dispatchers.IO) { + runCatching { io.flamenet.e2e.MessageSearch.hits(query, vault) } + .getOrDefault(emptyList()) + } + } + + fun sendMessage(text: String, mentions: List = emptyList()) = viewModelScope.launch { val current = session ?: return@launch val chat = signedIn()?.openChat ?: return@launch if (text.isBlank()) return@launch @@ -2189,7 +2440,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { runCatching { val group = chat.group val address = chat.address - if (group != null) current.sendGroup(group, text) + if (group != null) current.sendGroup(group, text, mentions) // ๐Ÿ”ด Before the `peerId!!` below, because a remote thread has // no peer id and that assertion would crash rather than send. // @@ -2525,6 +2776,23 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { "Your session expired. Sign out and back in to keep going." is AccountsClient.Failure.HandleTaken -> "That handle is already taken." + // โš ๏ธ Each of these says what to DO. A refusal somebody cannot act on is + // the same as no explanation: they retry the same thing and are refused + // again. "Too similar" without "changing a digit won't help" sends + // people round that loop deliberately. + is AccountsClient.Failure.EmailTaken -> + "An account with that email already exists. Try signing in instead." + is AccountsClient.Failure.HandleConfusable -> + "That handle looks too much like one that already exists. " + + "Changing a digit won't help โ€” try a different word." + is AccountsClient.Failure.DisposableEmail -> + "That email provider isn't accepted. Use an address you'll still be " + + "able to read later โ€” it's how you get back in." + is AccountsClient.Failure.BadHandle -> + "Handles can only contain lowercase letters and numbers." + is AccountsClient.Failure.BadEmail -> "That doesn't look like a valid email address." + is AccountsClient.Failure.WeakPassword -> + "Your passphrase needs to be at least 8 characters." is AccountsClient.Failure.RateLimited -> "Too many attempts. Wait a few minutes." // โš ๏ธ Not the underlying message: at this layer it is a Java networking // string ("Failed to connect to /10.0.2.2:443"), which names an address @@ -2561,7 +2829,20 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { 409 -> "That handle is already taken. Try a different one." 400, 422 -> "That server couldn't accept those details. " + "Check the handle and try again." - 403 -> "That isn't allowed on this server." + // ๐Ÿ”ด **Say what the relay said.** A 403 is a rule refusing this + // specific thing, and the relay writes a sentence that names it + // and what to do โ€” "this account already has 3 registered devices, + // remove one from the Devices screen". "That isn't allowed on this + // server" replaces an answer somebody can act on with one they + // cannot, and leaves them guessing at a rule nobody stated. + // + // โš ๏ธ Only when it really is a sentence. `body` falls back to the + // raw payload when there is no `message`, so anything that looks + // like JSON or a bare code keeps the generic line rather than + // putting a fragment on screen. + 403 -> failure.body + .takeIf { it.isNotBlank() && !it.trimStart().startsWith("{") && " " in it } + ?: "That isn't allowed on this server." in 500..599 -> "That server is having trouble right now. " + "Try again in a moment." else -> "That server couldn't complete this. Try again in a moment." diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 4374c31..e2b6560 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -40,6 +40,27 @@ class Session( val text: String, val mine: Boolean, val at: Long, + /** + * What identifies this message to the other side. + * + * ๐Ÿ”ด **Null for anything stored before ids existed, and that is the + * whole compatibility story.** A reaction, an edit and a pin all say + * "this one" by id; a line with none simply cannot be the subject of + * any of them. Making one up on read would be worse โ€” two devices + * would invent different ids for the same message and a reaction would + * land on nothing. + */ + val id: String? = null, + /** + * Emoji to the people who sent it, folded onto the message rather than + * filed as one of their own. + * + * โš ๏ธ One emoji per person, so reacting again replaces rather than + * accumulates โ€” which is why this is keyed by emoji and holds a set of + * authors, and why applying one removes that author from every other + * entry first. + */ + val reactions: Map> = emptyMap(), /** * Who wrote it โ€” a user id, 0 when unrecorded. Groups need it: every * received bubble in a group was captioned and pictured as the GROUP @@ -76,6 +97,31 @@ class Session( val delivery: Delivery? = null, /** A sender-generated preview of a link in this message (ยง6.12). */ val link: Content.LinkPreview? = null, + /** + * The sender's own id for this message (ยง6.9). + * + * ๐Ÿ”ด **The wire always carried one and this type never stored it.** + * `Content.groupText` has taken a `messageId` since ยง6.9 shipped and + * the parser reads it back โ€” the transcript was where it stopped. So + * nothing here could name an earlier message: no replies to resolve, no + * reactions to fold, no edits to apply. + */ + val messageId: String? = null, + /** + * What this message quotes, when it is a reply (ยง6.9). + * + * Also never stored, so a reply from the iPhone app arrived as a bare + * message with the quote silently removed โ€” and the reader had no way + * to know anything had been dropped. + */ + val about: Content.Reference? = null, +/** + * A timer this one message carries, in seconds (ยง6.17); 0 for none. + * + * โš ๏ธ Not the conversation's โ€” `Vault.effectiveLifetime` combines the + * two, and the shorter always wins. + */ + val lifetimeSeconds: Int = 0, ) /** Where an outbound message got to. */ @@ -94,6 +140,13 @@ class Session( text = m.optString("text"), mine = m.optBoolean("mine"), at = m.optLong("at"), + id = m.optString("mid").takeIf { it.isNotBlank() }, + reactions = m.optJSONObject("rx")?.let { stored -> + stored.keys().asSequence().associateWith { emoji -> + val who = stored.optJSONArray(emoji) ?: org.json.JSONArray() + (0 until who.length()).map { i -> who.getLong(i) }.toSet() + }.filterValues { it.isNotEmpty() } + }.orEmpty(), author = m.optLong("author"), attachment = m.optJSONObject("att")?.let { att -> Attachment.Ref.fromJson(m.optString("kind", Attachment.IMAGE), att, 0.0) @@ -107,6 +160,9 @@ class Session( delivery = m.optString("delivery").takeIf { it.isNotBlank() } ?.let { runCatching { Delivery.valueOf(it) }.getOrNull() }, link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) }, + messageId = m.optString("mid").takeIf { it.isNotEmpty() }, + about = m.optJSONObject("re")?.let { Content.Reference.fromJson(it) }, + lifetimeSeconds = m.optInt("secs", 0), ) } } @@ -121,10 +177,21 @@ class Session( at: Long = System.currentTimeMillis(), delivery: Delivery? = null, link: Content.LinkPreview? = null, + /** What the other side will call this message when it refers back. */ + messageId: String? = null, + /** What this message quotes, when it is a reply (ยง6.9). */ + about: Content.Reference? = null, + /** A timer this one message carries, in seconds (ยง6.17); 0 for none. */ + lifetimeSeconds: Int = 0, ) { vault.append(threadId, JSONObject() .put("author", author) + .also { if (messageId != null) it.put("mid", messageId) } .put("text", text).put("mine", mine).put("at", at) + // โš ๏ธ Written only when there is one. A zero on every message would + // grow the transcript for a feature almost nobody uses, and would + // make "no timer" indistinguishable from "a timer of nothing". + .also { if (lifetimeSeconds > 0) it.put("secs", lifetimeSeconds) } .also { if (delivery != null) it.put("delivery", delivery.name) } .also { if (sticker != null) { @@ -132,7 +199,12 @@ class Session( } } .also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) } - .also { if (link != null) it.put("lnk", link.toJson()) }) + .also { if (link != null) it.put("lnk", link.toJson()) } + // โš ๏ธ Written only when present. A null on every message would grow + // the transcript for nothing and make "no id" indistinguishable + // from "an id of nothing". + .also { if (messageId != null) it.put("mid", messageId) } + .also { if (about != null) it.put("re", about.toJson()) }) // ๐Ÿ”ด A reply arriving cancels the reminder, and it is done HERE because // this is the one place every inbound message passes through. Hanging it @@ -292,18 +364,38 @@ class Session( * for every message that has no link in it. */ link: Content.LinkPreview? = null, + /** A timer for this one message, in seconds (ยง6.17); 0 for none. */ + lifetimeSeconds: Int = 0, ): Conversations.Sent { - // With no preview this is the raw body, byte for byte, exactly as - // before โ€” `Content.text` only builds an envelope when it has to. - val plaintext = Content.text(text, link = link) + // ๐Ÿ”ด **Every message gets an id now, so this is always an envelope.** + // It used to be the raw body byte for byte, which is cheaper on the + // wire and cost us reactions, edits and pins: all three say "this + // one", and there was nothing to point at. iOS has always wrapped, and + // both engines read a bare body as text, so an older peer is + // unaffected โ€” see `isUnknownControl`. + val mid = java.util.UUID.randomUUID().toString() + val plaintext = Content.text(text, messageId = mid, link = link, + lifetimeSeconds = lifetimeSeconds) val sent = conversations.send(peerUserId, plaintext) val at = System.currentTimeMillis() + // ๐Ÿ”ด The sender's own copy expires too. A timer that only cleared the + // recipient's phone would be the opposite of the promise: the person + // who set it would be the one still holding the message. record(Vault.threadForUser(peerUserId), text, mine = true, at = at, - link = link, + link = link, messageId = mid, lifetimeSeconds = lifetimeSeconds, delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) + // ios#16 โ€” every send's outcome, not just this one's. `Sent.sealed` + // answers "was that sealed"; the record answers "how often does this + // happen to me", which nobody could ask before. + onSendSealed(sent.sealed, at) // ๐Ÿ”ด Queued, not abandoned. A friend who is simply signed out should // not cost the sender a reminder to try again later. - if (!sent.reachedSomeone) enqueue(peerUserId, text, at) + // + // โš ๏ธ The ENVELOPE is queued, not the bare text: a retry has to arrive + // carrying the same id, or the copy that eventually lands is a + // different message from the one already in this transcript and + // nothing said about it reaches anywhere. + if (!sent.reachedSomeone) enqueue(peerUserId, plaintext, at) return sent } @@ -491,6 +583,85 @@ class Session( * which transmits its argument as the plaintext verbatim โ€” so both kinds * take the same path out. */ + /** + * Fold a reaction onto the message it is about. + * + * ๐Ÿ”ด **One emoji per person.** Reacting again replaces; reacting with the + * same one, or with an empty string, takes it back. So the author is + * removed from every emoji first and then added to at most one โ€” an + * accumulate-only version would let one tap each on five emoji stand + * simultaneously, which is not what anybody means by reacting. + * + * โš ๏ธ Returns false when the message is not in this thread, which is the + * ordinary case for a reaction to something sent before ids existed, or + * to a message this device never received. Silently doing nothing is + * right; inventing a bubble for it would not be. + */ + fun applyReaction(threadId: String, messageId: String, author: Long, emoji: String): Boolean { + val stored = vault.history(threadId) + var hit = false + for (i in 0 until stored.length()) { + val row = stored.getJSONObject(i) + if (row.optString("mid") != messageId) continue + hit = true + val existing = row.optJSONObject("rx") ?: JSONObject() + val rebuilt = JSONObject() + for (key in existing.keys()) { + val who = existing.optJSONArray(key) ?: org.json.JSONArray() + val kept = org.json.JSONArray() + for (j in 0 until who.length()) { + if (who.getLong(j) != author) kept.put(who.getLong(j)) + } + if (kept.length() > 0) rebuilt.put(key, kept) + } + if (emoji.isNotBlank()) { + val who = rebuilt.optJSONArray(emoji) ?: org.json.JSONArray() + who.put(author) + rebuilt.put(emoji, who) + } + if (rebuilt.length() > 0) row.put("rx", rebuilt) else row.remove("rx") + break + } + if (hit) vault.replaceHistory(threadId, stored) + return hit + } + + /** + * React to somebody's message, or take it back with an empty [emoji]. + * + * โš ๏ธ **Applied here first, then sent.** A reaction is a one-tap gesture, + * and waiting on a round trip to see your own tap register makes it feel + * broken on a slow connection. A send that fails restores what was there + * before rather than leaving a reaction that exists only on this phone โ€” + * and restoring "nothing" would be wrong for somebody who was replacing + * one emoji or removing one, so the previous value is captured first. + */ + fun react(emoji: String, threadId: String, messageId: String, author: Long, + peerUserId: Long?, group: RelayClient.Group?): Boolean { + val previous = history(threadId).firstOrNull { line -> line.id == messageId } + ?.reactions?.entries?.firstOrNull { entry -> userId in entry.value }?.key.orEmpty() + if (!applyReaction(threadId, messageId, userId, emoji)) return false + val reference = Content.Reference(messageId, author, "") + val body = Content.reaction(emoji, reference, groupId = group?.id) + val sent = runCatching { + when { + // โš ๏ธ Pairwise, like every group message: the relay does no + // fan-out, so a group reaction is one send per member. + group != null -> group.memberIds + .filter { it != userId } + .map { conversations.send(it, body).reachedSomeone } + .any { it } + peerUserId != null -> conversations.send(peerUserId, body).reachedSomeone + else -> false + } + }.getOrDefault(false) + if (!sent) { + applyReaction(threadId, messageId, userId, previous) + return false + } + return true + } + private fun enqueue(peerUserId: Long, text: String, at: Long, thread: String? = null) { val pending = vault.outbox() @@ -646,10 +817,13 @@ class Session( } .getOrNull() - fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent { - val sent = conversations.sendToGroup(group, text, userId) + fun sendGroup(group: RelayClient.Group, text: String, + mentions: List = emptyList()): Conversations.Sent { + val mid = java.util.UUID.randomUUID().toString() + val sent = conversations.sendToGroup(group, text, userId, mentions, messageId = mid) val at = System.currentTimeMillis() record(Vault.threadForGroup(group.id), text, mine = true, author = userId, at = at, + messageId = mid, delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) // ๐Ÿ”ด **The group half of the outbox promise.** Members with no // registered device are filed in the failure map as "user:" by the @@ -657,7 +831,8 @@ class Session( // the group thread to restate. A member whose devices took it is not // queued โ€” resending to them would duplicate the message. val body = io.flamenet.e2e.Content.groupText( - text, group.id, groupName = vault.groupName(group.id)) + text, group.id, groupName = vault.groupName(group.id), mentions = mentions, + messageId = mid) sent.failed.keys.filter { it.startsWith("user:") } .mapNotNull { it.removePrefix("user:").toLongOrNull() } .forEach { member -> @@ -723,6 +898,8 @@ class Session( val touched = mutableSetOf() val now = System.currentTimeMillis() / 1000.0 val incomingStories = mutableListOf() + // storyKey, who reacted, the emoji ("" takes one back). + val storyReactionsIncoming = mutableListOf>() for (message in arrived) { // โš ๏ธ **A story bypasses the thread entirely (ยง6.6).** It belongs to @@ -730,8 +907,67 @@ class Session( // somebody posted into a one-to-one conversation they did not have. // One that arrives already expired โ€” posted while this device was // offline โ€” is dropped rather than shown late. + // A story that is only words. Checked before the attachment path, + // which has nothing to key on for one. + val textExpires = message.content.textStoryExpires + if (textExpires != null && message.fromAddress == null) { + val story = Vault.Story( + id = message.content.messageId ?: java.util.UUID.randomUUID().toString(), + authorId = message.fromUser, + mine = false, + ref = null, + caption = message.content.text, + postedAt = System.currentTimeMillis(), + expiresAt = textExpires, + ) + if (story.isActive(now)) incomingStories += story + continue + } + // A reaction to a MESSAGE. Folded onto the message it is about, + // never filed as one of its own โ€” a thread where every thumbs-up + // is a bubble is a thread nobody can read. + // + // ๐Ÿ”ด Checked before the story branch, which is the narrower case: + // a story reaction carries the same `reaction` field and is told + // apart only by `reactsToStory`. + if (message.content.reaction != null && !message.content.reactsToStory) { + val about = message.content.about + if (about != null) { + val gid = if (message.fromAddress != null) null + else message.content.groupId + val thread = gid?.let(Vault::threadForGroup) + ?: message.fromAddress?.let(Vault::threadForAddress) + ?: Vault.threadForUser(message.fromUser) + if (applyReaction(thread, about.messageId, message.fromUser, + message.content.reaction.orEmpty())) { + touched += thread + } + } + continue + } + // A reaction to one of OUR stories. Folded onto the story rather + // than filed as a message โ€” and never notified, for the same + // reason a thumbs-up on a message is not. + if (message.content.reactsToStory) { + val about = message.content.about + if (about != null) { + storyReactionsIncoming += Triple( + about.messageId, message.fromUser, message.content.reaction.orEmpty()) + } + continue + } val ref = message.content.attachment if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) { + // ๐Ÿ”ด A story is caught before the group-message branch, so + // without this check a sender could attach the id of a group + // they are not in and have their story shown as having come + // from it. Dropped to null rather than refused: the story + // itself is legitimately theirs, and only the claim about + // where it was posted is unverified. + val claimed = message.content.groupId + val storyGid = claimed?.takeIf { g -> + groupRoster(g)?.contains(message.fromUser) == true + } val story = Vault.Story( id = ref.id, authorId = message.fromUser, @@ -739,6 +975,8 @@ class Session( ref = ref, caption = message.content.text, postedAt = System.currentTimeMillis(), + set = message.content.storySet, + gid = storyGid, ) if (story.isActive(now)) incomingStories += story continue @@ -800,7 +1038,14 @@ class Session( record(thread, message.content.text, mine = false, attachment = message.content.attachment, sticker = message.content.sticker?.let { Sticker(it.pack, it.id) }, - author = message.fromUser) + author = message.fromUser, + // โš ๏ธ THEIR id, not one we make up. It is what they will use + // when they edit this message, and what we use when we react + // to it โ€” an id invented here would match nothing on their + // side. + messageId = message.content.messageId, + about = message.content.about, + lifetimeSeconds = message.content.lifetimeSeconds ?: 0) touched += thread } @@ -809,15 +1054,192 @@ class Session( val known = existing.map { it.id }.toSet() vault.saveStories(existing + incomingStories.filterNot { it.id in known }, now) } + storyReactionsIncoming.forEach { (key, who, emoji) -> + applyStoryReaction(key, who, emoji) + } return touched } // -- Stories (ยง6.6) ------------------------------------------------------ + /** + * Who reacted to which story, and with what. + * + * โš ๏ธ One reaction per person, exactly like a message: a second emoji + * replaces the first rather than accumulating, or a reaction becomes a way + * to spam somebody's story. + * + * Session-lived, like `seen`. A reaction to a story that expires tomorrow + * does not need to outlive it. + */ + private val storyReactions = mutableMapOf>() + + fun applyStoryReaction(storyKey: String, who: Long, emoji: String) { + val forStory = storyReactions.getOrPut(storyKey) { mutableMapOf() } + if (emoji.isEmpty()) forStory.remove(who) else forStory[who] = emoji + if (forStory.isEmpty()) storyReactions.remove(storyKey) + } + + /** + * Whether the private story archive is switched on (ios#54). + * + * โš ๏ธ Injected rather than read from a context here: this layer has no + * Android dependency, and an unset value must read as OFF โ€” the archive + * keeps something the interface said would disappear. + */ + var archiveEnabled: () -> Boolean = { false } + + /** Stories this account posted, kept past their expiry (ios#54). */ + fun storyArchive(): List = vault.storyArchive() + + /** Empty the archive (ios#54). */ + fun clearStoryArchive() = vault.saveStoryArchive(emptyList()) + + /** + * Post a story to a group (ยง6.24). + * + * ๐Ÿ”ด **The group's roster is the audience, and `storyAudience` does NOT + * apply.** Two different lists making two different statements โ€” who may + * see this account's stories, and who is in a conversation โ€” and they will + * disagree. The roster wins: a story addressed to a group that silently + * dropped members would be a group message part of the group cannot see. + * The caller is responsible for having disclosed that before the first one. + * + * โš ๏ธ Recorded in the STORY set, never the group thread. `gid` names the + * audience, not a destination โ€” a story rendered into the conversation + * would be a story that is a message, and one that never expires out of it. + */ + fun postGroupStory( + bytes: ByteArray, + mime: String, + width: Int, + height: Int, + caption: String?, + gid: String, + memberIds: List, + lifetimeSeconds: Double, + ): Int { + val recipients = memberIds.filter { it != userId } + if (recipients.isEmpty()) return 0 + val posted = conversations.postStory( + bytes, mime, width, height, caption, recipients, + lifetimeSeconds = lifetimeSeconds, groupId = gid) + if (posted.reached > 0) { + val now = System.currentTimeMillis() / 1000.0 + vault.saveStories( + vault.loadStories() + Vault.Story( + id = posted.ref.id, + authorId = userId, + mine = true, + ref = posted.ref, + caption = caption.orEmpty(), + postedAt = System.currentTimeMillis(), + seen = true, + gid = gid, + ), + now, + ) + } + 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, + ) + } + + /** + * Told after every 1:1 send whether it was sealed (ios#16). + * + * โš ๏ธ Injected: the counters live in Android preferences and this layer has + * no Android dependency. + */ + var onSendSealed: (Boolean, Long) -> Unit = { _, _ -> } + + fun reactionsFor(storyKey: String): Map = storyReactions[storyKey].orEmpty() + + fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId) + + fun reactToStory(emoji: String, story: Vault.Story) { + if (story.mine) return + conversations.reactToStory(emoji, story.id, story.authorId) + applyStoryReaction(story.id, userId, emoji) + } + + fun replyToStory(body: String, story: Vault.Story) { + conversations.replyToStory(body, story.id, story.authorId, + story.caption.take(80)) + } + + fun postTextStory(text: String, toUserIds: List, lifetimeSeconds: Double) + : io.flamenet.client.Conversations.TextStoryPosted { + val posted = conversations.postTextStory(text, toUserIds, lifetimeSeconds) + if (posted.reached > 0) { + val now = System.currentTimeMillis() / 1000.0 + vault.saveStories( + vault.loadStories() + Vault.Story( + id = posted.key, + authorId = userId, + mine = true, + ref = null, + caption = text, + postedAt = System.currentTimeMillis(), + seen = true, + expiresAt = posted.expires, + ), + now, + ) + } + return posted + } + + /** + * ๐Ÿ”ด Muting somebody's stories is NOT muting their messages, and uses its + * own key. Wanting somebody's news to stop is not wanting to stop hearing + * from them, and folding the two together would make "I don't need their + * holiday photos" cost a missed message. It is not blocking either: + * nothing is told to the other person and nothing is refused. + */ + fun storiesMuted(authorId: Long): Boolean = vault.storyMutes().contains(authorId) + + fun setStoriesMuted(authorId: Long, muted: Boolean) { + val current = vault.storyMutes().toMutableSet() + if (muted) current += authorId else current -= authorId + vault.saveStoryMutes(current) + } + /** Everything still live, newest first. */ fun stories(): List { val now = System.currentTimeMillis() / 1000.0 - val live = vault.loadStories().filter { it.isActive(now) } + val all = vault.loadStories() + // ios#54 โ€” anything of YOUR OWN that has expired is archived here, + // before `saveStories` drops it. This is the only moment it can be + // caught: expired stories leave on write, so nothing downstream sees + // them again. + if (archiveEnabled()) { + val expiredMine = all.filter { it.mine && !it.isActive(now) } + if (expiredMine.isNotEmpty()) { + vault.saveStoryArchive(Vault.archive(vault.storyArchive(), expiredMine)) + } + } + val live = all + .filter { it.isActive(now) } + // One place, so every screen agrees about whose stories are hidden. + .filter { it.mine || !storiesMuted(it.authorId) } // Written back so expired bytes leave the device rather than lingering // until the next post. vault.saveStories(live, now) @@ -938,6 +1360,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() + + /** + * 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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 82725eb..7e7c067 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -31,6 +31,16 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.graphics.asImageBitmap import io.flamenet.e2e.Vault +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.TextButton +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue /** * Stories as a feed you scroll, which is the kit's Timeline. @@ -54,6 +64,7 @@ fun ColumnScope.StoriesFeed( phase: MessengerViewModel.Phase.SignedIn, ) { LaunchedEffect(Unit) { model.refreshStories() } + var writingWords by remember { mutableStateOf(false) } // Post from HERE too. The strip on Messages was the only way in, which // means the tab named Stories could show them but not make one. @@ -74,14 +85,41 @@ fun ColumnScope.StoriesFeed( style = MaterialTheme.typography.headlineMedium, modifier = Modifier.weight(1f), ) - Icon( - KitIcon.Add, - contentDescription = "New story", - tint = Palette.Accent, - modifier = Modifier - .size(28.dp) - .clickable { pickStory.launch("*/*") } - .testTag("new-story"), + // ๐Ÿ”ด A menu, not a picker. + // + // This went straight to the file chooser, so there was no way to reach + // anything else โ€” a story of words was unreachable even once it + // existed, for the same reason it was on iOS. + var choosing by remember { mutableStateOf(false) } + Box { + Icon( + KitIcon.Add, + contentDescription = "New story", + tint = Palette.Accent, + modifier = Modifier + .size(28.dp) + .clickable { choosing = true } + .testTag("new-story"), + ) + DropdownMenu(expanded = choosing, onDismissRequest = { choosing = false }) { + DropdownMenuItem( + text = { Text("Photo or video") }, + onClick = { choosing = false; pickStory.launch("*/*") }, + modifier = Modifier.testTag("new-story-photo"), + ) + DropdownMenuItem( + text = { Text("Just words") }, + onClick = { choosing = false; writingWords = true }, + modifier = Modifier.testTag("new-story-text"), + ) + } + } + } + + if (writingWords) { + TextStoryComposer( + onDismiss = { writingWords = false }, + onPost = { text -> writingWords = false; model.postTextStory(text) }, ) } Spacer(Modifier.height(Space.sm)) @@ -106,9 +144,9 @@ private fun StoryCard( ?: if (story.mine) "You" else "user ${story.authorId}" } val remaining = remember(story.id) { - val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0 + val seconds = story.expires - System.currentTimeMillis() / 1000.0 when { - story.ref.expires <= 0L -> "no expiry" + story.expires <= 0L -> "no expiry" seconds <= 0 -> "expired" seconds < 3600 -> "${(seconds / 60).toInt()} min left" else -> "${(seconds / 3600).toInt()} h left" @@ -180,17 +218,140 @@ private fun StoryCard( if (stream != null && streamHost != null && io.flamenet.e2e.StreamDisclosure.acceptable(stream)) { StoryStreamGate(stream, streamHost) - } else if (story.ref.mime.startsWith("video/")) { - StoryVideoCard(story.ref, model) } else { - StoryImage(story.ref, model) + val ref = story.ref + when { + // ๐Ÿ”ด Words alone, set large on a tinted band โ€” the way + // every other messenger draws one. A text story rendered + // as a caption under an empty rectangle reads as a picture + // that failed to load. + ref == null -> TextStoryCard(story.caption) + ref.mime.startsWith("video/") -> StoryVideoCard(ref, model) + else -> StoryImage(ref, model) + } } } - if (story.caption.isNotBlank()) { + if (story.caption.isNotBlank() && !story.textOnly) { Spacer(Modifier.height(Space.md)) Text(story.caption, fontSize = 14.sp, maxLines = 3) } + + Spacer(Modifier.height(Space.md)) + StoryActions(story, model) + } +} + +/** + * React, reply, or stop seeing somebody's stories. + * + * ๐Ÿ”ด A fixed row of five, not a picker. A story is glanced at and scrolled + * past; anything that takes two taps to say "nice" does not get used, and the + * feature is only worth having if it is faster than typing. + * + * โš ๏ธ Your OWN story gets the tally instead โ€” you cannot react to yourself, and + * a row of buttons that do nothing is worse than no row. + */ +@Composable +private fun StoryActions(story: Vault.Story, model: MessengerViewModel) { + val quick = listOf("โค๏ธ", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ˜ข", "๐Ÿ”ฅ") + var replying by remember { mutableStateOf(false) } + var muting by remember { mutableStateOf(false) } + + if (story.mine) { + val tally = model.storyTally(story.id) + if (tally.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.testTag("story-tally")) { + tally.entries.sortedBy { it.key }.forEach { (emoji, count) -> + Text("$emoji $count", fontSize = 13.sp, color = Palette.Mist) + } + } + } + return + } + + val mine = model.myStoryReaction(story.id) + Row(verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + quick.forEach { emoji -> + Text( + emoji, + fontSize = 20.sp, + modifier = Modifier + .clip(RoundedCornerShape(Radius.small)) + .background( + if (mine == emoji) Palette.Accent.copy(alpha = 0.15f) + else androidx.compose.ui.graphics.Color.Transparent + ) + .clickable { + // Tapping the one already sent takes it back. + model.reactToStory(if (mine == emoji) "" else emoji, story) + } + .padding(4.dp) + .testTag("story-react-$emoji"), + ) + } + Spacer(Modifier.weight(1f)) + Text("Reply", fontSize = 13.sp, color = Palette.Accent, + modifier = Modifier.clickable { replying = true }.testTag("story-reply")) + Spacer(Modifier.size(12.dp)) + Text("Mute", fontSize = 13.sp, color = Palette.Mist, + modifier = Modifier.clickable { muting = true }.testTag("story-mute")) + } + + if (replying) { + var draft by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { replying = false }, + title = { Text("Reply privately") }, + // ๐Ÿ”ด Says where it goes. A reply that looked like a comment under + // the story โ€” readable by whoever else saw it โ€” would be a privacy + // surprise in the wrong direction. + text = { + Column { + Text("Goes to them in your conversation. Nobody else who " + + "saw this story can read it.", fontSize = 13.sp) + Spacer(Modifier.height(Space.sm)) + OutlinedTextField( + value = draft, onValueChange = { draft = it }, + placeholder = { Text("Message") }, + modifier = Modifier.fillMaxWidth().testTag("story-reply-field"), + ) + } + }, + confirmButton = { + TextButton( + onClick = { replying = false; model.replyToStory(draft.trim(), story) }, + enabled = draft.isNotBlank(), + modifier = Modifier.testTag("story-reply-send"), + ) { Text("Send") } + }, + dismissButton = { TextButton(onClick = { replying = false }) { Text("Cancel") } }, + ) + } + + if (muting) { + AlertDialog( + onDismissRequest = { muting = false }, + title = { Text("Mute their stories?") }, + // โš ๏ธ Says what muting is NOT. People reach for it expecting + // something closer to blocking, and are owed the difference before + // they use it rather than after. + text = { + Text("Their stories stop showing on this phone. They aren't " + + "told, and their messages still arrive as usual. You " + + "can turn it back on from their contact page.", + fontSize = 13.sp) + }, + confirmButton = { + TextButton( + onClick = { muting = false; model.setStoriesMuted(story.authorId, true) }, + modifier = Modifier.testTag("story-mute-confirm"), + ) { Text("Mute") } + }, + dismissButton = { TextButton(onClick = { muting = false }) { Text("Cancel") } }, + ) } } @@ -457,3 +618,64 @@ private fun StoryImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerView else -> Text("Loadingโ€ฆ", fontSize = 14.sp, color = Palette.SecondaryText) } } + +/** + * A story that is only words. + * + * Set large and centred on a tinted band, matching iOS โ€” a text story drawn as + * a caption under an empty grey rectangle reads as a picture that failed to + * load, which is the opposite of what the sender meant. + */ +@Composable +fun TextStoryCard(text: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(240.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)) + .testTag("story-text"), + contentAlignment = Alignment.Center, + ) { + Text( + text, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + maxLines = 6, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(20.dp), + ) + } +} + + +/** + * Write a story that is only words. + * + * A dialog rather than a screen: a text story is a line or two, and a + * full-screen composer for that is the reason people do not post them. + */ +@Composable +private fun TextStoryComposer(onDismiss: () -> Unit, onPost: (String) -> Unit) { + var text by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New story") }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + placeholder = { Text("Say something") }, + modifier = Modifier.fillMaxWidth().testTag("story-text-field"), + ) + }, + confirmButton = { + TextButton( + onClick = { onPost(text.trim()) }, + enabled = text.isNotBlank(), + modifier = Modifier.testTag("story-text-post"), + ) { Text("Post") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt new file mode 100644 index 0000000..200df9b --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt @@ -0,0 +1,49 @@ +package io.flamenet.messenger.app + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore + +/** + * Writing one of your own stories out to the device gallery (ios#34). + * + * โš ๏ธ **MediaStore, so no storage permission is asked for.** An app writing its + * own entry through `MediaStore` needs no runtime grant on any version this app + * supports. Asking for `WRITE_EXTERNAL_STORAGE` in order to save one picture + * would request read access to everything the person has, to perform a write โ€” + * a much larger ask than the feature. + */ +object StorySave { + + /** + * Save bytes to the gallery under Flamenet's own album. + * + * Returns the display name written, or null if the insert failed. + */ + fun save(context: Context, bytes: ByteArray, mime: String, id: String): String? { + val isVideo = mime.startsWith("video/") + val extension = if (isVideo) "mp4" else "jpg" + val name = "flamenet-story-$id.$extension" + val collection = if (isVideo) { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, mime) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val root = if (isVideo) Environment.DIRECTORY_MOVIES + else Environment.DIRECTORY_PICTURES + put(MediaStore.MediaColumns.RELATIVE_PATH, "$root/Flamenet") + } + } + val uri = context.contentResolver.insert(collection, values) ?: return null + return runCatching { + context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } + name + }.getOrNull() + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/MentionPickerTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/MentionPickerTest.kt new file mode 100644 index 0000000..3ac464f --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/MentionPickerTest.kt @@ -0,0 +1,81 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The two decisions behind the `@` picker, tested away from Compose. + * + * ๐Ÿ”ด Both of these are about somebody being rung, or not being rung, by a + * message. A picker that opens at the wrong moment is an annoyance; a mention + * that reaches the wrong person, or fails to reach the right one, is the + * feature not working. + */ +class MentionPickerTest { + + private fun buddy(id: Long, handle: String, display: String = "") = + MessengerApi.Buddy(userId = id, handle = handle, displayName = display, + presence = "offline") + + // MARK: - When the list is open + + @Test + fun `no at sign means no list`() { + assertNull(mentionQuery("dinner at eight")) + assertNull(mentionQuery("")) + } + + @Test + fun `a bare at opens the list with everybody`() { + assertEquals("", mentionQuery("@")) + assertEquals("", mentionQuery("hello @")) + } + + @Test + fun `typing after the at narrows it`() { + assertEquals("ali", mentionQuery("hello @ali")) + } + + /** + * ๐Ÿ”ด The list closes on a space, and closing has to be driven by the text + * rather than by a flag. Somebody deleting their way back past the `@` + * expects it gone, and a flag has to be remembered to be cleared. + */ + @Test + fun `a space after the at closes the list`() { + assertNull(mentionQuery("@ali said")) + assertNull(mentionQuery("mail me @ ")) + } + + @Test + fun `the last at is the one being typed`() { + assertEquals("bo", mentionQuery("@ali and @bo")) + } + + // MARK: - Who the list offers + + @Test + fun `a contact is named by the name you know them by`() { + val buddies = listOf(buddy(7, "alicew", "Alice")) + assertEquals("Alice", memberLabel(7, buddies)) + } + + @Test + fun `a contact with no display name falls back to the handle`() { + val buddies = listOf(buddy(7, "alicew")) + assertEquals("alicew", memberLabel(7, buddies)) + } + + /** + * โš ๏ธ The relay stores ids and no names, so a member who is not in your + * contacts has nothing to show. They stay mentionable under their id โ€” + * being un-mentionable is worse than being ugly, because the alternative + * is a group where some people cannot be reached. + */ + @Test + fun `a member who is not a contact is still mentionable`() { + assertEquals("user 42", memberLabel(42, emptyList())) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/MessageReactionTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/MessageReactionTest.kt new file mode 100644 index 0000000..88c005f --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/MessageReactionTest.kt @@ -0,0 +1,210 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Reactions, and the message id everything else will hang off. + * + * ๐Ÿ”ด **The id is the point.** A reaction, an edit and a pin all say "this + * one", and until every message carried an identifier there was nothing to + * point at โ€” which is why all three sat in the codec with no way to reach + * them. The rules pinned here are the ones that decide whether a reaction + * lands on the right message, on the wrong one, or on nothing. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MessageReactionTest { + + private lateinit var server: MockWebServer + + private class NoKeyLog : okhttp3.mockwebserver.QueueDispatcher() { + override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse = + if ("/e2e/log/" in request.path.orEmpty()) MockResponse().setResponseCode(404) + else super.dispatch(request) + } + + @BeforeTest fun start() { + server = MockWebServer().also { it.dispatcher = NoKeyLog(); it.start() } + } + @AfterTest fun stop() { server.shutdown() } + + private class MapStorage : Vault.Storage { + private val records = mutableMapOf() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private fun key(): SecretKey = + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + private fun session(): Session { + val identity = DeviceIdentity.create() + val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) } + val base = server.url("/").toString() + return Session( + handle = "alice", + userId = 1, + identity = identity, + vault = vault, + relay = RelayClient(base, "alice", "app pass word"), + api = MessengerApi(base, "alice", "app pass word"), + ) + } + + private fun json(body: String) = MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) + + private val peer = 99L + private val thread get() = Vault.threadForUser(peer) + + // MARK: - The id + + /** + * ๐Ÿ”ด A sent message carries an id, and the copy in our own transcript + * carries the SAME one. Two ids for one message means anything the other + * side says about it arrives about a message we do not have. + */ + @Test + fun `a sent message and its local copy share one id`() { + val session = session() + server.enqueue(json("""{"devices":[]}""")) + + session.send(peer, "shall we say seven") + + val line = session.history(thread).single() + assertNotNull(line.id, "a sent message has no id, so nothing can refer to it") + assertTrue(line.id!!.isNotBlank()) + } + + /** + * โš ๏ธ The queued retry has to be the SAME message, id included. Queuing the + * bare text meant the copy that eventually landed was a different message + * from the one already in this transcript. + */ + @Test + fun `the queued retry carries the same id as the local copy`() { + val session = session() + server.enqueue(json("""{"devices":[]}""")) + + session.send(peer, "shall we say seven") + + val mid = session.history(thread).single().id + assertNotNull(mid) + val queued = session.vault.outbox().getJSONObject(0).getString("text") + assertTrue(queued.contains(mid!!), + "the queued copy does not carry the id the transcript recorded") + } + + // MARK: - Applying one + + @Test + fun `a reaction folds onto the message it names`() { + val session = session() + server.enqueue(json("""{"devices":[]}""")) + session.send(peer, "shall we say seven") + val mid = session.history(thread).single().id!! + + assertTrue(session.applyReaction(thread, mid, peer, "โค๏ธ")) + + val line = session.history(thread).single() + assertEquals(mapOf("โค๏ธ" to setOf(peer)), line.reactions) + // ๐Ÿ”ด And it stays one message. A reaction filed as a message of its + // own is how a thread becomes unreadable. + assertEquals(1, session.history(thread).size) + } + + /** + * ๐Ÿ”ด One emoji per person. Reacting again replaces rather than + * accumulates โ€” otherwise one tap each on five emoji all stand at once, + * which is not what anybody means by reacting. + */ + @Test + fun `reacting again replaces the previous one`() { + val session = session() + server.enqueue(json("""{"devices":[]}""")) + session.send(peer, "shall we say seven") + val mid = session.history(thread).single().id!! + + session.applyReaction(thread, mid, peer, "โค๏ธ") + session.applyReaction(thread, mid, peer, "๐Ÿ˜‚") + + assertEquals(mapOf("๐Ÿ˜‚" to setOf(peer)), session.history(thread).single().reactions) + } + + @Test + fun `an empty emoji takes the reaction back`() { + val session = session() + server.enqueue(json("""{"devices":[]}""")) + session.send(peer, "shall we say seven") + val mid = session.history(thread).single().id!! + + session.applyReaction(thread, mid, peer, "โค๏ธ") + session.applyReaction(thread, mid, peer, "") + + assertEquals(emptyMap(), session.history(thread).single().reactions) + } + + @Test + fun `two people can react with the same emoji`() { + val session = session() + server.enqueue(json("""{"devices":[]}""")) + session.send(peer, "shall we say seven") + val mid = session.history(thread).single().id!! + + session.applyReaction(thread, mid, peer, "โค๏ธ") + session.applyReaction(thread, mid, 77L, "โค๏ธ") + + assertEquals(setOf(peer, 77L), session.history(thread).single().reactions["โค๏ธ"]) + } + + /** + * โš ๏ธ A reaction to a message this device does not have does nothing, and + * says so. Inventing a bubble for it would put a message in the thread + * that nobody wrote. + */ + @Test + fun `a reaction to a message we do not have is ignored, not invented`() { + val session = session() + server.enqueue(json("""{"devices":[]}""")) + session.send(peer, "shall we say seven") + + assertFalse(session.applyReaction(thread, "not-a-message-here", peer, "โค๏ธ")) + assertEquals(1, session.history(thread).size) + assertEquals(emptyMap(), session.history(thread).single().reactions) + } + + /** + * ๐Ÿ”ด A line stored before ids existed has none, and must not be given one + * on read. Two devices would invent different ids for the same message, + * and a reaction would land on nothing โ€” or worse, on the wrong thing. + */ + @Test + fun `a message stored before ids existed keeps no id`() { + val session = session() + session.record(thread, "from the old days", mine = false, author = peer) + + assertNull(session.history(thread).single().id) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt new file mode 100644 index 0000000..6d187fb --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt @@ -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 }) + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt index 8253949..d780d35 100644 --- a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt @@ -81,6 +81,28 @@ class AccountsClient( */ object SessionExpired : Failure("your session has expired โ€” sign in again") object HandleTaken : Failure("that handle is already taken") + + /** + * ๐Ÿ”ด **A 409 is not always a taken handle, and saying so was wrong.** + * The relay answers 409 for a taken handle, a registered email, AND a + * handle too close to an existing one โ€” three different problems with + * three different fixes. Collapsing them told somebody whose EMAIL was + * already registered to pick a different handle, which cannot work and + * gives them no way to discover why. + * + * The status is the transport's opinion; the `code` in the body is the + * relay's, and it is the one that means something. + */ + object EmailTaken : Failure("an account with that email already exists") + + /** The handle folds onto an existing one โ€” see the relay's + * `User.confusableSkeleton`. Changing a digit will not help. */ + object HandleConfusable : Failure("that handle is too easily mistaken for another") + + object DisposableEmail : Failure("that email provider isn't accepted") + object BadHandle : Failure("that handle isn't allowed") + object BadEmail : Failure("that doesn't look like an email address") + object WeakPassword : Failure("that passphrase is too short") object RateLimited : Failure("too many attempts โ€” wait a few minutes") class Service(val status: Int) : Failure("the account service returned $status") class Transport(cause: String) : Failure("couldn't reach the account service: $cause") @@ -168,13 +190,48 @@ class AccountsClient( 401, 403 -> throw if (carriesCredentials) Failure.BadCredentials else Failure.SessionExpired - 409 -> throw Failure.HandleTaken + // ๐Ÿ”ด **Read the relay's own code, not just the status.** A + // 400 became a bare "the account service returned 400" and a + // 409 became "that handle is already taken" whatever the + // actual reason โ€” so a weak passphrase, an unusable email and + // an already-registered address were all reported as something + // the person could not act on, or as the wrong problem + // entirely. The relay has always sent a specific code; nothing + // here ever read it. + 409, 400 -> throw failureFor(code(text), status = it.code) 429 -> throw Failure.RateLimited else -> throw Failure.Service(it.code) } } } + /** + * The relay's error code, or null when the body is not its error shape. + * + * โš ๏ธ Never throws. A body that cannot be parsed is a transport problem, not + * a reason to lose the status we already have. + */ + private fun code(body: String): String? = runCatching { + JSONObject(body).optString("code").takeIf { it.isNotEmpty() } + }.getOrNull() + + /** + * โš ๏ธ Falls back to the STATUS when the code is unknown, so a refusal this + * build has never heard of still reports as a refusal rather than as a + * success or a crash. A relay newer than the app is the normal case, not + * the exception. + */ + private fun failureFor(code: String?, status: Int): Failure = when (code) { + "username_taken" -> Failure.HandleTaken + "email_taken" -> Failure.EmailTaken + "username_confusable" -> Failure.HandleConfusable + "disposable_email" -> Failure.DisposableEmail + "bad_username" -> Failure.BadHandle + "bad_email" -> Failure.BadEmail + "weak_password" -> Failure.WeakPassword + else -> if (status == 409) Failure.HandleTaken else Failure.Service(status) + } + private companion object { val JSON = "application/json".toMediaType() } diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 1b5ee14..87bf21b 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -212,10 +212,29 @@ class Conversations( * at the sender as an incoming one. The sender's own copy is written * locally by the caller. */ - fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long): Sent { + /** + * Whether this device refuses to send when it cannot seal (ios#16). + * + * โš ๏ธ Injected rather than read from preferences here: this module has no + * Android dependency. An unset value must read as FALSE โ€” degrade and say + * so, rather than silently refusing to send on a fresh install. + */ + var requireSealed: () -> Boolean = { false } + + fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long, + mentions: List = emptyList(), + /** + * ๐Ÿ”ด Minted by the caller, not here, because the caller + * also writes the sender's own copy and queues the retries. + * Three constructions of the same message that disagreed + * about its id would be three different messages, and + * anything said about one of them would land on nothing. + */ + messageId: String? = null): Sent { val delivered = mutableListOf() val failed = LinkedHashMap() - val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id)) + val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id), + mentions = mentions, messageId = messageId) for (member in group.memberIds) { if (member == myUserId) continue @@ -335,6 +354,14 @@ class Conversations( * the viewer's device contacts that host directly. */ streamUrl: String? = null, + /** + * The group this story is addressed to (ยง6.24). + * + * โš ๏ธ Names the AUDIENCE, not a destination thread. It rides on the + * ordinary story envelope; the recipient decides whether to believe + * the claim by checking its own roster. + */ + groupId: String? = null, ): StoryPosted { val sealed = Attachment.seal(data) val id = relay.uploadAttachment(sealed.blob) @@ -344,7 +371,7 @@ class Conversations( duration = duration, expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds, ) - val body = Content.attachment(ref, caption, null, streamUrl = streamUrl) + val body = Content.attachment(ref, caption, groupId, streamUrl = streamUrl) var reached = 0 var missed = 0 @@ -356,6 +383,61 @@ class Conversations( data class StoryPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int) + /** + * Post a story that is only words. + * + * ๐Ÿ”ด No blob and no upload โ€” the whole story is inside the ratchet, so the + * relay holds nothing at all for it. The lifetime is the sender's choice + * and rides in the sealed envelope rather than being something the server + * is told. + */ + fun postTextStory( + text: String, + toUserIds: List, + lifetimeSeconds: Double = STORY_LIFETIME_SECONDS, + ): TextStoryPosted { + val expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds + // โš ๏ธ Identifies the story across the sender's devices. A picture story + // is identified by its blob token and this has none, so without it the + // same story appears once per phone the sender posted from. + val key = java.util.UUID.randomUUID().toString() + val body = Content.textStory(text, expires, key) + + var reached = 0 + var missed = 0 + for (userId in toUserIds) { + if (sendPlaintext(userId, body).reachedSomeone) reached++ else missed++ + } + return TextStoryPosted(key, expires, reached, missed) + } + + data class TextStoryPosted( + val key: String, val expires: Double, val reached: Int, val missed: Int, + ) + + /** + * React to somebody's story. Goes to the author alone โ€” a story is sent to + * each viewer rather than hosted, so there is nobody else to tell, and no + * other viewer can learn who reacted. + */ + fun reactToStory(emoji: String, storyKey: String, author: Long) { + sendPlaintext(author, Content.storyReaction(emoji, storyKey, author)) + } + + /** + * Reply to somebody's story, privately, in the ordinary conversation. + * + * โš ๏ธ [preview] travels with it because stories expire: by the time this is + * read the story may be gone, and "replied to your story" with no idea + * which one cannot be understood. + */ + fun replyToStory(body: String, storyKey: String, author: Long, preview: String): String { + val messageId = java.util.UUID.randomUUID().toString() + sendPlaintext(author, + Content.storyReply(body, storyKey, author, preview, messageId)) + return messageId + } + /** * Publish a profile picture: upload it encrypted, then tell each contact * where it is and how to open it. @@ -664,6 +746,14 @@ class Conversations( // cannot tell it from a photo (ยง14.4). val sealed = peerToken != null || peerKey != null + // ios#16 โ€” refuse rather than degrade, for the people who would rather + // a message did not go than go attributable. + // + // โš ๏ธ Nothing is spent by refusing here: `sealed` is false exactly when + // neither a capability nor a key was found, so `takePeerToken` above + // returned null and there is no token to hand back. + if (!sealed && requireSealed()) throw RelayException.SealingRequired() + val preludes = listOfNotNull( ourDeliveryKeyEnvelopeFor(toUserId), ourKeyLogHeadEnvelopeFor(toUserId), diff --git a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt index f446f49..d72fc6e 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt @@ -34,12 +34,38 @@ class RelayAccounts( val appPassword: String, ) - fun signUp(username: String, email: String, password: String, displayName: String? = null): Account = - account(post("account/signup", JSONObject() + /** + * โš ๏ธ **The challenge is fetched only when the relay asks for one.** + * Fetching up front would add a round trip to every sign-up on every + * server, for a feature that is off by default and off on every relay + * running today โ€” a second wait on the one screen where somebody is + * already waiting. So the first attempt goes out plain, and only a relay + * that refuses it sends us to solve anything. + * + * The refused attempt costs one of fifty per hour from this address, which + * is the right trade for not charging everybody else a round trip. + */ + fun signUp(username: String, email: String, password: String, displayName: String? = null): Account { + fun body() = JSONObject() .put("username", username) .put("email", email) .put("password", password) - .put("display_name", displayName ?: username))) + .put("display_name", displayName ?: username) + + return try { + account(post("account/signup", body())) + } catch (asked: RelayException.ChallengeRequired) { + // ๐Ÿ”ด Rethrown, not swallowed, when we cannot answer. A difficulty + // above the cap or a search that ran out leaves this app genuinely + // unable to sign up here, and "update the app" is then the true + // sentence โ€” quietly failing with something else would send + // somebody looking in the wrong place. + val solved = SignupProofOfWork.satisfy(base.toString(), http) ?: throw asked + account(post("account/signup", body() + .put("challenge", solved.challenge) + .put("nonce", solved.nonce))) + } + } /** * โš ๏ธ The field is "username **or email**" and always has been. A client @@ -90,6 +116,17 @@ class RelayAccounts( post("account/reset/request", JSONObject().put("handle", handle)) } + /** + * Spend an email verification code. + * + * โš ๏ธ Unauthenticated, exactly like reset confirm: the code IS the proof. + * Requiring a signed-in session would mean somebody kept out *because* they + * are unverified could never verify. + */ + fun verifyEmail(token: String) { + post("account/verify", JSONObject().put("token", token)) + } + fun confirmPasswordReset(handle: String, code: String, newPassword: String) { post("account/reset/confirm", JSONObject() .put("handle", handle) @@ -128,13 +165,33 @@ class RelayAccounts( // better than anything this layer could invent from a status code. val message = runCatching { JSONObject(text).optString("message") } .getOrNull().orEmpty().ifBlank { text } - throw when (it.code) { - 401, 403 -> RelayException.Unauthorized(message) - 404 -> RelayException.NotFound(path) - 409 -> RelayException.Http(409, message) - 429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull()) + val code = runCatching { JSONObject(text).optString("code") } + .getOrNull().orEmpty() + throw when { + // ๐Ÿ”ด Decided by the relay's own `code`, never by the status. A + // 400 is also a malformed body and a bad email; telling either + // of those to go and update the app would send somebody to the + // Play Store to fix a typo. + code == "challenge_required" -> RelayException.ChallengeRequired(message) + code == "challenge_failed" -> RelayException.ChallengeFailed(message) + // ๐Ÿ”ด **A 403 is not an expired session.** 401 means the token + // was refused and signing in again is the fix. 403 means a + // RULE refused this โ€” too many devices, a retired device, an + // unverified address โ€” and the relay writes a sentence saying + // which. Reporting those as "your session expired, sign out and + // back in" sends somebody round a loop that cannot end: they + // sign in again, hit the same rule, and are told the same + // wrong thing. Observed tonight on a fresh install. + it.code == 401 -> RelayException.Unauthorized(message) + it.code == 403 && code == "email_unverified" -> + RelayException.EmailUnverified(message) + it.code == 403 -> RelayException.Http(403, message) + it.code == 404 -> RelayException.NotFound(path) + it.code == 409 -> RelayException.Http(409, message) + it.code == 429 -> + RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull()) // The geo-gate, not the relay. Needs a sentence, not a number. - 451 -> RelayException.Unavailable(message) + it.code == 451 -> RelayException.Unavailable(message) else -> RelayException.Http(it.code, message) } } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index 61e7ca4..b85db39 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -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") } @@ -796,6 +823,15 @@ class RelayClient( throw when (code) { 401 -> RelayException.Unauthorized(readable) + // โš ๏ธ The relay's own code, not the status. A 403 is also "that + // device belongs to another account", and telling somebody to check + // their email for that would send them looking for a message that + // was never sent. + 403 -> { + val relayCode = runCatching { JSONObject(body).optString("code") }.getOrNull() + if (relayCode == "email_unverified") RelayException.EmailUnverified(readable) + else RelayException.Http(code, readable) + } 404 -> RelayException.NotFound(path) 429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull()) // The geo-gate, not the relay. Needs a sentence, not a number. diff --git a/client/src/main/kotlin/io/flamenet/client/RelayException.kt b/client/src/main/kotlin/io/flamenet/client/RelayException.kt index 8bf6712..765eb19 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayException.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayException.kt @@ -41,11 +41,69 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti "Flamenet isn't available in your country yet. " + "This isn't a problem with your account or your connection.") + /** + * This relay charges a few seconds of arithmetic before a new account, and + * this app is too old to pay it. + * + * โš ๏ธ The remedy is an update, and it is the only refusal in sign-up where + * that is true โ€” which is exactly why it cannot share a sentence with the + * one below. Telling somebody to try again when the app will never be able + * to answer sends them round a loop that has no exit. + */ + class ChallengeRequired(val body: String) : RelayException( + "This server asks new accounts to do a quick check first, and this " + + "version of the app can't do it yet. Update the app and try again.") + + /** + * The check was answered and the answer did not hold up. + * + * โš ๏ธ Nearly always a challenge that expired while somebody was typing. + * The relay deliberately gives one message for all four of its refusals โ€” + * forged, stale, unsolved, malformed โ€” because telling an attacker which + * knob to turn is worse than being vague with everybody else, so this side + * cannot be more specific than "start a fresh one" either. + */ + class ChallengeFailed(val body: String) : RelayException( + "That check took too long. Tap Create account again and it will " + + "start a fresh one.") + /** Any other non-2xx. The body is kept because relays put the reason in it. */ + /** + * The account exists and the credential is right; the address has not been + * proved reachable yet. + * + * ๐Ÿ”ด **Its own type because everything else about it looks like a + * credential failure.** Without this it arrives as a bare 403 and + * [RelayAuth] logs "minting failed" before falling back to Basic โ€” which + * the e2e routes refuse โ€” so the person sees calls not working and an inbox + * that does nothing, and nothing anywhere says the remedy is a code sitting + * in their email. + * + * โš ๏ธ Do not write the e2e route glob in a KDoc here: Kotlin NESTS block + * comments, so a slash-star inside one opens a comment that never closes + * and takes the whole module down with ninety unrelated errors. + */ + class EmailUnverified(val body: String) : RelayException( + "this account's email address has not been confirmed yet") + class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body") /** A 2xx whose body was not what the route promised. */ class Malformed(message: String) : RelayException(message) + + /** + * Sealing was required and this send could not be sealed (ยง14.5, ios#16). + * + * โš ๏ธ Its own type rather than a generic failure. The user turned this on + * deliberately and the message did not go for a reason they chose โ€” a bare + * "couldn't send" reads as the app being broken, and the next thing + * somebody does is switch the setting off without knowing that is what + * they are doing. + */ + class SealingRequired : RelayException( + "Not sent. This message could not be sealed, so the server would have seen " + + "that you sent it โ€” and you asked Flamenet to refuse rather than send " + + "anyway. It will seal again once they message you.") } /** diff --git a/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt b/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt new file mode 100644 index 0000000..0330bb9 --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt @@ -0,0 +1,140 @@ +package io.flamenet.client + +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.security.MessageDigest + +/** + * A few seconds of this phone's arithmetic, spent before an account exists. + * + * ๐Ÿ”ด **Why this and not a CAPTCHA.** A CAPTCHA hands a third party every + * visitor's connection, which is a strange thing to do in a product whose + * argument is that it collects almost nothing. This collects nothing at all: + * no personal data, no third party, no picture of a crosswalk. It costs one + * person a couple of seconds and costs a farm of registrations real money. + * + * โš ๏ธ **Every branch here has to end in "sign up anyway".** A relay that + * predates the feature, a request that times out, a proxy that returns HTML โ€” + * none of those are reasons somebody cannot create an account. The relay + * decides whether a challenge is *required*; this side only tries to satisfy + * one when it is offered. + */ +object SignupProofOfWork { + + /** + * What the relay hands out. `bits == 0` means it wants no work โ€” the relay + * answers that rather than 404ing, so asking is always safe and the answer + * never reveals how a deployment is configured. + */ + data class Challenge(val challenge: String, val bits: Int, val expiresIn: Int) + + /** A solved challenge, ready to travel beside the sign-up fields. */ + data class Solution(val challenge: String, val nonce: String) + + /** + * ๐Ÿ”ด **Refuse to grind past this even if the relay asks.** The relay caps + * itself at 24 bits, but a client that trusts a number from the network + * with an unbounded loop is one misconfiguration away from an app that + * appears to hang on the sign-up button โ€” which reads as broken, not as + * spam protection. Above the cap we send nothing and let the relay refuse, + * because a refusal with a sentence beats a spinner that never ends. + */ + const val MAXIMUM_BITS = 24 + + /** + * Hashes to try before giving up. At 24 bits the expected count is ~16.7M, + * so this is roughly a 4x margin โ€” generous enough that an unlucky run + * still finishes, bounded enough that a wrong difficulty cannot run for + * ever. + */ + const val CEILING = 64_000_000 + + /** + * Ask what work this server wants, if any. + * + * โš ๏ธ Returns null for every failure, deliberately. 404 is the common one โ€” + * an older relay, or a site that has no such route โ€” and it is + * indistinguishable from success-with-no-work as far as what the caller + * should do next. + */ + fun fetch(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Challenge? { + val url = baseUrl.trimEnd('/').toHttpUrl().newBuilder() + .addPathSegments("account/challenge").build() + val request = Request.Builder().url(url).get().header("Accept", "application/json").build() + return try { + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) return null + val json = JSONObject(response.body?.string().orEmpty()) + Challenge( + challenge = json.getString("challenge"), + bits = json.optInt("bits", 0), + expiresIn = json.optInt("expires_in", 0), + ) + } + } catch (_: Exception) { + // Transport, a 404 page that is not JSON, a missing field โ€” all + // mean the same thing here, and it is not "no account for you". + null + } + } + + /** Solve a challenge, or return null if there is nothing solvable in it. */ + fun solve(issued: Challenge): Solution? { + if (issued.bits <= 0 || issued.bits > MAXIMUM_BITS) return null + val nonce = nonce(issued.challenge, issued.bits) ?: return null + return Solution(issued.challenge, nonce) + } + + /** + * The search itself. Separated so tests can drive it at a low difficulty + * without going through a [Challenge] that claims an expiry. + */ + fun nonce(challenge: String, bits: Int, ceiling: Int = CEILING): String? { + val digest = MessageDigest.getInstance("SHA-256") + // Hashed as `challenge.nonce`, which is exactly what the relay + // verifies. The prefix never changes, so it is encoded once rather than + // per attempt โ€” at tens of millions of iterations that is not a + // micro-optimisation, it is most of the running time. + val prefix = "$challenge.".toByteArray(Charsets.UTF_8) + for (n in 0 until ceiling) { + digest.reset() + digest.update(prefix) + digest.update(n.toString().toByteArray(Charsets.UTF_8)) + if (leadingZeroBits(digest.digest()) >= bits) return n.toString() + } + return null + } + + /** + * โš ๏ธ Counted in BITS, not leading zero bytes. Bytes move in steps of 256x, + * which leaves no way to ask for two seconds rather than half a second or + * two minutes. This must agree with the relay exactly or every solution + * this phone produces is rejected by a server that is working perfectly. + */ + fun leadingZeroBits(digest: ByteArray): Int { + var count = 0 + for (byte in digest) { + val value = byte.toInt() and 0xFF + if (value == 0) { count += 8; continue } + count += Integer.numberOfLeadingZeros(value) - 24 + break + } + return count + } + + /** + * Fetch and solve, for the caller that just wants fields to send. + * + * Returns null when this server asks for no work, which is the normal case + * today and stays the normal case for anyone self-hosting who never turns + * it on. + * + * โš ๏ธ Blocking and CPU-bound, like every other call in this module. The + * caller is already off the main thread; if it were not, sign-up would + * already be an ANR without any of this. + */ + fun satisfy(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Solution? = + fetch(baseUrl, http)?.let { solve(it) } +} diff --git a/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt b/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt new file mode 100644 index 0000000..3e3fbfc --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt @@ -0,0 +1,99 @@ +package io.flamenet.client + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * A 403 that means "check your email", told apart from every other 403. + * + * ๐Ÿ”ด **Without this it is indistinguishable from a credential problem.** An + * unverified account arrives as a bare 403; `RelayAuth` logs "minting failed" + * and falls back to Basic, which the e2e routes refuse. The person then sees + * calls that do not work and an inbox that does nothing, and nothing anywhere + * says the remedy is a code sitting in their inbox. + * + * โš ๏ธ The status alone is not enough: 403 is also "that device belongs to + * another account". Telling somebody to check their email for that would send + * them looking for a message that was never sent โ€” so the relay's own `code` is + * what decides, not the number. + */ +class EmailUnverifiedTest { + + private lateinit var server: MockWebServer + + @BeforeTest fun start() { + server = MockWebServer() + server.start() + } + + @AfterTest fun stop() { + server.shutdown() + } + + private fun relay() = RelayClient( + server.url("/").toString(), username = "ada", appPassword = "credential") + + private fun refuse(code: String?, status: Int = 403) { + val body = if (code == null) """{"message":"no"}""" + else """{"code":"$code","message":"Check your email and enter the code."}""" + server.enqueue( + MockResponse().setResponseCode(status) + .setHeader("Content-Type", "application/json") + .setBody(body) + ) + } + + @Test + fun `an unverified account is its own failure, not a generic 403`() { + refuse("email_unverified") + val failure = assertFailsWith { + relay().listDevices(1) + } + assertTrue( + failure.body.contains("code"), + "the relay's sentence should survive: ${failure.body}" + ) + } + + /** + * ๐Ÿ”ด The distinction that matters. A device conflict is also a 403 and must + * NOT be reported as an unverified address. + */ + @Test + fun `another 403 is not mistaken for an unverified address`() { + refuse("forbidden") + val failure = assertFailsWith { relay().listDevices(1) } + assertEquals(403, failure.status) + } + + /** โš ๏ธ A 403 with no code at all โ€” an old relay, or a proxy โ€” stays generic. */ + @Test + fun `a 403 with no code stays generic`() { + refuse(null) + assertFailsWith { relay().listDevices(1) } + } + + /** โš ๏ธ And a body that is not JSON must not throw a parse error. */ + @Test + fun `an unparseable 403 body does not crash`() { + server.enqueue( + MockResponse().setResponseCode(403) + .setHeader("Content-Type", "text/html") + .setBody("a proxy wrote this") + ) + assertFailsWith { relay().listDevices(1) } + } + + /** The code is only meaningful on a 403 โ€” the same string on a 500 is not. */ + @Test + fun `the code does not change the meaning of other statuses`() { + refuse("email_unverified", status = 500) + assertFailsWith { relay().listDevices(1) } + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt index 7d32a2d..8f7c4fb 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt @@ -47,11 +47,18 @@ class LiveShapesTest { private fun needFleet() { LiveTarget.refuseProduction(System.getenv("FN_LIVE_FLEET")) assumeTrue(System.getenv("FN_LIVE_FLEET") != null, "set FN_LIVE_FLEET to run against the fleet") + assumeTrue(PASSPHRASE.isNotEmpty(), "set FN_LIVE_PASSPHRASE") } - private fun needManaged() = assumeTrue( - System.getenv("FN_LIVE_MANAGED") == "1", - "set FN_LIVE_MANAGED=1 โ€” these create real accounts on accounts.flamenet.io") + private fun needManaged() { + assumeTrue( + System.getenv("FN_LIVE_MANAGED") == "1", + "set FN_LIVE_MANAGED=1 โ€” these create real accounts on accounts.flamenet.io") + assumeTrue( + PASSPHRASE.isNotEmpty(), + "set FN_LIVE_PASSPHRASE โ€” these create real accounts and will not use " + + "a passphrase written into the repository") + } private val local = "https://relay1.flamenet.io" private val sharedSelfHosted = "https://relay2.flamenet.io" @@ -388,6 +395,10 @@ class LiveShapesTest { } private companion object { - const val PASSPHRASE = "a-long-enough-passphrase" + /** + * ๐Ÿ”ด Was `"a-long-enough-passphrase"`, a literal in a source file used + * to create real accounts. See [LiveTarget.passphrase]. + */ + val PASSPHRASE: String get() = LiveTarget.passphrase() } } diff --git a/client/src/test/kotlin/io/flamenet/client/LiveTarget.kt b/client/src/test/kotlin/io/flamenet/client/LiveTarget.kt index 19f521e..4a4b4a6 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveTarget.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveTarget.kt @@ -77,4 +77,26 @@ object LiveTarget { ) } } + + /** + * The passphrase a live suite signs up with. + * + * ๐Ÿ”ด **From the environment, with no fallback, and that is the whole + * point.** Both live suites carried + * `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create + * accounts on `accounts.flamenet.io` whenever somebody set + * `FN_LIVE_MANAGED=1`. A credential written into a source file is a + * published password: every account those runs ever made can be signed into + * by anybody who can read this repository, and nothing about the accounts + * being throwaway changes that โ€” they are real accounts on a real service, + * and they persist. + * + * โš ๏ธ Empty rather than throwing, so the caller turns it into an announced + * skip. A live suite that cannot run must report SKIPPED and not fail: a + * red suite for a missing variable teaches people to ignore red. + * + * The iOS side had the same class of bug twice (ios#62) โ€” a literal + * fallback used against production. This is the Kotlin half of that fix. + */ + fun passphrase(): String = System.getenv("FN_LIVE_PASSPHRASE").orEmpty() } diff --git a/client/src/test/kotlin/io/flamenet/client/LiveTwoProductsTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveTwoProductsTest.kt index 0ddd7c6..c9dd5cd 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveTwoProductsTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveTwoProductsTest.kt @@ -26,13 +26,20 @@ class LiveTwoProductsTest { private val filesHost = "https://files.flamenet.io" private val localRelay = "https://relay1.flamenet.io" - private fun needManaged() = assumeTrue( - System.getenv("FN_LIVE_MANAGED") == "1", - "set FN_LIVE_MANAGED=1 โ€” this creates a real account") + private fun needManaged() { + assumeTrue( + System.getenv("FN_LIVE_MANAGED") == "1", + "set FN_LIVE_MANAGED=1 โ€” this creates a real account") + assumeTrue( + PASSPHRASE.isNotEmpty(), + "set FN_LIVE_PASSPHRASE โ€” this creates a real account and will not " + + "use a passphrase written into the repository") + } private fun needFleet() { LiveTarget.refuseProduction(System.getenv("FN_LIVE_FLEET")) assumeTrue(System.getenv("FN_LIVE_FLEET") != null, "set FN_LIVE_FLEET") + assumeTrue(PASSPHRASE.isNotEmpty(), "set FN_LIVE_PASSPHRASE") } /** @@ -114,6 +121,10 @@ class LiveTwoProductsTest { } private companion object { - const val PASSPHRASE = "a-long-enough-passphrase" + /** + * ๐Ÿ”ด Was `"a-long-enough-passphrase"`, a literal in a source file used + * to create real accounts. See [LiveTarget.passphrase]. + */ + val PASSPHRASE: String get() = LiveTarget.passphrase() } } diff --git a/client/src/test/kotlin/io/flamenet/client/SignupFailureMappingTest.kt b/client/src/test/kotlin/io/flamenet/client/SignupFailureMappingTest.kt new file mode 100644 index 0000000..1a0eed1 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/SignupFailureMappingTest.kt @@ -0,0 +1,129 @@ +package io.flamenet.client + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What a refused sign-up is reported as. + * + * ๐Ÿ”ด **A 409 is not always a taken handle, and this used to say it was.** The + * relay answers 409 for a taken handle, an already-registered email, and a + * handle too close to an existing one โ€” three problems with three different + * fixes. Collapsing them told somebody whose EMAIL was registered to pick a + * different handle, which cannot work and leaves them no way to find out why. + * + * 400 was worse: every format refusal became "the account service returned + * 400", which is not a sentence anybody can act on. + * + * The status is the transport's opinion. The `code` in the body is the relay's, + * and it is the one that means something. + */ +class SignupFailureMappingTest { + + private lateinit var server: MockWebServer + + @BeforeTest fun start() { + server = MockWebServer() + server.start() + } + + @AfterTest fun stop() { + server.shutdown() + } + + private fun client() = AccountsClient(server.url("/").toString()) + + /** Ask the server to refuse with a given code and status, and see what comes back. */ + private fun refusalFor(code: String, status: Int): AccountsClient.Failure { + server.enqueue( + MockResponse().setResponseCode(status) + .setHeader("Content-Type", "application/json") + .setBody("""{"code":"$code","message":"refused"}""") + ) + return try { + client().register("someone", "passphrase-long-enough") + throw AssertionError("the sign-up was not refused at all") + } catch (failure: AccountsClient.Failure) { + failure + } + } + + @Test + fun `each refusal code maps to its own failure`() { + val cases = listOf( + Triple("username_taken", 409, AccountsClient.Failure.HandleTaken), + Triple("email_taken", 409, AccountsClient.Failure.EmailTaken), + Triple("username_confusable", 409, AccountsClient.Failure.HandleConfusable), + Triple("disposable_email", 400, AccountsClient.Failure.DisposableEmail), + Triple("bad_username", 400, AccountsClient.Failure.BadHandle), + Triple("bad_email", 400, AccountsClient.Failure.BadEmail), + Triple("weak_password", 400, AccountsClient.Failure.WeakPassword), + ) + for ((code, status, expected) in cases) { + assertEquals(expected, refusalFor(code, status), "$code should map to $expected") + } + } + + /** + * ๐Ÿ”ด The regression this whole change exists for. An already-registered + * email must NOT be reported as a taken handle โ€” the person would change + * their handle, be refused again, and have no way to work out why. + */ + @Test + fun `an already registered email is not reported as a taken handle`() { + val failure = refusalFor("email_taken", 409) + assertEquals(AccountsClient.Failure.EmailTaken, failure) + assertTrue( + failure.message!!.contains("email"), + "the message should mention the email, not the handle: ${failure.message}" + ) + } + + /** + * โš ๏ธ A relay newer than the app is the normal case, not the exception. An + * unknown code must still refuse โ€” falling through to success would create + * an account the server never made. + */ + @Test + fun `an unknown refusal code still refuses`() { + assertEquals(AccountsClient.Failure.HandleTaken, refusalFor("invented_next_year", 409)) + val fourHundred = refusalFor("invented_next_year", 400) + assertTrue( + fourHundred is AccountsClient.Failure.Service, + "an unknown 400 should keep its status: $fourHundred" + ) + } + + /** โš ๏ธ A body that is not the relay's error shape must not throw a parse error. */ + @Test + fun `an unparseable body falls back to the status`() { + server.enqueue( + MockResponse().setResponseCode(409) + .setHeader("Content-Type", "text/html") + .setBody("a proxy wrote this") + ) + try { + client().register("someone", "passphrase-long-enough") + throw AssertionError("the sign-up was not refused") + } catch (failure: AccountsClient.Failure) { + assertEquals(AccountsClient.Failure.HandleTaken, failure) + } + } + + /** Rate limiting and auth keep their existing meanings. */ + @Test + fun `the other statuses are unchanged`() { + server.enqueue(MockResponse().setResponseCode(429)) + try { + client().register("someone", "passphrase-long-enough") + throw AssertionError("not refused") + } catch (failure: AccountsClient.Failure) { + assertEquals(AccountsClient.Failure.RateLimited, failure) + } + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt b/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt new file mode 100644 index 0000000..69e86b6 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt @@ -0,0 +1,271 @@ +package io.flamenet.client + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import java.security.MessageDigest + +/** + * The client half of the sign-up puzzle. + * + * ๐Ÿ”ด **The bit counting has to agree with the relay exactly.** Both sides count + * leading zero *bits* of `SHA256("challenge.nonce")`. If this side counted + * bytes, every solution this phone produced would still be accepted โ€” it would + * simply do hundreds of times the work โ€” and no assertion about "at least N + * leading zeros" could see it, because overshooting also satisfies N. That is + * why the vectors below are pinned to exact values. + * + * โš ๏ธ The other half of the contract is that **nothing here may prevent a + * sign-up**. A relay that predates the feature, a request that times out, a + * proxy returning HTML โ€” all of those mean "no work wanted", not "no account + * for you". + */ +class SignupProofOfWorkTest { + + private lateinit var server: MockWebServer + + @BeforeTest fun start() { + server = MockWebServer() + server.start() + } + + @AfterTest fun stop() { + server.shutdown() + } + + private fun url() = server.url("/").toString() + + // ---------------------------------------------------------------- the arithmetic + + /** + * โš ๏ธ The input is chosen so the answer is NOT a multiple of eight. + * `SHA256("pin754")` starts `000f d989โ€ฆ` โ€” twelve zero bits, which a + * byte-counting implementation reports as eight. Almost every digest has no + * leading zeros at all and cannot tell the two apart. + */ + @Test + fun `leading zeros are counted in bits, not bytes`() { + val digest = MessageDigest.getInstance("SHA-256") + .digest("pin754".toByteArray(Charsets.UTF_8)) + assertEquals(12, SignupProofOfWork.leadingZeroBits(digest), + "counted to a byte boundary instead of a bit") + } + + /** + * ๐Ÿ”ด The exact nonce, not merely "a nonce that is good enough". A + * byte-rounding implementation still satisfies a 9-bit difficulty โ€” it + * overshoots to 16 bits and returns 4416 instead of 19, which is 230x the + * work for the person signing up. + */ + @Test + fun `the first satisfying nonce is the one returned`() { + assertEquals("19", SignupProofOfWork.nonce("1700000000.c2FsdA.bWFj", bits = 9), + "solved a harder puzzle than the relay asked for") + } + + /** The same string the relay hashes: `challenge` then a dot then `nonce`. */ + @Test + fun `a solved nonce satisfies the relay's own check`() { + val challenge = "1700000000.c2FsdA.bWFj" + val nonce = assertNotNull(SignupProofOfWork.nonce(challenge, bits = 12)) + val digest = MessageDigest.getInstance("SHA-256") + .digest("$challenge.$nonce".toByteArray(Charsets.UTF_8)) + assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 12, + "the relay would refuse this as unsolved") + } + + /** + * ๐Ÿ”ด A nonce solved for one challenge must not satisfy another, or the + * puzzle is solved once and reused for every registration in a farm. + */ + @Test + fun `a nonce does not transfer between challenges`() { + val nonce = assertNotNull(SignupProofOfWork.nonce("challenge-one", bits = 12)) + val digest = MessageDigest.getInstance("SHA-256") + .digest("challenge-two.$nonce".toByteArray(Charsets.UTF_8)) + assertTrue(SignupProofOfWork.leadingZeroBits(digest) < 12, + "a nonce solved elsewhere satisfied this challenge") + } + + // ---------------------------------------------------------------- refusing to grind + + /** โš ๏ธ The ceiling is a real bound. A search that cannot finish must return. */ + @Test + fun `the search is bounded rather than endless`() { + assertNull(SignupProofOfWork.nonce("unreachable", bits = 32, ceiling = 5_000)) + } + + /** + * ๐Ÿ”ด A difficulty above the cap is not attempted at all. Trusting a number + * from the network with a long loop is how an app comes to look hung. + */ + @Test + fun `a difficulty above the cap is not attempted`() { + val absurd = SignupProofOfWork.Challenge("x", SignupProofOfWork.MAXIMUM_BITS + 1, 600) + assertNull(SignupProofOfWork.solve(absurd), + "ground on a difficulty the relay is not even allowed to ask for") + } + + /** `bits: 0` is how a relay says it wants none. That must cost nothing. */ + @Test + fun `no work wanted means no solution`() { + assertNull(SignupProofOfWork.solve(SignupProofOfWork.Challenge("x", 0, 600))) + } + + // ---------------------------------------------------------------- never in the way + + /** ๐Ÿ”ด Every one of these servers must still be able to create an account. */ + @Test + fun `every server that cannot offer a challenge still allows sign-up`() { + val cases = listOf( + "an older relay with no such route" to MockResponse().setResponseCode(404), + "a relay that is unwell" to MockResponse().setResponseCode(500), + "a proxy returning HTML with a 200" to + MockResponse().setResponseCode(200).setBody("not json"), + "an empty body" to MockResponse().setResponseCode(200).setBody(""), + "JSON with no challenge field" to + MockResponse().setResponseCode(200).setBody("""{"bits":10}"""), + ) + for ((name, response) in cases) { + server.enqueue(response) + assertNull(SignupProofOfWork.satisfy(url()), + "$name should mean no work, not no account") + } + } + + /** And the ordinary case: an offer of real work is fetched, parsed and met. */ + @Test + fun `an offered challenge is fetched and solved`() { + server.enqueue(MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":10,"expires_in":600}""")) + + val solution = assertNotNull(SignupProofOfWork.satisfy(url())) + assertEquals("1700000000.c2FsdA.bWFj", solution.challenge) + val digest = MessageDigest.getInstance("SHA-256") + .digest("${solution.challenge}.${solution.nonce}".toByteArray(Charsets.UTF_8)) + assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 10) + } + + /** โš ๏ธ Asked at `account/challenge`, beside `account/signup`. */ + @Test + fun `the challenge is asked for beside sign-up`() { + server.enqueue(MockResponse().setResponseCode(404)) + SignupProofOfWork.fetch(url()) + assertEquals("/account/challenge", server.takeRequest().path) + } + + // ---------------------------------------------------------------- what people read + + private fun refuse(code: String) { + server.enqueue(MockResponse().setResponseCode(400) + .setHeader("Content-Type", "application/json") + .setBody("""{"code":"$code","message":"the relay said something"}""")) + } + + /** + * ๐Ÿ”ด **The whole reactive flow.** Refused for a missing challenge, we go + * and get one, solve it, and send the same fields again with the answer. + * + * โš ๏ธ Three exchanges, in this order. The first sign-up carries no challenge + * at all, which is the point: a relay with this feature off โ€” every relay + * running today โ€” sees exactly one request and nobody waits twice. + */ + @Test + fun `a relay that asks for work is answered and the sign-up goes through`() { + refuse("challenge_required") + server.enqueue(MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":8,"expires_in":600}""")) + server.enqueue(MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"user_id":7,"username":"ada","app_password":"credential"}""")) + + val account = RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase") + assertEquals("ada", account.username) + + val first = server.takeRequest() + assertEquals("/account/signup", first.path) + assertTrue(!first.body.readUtf8().contains("challenge"), + "the first attempt should carry no challenge โ€” that round trip is the cost") + + assertEquals("/account/challenge", server.takeRequest().path) + + val retry = JSONObject(server.takeRequest().body.readUtf8()) + assertEquals("1700000000.c2FsdA.bWFj", retry.getString("challenge")) + // Every original field survives the retry. Dropping one here would look + // like a validation failure on a sign-up that was fine. + assertEquals("ada", retry.getString("username")) + assertEquals("ada@example.com", retry.getString("email")) + assertEquals("passphrase", retry.getString("password")) + } + + /** + * ๐Ÿ”ด Rethrown, not swallowed. When this app genuinely cannot answer โ€” a + * difficulty above the cap, or a relay that will not hand out a challenge โ€” + * "update the app" is the true sentence, and failing with anything else + * sends somebody looking in the wrong place. + */ + @Test + fun `an app that cannot answer is told to update`() { + refuse("challenge_required") + server.enqueue(MockResponse().setResponseCode(404)) + val failure = assertFailsWith { + RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase") + } + assertTrue(failure.message!!.contains("Update"), "no remedy offered: ${failure.message}") + } + + /** + * โš ๏ธ A different sentence from the one above, and it must stay different. + * This one is nearly always a challenge that expired while somebody was + * typing; the remedy is another tap, not a trip to the Play Store. + */ + @Test + fun `an expired check is told to try again`() { + refuse("challenge_failed") + val failure = assertFailsWith { + RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase") + } + assertTrue(failure.message!!.contains("again"), "no remedy offered: ${failure.message}") + } + + /** + * ๐Ÿ”ด The distinction that matters. A 400 is also a bad email, and telling + * somebody to update the app to fix a typo is worse than saying nothing. + */ + @Test + fun `another 400 is not mistaken for a challenge problem`() { + refuse("bad_email") + assertFailsWith { + RelayAccounts(url()).signUp("ada", "not-an-email", "passphrase") + } + } + + /** โš ๏ธ Neither sentence blames the person or their password. */ + @Test + fun `neither refusal reads as a credential problem`() { + val sentences = listOf( + RelayException.ChallengeRequired("").message!!, + RelayException.ChallengeFailed("").message!!, + ) + for (text in sentences.map { it.lowercase() }) { + for (misleading in listOf("password", "passphrase", "incorrect", "invalid", "wrong")) { + assertTrue(!text.contains(misleading), "reads as a credential problem: $text") + } + // And nothing machine-shaped: somebody creating an account does not + // need the words "proof of work", "hash" or "nonce". + for (jargon in listOf("proof of work", "hash", "nonce", "sha", "_")) { + assertTrue(!text.contains(jargon), "leads with the mechanism: $text") + } + } + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/TestSafetyTest.kt b/client/src/test/kotlin/io/flamenet/client/TestSafetyTest.kt new file mode 100644 index 0000000..43c8168 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/TestSafetyTest.kt @@ -0,0 +1,187 @@ +package io.flamenet.client + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * A test run must not publish a credential (the Kotlin half of ios#62). + * + * ๐Ÿ”ด **The iOS side had this bug twice, and Android had a quieter version of + * it.** Every live suite here is properly gated behind `assumeTrue` โ€” better + * than iOS managed โ€” but two of them carried + * `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create + * accounts on `accounts.flamenet.io` whenever somebody set `FN_LIVE_MANAGED=1`. + * + * The gate stops it running by accident. It does not stop the password being + * written down. Every account those runs ever created can be signed into by + * anybody who can read this repository, and "they were throwaway accounts" + * does not change it โ€” they are real accounts on a real service and they + * persist until somebody deletes them. + * + * โš ๏ธ **A source scan, and coarse on purpose.** It cannot prove a suite is safe; + * it proves the specific thing that went wrong cannot come back silently. + * That is worth having even though it is not a proof, and it costs a few + * milliseconds in the JVM suite rather than a device. + */ +class TestSafetyTest { + + private fun testSources(): List { + // From `client/build/classes/...` back to the repository root, then to + // every Kotlin test source under it. Walking from the working directory + // rather than a compiled resource, so this sees the tree as written. + val root = generateSequence(File(".").absoluteFile) { it.parentFile } + .firstOrNull { File(it, "settings.gradle.kts").exists() || File(it, "PARITY.md").exists() } + ?: File(".").absoluteFile + return root.walkTopDown() + .onEnter { it.name != "build" && it.name != ".git" } + .filter { it.isFile && it.extension == "kt" } + .filter { it.path.contains("/test/") || it.path.contains("/androidTest/") } + .toList() + } + + /** + * Lines that are code rather than prose. + * + * โš ๏ธ Trailing comments go too. The Swift version of this guard flagged an + * *email* default because the word "password" appeared in a comment at the + * end of the line โ€” and a guard that cries wolf is a guard somebody + * switches off. + */ + private fun codeLines(body: String): List = + body.lineSequence() + .filterNot { + val t = it.trimStart() + t.startsWith("//") || t.startsWith("*") || t.startsWith("/*") + } + .map { line -> + var i = 0 + while (true) { + val at = line.indexOf("//", i) + if (at < 0) return@map line + if (at == 0 || line[at - 1] != ':') return@map line.substring(0, at) + i = at + 2 + } + @Suppress("UNREACHABLE_CODE") line + } + .toList() + + /** + * Whether this file can actually reach a relay somebody else runs. + * + * โš ๏ธ **`MockWebServer` is the discriminator, and getting this wrong made + * the guard useless twice.** Keying on the `Live` name prefix flagged the + * guard helper and its own unit test; keying on the string "http" flagged + * four suites that sign up against a local mock, which is exactly the + * correct way to test sign-up. A file that stands up a mock server cannot + * reach anybody, whatever else it contains. + * + * What is left is: it signs up or signs in, it does NOT mock the server, + * and it names a real host or takes one from the environment. + */ + private fun reachesALiveRelay(code: List): Boolean { + val reaching = listOf("signUp(", "signIn(", "register(") + if (reaching.none { needle -> code.any { it.contains(needle) } }) return false + if (code.any { it.contains("MockWebServer") }) return false + return code.any { it.contains("flamenet.io") || it.contains("System.getenv") } + } + + /** + * ๐Ÿ”ด **No credential may be a literal in a suite that reaches a relay.** + * + * The rule is scoped to files that actually sign up or sign in, because a + * purely local unit test using `passphrase = "pass"` against an in-memory + * store publishes nothing and is not the problem. + */ + @Test + fun `no live suite carries a credential literal`() { + val offenders = mutableListOf() + + for (file in testSources()) { + val code = codeLines(file.readText()) + if (!reachesALiveRelay(code)) continue + for (line in code) { + val looksLikeCredential = line.contains("PASSPHRASE") || + line.contains("passphrase", ignoreCase = true) || + line.contains("password", ignoreCase = true) + if (!looksLikeCredential) continue + // `val X = "โ€ฆ"` or `const val X = "โ€ฆ"` โ€” an assignment to a + // non-empty literal. A getter that reads the environment has no + // `= "` on it and is the shape this wants people to use. + val assigned = Regex("""\bva[lr]\s+\w+\s*(:\s*String\s*)?=\s*"([^"]+)"""") + .find(line)?.groupValues?.getOrNull(2) + if (assigned != null) { + offenders += "${file.name}: $assigned" + } + } + } + + assertTrue( + offenders.isEmpty(), + """ + A suite that signs up or signs in carries a credential written into + the source: $offenders + + This repository is readable by everybody who can read it, so that + string is a published password โ€” and these suites create REAL + accounts on a real service. Being gated behind an environment + variable stops it running by accident; it does not stop the password + being written down. + + Read it from the environment instead, with no fallback: + LiveTarget.passphrase(), and an assumeTrue on it being non-empty so + an unconfigured run reports SKIPPED rather than signing up with a + value anybody can read. + """.trimIndent(), + ) + } + + /** + * ๐Ÿ”ด **Every suite that reaches a live relay must be able to decline.** + * + * `assumeTrue` reports SKIPPED, which is the truth. A bare `return` makes a + * test that did nothing report PASSED, and a suite of those is + * indistinguishable from a suite that works โ€” the point `LiveShapesTest` + * already makes in prose and this makes checkable. + */ + @Test + fun `every live suite can announce a skip`() { + // โš ๏ธ Scoped to what a file DOES, not to what it is called โ€” see + // `reachesALiveRelay`. + val offenders = testSources() + .filter { reachesALiveRelay(codeLines(it.readText())) } + .filterNot { codeLines(it.readText()).any { line -> line.contains("assumeTrue") } } + .map { it.name } + + assertTrue( + offenders.isEmpty(), + "these live suites cannot skip and will reach a real relay on any " + + "plain `./gradlew test`: $offenders", + ) + } + + /** + * โš ๏ธ A scan that finds nothing passes. This proves it is reading the real + * tree โ€” a moved directory would otherwise turn both checks above into + * no-ops reporting success. + */ + @Test + fun `the scan is looking at the real test sources`() { + val found = testSources() + assertTrue(found.size > 15, "found ${found.size} test sources, too few to be this repository") + assertTrue(found.any { it.name == "LiveShapesTest.kt" }, "LiveShapesTest.kt was not found") + assertTrue( + found.any { codeLines(it.readText()).any { line -> line.contains("assumeTrue") } }, + "the scan cannot see into the files it is reading", + ) + // ๐Ÿ”ด And the live-relay predicate must actually match something. If it + // matched nothing, both checks above would pass by looking at an empty + // set โ€” the exact way a guard reports success while guarding nothing. + val live = found.filter { reachesALiveRelay(codeLines(it.readText())) } + assertTrue( + live.size >= 5, + "the live-relay predicate matched ${live.size} suites, so the checks " + + "above are guarding almost nothing: ${live.map { it.name }}", + ) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 8cea422..7653742 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -61,6 +61,55 @@ data class Content( val link: LinkPreview? = null, /** A live stream this story embeds (ยง6.13). */ val streamUrl: String? = null, + + /** + * Who was named in this message, by user id. + * + * ๐Ÿ”ด Ids, not names. A mention has to reach somebody whose group is muted, + * and matching on a display name would mean anybody could be reached by + * typing their name โ€” or missed, because they had renamed themselves. + */ + val mentions: List = emptyList(), + + /** + * When this is a story made only of words, when it stops showing. + * + * ๐Ÿ”ด A story envelope with no attachment. Every story path here assumed a + * picture, so a text story had nowhere to be carried โ€” the same shape that + * was missing on iOS. + */ + val textStoryExpires: Double? = null, + + /** + * This reaction is about a STORY, not a message. + * + * โš ๏ธ Both are `kind: "react"`; only this separates them. Without it a story + * reaction tries to fold its emoji onto a message with that id โ€” and if one + * ever matched, onto entirely the wrong thing. + */ + val reactsToStory: Boolean = false, + + /** This message replies to a story rather than to a bubble. */ + val repliesToStory: Boolean = false, + /** + * Set iff this envelope replaces the text of an earlier message (ยง6.15): + * the `mid` of the message being edited. [text] then carries the new + * wording. + * + * ๐Ÿ”ด An edit is never a bubble of its own. It changes one that is already + * there, or it does nothing. + */ + val editOf: String? = null, + /** + * Set iff this envelope pins or unpins a message (ยง6.19): the id being + * pinned, or the empty string to take the pin down. + * + * ๐Ÿ”ด An id and never a copy of the message. + */ + val pins: String? = null, + + /** The sequence this story item belongs to (ยง6.23), if any. */ + val storySet: StorySet? = null, ) { /** @@ -93,6 +142,27 @@ data class Content( * was said. The preview is the sender's copy of their own words, the only * copy anyone can be sure of. */ + /** + * Which sequence a story item belongs to (ยง6.23). + * + * โš ๏ธ Every item of a set must carry the same `exp`. Expiry is per set: + * per-item expiry lets a sequence decay into items 0 and 2, which is + * confusing and is also a disclosure โ€” the gaps reveal the order the + * author added things. + */ + data class StorySet(val id: String, val i: Int, val n: Int) { + fun toJson(): JSONObject = + JSONObject().put("id", id).put("i", i).put("n", n) + + companion object { + fun fromJson(o: JSONObject): StorySet? { + val id = o.optString("id") + if (id.isEmpty()) return null + return StorySet(id, o.optInt("i", 0), o.optInt("n", 1)) + } + } + } + data class Reference(val messageId: String, val author: Long, val preview: String) { fun toJson(): JSONObject = JSONObject() .put("mid", messageId).put("author", author).put("preview", preview) @@ -135,9 +205,14 @@ data class Content( * * Reactions fold onto an existing bubble, call signals drive the call UI, * and control messages are consumed silently. Everything else is a message. + * + * ๐Ÿ”ด **An edit is not a message either (ยง6.15).** It carries the new + * wording in [text], so without this it falls straight through and draws as + * a second bubble beside the one it was meant to replace โ€” a correction + * arriving as a duplicate, which is worse than the typo. */ val isRenderable: Boolean - get() = reaction == null && call == null && !isControl + get() = reaction == null && call == null && pins == null && editOf == null && !isControl /** * Control traffic: consumed by a layer, never shown. @@ -161,6 +236,42 @@ data class Content( raw?.optBoolean("ctl", false) == true || (raw?.optInt("ctl", 0) ?: 0) != 0 + /** + * True when the sender is passing on somebody else's words (ยง6.16). + * + * โš ๏ธ Says THAT it was forwarded and never from whom. The point of a forward + * is that the original author does not travel with it; this exists so + * second-hand words are not shown as the sender's own, which is a different + * thing from attribution. + * + * โš ๏ธ Both shapes accepted, for the same reason [isControl] accepts both: + * `org.json`'s `optBoolean` does not coerce a number, so reading only that + * would miss every `"fwd":1` the other engines send. + */ + val isForwarded: Boolean + get() = raw?.optBoolean("fwd", false) == true || + (raw?.optInt("fwd", 0) ?: 0) != 0 +/** + * How long this one message lives, in seconds (ยง6.17), or null for no + * timer of its own. + * + * ๐Ÿ”ด **A duration, never a deadline.** An absolute expiry would be the + * sender's clock, and a sender who chose it could backdate a message so it + * vanished from the recipient's phone on arrival, or forward-date one so it + * never expired. The receiver counts from when IT recorded the message โ€” + * the rule ยง6.11 already follows. + * + * โš ๏ธ Only on a message. A [Kind.DISAPPEAR] envelope carries `secs` too and + * means something entirely different: it sets the whole conversation. Both + * being read off one field is why this checks the kind. + */ + val lifetimeSeconds: Int? + get() { + if (kind == Kind.DISAPPEAR) return null + val secs = raw?.optInt("secs", 0) ?: 0 + return if (secs > 0) secs else null + } + /** * A kind this build does not know, which is not control traffic. * @@ -247,12 +358,34 @@ 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). + * + * ๐Ÿ”ด **Not in [CONTROL].** Control kinds are swallowed whole; this one + * has to be acted on, and a build that merely swallowed it would drop + * corrections silently while looking like it worked. + */ + const val EDIT = "edit" + + /** + * A pinned message, or a pin being taken down (ยง6.19). + * + * ๐Ÿ”ด **Not in [CONTROL].** Control kinds are swallowed whole; this one + * has to be acted on, and a build that merely swallowed it would drop + * every pin while looking like it worked. + */ + const val PIN = "pin" + /** * โš ๏ธ Consumed, never shown. Each of these is a real message the web * 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. @@ -262,8 +395,8 @@ data class Content( * draw (shown as a placeholder) โ€” never raw JSON in a bubble. */ val KNOWN = CONTROL + setOf( - TEXT, REACT, STICKER, - Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY, + TEXT, REACT, STICKER, PIN, EDIT, + Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY, Attachment.FILE, CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END, ) } @@ -286,10 +419,20 @@ data class Content( messageId: String? = null, about: Reference? = null, link: LinkPreview? = null, + forwarded: Boolean = false, + lifetimeSeconds: Int? = null, ): String = - if (messageId == null && about == null && link == null) body + // โš ๏ธ Both of these belong in this condition, not just in the + // envelope. A plain message with nothing else to carry goes out as + // raw bytes with no envelope at all โ€” so a forward that was not + // counted here would lose its flag and arrive looking like the + // sender's own words, and a timed message would lose its timer and + // live for ever. + if (messageId == null && about == null && link == null && !forwarded + && (lifetimeSeconds ?: 0) <= 0) body else assemble("text", caption = body, messageId = messageId, about = about, - link = link) + link = link, forwarded = forwarded, + lifetimeSeconds = lifetimeSeconds) fun groupText( body: String, @@ -297,8 +440,11 @@ data class Content( messageId: String? = null, about: Reference? = null, groupName: String? = null, + mentions: List = emptyList(), + forwarded: Boolean = false, ): String = assemble("text", caption = body, groupId = groupId, - messageId = messageId, about = about, groupName = groupName) + messageId = messageId, about = about, groupName = groupName, + mentions = mentions, forwarded = forwarded) fun attachment( ref: Attachment.Ref, @@ -309,18 +455,78 @@ data class Content( /** A live stream this story embeds (ยง6.13); [ref] is then a poster. */ streamUrl: String? = null, groupName: String? = null, + forwarded: Boolean = false, + lifetimeSeconds: Int? = null, + /** + * The sequence this item belongs to (ยง6.23). + * + * โš ๏ธ Every item of a set must carry the same `exp`. Expiry is per + * set: per-item expiry lets a sequence decay into items 0 and 2, + * which is confusing and is also a disclosure โ€” the gaps reveal + * the order the author added things. + */ + storySet: StorySet? = null, ): String = assemble( kind = ref.kind, - caption = caption?.takeIf { it.isNotEmpty() }, + // ๐Ÿ”ด A file envelope ALWAYS carries a caption, even with none + // written. Builds that predate this kind show the caption and drop + // the rest, so an empty one reaches them as "[unsupported + // attachment]" with no clue what was sent. Matches iOS exactly. + caption = caption?.takeIf { it.isNotEmpty() } + ?: "๐Ÿ“Ž ".plus(ref.filename.ifEmpty { "A file" }) + .takeIf { ref.kind == Attachment.FILE }, expires = ref.expires.takeIf { ref.kind == Attachment.STORY && it > 0 }, groupId = groupId, attachment = ref, + filename = ref.filename.takeIf { ref.kind == Attachment.FILE }, + label = if (ref.kind == Attachment.FILE) { + ref.classification to ref.compartments + } else null, messageId = messageId, about = about, streamUrl = streamUrl, groupName = groupName, + forwarded = forwarded, + lifetimeSeconds = lifetimeSeconds, + storySet = storySet, ) + /** + * A story that is only words. + * + * โš ๏ธ Carries a [messageId], which is what identifies it across the + * sender's devices โ€” a picture story is identified by its blob token + * and a text story has none. Without it the same story appears once + * per phone the sender posted from. + */ + fun textStory(text: String, expires: Double, messageId: String): String = + assemble(Attachment.STORY, caption = text, expires = expires, + messageId = messageId) + + /** + * React to somebody's story. + * + * ๐Ÿ”ด Goes to the AUTHOR alone. A story is sent to each viewer rather + * than hosted, so there is no shared object to attach a reaction to + * and nobody else to tell โ€” which is why other viewers never learn who + * reacted: there is nowhere for them to look. + */ + fun storyReaction(emoji: String, storyKey: String, author: Long): String = + assemble(Kind.REACT, caption = emoji, + about = Reference(storyKey, author, ""), aboutAStory = true) + + /** + * Reply to somebody's story, as an ordinary sealed direct message. + * + * โš ๏ธ [preview] travels with the reply because stories expire: by the + * time this is read the story may be gone, and "replied to your story" + * with no idea which one cannot be understood. + */ + fun storyReply(body: String, storyKey: String, author: Long, + preview: String, messageId: String): String = + assemble("text", caption = body, messageId = messageId, + about = Reference(storyKey, author, preview), aboutAStory = true) + /** * โš ๏ธ An empty emoji takes a previous reaction back. Removal has to be a * message rather than an absence: the other device has already rendered @@ -343,6 +549,38 @@ data class Content( return "{" + parts.joinToString(",") + "}" } + /** + * An edit of an earlier message (ยง6.15). + * + * โš ๏ธ Reuses `re` to name the target, exactly as a reaction does โ€” a new + * field would be a second way of saying "this is about that message" + * and would need adding to two clients, an engine and a spec before + * anything worked. + * + * ๐Ÿ”ด **`re.author` is not evidence and the receiver must not read it as + * evidence.** Anyone can put any user id there. The receiver decides + * whether an edit is allowed from the copy of the message it already + * holds, never from what the envelope claims about itself. + */ + fun edit(newText: String, of: String, groupId: String? = null): String = + assemble(Kind.EDIT, caption = newText, groupId = groupId, + about = Reference(of, author = 0, preview = "")) + + /** + * A pin, or a pin being taken down (ยง6.19). + * + * โš ๏ธ An empty [messageId] unpins. Removal has to be a message rather + * than an absence: the other device has already drawn the pin, and + * there is no other way to reach it. + * + * ๐Ÿ”ด An id and never a copy of the message. A pin carrying the text + * would keep a message readable after its own timer had removed it, so + * pinning would quietly defeat disappearing messages. + */ + fun pin(messageId: String, groupId: String? = null): String = + assemble(Kind.PIN, groupId = groupId, + about = Reference(messageId, author = 0, preview = "")) + fun callSignal(signal: CallSignal): String { val call = JSONObject().put("id", signal.callId) signal.sdp?.let { call.put("sdp", it) } @@ -362,8 +600,24 @@ data class Content( link: LinkPreview? = null, streamUrl: String? = null, groupName: String? = null, + filename: String? = null, + label: Pair>? = null, + mentions: List = emptyList(), + aboutAStory: Boolean = false, + forwarded: Boolean = false, + lifetimeSeconds: Int? = null, + storySet: StorySet? = null, ): String { val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}") + // โš ๏ธ `1`, not `true` โ€” ยง6.10's shape for every flag on this + // envelope, and what the other two engines read first. + if (forwarded) parts += "\"fwd\":1" + // ยง6.17 โ€” this one message's own timer. Reuses `secs`, the field + // the per-conversation announcement already uses, rather than + // inventing a second name for a number of seconds. + if (lifetimeSeconds != null && lifetimeSeconds > 0) { + parts += "\"secs\":$lifetimeSeconds" + } messageId?.let { parts += "\"mid\":${quote(it)}" } about?.let { parts += "\"re\":${it.toJson()}" } caption?.let { parts += "\"caption\":${quote(it)}" } @@ -380,6 +634,20 @@ data class Content( attachment?.let { parts += "\"att\":${it.toJson()}" } link?.let { parts += "\"lnk\":${it.toJson()}" } streamUrl?.let { parts += "\"stream\":${quote(it)}" } + // ยง6.23 โ€” the sequence this item belongs to. + storySet?.let { parts += "\"set\":${it.toJson()}" } + // File envelopes only. Additive top-level keys: a build that + // predates them ignores them and still reads the caption, which is + // what keeps a file message legible to an older peer. + filename?.takeIf { it.isNotEmpty() }?.let { parts += "\"fn\":${quote(it)}" } + if (mentions.isNotEmpty()) { + parts += "\"men\":${mentions.sorted().joinToString(",", "[", "]")}" + } + if (aboutAStory) parts += "\"st\":1" + if (label != null) { + parts += "\"cls\":${label.first}" + parts += "\"cmp\":${label.second.sorted().joinToString(",", "[", "]")}" + } return parts.joinToString(",", prefix = "{", postfix = "}") } @@ -404,6 +672,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 @@ -430,13 +753,31 @@ data class Content( val messageId = json.optString("mid").takeIf { it.isNotEmpty() } val about = json.optJSONObject("re")?.let(Reference::fromJson) val caption = json.optString("caption") + // Additive keys. An older peer omits them and every default here + // is what that peer meant. + val aboutAStory = json.optInt("st", 0) != 0 || json.optBoolean("st", false) + val storySet = json.optJSONObject("set")?.let(StorySet::fromJson) + val mentions = json.optJSONArray("men")?.let { array -> + (0 until array.length()).map { array.optInt(it) } + } ?: emptyList() // A reaction is not a message and must never become a bubble. // Checked before everything else so a field-ordering change cannot // let one fall through and render as an empty line in the thread. if (kind == Kind.REACT && about != null) { return Content("", kind = kind, raw = json, groupId = groupId, - messageId = messageId, about = about, reaction = caption) + messageId = messageId, about = about, reaction = caption, + reactsToStory = aboutAStory) + } + + // A story made of words. Checked before the attachment paths, which + // would otherwise treat it as an attachment reference that failed + // to parse โ€” and before the "[unsupported attachment]" fallback an + // older build correctly shows for it. + if (kind == Attachment.STORY && json.optJSONObject("att") == null) { + return Content(caption, kind = kind, raw = json, groupId = groupId, + messageId = messageId, + textStoryExpires = json.optDouble("exp", 0.0)) } // A sticker is renderable but is NOT text: it draws as a glyph with @@ -450,6 +791,23 @@ data class Content( } } + // ยง6.15 โ€” a replacement for the text of an earlier message. + // Checked BEFORE the control swallow, which would consume it + // correctly and act on nothing, and before TEXT, which would render + // the new wording as a second bubble beside the original. + if (kind == Kind.EDIT && about != null) { + return Content(caption, kind = kind, raw = json, groupId = groupId, + about = about, editOf = about.messageId) + } + + // ยง6.19 โ€” a pin changes a banner and is never a bubble. Checked + // before the control swallow, which would consume it correctly and + // act on nothing. + if (kind == Kind.PIN && about != null) { + return Content("", kind = kind, raw = json, groupId = groupId, + about = about, pins = about.messageId) + } + // Control traffic. Returned with its envelope so the caller can act // on it, and never renderable. // @@ -478,11 +836,22 @@ data class Content( if (kind == Kind.TEXT) { val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) } return Content(caption, kind = kind, raw = json, groupId = groupId, link = link, - messageId = messageId, about = about) + messageId = messageId, about = about, + mentions = mentions, + repliesToStory = aboutAStory && about != null) } val ref = json.optJSONObject("att") - ?.let { Attachment.Ref.fromJson(kind, it, json.optDouble("exp", 0.0)) } + ?.let { + Attachment.Ref.fromJson( + kind, it, json.optDouble("exp", 0.0), + filename = json.optString("fn"), + classification = json.optInt("cls", 0), + compartments = json.optJSONArray("cmp")?.let { array -> + (0 until array.length()).map { i -> array.optInt(i) } + } ?: emptyList(), + ) + } ?: return Content( // The sender meant to say something; a message that // silently vanishes is the worst way to meet a version this @@ -493,6 +862,7 @@ data class Content( ) return Content(caption, kind = kind, raw = json, attachment = ref, groupId = groupId, messageId = messageId, about = about, + storySet = storySet, streamUrl = json.optString("stream").takeIf { it.isNotEmpty() }) } } @@ -513,6 +883,16 @@ object Attachment { /** An image for the story strip rather than a thread. */ const val STORY = "story" + /** + * A document from Files, carrying the label it was filed under. + * + * ๐Ÿ”ด The label is a MARKING, not a permission. The content key is in the + * same envelope, so whoever can read the message can read the file โ€” the + * bubble shows the classification so it can be filed the same way here, + * and must never imply this device was cleared for it. + */ + const val FILE = "file" + private val random = SecureRandom() /** Everything needed to fetch, verify and open a blob. */ @@ -531,6 +911,11 @@ object Attachment { val duration: Double = 0.0, /** Story only: unix seconds after which it must not render. */ val expires: Double = 0.0, + /** File only: the document's own name, for showing and for saving. */ + val filename: String = "", + /** File only: the label it was filed under. See [FILE]. */ + val classification: Int = 0, + val compartments: List = emptyList(), ) { fun toJson(): JSONObject = JSONObject() .put("id", id) @@ -553,7 +938,20 @@ object Attachment { .also { if (expires > 0) it.put("exp", expires) } companion object { - fun fromJson(kind: String, json: JSONObject, expires: Double): Ref? { + /** + * โš ๏ธ `filename`, `classification` and `compartments` come from the + * ENVELOPE, not from `att` โ€” they are additive top-level keys, so + * an older parser ignores them and a newer one has to be handed + * them explicitly rather than reading them off the attachment. + */ + fun fromJson( + kind: String, + json: JSONObject, + expires: Double, + filename: String = "", + classification: Int = 0, + compartments: List = emptyList(), + ): Ref? { val key = runCatching { Bytes.unb64(json.optString("key")) }.getOrNull() val digest = runCatching { Bytes.unb64(json.optString("digest")) }.getOrNull() // Wrong-sized key material is a malformed reference, not a @@ -571,6 +969,9 @@ object Attachment { height = json.optInt("h"), duration = json.optDouble("dur", 0.0), expires = expires, + filename = filename, + classification = classification, + compartments = compartments, ) } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/MessageSearch.kt b/engine/src/main/kotlin/io/flamenet/e2e/MessageSearch.kt new file mode 100644 index 0000000..ba18e43 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/MessageSearch.kt @@ -0,0 +1,146 @@ +package io.flamenet.e2e + +import org.json.JSONObject + +/** + * Finding a word somebody said, across every conversation on this device. + * + * ๐Ÿ”ด **There is no index, and that is a decision rather than an omission.** The + * obvious design builds a search index over the decrypted history โ€” and an + * index derived from plaintext is plaintext by another name, so it would have + * to be sealed as carefully as the history itself, purged with it, and kept + * consistent with it. That is a second copy of everything anybody ever wrote, + * with its own ways to go wrong. + * + * Instead this reads the threads that already exist, through the vault, and + * throws the plaintext away when it is done. Nothing new comes to rest, so + * there is nothing new to protect. + * + * โš ๏ธ **This costs more here than on iOS**, and the difference is worth naming. + * The iPhone app keeps the whole decrypted history in memory, so its search is + * a scan of what is already there. Android reads and decrypts each thread on + * demand, so a search touches the vault once per conversation. That is why this + * takes a minimum query length and a bound, and why callers must run it off the + * main thread. If it ever becomes too slow, an index is the answer โ€” and then it + * must be sealed with the vault and taken by `forgetEverything`, or the app will + * have quietly acquired an unencrypted transcript. + * + * ๐Ÿ”ด **The query itself is never persisted.** What somebody searched for is + * often more revealing than what they found. + */ +object MessageSearch { + + /** + * How many hits are built at once. + * + * โš ๏ธ A bound, not a preference. A short query matches most of a long + * history, and a list of thousands of rows is a spinner that never ends for + * a result nobody reads past the first screen of. + */ + const val LIMIT = 60 + + /** + * Below this, a search is not run at all. + * + * โš ๏ธ Not a UI nicety. One character matches nearly every message, so the + * work is maximal and the result is useless โ€” and unlike iOS, that work + * means decrypting every thread on the device. + */ + const val MINIMUM_QUERY = 2 + + /** Characters of context kept either side of the match. */ + const val CONTEXT = 40 + + data class Hit( + val threadId: String, + val text: String, + /** The part of the message around the match. */ + val snippet: String, + val at: Long, + val mine: Boolean, + ) + + /** + * Search every thread the vault holds. + * + * โš ๏ธ Blocking and disk-bound. The caller is responsible for keeping it off + * the main thread. + */ + fun hits(query: String, vault: Vault, limit: Int = LIMIT): List { + // ๐Ÿ”ด An empty query finds NOTHING, never everything. Returning the whole + // history the moment somebody focuses the search box would put every + // message they have on one screen โ€” the place most likely to be read + // over a shoulder. + val needle = query.trim() + if (needle.length < MINIMUM_QUERY) return emptyList() + + val found = mutableListOf() + for (threadId in vault.threads()) { + val stored = vault.history(threadId) + for (i in 0 until stored.length()) { + val m: JSONObject = stored.getJSONObject(i) + val text = m.optString("text") + if (!matches(text, needle)) continue + found += Hit( + threadId = threadId, + text = text, + snippet = snippet(text, needle), + at = m.optLong("at", 0L), + mine = m.optBoolean("mine", false), + ) + } + } + // Newest first: what somebody is looking for is far more often recent + // than old, and it is the only ordering that needs no explanation. + return found.sortedByDescending { it.at }.take(limit) + } + + /** + * โš ๏ธ Case- AND accent-insensitive. Somebody typing `cafe` means `cafรฉ`, and + * a search that knows the difference quietly fails for every language with + * accents in it โ€” which is most of them, and this app ships in Spanish next. + * + * โš ๏ธ A plain substring, never a regular expression. The needle is typed by + * a person: a stray bracket is a crash or a hang, and nobody typing into a + * search box intended a pattern. + */ + fun matches(haystack: String, needle: String): Boolean { + if (haystack.isEmpty() || needle.isEmpty()) return false + return fold(haystack).contains(fold(needle)) + } + + /** + * The part of a message around the match, so a hit says why it is a hit. + * + * โš ๏ธ Bounded on both sides. A whole message means one long one pushes every + * other result off the screen; only the start means a match near the end + * looks like no match at all. + */ + fun snippet(text: String, needle: String): String { + val at = fold(text).indexOf(fold(needle)) + if (at < 0) return text.take(CONTEXT * 2) + + val start = (at - CONTEXT).coerceAtLeast(0) + val end = (at + needle.length + CONTEXT).coerceAtMost(text.length) + val piece = text.substring(start, end) + return (if (start > 0) "โ€ฆ" else "") + piece + (if (end < text.length) "โ€ฆ" else "") + } + + /** + * โš ๏ธ Folding runs on both sides and must be length-preserving, because + * [snippet] slices the ORIGINAL string at an index found in the folded one. + * A fold that dropped or added characters would cut the snippet in the + * wrong place โ€” and for a combining accent it would cut mid-character. + */ + private fun fold(s: String): String { + val normalised = java.text.Normalizer.normalize(s, java.text.Normalizer.Form.NFD) + val stripped = buildString(s.length) { + for (ch in normalised) { + // Combining marks only. Dropping them from the DECOMPOSED form + // leaves one character per original character. + if (Character.getType(ch) != Character.NON_SPACING_MARK.toInt()) append(ch) + } + } + return stripped.lowercase() + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt new file mode 100644 index 0000000..6380626 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt @@ -0,0 +1,65 @@ +package io.flamenet.e2e + +/** + * How often sending falls back from sealed to unsealed (ยง14.5, ios#16). + * + * ๐Ÿ”ด **The fallback is the thing worth counting.** Sealing hides WHO SENT a + * message from the relay. When the peer's delivery key or capability is missing + * the send still goes โ€” end-to-end encrypted as always โ€” but unsealed, so the + * relay learns the sender. The outcome was reported per send and nothing + * accumulated, so nobody could answer "how often does this happen to me". + * + * โš ๏ธ **Aggregate only, deliberately.** Counting per contact would be more + * useful and would also build a record of who this account talks to and how + * often โ€” the precise thing sealing exists to keep off a server, kept instead + * on the device. Totals answer the question without producing a social graph as + * a side effect. + */ +data class SealingRecord( + /** Sends where the relay could not see who sent it. */ + val sealed: Int = 0, + /** Sends where it could. */ + val unsealed: Int = 0, + /** When the last unsealed send happened, unix seconds. 0 = never. */ + val lastUnsealedAt: Long = 0, +) { + val total: Int get() = sealed + unsealed + + /** + * What share of sends the relay could attribute, 0..1. + * + * โš ๏ธ Zero sends is not "nothing leaked", it is "nothing sent". Returning + * 0.0 would render as a reassuring 0% on a fresh install. + */ + val unsealedShare: Double? get() = + if (total == 0) null else unsealed.toDouble() / total.toDouble() + + /** + * The sentence shown to the user. + * + * ๐Ÿ”ด **Says what is exposed, not that the message was readable.** An + * unsealed send is still end-to-end encrypted: the relay learns who sent + * it, not what it said. Copy that blurred the two would either frighten + * people about nothing or, worse, teach them that "sealed" means + * "encrypted" and leave them relaxed when it is off. + */ + val summary: String get() { + val share = unsealedShare ?: return "Nothing sent from this device yet." + if (unsealed == 0) { + return "All $sealed messages sent from this device were sealed โ€” " + + "the server was never told who sent them." + } + val percent = Math.round(share * 100).toInt() + return "$unsealed of $total messages sent from this device ($percent%) went " + + "unsealed. Those were still end-to-end encrypted โ€” the server could see " + + "who sent them, not what they said." + } + + /** Fold one send's outcome in. */ + fun recording(wasSealed: Boolean, at: Long): SealingRecord = + if (wasSealed) copy(sealed = sealed + 1) + // โš ๏ธ A sealed send must not clear the stamp: "when did this last + // happen" is the question, and one good send afterwards does not + // unhappen it. + else copy(unsealed = unsealed + 1, lastUnsealedAt = at) +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index f241cd1..72f7caf 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -82,11 +82,14 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val OUTBOX = "outbox" private const val CALL_LOG = "call-log" private const val STORY_AUDIENCE = "story-audience" + private const val STORY_MUTES = "story-mutes" + private const val STORY_ARCHIVE = "story-archive" private const val DISAPPEAR = "disappear" private const val SNOOZE = "snooze" private const val REPLY_REMINDER = "reply-reminder" private const val ROSTER = "roster" private const val GROUP_NAMES = "group-names" + private const val DRAFTS = "drafts" private const val GROUP_SECRETS = "group-secrets" private const val DEVICE_OWNER_PREFIX = "devowner/" private const val KT_ISSUER_PIN = "kt-issuer-pin" @@ -100,6 +103,149 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val MAX_GROUP_NAME = 80 /** The two kinds of thread, named once so they cannot drift apart. */ + /** + * Who a group story will reach, and who among them this account has + * excluded from its stories (ยง6.24). + * + * ๐Ÿ”ด **The roster decides; the story audience does not apply.** Those + * are two different lists making two different statements, and they + * will disagree. This returns the overlap rather than a warning, + * because a sender who believes their story audience still applies is + * being misled by the interface, and a disclosure that cannot name the + * consequence is not one. + * + * โš ๏ธ The two categories do not double-count. Somebody who is not a + * contact is not "excluded from your stories" โ€” they were never on + * that list at all. + */ + fun groupStoryReach( + members: List, + me: Long, + /** + * Who this account's story audience would have allowed, already + * resolved. Passed in rather than an audience type: the audience + * lives in the app layer and this module has no dependency on it. + */ + allowedByAudience: Set, + contacts: List, + ): GroupStoryReach { + val recipients = members.filter { it != me } + val contactSet = contacts.toSet() + return GroupStoryReach( + recipients = recipients, + excludedByAudience = + recipients.filter { it in contactSet && it !in allowedByAudience }, + notContacts = recipients.filter { it !in contactSet }, + ) + } + + /** + * Collapse a set to the expiry every item shares (ยง6.23). + * + * ๐Ÿ”ด **The earliest wins.** Every item is required to carry the same + * `exp`, but a sender can write different ones โ€” and if the latest + * won, the difference would be a way to keep part of a sequence alive + * after the rest had gone. + * + * โš ๏ธ A zero means "no expiry on that item" and is skipped, not read as + * "expires at the epoch", which would kill the whole set. A text story + * has no attachment at all and contributes nothing here. + */ + fun setExpiry(items: List): Double = + items.mapNotNull { it.ref?.expires?.takeIf { e -> e > 0 } }.minOrNull() ?: 0.0 + + /** + * Group one author's stories into sets, in position order (ยง6.23). + * + * โš ๏ธ Renders what arrived. `n` is the sender's claim and any item can + * fail to arrive, so a set is never held back or shown as incomplete โ€” + * a missing item is indistinguishable from one never sent. + */ + fun groupedStories(stories: List): List> { + val order = mutableListOf() + val sets = LinkedHashMap>() + stories.forEach { story -> + // A story with no set is its own group of one, keyed so it can + // never collide with a real set id. + val key = story.set?.let { "set:${it.id}" } ?: "one:${story.id}" + if (sets[key] == null) { order += key; sets[key] = mutableListOf() } + sets.getValue(key) += story + } + return order.map { key -> sets.getValue(key).sortedBy { it.set?.i ?: 0 } } + } + + /** + * Fold newly-expired stories into the archive (ios#54). + * + * ๐Ÿ”ด **Refuses anything not the author's own.** The caller filters as + * well, and this is the guard that survives the caller being + * rewritten โ€” an archive holding somebody else's expired story breaks + * the promise the app made to them, and "it is private to you" does + * not repair that. + * + * โš ๏ธ De-duplicated by id, because the sweep runs on every load and a + * story that expired weeks ago would otherwise be appended each time. + * Newest first, so the screen needs no sort. + */ + fun archive(existing: List, adding: List): List { + val seen = existing.map { it.id }.toMutableSet() + val result = existing.toMutableList() + adding.forEach { story -> + if (story.mine && seen.add(story.id)) result += story + } + return result.sortedByDescending { it.postedAt } + } + + /** + * Drop muted authors' stories from the strip (ios#33). + * + * โš ๏ธ `me` is never dropped. Muting yourself is not offered, but a + * muted id that reached the set some other way โ€” a restored backup + * from a build without the guard โ€” must not hide the user's own posts + * from them, which reads as the posts having failed to send. + * + * โš ๏ธ Order is the caller's, newest first. Filtering must not re-sort. + */ + fun visibleStories(stories: List, muted: Set, me: Long): List = + stories.filter { it.authorId == me || it.authorId !in muted } +/** + * How long a message actually lives, given its own timer and its + * conversation's (ยง6.17). Null means it does not expire. + * + * ๐Ÿ”ด **The shorter of the two always wins, and that is the whole + * rule.** A per-message timer may only take life away, never add it. + * Otherwise a sender could defeat the recipient's conversation timer by + * putting a long one on every message โ€” turning a setting the recipient + * chose, on their own device, into something the other party overrules. + * + * โš ๏ธ Zero means "no timer", not "expire immediately", on both axes. + * Reading it the other way would delete a message the instant it + * arrived, and zero is how "off" has always been spelled here. + */ + fun effectiveLifetime(message: Int, conversation: Int): Int? { + val own = message.takeIf { it > 0 } + val thread = conversation.takeIf { it > 0 } + return when { + own != null && thread != null -> minOf(own, thread) + else -> own ?: thread + } + } + + /** + * Whether this story may be written out of the app (ios#34). + * + * ๐Ÿ”ด **Only ever your own, and the rule lives here rather than in the + * button.** Saving somebody else's story keeps a copy of something + * they were told would disappear โ€” the opposite of what the feature + * promises them โ€” and no wording on a menu item makes that acceptable. + * A guard in a composable is one refactor away from being lost. + * + * โš ๏ธ A text story (ยง6.21) has no blob and nothing to write out. False + * rather than rendering the words into a picture: a story is what the + * author posted, and an image of it is a different object. + */ + fun isSaveable(mine: Boolean, hasBlob: Boolean): Boolean = mine && hasBlob + fun threadForUser(userId: Long) = "user:$userId" fun threadForGroup(groupId: String) = "group:$groupId" @@ -393,14 +539,43 @@ class Vault(private val storage: Storage, private val key: SecretKey) { * this one deletes them because it chooses to. A story is not a secret you * can take back, and the UI should not suggest otherwise. */ + data class GroupStoryReach( + val recipients: List, + /** Members this account excluded from its stories, who get it anyway. */ + val excludedByAudience: List, + /** Members who are not contacts at all. */ + val notContacts: List, + ) + data class Story( val id: String, val authorId: Long, val mine: Boolean, - val ref: Attachment.Ref, + /** + * The picture or video, when there is one. + * + * ๐Ÿ”ด Nullable because a story can be words alone. Non-nullable, there + * was nowhere for a text story to exist โ€” which is why posting one was + * impossible rather than merely unbuilt, on both platforms. + */ + val ref: Attachment.Ref?, val caption: String, val postedAt: Long, val seen: Boolean = false, + /** When it stops showing, in unix seconds. 0 means it does not. */ + val expiresAt: Double? = null, + /** Which sequence this story belongs to (ยง6.23), if any. */ + val set: Content.StorySet? = null, + /** + * The group this story was posted to (ยง6.24), if any. + * + * โš ๏ธ Names the AUDIENCE, not a destination thread. A group story + * belongs in the story surface; rendering it into the group's messages + * would make a story into a message, which it is not. + * + * ๐Ÿ”ด Persisted. Without it a reload forgets which story went where. + */ + val gid: String? = null, /** * A live stream this story embeds, if it is one (ยง6.13). * @@ -413,7 +588,13 @@ class Vault(private val storage: Storage, private val key: SecretKey) { */ val streamUrl: String? = null, ) { - fun isActive(nowSeconds: Double): Boolean = ref.expires <= 0 || ref.expires > nowSeconds + /** Where the expiry actually lives โ€” on the story, or on its picture. */ + val expires: Double get() = expiresAt ?: ref?.expires ?: 0.0 + + /** Words with no picture behind them. */ + val textOnly: Boolean get() = ref == null + + fun isActive(nowSeconds: Double): Boolean = expires <= 0 || expires > nowSeconds /** The host a viewer would connect to, for the disclosure. */ val streamHost: String? @@ -537,6 +718,40 @@ class Vault(private val storage: Storage, private val key: SecretKey) { writeJson(GROUP_NAMES, json) } + /** + * A half-written message, kept across a relaunch. + * + * ๐Ÿ”ด **In the vault rather than in SharedPreferences, and that is the whole + * point.** A draft is plaintext the person has not decided to send โ€” very + * often the most sensitive text in the app, because it is the thing they + * are still weighing. `SharedPreferences` writes it as readable XML that + * survives into backups; here it is sealed under the same key as the + * conversation, with the record's own name bound into the AAD, and + * [forgetEverything] takes it on sign-out. + * + * โš ๏ธ Keyed by the same `threadFor*` ids the transcript uses, so a group, a + * local contact and somebody on another relay cannot collide โ€” see the note + * on [threadForAddress] for why that third case is not theoretical. + */ + fun draft(threadId: String): String? = + readJson(DRAFTS)?.optString(threadId)?.takeIf { it.isNotEmpty() } + + /** + * โš ๏ธ Blank is not a draft. Storing " " would restore a composer that + * looks empty and reports itself as having content, which on this screen + * means an enabled send button that sends nothing. + * + * โš ๏ธ Stored AS TYPED though, not trimmed: somebody who left a trailing + * space was mid-word, and handing back a subtly different string than the + * one they left is the kind of small wrongness that makes an app feel + * untrustworthy. + */ + fun saveDraft(threadId: String, text: String) { + val json = readJson(DRAFTS) ?: JSONObject() + if (text.isBlank()) json.remove(threadId) else json.put(threadId, text) + writeJson(DRAFTS, json) + } + /** * Record a contact's picture. * @@ -579,13 +794,25 @@ class Vault(private val storage: Storage, private val key: SecretKey) { if (ref == null) storage.delete(MY_AVATAR) else writeJson(MY_AVATAR, ref.toJson()) } - fun loadStories(): List { - val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList() + fun loadStories(): List = storiesFrom(readJson(STORIES)) + + /** Shared by the live set and the archive (ios#54). */ + private fun storiesFrom(record: JSONObject?): List { + val array = record?.optJSONArray("items") ?: return emptyList() return (0 until array.length()).mapNotNull { i -> val o = array.getJSONObject(i) - val ref = Attachment.Ref.fromJson( - Attachment.STORY, o.getJSONObject("ref"), o.getJSONObject("ref").optDouble("exp", 0.0) - ) ?: return@mapNotNull null + // โš ๏ธ `optJSONObject`, not `getJSONObject`. A text story has no ref, + // and the throwing accessor would take down the load of EVERY + // story rather than skipping one โ€” the feed would simply be empty + // with nothing to say why. + val refJson = o.optJSONObject("ref") + val ref = refJson?.let { + Attachment.Ref.fromJson(Attachment.STORY, it, it.optDouble("exp", 0.0)) + } + // A row with a ref that would not parse is broken; one with no ref + // at all is a text story. Telling those apart is the difference + // between dropping a corrupt row and dropping a valid one. + if (refJson != null && ref == null) return@mapNotNull null Story( id = o.getString("id"), authorId = o.getLong("author"), @@ -594,6 +821,9 @@ class Vault(private val storage: Storage, private val key: SecretKey) { caption = o.optString("caption"), postedAt = o.optLong("at"), seen = o.optBoolean("seen", false), + expiresAt = if (o.has("expires")) o.optDouble("expires") else null, + set = o.optJSONObject("set")?.let { Content.StorySet.fromJson(it) }, + gid = o.optString("gid").takeIf { it.isNotEmpty() }, ) } } @@ -607,21 +837,44 @@ class Vault(private val storage: Storage, private val key: SecretKey) { * is the opposite of what an expiring story promises. */ fun saveStories(stories: List, nowSeconds: Double) { - val live = stories.filter { it.isActive(nowSeconds) } + writeJson(STORIES, JSONObject() + .put("items", storiesToJson(stories.filter { it.isActive(nowSeconds) }))) + } + + /** Shared by the live set and the archive (ios#54). */ + private fun storiesToJson(stories: List): org.json.JSONArray { val array = org.json.JSONArray() - live.forEach { story -> - array.put(JSONObject() + stories.forEach { story -> + val row = JSONObject() .put("id", story.id) .put("author", story.authorId) .put("mine", story.mine) - .put("ref", story.ref.toJson()) .put("caption", story.caption) .put("at", story.postedAt) - .put("seen", story.seen)) + .put("seen", story.seen) + .put("expires", story.expires) + // Only when there is one โ€” `put(key, null)` REMOVES a key here, + // which is what we want, but being explicit is clearer than + // relying on it. + story.ref?.let { row.put("ref", it.toJson()) } + story.set?.let { row.put("set", it.toJson()) } + story.gid?.let { row.put("gid", it) } + array.put(row) } - writeJson(STORIES, JSONObject().put("items", array)) + return array } + /** + * Stories this account posted, kept past their expiry (ios#54). + * + * ๐Ÿ”ด Only ever your own, and only when the setting is on. This keeps + * something the interface told everybody would disappear. + */ + fun storyArchive(): List = storiesFrom(readJson(STORY_ARCHIVE)) + + fun saveStoryArchive(stories: List) = + writeJson(STORY_ARCHIVE, JSONObject().put("items", storiesToJson(stories))) + // -- Key-log pin (ยง19.3) ------------------------------------------------- /** @@ -986,20 +1239,24 @@ class Vault(private val storage: Storage, private val key: SecretKey) { * Returns the surviving messages. */ fun pruneExpired(threadId: String, now: Long): org.json.JSONArray { - val seconds = disappearSeconds(threadId) + val conversation = disappearSeconds(threadId) val stored = readJson(historyName(threadId))?.optJSONArray("messages") ?: org.json.JSONArray() - if (seconds <= 0 || stored.length() == 0) return stored + if (stored.length() == 0) return stored - val cutoff = now - seconds * 1000L val kept = org.json.JSONArray() for (i in 0 until stored.length()) { val m = stored.getJSONObject(i) + // โš ๏ธ Per MESSAGE, not per thread. A conversation with no timer can + // still hold messages carrying their own (ยง6.17), so the old + // `if (seconds <= 0) return` skipped the whole thread and silently + // kept every timed message in it. + val life = effectiveLifetime(m.optInt("secs", 0), conversation) // A message with no timestamp predates the field. Kept rather than // destroyed: an unknown age is not evidence of an old one, and // deleting somebody's history on a guess is unrecoverable. val at = m.optLong("at", 0L) - if (at == 0L || at >= cutoff) kept.put(m) + if (life == null || at == 0L || at >= now - life * 1000L) kept.put(m) } if (kept.length() != stored.length()) { writeJson(historyName(threadId), JSONObject().put("messages", kept)) @@ -1061,6 +1318,25 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience) + /** + * Whose stories this device does not show. + * + * ๐Ÿ”ด Encrypted at rest with everything else in here. Who somebody has + * quietly stopped watching is exactly the kind of thing a stolen phone + * should not hand over โ€” and it is a judgement about a person, which is + * worse to leak than a preference. + */ + fun storyMutes(): Set { + val array = readJson(STORY_MUTES)?.optJSONArray("ids") ?: return emptySet() + return (0 until array.length()).map { array.optLong(it) }.toSet() + } + + fun saveStoryMutes(ids: Set) { + val array = org.json.JSONArray() + ids.sorted().forEach { array.put(it) } + writeJson(STORY_MUTES, JSONObject().put("ids", array)) + } + // -- Key-log issuer pin (audit M1) --------------------------------------- /** diff --git a/engine/src/test/kotlin/io/flamenet/e2e/DraftsThatSurviveTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/DraftsThatSurviveTest.kt new file mode 100644 index 0000000..830efb6 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/DraftsThatSurviveTest.kt @@ -0,0 +1,202 @@ +package io.flamenet.e2e + +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Half-written messages, kept across a relaunch. + * + * ๐Ÿ”ด **The reason this is not a two-line SharedPreferences change** is where a + * draft has to live. It is plaintext the person has not decided to send โ€” very + * often the most sensitive text in the app, because it is the thing they are + * still weighing. `SharedPreferences` writes readable XML that survives into + * backups. In the vault it is sealed under the same key as the conversation, + * with the record's own name bound into the AAD, and `forgetEverything` takes + * it on sign-out. + */ +class DraftsThatSurviveTest { + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private fun key(): SecretKey = + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + // ---------------------------------------------------------------- surviving + + @Test + fun `a draft survives being stored and read back`() { + val storage = MapStorage() + val secret = key() + val thread = Vault.threadForUser(7) + + Vault(storage, secret).saveDraft(thread, "meet at 6") + assertEquals("meet at 6", Vault(storage, secret).draft(thread)) + } + + /** + * ๐Ÿ”ด **On disk it must be ciphertext.** This is the assertion the whole + * ticket is about: the same feature built on SharedPreferences would put + * the text in readable XML, and no other test here would notice. + */ + @Test + fun `a draft is not readable on disk`() { + val storage = MapStorage() + Vault(storage, key()).saveDraft(Vault.threadForUser(7), "the thing I have not sent") + + val onDisk = storage.records.values.joinToString("") { bytes -> + bytes.joinToString("") { byte -> (byte.toInt() and 0xFF).toChar().toString() } + } + assertFalse(onDisk.contains("the thing I have not sent"), + "the draft was written in the clear") + } + + /** โš ๏ธ Multi-line text and awkward characters survive the round trip. */ + @Test + fun `awkward text survives`() { + val storage = MapStorage() + val secret = key() + val awkward = "line one\n\"quoted\"\ttabbed \\ backslash ๐Ÿ™‚" + + Vault(storage, secret).saveDraft(Vault.threadForUser(1), awkward) + assertEquals(awkward, Vault(storage, secret).draft(Vault.threadForUser(1))) + } + + // ---------------------------------------------------------------- forgetting + + /** + * ๐Ÿ”ด Clearing the composer has to clear what was stored. A draft that + * outlives the message it became comes back as a draft of something already + * sent, which is worse than not having drafts at all. + */ + @Test + fun `clearing the composer forgets the draft`() { + val storage = MapStorage() + val secret = key() + val thread = Vault.threadForUser(7) + + Vault(storage, secret).saveDraft(thread, "meet at 6") + Vault(storage, secret).saveDraft(thread, "") + assertNull(Vault(storage, secret).draft(thread)) + } + + /** + * โš ๏ธ Blank is not a draft. Storing " " would restore a composer that + * looks empty and reports itself as having content, which on this screen + * means an enabled send button that sends nothing. + */ + @Test + fun `whitespace is not a draft`() { + val storage = MapStorage() + val secret = key() + val thread = Vault.threadForUser(7) + + for (blank in listOf("", " ", " ", "\n", "\t\n ")) { + Vault(storage, secret).saveDraft(thread, "something") + Vault(storage, secret).saveDraft(thread, blank) + assertNull(Vault(storage, secret).draft(thread), + "kept \"$blank\", which would enable send on an empty composer") + } + } + + /** + * โš ๏ธ But stored as typed, not trimmed. Somebody who left a trailing space + * was mid-word, and handing back a subtly different string than the one + * they left is the kind of small wrongness that makes an app feel + * untrustworthy. + */ + @Test + fun `text is kept exactly as it was typed`() { + val storage = MapStorage() + val secret = key() + Vault(storage, secret).saveDraft(Vault.threadForUser(1), "meet at ") + assertEquals("meet at ", Vault(storage, secret).draft(Vault.threadForUser(1))) + } + + // ---------------------------------------------------------------- which thread + + /** + * ๐Ÿ”ด Three kinds of conversation share one record, so the ids must not + * collide. A group called "7", the person who is user 7, and an address + * "7" are three different threads โ€” and the transcript already names them + * apart for exactly this reason. + */ + @Test + fun `three kinds of conversation cannot share a draft`() { + val storage = MapStorage() + val secret = key() + + Vault(storage, secret).saveDraft(Vault.threadForUser(7), "to the contact") + Vault(storage, secret).saveDraft(Vault.threadForGroup("7"), "to the group") + Vault(storage, secret).saveDraft(Vault.threadForAddress("7"), "to the address") + + val vault = Vault(storage, secret) + assertEquals("to the contact", vault.draft(Vault.threadForUser(7))) + assertEquals("to the group", vault.draft(Vault.threadForGroup("7"))) + assertEquals("to the address", vault.draft(Vault.threadForAddress("7"))) + } + + @Test + fun `a conversation with no draft has none`() { + assertNull(Vault(MapStorage(), key()).draft(Vault.threadForUser(99))) + } + + /** Writing one draft must not disturb another. */ + @Test + fun `drafts do not overwrite each other`() { + val storage = MapStorage() + val secret = key() + + Vault(storage, secret).saveDraft(Vault.threadForUser(1), "first") + Vault(storage, secret).saveDraft(Vault.threadForUser(2), "second") + Vault(storage, secret).saveDraft(Vault.threadForUser(1), "") + + assertNull(Vault(storage, secret).draft(Vault.threadForUser(1))) + assertEquals("second", Vault(storage, secret).draft(Vault.threadForUser(2))) + } + + // ---------------------------------------------------------------- sign-out + + /** + * ๐Ÿ”ด Signing out must take drafts with it. Leaving the one piece of + * plaintext the person never chose to send behind, after the credential + * that justified it is gone, is exactly the residue the vault exists to + * avoid. + */ + @Test + fun `signing out forgets drafts`() { + val storage = MapStorage() + val secret = key() + + Vault(storage, secret).saveDraft(Vault.threadForUser(7), "meet at 6") + Vault(storage, secret).forgetEverything() + + assertTrue(storage.records.isEmpty(), "something survived sign-out") + } + + /** + * โš ๏ธ A vault opened with a different key cannot read the draft โ€” the same + * guarantee as everything else in here, asserted because a draft is the + * newest thing in it and the easiest to get wrong. + */ + @Test + fun `another key cannot read a draft`() { + val storage = MapStorage() + Vault(storage, key()).saveDraft(Vault.threadForUser(7), "meet at 6") + + val other = Vault(storage, key()) + val read = runCatching { other.draft(Vault.threadForUser(7)) } + assertNotEquals("meet at 6", read.getOrNull()) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt new file mode 100644 index 0000000..2013b8a --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt @@ -0,0 +1,151 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.json.JSONObject + +/** + * Replacing the wording of a message already sent (ยง6.15). + * + * ๐Ÿ”ด **Without this branch an edit renders as a duplicate message.** An unknown + * kind with a caption and no attachment falls through to the last case in + * `parse`, which returns the caption as text โ€” so an iPhone correcting a typo + * would appear on Android as a second bubble beside the original, with no + * indication that either replaced the other. That is the bug this fixes today, + * before any of the applying half exists. + * + * โš ๏ธ **This is the codec only.** `Session.Line` carries no `mid`, so nothing in + * an Android transcript can be named by an edit and the app swallows them for + * now โ€” the same place reactions have been sitting (PARITY.md: "codec done, no + * UI"). One transcript change unlocks replies, reactions and edits together. + */ +class EditEnvelopeTest { + + @Test + fun `an edit names the message it replaces and carries the new wording`() { + val wire = Content.edit("meet at 9", of = "abc") + val content = Content.parse(wire) + + assertEquals("abc", content.editOf) + assertEquals("meet at 9", content.text) + assertEquals(Content.Kind.EDIT, content.kind) + } + + /** + * ๐Ÿ”ด The bug this exists to prevent. Before the branch, an edit fell + * through to the attachment path and came back as renderable text โ€” a + * second bubble the sender never sent. + */ + @Test + fun `an edit is not a renderable message`() { + val content = Content.parse(Content.edit("meet at 9", of = "abc")) + assertNull(content.attachment, "an edit came back carrying an attachment") + assertNull(content.reaction) + assertNull(content.sticker) + assertTrue(content.editOf != null, + "an edit that is not recognisable as one will be drawn as a message") + } + + /** + * โš ๏ธ It must not be swallowed as generic control traffic either. A build + * that consumed it silently would look like it worked while dropping every + * correction โ€” which is worse than the duplicate bubble, because nobody + * would notice. + */ + @Test + fun `an edit is a known kind, not unknown control`() { + assertTrue(Content.Kind.EDIT in Content.Kind.KNOWN) + assertTrue(Content.Kind.EDIT !in Content.Kind.CONTROL, + "an edit swallowed as control would drop corrections silently") + } + + /** Group routing survives, for when a group thread can name a message. */ + @Test + fun `a group edit keeps its routing`() { + val content = Content.parse(Content.edit("meet at 9", of = "abc", groupId = "g1")) + assertEquals("g1", content.groupId) + assertEquals("abc", content.editOf) + } + + /** + * ๐Ÿ”ด `re.author` is a claim, not evidence โ€” anyone can put any user id + * there. The codec must not present it as authorship, and the encoder must + * not fill it in with something that looks like one. + */ + @Test + fun `the envelope makes no claim about who wrote the original`() { + val json = JSONObject(Content.edit("meet at 9", of = "abc")) + assertEquals(0L, json.getJSONObject("re").getLong("author") as Long, + "the encoder asserted an author, which a receiver might believe") + + // And a forged one parses identically, because nothing downstream reads it. + val forged = Content.edit("meet at 9", of = "abc") + .replace("\"author\":0", "\"author\":99") + assertEquals("abc", Content.parse(forged).editOf) + } + + /** โš ๏ธ `fnc` first, like every other envelope โ€” the receive side's marker + * check matches the serialized prefix. */ + @Test + fun `the envelope starts with the marker`() { + assertTrue(Content.edit("hi", of = "abc").startsWith("{\"fnc\":1,")) + } + + /** + * An edit with no target is not an edit. It must fall out of the branch + * rather than become one naming nothing. + */ + @Test + fun `an edit with no target is not treated as an edit`() { + val malformed = """{"fnc":1,"kind":"edit","caption":"meet at 9"}""" + assertNull(Content.parse(malformed).editOf) + } + + /** + * โš ๏ธ Both clients must agree byte-for-byte on the shape, since one encodes + * what the other parses. This is the shape iOS emits. + */ + @Test + fun `the wire shape matches what iOS sends`() { + val json = JSONObject(Content.edit("meet at 9", of = "abc")) + assertEquals(1, json.getInt("fnc")) + assertEquals("edit", json.getString("kind")) + assertEquals("abc", json.getJSONObject("re").getString("mid")) + assertEquals("meet at 9", json.getString("caption")) + } +} + +/** + * ๐Ÿ”ด The single decision that keeps an edit out of the transcript. + * + * `Session` filters inbound envelopes on `isRenderable`, so this property is + * the whole of what stops a correction being filed as a new message. It is + * tested apart from the codec because it is the part that would still be wrong + * if the parse branch were right. + */ +class EditIsNotRenderableTest { + + @Test + fun `an edit is not filed as a message`() { + val content = Content.parse(Content.edit("meet at 9", of = "abc")) + assertTrue(!content.isRenderable, + "a correction would be filed as a second bubble beside the original") + } + + /** โš ๏ธ And ordinary text still is, or this fix has broken conversation. */ + @Test + fun `ordinary text is still a message`() { + val content = Content.parse(Content.text("meet at 6")) + assertTrue(content.isRenderable) + } + + /** A reaction and a call signal stay excluded for their own reasons. */ + @Test + fun `the other non-messages are still excluded`() { + val reaction = Content.parse( + Content.reaction("๐Ÿ‘", about = Content.Reference("abc", 7L, ""))) + assertTrue(!reaction.isRenderable) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/FileEnvelopeInteropTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/FileEnvelopeInteropTest.kt new file mode 100644 index 0000000..d7ad98a --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/FileEnvelopeInteropTest.kt @@ -0,0 +1,164 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.assertFalse + +/** + * Documents sent between the two apps. + * + * ๐Ÿ”ด **The envelope in [IOS_FILE] was produced by the iOS build**, not by this + * engine. Two implementations of one format that each only test against + * themselves agree right up to the day somebody sends a real file โ€” and the + * symptom is a message that arrives as "[unsupported attachment]" with no clue + * why. This is the test that catches a drift in the field names before then. + */ +class FileEnvelopeInteropTest { + + // Standard base64 with padding, which is what Bytes.unb64 decodes. + private val key = "ERERERERERERERERERERERERERERERERERERERERERE=" + private val digest = "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=" + + /** As iOS assembles it: `fn`, `cls`, `cmp` as additive top-level keys. */ + private val iosFile = """ + {"fnc":1,"kind":"file","caption":"๐Ÿ“Ž Lease agreement.pdf", + "att":{"id":"${"a".repeat(64)}","key":"$key","digest":"$digest", + "bytes":4096,"mime":"application/pdf","w":0,"h":0}, + "fn":"Lease agreement.pdf","cls":2,"cmp":[0,1]} + """.trimIndent().replace("\n", "") + + @Test + fun `a file envelope from iOS parses with its name and label`() { + val content = Content.parse(iosFile) + val ref = assertNotNull(content.attachment, "no attachment parsed") + assertEquals(Attachment.FILE, ref.kind) + assertEquals("Lease agreement.pdf", ref.filename) + assertEquals(2, ref.classification) + assertEquals(listOf(0, 1), ref.compartments) + assertEquals(4096, ref.bytes) + assertEquals("application/pdf", ref.mime) + } + + /** ๐Ÿ”ด It must be recognised, not merely tolerated. */ + @Test + fun `a file is a known kind rather than needing a newer client`() { + val content = Content.parse(iosFile) + assertFalse(content.needsNewerClient, "file is still treated as an unknown kind") + assertFalse(content.isControl, "a document is not control traffic") + } + + @Test + fun `this engine assembles the same shape it parses`() { + val ref = Attachment.Ref( + kind = Attachment.FILE, + id = "b".repeat(64), + key = ByteArray(32) { 0x11 }, + digest = ByteArray(32) { 0x22 }, + bytes = 2048, + mime = "application/pdf", + filename = "Settlement draft.docx", + classification = 3, + compartments = listOf(2), + ) + val json = Content.attachment(ref) + val parsed = JSONObject(json) + assertEquals("file", parsed.getString("kind")) + assertEquals("Settlement draft.docx", parsed.getString("fn")) + assertEquals(3, parsed.getInt("cls")) + assertEquals(1, parsed.getJSONArray("cmp").length()) + + val back = assertNotNull(Content.parse(json).attachment) + assertEquals("Settlement draft.docx", back.filename) + assertEquals(3, back.classification) + assertEquals(listOf(2), back.compartments) + } + + /** + * ๐Ÿ”ด The compatibility rule, on this side too: never captionless, because + * a build that predates this kind shows only the caption. + */ + @Test + fun `a file envelope always carries a caption naming the document`() { + val ref = Attachment.Ref( + kind = Attachment.FILE, id = "c".repeat(64), + key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 }, + bytes = 10, mime = "application/pdf", filename = "notes.txt", + ) + val json = Content.attachment(ref) + assertTrue(json.contains("\"caption\""), "a file envelope went out captionless") + assertEquals("๐Ÿ“Ž notes.txt", Content.parse(json).text) + } + + @Test + fun `a caption the sender wrote is not replaced`() { + val ref = Attachment.Ref( + kind = Attachment.FILE, id = "d".repeat(64), + key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 }, + bytes = 10, mime = "application/pdf", filename = "notes.txt", + ) + assertEquals("signed copy", Content.parse(Content.attachment(ref, "signed copy")).text) + } + + /** โš ๏ธ An older peer sends no `fn`/`cls`/`cmp`. It must parse, not throw. */ + @Test + fun `an envelope without the new fields still parses`() { + val older = """ + {"fnc":1,"kind":"file","caption":"๐Ÿ“Ž old.pdf", + "att":{"id":"${"e".repeat(64)}","key":"$key","digest":"$digest", + "bytes":10,"mime":"application/pdf","w":0,"h":0}} + """.trimIndent().replace("\n", "") + val ref = assertNotNull(Content.parse(older).attachment) + assertEquals("", ref.filename) + assertEquals(0, ref.classification) + assertEquals(emptyList(), ref.compartments) + } + + /** + * ๐Ÿ”ด The additive property that makes any of this safe: a photo envelope + * from this engine still carries none of the file keys, so a peer that has + * never heard of them reads it exactly as before. + */ + @Test + fun `a photo envelope carries none of the file keys`() { + val photo = Attachment.Ref( + kind = Attachment.IMAGE, id = "f".repeat(64), + key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 }, + bytes = 1000, mime = "image/jpeg", width = 100, height = 200, + ) + val json = Content.attachment(photo, "hi") + assertFalse(json.contains("\"fn\""), "a photo envelope carried file fields") + assertFalse(json.contains("\"cls\"")) + assertFalse(json.contains("\"cmp\"")) + } + + /** + * โš ๏ธ A filename from a peer is display text and a name, never a path. + * Carried verbatim, and nothing here should ever join it onto a directory. + */ + @Test + fun `a hostile filename survives as plain text`() { + val hostile = """ + {"fnc":1,"kind":"file","caption":"x", + "att":{"id":"${"a".repeat(64)}","key":"$key","digest":"$digest", + "bytes":1,"mime":"application/pdf","w":0,"h":0}, + "fn":"../../../etc/passwd","cls":0,"cmp":[]} + """.trimIndent().replace("\n", "") + assertEquals("../../../etc/passwd", + assertNotNull(Content.parse(hostile).attachment).filename) + } + + @Test + fun `a file in a group keeps its routing`() { + val ref = Attachment.Ref( + kind = Attachment.FILE, id = "a".repeat(64), + key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 }, + bytes = 1, mime = "application/pdf", filename = "g.pdf", + ) + val content = Content.parse(Content.attachment(ref, groupId = "g-123")) + assertEquals("g-123", content.groupId) + assertEquals(Attachment.FILE, content.attachment?.kind) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt new file mode 100644 index 0000000..9b8218c --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt @@ -0,0 +1,96 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.json.JSONObject + +/** + * Passing a message on without passing on where it came from (ยง6.16). + * + * โš ๏ธ **The codec half.** The Android app cannot forward yet โ€” that needs the + * transcript work in #10 โ€” but it must already understand an envelope the + * iPhone app sends, so a forwarded message arrives as a message and not as + * something the reader is told nothing about. + */ +class ForwardWithoutProvenanceTest { + + @Test + fun `a forwarded message is marked as forwarded`() { + val content = Content.parse(Content.text("meet at 6", forwarded = true)) + assertTrue(content.isForwarded) + assertEquals("meet at 6", content.text) + } + + @Test + fun `an ordinary message is not marked forwarded`() { + assertFalse(Content.parse(Content.text("meet at 6")).isForwarded) + } + + /** + * ๐Ÿ”ด The whole point: the envelope says THAT it was forwarded and nothing + * about who wrote it or what it answered. + */ + @Test + fun `the envelope carries no author and no reply chain`() { + val wire = Content.text("meet at 6", forwarded = true) + assertFalse(wire.contains("author"), "the envelope carries an author: $wire") + assertFalse(wire.contains("\"re\""), "the envelope carries a reply chain: $wire") + assertNull(Content.parse(wire).about) + } + + /** + * โš ๏ธ `1`, not `true` โ€” ยง6.10's shape. `org.json`'s optBoolean does not + * coerce a number, so an engine that read only one shape would silently + * miss every forward the other two engines send. + */ + @Test + fun `both flag shapes are accepted`() { + val asNumber = Content.text("hi", forwarded = true) + assertTrue(asNumber.contains("\"fwd\":1"), "wrote the wrong shape: $asNumber") + assertTrue(Content.parse(asNumber).isForwarded) + + val asBool = asNumber.replace("\"fwd\":1", "\"fwd\":true") + assertTrue(Content.parse(asBool).isForwarded, + "an engine writing `true` would have its forwards read as ordinary messages") + } + + /** โš ๏ธ A forward is still a message: it renders, unlike control traffic. */ + @Test + fun `a forwarded message is still renderable`() { + assertTrue(Content.parse(Content.text("meet at 6", forwarded = true)).isRenderable) + } + + /** The flag rides an attachment too โ€” a forwarded photo is still a forward. */ + @Test + fun `a forwarded attachment keeps the flag`() { + val ref = Attachment.Ref( + kind = Attachment.IMAGE, id = "a".repeat(64), + key = ByteArray(32) { 1 }, digest = ByteArray(32) { 2 }, + bytes = 1024, mime = "image/jpeg", width = 100, height = 100, + duration = 0.0, expires = 0.0, + ) + val content = Content.parse( + Content.attachment(ref, caption = "look", forwarded = true)) + assertTrue(content.isForwarded) + assertEquals("look", content.text) + } + + /** โš ๏ธ `fnc` first, like every other envelope. */ + @Test + fun `the envelope starts with the marker`() { + assertTrue(Content.text("hi", forwarded = true).startsWith("{\"fnc\":1,")) + } + + /** + * A missing flag is not a forward โ€” the ordinary case, and the one that + * every message sent before today takes. + */ + @Test + fun `an envelope with no flag is not a forward`() { + val json = JSONObject("""{"fnc":1,"kind":"text","caption":"hi"}""") + assertFalse(Content.parse(json.toString()).isForwarded) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt new file mode 100644 index 0000000..1046370 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt @@ -0,0 +1,99 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.json.JSONObject + +/** + * Group messages carrying an id of their own (ยง6.9). + * + * ๐Ÿ”ด **The wire always carried one and the transcript never stored it.** + * `Content.groupText` has taken a `messageId` since ยง6.9 shipped and `parse` + * reads it back โ€” `Session.Line` was where it stopped. So nothing in a group + * could name an earlier message: no reply to resolve, no reaction to fold, no + * edit to apply. All three were "codec done" and none of them worked. + */ +class GroupMessageIdentityTest { + + @Test + fun `a group message round trips its id`() { + val content = Content.parse(Content.groupText("meet at 6", "g1", messageId = "abc")) + assertEquals("abc", content.messageId) + assertEquals("g1", content.groupId) + } + + /** + * ๐Ÿ”ด One id for the whole fan-out. A group message is ONE message: a reply + * or a reaction from any member has to name the same thing, so + * per-recipient ids would make that impossible. + */ + fun bodiesFor(memberCount: Int, messageId: String) = + (1..memberCount).map { Content.groupText("meet at 6", "g1", messageId = messageId) } + + @Test + fun `every copy of a group message carries the same id`() { + val ids = bodiesFor(3, "shared").map { Content.parse(it).messageId } + assertEquals(setOf("shared"), ids.toSet(), + "members received different ids for one message") + } + + /** + * ๐Ÿ”ด **The queued retry has to be the same message.** `sendGroup` builds + * the envelope twice โ€” once to send and once for the outbox โ€” so an id + * minted inside either would differ between them, and a member who was + * offline would eventually receive a *different* message from everybody + * else, which nothing could reconcile with a reply or a reaction. + */ + @Test + fun `the queued copy carries the same id as the sent one`() { + val messageId = "shared" + val sent = Content.groupText("meet at 6", "g1", messageId = messageId) + val queued = Content.groupText("meet at 6", "g1", messageId = messageId) + assertEquals(Content.parse(sent).messageId, Content.parse(queued).messageId) + } + + @Test + fun `a group reply round trips its quote`() { + val about = Content.Reference("earlier", 9L, "what they said") + val content = Content.parse( + Content.groupText("yes", "g1", messageId = "m", about = about)) + assertEquals("earlier", content.about?.messageId) + assertEquals("what they said", content.about?.preview) + } + + /** + * โš ๏ธ A reference survives the transcript's JSON round trip, which is the + * part that was missing โ€” the codec was always fine. + */ + @Test + fun `a reference survives being stored and read back`() { + val about = Content.Reference("earlier", 9L, "what they said") + val stored = JSONObject().put("re", about.toJson()) + val read = Content.Reference.fromJson(stored.getJSONObject("re")) + + assertEquals("earlier", read.messageId) + assertEquals(9L, read.author) + assertEquals("what they said", read.preview) + } + + /** + * โš ๏ธ A message stored before ids existed reads back with none, rather than + * with an empty string that would then look like an id and match nothing. + */ + @Test + fun `a message stored before ids existed has none`() { + val stored = JSONObject().put("text", "hi").put("mine", false).put("at", 1L) + assertNull(stored.optString("mid").takeIf { it.isNotEmpty() }) + assertNull(stored.optJSONObject("re")) + } + + @Test + fun `a message stored with an id reads it back`() { + val stored = JSONObject().put("text", "hi").put("mine", false).put("at", 1L) + .put("mid", "abc") + assertNotNull(stored.optString("mid").takeIf { it.isNotEmpty() }) + assertEquals("abc", stored.optString("mid")) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/GroupStoryTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/GroupStoryTest.kt new file mode 100644 index 0000000..e3f078d --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/GroupStoryTest.kt @@ -0,0 +1,95 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Stories posted to a group (spec ยง6.24, ios#55). + * + * ๐Ÿ”ด **The group's roster is the audience, and the sender's story audience does + * not apply.** Two different lists making two different statements โ€” who may + * see this account's stories, and who is in a conversation. They will disagree, + * and the roster wins. + */ +class GroupStoryTest { + + @Test + fun `every member but yourself is a recipient`() { + val reach = Vault.groupStoryReach( + members = listOf(1, 2, 3), me = 1, + allowedByAudience = setOf(2, 3), contacts = listOf(2, 3)) + assertEquals(listOf(2L, 3L), reach.recipients) + } + + /** + * ๐Ÿ”ด The hazard the disclosure exists for: somebody excluded from this + * account's stories still receives a story posted to a group they are in. + */ + @Test + fun `a member excluded from your stories still receives a group story`() { + val reach = Vault.groupStoryReach( + members = listOf(1, 2, 3), me = 1, + allowedByAudience = setOf(2), contacts = listOf(2, 3)) + assertEquals(listOf(2L, 3L), reach.recipients) + assertEquals(listOf(3L), reach.excludedByAudience) + } + + /** The other half: a member who is not a contact at all. */ + @Test + fun `a member who is not a contact is named separately`() { + val reach = Vault.groupStoryReach( + members = listOf(1, 2, 9), me = 1, + allowedByAudience = setOf(2), contacts = listOf(2)) + assertEquals(listOf(9L), reach.notContacts) + assertTrue(reach.excludedByAudience.isEmpty()) + } + + /** + * โš ๏ธ The categories must not double-count. Somebody who is not a contact + * is not "excluded from your stories" โ€” they were never on that list. + */ + @Test + fun `a non-contact is not also counted as excluded`() { + val reach = Vault.groupStoryReach( + members = listOf(1, 9), me = 1, + allowedByAudience = emptySet(), contacts = emptyList()) + assertEquals(listOf(9L), reach.notContacts) + assertTrue(reach.excludedByAudience.isEmpty()) + } + + @Test + fun `nothing is disclosed when the lists agree`() { + val reach = Vault.groupStoryReach( + members = listOf(1, 2), me = 1, + allowedByAudience = setOf(2), contacts = listOf(2)) + assertTrue(reach.excludedByAudience.isEmpty()) + assertTrue(reach.notContacts.isEmpty()) + } + + // -- Storage -------------------------------------------------------------- + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + /** โš ๏ธ Without persisting `gid`, a reload forgets which story went where. */ + @Test + fun `the group survives the vault round trip`() { + val vault = Vault(MapStorage(), + javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + val now = System.currentTimeMillis() / 1000.0 + vault.saveStories(listOf(Vault.Story( + id = "s1", authorId = 7, mine = false, + ref = Attachment.Ref( + kind = Attachment.STORY, id = "b1", key = ByteArray(32), + digest = ByteArray(32), bytes = 1, mime = "image/jpeg", + width = 1, height = 1, duration = 0.0, expires = now + 600), + caption = "", postedAt = 1, gid = "g1")), now) + assertEquals("g1", vault.loadStories().single().gid) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/MessageSearchTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/MessageSearchTest.kt new file mode 100644 index 0000000..5d4e979 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/MessageSearchTest.kt @@ -0,0 +1,181 @@ +package io.flamenet.e2e + +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.json.JSONObject + +/** + * Finding a word somebody said, across every conversation on this device. + * + * ๐Ÿ”ด **There is no index, and that is the design.** An index derived from + * decrypted history is plaintext by another name: it would have to be sealed as + * carefully as the vault, taken by `forgetEverything`, and kept consistent with + * the threads. This reads what already exists and throws the plaintext away, + * so nothing new comes to rest. + */ +class MessageSearchTest { + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private fun key(): SecretKey = + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + private fun vaultWith(vararg lines: Triple): Vault { + val vault = Vault(MapStorage(), key()) + for ((threadId, text, at) in lines) { + vault.append(threadId, JSONObject() + .put("text", text).put("mine", false).put("at", at)) + } + return vault + } + + private val now = 1_700_000_000_000L + + // ---------------------------------------------------------------- finding + + @Test + fun `it finds a message by its words`() { + val vault = vaultWith( + Triple(Vault.threadForUser(7), "meet at the cafe at six", now - 3000), + Triple(Vault.threadForUser(7), "bring the keys", now - 2000), + ) + val hits = MessageSearch.hits("keys", vault) + assertEquals(1, hits.size) + assertEquals("bring the keys", hits.first().text) + } + + @Test + fun `matching is case insensitive`() { + val vault = vaultWith( + Triple(Vault.threadForUser(7), "CAFE is closed", now), + Triple(Vault.threadForUser(7), "the cafe again", now - 1000), + ) + assertEquals(2, MessageSearch.hits("cafe", vault).size) + } + + /** + * โš ๏ธ Somebody typing `cafe` means `cafรฉ`. A search that knows the + * difference quietly fails for every language with accents in it โ€” which is + * most of them, and this app ships in Spanish next. + */ + @Test + fun `matching ignores accents`() { + val vault = vaultWith(Triple(Vault.threadForUser(7), "meet at the cafรฉ", now)) + assertEquals(1, MessageSearch.hits("cafe", vault).size) + assertTrue(MessageSearch.matches("meet at the cafe", "cafรฉ")) + } + + /** + * โš ๏ธ A plain substring, never a regular expression. A stray bracket typed + * into a search box is a crash or a hang, and nobody meant a pattern. + */ + @Test + fun `the query is not a pattern`() { + val vault = vaultWith( + Triple(Vault.threadForUser(7), "the answer is (42)", now), + Triple(Vault.threadForUser(7), "unrelated", now - 1000), + ) + assertEquals(1, MessageSearch.hits("(42)", vault).size) + // `.*` matches everything as a regex and nothing as a substring. + assertTrue(MessageSearch.hits(".*", vault).isEmpty()) + } + + /** It reaches across every conversation, not just the open one. */ + @Test + fun `it searches every thread`() { + val vault = vaultWith( + Triple(Vault.threadForUser(7), "hello there", now - 3000), + Triple(Vault.threadForGroup("g1"), "hello all", now - 2000), + Triple(Vault.threadForAddress("ada@other.example"), "hello afar", now - 1000), + ) + assertEquals(3, MessageSearch.hits("hello", vault).size) + assertEquals( + setOf(Vault.threadForUser(7), Vault.threadForGroup("g1"), + Vault.threadForAddress("ada@other.example")), + MessageSearch.hits("hello", vault).map { it.threadId }.toSet()) + } + + // ---------------------------------------------------------------- what it must not do + + /** + * ๐Ÿ”ด A short query finds NOTHING, never everything. Returning the whole + * history the moment somebody focuses the search box would put every + * message they have on one screen โ€” and here it would decrypt every thread + * on the device to do it. + */ + @Test + fun `a query below the minimum finds nothing`() { + val vault = vaultWith(Triple(Vault.threadForUser(7), "anything at all", now)) + for (tooShort in listOf("", " ", "a", " x ")) { + assertTrue(MessageSearch.hits(tooShort, vault).isEmpty(), + "\"$tooShort\" searched anyway") + } + } + + /** An empty message must not match everything. */ + @Test + fun `an empty message does not match everything`() { + assertFalse(MessageSearch.matches("", "anything")) + assertFalse(MessageSearch.matches("anything", "")) + } + + /** โš ๏ธ A bound, not a preference. */ + @Test + fun `results are bounded and keep the newest`() { + val vault = Vault(MapStorage(), key()) + for (i in 1..200) { + vault.append(Vault.threadForUser(7), JSONObject() + .put("text", "needle $i").put("mine", false).put("at", now - i * 1000L)) + } + val hits = MessageSearch.hits("needle", vault, limit = 3) + assertEquals(3, hits.size) + assertEquals(listOf("needle 1", "needle 2", "needle 3"), hits.map { it.text }) + } + + // ---------------------------------------------------------------- the snippet + + /** + * โš ๏ธ Bounded on both sides. A whole message means one long one pushes every + * other result off the screen; only the start means a match near the end + * looks like no match at all. + */ + @Test + fun `the snippet is centred on the match`() { + val long = "x".repeat(300) + "needle" + "y".repeat(300) + val snippet = MessageSearch.snippet(long, "needle") + + assertTrue(snippet.contains("needle"), "the snippet does not show the match") + assertTrue(snippet.length < 120, "the snippet is the whole message") + assertTrue(snippet.startsWith("โ€ฆ"), "no sign that text was cut from the front") + assertTrue(snippet.endsWith("โ€ฆ"), "no sign that text was cut from the end") + } + + @Test + fun `a short message is shown whole`() { + assertEquals("bring the keys", MessageSearch.snippet("bring the keys", "keys")) + } + + /** + * ๐Ÿ”ด **Folding has to be length-preserving**, because the snippet slices the + * ORIGINAL string at an index found in the folded one. A fold that dropped + * or added characters would cut in the wrong place โ€” and for an accented + * character, mid-character. + */ + @Test + fun `the snippet is correct when the text has accents before the match`() { + val text = "cafรฉ cafรฉ cafรฉ needle here" + val snippet = MessageSearch.snippet(text, "needle") + assertTrue(snippet.contains("needle"), + "accents shifted the slice off the match: $snippet") + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt new file mode 100644 index 0000000..29a67b9 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt @@ -0,0 +1,199 @@ +package io.flamenet.e2e + +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.json.JSONObject + +/** + * A timer on one message rather than the whole conversation (ยง6.17). + * + * ๐Ÿ”ด **The rule that keeps this from making the product worse is that the + * shorter timer always wins.** A per-message timer may only take life away, + * never add it. Without that, a sender could defeat the recipient's + * conversation timer by putting a long one on every message โ€” turning a setting + * the recipient chose, on their own device, into something the other party gets + * to overrule. + */ +class PerMessageTimerTest { + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private fun key(): SecretKey = + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + // ---------------------------------------------------------------- whose timer wins + + @Test + fun `a message timer applies when the conversation has none`() { + assertEquals(60, Vault.effectiveLifetime(message = 60, conversation = 0)) + } + + @Test + fun `the conversation timer applies when the message has none`() { + assertEquals(3600, Vault.effectiveLifetime(message = 0, conversation = 3600)) + } + + /** + * ๐Ÿ”ด The one that matters. A message asking to live a week, in a + * conversation set to an hour, lives an hour. + */ + @Test + fun `a longer message timer cannot extend a message's life`() { + assertEquals(3600, Vault.effectiveLifetime(message = 604_800, conversation = 3600), + "a sender overruled the recipient's own conversation timer") + } + + @Test + fun `a shorter message timer shortens it`() { + assertEquals(60, Vault.effectiveLifetime(message = 60, conversation = 3600)) + } + + @Test + fun `no timer anywhere means it does not expire`() { + assertNull(Vault.effectiveLifetime(message = 0, conversation = 0)) + } + + /** + * โš ๏ธ Zero means "no timer", not "expire immediately". Reading it the other + * way would delete a message the instant it arrived. + */ + @Test + fun `a negative timer is ignored rather than expiring immediately`() { + assertNull(Vault.effectiveLifetime(message = -1, conversation = 0)) + assertEquals(3600, Vault.effectiveLifetime(message = -1, conversation = 3600)) + } + + // ---------------------------------------------------------------- pruning + + private fun store(vault: Vault, threadId: String, at: Long, secs: Int, text: String) { + vault.append(threadId, JSONObject() + .put("text", text).put("mine", false).put("at", at) + .also { if (secs > 0) it.put("secs", secs) }) + } + + /** + * ๐Ÿ”ด **The bug the old code had.** Pruning returned early when the + * CONVERSATION had no timer, so a timed message in an untimed thread was + * kept for ever โ€” the feature would have looked like it worked on the + * sending phone and done nothing on the receiving one. + */ + @Test + fun `a timed message expires even when the conversation has no timer`() { + val vault = Vault(MapStorage(), key()) + val thread = Vault.threadForUser(7) + val now = 1_000_000_000L + store(vault, thread, at = now - 120_000, secs = 60, text = "should be gone") + store(vault, thread, at = now, secs = 0, text = "should stay") + + val kept = vault.pruneExpired(thread, now) + assertEquals(1, kept.length()) + assertEquals("should stay", kept.getJSONObject(0).getString("text")) + } + + /** And the conversation timer still works on messages that carry none. */ + @Test + fun `the conversation timer still expires untimed messages`() { + val vault = Vault(MapStorage(), key()) + val thread = Vault.threadForUser(7) + val now = 1_000_000_000L + vault.saveDisappearSeconds(thread, 60) + store(vault, thread, at = now - 120_000, secs = 0, text = "old") + store(vault, thread, at = now, secs = 0, text = "new") + + val kept = vault.pruneExpired(thread, now) + assertEquals(1, kept.length()) + assertEquals("new", kept.getJSONObject(0).getString("text")) + } + + /** ๐Ÿ”ด A long message timer does not save a message from a short thread one. */ + @Test + fun `a long message timer does not survive a short conversation timer`() { + val vault = Vault(MapStorage(), key()) + val thread = Vault.threadForUser(7) + val now = 1_000_000_000L + vault.saveDisappearSeconds(thread, 60) + store(vault, thread, at = now - 120_000, secs = 604_800, text = "asked to live a week") + + assertEquals(0, vault.pruneExpired(thread, now).length(), + "a sender overruled the recipient's conversation timer") + } + + /** + * โš ๏ธ A message with no timestamp predates the field. Kept rather than + * destroyed: an unknown age is not evidence of an old one, and deleting + * somebody's history on a guess is unrecoverable. + */ + @Test + fun `a message with no timestamp is kept`() { + val vault = Vault(MapStorage(), key()) + val thread = Vault.threadForUser(7) + vault.append(thread, JSONObject().put("text", "ancient").put("mine", false) + .put("secs", 60)) + + assertEquals(1, vault.pruneExpired(thread, 1_000_000_000L).length()) + } + + // ---------------------------------------------------------------- the wire + + /** + * ๐Ÿ”ด **A duration, never a deadline.** An absolute expiry would be the + * sender's clock: they could backdate a message so it vanished on arrival, + * or forward-date one so it never expired. + */ + @Test + fun `the envelope carries a duration not a deadline`() { + val wire = Content.text("meet at 6", lifetimeSeconds = 60) + assertTrue(wire.contains("\"secs\":60"), "wrong shape: $wire") + assertEquals(60, Content.parse(wire).lifetimeSeconds) + assertFalse(wire.contains("exp"), "the envelope carries a deadline: $wire") + } + + /** + * ๐Ÿ”ด **`Content.text` returns raw bytes when it has nothing to carry**, so a + * timer that was not counted in that decision would be dropped and the + * message would live for ever. + */ + @Test + fun `a timed message always gets an envelope`() { + val wire = Content.text("meet at 6", lifetimeSeconds = 60) + assertTrue(wire.startsWith("{\"fnc\":1,"), "the timer was dropped: $wire") + } + + @Test + fun `an untimed plain message still goes out as raw bytes`() { + assertEquals("meet at 6", Content.text("meet at 6")) + } + + /** + * โš ๏ธ A conversation-timer announcement carries `secs` too and means + * something entirely different. Reading both off one field is why this + * checks the kind. + */ + @Test + fun `a conversation timer announcement is not a timed message`() { + val announcement = Content.parse(Content.disappearing(3600)) + assertNull(announcement.lifetimeSeconds, + "a conversation timer was read as one message's own") + assertEquals(3600, Content.disappearSeconds(announcement)) + + val timed = Content.parse(Content.text("meet at 6", lifetimeSeconds = 60)) + assertNull(Content.disappearSeconds(timed), + "a timed message was read as setting the whole conversation") + } + + @Test + fun `a timer of zero is not written`() { + assertFalse(Content.text("hi", messageId = "m", lifetimeSeconds = 0).contains("secs")) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt new file mode 100644 index 0000000..bcef44f --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt @@ -0,0 +1,86 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pinning a message in a conversation (ยง6.19). + * + * โš ๏ธ **The codec half.** The Android app does not pin yet, but it must already + * understand an envelope the iPhone app sends โ€” and in particular must not draw + * one as a message, which is what an unrecognised pin becomes. + * + * ๐Ÿ”ด A pin is an id and never a copy of the message. A pin carrying the text + * would keep a message readable after its own timer had removed it, so pinning + * would quietly defeat disappearing messages. + */ +class PinTest { + + @Test + fun `a pin names the message it pins`() { + val content = Content.parse(Content.pin("abc")) + assertEquals("abc", content.pins) + assertEquals(Content.Kind.PIN, content.kind) + } + + /** An empty id takes the pin down. */ + @Test + fun `an unpin round trips`() { + assertEquals("", Content.parse(Content.pin("")).pins) + } + + /** + * ๐Ÿ”ด The bug this prevents. An unrecognised pin falls through `parse` to + * the attachment path and comes back renderable โ€” an empty bubble in + * somebody's conversation every time the other side pins something. + */ + @Test + fun `a pin is not a message`() { + val content = Content.parse(Content.pin("abc")) + assertFalse(content.isRenderable, "a pin would be drawn as a bubble") + assertTrue(content.text.isEmpty()) + assertNull(content.attachment) + } + + /** + * โš ๏ธ Not swallowed as generic control either. A build that consumed it + * silently would drop every pin while looking like it worked. + */ + @Test + fun `a pin is a known kind, not unknown control`() { + assertTrue(Content.Kind.PIN in Content.Kind.KNOWN) + assertTrue(Content.Kind.PIN !in Content.Kind.CONTROL, + "a pin swallowed as control would drop silently") + } + + /** ๐Ÿ”ด The envelope carries no copy of the message โ€” only its id. */ + @Test + fun `the envelope carries no copy of the message`() { + val wire = Content.pin("abc") + assertFalse(wire.contains("caption"), "a pin carried message text: $wire") + assertEquals("", Content.parse(wire).about?.preview) + } + + /** Group routing survives, for when a group transcript can name a message. */ + @Test + fun `a group pin keeps its routing`() { + val content = Content.parse(Content.pin("abc", groupId = "g1")) + assertEquals("g1", content.groupId) + assertEquals("abc", content.pins) + } + + /** โš ๏ธ `fnc` first, like every other envelope. */ + @Test + fun `the envelope starts with the marker`() { + assertTrue(Content.pin("abc").startsWith("{\"fnc\":1,")) + } + + /** A pin with no target is not a pin. */ + @Test + fun `a pin with no reference is not treated as one`() { + assertNull(Content.parse("""{"fnc":1,"kind":"pin"}""").pins) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt new file mode 100644 index 0000000..bd0b14f --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt @@ -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) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt new file mode 100644 index 0000000..238d58c --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt @@ -0,0 +1,69 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Counting the sealed-to-unsealed fallback (ยง14.5, ios#16). */ +class SealingRecordTest { + + @Test + fun `a sealed send is counted`() { + val r = SealingRecord().recording(wasSealed = true, at = 5) + assertEquals(1, r.sealed) + assertEquals(0, r.unsealed) + } + + @Test + fun `an unsealed send is counted and stamped`() { + val r = SealingRecord().recording(wasSealed = false, at = 1_700_000_000) + assertEquals(1, r.unsealed) + assertEquals(1_700_000_000, r.lastUnsealedAt) + } + + /** โš ๏ธ One good send afterwards does not unhappen the last bad one. */ + @Test + fun `a sealed send does not clear the last unsealed stamp`() { + val r = SealingRecord() + .recording(wasSealed = false, at = 100) + .recording(wasSealed = true, at = 200) + assertEquals(100, r.lastUnsealedAt) + } + + /** ๐Ÿ”ด Zero sends is no data, not zero leakage. */ + @Test + fun `no sends has no share rather than zero`() { + assertNull(SealingRecord().unsealedShare) + assertTrue(SealingRecord().summary.contains("Nothing sent")) + } + + @Test + fun `the share is what went unsealed`() { + var r = SealingRecord() + repeat(3) { r = r.recording(wasSealed = true, at = 1) } + r = r.recording(wasSealed = false, at = 1) + assertEquals(0.25, r.unsealedShare!!, 0.0001) + } + + /** + * ๐Ÿ”ด **Must not say the message was readable.** An unsealed send is still + * end-to-end encrypted; the relay learns the sender, not the content. + */ + @Test + fun `the summary distinguishes sender from content`() { + val summary = SealingRecord().recording(wasSealed = false, at = 1).summary + assertTrue(summary.contains("end-to-end encrypted"), summary) + assertTrue(summary.contains("who sent them"), summary) + assertTrue(summary.contains("not what they said"), summary) + } + + @Test + fun `a clean record says so without overclaiming`() { + var r = SealingRecord() + repeat(5) { r = r.recording(wasSealed = true, at = 1) } + assertTrue(r.summary.contains("were sealed"), r.summary) + assertFalse(r.summary.contains("%"), r.summary) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt new file mode 100644 index 0000000..349fb5d --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt @@ -0,0 +1,171 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Stories and mentions, against envelopes the **iOS** build produces. + * + * ๐Ÿ”ด Every string below is the shape iOS assembles, not this engine's own + * output. Two implementations that each only test against themselves agree + * right up to the day somebody sends a real message โ€” and the symptom is a + * mention that never rings, or a story that arrives as "[unsupported + * attachment]", with nothing in either log to say why. + */ +class StoryAndMentionInteropTest { + + // MARK: text stories + + private val iosTextStory = + """{"fnc":1,"kind":"story","mid":"story-1","caption":"only words","exp":1800000000.0}""" + + @Test + fun `a text story from iOS parses as a story rather than an attachment`() { + val content = Content.parse(iosTextStory) + assertEquals("only words", content.text) + assertEquals(1800000000.0, content.textStoryExpires) + assertEquals(null, content.attachment) + assertEquals("story-1", content.messageId) + } + + @Test + fun `this engine assembles a text story the same way`() { + val wire = Content.textStory("hello", 1800000000.0, "m-1") + // โš ๏ธ Asserted on the WIRE STRING, not on a JSONObject built from it. + // Round-tripping through JSONObject reorders the keys and puts `fnc` + // somewhere other than first โ€” which is exactly the hazard that makes + // both engines hand-assemble these strings. An earlier version of this + // test did that and failed, correctly. + assertTrue(wire.startsWith("{\"fnc\":1"), wire) + assertTrue(wire.contains("\"kind\":\"story\""), wire) + assertTrue(wire.contains("\"caption\":\"hello\""), wire) + assertEquals("hello", Content.parse(wire).text) + } + + /** + * โš ๏ธ Kotlin prints a Double in scientific notation, so this engine writes + * `"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and + * both sides read either โ€” but a story's expiry decides whether it shows + * at all, so the shape is pinned here rather than assumed. (This is not + * new: picture stories have carried `exp` this way all along.) + */ + @Test + fun `an expiry written in scientific notation reads back`() { + val wire = Content.textStory("x", 1800000000.0, "m") + assertTrue(wire.contains("\"exp\":1.8E9"), wire) + assertEquals(1800000000.0, Content.parse(wire).textStoryExpires) + } + + /** ๐Ÿ”ด A picture story must be completely unaffected. */ + @Test + fun `a picture story still parses as an attachment`() { + val ref = Attachment.Ref( + kind = Attachment.STORY, id = "a".repeat(64), + key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 }, + bytes = 10, mime = "image/jpeg", width = 4, height = 3, expires = 1800000000.0, + ) + val content = Content.parse(Content.attachment(ref, "look")) + assertNotNull(content.attachment) + assertEquals(null, content.textStoryExpires) + } + + // MARK: mentions + + @Test + fun `mentions from iOS arrive as ids`() { + val ios = """{"fnc":1,"kind":"text","caption":"morning @Ada","gid":"g1","men":[3,7]}""" + val content = Content.parse(ios) + assertEquals(listOf(3, 7), content.mentions) + assertEquals("g1", content.groupId) + } + + @Test + fun `this engine emits mentions the same way`() { + val json = Content.groupText("hi @Ada", "g1", mentions = listOf(7, 3)) + assertTrue(json.contains("\"men\":[3,7]"), json) + assertEquals(listOf(3, 7), Content.parse(json).mentions) + } + + /** โš ๏ธ A message that merely spells a name carries no mention. */ + @Test + fun `spelling a name is not a mention`() { + val json = Content.groupText("hello @Ada", "g1") + assertFalse(json.contains("\"men\"")) + assertTrue(Content.parse(json).mentions.isEmpty()) + } + + // MARK: story reactions + + @Test + fun `a story reaction from iOS is not mistaken for a message reaction`() { + val ios = """{"fnc":1,"kind":"react","caption":"โค๏ธ","re":{"mid":"s-1","author":42,"preview":""},"st":1}""" + val content = Content.parse(ios) + assertTrue(content.reactsToStory) + assertEquals("โค๏ธ", content.reaction) + assertEquals("s-1", content.about?.messageId) + } + + /** + * ๐Ÿ”ด The separation that matters. Both are `kind: "react"`; without the + * flag a story reaction folds onto a message with that id โ€” and if one + * ever matched, onto entirely the wrong thing. + */ + @Test + fun `an ordinary message reaction is not read as a story one`() { + val json = Content.reaction("๐Ÿ‘", Content.Reference("m-1", 5, "")) + val content = Content.parse(json) + assertFalse(content.reactsToStory) + assertFalse(json.contains("\"st\"")) + } + + @Test + fun `this engine emits a story reaction the same way`() { + val json = Content.storyReaction("๐Ÿ˜‚", "s-2", 9L) + assertTrue(json.contains("\"st\":1"), json) + val content = Content.parse(json) + assertTrue(content.reactsToStory) + assertEquals("๐Ÿ˜‚", content.reaction) + } + + // MARK: story replies + + @Test + fun `a story reply from iOS keeps what it is about`() { + val ios = """{"fnc":1,"kind":"text","mid":"m-9","caption":"lovely","re":{"mid":"s-3","author":1,"preview":"at the beach"},"st":1}""" + val content = Content.parse(ios) + assertTrue(content.repliesToStory) + assertEquals("lovely", content.text) + assertEquals("at the beach", content.about?.preview) + } + + @Test + fun `an ordinary reply is not a story reply`() { + val json = Content.text("sure", messageId = "m-1", + about = Content.Reference("other", 2, "earlier")) + assertFalse(Content.parse(json).repliesToStory) + } + + @Test + fun `this engine emits a story reply the same way`() { + val json = Content.storyReply("nice", "s-4", 3L, "sunset", "m-2") + val content = Content.parse(json) + assertTrue(content.repliesToStory) + assertEquals("sunset", content.about?.preview) + assertEquals("nice", content.text) + } + + /** ๐Ÿ”ด fnc stays first, whatever else was added. */ + @Test + fun `every envelope this engine builds starts with fnc`() { + listOf( + Content.textStory("x", 1.0, "m"), + Content.storyReaction("๐Ÿ‘", "s", 1L), + Content.storyReply("hi", "s", 1L, "p", "m"), + Content.groupText("hi", "g", mentions = listOf(1)), + ).forEach { assertTrue(it.startsWith("{\"fnc\":1"), it) } + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt new file mode 100644 index 0000000..9084c8f --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt @@ -0,0 +1,93 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A private archive of your own stories (ios#54). + * + * ๐Ÿ”ด **This keeps something the interface said would disappear.** So it is + * opt-in, and it only ever holds stories this account posted โ€” an archive of + * other people's expiring stories is the exact opposite of what they were + * promised, and "it is private to you" does not repair that. + */ +class StoryArchiveTest { + + private fun story(id: String, mine: Boolean, at: Long) = Vault.Story( + id = id, authorId = if (mine) 1L else 7L, mine = mine, + ref = Attachment.Ref( + kind = Attachment.STORY, id = "b$id", key = ByteArray(32), + digest = ByteArray(32), bytes = 1, mime = "image/jpeg", + width = 1, height = 1, duration = 0.0, expires = 1.0, + ), + caption = "c$id", postedAt = at, + ) + + @Test + fun `your own expired story is kept`() { + assertEquals(listOf("1"), + Vault.archive(emptyList(), listOf(story("1", true, 100))).map { it.id }) + } + + /** ๐Ÿ”ด The rule the whole feature turns on. */ + @Test + fun `somebody elses expired story is never kept`() { + assertTrue(Vault.archive(emptyList(), listOf(story("2", false, 100))).isEmpty()) + } + + @Test + fun `a mixed batch keeps only your own`() { + val result = Vault.archive(emptyList(), listOf( + story("1", true, 100), story("2", false, 200), story("3", true, 300))) + assertEquals(setOf("1", "3"), result.map { it.id }.toSet()) + } + + /** โš ๏ธ The sweep runs on every load; without this, one story piles up. */ + @Test + fun `archiving is idempotent`() { + val one = story("1", true, 100) + val first = Vault.archive(emptyList(), listOf(one)) + assertEquals(1, Vault.archive(first, listOf(one)).size) + } + + /** โš ๏ธ Newest first, so the screen needs no sort. */ + @Test + fun `the archive is newest first`() { + val result = Vault.archive(emptyList(), listOf( + story("1", true, 100), story("3", true, 300), story("2", true, 200))) + assertEquals(listOf("3", "2", "1"), result.map { it.id }) + } + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private fun key() = javax.crypto.KeyGenerator.getInstance("AES") + .apply { init(256) }.generateKey() + + /** โš ๏ธ Kept in the vault, like everything else about somebody's stories. */ + @Test + fun `the archive round-trips through the vault`() { + val vault = Vault(MapStorage(), key()) + assertTrue(vault.storyArchive().isEmpty()) + vault.saveStoryArchive(listOf(story("1", true, 100))) + assertEquals(listOf("1"), vault.storyArchive().map { it.id }) + } + + /** + * ๐Ÿ”ด The archive must NOT be swept by the expiry rule the live set uses โ€” + * everything in it is expired by definition, so reusing `saveStories` + * would empty it on the next write. + */ + @Test + fun `an expired story survives in the archive`() { + val vault = Vault(MapStorage(), key()) + vault.saveStoryArchive(listOf(story("1", true, 100))) + assertEquals(1, vault.storyArchive().size) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt new file mode 100644 index 0000000..993fd38 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt @@ -0,0 +1,42 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Saving your own story before it expires (ios#34). + * + * ๐Ÿ”ด **Only ever your own.** Saving somebody else's keeps a copy of something + * they were told would disappear, which is the opposite of what the feature + * promises them. The rule lives in the engine, not in the button, because a + * guard in a composable is one refactor away from being lost. + */ +class StorySaveTest { + + @Test + fun `your own story with a picture can be saved`() { + assertTrue(Vault.isSaveable(mine = true, hasBlob = true)) + } + + /** ๐Ÿ”ด The whole point: never somebody else's, whatever the UI offers. */ + @Test + fun `somebody elses story can never be saved`() { + assertFalse(Vault.isSaveable(mine = false, hasBlob = true)) + } + + /** + * โš ๏ธ A text story (ยง6.21) has no blob and nothing to write out. False + * rather than rendering the words into an image: a story is what the + * author posted, and a picture of it is a different object. + */ + @Test + fun `a text story of your own has nothing to save`() { + assertFalse(Vault.isSaveable(mine = true, hasBlob = false)) + } + + @Test + fun `somebody elses text story is also not saveable`() { + assertFalse(Vault.isSaveable(mine = false, hasBlob = false)) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt new file mode 100644 index 0000000..a173879 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt @@ -0,0 +1,129 @@ +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 + +/** Multi-item stories (spec ยง6.23, ios#53). */ +class StorySetTest { + + private fun ref(id: Int, expires: Double = 100.0) = Attachment.Ref( + kind = Attachment.STORY, id = "b$id", key = ByteArray(32), + digest = ByteArray(32), bytes = 1, mime = "image/jpeg", + width = 1, height = 1, duration = 0.0, expires = expires, + ) + + private fun item(id: Int, set: String?, i: Int = 0, n: Int = 1, expires: Double = 100.0) = + Vault.Story( + id = "s$id", authorId = 7, mine = false, ref = ref(id, expires), + caption = "", postedAt = id.toLong(), + set = set?.let { Content.StorySet(it, i, n) }, + ) + + // -- The wire ------------------------------------------------------------- + + @Test + fun `an item carries its set position and size`() { + val wire = Content.attachment(ref(1), storySet = Content.StorySet("set-1", 2, 5)) + val content = Content.parse(wire) + assertEquals("set-1", content.storySet?.id) + assertEquals(2, content.storySet?.i) + assertEquals(5, content.storySet?.n) + } + + /** + * ๐Ÿ”ด One envelope per item. An array of attachments would fail the + * single-`att` guard on any build predating ยง6.23 and render the whole set + * as a message in the conversation. + */ + @Test + fun `an item is an ordinary story envelope`() { + val json = JSONObject(Content.attachment(ref(1), storySet = Content.StorySet("s", 0, 2))) + assertEquals(Attachment.STORY, json.getString("kind")) + assertTrue(json.has("att")) + assertFalse(json.has("atts")) + } + + @Test + fun `an ordinary story has no set`() { + assertNull(Content.parse(Content.attachment(ref(1))).storySet) + } + + // -- Grouping ------------------------------------------------------------- + + @Test + fun `items of one set are grouped in position order`() { + val groups = Vault.groupedStories(listOf( + item(3, "a", i = 2, n = 3), item(1, "a", i = 0, n = 3), item(2, "a", i = 1, n = 3))) + assertEquals(1, groups.size) + assertEquals(listOf("s1", "s2", "s3"), groups[0].map { it.id }) + } + + /** โš ๏ธ `n` says three; two came. Render what arrived. */ + @Test + fun `a partly arrived set is still rendered`() { + val groups = Vault.groupedStories(listOf( + item(1, "a", i = 0, n = 3), item(3, "a", i = 2, n = 3))) + assertEquals(1, groups.size) + assertEquals(2, groups[0].size) + } + + /** โš ๏ธ Two singles must not collide just because neither has a set id. */ + @Test + fun `two sets and a single stay apart`() { + val groups = Vault.groupedStories(listOf( + item(1, "a", i = 0, n = 2), item(2, "b"), item(3, null), item(4, "a", i = 1, n = 2))) + assertEquals(3, groups.size) + assertEquals(listOf("s1", "s4"), groups[0].map { it.id }) + } + + // -- Expiry --------------------------------------------------------------- + + /** ๐Ÿ”ด The earliest wins, so a sender cannot outlive part of its own set. */ + @Test + fun `a set expires at its earliest stamp`() { + assertEquals(100.0, Vault.setExpiry(listOf( + item(1, "a", expires = 300.0), + item(2, "a", expires = 100.0), + item(3, "a", expires = 200.0)))) + } + + /** โš ๏ธ A zero means "never" and must not be read as "expires at the epoch". */ + @Test + fun `a zero stamp does not expire the whole set`() { + assertEquals(500.0, Vault.setExpiry(listOf( + item(1, "a", expires = 0.0), item(2, "a", expires = 500.0)))) + } + + @Test + fun `a set of never-expiring items never expires`() { + assertEquals(0.0, Vault.setExpiry(listOf( + item(1, "a", expires = 0.0), item(2, "a", expires = 0.0)))) + } + + // -- Storage -------------------------------------------------------------- + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + /** โš ๏ธ The set survives a reload, or a relaunch scatters every sequence. */ + @Test + fun `the set survives the vault round trip`() { + val vault = Vault(MapStorage(), + javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + val now = System.currentTimeMillis() / 1000.0 + vault.saveStories(listOf(item(1, "a", i = 1, n = 4, expires = now + 600)), now) + val back = vault.loadStories().single() + assertEquals("a", back.set?.id) + assertEquals(1, back.set?.i) + assertEquals(4, back.set?.n) + } +}