Merge branch 'fix/tests-must-not-publish-credentials' into 'main'

A test run must not publish a credential (ios#62, Android half)

See merge request paulhitt/flamenet-messenger-android!36
This commit was merged in pull request #63.
This commit is contained in:
Paul Hitt
2026-09-04 20:49:04 +00:00
53 changed files with 6088 additions and 133 deletions
+80 -7
View File
@@ -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<Int>` rather than `List<Long>`. 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
+3
View File
@@ -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")
@@ -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<MainActivity>()
/**
* 🔴 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<MainActivity>()
/**
* 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 <host>` 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)
@@ -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<MainActivity>()
/**
* 🔴 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<MainActivity>()
private val args get() = InstrumentationRegistry.getArguments()
private val relay: String get() = args.getString("relay") ?: "relay1.flamenet.io"
@@ -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<MainActivity>()
/**
* 🔴 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<MainActivity>()
/**
* 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 <host>` 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)
@@ -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<MainActivity>()
/**
* 🔴 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<MainActivity>()
/**
* 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 <host>` 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)
@@ -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<MainActivity>()
/**
* 🔴 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<MainActivity>()
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")
}
@@ -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<MainActivity>()
/**
* 🔴 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<MainActivity>()
/**
* 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 <host>` 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()
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only overlay. Points debug builds at a network security config that
permits cleartext to the loopback addresses, so a local relay is reachable
for end-to-end tests. Release builds never see this file.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:networkSecurityConfig="@xml/network_security_config"
tools:replace="android:networkSecurityConfig" />
</manifest>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
🔴 DEBUG BUILDS ONLY. This file lives in src/debug and is not part of a
release APK, so nothing shipped to anybody is loosened by it.
Android blocks cleartext HTTP by default, which is right — but it also
means a debug build cannot talk to a relay running on the developer's own
machine, and so every end-to-end test either points at PRODUCTION or skips.
Both of those are worse than a scoped exception: one creates real accounts
for every test run, the other reports a pass for a test that never ran.
Scoped to the loopback addresses only. 10.0.2.2 is how the emulator reaches
the host; 127.0.0.1 and localhost are the device itself. Nothing else is
permitted, so this cannot quietly enable cleartext to a real server.
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
@@ -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<io.flamenet.client.MessengerApi.Buddy> = emptyList(),
/** Pictures, so a received bubble can carry its author's. */
avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = 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<Long>()) }
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<io.flamenet.client.MessengerApi.Buddy>,
): String = buddies.firstOrNull { it.userId == id }
?.let { it.displayName.ifBlank { it.handle } } ?: "user $id"
@@ -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
@@ -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()
}
}
@@ -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<List<io.flamenet.e2e.MessageSearch.Hit>>(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))
}
@@ -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<android.app.Application>().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<String, Int> =
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<io.flamenet.e2e.MessageSearch.Hit> {
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<Int> = 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."
@@ -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<String, Set<Long>> = 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<Int> = 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:<id>" 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<String>()
val now = System.currentTimeMillis() / 1000.0
val incomingStories = mutableListOf<Vault.Story>()
// storyKey, who reacted, the emoji ("" takes one back).
val storyReactionsIncoming = mutableListOf<Triple<String, Long, String>>()
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<String, MutableMap<Long, String>>()
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.Story> = 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<Long>,
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<Long, String> = 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<Long>, 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<Vault.Story> {
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<Long, Long>()
/**
* 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
@@ -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") } },
)
}
@@ -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()
}
}
@@ -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()))
}
}
@@ -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<String, ByteArray>()
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)
}
}
@@ -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 })
}
}
@@ -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()
}
@@ -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<Int> = 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<String>()
val failed = LinkedHashMap<String, String>()
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<Long>,
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),
@@ -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)
}
}
@@ -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.
@@ -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.")
}
/**
@@ -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) }
}
@@ -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<RelayException.EmailUnverified> {
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<RelayException.Http> { 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<RelayException.Http> { 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("<html>a proxy wrote this</html>")
)
assertFailsWith<RelayException.Http> { 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<RelayException.Http> { relay().listDevices(1) }
}
}
@@ -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()
}
}
@@ -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()
}
@@ -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()
}
}
@@ -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("<html>a proxy wrote this</html>")
)
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)
}
}
}
@@ -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("<html>not json</html>"),
"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<RelayException.ChallengeRequired> {
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<RelayException.ChallengeFailed> {
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<RelayException.Http> {
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")
}
}
}
}
@@ -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<File> {
// 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<String> =
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<String>): 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<String>()
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 }}",
)
}
}
+413 -12
View File
@@ -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<Int> = 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<Int> = 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<Int, List<Int>>? = null,
mentions: List<Int> = 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<Int> = 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<Int> = 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,
)
}
}
@@ -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<Hit> {
// 🔴 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<Hit>()
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()
}
}
@@ -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)
}
+293 -17
View File
@@ -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<Long>,
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<Long>,
contacts: List<Long>,
): 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<Story>): 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<Story>): List<List<Story>> {
val order = mutableListOf<String>()
val sets = LinkedHashMap<String, MutableList<Story>>()
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<Story>, adding: List<Story>): List<Story> {
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<Story>, muted: Set<Long>, me: Long): List<Story> =
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<Long>,
/** Members this account excluded from its stories, who get it anyway. */
val excludedByAudience: List<Long>,
/** Members who are not contacts at all. */
val notContacts: List<Long>,
)
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<Story> {
val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList()
fun loadStories(): List<Story> = storiesFrom(readJson(STORIES))
/** Shared by the live set and the archive (ios#54). */
private fun storiesFrom(record: JSONObject?): List<Story> {
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<Story>, 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<Story>): 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<Story> = storiesFrom(readJson(STORY_ARCHIVE))
fun saveStoryArchive(stories: List<Story>) =
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<Long> {
val array = readJson(STORY_MUTES)?.optJSONArray("ids") ?: return emptySet()
return (0 until array.length()).map { array.optLong(it) }.toSet()
}
fun saveStoryMutes(ids: Set<Long>) {
val array = org.json.JSONArray()
ids.sorted().forEach { array.put(it) }
writeJson(STORY_MUTES, JSONObject().put("ids", array))
}
// -- Key-log issuer pin (audit M1) ---------------------------------------
/**
@@ -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<String, ByteArray>()
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())
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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"))
}
}
@@ -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<String, ByteArray>()
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)
}
}
@@ -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<String, ByteArray>()
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<String, String, Long>): 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")
}
}
@@ -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<String, ByteArray>()
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"))
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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) }
}
}
@@ -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<String, ByteArray>()
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)
}
}
@@ -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))
}
}
@@ -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<String, ByteArray>()
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)
}
}