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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }}",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user