API-style comments and ktlint formatting
Every comment is reduced to its first sentence plus @param, @return and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied with its default (official Kotlin) style across all modules; 262 files reformatted. The 264 remaining ktlint findings are naming and filename rules, which are code changes, and are left alone. Comments and formatting only. 1208 tests pass, counted from the XML. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
8a7f40fb13
commit
2fa430f21c
@@ -10,9 +10,9 @@ 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 androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -21,35 +21,15 @@ import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* A conversation with somebody who is not this app.
|
||||
*
|
||||
* The correspondent must be a different program. Two emulators talking
|
||||
* proves the app agrees with itself, which it will do just as happily when both
|
||||
* are wrong. `PEER_HANDLE` names an account created by the `:interop` peer
|
||||
* tool, built from `:engine` and `:client` and driven from a shell:
|
||||
*
|
||||
* ./gradlew :interop:peer -Pargs="peer1.json create https://relay1.flamenet.io peerjvmone"
|
||||
* ./gradlew :app:connectedDebugAndroidTest \
|
||||
* -Pandroid.testInstrumentationRunnerArguments.class=…ChatFlowTest
|
||||
* ./gradlew :interop:peer -Pargs="peer1.json poll"
|
||||
*
|
||||
* Skipped, not failed, when the relay is unreachable.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChatFlowTest {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
@@ -58,23 +38,21 @@ class ChatFlowTest {
|
||||
|
||||
/**
|
||||
* 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")
|
||||
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 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
|
||||
|
||||
@@ -118,20 +96,12 @@ class ChatFlowTest {
|
||||
|
||||
android.util.Log.i("fnchat", "sent from $handle to $peerHandle: $text")
|
||||
|
||||
// The return trip. Requires the peer running `autoreply`; without it
|
||||
// this is a wait that legitimately times out, so it is its own test
|
||||
// rather than a silent extra assertion on the one above.
|
||||
// The return trip.
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole round trip: the app sends, a different program answers, and the
|
||||
* answer appears in the thread.
|
||||
*
|
||||
* Needs the peer echoing in another shell, which is why it skips rather
|
||||
* than fails when no reply arrives, a missing correspondent is not a
|
||||
* broken app. Run:
|
||||
*
|
||||
* ./gradlew :interop:peer -Pargs="peer1.json autoreply 240"
|
||||
*/
|
||||
@Test
|
||||
fun aReplyFromSomebodyElseAppearsInTheThread() {
|
||||
@@ -154,12 +124,13 @@ class ChatFlowTest {
|
||||
compose.onNodeWithTag("send").performClick()
|
||||
awaitNode(hasText(text, substring = true), 60_000)
|
||||
|
||||
// The app polls every few seconds; the peer echoes every couple. This
|
||||
// is the first thing that proves the RECEIVE path works in the UI --
|
||||
// everything before it only proved sending.
|
||||
// The app polls every few seconds; the peer echoes every couple.
|
||||
val echo = "echo: $text"
|
||||
val arrived = runCatching { awaitNode(hasText(echo, substring = true), 90_000); true }
|
||||
.getOrDefault(false)
|
||||
val arrived =
|
||||
runCatching {
|
||||
awaitNode(hasText(echo, substring = true), 90_000)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
if (!arrived) {
|
||||
android.util.Log.w("fnchat", "no echo for '$text', is the peer running autoreply?")
|
||||
return
|
||||
@@ -168,7 +139,7 @@ class ChatFlowTest {
|
||||
android.util.Log.i("fnchat", "round trip complete: $echo")
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
// -- Helpers -------------------------------------------------------------.
|
||||
|
||||
private fun signUp() {
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
@@ -177,13 +148,7 @@ class ChatFlowTest {
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||
// The age gate, which nothing here filled. `submit` is
|
||||
// `enabled = … && (!registering || birthYear.length == 4)`, so without
|
||||
// this the button is DISABLED, and `performClick()` on a disabled node
|
||||
// does not throw, it does nothing. Every sign-up in this suite has been
|
||||
// silently tapping a dead button since the gate landed, and the failure
|
||||
// surfaced two minutes later as "nav-contacts did not appear" with no
|
||||
// error anywhere on screen.
|
||||
// The age gate, which nothing here filled.
|
||||
compose.onNodeWithTag("birth-year").performTextInput("1990")
|
||||
compose.onNodeWithTag("submit").performClick()
|
||||
awaitTag("device-id", 120_000)
|
||||
@@ -191,25 +156,12 @@ class ChatFlowTest {
|
||||
|
||||
/**
|
||||
* Get to a signed-out app.
|
||||
*
|
||||
* Sign-out lives on the Account screen, so a test that only looks for it
|
||||
* on whatever screen the previous test left behind silently does nothing,
|
||||
* and the next assertion fails looking for a sign-in field that was never
|
||||
* going to be there.
|
||||
*/
|
||||
private fun signOutIfSignedIn() {
|
||||
// Wait for the app to settle before deciding anything. The
|
||||
// Compose rule launches a fresh Activity per test but the process, and
|
||||
// the vault, survive, so the app spends its first moments reading the
|
||||
// vault and showing a spinner. Checking for a signed-in control right
|
||||
// away finds nothing, concludes "already signed out", and then the
|
||||
// session restores underneath: every later test fails looking for a
|
||||
// sign-in field. This race is why the suite passed one test at a time
|
||||
// and failed as a set.
|
||||
// Wait for the app to settle before deciding anything.
|
||||
awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000)
|
||||
|
||||
// Close any open thread. The chat screen replaces the bottom nav, so a
|
||||
// test inheriting an open conversation cannot reach Account at all.
|
||||
// Close any open thread.
|
||||
if (nodesWithTag("back").isNotEmpty()) {
|
||||
compose.onNodeWithTag("back").performClick()
|
||||
awaitTag("nav-contacts", 20_000)
|
||||
@@ -221,9 +173,11 @@ class ChatFlowTest {
|
||||
awaitTag("relay", 30_000)
|
||||
}
|
||||
|
||||
|
||||
/** Wait until any one of these tags exists. */
|
||||
private fun awaitAny(tags: List<String>, timeoutMillis: Long) {
|
||||
private fun awaitAny(
|
||||
tags: List<String>,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -237,19 +191,18 @@ class ChatFlowTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> = runCatching {
|
||||
private fun nodesWithTag(tag: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) =
|
||||
awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) = awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
|
||||
/**
|
||||
* Auto-advance is turned off only while waiting. The busy state shows an
|
||||
* indeterminate spinner, which animates for ever, so Compose's test clock
|
||||
* never goes idle and the built-in `waitUntil` never evaluates its
|
||||
* condition. Turning it off for the whole test is the next mistake: the app
|
||||
* opens on a spinner while it reads the vault, so nothing would compose.
|
||||
* Auto-advance is turned off only while waiting.
|
||||
*/
|
||||
private fun awaitNode(
|
||||
matcher: androidx.compose.ui.test.SemanticsMatcher,
|
||||
@@ -260,26 +213,30 @@ class ChatFlowTest {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
val found = runCatching {
|
||||
val found =
|
||||
runCatching {
|
||||
compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
if (found) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
throw AssertionError(
|
||||
"nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
throw AssertionError("nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 30)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun relayReachable(): Boolean = runCatching {
|
||||
private fun relayReachable(): Boolean =
|
||||
runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
.apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
@@ -10,43 +10,24 @@ 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 androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The Android half of a conversation with a client that is not Android.
|
||||
*
|
||||
* Driven by arguments, and deliberately NOT self-contained. Each step is
|
||||
* its own test so the two platforms can be interleaved from a shell:
|
||||
*
|
||||
* -e handle andcross123 → sign up under a name iOS can address
|
||||
* -e peer ioscross456 -e expect "hello from iOS" → wait for their message
|
||||
* -e peer ioscross456 -e reply "hello back" → answer
|
||||
*
|
||||
* Run with `adb shell am instrument`, not `connectedAndroidTest`, which
|
||||
* uninstalls the app afterwards and takes the vault, and therefore the account
|
||||
* with it. The point is that the account survives between steps.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CrossPlatformTest {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
@@ -68,13 +49,7 @@ class CrossPlatformTest {
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||
// The age gate, which nothing here filled. `submit` is
|
||||
// `enabled = … && (!registering || birthYear.length == 4)`, so without
|
||||
// this the button is DISABLED, and `performClick()` on a disabled node
|
||||
// does not throw, it does nothing. Every sign-up in this suite has been
|
||||
// silently tapping a dead button since the gate landed, and the failure
|
||||
// surfaced two minutes later as "nav-contacts did not appear" with no
|
||||
// error anywhere on screen.
|
||||
// The age gate, which nothing here filled.
|
||||
compose.onNodeWithTag("birth-year").performTextInput("1990")
|
||||
compose.onNodeWithTag("submit").performClick()
|
||||
awaitTag("nav-contacts", 150_000)
|
||||
@@ -106,14 +81,10 @@ class CrossPlatformTest {
|
||||
android.util.Log.i("fnxplat", "ANDROID_SENT=$body")
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
// -- Helpers -------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Open the thread with `peer`, adding them if they are not a contact yet.
|
||||
*
|
||||
* The peer may already be a contact from an earlier step, this whole
|
||||
* class is designed to be run repeatedly against a surviving account, so
|
||||
* "add" has to be conditional rather than assumed.
|
||||
*/
|
||||
private fun openThreadWith(peer: String) {
|
||||
if (nodesWithTag("back").isNotEmpty()) return // already in a thread
|
||||
@@ -148,12 +119,15 @@ class CrossPlatformTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> = runCatching {
|
||||
private fun nodesWithTag(tag: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) =
|
||||
awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) = awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
|
||||
private fun awaitNode(
|
||||
matcher: androidx.compose.ui.test.SemanticsMatcher,
|
||||
@@ -164,20 +138,21 @@ class CrossPlatformTest {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
val found = runCatching {
|
||||
val found =
|
||||
runCatching {
|
||||
compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
if (found) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
throw AssertionError(
|
||||
"nothing matched within ${timeoutMillis}ms. On screen: " + visibleText())
|
||||
throw AssertionError("nothing matched within ${timeoutMillis}ms. On screen: " + visibleText())
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 40)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ 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 androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -21,34 +21,15 @@ import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Every capability is reachable from the UI.
|
||||
*
|
||||
* This exists because of a real habit. Features kept landing in
|
||||
* `:client` with a live test proving them and no way for a person to use them,
|
||||
* remove a contact, block someone, clear a thread, change a password, read a
|
||||
* notification. A capability only reachable from a terminal is not shipped,
|
||||
* and a screen that can add a contact but not remove one reads as broken
|
||||
* rather than unfinished.
|
||||
*
|
||||
* So this walks the app and asserts the controls exist. It deliberately checks
|
||||
* *presence and reachability* rather than behaviour, the behaviour is covered
|
||||
* by the live tests; what was missing was the door.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CrudSurfaceTest {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
@@ -57,23 +38,21 @@ class CrudSurfaceTest {
|
||||
|
||||
/**
|
||||
* 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")
|
||||
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 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
|
||||
|
||||
@@ -92,10 +71,7 @@ class CrudSurfaceTest {
|
||||
compose.onNodeWithTag("submit").assertExists()
|
||||
compose.onNodeWithTag("toggle-register").assertExists()
|
||||
|
||||
// `assertExists`, not `assertIsDisplayed`. The sign-in column
|
||||
// scrolls, so a control below the fold is present and reachable but not
|
||||
// on screen, and this test is about whether the door exists, not about
|
||||
// where it currently sits.
|
||||
// `assertExists`, not `assertIsDisplayed`.
|
||||
compose.onNodeWithTag("toggle-reset").performClick()
|
||||
compose.onNodeWithTag("send-reset-code").assertExists()
|
||||
compose.onNodeWithTag("reset-code").assertExists()
|
||||
@@ -130,11 +106,6 @@ class CrudSurfaceTest {
|
||||
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
||||
|
||||
// Read the id the row was given, then open the contact screen.
|
||||
//
|
||||
// The actions moved OFF the row. They were three text buttons that
|
||||
// unfolded under the entry, tagged per peer id; they are now the
|
||||
// contact screen's rail and rows, which exist once and are not keyed
|
||||
// by id. The assertions follow them rather than the old names.
|
||||
val peerId = peerIdFromTags()
|
||||
compose.onNodeWithTag("contact-menu-$peerId").performClick()
|
||||
compose.onNodeWithTag("contact-message").assertExists()
|
||||
@@ -152,8 +123,8 @@ class CrudSurfaceTest {
|
||||
signUp()
|
||||
|
||||
// Stories are reachable from the contact list, and say what they are
|
||||
// when there are none, an empty strip with no explanation is a
|
||||
// feature nobody discovers.
|
||||
// when there are none, an empty strip with no explanation is a feature
|
||||
// nobody discovers.
|
||||
compose.onNodeWithTag("post-story").assertExists()
|
||||
|
||||
compose.onNodeWithTag("nav-alerts").performClick()
|
||||
@@ -169,7 +140,6 @@ class CrudSurfaceTest {
|
||||
compose.onNodeWithTag("sign-out").assertExists()
|
||||
compose.onNodeWithTag("delete-account").assertExists()
|
||||
// §19 enforcement is a user-facing choice, so it has to have a control.
|
||||
// A security setting reachable only from code is a setting nobody has.
|
||||
compose.onNodeWithTag("require-key-log").assertExists()
|
||||
// §14.4 rotation is the only lever against a sealed spammer, so it has
|
||||
// to be reachable, a lever nobody can find is not a lever.
|
||||
@@ -200,10 +170,7 @@ class CrudSurfaceTest {
|
||||
compose.onNodeWithTag("back").assertExists()
|
||||
|
||||
// Identity state, on the thread it describes rather than buried in a
|
||||
// settings screen. A freshly added contact is PINNED, trust on first
|
||||
// use, so the state line and a way to reach the number both exist.
|
||||
// Voice notes are reachable from the thread, and the control says what
|
||||
// it does rather than being a bare icon.
|
||||
// settings screen.
|
||||
compose.onNodeWithTag("record-voice").assertExists()
|
||||
// Calling is reachable from the thread it applies to.
|
||||
compose.onNodeWithTag("start-call").assertExists()
|
||||
@@ -214,12 +181,13 @@ class CrudSurfaceTest {
|
||||
compose.onNodeWithTag("toggle-verified").assertExists()
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
// -- Helpers -------------------------------------------------------------.
|
||||
|
||||
/** The contact row carries its user id in its tag; read it back from the tree. */
|
||||
private fun peerIdFromTags(): Long {
|
||||
val dump = visibleText()
|
||||
val match = Regex("contact-menu-(\\d+)").find(dump)
|
||||
val match =
|
||||
Regex("contact-menu-(\\d+)").find(dump)
|
||||
?: throw AssertionError("no contact row found. On screen: $dump")
|
||||
return match.groupValues[1].toLong()
|
||||
}
|
||||
@@ -231,13 +199,7 @@ class CrudSurfaceTest {
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||
// The age gate, which nothing here filled. `submit` is
|
||||
// `enabled = … && (!registering || birthYear.length == 4)`, so without
|
||||
// this the button is DISABLED, and `performClick()` on a disabled node
|
||||
// does not throw, it does nothing. Every sign-up in this suite has been
|
||||
// silently tapping a dead button since the gate landed, and the failure
|
||||
// surfaced two minutes later as "nav-contacts did not appear" with no
|
||||
// error anywhere on screen.
|
||||
// The age gate, which nothing here filled.
|
||||
compose.onNodeWithTag("birth-year").performTextInput("1990")
|
||||
compose.onNodeWithTag("submit").performClick()
|
||||
awaitTag("nav-contacts", 120_000)
|
||||
@@ -245,25 +207,12 @@ class CrudSurfaceTest {
|
||||
|
||||
/**
|
||||
* Get to a signed-out app.
|
||||
*
|
||||
* Sign-out lives on the Account screen, so a test that only looks for it
|
||||
* on whatever screen the previous test left behind silently does nothing,
|
||||
* and the next assertion fails looking for a sign-in field that was never
|
||||
* going to be there.
|
||||
*/
|
||||
private fun signOutIfSignedIn() {
|
||||
// Wait for the app to settle before deciding anything. The
|
||||
// Compose rule launches a fresh Activity per test but the process, and
|
||||
// the vault, survive, so the app spends its first moments reading the
|
||||
// vault and showing a spinner. Checking for a signed-in control right
|
||||
// away finds nothing, concludes "already signed out", and then the
|
||||
// session restores underneath: every later test fails looking for a
|
||||
// sign-in field. This race is why the suite passed one test at a time
|
||||
// and failed as a set.
|
||||
// Wait for the app to settle before deciding anything.
|
||||
awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000)
|
||||
|
||||
// Close any open thread. The chat screen replaces the bottom nav, so a
|
||||
// test inheriting an open conversation cannot reach Account at all.
|
||||
// Close any open thread.
|
||||
if (nodesWithTag("back").isNotEmpty()) {
|
||||
compose.onNodeWithTag("back").performClick()
|
||||
awaitTag("nav-contacts", 20_000)
|
||||
@@ -277,9 +226,11 @@ class CrudSurfaceTest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Wait until any one of these tags exists. */
|
||||
private fun awaitAny(tags: List<String>, timeoutMillis: Long) {
|
||||
private fun awaitAny(
|
||||
tags: List<String>,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -293,12 +244,15 @@ class CrudSurfaceTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> = runCatching {
|
||||
private fun nodesWithTag(tag: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) =
|
||||
awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) = awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
|
||||
/** See ChatFlowTest: the busy spinner never lets the test clock go idle. */
|
||||
private fun awaitNode(
|
||||
@@ -310,26 +264,30 @@ class CrudSurfaceTest {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
val found = runCatching {
|
||||
val found =
|
||||
runCatching {
|
||||
compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
if (found) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
throw AssertionError(
|
||||
"nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
throw AssertionError("nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 40)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun relayReachable(): Boolean = runCatching {
|
||||
private fun relayReachable(): Boolean =
|
||||
runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
.apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
@@ -20,32 +20,29 @@ import kotlin.system.measureTimeMillis
|
||||
|
||||
/**
|
||||
* The engine on a real Android runtime.
|
||||
*
|
||||
* The JVM suite proves nothing about this. Android's provider is
|
||||
* Conscrypt, not the OpenJDK one, and the two disagree about things that matter:
|
||||
* Conscrypt will not build an XDH key from raw bytes, and XDH itself only
|
||||
* exists from API 33. An engine that passes 81 tests on a laptop and throws on
|
||||
* the first tap of a phone is the exact failure this file exists to catch, it
|
||||
* has already happened once to this engine's sibling.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EngineOnDeviceTest {
|
||||
|
||||
private fun bundleFor(identity: DeviceIdentity): PeerBundle {
|
||||
val oneTime = identity.generateOneTimePreKeys(1).first()
|
||||
return PeerBundle.fromWire(identity.registrationWire().put("opk",
|
||||
JSONObject().put("id", oneTime.id)
|
||||
.put("pub", android.util.Base64.encodeToString(
|
||||
oneTime.publicKey, android.util.Base64.NO_WRAP))))
|
||||
return PeerBundle.fromWire(
|
||||
identity.registrationWire().put(
|
||||
"opk",
|
||||
JSONObject()
|
||||
.put("id", oneTime.id)
|
||||
.put(
|
||||
"pub",
|
||||
android.util.Base64.encodeToString(oneTime.publicKey, android.util.Base64.NO_WRAP),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theProviderInUseIsWorthKnowing() {
|
||||
val providers = Security.getProviders().joinToString { "${it.name} ${it.version}" }
|
||||
android.util.Log.i("fnmsg", "providers: $providers")
|
||||
// XDH comes from Conscrypt and is fine. Ed25519 is deliberately NOT
|
||||
// requested from the JCA here, on Android only AndroidKeyStore serves
|
||||
// it, and Keystore only makes keys it keeps. See Ed25519.kt.
|
||||
// XDH comes from Conscrypt and is fine.
|
||||
assertNotNull(java.security.KeyPairGenerator.getInstance("XDH"))
|
||||
}
|
||||
|
||||
@@ -54,22 +51,19 @@ class EngineOnDeviceTest {
|
||||
val a = X25519.generate()
|
||||
val b = X25519.generate()
|
||||
assertEquals(
|
||||
android.util.Base64.encodeToString(
|
||||
X25519.agree(a.private, X25519.rawFromPublicKey(b.public)), 0),
|
||||
android.util.Base64.encodeToString(
|
||||
X25519.agree(b.private, X25519.rawFromPublicKey(a.public)), 0),
|
||||
android.util.Base64.encodeToString(X25519.agree(a.private, X25519.rawFromPublicKey(b.public)), 0),
|
||||
android.util.Base64.encodeToString(X25519.agree(b.private, X25519.rawFromPublicKey(a.public)), 0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFC 8032 vector, on device. Keygen-from-seed is our own arithmetic,
|
||||
* so a platform difference in `BigInteger` or in the provider's PKCS#8
|
||||
* handling would change the account address a passphrase produces.
|
||||
* The RFC 8032 vector, on device.
|
||||
*/
|
||||
@Test
|
||||
fun ed25519MatchesTheRfcVectorOnDevice() {
|
||||
val seed = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
|
||||
val expected = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||
|
||||
fun hex(s: String) = ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||
|
||||
val pair = Ed25519.fromSeed(hex(seed))
|
||||
@@ -109,18 +103,12 @@ class EngineOnDeviceTest {
|
||||
|
||||
/**
|
||||
* How long a sign-in actually costs.
|
||||
*
|
||||
* Not a correctness test, a budget. The account service dictates
|
||||
* 650,000 PBKDF2 iterations, and Android's `SecretKeyFactory` is far slower
|
||||
* at that than iOS's CommonCrypto. A sign-in nobody is willing to wait for
|
||||
* is a broken sign-in, so the number is measured and asserted rather than
|
||||
* assumed. It is generous on purpose: this runs on an emulator in CI, which
|
||||
* is slower than any phone.
|
||||
*/
|
||||
@Test
|
||||
fun theAccountKdfFinishesInSomethingLikeAReasonableTime() {
|
||||
val salt = ByteArray(16) { it.toByte() }
|
||||
val elapsed = measureTimeMillis {
|
||||
val elapsed =
|
||||
measureTimeMillis {
|
||||
AccountKdf.authSecret("a reasonably long passphrase", salt)
|
||||
}
|
||||
android.util.Log.i("fnmsg", "AccountKdf.authSecret took ${elapsed}ms")
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
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 androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -23,46 +23,17 @@ import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Voice notes, stories, groups and calls, used, not just reachable.
|
||||
*
|
||||
* `CrudSurfaceTest` already asserts these controls EXIST, and that was
|
||||
* the right thing to build first: features kept landing with a live test and no
|
||||
* door in the UI. But `post-story`, `record-voice` and `start-call` being
|
||||
* present says nothing about what happens when somebody presses them, and until
|
||||
* now nothing pressed them. Four features shipped in an APK on that basis.
|
||||
*
|
||||
* So this presses them and asserts the state that follows: the recorder shows a
|
||||
* timer, discarding puts the composer back, a group appears in the list under
|
||||
* the name it was given, the story viewer opens, the call banner comes up and
|
||||
* hanging up takes it away.
|
||||
*
|
||||
* What this deliberately does NOT claim. An emulator has no microphone
|
||||
* worth the name and no second handset, so a voice note is exercised as far as
|
||||
* the recorder and a call as far as signalling. Whether audio arrives at another
|
||||
* person is not something one device can answer, and pretending otherwise would
|
||||
* be worse than the gap.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FeatureUseTest {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
// RECORD_AUDIO, or the recorder test cannot see the app. Tapping
|
||||
// record raises the system microphone dialog, which is not part of the
|
||||
// app's Compose tree, so the failure is "No compose hierarchies
|
||||
// found", which reads as the app having died rather than as a dialog
|
||||
// sitting on top of it.
|
||||
// RECORD_AUDIO, or the recorder test cannot see the app.
|
||||
android.Manifest.permission.RECORD_AUDIO,
|
||||
)
|
||||
|
||||
@@ -71,23 +42,21 @@ class FeatureUseTest {
|
||||
|
||||
/**
|
||||
* 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")
|
||||
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 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
|
||||
@@ -96,13 +65,10 @@ class FeatureUseTest {
|
||||
signOutIfSignedIn()
|
||||
}
|
||||
|
||||
// -- Voice notes ---------------------------------------------------------
|
||||
// -- Voice notes ---------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The recorder starts, runs, and can be thrown away.
|
||||
*
|
||||
* The discard half matters as much as the start: a recorder you cannot back
|
||||
* out of holds the microphone open and leaves the composer unusable.
|
||||
*/
|
||||
@Test
|
||||
fun aVoiceNoteRecordsAndCanBeDiscarded() {
|
||||
@@ -125,22 +91,17 @@ class FeatureUseTest {
|
||||
)
|
||||
}
|
||||
|
||||
// -- Groups --------------------------------------------------------------
|
||||
// -- Groups --------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* A group is created and appears in the list under the name it was given.
|
||||
*
|
||||
* Needs a contact: the member picker has nobody in it otherwise, and the
|
||||
* relay refuses a group whose members have no device (`requireReachable`).
|
||||
*/
|
||||
@Test
|
||||
fun aGroupIsCreatedAndListed() {
|
||||
assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable())
|
||||
signUpAndAddPeer()
|
||||
|
||||
// Groups live on the MESSAGES tab, not Home. Home is the roster and
|
||||
// the stories strip; the conversation list and the groups moved when
|
||||
// the tabs were split.
|
||||
// Groups live on the MESSAGES tab, not Home.
|
||||
compose.onNodeWithTag("nav-messages").performClick()
|
||||
awaitTag("new-group", 20_000)
|
||||
compose.onNodeWithTag("new-group").performScrollTo()
|
||||
@@ -149,41 +110,36 @@ class FeatureUseTest {
|
||||
compose.onNodeWithTag("group-name").performTextInput("Test Crew")
|
||||
|
||||
// Pick the one contact there is, by the tag the row carries.
|
||||
val memberTag = Regex("group-member-(\\d+)").find(visibleText())?.value
|
||||
val memberTag =
|
||||
Regex("group-member-(\\d+)").find(visibleText())?.value
|
||||
?: throw AssertionError(
|
||||
"nobody offered as a group member, so the contact did not stick. " +
|
||||
"On screen: ${visibleText()}")
|
||||
"On screen: ${visibleText()}",
|
||||
)
|
||||
compose.onNodeWithTag(memberTag).performClick()
|
||||
compose.onNodeWithTag("create-group").performClick()
|
||||
|
||||
// The group list carries a `group-<id>` row once the relay answers.
|
||||
awaitNodeMatching(Regex("group-\\d+"), 150_000,
|
||||
"the group never appeared in the list after Create")
|
||||
awaitNodeMatching(
|
||||
Regex("group-\\d+"),
|
||||
150_000,
|
||||
"the group never appeared in the list after Create",
|
||||
)
|
||||
}
|
||||
|
||||
// -- Stories -------------------------------------------------------------
|
||||
// -- Stories -------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The story surface opens and says what it is when there is nothing in it.
|
||||
*
|
||||
* Posting one needs an image from the system picker, which is a
|
||||
* different process and not something a Compose test drives. So this covers
|
||||
* the strip and the empty state, an unexplained empty strip is a feature
|
||||
* nobody finds, and stops short of claiming a story was published.
|
||||
*/
|
||||
@Test
|
||||
fun theStorySurfaceOpensAndExplainsItself() {
|
||||
assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable())
|
||||
signUp()
|
||||
|
||||
// The stories surface is its own tab. Asserting from Home found
|
||||
// nothing and reported it as the strip being absent.
|
||||
// The stories surface is its own tab.
|
||||
compose.onNodeWithTag("nav-stories").performClick()
|
||||
// `new-story`, not `stories`. The `stories` tag belongs to the
|
||||
// STRIP on Home; the Stories TAB is its own screen and carries the
|
||||
// compose button instead. Asserting the strip's tag from the tab found
|
||||
// nothing and reported it as the surface being absent, while the
|
||||
// screen was right there, headed "Stories", saying "Nothing here yet".
|
||||
// `new-story`, not `stories`.
|
||||
awaitTag("new-story", 20_000)
|
||||
compose.onNodeWithTag("new-story").assertExists()
|
||||
// The point of the test is the EXPLANATION, not the strip: an empty
|
||||
@@ -196,15 +152,11 @@ class FeatureUseTest {
|
||||
)
|
||||
}
|
||||
|
||||
// -- Calls ---------------------------------------------------------------
|
||||
// -- Calls ---------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Placing a call raises the privacy notice, then the in-call banner, and
|
||||
* hanging up clears it.
|
||||
*
|
||||
* Signalling only. Nobody answers on an emulator, so this proves the
|
||||
* call is placed and can be ended, not that audio flows. Media needs two
|
||||
* real devices and is honestly out of reach here.
|
||||
*/
|
||||
@Test
|
||||
fun placingACallRaisesTheBannerAndHangingUpClearsIt() {
|
||||
@@ -223,48 +175,41 @@ class FeatureUseTest {
|
||||
awaitTag("call-banner", 30_000)
|
||||
compose.onNodeWithTag("end-call").performClick()
|
||||
|
||||
val gone = (1..40).any {
|
||||
val gone =
|
||||
(1..40).any {
|
||||
Thread.sleep(250)
|
||||
!nodeExists("call-banner")
|
||||
}
|
||||
assertTrue("hanging up did not clear the call banner", gone)
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
// -- Helpers -------------------------------------------------------------.
|
||||
|
||||
private fun signUpAndAddPeer() {
|
||||
signUp()
|
||||
// The add-contact flow, as it exists. `new-contact` is the text
|
||||
// FIELD, not a button that opens one, and `lookup-contact` is not in
|
||||
// the app at all, the form is opened by `add-contact` and submitted
|
||||
// with `confirm-contact`.
|
||||
// The add-contact flow, as it exists.
|
||||
compose.onNodeWithTag("add-contact").performScrollTo()
|
||||
compose.onNodeWithTag("add-contact").performClick()
|
||||
awaitTag("new-contact", 20_000)
|
||||
compose.onNodeWithTag("new-contact").performTextInput(PEER)
|
||||
// Look up, THEN add. `confirm-contact` only exists once the
|
||||
// lookup has found somebody, the app refuses to turn a typed string
|
||||
// straight into a contact, because a typo would silently become a
|
||||
// stranger and the first thing sent to them is a message meant for
|
||||
// someone else.
|
||||
// Look up, THEN add.
|
||||
compose.onNodeWithTag("lookup-contact").performScrollTo()
|
||||
compose.onNodeWithTag("lookup-contact").performClick()
|
||||
awaitTag("confirm-contact", 30_000)
|
||||
compose.onNodeWithTag("confirm-contact").performScrollTo()
|
||||
compose.onNodeWithTag("confirm-contact").performClick()
|
||||
// `buddy-circle-N` as well as `buddy-N`. Home shows the Recent
|
||||
// circles row, whose tags are `buddy-circle-<id>`; the plain
|
||||
// `buddy-<id>` belongs to the list further in. Matching only the
|
||||
// latter reported "never became a contact" about a contact that was
|
||||
// right there on screen, the roster had the person, under the other
|
||||
// name for the same thing.
|
||||
awaitNodeMatching(Regex("buddy-(circle-)?\\d+"), 150_000,
|
||||
"'$PEER' never became a contact, does that account exist on $relay?")
|
||||
// `buddy-circle-N` as well as `buddy-N`.
|
||||
awaitNodeMatching(
|
||||
Regex("buddy-(circle-)?\\d+"),
|
||||
150_000,
|
||||
"'$PEER' never became a contact, does that account exist on $relay?",
|
||||
)
|
||||
}
|
||||
|
||||
private fun signUpAndOpenAThread() {
|
||||
signUpAndAddPeer()
|
||||
val buddyTag = Regex("buddy-(circle-)?\\d+").find(visibleText())?.value
|
||||
val buddyTag =
|
||||
Regex("buddy-(circle-)?\\d+").find(visibleText())?.value
|
||||
?: throw AssertionError("no contact row to open")
|
||||
compose.onNodeWithTag(buddyTag).performClick()
|
||||
awaitTag("draft", 20_000)
|
||||
@@ -277,61 +222,35 @@ class FeatureUseTest {
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||
// The age gate, which nothing here filled. `submit` is
|
||||
// `enabled = … && (!registering || birthYear.length == 4)`, so without
|
||||
// this the button is DISABLED, and `performClick()` on a disabled node
|
||||
// does not throw, it does nothing. Every sign-up in this suite has been
|
||||
// silently tapping a dead button since the gate landed, and the failure
|
||||
// surfaced two minutes later as "nav-contacts did not appear" with no
|
||||
// error anywhere on screen.
|
||||
// The age gate, which nothing here filled.
|
||||
compose.onNodeWithTag("birth-year").performTextInput("1990")
|
||||
// Scroll to it first. The sign-up form grew a "Year of birth"
|
||||
// field and its explanation, and `submit` went below the fold on a
|
||||
// 1080x2400 screen. An off-screen node still reports ENABLED, that is
|
||||
// a state check, not a visibility one, and the gesture reached
|
||||
// nothing. The app's own logging proved it: `signIn` was never called,
|
||||
// through 450 seconds of waiting for a screen nobody had asked for.
|
||||
// Scroll to it first.
|
||||
compose.onNodeWithTag("submit").performScrollTo()
|
||||
compose.onNodeWithTag("submit").performClick()
|
||||
val began = System.currentTimeMillis()
|
||||
// How long sign-up actually takes here, logged so the answer is a
|
||||
// number rather than a guess.
|
||||
awaitTag("nav-contacts", 420_000)
|
||||
android.util.Log.e("FNTEST", "sign-up completed in " +
|
||||
((System.currentTimeMillis() - began) / 1000) + "s")
|
||||
android.util.Log.e(
|
||||
"FNTEST",
|
||||
"sign-up completed in " +
|
||||
((System.currentTimeMillis() - began) / 1000) + "s",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get back to the signed-out screen, or establish that we are already there.
|
||||
*
|
||||
* Two things made this a no-op. `sign-out` sits at the bottom of the
|
||||
* Account screen and needs scrolling to, an off-screen node accepts a
|
||||
* click and does nothing. And the destructive rows confirm in place:
|
||||
* the first tap changes the row to say what it is about to do, and a second
|
||||
* tap performs it. One tap armed it and left the session intact, so every
|
||||
* test after the first opened signed in and could not find the sign-up
|
||||
* form, failing on `TestTag = 'relay'` as though the screen were missing.
|
||||
*
|
||||
* Not wrapped in `runCatching` any more. Swallowing a failure here made
|
||||
* the next test's failure the visible one, three screens away from the
|
||||
* cause.
|
||||
* Get back to the signed-out screen, or establish that we are already
|
||||
* there.
|
||||
*/
|
||||
private fun signOutIfSignedIn() {
|
||||
if (!awaitTagOrNull("nav-account", 8_000)) return
|
||||
compose.onNodeWithTag("nav-account").performClick()
|
||||
// `sign-out-erase`, not `sign-out`. The two doors do different
|
||||
// things on purpose: a plain sign-out KEEPS the device and the stored
|
||||
// credential, so signing back in picks up where you left off, correct
|
||||
// for a person, wrong for a suite whose next test wants a DIFFERENT
|
||||
// account. The old session was still in the vault, the app kept trying
|
||||
// to use it, and the next sign-up sat behind "Your session expired.
|
||||
// Sign out and back in to keep going."
|
||||
// `sign-out-erase`, not `sign-out`.
|
||||
if (!awaitTagOrNull("sign-out-erase", 10_000)) return
|
||||
compose.onNodeWithTag("sign-out-erase").performScrollTo()
|
||||
compose.onNodeWithTag("sign-out-erase").performClick()
|
||||
// Confirm in place: the first tap changes the row to "Really erase,
|
||||
// this cannot be undone", and the second performs it. One tap only
|
||||
// armed it.
|
||||
// this cannot be undone", and the second performs it.
|
||||
if (awaitTagOrNull("sign-out-erase", 3_000)) {
|
||||
compose.onNodeWithTag("sign-out-erase").performClick()
|
||||
}
|
||||
@@ -341,20 +260,28 @@ class FeatureUseTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun nodeExists(tag: String) = runCatching {
|
||||
private fun nodeExists(tag: String) =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** See ChatFlowTest: the busy spinner never lets the test clock go idle. */
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) {
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
if (!awaitTagOrNull(tag, timeoutMillis)) {
|
||||
throw AssertionError(
|
||||
"'$tag' did not appear within ${timeoutMillis}ms. " +
|
||||
"On screen: ${visibleText()}")
|
||||
"On screen: ${visibleText()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitTagOrNull(tag: String, timeoutMillis: Long): Boolean {
|
||||
private fun awaitTagOrNull(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
): Boolean {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -370,7 +297,11 @@ class FeatureUseTest {
|
||||
}
|
||||
|
||||
/** Wait for any tag matching a pattern, ids are assigned by the relay. */
|
||||
private fun awaitNodeMatching(pattern: Regex, timeoutMillis: Long, whenMissing: String) {
|
||||
private fun awaitNodeMatching(
|
||||
pattern: Regex,
|
||||
timeoutMillis: Long,
|
||||
whenMissing: String,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -385,14 +316,18 @@ class FeatureUseTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 40)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun relayReachable(): Boolean = runCatching {
|
||||
private fun relayReachable(): Boolean =
|
||||
runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
.apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -9,9 +9,9 @@ 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 androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -20,50 +20,15 @@ import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Sign-up on the two shapes that hold their accounts in the shared service.
|
||||
*
|
||||
* `SignUpFlowTest` covers the third shape, a relay with its own account table
|
||||
* and is the one that can be run freely. This one is its opposite number, and
|
||||
* the difference is not cosmetic: these two shapes send a passphrase-derived
|
||||
* secret to `accounts.flamenet.io` rather than to the relay, and they are the
|
||||
* two the parity table listed as built but never exercised end to end.
|
||||
*
|
||||
* These create real accounts on `accounts.flamenet.io`. There is no
|
||||
* disposable copy of that service, so unlike the fleet these cannot be reset,
|
||||
* every run leaves an account behind. Hence opt-in:
|
||||
*
|
||||
* adb shell am instrument -w -e live true \
|
||||
* -e class io.flamenet.messenger.app.LiveSignUpFleetTest \
|
||||
* io.flamenet.messenger.app.test/androidx.test.runner.AndroidJUnitRunner
|
||||
*
|
||||
* An instrumentation argument, not an environment variable. `FOO=1
|
||||
* ./gradlew connectedAndroidTest` does not reach the instrumentation process;
|
||||
* the variable is simply absent, so an env-gated skip stays skipped no matter
|
||||
* what is set and the suite reports success while having run nothing.
|
||||
* `CrossPlatformTest` takes its parameters the same way for the same reason.
|
||||
*
|
||||
* The account service allows three sign-ups per hour per address
|
||||
* (`ENCLAVE_SIGNUPS_PER_ADDRESS`, an hour-long window that survives restarts).
|
||||
* That budget is shared with the iOS `LiveSignUpUITests` and with anything else
|
||||
* signing up from this network, so a fourth run inside the hour is refused,
|
||||
* which is the service behaving correctly, and reads as a broken sign-up.
|
||||
* Two shapes per round means one round per half hour, at best.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LiveSignUpFleetTest {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
@@ -77,10 +42,6 @@ class LiveSignUpFleetTest {
|
||||
|
||||
/**
|
||||
* Somebody else's relay, keeping its accounts in the shared service.
|
||||
*
|
||||
* The shape that was unreachable before discovery existed: it can never be
|
||||
* on a list compiled into the app, so reaching it at all depends on the
|
||||
* relay being asked what it uses and believed.
|
||||
*/
|
||||
private val sharedSelfHosted get() = args.getString("shared") ?: "relay2.flamenet.io"
|
||||
|
||||
@@ -94,16 +55,12 @@ class LiveSignUpFleetTest {
|
||||
|
||||
/**
|
||||
* `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")
|
||||
args.getString("live") == "true",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun signUpOnTheManagedRelay() {
|
||||
@@ -119,16 +76,11 @@ class LiveSignUpFleetTest {
|
||||
|
||||
/**
|
||||
* One whole sign-up, driven the way a person drives it.
|
||||
*
|
||||
* The assertion that carries the weight is the absence of the email
|
||||
* field. A relay keeping its own accounts asks for an address so it can
|
||||
* send a reset code; the shared service never does. So an email field still
|
||||
* on screen means discovery did not land and the app is about to stretch
|
||||
* the passphrase with the wrong salt, which does not error. It derives a
|
||||
* different account from the same passphrase, and the person finds out
|
||||
* later, on another device, when their credential does not work.
|
||||
*/
|
||||
private fun signUpLive(relay: String, tag: String) {
|
||||
private fun signUpLive(
|
||||
relay: String,
|
||||
tag: String,
|
||||
) {
|
||||
if (!reachable(relay)) return
|
||||
|
||||
val name = "$handle$tag"
|
||||
@@ -136,10 +88,7 @@ class LiveSignUpFleetTest {
|
||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
|
||||
// Wait for discovery BEFORE deciding anything about the form. The
|
||||
// screen renders from the default. LOCAL, the safe direction, and the
|
||||
// descriptor lands a moment later. Asserting immediately tests the
|
||||
// placeholder rather than the answer.
|
||||
// Wait for discovery BEFORE deciding anything about the form.
|
||||
awaitTag("identity-mode", 30_000)
|
||||
awaitNoTag("email", 30_000)
|
||||
|
||||
@@ -158,12 +107,6 @@ class LiveSignUpFleetTest {
|
||||
|
||||
/**
|
||||
* Get to a signed-out app.
|
||||
*
|
||||
* The process and the vault survive between tests even though the rule
|
||||
* gives each one a fresh Activity, so the app opens on a spinner while it
|
||||
* reads the vault. Deciding "already signed out" before it settles lets the
|
||||
* session restore underneath, and the next assertion fails looking for a
|
||||
* field that was never going to be there.
|
||||
*/
|
||||
private fun signOutIfSignedIn() {
|
||||
awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000)
|
||||
@@ -178,43 +121,48 @@ class LiveSignUpFleetTest {
|
||||
awaitTag("relay", 30_000)
|
||||
}
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> = runCatching {
|
||||
private fun nodesWithTag(tag: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun awaitAny(tags: List<String>, timeoutMillis: Long) {
|
||||
private fun awaitAny(
|
||||
tags: List<String>,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
pumping(timeoutMillis) { tags.any { nodesWithTag(it).isNotEmpty() } }
|
||||
}
|
||||
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) {
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
if (!pumping(timeoutMillis) { nodesWithTag(tag).isNotEmpty() }) {
|
||||
throw AssertionError(
|
||||
"'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
throw AssertionError("'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
}
|
||||
}
|
||||
|
||||
/** The inverse, and the one this test actually turns on. */
|
||||
private fun awaitNoTag(tag: String, timeoutMillis: Long) {
|
||||
private fun awaitNoTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
if (!pumping(timeoutMillis) { nodesWithTag(tag).isEmpty() }) {
|
||||
throw AssertionError(
|
||||
"'$tag' was still on screen after ${timeoutMillis}ms, discovery did not land, " +
|
||||
"so this relay is being treated as if it kept its own accounts. " +
|
||||
"On screen: ${visibleText()}")
|
||||
"On screen: ${visibleText()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a condition, advancing the clock by hand.
|
||||
*
|
||||
* Auto-advance is turned off only for the wait. Compose's test clock
|
||||
* synchronises on idleness and the busy state shows an indeterminate
|
||||
* spinner, which animates for ever, so the composition is never idle and
|
||||
* `waitUntil` never evaluates its condition, failing on the full timeout in
|
||||
* a way that reads as "sign-up is broken" when sign-up works. Turning it
|
||||
* off for the whole test is the next mistake: the app opens on a spinner
|
||||
* while it reads the vault, so nothing would ever compose.
|
||||
*/
|
||||
private fun pumping(timeoutMillis: Long, condition: () -> Boolean): Boolean {
|
||||
private fun pumping(
|
||||
timeoutMillis: Long,
|
||||
condition: () -> Boolean,
|
||||
): Boolean {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -229,13 +177,17 @@ class LiveSignUpFleetTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 30)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun reachable(relay: String): Boolean = runCatching {
|
||||
private fun reachable(relay: String): Boolean =
|
||||
runCatching {
|
||||
(java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
.apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
@@ -7,24 +7,28 @@ import java.security.Security
|
||||
|
||||
/**
|
||||
* What this Android build actually offers, algorithm by algorithm.
|
||||
*
|
||||
* Not an assertion, a measurement. Guessing which provider serves `Ed25519`
|
||||
* or `XDH` is how an engine ends up calling into `AndroidKeyStore` by accident
|
||||
* and failing with "Not initialized", which names nothing useful.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ProviderProbeTest {
|
||||
|
||||
@Test
|
||||
fun whoServesWhat() {
|
||||
val interesting = listOf(
|
||||
"KeyPairGenerator.XDH", "KeyFactory.XDH", "KeyAgreement.XDH",
|
||||
"KeyPairGenerator.Ed25519", "KeyFactory.Ed25519", "Signature.Ed25519",
|
||||
"KeyPairGenerator.EdDSA", "KeyFactory.EdDSA", "Signature.EdDSA",
|
||||
val interesting =
|
||||
listOf(
|
||||
"KeyPairGenerator.XDH",
|
||||
"KeyFactory.XDH",
|
||||
"KeyAgreement.XDH",
|
||||
"KeyPairGenerator.Ed25519",
|
||||
"KeyFactory.Ed25519",
|
||||
"Signature.Ed25519",
|
||||
"KeyPairGenerator.EdDSA",
|
||||
"KeyFactory.EdDSA",
|
||||
"Signature.EdDSA",
|
||||
"SecretKeyFactory.PBKDF2WithHmacSHA256",
|
||||
)
|
||||
for (service in interesting) {
|
||||
val providers = Security.getProviders()
|
||||
val providers =
|
||||
Security
|
||||
.getProviders()
|
||||
.filter { p -> p.services.any { "${it.type}.${it.algorithm}".equals(service, true) } }
|
||||
.map { it.name }
|
||||
android.util.Log.i("fnprobe", "$service -> ${providers.ifEmpty { listOf("NONE") }}")
|
||||
|
||||
@@ -5,13 +5,13 @@ import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
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.compose.ui.test.printToString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -20,36 +20,15 @@ import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Sign-up, through the actual screens, against an actual relay.
|
||||
*
|
||||
* Points at the throwaway fleet, never at production. `relay1` is a
|
||||
* disposable relay that exists to be written to; `relay.flamenet.io` carries
|
||||
* real accounts, and a test suite that creates accounts there leaves litter
|
||||
* nobody asked for.
|
||||
*
|
||||
* Every run uses a fresh handle. Reusing one means the second run fails
|
||||
* with "that username is taken", a real relay answering correctly, which reads
|
||||
* as a product bug. The handle is derived from the clock, so a repeat run is a
|
||||
* repeat test rather than a different one.
|
||||
*
|
||||
* Skipped, not failed, when the relay is unreachable: a laptop with no network
|
||||
* is not a broken app.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SignUpFlowTest {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
val permissions: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
@@ -58,23 +37,21 @@ class SignUpFlowTest {
|
||||
|
||||
/**
|
||||
* 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")
|
||||
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 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
|
||||
@@ -82,39 +59,17 @@ class SignUpFlowTest {
|
||||
handle = "andt" + (System.currentTimeMillis() % 100_000_000)
|
||||
|
||||
// Do NOT wipe the Keystore here.
|
||||
//
|
||||
// The compose rule launches the activity before `@Before` runs, so the
|
||||
// view model has already opened the vault and is holding a `SecretKey`
|
||||
// handle. Deleting the Keystore entry underneath it leaves every later
|
||||
// write failing against a key that no longer exists, and the symptom
|
||||
// is a sign-up that never completes, with no error naming the cause.
|
||||
//
|
||||
// Signing out through the UI is both safe and a truer test: it is what
|
||||
// a person does, and the view model reopens the vault afterwards.
|
||||
signOutIfSignedIn()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get to a signed-out app.
|
||||
*
|
||||
* Sign-out lives on the Account screen, so a test that only looks for it
|
||||
* on whatever screen the previous test left behind silently does nothing,
|
||||
* and the next assertion fails looking for a sign-in field that was never
|
||||
* going to be there.
|
||||
*/
|
||||
private fun signOutIfSignedIn() {
|
||||
// Wait for the app to settle before deciding anything. The
|
||||
// Compose rule launches a fresh Activity per test but the process, and
|
||||
// the vault, survive, so the app spends its first moments reading the
|
||||
// vault and showing a spinner. Checking for a signed-in control right
|
||||
// away finds nothing, concludes "already signed out", and then the
|
||||
// session restores underneath: every later test fails looking for a
|
||||
// sign-in field. This race is why the suite passed one test at a time
|
||||
// and failed as a set.
|
||||
// Wait for the app to settle before deciding anything.
|
||||
awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000)
|
||||
|
||||
// Close any open thread. The chat screen replaces the bottom nav, so a
|
||||
// test inheriting an open conversation cannot reach Account at all.
|
||||
// Close any open thread.
|
||||
if (nodesWithTag("back").isNotEmpty()) {
|
||||
compose.onNodeWithTag("back").performClick()
|
||||
awaitTag("nav-contacts", 20_000)
|
||||
@@ -126,9 +81,11 @@ class SignUpFlowTest {
|
||||
awaitTag("relay", 30_000)
|
||||
}
|
||||
|
||||
|
||||
/** Wait until any one of these tags exists. */
|
||||
private fun awaitAny(tags: List<String>, timeoutMillis: Long) {
|
||||
private fun awaitAny(
|
||||
tags: List<String>,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
@@ -142,60 +99,51 @@ class SignUpFlowTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> = runCatching {
|
||||
private fun nodesWithTag(tag: String): List<Any> =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/**
|
||||
* Poll for a tag, advancing the clock ourselves.
|
||||
*
|
||||
* The work being waited on happens on a background thread, so the frames
|
||||
* are only needed to let recomposition land, which is why this advances
|
||||
* the clock a little on every turn rather than waiting for idle.
|
||||
*/
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) {
|
||||
private fun awaitTag(
|
||||
tag: String,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
// Auto-advance is turned off HERE, not for the whole test.
|
||||
//
|
||||
// Compose's test clock synchronises on idleness, and the busy state
|
||||
// shows an indeterminate `CircularProgressIndicator`, which animates
|
||||
// for ever, so the composition is never idle and `waitUntil` never
|
||||
// evaluates its condition. It fails on the full timeout with
|
||||
// `ComposeTimeoutException`, which reads as "sign-up is broken" when
|
||||
// sign-up works; driving the app by hand is what settled that.
|
||||
//
|
||||
// Turning it off for the whole test is the obvious next mistake: the
|
||||
// app opens on a spinner while it reads the vault, so nothing would
|
||||
// ever compose and even the first field would be missing.
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
val found = runCatching {
|
||||
val found =
|
||||
runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
if (found) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
// Report what the screen actually says. A bare timeout sends
|
||||
// you to the network, the relay and the crypto in turn, when the
|
||||
// app has been displaying the reason the whole time.
|
||||
throw AssertionError(
|
||||
"'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
// Report what the screen actually says.
|
||||
throw AssertionError("'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the screen is showing, for a failure message worth reading. */
|
||||
private fun visibleText(): String = runCatching {
|
||||
private fun visibleText(): String =
|
||||
runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 30)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun relayReachable(): Boolean = runCatching {
|
||||
private fun relayReachable(): Boolean =
|
||||
runCatching {
|
||||
(java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
.apply {
|
||||
connectTimeout = 8000
|
||||
readTimeout = 8000
|
||||
}.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
|
||||
@Test
|
||||
@@ -207,8 +155,7 @@ class SignUpFlowTest {
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
|
||||
// A relay with its own account table asks for an email; the shared
|
||||
// service does not. If this node is missing, the hostname routing that
|
||||
// decides which provider is in use has gone wrong.
|
||||
// service does not.
|
||||
compose.onNodeWithTag("email").assertIsDisplayed()
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
@@ -223,9 +170,6 @@ class SignUpFlowTest {
|
||||
|
||||
/**
|
||||
* Signing out erases the vault, and the sign-in screen comes back.
|
||||
*
|
||||
* This is the promise the sign-out copy makes, "erases this phone's
|
||||
* keys and stored messages", so it is worth a test rather than a comment.
|
||||
*/
|
||||
@Test
|
||||
fun signingOutReturnsToSignIn() {
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color as AndroidColor
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -14,38 +13,33 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.QrCode
|
||||
import android.graphics.Color as AndroidColor
|
||||
|
||||
/**
|
||||
* Your address, as a code somebody can scan and a string they can copy.
|
||||
*
|
||||
* Android had no way to show this at all, which made the cross-relay
|
||||
* work iOS-only in practice: an address you cannot display is an address
|
||||
* nobody can send to. The engine could encode an fnid and had no way to derive
|
||||
* one, so there was nothing to put on a screen even if a screen had existed.
|
||||
*
|
||||
* The string stays visible under the code. An fnid is a derived key, not
|
||||
* a name, an address that is nearly right addresses somebody else, and a
|
||||
* scanner is not always to hand. Copy is there because retyping 52 characters
|
||||
* off a screen is how the wrong person gets messaged.
|
||||
*/
|
||||
@Composable
|
||||
fun AddressCard(fnid: String?, relay: String, handle: String? = null) {
|
||||
fun AddressCard(
|
||||
fnid: String?,
|
||||
relay: String,
|
||||
handle: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
@@ -53,12 +47,10 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = null) {
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
if (fnid == null) {
|
||||
// Said plainly rather than hidden. Without an address this phone
|
||||
// cannot be reached from another relay, and a blank space would
|
||||
// read as the feature not existing rather than as it not working.
|
||||
// Said plainly rather than hidden.
|
||||
Text(
|
||||
"This device could not derive your address. You can still message "
|
||||
+ "people on ${relay}, but nobody on another relay can reach you.",
|
||||
"This device could not derive your address. You can still message " +
|
||||
"people on $relay, but nobody on another relay can reach you.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.testTag("address-missing"),
|
||||
)
|
||||
@@ -66,11 +58,11 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = null) {
|
||||
}
|
||||
|
||||
val address = "$fnid@$relay"
|
||||
// The handle rides in the CODE only, never in the copyable string,
|
||||
// it lets a same-relay scanner resolve the account through the
|
||||
// ordinary username lookup. Foreign scanners ignore the parameter
|
||||
// (iOS's decoder reads only `k=`); handles are slugs, so no escaping.
|
||||
val encoded = "flamenet:$address" +
|
||||
// The handle rides in the CODE only, never in the copyable string, it
|
||||
// lets a same-relay scanner resolve the account through the ordinary
|
||||
// username lookup.
|
||||
val encoded =
|
||||
"flamenet:$address" +
|
||||
(handle?.takeIf { it.isNotBlank() }?.let { "?u=$it" } ?: "")
|
||||
val bitmap = remember(encoded) { qrBitmap(encoded) }
|
||||
|
||||
@@ -86,19 +78,12 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = null) {
|
||||
// stops scanning, the default filtering is smooth.
|
||||
filterQuality = FilterQuality.None,
|
||||
contentScale = ContentScale.FillBounds,
|
||||
// 140, not 180. At 180 the code was the largest thing on
|
||||
// the screen and pushed everything else below the fold,
|
||||
// and a QR only has to be big enough for a phone camera a
|
||||
// foot away, which 140dp at this density is.
|
||||
// 140, not 180.
|
||||
modifier = Modifier.size(140.dp).testTag("address-qr"),
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
}
|
||||
// Broken at the `@`, not wherever the line runs out. As one
|
||||
// string it wrapped mid-hostname, "…@rela / y.flamenet.io", and a
|
||||
// relay name split across a line break is the kind of thing
|
||||
// somebody retypes wrong. The fnid is a derived key and wraps
|
||||
// anywhere without harm; the host must not.
|
||||
// Broken at the `@`, not wherever the line runs out.
|
||||
Text(
|
||||
fnid,
|
||||
fontSize = 12.sp,
|
||||
@@ -125,27 +110,30 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = null) {
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
"Derived from your passphrase, not issued by a server. Sign in anywhere "
|
||||
+ "with the same passphrase and this stays the same, which is what "
|
||||
+ "lets it outlive any one relay.",
|
||||
"Derived from your passphrase, not issued by a server. Sign in anywhere " +
|
||||
"with the same passphrase and this stays the same, which is what " +
|
||||
"lets it outlive any one relay.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copy(context: Context, label: String, text: String) {
|
||||
private fun copy(
|
||||
context: Context,
|
||||
label: String,
|
||||
text: String,
|
||||
) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
|
||||
}
|
||||
|
||||
/**
|
||||
* One module per pixel plus a quiet zone; Compose scales it up.
|
||||
*
|
||||
* The four-module quiet zone is not decoration. A QR printed hard against
|
||||
* other content does not scan, and the margin has to be part of the bitmap
|
||||
* because the surrounding card has a background of its own.
|
||||
*/
|
||||
private fun qrBitmap(text: String, quiet: Int = 4): Bitmap? {
|
||||
private fun qrBitmap(
|
||||
text: String,
|
||||
quiet: Int = 4,
|
||||
): Bitmap? {
|
||||
val matrix = runCatching { QrCode.encode(text) }.getOrNull() ?: return null
|
||||
val side = matrix.size + quiet * 2
|
||||
val pixels = IntArray(side * side) { AndroidColor.WHITE }
|
||||
|
||||
@@ -3,8 +3,6 @@ package io.flamenet.messenger.app
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -19,36 +17,15 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
|
||||
/**
|
||||
* A fingerprint, a face, or the device PIN in front of the app.
|
||||
*
|
||||
* Android had none of this and iOS has had it for a while. The sealed
|
||||
* history file protects a phone that is off or locked; it does nothing about
|
||||
* the phone somebody just handed to a friend. On iOS the app asks for Face ID;
|
||||
* on Android anyone holding an unlocked handset read every conversation. Two
|
||||
* platforms disagreeing about a security guarantee is how one of them ends up
|
||||
* being the one that leaks.
|
||||
*
|
||||
* What this defends against, stated plainly, because the alternative is
|
||||
* implying more: somebody who picks up your unlocked phone. That is the
|
||||
* threat people actually have, and it is worth something.
|
||||
*
|
||||
* What it does not defend against: anyone with the device and the tools to
|
||||
* read its storage. This is a check in the app's own process. The vault and the
|
||||
* ratchet keys are protected by the Android keystore, not by this switch, and
|
||||
* turning it on re-encrypts nothing. An app lock that claimed otherwise would
|
||||
* be selling a screen as if it were a key.
|
||||
*
|
||||
* `DEVICE_CREDENTIAL` alongside the biometrics on purpose. A phone with a
|
||||
* PIN and no fingerprint reader should still be able to use this, the same
|
||||
* reason iOS asks for `deviceOwnerAuthentication` rather than biometrics
|
||||
* specifically. Locking people out of their own messages because their hardware
|
||||
* is older is not a security win.
|
||||
*/
|
||||
class AppLock(private val context: Context) {
|
||||
|
||||
class AppLock(
|
||||
private val context: Context,
|
||||
) {
|
||||
companion object {
|
||||
private const val PREFS = "flamenet-app-lock"
|
||||
private const val ENABLED = "enabled"
|
||||
@@ -61,33 +38,16 @@ class AppLock(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Failed attempts before the first wait.
|
||||
*
|
||||
* Three, not one. A mistyped PIN or a fingerprint that did not read
|
||||
* is ordinary, and making the first slip cost a wait would punish the
|
||||
* owner far more often than anybody else.
|
||||
*/
|
||||
const val ATTEMPTS_BEFORE_DELAY = 3
|
||||
|
||||
/**
|
||||
* The longest wait this will ever impose.
|
||||
*
|
||||
* Capped, and it must be. An unbounded back-off locks somebody
|
||||
* out of their own messages permanently, which is a worse outcome than
|
||||
* the one it defends against, there is no reset, no support desk, and
|
||||
* the data is on their own phone.
|
||||
*/
|
||||
const val MAXIMUM_DELAY_MS = 300_000L
|
||||
|
||||
/**
|
||||
* How long to wait after [failures] consecutive failed unlocks.
|
||||
*
|
||||
* Doubles from 30 seconds and stops at five minutes. The shape matters
|
||||
* less than the two ends: nothing for an ordinary slip, and a ceiling.
|
||||
*
|
||||
* Doubling is done by shifting a Long rather than `Math.pow`, so a
|
||||
* large failure count cannot overflow a Double into infinity on the way
|
||||
* to being capped. Twenty consecutive failures is not a realistic
|
||||
* number and it should still return five minutes.
|
||||
*/
|
||||
fun delayMillis(failures: Int): Long {
|
||||
if (failures < ATTEMPTS_BEFORE_DELAY) return 0L
|
||||
@@ -104,10 +64,7 @@ class AppLock(private val context: Context) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Ordinary SharedPreferences, NOT the vault. This records a preference,
|
||||
* not a secret, and putting it in the encrypted store would mean the
|
||||
* setting could not be read until after sign-in, which is exactly when the
|
||||
* lock needs to already be up.
|
||||
* Ordinary SharedPreferences, NOT the vault.
|
||||
*/
|
||||
var enabled: Boolean
|
||||
get() = prefs.getBoolean(ENABLED, false)
|
||||
@@ -125,25 +82,19 @@ class AppLock(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Whether this device can do it at all.
|
||||
*
|
||||
* Checked before the setting is offered. A toggle that turns on and then
|
||||
* cannot authenticate locks somebody out of their own messages with no way
|
||||
* back, which is worse than not having the feature.
|
||||
*/
|
||||
fun available(): Boolean =
|
||||
BiometricManager.from(context).canAuthenticate(AUTHENTICATORS) ==
|
||||
BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
/**
|
||||
* When the app was last sent to the background. Null means it has not been.
|
||||
*
|
||||
* The grace period exists so that picking a photo, answering a call, or
|
||||
* being bounced out by the system camera does not demand a fingerprint on
|
||||
* the way back. A lock with no grace is one people switch off.
|
||||
* When the app was last sent to the background.
|
||||
*/
|
||||
private var leftAt: Long? = null
|
||||
|
||||
fun noteLeft(now: Long = System.currentTimeMillis()) { leftAt = now }
|
||||
fun noteLeft(now: Long = System.currentTimeMillis()) {
|
||||
leftAt = now
|
||||
}
|
||||
|
||||
fun shouldLock(now: Long = System.currentTimeMillis()): Boolean {
|
||||
if (!enabled) return false
|
||||
@@ -151,28 +102,21 @@ class AppLock(private val context: Context) {
|
||||
return (now - away) >= graceSeconds * 1000L
|
||||
}
|
||||
|
||||
fun noteUnlocked() { leftAt = null }
|
||||
fun noteUnlocked() {
|
||||
leftAt = null
|
||||
}
|
||||
|
||||
// ---- Backing off (ios#41) -------------------------------------------
|
||||
//
|
||||
// Android had the lock and none of the back-off. iOS has counted
|
||||
// failed unlocks and imposed a growing wait since ios#41; here every
|
||||
// attempt was free, so somebody holding an unlocked phone could work
|
||||
// through PINs as fast as the prompt would reappear. Two platforms
|
||||
// disagreeing about a security guarantee is how one of them ends up being
|
||||
// the one that leaks, the same argument that put the lock here at all.
|
||||
// Android had the lock and none of the back-off. iOS has counted failed
|
||||
// unlocks and imposed a growing wait since ios#41; here every attempt was
|
||||
// free, so somebody holding an unlocked phone could work through PINs as
|
||||
// fast as the prompt would reappear.
|
||||
|
||||
/** Consecutive failures so far. */
|
||||
val failures: Int get() = prefs.getInt(FAILURES, 0)
|
||||
|
||||
/**
|
||||
* When the next attempt is allowed, or null if one is allowed now.
|
||||
*
|
||||
* Wall-clock, and somebody who can change the device clock can skip
|
||||
* the wait. Said rather than hidden: this slows a person holding the
|
||||
* phone, and it is not a defence against somebody with the device and the
|
||||
* patience to work at it. What actually protects the messages is the
|
||||
* keystore, exactly as the note at the top of this file says.
|
||||
*/
|
||||
fun waitingUntil(now: Long = System.currentTimeMillis()): Long? {
|
||||
val until = prefs.getLong(LOCKED_UNTIL, 0L)
|
||||
@@ -186,27 +130,28 @@ class AppLock(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a wrong answer counts. Dismissing the prompt is not an
|
||||
* attempt, counting a cancel would let the owner back themselves into a
|
||||
* wait by changing their mind twice. See the error handling in
|
||||
* [authenticate], which is where that distinction is actually made.
|
||||
* Only a wrong answer counts.
|
||||
*/
|
||||
fun recordFailure(now: Long = System.currentTimeMillis()) {
|
||||
val next = failures + 1
|
||||
val wait = delayMillis(next)
|
||||
prefs.edit().apply {
|
||||
prefs
|
||||
.edit()
|
||||
.apply {
|
||||
putInt(FAILURES, next)
|
||||
if (wait > 0L) putLong(LOCKED_UNTIL, now + wait)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the failures. On a successful unlock, and when the feature is
|
||||
* switched off, leaving a wait behind on a disabled lock would strand
|
||||
* somebody behind a screen that is no longer supposed to exist.
|
||||
* Forget the failures.
|
||||
*/
|
||||
fun clearFailures() {
|
||||
prefs.edit().remove(FAILURES).remove(LOCKED_UNTIL).apply()
|
||||
prefs
|
||||
.edit()
|
||||
.remove(FAILURES)
|
||||
.remove(LOCKED_UNTIL)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** How to say the wait, in whichever unit reads naturally. */
|
||||
@@ -223,76 +168,65 @@ class AppLock(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Ask.
|
||||
*
|
||||
* Requires a `FragmentActivity`. `BiometricPrompt` hosts a fragment, and
|
||||
* a plain `ComponentActivity` silently cannot show it, the callback never
|
||||
* fires and the lock screen sits there looking broken.
|
||||
*/
|
||||
fun authenticate(
|
||||
activity: FragmentActivity,
|
||||
onSuccess: () -> Unit,
|
||||
onFailure: (String) -> Unit,
|
||||
) {
|
||||
// Refuse BEFORE showing the prompt. Letting the system sheet appear
|
||||
// and then discarding the answer would teach somebody that attempts are
|
||||
// free, which is the opposite of the point.
|
||||
waitMessage()?.let { onFailure(it); return }
|
||||
// Refuse BEFORE showing the prompt.
|
||||
waitMessage()?.let {
|
||||
onFailure(it)
|
||||
return
|
||||
}
|
||||
|
||||
val prompt = BiometricPrompt(
|
||||
val prompt =
|
||||
BiometricPrompt(
|
||||
activity,
|
||||
ContextCompat.getMainExecutor(activity),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult,
|
||||
) {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
clearFailures()
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
/**
|
||||
* The one callback that means a wrong answer.
|
||||
* `onAuthenticationFailed` fires for a fingerprint that did not
|
||||
* match; `onAuthenticationError` fires for cancels, lockouts and
|
||||
* hardware problems. Only this moves the counter, which is why
|
||||
* dismissing the prompt cannot back the owner into a wait.
|
||||
*/
|
||||
override fun onAuthenticationFailed() {
|
||||
recordFailure()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(code: Int, message: CharSequence) {
|
||||
// A cancel is not a failure. Reporting "authentication
|
||||
// failed" when somebody tapped away is how a lock screen
|
||||
// teaches people it is broken.
|
||||
override fun onAuthenticationError(
|
||||
code: Int,
|
||||
message: CharSequence,
|
||||
) {
|
||||
// A cancel is not a failure.
|
||||
if (code == BiometricPrompt.ERROR_USER_CANCELED ||
|
||||
code == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
|
||||
code == BiometricPrompt.ERROR_CANCELED
|
||||
) return
|
||||
// The platform's own lockout is not counted on top of
|
||||
// ours. Two back-offs stacking would produce a wait nobody
|
||||
// can predict from either rule.
|
||||
) {
|
||||
return
|
||||
}
|
||||
// The platform's own lockout is not counted on top of ours.
|
||||
onFailure(message.toString())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
BiometricPrompt.PromptInfo
|
||||
.Builder()
|
||||
.setTitle("Unlock Flamenet")
|
||||
.setSubtitle("Your messages are locked on this device.")
|
||||
.setAllowedAuthenticators(AUTHENTICATORS)
|
||||
.build()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What is on screen while the app is locked.
|
||||
*
|
||||
* Deliberately says nothing. No sender, no preview, no unread count,
|
||||
* the point of the lock is that whoever is holding the phone learns nothing,
|
||||
* and a lock screen reporting "3 new messages from Dana" has given away the two
|
||||
* facts that usually matter most.
|
||||
*/
|
||||
@Composable
|
||||
fun LockScreen(onUnlock: () -> Unit) {
|
||||
|
||||
@@ -24,75 +24,56 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Telling a hand-installed app that a newer one exists.
|
||||
*
|
||||
* This app is not in a store, so nothing else will ever say this. A
|
||||
* person who installs the APK and never visits the site again keeps running
|
||||
* that build forever, including through every fix that came after it. The
|
||||
* network check lives in [UpdateCheck] and explains why it asks the user's own
|
||||
* relay rather than an address of ours; this file is the part that decides how
|
||||
* often to ask and how loudly to say so.
|
||||
*
|
||||
* Nobody already installed will see this. Every build before this one
|
||||
* has no update check in it, so the first prompt anybody gets is for the build
|
||||
* *after* the one that introduces this. That is not a bug to fix later, it is
|
||||
* inherent, and it means the existing install base still has to be reached the
|
||||
* way it always was, by telling them.
|
||||
*/
|
||||
object AppUpdate {
|
||||
|
||||
private const val PREFS = "app-update"
|
||||
private const val LAST_CHECKED = "last-checked-at"
|
||||
private const val DISMISSED_CODE = "dismissed-version-code"
|
||||
|
||||
/**
|
||||
* Six hours. Long enough that opening the app repeatedly costs one request
|
||||
* a quarter of a day, short enough that a fix published in the morning is
|
||||
* offered the same day.
|
||||
* Six hours.
|
||||
*/
|
||||
private const val CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* What is actually installed, asked of the package manager.
|
||||
*
|
||||
* Not `BuildConfig.VERSION_CODE`. It would need a build feature turned
|
||||
* on, and more to the point it is a constant compiled into the APK: it
|
||||
* describes what was *built*, and this needs to describe what is *running*.
|
||||
* They are the same today and the package manager cannot drift.
|
||||
*/
|
||||
fun installedVersionCode(context: Context): Int = runCatching {
|
||||
fun installedVersionCode(context: Context): Int =
|
||||
runCatching {
|
||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
info.longVersionCode.toInt()
|
||||
}.getOrDefault(0)
|
||||
|
||||
fun installedVersionName(context: Context): String = runCatching {
|
||||
fun installedVersionName(context: Context): String =
|
||||
runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
}.getOrNull() ?: "unknown"
|
||||
|
||||
private fun dueForCheck(context: Context): Boolean =
|
||||
System.currentTimeMillis() - prefs(context).getLong(LAST_CHECKED, 0) > CHECK_INTERVAL_MS
|
||||
|
||||
private fun recordCheck(context: Context) =
|
||||
prefs(context).edit().putLong(LAST_CHECKED, System.currentTimeMillis()).apply()
|
||||
private fun recordCheck(context: Context) = prefs(context).edit().putLong(LAST_CHECKED, System.currentTimeMillis()).apply()
|
||||
|
||||
/**
|
||||
* Stop offering this particular build.
|
||||
*
|
||||
* Recorded per version, not as a "don't ask me again" flag. Somebody who
|
||||
* declines 0.7 today should still be told about 0.8 next month, a single
|
||||
* boolean would silence the feature permanently on the first dismissal,
|
||||
* which is how update prompts end up being useless.
|
||||
*/
|
||||
fun dismiss(context: Context, versionCode: Int) =
|
||||
prefs(context).edit().putInt(DISMISSED_CODE, versionCode).apply()
|
||||
fun dismiss(
|
||||
context: Context,
|
||||
versionCode: Int,
|
||||
) = prefs(context).edit().putInt(DISMISSED_CODE, versionCode).apply()
|
||||
|
||||
fun isDismissed(context: Context, versionCode: Int): Boolean =
|
||||
prefs(context).getInt(DISMISSED_CODE, 0) >= versionCode
|
||||
fun isDismissed(
|
||||
context: Context,
|
||||
versionCode: Int,
|
||||
): Boolean = prefs(context).getInt(DISMISSED_CODE, 0) >= versionCode
|
||||
|
||||
/** Hand the download to the browser, which is where an APK install starts. */
|
||||
fun openDownload(context: Context, url: String) {
|
||||
fun openDownload(
|
||||
context: Context,
|
||||
url: String,
|
||||
) {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
@@ -103,34 +84,31 @@ object AppUpdate {
|
||||
|
||||
/**
|
||||
* Where releases are published when the relay does not publish them.
|
||||
*
|
||||
* Only ever reached because somebody pressed the button. The
|
||||
* automatic check asks the user's own relay and stops there, so an app that
|
||||
* is signed in to somebody else's server never contacts us on a timer.
|
||||
* Pressing "Check for updates" is a different thing: it is a question the
|
||||
* user asked, and answering "your relay does not publish updates, so I
|
||||
* cannot tell you" when we plainly could is unhelpful. The screen says
|
||||
* which host answered, so this is never a silent phone-home.
|
||||
*/
|
||||
const val OFFICIAL_SITE = "https://flamenetmessenger.com"
|
||||
|
||||
/** What an explicit check found, including which host answered. */
|
||||
sealed interface CheckResult {
|
||||
data class Available(val release: UpdateCheck.Release, val source: String) : CheckResult
|
||||
data class UpToDate(val source: String) : CheckResult
|
||||
data class Available(
|
||||
val release: UpdateCheck.Release,
|
||||
val source: String,
|
||||
) : CheckResult
|
||||
|
||||
data class UpToDate(
|
||||
val source: String,
|
||||
) : CheckResult
|
||||
|
||||
/** Neither host published a manifest, or neither could be reached. */
|
||||
data object Unknown : CheckResult
|
||||
}
|
||||
|
||||
/**
|
||||
* The check behind the button: this relay first, then the official site.
|
||||
*
|
||||
* Ordered, not raced. The relay is asked first so somebody running their
|
||||
* own gets their own answer, and the site is only reached for if that
|
||||
* produced nothing at all, a relay that publishes an OLDER build than we
|
||||
* do is still the answer for the person who chose that relay.
|
||||
*/
|
||||
suspend fun checkNow(context: Context, baseUrl: String?): CheckResult =
|
||||
suspend fun checkNow(
|
||||
context: Context,
|
||||
baseUrl: String?,
|
||||
): CheckResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val installed = installedVersionCode(context)
|
||||
val hosts = listOfNotNull(baseUrl?.takeIf { it.isNotBlank() }, OFFICIAL_SITE)
|
||||
@@ -147,22 +125,16 @@ object AppUpdate {
|
||||
}
|
||||
|
||||
/** Just the hostname, a full URL in a sentence reads as an error. */
|
||||
private fun hostLabel(url: String): String =
|
||||
runCatching { java.net.URI(url).host }.getOrNull() ?: url
|
||||
private fun hostLabel(url: String): String = runCatching { java.net.URI(url).host }.getOrNull() ?: url
|
||||
|
||||
/**
|
||||
* Ask the relay, off the main thread, no more often than the interval.
|
||||
*
|
||||
* The relay ONLY. The fallback to our site belongs to [checkNow], which
|
||||
* runs because somebody asked; putting it here would make every install
|
||||
* check in with us on a timer, which is the thing this product says it does
|
||||
* not do.
|
||||
*
|
||||
* Returns null when there is nothing to say, which is the overwhelmingly
|
||||
* common answer and must stay silent.
|
||||
*/
|
||||
suspend fun check(context: Context, baseUrl: String?, force: Boolean = false):
|
||||
UpdateCheck.Release? {
|
||||
suspend fun check(
|
||||
context: Context,
|
||||
baseUrl: String?,
|
||||
force: Boolean = false,
|
||||
): UpdateCheck.Release? {
|
||||
if (baseUrl.isNullOrBlank()) return null
|
||||
if (!force && !dueForCheck(context)) return null
|
||||
val installed = installedVersionCode(context)
|
||||
@@ -173,9 +145,6 @@ object AppUpdate {
|
||||
|
||||
/**
|
||||
* The check the whole screen hangs off, run once per composition.
|
||||
*
|
||||
* Keyed on the relay so that signing into a different one re-asks. The
|
||||
* answer is that relay's, not a global fact.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberAvailable(baseUrl: String?): UpdateCheck.Release? {
|
||||
@@ -188,11 +157,6 @@ object AppUpdate {
|
||||
|
||||
/**
|
||||
* The prompt itself.
|
||||
*
|
||||
* A dialog, and only for a version not yet declined. Everything else about
|
||||
* updates lives quietly in Account, an app that interrupts you on every launch
|
||||
* to talk about itself trains people to dismiss it without reading, and then
|
||||
* the one update that mattered gets dismissed too.
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateAvailableDialog(baseUrl: String?) {
|
||||
@@ -233,8 +197,7 @@ fun UpdateAvailableDialog(baseUrl: String?) {
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// Declining means declining THIS version, permanently. The
|
||||
// next one asks again.
|
||||
// Declining means declining THIS version, permanently.
|
||||
AppUpdate.dismiss(context, release.versionCode)
|
||||
showing = false
|
||||
},
|
||||
|
||||
@@ -7,18 +7,12 @@ import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
/**
|
||||
* The Decline button on a ringing call notification.
|
||||
*
|
||||
* A broadcast, not an activity. Declining must not open the app, the whole
|
||||
* point of answering from the lock screen is that the phone stays locked for
|
||||
* the choice the user did NOT make.
|
||||
*
|
||||
* Clears the parked offer as well as the notification. The notification is
|
||||
* `ongoing`, so it does not dismiss itself, and an offer left parked would be
|
||||
* answered the next time the app opened, ringing for a call declined an hour
|
||||
* ago.
|
||||
*/
|
||||
class CallActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
if (intent.action != MessengerService.ACTION_DECLINE) return
|
||||
PendingIncomingCall.clearAll()
|
||||
runCatching { NotificationManagerCompat.from(context).cancel(3) }
|
||||
|
||||
@@ -6,14 +6,10 @@ import org.json.JSONObject
|
||||
import org.webrtc.AudioSource
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.Camera2Enumerator
|
||||
import org.webrtc.DataChannel
|
||||
import org.webrtc.DefaultVideoDecoderFactory
|
||||
import org.webrtc.DefaultVideoEncoderFactory
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.SurfaceTextureHelper
|
||||
import org.webrtc.VideoCapturer
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
import org.webrtc.DataChannel
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.MediaStream
|
||||
@@ -22,28 +18,14 @@ import org.webrtc.PeerConnectionFactory
|
||||
import org.webrtc.RtpReceiver
|
||||
import org.webrtc.SdpObserver
|
||||
import org.webrtc.SessionDescription
|
||||
import org.webrtc.SurfaceTextureHelper
|
||||
import org.webrtc.VideoCapturer
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Voice calls over WebRTC (spec §8.10).
|
||||
*
|
||||
* Peer to peer whenever the network allows it. ICE tries a direct path
|
||||
* first, same network, then each side's address as seen through NAT, and
|
||||
* only falls back to relaying through TURN when both ends sit behind NAT that
|
||||
* refuses a direct path. The relay mints TURN credentials and never carries
|
||||
* media; when it carries anything it carries DTLS-SRTP ciphertext between two
|
||||
* peers who authenticated each other through the ratchet.
|
||||
*
|
||||
* A direct call shows the other person your IP address. That is what
|
||||
* "peer to peer" means and it is not a bug, but it is a disclosure, and the
|
||||
* app says so before the first call rather than after. Someone who does not
|
||||
* want it needs a relay, and relaying requires a TURN server to exist.
|
||||
*
|
||||
* Signalling rides the ratchet, not the relay. The offer and answer are
|
||||
* ordinary content envelopes, so the SDP, which names codecs, and every
|
||||
* candidate address, is end-to-end encrypted like any other message. A relay
|
||||
* that could read signalling would learn the addresses of both parties even on
|
||||
* a call it never carries.
|
||||
*/
|
||||
class CallManager(
|
||||
private val context: Context,
|
||||
@@ -51,11 +33,14 @@ class CallManager(
|
||||
private val signal: (Long, Content.CallSignal) -> Unit,
|
||||
private val onStateChanged: (State) -> Unit,
|
||||
) {
|
||||
|
||||
enum class State { IDLE, DIALLING, RINGING, CONNECTING, ACTIVE, ENDED }
|
||||
|
||||
data class Active(val callId: String, val peerId: Long, val incoming: Boolean,
|
||||
val video: Boolean = false)
|
||||
data class Active(
|
||||
val callId: String,
|
||||
val peerId: Long,
|
||||
val incoming: Boolean,
|
||||
val video: Boolean = false,
|
||||
)
|
||||
|
||||
var current: Active? = null
|
||||
private set
|
||||
@@ -73,7 +58,7 @@ class CallManager(
|
||||
|
||||
// -- Video (§6.8v) -------------------------------------------------------
|
||||
// One EGL context for the factory's codecs, the capturer and every
|
||||
// renderer. WebRTC textures only travel between things that share it.
|
||||
// renderer.
|
||||
val eglBase: EglBase by lazy { EglBase.create() }
|
||||
private var videoCapturer: VideoCapturer? = null
|
||||
private var videoHelper: SurfaceTextureHelper? = null
|
||||
@@ -82,6 +67,7 @@ class CallManager(
|
||||
private set
|
||||
var remoteVideoTrack: VideoTrack? = null
|
||||
private set
|
||||
|
||||
/** Fired on the main thread when a remote video track appears or goes. */
|
||||
var onVideoTracksChanged: (() -> Unit)? = null
|
||||
|
||||
@@ -90,23 +76,11 @@ class CallManager(
|
||||
/**
|
||||
* Runs once, on the main thread, when ICE gathering completes, this is
|
||||
* where the offer or answer is actually sent.
|
||||
*
|
||||
* The SDP from `onCreateSuccess` has ZERO candidates in it. With
|
||||
* trickle off, gathering starts at `setLocalDescription` and the
|
||||
* candidates land in the *local description*, not in callbacks, so
|
||||
* sending `sdp.description` the moment the offer was created shipped an
|
||||
* SDP that named no address at all. The other end had nothing to connect
|
||||
* to, ICE had no pairs to check, and every call sat in "Connecting…"
|
||||
* until someone gave up. The complete SDP exists only after
|
||||
* [PeerConnection.IceGatheringState.COMPLETE], read back from
|
||||
* `localDescription`.
|
||||
*/
|
||||
private var onGathered: ((String) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Sends whatever was gathered if COMPLETE never fires. A TURN server that
|
||||
* is configured but unreachable stalls gathering for tens of seconds;
|
||||
* host + STUN candidates collected by then usually still connect.
|
||||
* Sends whatever was gathered if COMPLETE never fires.
|
||||
*/
|
||||
private var gatherGuard: Runnable? = null
|
||||
|
||||
@@ -115,15 +89,13 @@ class CallManager(
|
||||
|
||||
/**
|
||||
* Gives up a call that was answered but whose media never connected.
|
||||
* Without it, an answer whose ICE cannot find a path, no TURN, hostile
|
||||
* NAT, left BOTH screens sitting in "Connecting…" until someone forced
|
||||
* the app closed.
|
||||
*/
|
||||
private var connectGuard: Runnable? = null
|
||||
|
||||
private fun armConnectGuard() {
|
||||
connectGuard?.let { main.removeCallbacks(it) }
|
||||
val guard = Runnable {
|
||||
val guard =
|
||||
Runnable {
|
||||
if (state == State.CONNECTING) hangUp(notifyPeer = true, reason = "timeout")
|
||||
}
|
||||
connectGuard = guard
|
||||
@@ -136,8 +108,8 @@ class CallManager(
|
||||
val guard = Runnable { deliverGatheredSdp() }
|
||||
gatherGuard = guard
|
||||
main.postDelayed(guard, GATHER_TIMEOUT_MS)
|
||||
// Gathering can already be done, tiny SDPs on a fast network, and
|
||||
// then COMPLETE fired before this registration and nothing else will.
|
||||
// Gathering can already be done, tiny SDPs on a fast network, and then
|
||||
// COMPLETE fired before this registration and nothing else will.
|
||||
if (connection?.iceGatheringState() == PeerConnection.IceGatheringState.COMPLETE) {
|
||||
deliverGatheredSdp()
|
||||
}
|
||||
@@ -149,45 +121,42 @@ class CallManager(
|
||||
onGathered = null
|
||||
gatherGuard?.let { main.removeCallbacks(it) }
|
||||
gatherGuard = null
|
||||
// This `?: return` sent nobody anything, silently. After the
|
||||
// 8s gather timeout the local description can still be null, because
|
||||
// setLocalDescription had not finished, or had FAILED and said nothing
|
||||
// (see SimpleSdpObserver). The call then dies here: no offer, no
|
||||
// envelope on the relay, no ring on the other phone, and no error.
|
||||
// Verified against the database, zero envelopes had ever been created
|
||||
// between two accounts that had "placed" several calls.
|
||||
val sdp = connection?.localDescription?.description ?: run {
|
||||
android.util.Log.w("fnmsg-call", "NOT SENDING the offer/answer: no localDescription after " +
|
||||
// This `?: return` sent nobody anything, silently.
|
||||
val sdp =
|
||||
connection?.localDescription?.description ?: run {
|
||||
android.util.Log.w(
|
||||
"fnmsg-call",
|
||||
"NOT SENDING the offer/answer: no localDescription after " +
|
||||
"gathering (state=${connection?.iceGatheringState()}). " +
|
||||
"The other side will never see this call.")
|
||||
"The other side will never see this call.",
|
||||
)
|
||||
return
|
||||
}
|
||||
android.util.Log.i("fnmsg-call", "sending signal after gathering, sdp=${sdp.length} bytes, " +
|
||||
"state=${connection?.iceGatheringState()}")
|
||||
android.util.Log.i(
|
||||
"fnmsg-call",
|
||||
"sending signal after gathering, sdp=${sdp.length} bytes, " +
|
||||
"state=${connection?.iceGatheringState()}",
|
||||
)
|
||||
send(sdp)
|
||||
}
|
||||
|
||||
/**
|
||||
* ICE servers, P2P-first.
|
||||
*
|
||||
* A relay with no TURN configured answers 503, and that is a supported
|
||||
* deployment rather than an error: STUN alone still gets a direct call
|
||||
* through most NATs. What it cannot do is rescue the minority of networks
|
||||
* that refuse a direct path, those calls simply fail to connect, and
|
||||
* saying so is better than pretending a relay exists.
|
||||
*/
|
||||
private fun iceServers(turn: JSONObject?): List<PeerConnection.IceServer> {
|
||||
if (turn == null) {
|
||||
return listOf(
|
||||
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302")
|
||||
.createIceServer()
|
||||
PeerConnection.IceServer
|
||||
.builder("stun:stun.l.google.com:19302")
|
||||
.createIceServer(),
|
||||
)
|
||||
}
|
||||
val urls = turn.optJSONArray("urls") ?: return emptyList()
|
||||
val username = turn.optString("username")
|
||||
val credential = turn.optString("credential")
|
||||
return (0 until urls.length()).map { i ->
|
||||
PeerConnection.IceServer.builder(urls.getString(i))
|
||||
PeerConnection.IceServer
|
||||
.builder(urls.getString(i))
|
||||
.setUsername(username)
|
||||
.setPassword(credential)
|
||||
.createIceServer()
|
||||
@@ -197,70 +166,73 @@ class CallManager(
|
||||
private fun ensureFactory(): PeerConnectionFactory {
|
||||
factory?.let { return it }
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory.InitializationOptions.builder(context)
|
||||
.createInitializationOptions()
|
||||
PeerConnectionFactory.InitializationOptions
|
||||
.builder(context)
|
||||
.createInitializationOptions(),
|
||||
)
|
||||
return PeerConnectionFactory.builder()
|
||||
// Video codecs even for a voice call: the factory is built once
|
||||
// per process, and a voice-only factory would make the first
|
||||
// video call render nothing while looking connected.
|
||||
.setVideoEncoderFactory(
|
||||
DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
|
||||
return PeerConnectionFactory
|
||||
.builder()
|
||||
// Video codecs even for a voice call: the factory is built once per
|
||||
// process, and a voice-only factory would make the first video call
|
||||
// render nothing while looking connected.
|
||||
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
|
||||
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext))
|
||||
.createPeerConnectionFactory()
|
||||
.also { factory = it }
|
||||
}
|
||||
|
||||
private fun open(turn: JSONObject?, peerId: Long, callId: String,
|
||||
video: Boolean): PeerConnection? {
|
||||
val config = PeerConnection.RTCConfiguration(iceServers(turn)).apply {
|
||||
private fun open(
|
||||
turn: JSONObject?,
|
||||
peerId: Long,
|
||||
callId: String,
|
||||
video: Boolean,
|
||||
): PeerConnection? {
|
||||
val config =
|
||||
PeerConnection.RTCConfiguration(iceServers(turn)).apply {
|
||||
// ALL rather than RELAY: direct paths are tried, and TURN is the
|
||||
// fallback. RELAY would force every call through the server, which
|
||||
// is the opposite of the default this product chose.
|
||||
// fallback.
|
||||
iceTransportsType = PeerConnection.IceTransportsType.ALL
|
||||
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
||||
// Trickle is off: candidates are gathered before the offer is sent,
|
||||
// so signalling is ONE encrypted envelope each way rather than a
|
||||
// stream of them. Slower to connect by a second or two, and far
|
||||
// simpler to reason about over a message channel that is polled
|
||||
// rather than pushed.
|
||||
// stream of them.
|
||||
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
|
||||
}
|
||||
val observer = object : PeerConnection.Observer {
|
||||
val observer =
|
||||
object : PeerConnection.Observer {
|
||||
override fun onIceCandidate(candidate: IceCandidate?) {
|
||||
// The candidate TYPE is the whole diagnosis. `host` only
|
||||
// means we never reached STUN/TURN and cannot cross a NAT;
|
||||
// `relay` means TURN answered and the call can connect even
|
||||
// when a direct path is impossible.
|
||||
// The candidate TYPE is the whole diagnosis.
|
||||
val sdp = candidate?.sdp ?: return
|
||||
val kind = Regex("typ (\\w+)").find(sdp)?.groupValues?.get(1) ?: "?"
|
||||
android.util.Log.i("fnmsg-call", "ice candidate: typ=$kind")
|
||||
}
|
||||
|
||||
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
|
||||
|
||||
override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit
|
||||
// Nothing may throw out of here, and nothing may call back
|
||||
// into WebRTC from here. This runs on WebRTC's signaling thread,
|
||||
// and its JNI asserts RTC_CHECK(!env->ExceptionCheck()) on the way
|
||||
// out, so ANY escaping Kotlin exception aborts the process:
|
||||
// SIGABRT, no AndroidRuntime trace, nothing to catch.
|
||||
|
||||
// Nothing may throw out of here, and nothing may call back into
|
||||
// WebRTC from here.
|
||||
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
|
||||
android.util.Log.i("fnmsg-call", "ice connection -> $newState")
|
||||
runCatching {
|
||||
when (newState) {
|
||||
PeerConnection.IceConnectionState.CONNECTED,
|
||||
PeerConnection.IceConnectionState.COMPLETED -> state = State.ACTIVE
|
||||
// CLOSED is what our OWN close() produces. Treating it as
|
||||
// a reason to hang up re-entered hangUp from inside this
|
||||
// callback, which then did a blocking network send AND
|
||||
// disposed the connection we are currently inside, and
|
||||
// killed the app every single time somebody pressed Hang up.
|
||||
PeerConnection.IceConnectionState.COMPLETED,
|
||||
-> state = State.ACTIVE
|
||||
|
||||
// CLOSED is what our OWN close() produces.
|
||||
PeerConnection.IceConnectionState.CLOSED -> Unit
|
||||
|
||||
PeerConnection.IceConnectionState.FAILED -> hangUp(notifyPeer = true)
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
||||
|
||||
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
|
||||
android.util.Log.i("fnmsg-call", "ice gathering -> $newState")
|
||||
// Hop to the main thread rather than touching the connection
|
||||
@@ -270,13 +242,20 @@ class CallManager(
|
||||
main.post { runCatching { deliverGatheredSdp() } }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddStream(stream: MediaStream?) = Unit
|
||||
|
||||
override fun onRemoveStream(stream: MediaStream?) = Unit
|
||||
|
||||
override fun onDataChannel(channel: DataChannel?) = Unit
|
||||
|
||||
override fun onRenegotiationNeeded() = Unit
|
||||
override fun onAddTrack(receiver: RtpReceiver?, streams: Array<out MediaStream>?) {
|
||||
// The other side's camera. Same JNI rules as above: store the
|
||||
// reference, hop to main, and let the UI attach a renderer.
|
||||
|
||||
override fun onAddTrack(
|
||||
receiver: RtpReceiver?,
|
||||
streams: Array<out MediaStream>?,
|
||||
) {
|
||||
// The other side's camera.
|
||||
runCatching {
|
||||
val track = receiver?.track()
|
||||
if (track is VideoTrack) {
|
||||
@@ -294,12 +273,13 @@ class CallManager(
|
||||
audioSource = source
|
||||
audioTrack = track
|
||||
if (video) {
|
||||
// Failure here downgrades to voice rather than killing the
|
||||
// call, an emulator without a camera, or a denied permission
|
||||
// mid-flow, should still connect and say something.
|
||||
// Failure here downgrades to voice rather than killing the call, an
|
||||
// emulator without a camera, or a denied permission mid-flow,
|
||||
// should still connect and say something.
|
||||
runCatching {
|
||||
val cams = Camera2Enumerator(context)
|
||||
val name = cams.deviceNames.firstOrNull { cams.isFrontFacing(it) }
|
||||
val name =
|
||||
cams.deviceNames.firstOrNull { cams.isFrontFacing(it) }
|
||||
?: cams.deviceNames.firstOrNull()
|
||||
if (name != null) {
|
||||
val cap = cams.createCapturer(name, null)
|
||||
@@ -330,43 +310,46 @@ class CallManager(
|
||||
}
|
||||
|
||||
/** Place a call. The offer is gathered, then sent as one encrypted envelope. */
|
||||
fun call(peerId: Long, turn: JSONObject?, video: Boolean = false) {
|
||||
fun call(
|
||||
peerId: Long,
|
||||
turn: JSONObject?,
|
||||
video: Boolean = false,
|
||||
) {
|
||||
// Supervision: enforced where the call would start, not where the
|
||||
// button is drawn, every entry point hits the same wall.
|
||||
// Backstop only, the view model refuses first, with the message.
|
||||
if (ParentalControls.callsDisabled(context)) return
|
||||
if (state != State.IDLE && state != State.ENDED) return
|
||||
val callId = UUID.randomUUID().toString()
|
||||
// `current` BEFORE the state change. The state setter notifies the
|
||||
// observer, and the observer reads `current` to decide whether there
|
||||
// is a call to draw. DIALLING with a null `current` cleared the
|
||||
// banner the UI had just put up, and the caller's screen showed
|
||||
// nothing until a second tap redrew it (while this guard ate the tap).
|
||||
// `current` BEFORE the state change.
|
||||
current = Active(callId, peerId, incoming = false, video = video)
|
||||
state = State.DIALLING
|
||||
val pc = open(turn, peerId, callId, video) ?: run {
|
||||
val pc =
|
||||
open(turn, peerId, callId, video) ?: run {
|
||||
current = null
|
||||
state = State.IDLE
|
||||
return
|
||||
}
|
||||
pc.createOffer(object : SimpleSdpObserver() {
|
||||
pc.createOffer(
|
||||
object : SimpleSdpObserver() {
|
||||
override fun onCreateSuccess(sdp: SessionDescription) {
|
||||
pc.setLocalDescription(SimpleSdpObserver("offer:setLocal"), sdp)
|
||||
// The complete SDP, with candidates, exists only after
|
||||
// gathering; see onGathered. DIALLING holds until the answer
|
||||
// arrives, so the caller's screen says what is actually true.
|
||||
// gathering; see onGathered.
|
||||
main.post {
|
||||
sendWhenGathered { full ->
|
||||
signal(peerId, Content.CallSignal(
|
||||
Content.CallSignal.OFFER, callId, full, video = video))
|
||||
signal(
|
||||
peerId,
|
||||
Content.CallSignal(Content.CallSignal.OFFER, callId, full, video = video),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, MediaConstraints())
|
||||
// Give up out loud. Without this an unanswered call sat in
|
||||
// "Calling…" forever, and the offer waiting in the callee's mailbox
|
||||
// would ring long after this end had stopped listening.
|
||||
val guard = Runnable {
|
||||
},
|
||||
MediaConstraints(),
|
||||
)
|
||||
// Give up out loud.
|
||||
val guard =
|
||||
Runnable {
|
||||
if (state == State.DIALLING) hangUp(notifyPeer = true, reason = "timeout")
|
||||
}
|
||||
dialGuard = guard
|
||||
@@ -377,47 +360,59 @@ class CallManager(
|
||||
private var pendingOffer: Pair<Long, Content.CallSignal>? = null
|
||||
|
||||
/**
|
||||
* A signal arrived down the ratchet. [sentAt] is when the relay accepted
|
||||
* it (epoch millis, 0 = unknown).
|
||||
* A signal arrived down the ratchet.
|
||||
*/
|
||||
fun onSignal(fromUser: Long, incoming: Content.CallSignal, turn: JSONObject?,
|
||||
sentAt: Long = 0) {
|
||||
android.util.Log.i("fnmsg-call",
|
||||
"call signal in: ${incoming.action} from $fromUser state=$state")
|
||||
fun onSignal(
|
||||
fromUser: Long,
|
||||
incoming: Content.CallSignal,
|
||||
turn: JSONObject?,
|
||||
sentAt: Long = 0,
|
||||
) {
|
||||
android.util.Log.i(
|
||||
"fnmsg-call",
|
||||
"call signal in: ${incoming.action} from $fromUser state=$state",
|
||||
)
|
||||
when (incoming.action) {
|
||||
Content.CallSignal.OFFER -> {
|
||||
// Supervision: a device with calls off does not ring, the
|
||||
// caller is answered busy, like any other unavailable state.
|
||||
if (ParentalControls.callsDisabled(context)) {
|
||||
runCatching {
|
||||
signal(fromUser, Content.CallSignal(
|
||||
Content.CallSignal.END, incoming.callId, reason = "busy"))
|
||||
signal(
|
||||
fromUser,
|
||||
Content.CallSignal(Content.CallSignal.END, incoming.callId, reason = "busy"),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// An offer older than the caller's own patience is not a
|
||||
// call. The caller gives up after DIAL_TIMEOUT_MS; one that
|
||||
// aged in the mailbox while this app was closed would ring for
|
||||
// a call nobody is on the other end of, and answering it
|
||||
// connects to nothing. The view model files it as missed.
|
||||
// An offer older than the caller's own patience is not a call.
|
||||
if (sentAt > 0 && System.currentTimeMillis() - sentAt > DIAL_TIMEOUT_MS) {
|
||||
android.util.Log.i("fnmsg-call",
|
||||
"offer from $fromUser dropped: ${System.currentTimeMillis() - sentAt}ms old")
|
||||
android.util.Log.i(
|
||||
"fnmsg-call",
|
||||
"offer from $fromUser dropped: ${System.currentTimeMillis() - sentAt}ms old",
|
||||
)
|
||||
onStaleOffer?.invoke(fromUser, sentAt)
|
||||
return
|
||||
}
|
||||
// One call at a time. A second offer while busy is declined
|
||||
// rather than silently dropped, so the caller hears something.
|
||||
// One call at a time.
|
||||
if (state != State.IDLE && state != State.ENDED) {
|
||||
signal(fromUser, Content.CallSignal(
|
||||
Content.CallSignal.END, incoming.callId, reason = "busy"))
|
||||
signal(
|
||||
fromUser,
|
||||
Content.CallSignal(Content.CallSignal.END, incoming.callId, reason = "busy"),
|
||||
)
|
||||
return
|
||||
}
|
||||
pendingOffer = fromUser to incoming
|
||||
current = Active(incoming.callId, fromUser, incoming = true,
|
||||
video = incoming.video)
|
||||
current =
|
||||
Active(
|
||||
incoming.callId,
|
||||
fromUser,
|
||||
incoming = true,
|
||||
video = incoming.video,
|
||||
)
|
||||
state = State.RINGING
|
||||
}
|
||||
|
||||
Content.CallSignal.ANSWER -> {
|
||||
val sdp = incoming.sdp ?: return
|
||||
// An answer for some earlier call, a retry raced a hang-up,
|
||||
@@ -432,7 +427,10 @@ class CallManager(
|
||||
SessionDescription(SessionDescription.Type.ANSWER, sdp),
|
||||
)
|
||||
}
|
||||
Content.CallSignal.END -> hangUp(notifyPeer = false)
|
||||
|
||||
Content.CallSignal.END -> {
|
||||
hangUp(notifyPeer = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,29 +438,38 @@ class CallManager(
|
||||
var onStaleOffer: ((Long, Long) -> Unit)? = null
|
||||
|
||||
/** Accept the call that is ringing. */
|
||||
|
||||
/**
|
||||
* Build and send the answer. Split out so it can only be reached from
|
||||
* `setRemoteDescription`'s success callback, the ordering IS the fix.
|
||||
* Build and send the answer.
|
||||
*/
|
||||
private fun answerNow(
|
||||
pc: PeerConnection,
|
||||
fromUser: Long,
|
||||
offer: Content.CallSignal,
|
||||
) {
|
||||
pc.createAnswer(object : SimpleSdpObserver("answer:create") {
|
||||
pc.createAnswer(
|
||||
object : SimpleSdpObserver("answer:create") {
|
||||
override fun onCreateSuccess(answer: SessionDescription) {
|
||||
pc.setLocalDescription(SimpleSdpObserver("answer:setLocal"), answer)
|
||||
// Same as the offer: the answer is complete only after
|
||||
// gathering, and one sent early names no address to reach.
|
||||
main.post {
|
||||
sendWhenGathered { full ->
|
||||
signal(fromUser, Content.CallSignal(
|
||||
Content.CallSignal.ANSWER, offer.callId, full,
|
||||
video = offer.video))
|
||||
signal(
|
||||
fromUser,
|
||||
Content.CallSignal(
|
||||
Content.CallSignal.ANSWER,
|
||||
offer.callId,
|
||||
full,
|
||||
video = offer.video,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, MediaConstraints())
|
||||
},
|
||||
MediaConstraints(),
|
||||
)
|
||||
}
|
||||
|
||||
fun accept(turn: JSONObject?) {
|
||||
@@ -471,23 +478,13 @@ class CallManager(
|
||||
val sdp = offer.sdp ?: return
|
||||
state = State.CONNECTING
|
||||
armConnectGuard()
|
||||
val pc = open(turn, fromUser, offer.callId, offer.video) ?: run {
|
||||
val pc =
|
||||
open(turn, fromUser, offer.callId, offer.video) ?: run {
|
||||
state = State.IDLE
|
||||
return
|
||||
}
|
||||
current = Active(offer.callId, fromUser, incoming = true, video = offer.video)
|
||||
// `createAnswer` waits for the remote description to be APPLIED.
|
||||
// These two were fired back to back: `setRemoteDescription` is
|
||||
// asynchronous, and WebRTC cannot answer an offer it has not finished
|
||||
// reading. When it lost that race `createAnswer` failed,
|
||||
// `setLocalDescription` never ran, and the send path found no local
|
||||
// description and gave up, "NOT SENDING the offer/answer ... the other
|
||||
// side will never see this call". Accepting a call did nothing, the
|
||||
// caller rang on until it timed out, and neither end said why.
|
||||
//
|
||||
// It is a RACE, which is what made it look intermittent: on a quiet
|
||||
// device the description often lands first and the call connects, so
|
||||
// the same build both works and fails depending on load.
|
||||
pc.setRemoteDescription(
|
||||
object : SimpleSdpObserver("answer:setRemote") {
|
||||
override fun onSetSuccess() {
|
||||
@@ -502,8 +499,10 @@ class CallManager(
|
||||
fun decline() {
|
||||
val (fromUser, offer) = pendingOffer ?: return
|
||||
pendingOffer = null
|
||||
signal(fromUser, Content.CallSignal(
|
||||
Content.CallSignal.END, offer.callId, reason = "declined"))
|
||||
signal(
|
||||
fromUser,
|
||||
Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "declined"),
|
||||
)
|
||||
// ENDED while `current` still names the caller, so the observer can
|
||||
// file the declined call, see the ordering note in hangUpInner.
|
||||
state = State.ENDED
|
||||
@@ -513,19 +512,17 @@ class CallManager(
|
||||
|
||||
/**
|
||||
* End the call and release everything.
|
||||
*
|
||||
* The microphone is held by the audio source, not by the connection.
|
||||
* Closing one without disposing the other leaves the mic live with no call
|
||||
* attached, the worst possible failure for this particular feature.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Re-entrancy-safe on purpose. Tearing a connection down makes
|
||||
* WebRTC fire state callbacks, and a callback that calls back in here
|
||||
* would dispose the very object it is running inside.
|
||||
* Re-entrancy-safe on purpose.
|
||||
*/
|
||||
private var tearingDown = false
|
||||
|
||||
fun hangUp(notifyPeer: Boolean, reason: String? = null) {
|
||||
fun hangUp(
|
||||
notifyPeer: Boolean,
|
||||
reason: String? = null,
|
||||
) {
|
||||
if (tearingDown) return
|
||||
tearingDown = true
|
||||
try {
|
||||
@@ -535,7 +532,10 @@ class CallManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun hangUpInner(notifyPeer: Boolean, reason: String?) {
|
||||
private fun hangUpInner(
|
||||
notifyPeer: Boolean,
|
||||
reason: String?,
|
||||
) {
|
||||
onGathered = null
|
||||
gatherGuard?.let { main.removeCallbacks(it) }
|
||||
gatherGuard = null
|
||||
@@ -545,24 +545,28 @@ class CallManager(
|
||||
connectGuard = null
|
||||
val active = current
|
||||
if (notifyPeer && active != null) {
|
||||
// Best effort. Telling the other end is worth doing and is not
|
||||
// worth failing the teardown over, the relay can be rate limiting
|
||||
// us, or simply unreachable.
|
||||
// Best effort.
|
||||
runCatching {
|
||||
signal(active.peerId,
|
||||
Content.CallSignal(Content.CallSignal.END, active.callId, reason = reason))
|
||||
signal(
|
||||
active.peerId,
|
||||
Content.CallSignal(Content.CallSignal.END, active.callId, reason = reason),
|
||||
)
|
||||
}
|
||||
}
|
||||
connection?.let { runCatching { it.close() }; runCatching { it.dispose() } }
|
||||
connection?.let {
|
||||
runCatching { it.close() }
|
||||
runCatching { it.dispose() }
|
||||
}
|
||||
connection = null
|
||||
audioTrack?.let { runCatching { it.dispose() } }
|
||||
audioTrack = null
|
||||
audioSource?.let { runCatching { it.dispose() } }
|
||||
audioSource = null
|
||||
// The camera, released in capture order. Same rule as the mic: the
|
||||
// capturer holds the hardware, not the connection, and a missed
|
||||
// dispose leaves the camera indicator on with no call attached.
|
||||
videoCapturer?.let { runCatching { it.stopCapture() }; runCatching { it.dispose() } }
|
||||
// The camera, released in capture order.
|
||||
videoCapturer?.let {
|
||||
runCatching { it.stopCapture() }
|
||||
runCatching { it.dispose() }
|
||||
}
|
||||
videoCapturer = null
|
||||
videoHelper?.let { runCatching { it.dispose() } }
|
||||
videoHelper = null
|
||||
@@ -572,11 +576,10 @@ class CallManager(
|
||||
remoteVideoTrack = null
|
||||
main.post { runCatching { onVideoTracksChanged?.invoke() } }
|
||||
pendingOffer = null
|
||||
// ENDED fires while `current` still says who the call was with,
|
||||
// the observer files the call log entry from it, and nulling `current`
|
||||
// ENDED fires while `current` still says who the call was with, the
|
||||
// observer files the call log entry from it, and nulling `current`
|
||||
// first (as this used to) meant NO call, answered or missed, was ever
|
||||
// recorded: by the time the state changed there was nobody to file.
|
||||
// IDLE follows with `current` cleared so the UI banner comes down.
|
||||
state = State.ENDED
|
||||
current = null
|
||||
state = State.IDLE
|
||||
@@ -599,9 +602,7 @@ class CallManager(
|
||||
const val CONNECT_TIMEOUT_MS = 30_000L
|
||||
|
||||
/**
|
||||
* How long to wait for ICE gathering before sending what we have. An
|
||||
* unreachable TURN server stalls COMPLETE for tens of seconds; the
|
||||
* host and STUN candidates in hand by now usually still connect.
|
||||
* How long to wait for ICE gathering before sending what we have.
|
||||
*/
|
||||
const val GATHER_TIMEOUT_MS = 8_000L
|
||||
}
|
||||
@@ -609,24 +610,27 @@ class CallManager(
|
||||
|
||||
/**
|
||||
* The offer the background service received, parked for the UI to answer.
|
||||
*
|
||||
* The service could ring but nobody could pick up. The service and the
|
||||
* ViewModel each poll the same mailbox with their OWN session, and whichever
|
||||
* polls first consumes the envelope, so an offer that arrived while the app
|
||||
* was backgrounded was decrypted by the service, turned into a notification,
|
||||
* and its SDP thrown away. Tapping the notification opened an app that had
|
||||
* never seen the offer and had nothing to accept. Both run in the same
|
||||
* process, so the handoff is this holder: the service parks the offer, the
|
||||
* ViewModel collects it on its next poll tick and rings properly.
|
||||
*/
|
||||
object PendingIncomingCall {
|
||||
data class Parked(val fromUser: Long, val signal: Content.CallSignal, val at: Long)
|
||||
data class Parked(
|
||||
val fromUser: Long,
|
||||
val signal: Content.CallSignal,
|
||||
val at: Long,
|
||||
)
|
||||
|
||||
@Volatile private var parked: Parked? = null
|
||||
|
||||
fun park(fromUser: Long, signal: Content.CallSignal, sentAt: Long) {
|
||||
parked = Parked(fromUser, signal,
|
||||
if (sentAt > 0) sentAt else System.currentTimeMillis())
|
||||
fun park(
|
||||
fromUser: Long,
|
||||
signal: Content.CallSignal,
|
||||
sentAt: Long,
|
||||
) {
|
||||
parked =
|
||||
Parked(
|
||||
fromUser,
|
||||
signal,
|
||||
if (sentAt > 0) sentAt else System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/** The caller hung up or the call was answered elsewhere, stop offering it. */
|
||||
@@ -636,19 +640,13 @@ object PendingIncomingCall {
|
||||
|
||||
/**
|
||||
* Drop whatever is parked, whichever call it is.
|
||||
*
|
||||
* For Decline from the lock screen, where the id is not to hand. An
|
||||
* offer left parked would be answered the next time the app opened,
|
||||
* ringing for a call the user turned down an hour ago.
|
||||
*/
|
||||
fun clearAll() { parked = null }
|
||||
fun clearAll() {
|
||||
parked = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether something is parked that is already too old to answer.
|
||||
*
|
||||
* Distinct from [take] on purpose: `take` consumes, and the ring has to be
|
||||
* able to ASK without consuming, a stale check that swallowed a live offer
|
||||
* would drop a call that was still ringing.
|
||||
*/
|
||||
fun isStale(now: Long = System.currentTimeMillis()): Boolean {
|
||||
val current = parked ?: return false
|
||||
@@ -665,23 +663,21 @@ object PendingIncomingCall {
|
||||
}
|
||||
|
||||
/** Most SDP callbacks have nothing to say; this stops four stubs per call site. */
|
||||
|
||||
/**
|
||||
* This discarded every SDP failure, and that is why calls were undebuggable.
|
||||
* `createOffer` or `setLocalDescription` could fail for any reason and nothing
|
||||
* anywhere recorded it, the offer then simply never went out, the callee never
|
||||
* rang, and the database showed no envelope had ever been created. Four hours of
|
||||
* "the call failed" with no cause, because the two callbacks that knew returned
|
||||
* `Unit`.
|
||||
*
|
||||
* A `label` so the log says WHICH step failed. "setLocalDescription failed"
|
||||
* and "createAnswer failed" send you to different places.
|
||||
*/
|
||||
private open class SimpleSdpObserver(private val label: String = "sdp") : SdpObserver {
|
||||
private open class SimpleSdpObserver(
|
||||
private val label: String = "sdp",
|
||||
) : SdpObserver {
|
||||
override fun onCreateSuccess(sdp: SessionDescription) = Unit
|
||||
|
||||
override fun onSetSuccess() = Unit
|
||||
|
||||
override fun onCreateFailure(error: String?) {
|
||||
android.util.Log.w("fnmsg-call", "$label: createOffer/createAnswer FAILED: $error")
|
||||
}
|
||||
|
||||
override fun onSetFailure(error: String?) {
|
||||
android.util.Log.w("fnmsg-call", "$label: setLocal/setRemoteDescription FAILED: $error")
|
||||
}
|
||||
|
||||
@@ -32,21 +32,6 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* A call, as a screen.
|
||||
*
|
||||
* Android had no call screen at all, it had a banner. A line of text
|
||||
* and two `TextButton`s stacked above whatever screen you happened to be on,
|
||||
* so the most attention-demanding thing the app ever does looked like a
|
||||
* notice. iOS has had a full screen throughout; the kit draws one; this is the
|
||||
* parity gap that mattered most, and the one a person is most likely to be
|
||||
* fumbling with under time pressure.
|
||||
*
|
||||
* Plain values, no `CallManager`. Same split iOS uses: presentation that
|
||||
* takes a name and a status can be put in front of the screenshot harness; a
|
||||
* state machine cannot, and a call screen nobody can look at is a call screen
|
||||
* nobody has checked.
|
||||
*
|
||||
* The kit animates a 400pt ring. A static ring pair stands in and says so,
|
||||
* an animation that never resolves is worse than none.
|
||||
*/
|
||||
@Composable
|
||||
fun CallScreenBody(
|
||||
@@ -56,11 +41,20 @@ fun CallScreenBody(
|
||||
/** Shown under the status when this relay has no TURN server. */
|
||||
warning: String? = null,
|
||||
) {
|
||||
val initials = peerName.trim().split(" ").filter { it.isNotBlank() }
|
||||
.take(2).mapNotNull { it.firstOrNull() }.joinToString("").uppercase().ifEmpty { "?" }
|
||||
val initials =
|
||||
peerName
|
||||
.trim()
|
||||
.split(" ")
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull() }
|
||||
.joinToString("")
|
||||
.uppercase()
|
||||
.ifEmpty { "?" }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// Back to the theme's own background: the gradient this used to
|
||||
// avoid painting over went with the purple theme (reverted
|
||||
@@ -72,10 +66,7 @@ fun CallScreenBody(
|
||||
) {
|
||||
Spacer(Modifier.height(Space.xxl))
|
||||
|
||||
// No rings. A pair of translucent `primary` circles sat behind the
|
||||
// avatar as a stand-in for the kit's animation, and against the
|
||||
// gradient they read as banding, two hard edges around the one thing
|
||||
// on screen. The avatar carries the centre on its own.
|
||||
// No rings.
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier.size(128.dp).clip(CircleShape).background(Palette.AvatarFill),
|
||||
@@ -113,9 +104,7 @@ fun CallScreenBody(
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// The kit puts the secondaries on an arc around a 72dp primary. A row
|
||||
// above it reproduces the reading order without pretending to
|
||||
// trigonometry the kit did by hand.
|
||||
// The kit puts the secondaries on an arc around a 72dp primary.
|
||||
val primary = actions.firstOrNull { it.isPrimary }
|
||||
val others = actions.filterNot { it.isPrimary }
|
||||
if (others.isNotEmpty()) {
|
||||
@@ -142,10 +131,14 @@ data class CallAction(
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun CallButton(action: CallAction, diameter: androidx.compose.ui.unit.Dp) {
|
||||
private fun CallButton(
|
||||
action: CallAction,
|
||||
diameter: androidx.compose.ui.unit.Dp,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(diameter)
|
||||
.clip(CircleShape)
|
||||
.background(action.tint ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||
@@ -168,55 +161,101 @@ private fun CallButton(action: CallAction, diameter: androidx.compose.ui.unit.Dp
|
||||
|
||||
/**
|
||||
* Map the live call onto the screen above.
|
||||
*
|
||||
* Returns null when there is no call, so the caller can fall through to the
|
||||
* ordinary tabs. The privacy prompt stays a banner deliberately, it is a
|
||||
* question asked BEFORE a call exists, and a full screen for it would look
|
||||
* like a call that is already happening.
|
||||
*/
|
||||
@Composable
|
||||
fun LiveCallScreen(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn): Boolean {
|
||||
fun LiveCallScreen(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
): Boolean {
|
||||
val call = phase.call ?: return false
|
||||
if (call.state == CallManager.State.IDLE) return false
|
||||
|
||||
val status = when (call.state) {
|
||||
CallManager.State.RINGING ->
|
||||
val status =
|
||||
when (call.state) {
|
||||
CallManager.State.RINGING -> {
|
||||
if (call.incoming) "Incoming call" else "Ringing…"
|
||||
CallManager.State.DIALLING -> "Calling…"
|
||||
CallManager.State.CONNECTING -> "Connecting…"
|
||||
CallManager.State.ACTIVE -> "On a call"
|
||||
else -> "Call ended"
|
||||
}
|
||||
|
||||
val actions = when {
|
||||
call.state == CallManager.State.RINGING && call.incoming -> listOf(
|
||||
CallAction("decline", KitIcon.CallEnd, "Decline",
|
||||
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
|
||||
run = model::declineCall),
|
||||
CallAction("accept", KitIcon.Call, "Answer", isPrimary = true,
|
||||
tint = Palette.Teal, iconColor = Color.White,
|
||||
run = model::acceptCall),
|
||||
CallManager.State.DIALLING -> {
|
||||
"Calling…"
|
||||
}
|
||||
|
||||
CallManager.State.CONNECTING -> {
|
||||
"Connecting…"
|
||||
}
|
||||
|
||||
CallManager.State.ACTIVE -> {
|
||||
"On a call"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"Call ended"
|
||||
}
|
||||
}
|
||||
|
||||
val actions =
|
||||
when {
|
||||
call.state == CallManager.State.RINGING && call.incoming -> {
|
||||
listOf(
|
||||
CallAction(
|
||||
"decline",
|
||||
KitIcon.CallEnd,
|
||||
"Decline",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
iconColor = Color.White,
|
||||
run = model::declineCall,
|
||||
),
|
||||
CallAction(
|
||||
"accept",
|
||||
KitIcon.Call,
|
||||
"Answer",
|
||||
isPrimary = true,
|
||||
tint = Palette.Teal,
|
||||
iconColor = Color.White,
|
||||
run = model::acceptCall,
|
||||
),
|
||||
)
|
||||
call.state == CallManager.State.ACTIVE -> listOf(
|
||||
CallAction("mute", if (call.muted) KitIcon.MicOff else KitIcon.Mic,
|
||||
}
|
||||
|
||||
call.state == CallManager.State.ACTIVE -> {
|
||||
listOf(
|
||||
CallAction(
|
||||
"mute",
|
||||
if (call.muted) KitIcon.MicOff else KitIcon.Mic,
|
||||
if (call.muted) "Unmute" else "Mute",
|
||||
tint = if (call.muted) MaterialTheme.colorScheme.primary else null,
|
||||
iconColor = if (call.muted) Color.White else null,
|
||||
run = { model.setCallMuted(!call.muted) }),
|
||||
CallAction("end", KitIcon.CallEnd, "End", isPrimary = true,
|
||||
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
|
||||
run = model::hangUp),
|
||||
)
|
||||
else -> listOf(
|
||||
CallAction("end", KitIcon.CallEnd, "Cancel", isPrimary = true,
|
||||
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
|
||||
run = model::hangUp),
|
||||
run = { model.setCallMuted(!call.muted) },
|
||||
),
|
||||
CallAction(
|
||||
"end",
|
||||
KitIcon.CallEnd,
|
||||
"End",
|
||||
isPrimary = true,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
iconColor = Color.White,
|
||||
run = model::hangUp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// A video call with anything to show renders surfaces instead of the
|
||||
// ring; the same action row overlays the bottom. Voice calls and video
|
||||
// calls still connecting keep the kit's ring screen.
|
||||
else -> {
|
||||
listOf(
|
||||
CallAction(
|
||||
"end",
|
||||
KitIcon.CallEnd,
|
||||
"Cancel",
|
||||
isPrimary = true,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
iconColor = Color.White,
|
||||
run = model::hangUp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A video call with anything to show renders surfaces instead of the ring;
|
||||
// the same action row overlays the bottom.
|
||||
val manager = model.activeCallManager()
|
||||
val showVideo = call.video && call.state == CallManager.State.ACTIVE && manager != null
|
||||
if (showVideo) {
|
||||
@@ -231,21 +270,21 @@ fun LiveCallScreen(model: MessengerViewModel, phase: MessengerViewModel.Phase.Si
|
||||
peerName = call.peerName,
|
||||
status = status,
|
||||
actions = actions,
|
||||
warning = if (call.relayless && call.state != CallManager.State.ACTIVE) {
|
||||
warning =
|
||||
if (call.relayless && call.state != CallManager.State.ACTIVE) {
|
||||
"This server has no call relay, so the call only connects if a " +
|
||||
"direct path exists between the two networks."
|
||||
} else null,
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The video layout: their camera full-screen, yours in a corner card, the
|
||||
* usual actions along the bottom, the kit's 08 Video Call artboard.
|
||||
*
|
||||
* `videoTick` exists to re-run this when tracks appear: the track objects
|
||||
* live on the manager, not in Compose state, so nothing else would recompose.
|
||||
* The video layout: their camera full-screen, yours in a corner card, the usual
|
||||
* actions along the bottom, the kit's 08 Video Call artboard.
|
||||
*/
|
||||
@Composable
|
||||
private fun VideoCallBody(
|
||||
@@ -259,8 +298,12 @@ private fun VideoCallBody(
|
||||
val local = manager.localVideoTrack
|
||||
if (remote != null) {
|
||||
key(remote, videoTick) {
|
||||
VideoSurface(manager, remote, mirror = false,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
VideoSurface(
|
||||
manager,
|
||||
remote,
|
||||
mirror = false,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
@@ -272,12 +315,18 @@ private fun VideoCallBody(
|
||||
if (local != null) {
|
||||
key(local, videoTick) {
|
||||
VideoSurface(
|
||||
manager, local, mirror = true,
|
||||
modifier = Modifier
|
||||
manager,
|
||||
local,
|
||||
mirror = true,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(Kit.margin)
|
||||
.size(width = 108.dp, height = 144.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)),
|
||||
.clip(
|
||||
androidx.compose.foundation.shape
|
||||
.RoundedCornerShape(12.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -285,8 +334,12 @@ private fun VideoCallBody(
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = Space.xl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(peerName, color = Color.White, fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
peerName,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.lg)) {
|
||||
actions.forEach { CallButton(it, diameter = if (it.isPrimary) 72.dp else 48.dp) }
|
||||
@@ -296,9 +349,7 @@ private fun VideoCallBody(
|
||||
}
|
||||
|
||||
/**
|
||||
* One WebRTC renderer. Init and release travel with the composable, a
|
||||
* SurfaceViewRenderer that outlives its sink leaks the EGL surface, and one
|
||||
* released while still sinked crashes in native code.
|
||||
* One WebRTC renderer.
|
||||
*/
|
||||
@Composable
|
||||
private fun VideoSurface(
|
||||
@@ -324,23 +375,20 @@ private fun VideoSurface(
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A "start a video call" action that asks for the camera first.
|
||||
*
|
||||
* Returns a function of the peer id. The permission launcher must be created
|
||||
* during composition, so this cannot be a plain method on the model, and
|
||||
* asking at the moment of the tap is the only time the question makes sense
|
||||
* to the person being asked.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberVideoCall(model: MessengerViewModel): (Long) -> Unit {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val pending = androidx.compose.runtime.remember {
|
||||
val pending =
|
||||
androidx.compose.runtime.remember {
|
||||
androidx.compose.runtime.mutableStateOf<Long?>(null)
|
||||
}
|
||||
val ask = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
val ask =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts
|
||||
.RequestPermission(),
|
||||
) { granted ->
|
||||
val peer = pending.value
|
||||
pending.value = null
|
||||
@@ -349,8 +397,10 @@ fun rememberVideoCall(model: MessengerViewModel): (Long) -> Unit {
|
||||
if (peer != null && granted) model.requestCall(peer, video = true)
|
||||
}
|
||||
return { peer ->
|
||||
val has = androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.CAMERA
|
||||
val has =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) {
|
||||
model.requestCall(peer, video = true)
|
||||
|
||||
@@ -21,28 +21,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Calls placed and received, the kit's `08 Calls-Listing`.
|
||||
*
|
||||
* Measured out of `item/call/White`: a 48dp avatar, the name at 16pt, and a
|
||||
* second line that is a 12dp direction icon followed by a 12pt time. The kit
|
||||
* puts a video icon at x=255 and a call icon at x=303; only the call icon is
|
||||
* here, because there is no video path in either CallManager and a control
|
||||
* that does nothing is worse than a missing one.
|
||||
*
|
||||
* This screen needed a call log to exist first. Nothing recorded calls
|
||||
* anywhere, there was a live call UI and a "missed call" notification from the
|
||||
* relay, and no history at all, so "who rang me yesterday" had no answer. The
|
||||
* log lives in the vault with everything else: who somebody called and when is
|
||||
* precisely the metadata this app exists to keep off a server, and encrypting
|
||||
* the words while leaving the call log in the clear would be an odd place to
|
||||
* stop.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.CallsScreen(
|
||||
@@ -50,7 +36,8 @@ fun ColumnScope.CallsScreen(
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
val videoCall = rememberVideoCall(model)
|
||||
val showNewCall = androidx.compose.runtime.remember {
|
||||
val showNewCall =
|
||||
androidx.compose.runtime.remember {
|
||||
androidx.compose.runtime.mutableStateOf(false)
|
||||
}
|
||||
|
||||
@@ -64,13 +51,13 @@ fun ColumnScope.CallsScreen(
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// The way IN. Every other path to a call runs through an open
|
||||
// conversation, this tab's own empty state used to admit as much.
|
||||
// The way IN.
|
||||
Icon(
|
||||
KitIcon.Call,
|
||||
contentDescription = "New call",
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.clickable { showNewCall.value = true }
|
||||
.testTag("new-call"),
|
||||
@@ -88,13 +75,13 @@ fun ColumnScope.CallsScreen(
|
||||
Column {
|
||||
phase.buddies.forEach { buddy ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
showNewCall.value = false
|
||||
model.requestCall(buddy.userId)
|
||||
}
|
||||
.padding(vertical = 10.dp)
|
||||
}.padding(vertical = 10.dp)
|
||||
.testTag("new-call-${buddy.userId}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -104,20 +91,24 @@ fun ColumnScope.CallsScreen(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(KitIcon.Call, contentDescription = null,
|
||||
tint = Palette.Accent, modifier = Modifier.size(20.dp))
|
||||
Icon(
|
||||
KitIcon.Call,
|
||||
contentDescription = null,
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(Space.md))
|
||||
Icon(
|
||||
KitIcon.Video,
|
||||
contentDescription = "Video call",
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(22.dp)
|
||||
.clickable {
|
||||
showNewCall.value = false
|
||||
videoCall(buddy.userId)
|
||||
}
|
||||
.testTag("new-video-call-${buddy.userId}"),
|
||||
}.testTag("new-video-call-${buddy.userId}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -133,9 +124,7 @@ fun ColumnScope.CallsScreen(
|
||||
}
|
||||
|
||||
if (phase.calls.isEmpty()) {
|
||||
// Says what would appear here. An empty screen with no explanation
|
||||
// is a feature nobody discovers, and this one has no other entry point
|
||||
// calls start from a conversation.
|
||||
// Says what would appear here.
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xxl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -149,10 +138,10 @@ fun ColumnScope.CallsScreen(
|
||||
)
|
||||
Text("No calls yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
"Calls you place and receive appear here. Start one from a "
|
||||
+ "conversation, they go directly between the two phones, "
|
||||
+ "so this list is the only record of them and it is on "
|
||||
+ "this device.",
|
||||
"Calls you place and receive appear here. Start one from a " +
|
||||
"conversation, they go directly between the two phones, " +
|
||||
"so this list is the only record of them and it is on " +
|
||||
"this device.",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -162,11 +151,14 @@ fun ColumnScope.CallsScreen(
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().testTag("calls")) {
|
||||
phase.calls.forEach { entry ->
|
||||
val name = phase.buddies.firstOrNull { it.userId == entry.peerId }
|
||||
val name =
|
||||
phase.buddies
|
||||
.firstOrNull { it.userId == entry.peerId }
|
||||
?.let { it.displayName.ifBlank { it.handle } }
|
||||
?: "user ${entry.peerId}"
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.requestCall(entry.peerId) }
|
||||
.padding(vertical = Kit.rowGap / 2)
|
||||
@@ -189,9 +181,9 @@ fun ColumnScope.CallsScreen(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// The direction icon the kit puts before the time,
|
||||
// and the one thing that makes a missed call findable:
|
||||
// an incoming call that never connected is the row
|
||||
// The direction icon the kit puts before the time, and
|
||||
// the one thing that makes a missed call findable: an
|
||||
// incoming call that never connected is the row
|
||||
// somebody is actually looking for.
|
||||
Icon(
|
||||
if (entry.connected) KitIcon.Call else KitIcon.CallEnd,
|
||||
@@ -215,7 +207,8 @@ fun ColumnScope.CallsScreen(
|
||||
}
|
||||
// Ring them back, voice, or camera-first.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.background(Palette.AccentSoft)
|
||||
@@ -232,7 +225,8 @@ fun ColumnScope.CallsScreen(
|
||||
}
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.background(Palette.AccentSoft)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,16 +38,6 @@ import io.flamenet.client.MessengerApi
|
||||
/**
|
||||
* One contact, as the kit draws it: a cover, a name plate, and a rail of
|
||||
* actions floating at the seam between them.
|
||||
*
|
||||
* Android had no contact screen at all. Everything you could do to a
|
||||
* person was an inline row that unfolded under their list entry. Block, Clear
|
||||
* chat, Remove, three text buttons in a line, with no room for anything that
|
||||
* needed explaining and no place to put a safety number. iOS has the kit's
|
||||
* version; this is the same screen.
|
||||
*
|
||||
* The rail carries only actions that do something. The kit's has a
|
||||
* "personal page" icon; there is no such page here, and a fourth control that
|
||||
* opens nothing is worse than a rail of three.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.ContactDetail(
|
||||
@@ -59,9 +49,17 @@ fun ColumnScope.ContactDetail(
|
||||
var confirmingRemove by remember { mutableStateOf(false) }
|
||||
val isBlocked = buddy.userId in phase.blocked
|
||||
val name = buddy.displayName.ifBlank { buddy.handle }.ifBlank { "user ${buddy.userId}" }
|
||||
val initials = remember(name) {
|
||||
name.trim().split(" ").filter { it.isNotBlank() }
|
||||
.take(2).mapNotNull { it.firstOrNull() }.joinToString("").uppercase().ifEmpty { "?" }
|
||||
val initials =
|
||||
remember(name) {
|
||||
name
|
||||
.trim()
|
||||
.split(" ")
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull() }
|
||||
.joinToString("")
|
||||
.uppercase()
|
||||
.ifEmpty { "?" }
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -69,7 +67,8 @@ fun ColumnScope.ContactDetail(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClose)
|
||||
@@ -89,20 +88,17 @@ fun ColumnScope.ContactDetail(
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
// The cover. A photo in the kit; we hold no avatars, so it is this
|
||||
// person's initials on a wash of the accent, colourless, but at
|
||||
// least theirs, rather than a stock face standing in for them.
|
||||
// The cover.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Their picture if they have sent us one; their initials if
|
||||
// not. Initials are the normal case, not a placeholder, a
|
||||
// picture only exists once somebody has set one AND messaged
|
||||
// you, so most covers will be letters and that is fine.
|
||||
// not.
|
||||
val picture = phase.avatars[buddy.userId]
|
||||
if (picture != null) {
|
||||
AttachedImage(picture, model)
|
||||
@@ -118,7 +114,8 @@ fun ColumnScope.ContactDetail(
|
||||
|
||||
// Name plate: 60 tall, radius 16, on the kit's neutral fill.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
@@ -128,8 +125,8 @@ fun ColumnScope.ContactDetail(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(name, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
// Presence goes on THIS line, not the trailing edge,
|
||||
// the rail floats over the plate's right-hand third, so
|
||||
// Presence goes on THIS line, not the trailing edge, the
|
||||
// rail floats over the plate's right-hand third, so
|
||||
// anything put there is behind it.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (buddy.presence.equals("online", true)) {
|
||||
@@ -138,7 +135,8 @@ fun ColumnScope.ContactDetail(
|
||||
}
|
||||
Text(
|
||||
listOfNotNull(
|
||||
buddy.handle.takeIf { it.isNotBlank() && !it.equals(name, true) }
|
||||
buddy.handle
|
||||
.takeIf { it.isNotBlank() && !it.equals(name, true) }
|
||||
?.let { "@$it" },
|
||||
buddy.presence.takeIf { it.isNotBlank() },
|
||||
).joinToString(" · "),
|
||||
@@ -156,17 +154,19 @@ fun ColumnScope.ContactDetail(
|
||||
// The rail, overlapping the seam on purpose, that is what stops the
|
||||
// cover reading as a dead banner.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 120.dp, end = Kit.margin)
|
||||
.width(60.dp)
|
||||
.clip(RoundedCornerShape(30.dp))
|
||||
.background(
|
||||
if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary
|
||||
if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
RailButton(KitIcon.Chats, "Message", "contact-message", enabled = !isBlocked) {
|
||||
model.openChat(buddy.userId, buddy.displayName); onClose()
|
||||
model.openChat(buddy.userId, buddy.displayName)
|
||||
onClose()
|
||||
}
|
||||
RailDivider()
|
||||
RailButton(KitIcon.Call, "Call", "contact-call", enabled = !isBlocked) {
|
||||
@@ -196,19 +196,26 @@ fun ColumnScope.ContactDetail(
|
||||
"Clear this conversation",
|
||||
"Removes it from this phone only. Their copy is theirs.",
|
||||
tag = "contact-clear",
|
||||
) { model.clearHistory(io.flamenet.e2e.Vault.threadForUser(buddy.userId)) }
|
||||
) {
|
||||
model.clearHistory(
|
||||
io.flamenet.e2e.Vault
|
||||
.threadForUser(buddy.userId),
|
||||
)
|
||||
}
|
||||
|
||||
// Confirms in place rather than in a dialog. A dialog is dismissed
|
||||
// reflexively; a row that changes to say what it is about to do is
|
||||
// read. Same posture as the Account screen's two destructive rows.
|
||||
// Confirms in place rather than in a dialog.
|
||||
DetailRow(
|
||||
if (confirmingRemove) "Really remove, this cannot be undone" else "Remove contact",
|
||||
"Takes them off your list. It does not block them.",
|
||||
tag = "contact-remove",
|
||||
danger = true,
|
||||
) {
|
||||
if (confirmingRemove) { model.removeBuddy(buddy.userId); onClose() }
|
||||
else confirmingRemove = true
|
||||
if (confirmingRemove) {
|
||||
model.removeBuddy(buddy.userId)
|
||||
onClose()
|
||||
} else {
|
||||
confirmingRemove = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,7 +234,8 @@ private fun RailButton(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(60.dp)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.semantics { contentDescription = label }
|
||||
@@ -252,7 +260,8 @@ private fun DetailRow(
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier)
|
||||
|
||||
@@ -22,12 +22,6 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* How this phone draws things.
|
||||
*
|
||||
* Named for what they do. iOS shipped "Show online status" on a Privacy
|
||||
* & Security screen, where every other messenger uses those words for the
|
||||
* reciprocal thing; somebody switching it off to disappear would have been
|
||||
* wrong, because it only hides other people's dots from their own phone. It was
|
||||
* renamed on 2026-09-06 and this section is named that way from the start.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.DisplaySection() {
|
||||
@@ -41,25 +35,36 @@ fun ColumnScope.DisplaySection() {
|
||||
Text("Display", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
@Composable
|
||||
fun row(label: String, tag: String, value: Boolean, onChange: (Boolean) -> Unit) {
|
||||
fun row(
|
||||
label: String,
|
||||
tag: String,
|
||||
value: Boolean,
|
||||
onChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, fontSize = 16.sp, modifier = Modifier.weight(1f))
|
||||
Switch(checked = value, onCheckedChange = onChange,
|
||||
modifier = Modifier.testTag(tag))
|
||||
Switch(
|
||||
checked = value,
|
||||
onCheckedChange = onChange,
|
||||
modifier = Modifier.testTag(tag),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
row("Show whether contacts are online", "display-online", online) { on ->
|
||||
settings.showOnlineStatus = on; online = on
|
||||
settings.showOnlineStatus = on
|
||||
online = on
|
||||
}
|
||||
row("Show the time on messages", "display-timestamps", times) { on ->
|
||||
settings.showTimestamps = on; times = on
|
||||
settings.showTimestamps = on
|
||||
times = on
|
||||
}
|
||||
row("Larger message text", "display-larger-type", larger) { on ->
|
||||
settings.largerType = on; larger = on
|
||||
settings.largerType = on
|
||||
larger = on
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
@@ -68,7 +73,10 @@ fun ColumnScope.DisplaySection() {
|
||||
DisplaySettings.Appearance.entries.forEach { option ->
|
||||
KitSecondaryButton(
|
||||
text = if (option == appearance) "✓ ${option.label}" else option.label,
|
||||
onClick = { settings.appearance = option; appearance = option },
|
||||
onClick = {
|
||||
settings.appearance = option
|
||||
appearance = option
|
||||
},
|
||||
modifier = Modifier.weight(1f).padding(end = Space.xs),
|
||||
tag = "display-appearance-${option.stored}",
|
||||
)
|
||||
@@ -76,13 +84,13 @@ fun ColumnScope.DisplaySection() {
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
// Says the thing the label alone cannot: this hides them from you, not
|
||||
// you from them.
|
||||
// Says the thing the label alone cannot: this hides them from you, not you
|
||||
// from them.
|
||||
Text(
|
||||
"These change what this phone shows you. Your contacts are still told when "
|
||||
+ "you are online whichever way this is set, hiding yourself is not "
|
||||
+ "something the app can do yet.\n\nAppearance takes effect next time the "
|
||||
+ "app starts.",
|
||||
"These change what this phone shows you. Your contacts are still told when " +
|
||||
"you are online whichever way this is set, hiding yourself is not " +
|
||||
"something the app can do yet.\n\nAppearance takes effect next time the " +
|
||||
"app starts.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
|
||||
@@ -3,23 +3,11 @@ package io.flamenet.messenger.app
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* How this phone draws things. Preferences, not privacy.
|
||||
*
|
||||
* None of these change what anybody else sees. "Show whether contacts
|
||||
* are online" hides the dot on THIS screen; the relay still tells your contacts
|
||||
* when you are online, exactly as before. iOS shipped the same three settings
|
||||
* with names, "Show online status", "Show last seen", that every other
|
||||
* messenger uses for the reciprocal thing, on a screen called Privacy &
|
||||
* Security, and somebody switching them off to disappear would have been wrong.
|
||||
* They were renamed on 2026-09-06; these are named that way from the start.
|
||||
*
|
||||
* There is no last-seen setting here, because Android does not show last
|
||||
* seen at all. iOS does. That is a missing feature rather than a missing
|
||||
* toggle, and adding a switch for something the app never draws would be the
|
||||
* exact decoration the parity scrub exists to catch.
|
||||
* How this phone draws things.
|
||||
*/
|
||||
class DisplaySettings(context: Context) {
|
||||
|
||||
class DisplaySettings(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/** Draw the presence dot on avatars. Hides theirs from you, not you from them. */
|
||||
@@ -39,39 +27,33 @@ class DisplaySettings(context: Context) {
|
||||
|
||||
/**
|
||||
* Light, dark, or whatever the phone is set to.
|
||||
*
|
||||
* Stored as a string and clamped on read. An unrecognised value, from a
|
||||
* future build, or a corrupted preference, reads as `system`, which is
|
||||
* what somebody who never touched this setting has.
|
||||
*/
|
||||
var appearance: Appearance
|
||||
get() = Appearance.from(prefs.getString(APPEARANCE, null))
|
||||
set(value) = prefs.edit().putString(APPEARANCE, value.stored).apply()
|
||||
|
||||
enum class Appearance(val stored: String, val label: String) {
|
||||
enum class Appearance(
|
||||
val stored: String,
|
||||
val label: String,
|
||||
) {
|
||||
SYSTEM("system", "Match the phone"),
|
||||
LIGHT("light", "Light"),
|
||||
DARK("dark", "Dark");
|
||||
DARK("dark", "Dark"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun from(stored: String?) =
|
||||
entries.firstOrNull { it.stored == stored } ?: SYSTEM
|
||||
fun from(stored: String?) = entries.firstOrNull { it.stored == stored } ?: SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The message font size, in sp.
|
||||
*
|
||||
* Pure and lifted out so it can be asserted, for the same reason
|
||||
* `NotificationSettings.messageText` is: a size computed inline in a
|
||||
* `Text(...)` is a number nothing can test.
|
||||
*/
|
||||
/**
|
||||
* The base is 16, not a number of my choosing: the kit specifies
|
||||
* 16sp for a message body ("Body Text 2"), and `ChatScreens` says so at
|
||||
* the point it draws one. A setting that quietly restyled every message
|
||||
* when it was switched OFF would be a worse bug than the one it fixes.
|
||||
* The base is 16, not a number of my choosing: the kit specifies 16sp
|
||||
* for a message body ("Body Text 2"), and `ChatScreens` says so at the
|
||||
* point it draws one.
|
||||
*/
|
||||
fun messageTextSize(largerType: Boolean): Int = if (largerType) 20 else 16
|
||||
|
||||
|
||||
@@ -41,24 +41,6 @@ import java.util.UUID
|
||||
|
||||
/**
|
||||
* Files, on this phone.
|
||||
*
|
||||
* Local-first. Nothing here talks to a server. Files are encrypted into
|
||||
* a vault in this app's own container, and the only thing that ever leaves the
|
||||
* device is a transfer somebody asks for, where the relay is a courier holding
|
||||
* ciphertext until the other device collects it, and then not.
|
||||
*
|
||||
* What that changed, and the words this screen must not reuse. When the
|
||||
* rows lived in Postgres, read-down was a database predicate: the server would
|
||||
* not return a row above your clearance whatever the client asked. There is no
|
||||
* database now. Labels still gate decryption completely, no label key, no
|
||||
* plaintext, but "what you're cleared for" now means *the keys this phone
|
||||
* holds*, not *what a server will show you*. Those are different claims and only
|
||||
* one of them is still true, so the screen says the true one.
|
||||
*
|
||||
* It asks for a passphrase, and that is not friction to be optimised away.
|
||||
* Messenger deliberately keeps no copy of one, and the Files identity is
|
||||
* protected with it, which is what makes a borrowed, unlocked phone still not
|
||||
* open somebody's files.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.FilesScreen(
|
||||
@@ -67,8 +49,12 @@ fun ColumnScope.FilesScreen(
|
||||
) {
|
||||
val files = phase.files ?: MessengerViewModel.FilesState()
|
||||
|
||||
Text("Files", fontWeight = FontWeight.SemiBold, fontSize = 22.sp,
|
||||
modifier = Modifier.padding(bottom = Space.xs))
|
||||
Text(
|
||||
"Files",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
modifier = Modifier.padding(bottom = Space.xs),
|
||||
)
|
||||
|
||||
when {
|
||||
!files.hasIdentity -> FilesSetUp(model, files)
|
||||
@@ -88,7 +74,8 @@ private fun ColumnScope.FilesMessages(
|
||||
val message = files.problem ?: files.notice ?: return
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(if (files.problem != null) Palette.AccentSoft else Palette.SurfaceAlt)
|
||||
@@ -96,19 +83,18 @@ private fun ColumnScope.FilesMessages(
|
||||
.padding(Space.md)
|
||||
.testTag("files-message"),
|
||||
) {
|
||||
Text(message, fontSize = 13.sp,
|
||||
color = if (files.problem != null) Palette.Danger else Palette.SecondaryText)
|
||||
Text(
|
||||
message,
|
||||
fontSize = 13.sp,
|
||||
color = if (files.problem != null) Palette.Danger else Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Setting up ----------------------------------------------------------
|
||||
// ---- Setting up ----------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The one-way door is named before it is walked through, not after.
|
||||
* Forgetting this passphrase does not lock somebody out temporarily, the label
|
||||
* keys are derived from the identity it protects and exist nowhere else, so
|
||||
* every vaulted file becomes permanently unreadable. A screen that discovers
|
||||
* that for somebody afterwards has failed at the only moment it mattered.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.FilesSetUp(
|
||||
@@ -118,34 +104,28 @@ private fun ColumnScope.FilesSetUp(
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
var again by remember { mutableStateOf("") }
|
||||
|
||||
// "A passphrase of their own" told people to invent a second secret,
|
||||
// and iOS never did. iOS says "your passphrase" and takes whatever is
|
||||
// typed, so a person naturally uses their Flamenet one and it simply
|
||||
// works. Android's copy sent them off to make up something else, then
|
||||
// asked for it again weeks later, and forgetting it is the one-way door
|
||||
// described above this function.
|
||||
//
|
||||
// Nothing about the derivation changes. This is still a free-choice
|
||||
// string protecting the identity record, so anyone who already set a
|
||||
// different one keeps opening their files with it. Making the account
|
||||
// passphrase the ACTUAL key would be a migration with permanent data loss
|
||||
// on the far side of any mistake, and it is not what this fixes.
|
||||
// "A passphrase of their own" told people to invent a second secret, and
|
||||
// iOS never did. iOS says "your passphrase" and takes whatever is typed, so
|
||||
// a person naturally uses their Flamenet one and it simply works.
|
||||
Text(
|
||||
"Use your Flamenet passphrase, the one you sign in with. Files on "
|
||||
+ "this phone are encrypted with it, and Flamenet keeps no copy, so "
|
||||
+ "nobody can open them by picking up your unlocked phone.",
|
||||
fontSize = 14.sp, color = Palette.SecondaryText,
|
||||
"Use your Flamenet passphrase, the one you sign in with. Files on " +
|
||||
"this phone are encrypted with it, and Flamenet keeps no copy, so " +
|
||||
"nobody can open them by picking up your unlocked phone.",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
"If you forget it, the files are gone for good, there's no reset, "
|
||||
+ "because there's nothing anywhere that could do one.",
|
||||
fontSize = 13.sp, color = Palette.Warning,
|
||||
"If you forget it, the files are gone for good, there's no reset, " +
|
||||
"because there's nothing anywhere that could do one.",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.Warning,
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
OutlinedTextField(
|
||||
value = passphrase, onValueChange = { passphrase = it },
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text("Your Flamenet passphrase") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
@@ -153,13 +133,12 @@ private fun ColumnScope.FilesSetUp(
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = again, onValueChange = { again = it },
|
||||
value = again,
|
||||
onValueChange = { again = it },
|
||||
label = { Text("Type it again") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
// Confirmed rather than trusted. Everywhere else a typo means one
|
||||
// failed sign-in; here it means a vault nobody can ever open, and the
|
||||
// person would not find out until the files were already in it.
|
||||
// Confirmed rather than trusted.
|
||||
isError = again.isNotEmpty() && again != passphrase,
|
||||
modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase-again"),
|
||||
)
|
||||
@@ -180,14 +159,16 @@ private fun ColumnScope.FilesUnlock(
|
||||
) {
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
|
||||
// Not "your Files passphrase". That name implied a second secret even
|
||||
// for somebody who had used their Flamenet one, and left them hunting for
|
||||
// a passphrase they never set.
|
||||
Text("Enter your Flamenet passphrase to open them.",
|
||||
fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
// Not "your Files passphrase".
|
||||
Text(
|
||||
"Enter your Flamenet passphrase to open them.",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
OutlinedTextField(
|
||||
value = passphrase, onValueChange = { passphrase = it },
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text("Passphrase") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
@@ -203,7 +184,7 @@ private fun ColumnScope.FilesUnlock(
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Browsing ------------------------------------------------------------
|
||||
// ---- Browsing ------------------------------------------------------------.
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.FilesBrowser(
|
||||
@@ -215,11 +196,10 @@ private fun ColumnScope.FilesBrowser(
|
||||
var namingFolder by remember { mutableStateOf(false) }
|
||||
|
||||
/**
|
||||
* `OpenDocument`, never `GetContent`. `GetContent` returns a URI whose
|
||||
* read grant dies with this process, so a linked file works all session and
|
||||
* is broken tomorrow, and whoever added it watched it work.
|
||||
* `OpenDocument`, never `GetContent`.
|
||||
*/
|
||||
val pick = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
val pick =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
val saf = SafReferences(context)
|
||||
pending = PendingFile(uri, saf.displayName(uri) ?: "Untitled")
|
||||
@@ -229,12 +209,11 @@ private fun ColumnScope.FilesBrowser(
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
FilesBreadcrumb(model, files)
|
||||
|
||||
// Folders show in BOTH sections. A folder has no storage of its own, it
|
||||
// is a place, not a file, and hiding them from the linked section would
|
||||
// make anything filed inside one unreachable from there.
|
||||
// Folders show in BOTH sections.
|
||||
val rows = files.entries.filter { it.isFolder || it.storage == files.section }
|
||||
if (rows.isEmpty()) {
|
||||
val empty = if (files.section == LocalVault.Storage.VAULT) {
|
||||
val empty =
|
||||
if (files.section == LocalVault.Storage.VAULT) {
|
||||
"Nothing in the vault here yet. Anything you add is encrypted on this phone."
|
||||
} else {
|
||||
"No linked files here. A link points at a file where it already is, " +
|
||||
@@ -253,12 +232,14 @@ private fun ColumnScope.FilesBrowser(
|
||||
text = "Add a file",
|
||||
// Everything: the vault does not care what a file is.
|
||||
onClick = { pick.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.weight(1f), tag = "files-add",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-add",
|
||||
)
|
||||
KitSecondaryButton(
|
||||
text = "New folder",
|
||||
onClick = { namingFolder = true },
|
||||
modifier = Modifier.weight(1f), tag = "files-new-folder",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-new-folder",
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
@@ -266,20 +247,23 @@ private fun ColumnScope.FilesBrowser(
|
||||
KitSecondaryButton(
|
||||
text = if (files.selecting) "Done" else "Select",
|
||||
onClick = { model.filesToggleSelecting() },
|
||||
modifier = Modifier.weight(1f), tag = "files-select",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-select",
|
||||
)
|
||||
if (files.selecting) {
|
||||
KitSecondaryButton(
|
||||
text = "Delete ${files.selected.size}",
|
||||
onClick = { model.filesDeleteSelected() },
|
||||
enabled = files.selected.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f), tag = "files-delete",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-delete",
|
||||
)
|
||||
}
|
||||
KitSecondaryButton(
|
||||
text = "Lock",
|
||||
onClick = { model.lockFiles() },
|
||||
modifier = Modifier.weight(1f), tag = "files-lock",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-lock",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -296,19 +280,25 @@ private fun ColumnScope.FilesBrowser(
|
||||
AddFileSheet(
|
||||
file = file,
|
||||
onCancel = { pending = null },
|
||||
onVault = { name, level -> pending = null; model.filesAdd(file.uri, name, level) },
|
||||
onLink = { name, level -> pending = null; model.filesReference(file.uri, name, level) },
|
||||
onVault = { name, level ->
|
||||
pending = null
|
||||
model.filesAdd(file.uri, name, level)
|
||||
},
|
||||
onLink = { name, level ->
|
||||
pending = null
|
||||
model.filesReference(file.uri, name, level)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class PendingFile(val uri: android.net.Uri, val name: String)
|
||||
private data class PendingFile(
|
||||
val uri: android.net.Uri,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Two sections, not one mixed list. The vault and the links are not
|
||||
* equally protected, one is ciphertext in this app's container, the other is
|
||||
* somebody's own file with a note attached, and a list that interleaved them
|
||||
* would be a list where "encrypted" is invisible.
|
||||
* Two sections, not one mixed list.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.FilesSectionSwitch(
|
||||
@@ -341,14 +331,23 @@ private fun ColumnScope.FilesBreadcrumb(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Files", fontSize = 13.sp, color = Palette.Accent,
|
||||
modifier = Modifier.clickable { model.filesGoTo(0) }.testTag("files-crumb-root"))
|
||||
Text(
|
||||
"Files",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.Accent,
|
||||
modifier = Modifier.clickable { model.filesGoTo(0) }.testTag("files-crumb-root"),
|
||||
)
|
||||
files.breadcrumb.forEachIndexed { index, crumb ->
|
||||
Text(" › ", fontSize = 13.sp, color = Palette.SecondaryText)
|
||||
Text(
|
||||
crumb.name, fontSize = 13.sp,
|
||||
color = if (index == files.breadcrumb.lastIndex) Palette.SecondaryText
|
||||
else Palette.Accent,
|
||||
crumb.name,
|
||||
fontSize = 13.sp,
|
||||
color =
|
||||
if (index == files.breadcrumb.lastIndex) {
|
||||
Palette.SecondaryText
|
||||
} else {
|
||||
Palette.Accent
|
||||
},
|
||||
modifier = Modifier.clickable { model.filesGoTo(index + 1) },
|
||||
)
|
||||
}
|
||||
@@ -362,12 +361,12 @@ private fun ColumnScope.FileRow(
|
||||
entry: LocalVault.Entry,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = entry.isFolder && !files.selecting) {
|
||||
model.filesOpenFolder(entry)
|
||||
}
|
||||
.padding(vertical = Space.sm)
|
||||
}.padding(vertical = Space.sm)
|
||||
.testTag("file-row"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -381,32 +380,36 @@ private fun ColumnScope.FileRow(
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
// A row this phone cannot read is still SHOWN, with its
|
||||
// classification. Hiding it would leave somebody unable to see
|
||||
// that anything is there at all, and the label is not a secret;
|
||||
// it has to be readable on a row you cannot open.
|
||||
// classification.
|
||||
text = entry.name ?: "Encrypted for someone else",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (entry.isFolder) FontWeight.Medium else FontWeight.Normal,
|
||||
color = if (entry.name == null) Palette.SecondaryText else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
FileLevels.levelName(entry.label.classification),
|
||||
fontSize = 11.sp, color = Palette.SecondaryText,
|
||||
fontSize = 11.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
if (entry.isMissing) {
|
||||
Text(" · file has moved", fontSize = 11.sp, color = Palette.Warning)
|
||||
} else if (!entry.isFolder && entry.byteCount > 0) {
|
||||
Text(" · ${bytes(entry.byteCount)}",
|
||||
fontSize = 11.sp, color = Palette.SecondaryText)
|
||||
Text(
|
||||
" · ${bytes(entry.byteCount)}",
|
||||
fontSize = 11.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Promoting a link is the one per-row action, and only where it means
|
||||
// something.
|
||||
if (!files.selecting && entry.storage == LocalVault.Storage.REFERENCE
|
||||
&& !entry.isFolder && !entry.isMissing) {
|
||||
if (!files.selecting && entry.storage == LocalVault.Storage.REFERENCE &&
|
||||
!entry.isFolder && !entry.isMissing
|
||||
) {
|
||||
KitSecondaryButton(
|
||||
text = "Encrypt",
|
||||
onClick = { model.filesEncrypt(entry.id) },
|
||||
@@ -416,19 +419,18 @@ private fun ColumnScope.FileRow(
|
||||
}
|
||||
}
|
||||
|
||||
private fun bytes(count: Int): String = when {
|
||||
private fun bytes(count: Int): String =
|
||||
when {
|
||||
count >= 1_000_000 -> "%.1f MB".format(count / 1_000_000.0)
|
||||
count >= 1_000 -> "%.0f kB".format(count / 1_000.0)
|
||||
else -> "$count bytes"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Sheets --------------------------------------------------------------
|
||||
// ---- Sheets --------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The choice between copying and linking is made here, with both consequences
|
||||
* written out, rather than by a default somebody discovers later. They are not
|
||||
* two ways of doing one thing: one produces ciphertext this app owns, the other
|
||||
* produces a pointer at a file it does not.
|
||||
* written out, rather than by a default somebody discovers later.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.AddFileSheet(
|
||||
@@ -442,7 +444,8 @@ private fun ColumnScope.AddFileSheet(
|
||||
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
@@ -452,8 +455,10 @@ private fun ColumnScope.AddFileSheet(
|
||||
Text("Add a file", fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = name, onValueChange = { name = it },
|
||||
label = { Text("Name") }, singleLine = true,
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("files-add-name"),
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
@@ -461,32 +466,40 @@ private fun ColumnScope.AddFileSheet(
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
Text(
|
||||
"Copy it in and encrypt it. A stolen phone or backup gives up "
|
||||
+ "nothing but ciphertext.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
"Copy it in and encrypt it. A stolen phone or backup gives up " +
|
||||
"nothing but ciphertext.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
KitPrimaryButton(
|
||||
text = "Copy in and encrypt",
|
||||
onClick = { onVault(name.trim().ifEmpty { file.name }, level) },
|
||||
modifier = Modifier.fillMaxWidth(), tag = "files-add-vault",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "files-add-vault",
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text(
|
||||
"Or link to it where it is. Nothing is copied and nothing is "
|
||||
+ "encrypted until you send it, until then the classification "
|
||||
+ "is a note, not a lock.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
"Or link to it where it is. Nothing is copied and nothing is " +
|
||||
"encrypted until you send it, until then the classification " +
|
||||
"is a note, not a lock.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
KitSecondaryButton(
|
||||
text = "Link to it instead",
|
||||
onClick = { onLink(name.trim().ifEmpty { file.name }, level) },
|
||||
modifier = Modifier.fillMaxWidth(), tag = "files-add-link",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "files-add-link",
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(text = "Cancel", onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(), tag = "files-add-cancel")
|
||||
KitSecondaryButton(
|
||||
text = "Cancel",
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "files-add-cancel",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +513,8 @@ private fun ColumnScope.FolderNameSheet(
|
||||
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
@@ -510,8 +524,10 @@ private fun ColumnScope.FolderNameSheet(
|
||||
Text("New folder", fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = name, onValueChange = { name = it },
|
||||
label = { Text("Name") }, singleLine = true,
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("files-folder-name"),
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
@@ -520,25 +536,35 @@ private fun ColumnScope.FolderNameSheet(
|
||||
// Said here because the tree deliberately allows the opposite of what
|
||||
// people assume: a folder's level does not constrain what goes in it.
|
||||
Text(
|
||||
"A folder's level doesn't limit what you put inside it, each file "
|
||||
+ "keeps its own.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
"A folder's level doesn't limit what you put inside it, each file " +
|
||||
"keeps its own.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
KitSecondaryButton(text = "Cancel", onClick = onCancel,
|
||||
modifier = Modifier.weight(1f), tag = "files-folder-cancel")
|
||||
KitSecondaryButton(
|
||||
text = "Cancel",
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-folder-cancel",
|
||||
)
|
||||
KitPrimaryButton(
|
||||
text = "Create", onClick = { onCreate(name, level) },
|
||||
text = "Create",
|
||||
onClick = { onCreate(name, level) },
|
||||
enabled = name.isNotBlank(),
|
||||
modifier = Modifier.weight(1f), tag = "files-folder-create",
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "files-folder-create",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) {
|
||||
private fun ColumnScope.LevelPicker(
|
||||
selected: Short,
|
||||
onPick: (Short) -> Unit,
|
||||
) {
|
||||
Text("Classification", fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Row(
|
||||
@@ -547,7 +573,8 @@ private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) {
|
||||
) {
|
||||
for ((ordinal, name) in FileLevels.LOCAL_LEVELS) {
|
||||
KitChip(
|
||||
text = name, selected = ordinal == selected,
|
||||
text = name,
|
||||
selected = ordinal == selected,
|
||||
onClick = { onPick(ordinal) },
|
||||
tag = "files-level-$ordinal",
|
||||
)
|
||||
@@ -555,14 +582,10 @@ private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Into and out of a conversation --------------------------------------
|
||||
// ---- Into and out of a conversation --------------------------------------.
|
||||
|
||||
/**
|
||||
* What can be attached to a message.
|
||||
*
|
||||
* Two kinds, and they are not variations of one thing: a photo comes from
|
||||
* the gallery and is re-encoded for sending; a document comes from the vault
|
||||
* and carries a classification with it.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.AttachPanel(
|
||||
@@ -571,7 +594,8 @@ fun ColumnScope.AttachPanel(
|
||||
onFiles: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
@@ -579,28 +603,23 @@ fun ColumnScope.AttachPanel(
|
||||
.testTag("attach-panel"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
KitSecondaryButton(text = "Photo", onClick = onPhoto,
|
||||
modifier = Modifier.fillMaxWidth(), tag = "attach-photo")
|
||||
KitSecondaryButton(
|
||||
text = "Photo",
|
||||
onClick = onPhoto,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "attach-photo",
|
||||
)
|
||||
KitSecondaryButton(
|
||||
text = if (model.filesUnlocked()) "From Files" else "From Files (locked)",
|
||||
onClick = onFiles,
|
||||
modifier = Modifier.fillMaxWidth(), tag = "attach-files",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "attach-files",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a document from the vault, from inside a conversation.
|
||||
*
|
||||
* Sending is a downgrade, and the sheet says so before the tap. The
|
||||
* content key travels in the same envelope as the message, so a classification
|
||||
* does not stop the recipient opening it, it goes so their phone files it the
|
||||
* same way. Every word here has to carry that, because the alternative is a
|
||||
* person believing "Restricted" did something on the way.
|
||||
*
|
||||
* Never asks for the Files passphrase. A conversation that prompted for
|
||||
* one would be a conversation that has started collecting one, and Files goes
|
||||
* out of its way not to hold it. Locked says so and points at the Files screen.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.PickFileToSendSheet(
|
||||
@@ -615,7 +634,8 @@ fun ColumnScope.PickFileToSendSheet(
|
||||
val entries = remember(unlocked) { if (unlocked) model.filesForSending() else emptyList() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt)
|
||||
@@ -627,36 +647,35 @@ fun ColumnScope.PickFileToSendSheet(
|
||||
|
||||
if (!unlocked) {
|
||||
Text(
|
||||
"Files is locked. Open Files from your account and unlock it, "
|
||||
+ "then come back, your passphrase isn't asked for here.",
|
||||
fontSize = 13.sp, color = Palette.SecondaryText,
|
||||
"Files is locked. Open Files from your account and unlock it, " +
|
||||
"then come back, your passphrase isn't asked for here.",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
} else if (entries.isEmpty()) {
|
||||
Text("Nothing in your files yet.", fontSize = 13.sp, color = Palette.SecondaryText)
|
||||
} else {
|
||||
Text(
|
||||
"Whoever you send this to will be able to open it. The "
|
||||
+ "classification travels with the file so it's filed the "
|
||||
+ "same way on their phone, it doesn't stop them reading it.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
"Whoever you send this to will be able to open it. The " +
|
||||
"classification travels with the file so it's filed the " +
|
||||
"same way on their phone, it doesn't stop them reading it.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
// The photo button strips location; this one does not
|
||||
// (android#17). A picture attached from the picker is re-encoded
|
||||
// through ImagePrep and video through MediaScrub, so no metadata
|
||||
// container survives. `Session.sendFile` passes the vault's bytes
|
||||
// straight through, EXIF and all, correct for "send this file",
|
||||
// and dangerous only because nothing said which path you were on.
|
||||
// The photo button strips location; this one does not (android#17).
|
||||
Text(
|
||||
"A file is sent exactly as it is, including anything inside "
|
||||
+ "it, like where a photo was taken. The camera button "
|
||||
+ "leaves that out; this doesn't.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
"A file is sent exactly as it is, including anything inside " +
|
||||
"it, like where a photo was taken. The camera button " +
|
||||
"leaves that out; this doesn't.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
for (entry in entries) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onPick(entry) }
|
||||
.padding(vertical = Space.sm)
|
||||
@@ -664,18 +683,27 @@ fun ColumnScope.PickFileToSendSheet(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entry.name ?: "Unnamed", fontSize = 15.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
entry.name ?: "Unnamed",
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
FileLevels.levelName(entry.label.classification),
|
||||
fontSize = 11.sp, color = Palette.SecondaryText,
|
||||
fontSize = 11.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(text = "Cancel", onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(), tag = "pick-file-cancel")
|
||||
KitSecondaryButton(
|
||||
text = "Cancel",
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "pick-file-cancel",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,6 @@ import java.util.Locale
|
||||
|
||||
/**
|
||||
* Export and import of encrypted history backups (D4).
|
||||
*
|
||||
* The relay keeps no history, so a new phone starts empty, this is the
|
||||
* remedy, and the passphrase is deliberately the only key: the app stores
|
||||
* nothing that could open the file, so neither can anyone who takes the file.
|
||||
*/
|
||||
@Composable
|
||||
fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
@@ -49,13 +45,15 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
var working by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val saveLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
val saveLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/octet-stream"),
|
||||
) { uri ->
|
||||
val bytes = pendingExport
|
||||
pendingExport = null
|
||||
if (uri == null || bytes == null) return@rememberLauncherForActivityResult
|
||||
status = runCatching {
|
||||
status =
|
||||
runCatching {
|
||||
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
|
||||
?: error("nothing to write to")
|
||||
}.fold(
|
||||
@@ -64,17 +62,18 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
val openLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
val openLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> if (uri != null) pendingImport = uri }
|
||||
|
||||
Text("History backup", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Your history lives only on this phone, the server keeps none, so a "
|
||||
+ "new phone starts empty. An export is sealed with a passphrase you "
|
||||
+ "choose and can be imported on another Android phone signed into "
|
||||
+ "this account. Lose the passphrase and the backup cannot be "
|
||||
+ "opened, by anyone.",
|
||||
"Your history lives only on this phone, the server keeps none, so a " +
|
||||
"new phone starts empty. An export is sealed with a passphrase you " +
|
||||
"choose and can be imported on another Android phone signed into " +
|
||||
"this account. Lose the passphrase and the backup cannot be " +
|
||||
"opened, by anyone.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -96,16 +95,21 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
}
|
||||
status?.let {
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Text(it, fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("backup-status"))
|
||||
Text(
|
||||
it,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("backup-status"),
|
||||
)
|
||||
}
|
||||
|
||||
if (showExportDialog) {
|
||||
PassphraseDialog(
|
||||
title = "Export history",
|
||||
explainer = "This passphrase is the only key to the backup. It isn't "
|
||||
+ "stored anywhere, a forgotten passphrase means the backup can "
|
||||
+ "never be opened.",
|
||||
explainer =
|
||||
"This passphrase is the only key to the backup. It isn't " +
|
||||
"stored anywhere, a forgotten passphrase means the backup can " +
|
||||
"never be opened.",
|
||||
requireRepeat = true,
|
||||
busy = working,
|
||||
onDismiss = { if (!working) showExportDialog = false },
|
||||
@@ -129,15 +133,17 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
pendingImport?.let { uri ->
|
||||
PassphraseDialog(
|
||||
title = "Import history",
|
||||
explainer = "Anything already on this phone stays, the backup is "
|
||||
+ "merged in, never a replacement.",
|
||||
explainer =
|
||||
"Anything already on this phone stays, the backup is " +
|
||||
"merged in, never a replacement.",
|
||||
requireRepeat = false,
|
||||
busy = working,
|
||||
onDismiss = { if (!working) pendingImport = null },
|
||||
) { phrase ->
|
||||
working = true
|
||||
scope.launch {
|
||||
val bytes = runCatching {
|
||||
val bytes =
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (bytes == null) {
|
||||
@@ -149,14 +155,16 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
val outcome = model.importHistory(bytes, phrase.toCharArray())
|
||||
working = false
|
||||
pendingImport = null
|
||||
status = outcome.fold(
|
||||
status =
|
||||
outcome.fold(
|
||||
onSuccess = { counts ->
|
||||
if (counts.messages + counts.stories == 0)
|
||||
if (counts.messages + counts.stories == 0) {
|
||||
"Backup opened, everything in it was already here."
|
||||
else
|
||||
} else {
|
||||
"Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" +
|
||||
" and ${counts.stories} " +
|
||||
if (counts.stories == 1) "story." else "stories."
|
||||
}
|
||||
},
|
||||
onFailure = { it.message ?: "That backup couldn't be opened." },
|
||||
)
|
||||
@@ -206,13 +214,19 @@ private fun PassphraseDialog(
|
||||
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase-repeat"),
|
||||
)
|
||||
if (mismatch) {
|
||||
Text("The two passphrases don't match yet.",
|
||||
fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
"The two passphrases don't match yet.",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (phrase.isNotEmpty() && phrase.length < 6) {
|
||||
Text("Use at least 6 characters.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Text(
|
||||
"Use at least 6 characters.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,56 +6,45 @@ import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Downscale and re-encode a picked photo before it is encrypted and uploaded.
|
||||
*
|
||||
* This app did none of this. The picker read the file and handed the
|
||||
* ORIGINAL bytes straight to `sendImage`, so a modern phone camera, 12 to 50
|
||||
* megapixels, often 5-15 MB of JPEG, went at the relay's blob cap directly.
|
||||
* Over the cap the upload is refused and the photo simply fails to send; under
|
||||
* it, the recipient downloads tens of megabytes nobody asked for. iOS has
|
||||
* downscaled since it shipped, so the two platforms disagreed about what
|
||||
* sending a photo even means.
|
||||
*
|
||||
* There is a hard ceiling and it is the server's, so "full quality" cannot
|
||||
* mean "whatever the camera produced". It means *as good as will fit*: start at
|
||||
* the requested quality and step down until the bytes are under budget. A photo
|
||||
* that fails to send is worse than a photo that is slightly softer.
|
||||
*/
|
||||
object ImagePrep {
|
||||
|
||||
/**
|
||||
* How much of the server's cap a prepared image may use.
|
||||
*
|
||||
* Deliberately under the relay's 10 MiB: the blob is encrypted before
|
||||
* upload and the AEAD tag and framing ride on top of these bytes. Sizing to
|
||||
* the exact cap produces a file that passes here and is refused there.
|
||||
*/
|
||||
const val BYTE_BUDGET = 9 * 1024 * 1024
|
||||
|
||||
private const val STANDARD_MAX_SIDE = 2048
|
||||
private const val FULL_MAX_SIDE = 4096
|
||||
|
||||
data class Prepared(val bytes: ByteArray, val mime: String, val width: Int, val height: Int)
|
||||
data class Prepared(
|
||||
val bytes: ByteArray,
|
||||
val mime: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* @param fullQuality the user's "send full quality photos" setting.
|
||||
* @return the prepared image, or null if the bytes are not a decodable image.
|
||||
* @return the prepared image, or null if the bytes are not a decodable
|
||||
* image.
|
||||
*/
|
||||
fun prepare(source: ByteArray, fullQuality: Boolean): Prepared? {
|
||||
fun prepare(
|
||||
source: ByteArray,
|
||||
fullQuality: Boolean,
|
||||
): Prepared? {
|
||||
val maxSide = if (fullQuality) FULL_MAX_SIDE else STANDARD_MAX_SIDE
|
||||
|
||||
// Bounds first, so an enormous file is never fully decoded into memory
|
||||
// just to find out how big it is. Decoding a 50MP JPEG at full size is
|
||||
// ~200 MB of bitmap and is how a photo picker gets OOM-killed.
|
||||
// just to find out how big it is.
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(source, 0, source.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val decoded = decodeScaled(source, bounds, maxSide) ?: return null
|
||||
|
||||
// Steps, best first. Each is a real fallback rather than a retry: for a
|
||||
// very large picture quality alone will not reach the budget and the
|
||||
// dimensions have to give as well.
|
||||
val attempts = listOf(
|
||||
// Steps, best first.
|
||||
val attempts =
|
||||
listOf(
|
||||
maxSide to (if (fullQuality) 95 else 80),
|
||||
maxSide to 70,
|
||||
minOf(maxSide, 2048) to 70,
|
||||
@@ -77,30 +66,19 @@ object ImagePrep {
|
||||
}
|
||||
}
|
||||
decoded.recycle()
|
||||
// Every step was still too large. Returning the smallest is better than
|
||||
// null: the caller surfaces the server's refusal, where null here would
|
||||
// look like an unreadable file instead.
|
||||
// Every step was still too large.
|
||||
return last
|
||||
}
|
||||
|
||||
/** Decode with `inSampleSize`, so the full-size bitmap never exists. */
|
||||
|
||||
/**
|
||||
* Decode bytes that came from somebody else, bounded.
|
||||
*
|
||||
* The render paths were calling `decodeByteArray` with no options at
|
||||
* all. Sending was careful, bounds first, then a sampled decode, but
|
||||
* the two places that draw an image RECEIVED from a peer decoded at full
|
||||
* size. Inbound attachments are capped at 12 MiB, and 12 MiB of cleverly
|
||||
* chosen JPEG is a few hundred megabytes of ARGB_8888 once expanded. A
|
||||
* contact could send one image and crash the app every time the thread or
|
||||
* the story was opened, a conversation wedged from the outside, with no
|
||||
* way for the victim to get past it.
|
||||
*
|
||||
* Bounds are read first so an enormous picture is never fully decoded
|
||||
* just to discover how big it is. That is the point; a size check on
|
||||
* the byte count alone does not bound the decoded bitmap.
|
||||
*/
|
||||
fun decodeBounded(source: ByteArray, maxSide: Int = STANDARD_MAX_SIDE): Bitmap? {
|
||||
fun decodeBounded(
|
||||
source: ByteArray,
|
||||
maxSide: Int = STANDARD_MAX_SIDE,
|
||||
): Bitmap? {
|
||||
if (source.isEmpty()) return null
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(source, 0, source.size, bounds)
|
||||
@@ -108,7 +86,11 @@ object ImagePrep {
|
||||
return decodeScaled(source, bounds, maxSide)
|
||||
}
|
||||
|
||||
private fun decodeScaled(source: ByteArray, bounds: BitmapFactory.Options, maxSide: Int): Bitmap? {
|
||||
private fun decodeScaled(
|
||||
source: ByteArray,
|
||||
bounds: BitmapFactory.Options,
|
||||
maxSide: Int,
|
||||
): Bitmap? {
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSide * 2) sample *= 2
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
@@ -116,7 +98,10 @@ object ImagePrep {
|
||||
}
|
||||
|
||||
/** Scale so the longest side is at most [maxSide]. Never enlarges. */
|
||||
private fun scaleTo(bitmap: Bitmap, maxSide: Int): Bitmap {
|
||||
private fun scaleTo(
|
||||
bitmap: Bitmap,
|
||||
maxSide: Int,
|
||||
): Bitmap {
|
||||
val longest = maxOf(bitmap.width, bitmap.height)
|
||||
if (longest <= maxSide) return bitmap
|
||||
val ratio = maxSide.toDouble() / longest
|
||||
|
||||
@@ -11,37 +11,17 @@ import javax.crypto.SecretKey
|
||||
|
||||
/**
|
||||
* The vault's key, held by the platform, and its records, held as files.
|
||||
*
|
||||
* The key is never seen by this process. `AndroidKeyStore` returns a
|
||||
* handle; the bytes stay in hardware-backed storage where the OS keeps them.
|
||||
* That is why [io.flamenet.e2e.Vault] works with a `SecretKey` rather than raw
|
||||
* bytes, a design that needed the bytes would have to keep them somewhere,
|
||||
* which is exactly what this avoids.
|
||||
*
|
||||
* The key does not leave this device, so neither does the vault. It is
|
||||
* generated here on first launch and is not exportable or backupable, which is
|
||||
* the Android equivalent of the iOS client's `ThisDeviceOnly` Keychain item. A
|
||||
* consequence worth stating plainly: uninstalling the app destroys the key,
|
||||
* and every stored conversation with it. That is the intended behaviour,
|
||||
* "deleted means deleted", but it means the vault is not a backup, and the
|
||||
* manifest turns `allowBackup` off so the records cannot be restored somewhere
|
||||
* their key can never follow.
|
||||
*/
|
||||
object KeystoreVault {
|
||||
|
||||
private const val KEYSTORE = "AndroidKeyStore"
|
||||
private const val ALIAS = "flamenet.messenger.vault.v1"
|
||||
private const val DIRECTORY = "vault"
|
||||
|
||||
/**
|
||||
* Is there a session worth receiving for?
|
||||
*
|
||||
* Both halves, matching `restore()`: a device with no credential cannot
|
||||
* reach the relay, and a credential with no identity cannot be addressed.
|
||||
* Either alone is a half-finished sign-in, not something to run a
|
||||
* foreground service for.
|
||||
*/
|
||||
fun hasSession(context: Context): Boolean = runCatching {
|
||||
fun hasSession(context: Context): Boolean =
|
||||
runCatching {
|
||||
val v = open(context)
|
||||
v.loadIdentity() != null && v.loadCredential() != null
|
||||
}.getOrDefault(false)
|
||||
@@ -50,32 +30,27 @@ object KeystoreVault {
|
||||
|
||||
/**
|
||||
* The vault key, generated on first use.
|
||||
*
|
||||
* No `setUserAuthenticationRequired`. Requiring a device unlock per use
|
||||
* would sound stronger and would break background polling, the app cannot
|
||||
* decrypt an incoming message while the screen is off, so mail silently
|
||||
* stops arriving until the user opens the app. App lock is a separate
|
||||
* feature at the UI layer, where it can be honest about what it protects.
|
||||
*/
|
||||
private fun key(): SecretKey {
|
||||
val store = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
||||
(store.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
|
||||
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply {
|
||||
return KeyGenerator
|
||||
.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
||||
.apply {
|
||||
init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
KeyGenParameterSpec
|
||||
.Builder(
|
||||
ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
// The vault supplies its own nonce per record, and a
|
||||
// repeated nonce under one key is the catastrophic AES-GCM
|
||||
// failure. Letting the caller provide it is only safe
|
||||
// because that nonce is freshly random every write.
|
||||
// failure.
|
||||
.setRandomizedEncryptionRequired(false)
|
||||
.build()
|
||||
.build(),
|
||||
)
|
||||
}.generateKey()
|
||||
}
|
||||
@@ -88,30 +63,31 @@ object KeystoreVault {
|
||||
|
||||
/**
|
||||
* Records as files under `filesDir`, which is app-private storage.
|
||||
*
|
||||
* Record names contain a device id and are used as filenames, so they
|
||||
* are encoded rather than trusted: a name with a `/` in it would otherwise
|
||||
* write outside the vault directory. The *unencoded* name is what goes into
|
||||
* the record's AAD, so the binding survives the encoding.
|
||||
*/
|
||||
private class FileStorage(private val root: File) : Vault.Storage {
|
||||
|
||||
init { root.mkdirs() }
|
||||
private class FileStorage(
|
||||
private val root: File,
|
||||
) : Vault.Storage {
|
||||
init {
|
||||
root.mkdirs()
|
||||
}
|
||||
|
||||
private fun fileFor(name: String) =
|
||||
File(root, android.util.Base64.encodeToString(
|
||||
File(
|
||||
root,
|
||||
android.util.Base64.encodeToString(
|
||||
name.toByteArray(Charsets.UTF_8),
|
||||
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP,
|
||||
))
|
||||
),
|
||||
)
|
||||
|
||||
override fun read(name: String): ByteArray? =
|
||||
fileFor(name).takeIf { it.exists() }?.readBytes()
|
||||
override fun read(name: String): ByteArray? = fileFor(name).takeIf { it.exists() }?.readBytes()
|
||||
|
||||
override fun write(name: String, bytes: ByteArray) {
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
// Write beside and rename, so a kill mid-write leaves the previous
|
||||
// record intact rather than a truncated one. A half-written session
|
||||
// reads as tampering, which is the worst possible way to report
|
||||
// "the phone ran out of battery".
|
||||
// record intact rather than a truncated one.
|
||||
val target = fileFor(name)
|
||||
val staging = File(root, target.name + ".tmp")
|
||||
staging.writeBytes(bytes)
|
||||
@@ -121,10 +97,14 @@ object KeystoreVault {
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(name: String) { fileFor(name).delete() }
|
||||
override fun delete(name: String) {
|
||||
fileFor(name).delete()
|
||||
}
|
||||
|
||||
override fun names(): List<String> =
|
||||
root.listFiles().orEmpty()
|
||||
root
|
||||
.listFiles()
|
||||
.orEmpty()
|
||||
.filterNot { it.name.endsWith(".tmp") }
|
||||
.mapNotNull {
|
||||
runCatching {
|
||||
|
||||
@@ -22,21 +22,6 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The kit's buttons.
|
||||
*
|
||||
* Because almost every action in this app was a bare `TextButton`, an
|
||||
* orange word with nothing around it. Back, Call, Safety number, Record a
|
||||
* voice note, Mark all read, Look up, Leave, and the four presence states were
|
||||
* all the same undifferentiated orange text, so the app read as a page of
|
||||
* links rather than as a set of controls. That is not an Android convention;
|
||||
* it is the absence of one.
|
||||
*
|
||||
* The kit ships three: a filled primary, an outlined secondary and a grey
|
||||
* tertiary. All 64 tall at radius 32, fully rounded, with a 14 medium label
|
||||
* and 16 of horizontal padding. See `DESIGN.md`.
|
||||
*
|
||||
* Height 64 is the kit's, and it is deliberately large. Where a control has
|
||||
* to sit inline next to text, use [KitChip] rather than shrinking one of these
|
||||
* a 64pt button squeezed to 32 is neither the kit's button nor a chip.
|
||||
*/
|
||||
object KitButton {
|
||||
val height = 64.dp
|
||||
@@ -57,7 +42,8 @@ fun KitPrimaryButton(
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(KitButton.radius),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
modifier = modifier
|
||||
modifier =
|
||||
modifier
|
||||
.height(KitButton.height)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
) {
|
||||
@@ -84,7 +70,8 @@ fun KitSecondaryButton(
|
||||
border = BorderStroke(1.dp, colour),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = colour),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
modifier = modifier
|
||||
modifier =
|
||||
modifier
|
||||
.height(KitButton.height)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
) {
|
||||
@@ -93,12 +80,8 @@ fun KitSecondaryButton(
|
||||
}
|
||||
|
||||
/**
|
||||
* A small pill for something that sits in a row of its peers, a presence
|
||||
* state, a filter. Selected fills with the primary; the rest are the kit's
|
||||
* neutral surface.
|
||||
*
|
||||
* The kit's own small pill (its "On" badge) is 28 tall; 36 here so a finger
|
||||
* can hit it, which the kit's badge does not have to survive.
|
||||
* A small pill for something that sits in a row of its peers, a presence state,
|
||||
* a filter.
|
||||
*/
|
||||
@Composable
|
||||
fun KitChip(
|
||||
@@ -108,10 +91,7 @@ fun KitChip(
|
||||
modifier: Modifier = Modifier,
|
||||
tag: String? = null,
|
||||
/**
|
||||
* Optional, and a real vector when present. The voice-note chip used to
|
||||
* carry its picture as an emoji inside `text`, "Voice note", which
|
||||
* renders in whatever the system emoji font is, at the text baseline, in
|
||||
* full colour. Beside a drawn monochrome icon it reads as a sticker.
|
||||
* Optional, and a real vector when present.
|
||||
*/
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
|
||||
) {
|
||||
@@ -119,13 +99,23 @@ fun KitChip(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
contentPadding = PaddingValues(horizontal = 14.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary
|
||||
else Palette.SecondaryText,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor =
|
||||
if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor =
|
||||
if (selected) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
Palette.SecondaryText
|
||||
},
|
||||
),
|
||||
modifier = modifier
|
||||
modifier =
|
||||
modifier
|
||||
.height(36.dp)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
) {
|
||||
|
||||
@@ -8,34 +8,33 @@ import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.outlined.AccountBalanceWallet
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.AddComment
|
||||
import androidx.compose.material.icons.outlined.AddCircleOutline
|
||||
import androidx.compose.material.icons.outlined.AddComment
|
||||
import androidx.compose.material.icons.outlined.Block
|
||||
import androidx.compose.material.icons.outlined.Call
|
||||
import androidx.compose.material.icons.outlined.CallEnd
|
||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.Circle
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.DeleteOutline
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.FolderOpen
|
||||
import androidx.compose.material.icons.outlined.GroupAdd
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Mic
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.MicOff
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.StickyNote2
|
||||
import androidx.compose.material.icons.outlined.NotificationsNone
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.material.icons.outlined.PersonOutline
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material.icons.outlined.PersonRemove
|
||||
import androidx.compose.material.icons.outlined.Circle
|
||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.StickyNote2
|
||||
import androidx.compose.material.icons.outlined.Verified
|
||||
import androidx.compose.material.icons.outlined.Videocam
|
||||
import androidx.compose.material.icons.outlined.VolumeUp
|
||||
@@ -43,52 +42,35 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* The app's icon vocabulary, in one place.
|
||||
*
|
||||
* Why this file exists. Every icon in this app used to be a text
|
||||
* character or an emoji, `+`, `→`, `‹`, `⋯`, ``, ``, `⊞`. It was reported
|
||||
* three times as "bare", "disorganized" and "a bunch of text links", and each
|
||||
* time I adjusted spacing and type instead, because from the code the rows
|
||||
* looked structurally right. They were: the kit's Messages screen and ours
|
||||
* agree on the 24dp margin, the 48dp row on a 72dp pitch, the search pill and
|
||||
* the story strip. What they did not agree on is that every piece of chrome in
|
||||
* the kit is a drawn line icon and every piece of ours was a glyph in a text
|
||||
* run. A `Text("→")` in a circle is not a send button, it is an arrow
|
||||
* character, and no amount of layout work fixes that.
|
||||
*
|
||||
* `material-icons-extended` was already a dependency the whole time, and
|
||||
* the app had zero `Icon(` calls and zero `material.icons` imports. The
|
||||
* fix was never blocked on anything; it was blocked on looking at a render.
|
||||
*
|
||||
* Naming is by role, not by picture, `Kit.Send` rather than `Kit.PaperPlane`,
|
||||
* so swapping the drawing later is one edit here and nothing at the call sites.
|
||||
*/
|
||||
object KitIcon {
|
||||
// Chrome
|
||||
// Chrome.
|
||||
val Back: ImageVector = Icons.AutoMirrored.Outlined.ArrowBackIos
|
||||
val More: ImageVector = Icons.Outlined.MoreHoriz
|
||||
val Close: ImageVector = Icons.Outlined.Close
|
||||
val Add: ImageVector = Icons.Outlined.Add
|
||||
val Search: ImageVector = Icons.Outlined.Search
|
||||
|
||||
// Tab bar. The active tab is the filled variant of the same shape, which
|
||||
// is how the kit distinguishes it, not a different icon.
|
||||
// Tab bar.
|
||||
val Chats: ImageVector = Icons.Outlined.ChatBubbleOutline
|
||||
val ChatsActive: ImageVector = Icons.Filled.ChatBubble
|
||||
// A circle, matching the iOS pill's `circle.dashed`, the two bars used to
|
||||
// disagree (a camera here), and the same tab deserves the same glyph.
|
||||
|
||||
/** The Home tab, roster, stories strip, and the Recent circles. */
|
||||
val Home: ImageVector = Icons.Outlined.Home
|
||||
|
||||
val Stories: ImageVector = Icons.Outlined.Circle
|
||||
val Alerts: ImageVector = Icons.Outlined.NotificationsNone
|
||||
val Account: ImageVector = Icons.Outlined.PersonOutline
|
||||
|
||||
// The two that joined the bar on 2026-09-07. iOS draws `folder.fill` and
|
||||
// `wallet.bifold.fill`; these are the Material shapes closest to them, so
|
||||
// the same destination reads the same on both phones.
|
||||
val Files: ImageVector = Icons.Outlined.FolderOpen
|
||||
val Wallet: ImageVector = Icons.Outlined.AccountBalanceWallet
|
||||
|
||||
// Composer
|
||||
// Composer.
|
||||
val Attach: ImageVector = Icons.Outlined.AddCircleOutline
|
||||
val Camera: ImageVector = Icons.Outlined.PhotoCamera
|
||||
val Send: ImageVector = Icons.AutoMirrored.Outlined.Send
|
||||
@@ -97,18 +79,19 @@ object KitIcon {
|
||||
val Lock: ImageVector = Icons.Outlined.Lock
|
||||
val Sticker: ImageVector = Icons.Outlined.StickyNote2
|
||||
|
||||
// Calls
|
||||
// Calls.
|
||||
val Call: ImageVector = Icons.Outlined.Call
|
||||
val Video: ImageVector = Icons.Outlined.Videocam
|
||||
val CallEnd: ImageVector = Icons.Outlined.CallEnd
|
||||
val MicOff: ImageVector = Icons.Outlined.MicOff
|
||||
val Speaker: ImageVector = Icons.Outlined.VolumeUp
|
||||
|
||||
// Contact actions
|
||||
// Contact actions.
|
||||
val NewGroup: ImageVector = Icons.Outlined.GroupAdd
|
||||
val NewChat: ImageVector = Icons.Outlined.AddComment
|
||||
val Scan: ImageVector = Icons.Outlined.QrCodeScanner
|
||||
val Block: ImageVector = Icons.Outlined.Block
|
||||
|
||||
/** Unblocking: the same row, the opposite verb. */
|
||||
val PersonAdd: ImageVector = Icons.Outlined.PersonAdd
|
||||
val ClearChat: ImageVector = Icons.Outlined.DeleteOutline
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -61,47 +60,28 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
|
||||
|
||||
/**
|
||||
* `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic:
|
||||
* `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic.
|
||||
* `BiometricPrompt` hosts a fragment, so on a plain `ComponentActivity` it
|
||||
* silently cannot show, the callback never fires and the lock screen sits
|
||||
* there looking broken. `FragmentActivity` extends `ComponentActivity`, so
|
||||
* `setContent` and everything else is unaffected.
|
||||
* silently cannot show, the callback never fires and the lock screen sits there
|
||||
* looking broken.
|
||||
*/
|
||||
class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Screenshots off, and out of the recents thumbnail.
|
||||
//
|
||||
// The second half is the one that matters and the one nobody thinks
|
||||
// about: without FLAG_SECURE, Android captures whatever is on screen
|
||||
// into the recent-apps preview and WRITES IT TO DISK. An open
|
||||
// conversation therefore exists as a plaintext bitmap outside every
|
||||
// protection this app has, the ratchet, the sealed history file, the
|
||||
// ThisDeviceOnly key, none of which cover a screenshot the OS took of
|
||||
// the rendered thread.
|
||||
//
|
||||
// iOS has had the equivalent since the app lock landed: it obscures
|
||||
// the switcher snapshot on `scenePhase`. Android had nothing at all.
|
||||
// Two platforms disagreeing about a security guarantee is how one of
|
||||
// them ends up being the one that leaks.
|
||||
//
|
||||
// It also blocks screen recording and casting,
|
||||
// and is not something to make optional: a setting here is a setting
|
||||
// people leave off and then get surprised by.
|
||||
window.setFlags(
|
||||
android.view.WindowManager.LayoutParams.FLAG_SECURE,
|
||||
android.view.WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
|
||||
// Runtime permission on API 33+, which is this app's minSdk. Without
|
||||
// it the service runs and every alert is dropped silently, no error,
|
||||
// no banner, and nothing on screen to say why. Ask once here rather
|
||||
// than discovering it as "notifications do not work".
|
||||
// Runtime permission on API 33+, which is this app's minSdk.
|
||||
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
!= android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
|
||||
}
|
||||
|
||||
@@ -110,10 +90,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
setContent {
|
||||
MessengerTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
// The gate is around Root, not inside it. Drawn over
|
||||
// the top, the message list still renders underneath for a
|
||||
// frame, and a frame is all a screenshot or a shoulder
|
||||
// needs. Locked means not composed.
|
||||
// The gate is around Root, not inside it.
|
||||
var locked by remember { mutableStateOf(lock.enabled) }
|
||||
|
||||
if (locked) {
|
||||
@@ -121,7 +98,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
onUnlock = {
|
||||
lock.authenticate(
|
||||
activity = this@MainActivity,
|
||||
onSuccess = { lock.noteUnlocked(); locked = false },
|
||||
onSuccess = {
|
||||
lock.noteUnlocked()
|
||||
locked = false
|
||||
},
|
||||
onFailure = { /* the screen stays; it says why */ },
|
||||
)
|
||||
},
|
||||
@@ -132,7 +112,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
LaunchedEffect(Unit) {
|
||||
lock.authenticate(
|
||||
activity = this@MainActivity,
|
||||
onSuccess = { lock.noteUnlocked(); locked = false },
|
||||
onSuccess = {
|
||||
lock.noteUnlocked()
|
||||
locked = false
|
||||
},
|
||||
onFailure = {},
|
||||
)
|
||||
}
|
||||
@@ -142,12 +125,20 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
|
||||
// Re-arm on the way out, subject to the grace period.
|
||||
DisposableEffect(Unit) {
|
||||
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
val observer =
|
||||
androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
androidx.lifecycle.Lifecycle.Event.ON_STOP -> lock.noteLeft()
|
||||
androidx.lifecycle.Lifecycle.Event.ON_START ->
|
||||
androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
|
||||
lock.noteLeft()
|
||||
}
|
||||
|
||||
androidx.lifecycle.Lifecycle.Event.ON_START -> {
|
||||
if (lock.shouldLock()) locked = true
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
@@ -158,10 +149,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// The handover: exactly one poller at a time. The ViewModel polls
|
||||
// while the app is on screen; the service polls while it is not. Two
|
||||
// pollers would race for the same envelopes and double-notify, so this
|
||||
// pair has to stay symmetrical, if one side moves, move the other.
|
||||
// The handover: exactly one poller at a time.
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
MessengerService.stop(this)
|
||||
@@ -169,9 +157,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// Only when there is a session to receive for. Starting it signed
|
||||
// out puts a permanent notice in the shade for an app doing nothing,
|
||||
// and the service stops itself, but not before the user has seen it.
|
||||
// Only when there is a session to receive for.
|
||||
if (KeystoreVault.hasSession(applicationContext)) MessengerService.start(this)
|
||||
}
|
||||
}
|
||||
@@ -185,7 +171,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
// Scrollable, because a keyboard over a form needs somewhere to go.
|
||||
is MessengerViewModel.Phase.Starting,
|
||||
is MessengerViewModel.Phase.SignedOut,
|
||||
is MessengerViewModel.Phase.VaultUnreadable ->
|
||||
is MessengerViewModel.Phase.VaultUnreadable,
|
||||
-> {
|
||||
Column(
|
||||
modifier = frame.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
@@ -197,122 +184,90 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOT scrollable. A `LazyColumn` inside a vertically scrolling
|
||||
// Column is measured with infinite height and crashes outright,
|
||||
// the lists below manage their own scrolling.
|
||||
is MessengerViewModel.Phase.SignedIn ->
|
||||
// NOT scrollable.
|
||||
is MessengerViewModel.Phase.SignedIn -> {
|
||||
Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
// Above the screen switch, not inside one branch. An
|
||||
// incoming call has to be visible whatever screen somebody
|
||||
// is on, and this used to live inside SignedIn, which the
|
||||
// chat screen REPLACES. So a call arriving while you were
|
||||
// reading a thread showed nothing at all.
|
||||
//
|
||||
// Worse, the same banner renders the first-call privacy
|
||||
// prompt, and the only Call button is IN a chat. Tapping it
|
||||
// set the prompt state, nothing was composed to show it,
|
||||
// and the prompt gates placing the call, so calling could
|
||||
// not be started at all. The button looked simply dead.
|
||||
// A live call takes the screen. It used to be a
|
||||
// banner above whatever you were on, a line of text and
|
||||
// two text buttons for the single most attention-demanding
|
||||
// thing this app does. The privacy prompt stays a banner
|
||||
// deliberately: it is a question asked BEFORE a call
|
||||
// exists, and a full screen for it would look like a call
|
||||
// already happening.
|
||||
// Above the screen switch, not inside one branch.
|
||||
CallBanner(model, current)
|
||||
StoryComposerDialog(model, current)
|
||||
// Here, above the screen switch, for the same reason
|
||||
// the call banner is. Nothing installs this app but the
|
||||
// person holding the phone, so the one moment they can be
|
||||
// told a fix exists is while they have it open, whatever
|
||||
// screen that happens to be.
|
||||
// Here, above the screen switch, for the same reason the
|
||||
// call banner is.
|
||||
UpdateAvailableDialog(model.site?.baseUrl)
|
||||
if (LiveCallScreen(model, current)) return@Column
|
||||
|
||||
val chat = current.openChat
|
||||
if (chat != null) {
|
||||
ChatScreen(model, chat, current.error, current.buddies, current.avatars,
|
||||
myUserId = current.userId, groups = current.groups)
|
||||
ChatScreen(
|
||||
model,
|
||||
chat,
|
||||
current.error,
|
||||
current.buddies,
|
||||
current.avatars,
|
||||
myUserId = current.userId,
|
||||
groups = current.groups,
|
||||
)
|
||||
} else {
|
||||
// The content area is weighted, and the tab bar is
|
||||
// not. These used to be plain siblings, so the tab bar
|
||||
// sat immediately under whatever the screen had drawn:
|
||||
// near the TOP of an empty Alerts tab, and pushed clean
|
||||
// OFF the bottom by the Account tab, which is taller
|
||||
// than the screen. Account also could not scroll, so the
|
||||
// sign-out explanation was unreadable and there was no
|
||||
// way back to another tab.
|
||||
//
|
||||
// Every screen scrolls, Contacts included.
|
||||
//
|
||||
// This used to exempt Contacts, because "a lazy list
|
||||
// inside a vertically scrolling Column is measured with
|
||||
// infinite height and crashes outright". True, but
|
||||
// only for an UNBOUNDED lazy list, and the buddy list
|
||||
// has carried `heightIn(max = 320.dp)` since it was put
|
||||
// in its own card. The exemption outlived its reason.
|
||||
//
|
||||
// What it cost: Groups sits below the buddy list on
|
||||
// this screen, so opening New group pushed the
|
||||
// member checkboxes and the Create group button past
|
||||
// the bottom of a screen that could not scroll. The
|
||||
// name field was reachable and nothing else was, you
|
||||
// could type a group name and then only Cancel.
|
||||
// Measured on a 1080×2400 emulator: the field landed at
|
||||
// y≈1879, the button past the tab bar at y≈2280, and a
|
||||
// `uiautomator dump` found no scrollable container and
|
||||
// no "Create group" node at all.
|
||||
//
|
||||
// Before exempting a screen here again, check what is
|
||||
// BELOW the lazy list, not just the list itself. And
|
||||
// keep the buddy list bounded, remove that height and
|
||||
// this does crash.
|
||||
// Tighter than it was. Every section already carries
|
||||
// its own padding, so a 12dp gap between them on top of
|
||||
// that left the list airy in a way the kit is not, its
|
||||
// rows sit on a 72dp pitch with nothing extra between
|
||||
// the groups of them.
|
||||
// The content area is weighted, and the tab bar is not.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
when (current.screen) {
|
||||
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
|
||||
MessengerViewModel.Phase.Screen.MESSAGES ->
|
||||
MessengerViewModel.Phase.Screen.CONTACTS -> {
|
||||
SignedIn(model, current)
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.MESSAGES -> {
|
||||
MessagesScreen(model, current)
|
||||
MessengerViewModel.Phase.Screen.CALLS ->
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.CALLS -> {
|
||||
CallsScreen(model, current)
|
||||
MessengerViewModel.Phase.Screen.STORIES ->
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.STORIES -> {
|
||||
StoriesFeed(model, current)
|
||||
MessengerViewModel.Phase.Screen.WALLET ->
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.WALLET -> {
|
||||
WalletScreen(model, current)
|
||||
}
|
||||
|
||||
// Its own destination, not a section of the
|
||||
// wallet. iOS put them together once because
|
||||
// they share an unlock, true, and an
|
||||
// implementation detail. What it produced was
|
||||
// somebody pressing "wallet" and arriving at a
|
||||
// file list.
|
||||
MessengerViewModel.Phase.Screen.FILES ->
|
||||
// implementation detail.
|
||||
MessengerViewModel.Phase.Screen.FILES -> {
|
||||
FilesScreen(model, current)
|
||||
MessengerViewModel.Phase.Screen.NOTIFICATIONS ->
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.NOTIFICATIONS -> {
|
||||
NotificationsScreen(model, current)
|
||||
MessengerViewModel.Phase.Screen.ACCOUNT ->
|
||||
}
|
||||
|
||||
MessengerViewModel.Phase.Screen.ACCOUNT -> {
|
||||
AccountScreen(model, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
BottomNav(model, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) {
|
||||
private fun ColumnScope.SignIn(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedOut,
|
||||
) {
|
||||
val relay by model.relayInput.collectAsStateWithLifecycle()
|
||||
// Observed, not read once: the account mode is discovered from the relay a
|
||||
// moment after the address is typed, and the label has to catch up.
|
||||
@@ -326,24 +281,15 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
||||
// Only a relay keeping its own accounts needs an address for reset mail.
|
||||
val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL
|
||||
|
||||
// The kit's Welcome screen, which is a masthead and not a form
|
||||
// heading. A logo mark, a 40pt greeting, and one line saying what this
|
||||
// is, centred, with room above it. This was a 24pt product name jammed
|
||||
// against a Server field, which is the layout of a login dialog rather
|
||||
// than the first thing anyone sees of the app. See `DESIGN.md`.
|
||||
//
|
||||
// The kit's Welcome has no fields on it at all, it is a splash before
|
||||
// a phone number is asked for. Flamenet does not ask for a phone number
|
||||
// and has nothing to put on a second screen, so the masthead and the form
|
||||
// share one. The typography and the centring are the kit's; the fields
|
||||
// are ours.
|
||||
// The kit's Welcome screen, which is a masthead and not a form heading.
|
||||
Spacer(Modifier.height(Space.xl))
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
@@ -355,8 +301,8 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
||||
Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
"Flamenet Messenger. End-to-end encrypted, no phone number, and it "
|
||||
+ "runs on whichever server you point it at.",
|
||||
"Flamenet Messenger. End-to-end encrypted, no phone number, and it " +
|
||||
"runs on whichever server you point it at.",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -373,9 +319,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
||||
modifier = Modifier.fillMaxWidth().testTag("relay"),
|
||||
)
|
||||
|
||||
// Say which kind of server this is BEFORE the passphrase is typed. Whether
|
||||
// the passphrase leaves the device differs between the two, and that is not
|
||||
// something to disclose afterwards.
|
||||
// Say which kind of server this is BEFORE the passphrase is typed.
|
||||
site?.let {
|
||||
Text(
|
||||
it.identityDescription,
|
||||
@@ -393,18 +337,16 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
||||
modifier = Modifier.fillMaxWidth().testTag("handle"),
|
||||
)
|
||||
|
||||
// Only a relay keeping its own accounts asks for this, it is how the
|
||||
// relay sends a reset code, and it is the only party that ever sees it.
|
||||
// The shared service does not take one at all, so asking for it there
|
||||
// would be collecting something nobody needs.
|
||||
// Only a relay keeping its own accounts asks for this, it is how the relay
|
||||
// sends a reset code, and it is the only party that ever sees it.
|
||||
if (needsEmail) {
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Email") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||
keyboardOptions =
|
||||
KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth().testTag("email"),
|
||||
)
|
||||
Text(
|
||||
@@ -419,22 +361,20 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
||||
label = { Text("Passphrase") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
keyboardOptions =
|
||||
KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
modifier = Modifier.fillMaxWidth().testTag("passphrase"),
|
||||
)
|
||||
|
||||
if (registering) {
|
||||
// The age gate. Neutral, and stated honestly: the year is checked on
|
||||
// this device to refuse an under-13 sign-up, then forgotten. Nothing
|
||||
// about age is sent to any server.
|
||||
// The age gate.
|
||||
OutlinedTextField(
|
||||
value = birthYear,
|
||||
onValueChange = { birthYear = it.filter(Char::isDigit).take(4) },
|
||||
label = { Text("Year of birth") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
||||
keyboardOptions =
|
||||
KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
||||
modifier = Modifier.fillMaxWidth().testTag("birth-year"),
|
||||
)
|
||||
Text(
|
||||
@@ -442,8 +382,11 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
ageError?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.testTag("age-error"))
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.testTag("age-error"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,28 +394,35 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
||||
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
|
||||
}
|
||||
|
||||
// 240 wide and centred, which is the kit's primary button on this
|
||||
// screen, not a full-width slab. A button that spans the phone reads as
|
||||
// the bottom of a form; one that does not reads as a choice.
|
||||
// 240 wide and centred, which is the kit's primary button on this screen,
|
||||
// not a full-width slab.
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (registering) {
|
||||
// The most conservative reading, a birthday not yet
|
||||
// passed this year, because "maybe old enough" isn't.
|
||||
// The most conservative reading, a birthday not yet passed
|
||||
// this year, because "maybe old enough" isn't.
|
||||
val year = birthYear.toIntOrNull() ?: 0
|
||||
val age = java.util.Calendar.getInstance().get(java.util.Calendar.YEAR) - year
|
||||
val age =
|
||||
java.util.Calendar
|
||||
.getInstance()
|
||||
.get(java.util.Calendar.YEAR) - year
|
||||
if (year < 1900 || age < 14) {
|
||||
ageError = if (year < 1900) "Enter your year of birth."
|
||||
else "You must be at least 13 to use Flamenet Messenger."
|
||||
ageError =
|
||||
if (year < 1900) {
|
||||
"Enter your year of birth."
|
||||
} else {
|
||||
"You must be at least 13 to use Flamenet Messenger."
|
||||
}
|
||||
return@Button
|
||||
}
|
||||
ageError = null
|
||||
}
|
||||
model.signIn(handle.trim(), passphrase, register = registering, email = email.trim())
|
||||
},
|
||||
enabled = !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
|
||||
enabled =
|
||||
!phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
|
||||
(!needsEmail || email.isNotBlank()) &&
|
||||
(!registering || birthYear.length == 4),
|
||||
shape = RoundedCornerShape(KitButton.radius),
|
||||
@@ -508,14 +458,12 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
||||
|
||||
/**
|
||||
* Password reset, in place rather than on another screen.
|
||||
*
|
||||
* The relay answers a reset request identically whatever the truth is,
|
||||
* unknown handle, no address on file, an account that authenticates elsewhere.
|
||||
* So the copy here says what was *done*, never what was *found*. "No such
|
||||
* account" would undo that at the last step.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) {
|
||||
private fun ColumnScope.ResetPassword(
|
||||
model: MessengerViewModel,
|
||||
handle: String,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var fresh by remember { mutableStateOf("") }
|
||||
@@ -562,29 +510,19 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String)
|
||||
|
||||
/**
|
||||
* What the bar contains, as data rather than as a shape only Compose can see.
|
||||
*
|
||||
* Extracted so a test can read it. The bar was a hand-written run of
|
||||
* `NavButton` calls, which meant nothing could assert what was in it, a
|
||||
* destination could be dropped in a refactor and no test anywhere would go red.
|
||||
* It has happened once already in spirit: Files and Wallet lived only inside
|
||||
* the Account screen for weeks while iOS carried them in its bar, and the two
|
||||
* apps disagreed with nothing to notice.
|
||||
*/
|
||||
object NavDestinations {
|
||||
|
||||
data class Tab(val screen: MessengerViewModel.Phase.Screen, val label: String, val tag: String)
|
||||
data class Tab(
|
||||
val screen: MessengerViewModel.Phase.Screen,
|
||||
val label: String,
|
||||
val tag: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* The bar, in order.
|
||||
*
|
||||
* Order matches iOS, which puts Files and Wallet between Stories and
|
||||
* Account. The same destination in the same place on both phones is most of
|
||||
* what "the apps stay in sync" means to somebody holding one of each.
|
||||
*
|
||||
* `storiesHidden` is parental supervision: it removes the surface rather
|
||||
* than showing an empty one.
|
||||
*/
|
||||
fun tabs(storiesHidden: Boolean): List<Tab> = listOfNotNull(
|
||||
fun tabs(storiesHidden: Boolean): List<Tab> =
|
||||
listOfNotNull(
|
||||
Tab(Screen.CONTACTS, "Home", "nav-contacts"),
|
||||
Tab(Screen.MESSAGES, "Messages", "nav-messages"),
|
||||
Tab(Screen.CALLS, "Calls", "nav-calls"),
|
||||
@@ -597,17 +535,6 @@ object NavDestinations {
|
||||
|
||||
/**
|
||||
* The places to be, always visible.
|
||||
*
|
||||
* A tab bar rather than a menu: a capability hidden behind an overflow is one
|
||||
* most users never find. That was the argument for putting the account here,
|
||||
* and it is the same argument that brought Files and Wallet here on 2026-09-07
|
||||
* they were reachable only by scrolling most of the way down the account
|
||||
* screen, which is indistinguishable from not shipping them.
|
||||
*
|
||||
* The kit's artboard is four icons and this is now seven. That is a
|
||||
* deliberate departure, not an oversight: the earlier note here argued a wallet
|
||||
* "does not earn the slot", and iOS had already decided otherwise. Two apps
|
||||
* disagreeing about where a feature lives is worse than either answer.
|
||||
*/
|
||||
@Composable
|
||||
private fun BottomNav(
|
||||
@@ -615,13 +542,10 @@ private fun BottomNav(
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
val unreadNotes = phase.notifications.count { !it.read }
|
||||
// A floating pill, not a full-width strip. The kit's tab bar is a
|
||||
// 328x50 rounded rectangle inset from both edges, sitting inside an 84-tall
|
||||
// band, it reads as a control resting on the page rather than as the
|
||||
// bottom of the window. This is the single most recognisable thing about
|
||||
// the design and the app had a plain row of text buttons.
|
||||
// A floating pill, not a full-width strip.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = Kit.pillHeight)
|
||||
.clip(RoundedCornerShape(Kit.pillRadius))
|
||||
@@ -633,20 +557,24 @@ private fun BottomNav(
|
||||
val storiesHidden =
|
||||
ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)
|
||||
for (tab in NavDestinations.tabs(storiesHidden)) {
|
||||
val badge = when (tab.screen) {
|
||||
// The inbox badge lives on Messages. Home is the roster, this
|
||||
// is where new mail is read.
|
||||
val badge =
|
||||
when (tab.screen) {
|
||||
// The inbox badge lives on Messages.
|
||||
Screen.MESSAGES -> phase.unread.size
|
||||
|
||||
// Missed calls SINCE the tab was last opened, it used to count
|
||||
// every missed call in the log for ever, a badge nothing could
|
||||
// clear. Opening the tab is what "seeing" them means.
|
||||
// clear.
|
||||
Screen.CALLS -> model.missedCallsUnseen(phase)
|
||||
|
||||
// Alerts left the bar 2026-08-28 and iOS never had one; its
|
||||
// unread count badges Account on both phones now.
|
||||
Screen.ACCOUNT -> unreadNotes
|
||||
|
||||
else -> 0
|
||||
}
|
||||
val (icon, active) = when (tab.screen) {
|
||||
val (icon, active) =
|
||||
when (tab.screen) {
|
||||
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
|
||||
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
|
||||
Screen.CALLS -> KitIcon.Call to KitIcon.Call
|
||||
@@ -662,16 +590,6 @@ private fun BottomNav(
|
||||
|
||||
/**
|
||||
* One tab: a drawn icon, and a count when there is one.
|
||||
*
|
||||
* No label. The kit's bar is four icons and nothing else, the artboard
|
||||
* is literally named "Bottom Bar/Without Label". This was four `TextButton`s
|
||||
* reading "Contacts Stories Alerts Account", which is a row of links, and
|
||||
* the counts were parenthesised into those labels: "Contacts (3)".
|
||||
*
|
||||
* The label survives as the accessibility name. Dropping the text from the
|
||||
* screen must not drop it from VoiceOver/TalkBack, or the bar becomes four
|
||||
* unnamed buttons, the version of this that shipped as icons-only with no
|
||||
* `contentDescription` would be strictly worse than the text it replaced.
|
||||
*/
|
||||
@Composable
|
||||
private fun RowScope.NavButton(
|
||||
@@ -686,31 +604,33 @@ private fun RowScope.NavButton(
|
||||
) {
|
||||
val selected = phase.screen == screen
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.height(Kit.pillHeight)
|
||||
// `navigateTo`, not `showScreen`. Wallet and Files each have to
|
||||
// read the vault before they have anything to draw, so a bare
|
||||
// `showScreen` lands somebody on an empty screen with nothing
|
||||
// loading and no error, see the note on `navigateTo`.
|
||||
// `navigateTo`, not `showScreen`.
|
||||
.clickable { model.navigateTo(screen) }
|
||||
.semantics {
|
||||
contentDescription =
|
||||
if (badge > 0) "$label, $badge unread" else label
|
||||
}
|
||||
.testTag(tag),
|
||||
}.testTag(tag),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
if (selected) activeIcon else icon,
|
||||
contentDescription = null,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary
|
||||
else Palette.SecondaryText,
|
||||
tint =
|
||||
if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
Palette.SecondaryText
|
||||
},
|
||||
modifier = Modifier.size(Kit.icon),
|
||||
)
|
||||
if (badge > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = Space.md, end = Space.lg)
|
||||
.size(Space.sm)
|
||||
@@ -722,9 +642,7 @@ private fun RowScope.NavButton(
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal rather than private ONLY so the JVM screenshot suite can render
|
||||
* it. `MainActivity` is its one real caller, the alternative was a screenshot
|
||||
* harness that could photograph the app's parts and never a whole screen.
|
||||
* Internal rather than private ONLY so the JVM screenshot suite can render it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ColumnScope.SignedIn(
|
||||
@@ -736,28 +654,24 @@ internal fun ColumnScope.SignedIn(
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
var scannedPrefill by remember { mutableStateOf("") }
|
||||
val scanContext = androidx.compose.ui.platform.LocalContext.current
|
||||
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
val askCamera =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts
|
||||
.RequestPermission(),
|
||||
) { _ ->
|
||||
// Open either way, the dialog explains itself when access is refused.
|
||||
scanning = true
|
||||
}
|
||||
|
||||
// The contact screen REPLACES the list rather than sitting under it,
|
||||
// the same way an open chat does. A detail view stacked below a list is
|
||||
// two screens fighting over one scroll.
|
||||
// The contact screen REPLACES the list rather than sitting under it, the
|
||||
// same way an open chat does.
|
||||
val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } }
|
||||
if (profile != null) {
|
||||
ContactDetail(model, phase, profile) { openProfile = null }
|
||||
return
|
||||
}
|
||||
|
||||
// Home is two things now, on purpose: your people, and their
|
||||
// stories. The conversation list, the groups and the search moved to
|
||||
// the Messages tab, this screen is the kit's social front page: a
|
||||
// horizontal row of contacts, then everyone's stories. Managing the
|
||||
// roster stays here (the Add action, and a long-press on a circle for
|
||||
// the contact's profile).
|
||||
// Home is two things now, on purpose: your people, and their stories.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -768,18 +682,22 @@ internal fun ColumnScope.SignedIn(
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Scan their Address screen, iOS's home has the same button. A scan
|
||||
// fills the Add-contact field and stops; nothing is added unlooked-at.
|
||||
// Scan their Address screen, iOS's home has the same button.
|
||||
BarAction(
|
||||
icon = KitIcon.Scan,
|
||||
label = "Scan an address",
|
||||
tag = "scan-address",
|
||||
onClick = {
|
||||
val has = androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
scanContext, android.Manifest.permission.CAMERA
|
||||
val has =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
scanContext,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) scanning = true
|
||||
else askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
if (has) {
|
||||
scanning = true
|
||||
} else {
|
||||
askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
},
|
||||
)
|
||||
BarAction(
|
||||
@@ -796,11 +714,6 @@ internal fun ColumnScope.SignedIn(
|
||||
scanning = false
|
||||
parseScannedAddress(raw)?.let { card ->
|
||||
// Bind by the fnid, never the handle (audit LOW).
|
||||
// The fnid is derived from the account key and certifies
|
||||
// itself; a handle is a name the RELAY maps to an account,
|
||||
// so resolving a scanned code by handle handed the relay
|
||||
// the choice of who you just scanned. iOS always binds the
|
||||
// address; this now matches it, same relay or not.
|
||||
scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}"
|
||||
addingContact = true
|
||||
}
|
||||
@@ -847,11 +760,6 @@ internal fun ColumnScope.SignedIn(
|
||||
|
||||
/**
|
||||
* One icon-sized action on the trailing edge of the bar.
|
||||
*
|
||||
* Glyphs rather than vector assets, because this app ships no icon set and
|
||||
* pulling one in for four buttons is a dependency for decoration. They are
|
||||
* sized and spaced like the kit's 24dp icons and carry the real label for
|
||||
* anyone who cannot see them.
|
||||
*/
|
||||
@Composable
|
||||
private fun BarAction(
|
||||
@@ -861,7 +769,8 @@ private fun BarAction(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick)
|
||||
@@ -869,9 +778,7 @@ private fun BarAction(
|
||||
.testTag(tag),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// An `Icon`, not a glyph in a text run. See `KitIcons.kt`, the bar
|
||||
// used to draw "⊞" and "+" as characters, which is most of why this
|
||||
// screen read as text links rather than as chrome.
|
||||
// An `Icon`, not a glyph in a text run.
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
@@ -881,7 +788,6 @@ private fun BarAction(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.VaultUnreadable(
|
||||
model: MessengerViewModel,
|
||||
@@ -898,12 +804,11 @@ private fun ColumnScope.VaultUnreadable(
|
||||
"Starting fresh publishes a new device. Everyone you talk to will see " +
|
||||
"\"security keys changed\", so tell them to expect it.",
|
||||
)
|
||||
// `discardUnreadableVault`, NOT `signOut`. `signOut()` defaults to
|
||||
// keeping the device, so it left the unreadable vault in place and the next
|
||||
// restore came straight back here, the only button on a dead-end screen
|
||||
// did nothing but redraw it.
|
||||
Button(onClick = model::discardUnreadableVault,
|
||||
modifier = Modifier.testTag("start-fresh")) {
|
||||
// `discardUnreadableVault`, NOT `signOut`.
|
||||
Button(
|
||||
onClick = model::discardUnreadableVault,
|
||||
modifier = Modifier.testTag("start-fresh"),
|
||||
) {
|
||||
Text("Erase and start fresh")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,31 +9,12 @@ import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Strip everything from a video except the pictures and the sound.
|
||||
*
|
||||
* A clip from the camera roll says where it was shot. Android writes the
|
||||
* capture location into the MP4 container, the `©xyz` / `location` atom, and
|
||||
* the story path used to hand the picked file over byte for byte, so posting a
|
||||
* video told every viewer the coordinates of wherever the person happened to
|
||||
* be. Nothing on screen suggested it and no setting controlled it. Photos were
|
||||
* already safe by accident, because [ImagePrep] re-encodes them through a
|
||||
* bitmap and `Bitmap.compress` writes no EXIF; video had no equivalent step at
|
||||
* all.
|
||||
*
|
||||
* A remux, not a transcode. The samples are copied across untouched, so
|
||||
* there is no quality loss and no codec work, the container is rebuilt and the
|
||||
* metadata simply is not carried over. That is cheap enough to do on every
|
||||
* post, which matters: a scrub people wait for is a scrub somebody eventually
|
||||
* makes optional.
|
||||
*
|
||||
* Rotation IS carried over, deliberately. It says which way up the phone
|
||||
* was, not where it was, and dropping it plays every portrait clip on its side.
|
||||
*/
|
||||
object MediaScrub {
|
||||
|
||||
/**
|
||||
* @param source the picked video's bytes.
|
||||
* @param rotationDegrees from `METADATA_KEY_VIDEO_ROTATION`, re-applied to
|
||||
* the rebuilt file.
|
||||
* @param rotationDegrees from `METADATA_KEY_VIDEO_ROTATION`, re-applied
|
||||
* to the rebuilt file.
|
||||
* @return scrubbed bytes, or null if the file could not be rebuilt, the
|
||||
* caller must then refuse the post rather than fall back to the original.
|
||||
*/
|
||||
@@ -54,9 +35,7 @@ object MediaScrub {
|
||||
extractor.setDataSource(input.absolutePath)
|
||||
muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
|
||||
// Audio and video only. Anything else in the file, timed text, a
|
||||
// sensor track, whatever a particular camera app decided to add,
|
||||
// is exactly the kind of passenger this exists to leave behind.
|
||||
// Audio and video only.
|
||||
val trackMap = HashMap<Int, Int>()
|
||||
var bufferSize = DEFAULT_BUFFER
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
@@ -87,9 +66,7 @@ object MediaScrub {
|
||||
info.offset = 0
|
||||
info.size = size
|
||||
info.presentationTimeUs = extractor.sampleTime
|
||||
// Mapped rather than assigned across. The extractor's
|
||||
// flags and the codec's are different vocabularies that
|
||||
// happen to agree on the value of "sync sample" today.
|
||||
// Mapped rather than assigned across.
|
||||
info.flags =
|
||||
if (extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0) {
|
||||
MediaCodec.BUFFER_FLAG_KEY_FRAME
|
||||
|
||||
@@ -4,21 +4,14 @@ import android.content.Context
|
||||
|
||||
/**
|
||||
* How photos are prepared before they are sent.
|
||||
*
|
||||
* Ordinary SharedPreferences, not the vault. This records a preference, not
|
||||
* a secret, and the picker needs to read it on a path that must not depend on
|
||||
* the encrypted store being open.
|
||||
*/
|
||||
class MediaSettings(context: Context) {
|
||||
|
||||
class MediaSettings(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Send photos at the highest quality that will still fit.
|
||||
*
|
||||
* Not "uncompressed". The relay refuses a blob over its cap, so this
|
||||
* raises the ceiling and [ImagePrep] steps down until the bytes fit, a
|
||||
* photo that failed to send would be worse than a slightly softer one.
|
||||
*/
|
||||
var fullQuality: Boolean
|
||||
get() = prefs.getBoolean(FULL_QUALITY, false)
|
||||
@@ -26,18 +19,6 @@ class MediaSettings(context: Context) {
|
||||
|
||||
/**
|
||||
* How long a story posted from this phone stays visible, in hours.
|
||||
*
|
||||
* A day was the only answer the app had. The lifetime was a
|
||||
* constant, so the one thing people actually want to change about a story
|
||||
* how long it lasts, was the one thing they could not.
|
||||
*
|
||||
* The choice is the SENDER's and rides inside the sealed envelope
|
||||
* (`exp`, §6.6), so it does not ask the relay to keep a promise: every copy
|
||||
* carries the expiry its poster chose.
|
||||
*
|
||||
* Clamped to the offered set on read. A value from a future build, or a
|
||||
* corrupted preference, must not post a story that expires before anybody
|
||||
* sees it, which is what a raw 0 would do.
|
||||
*/
|
||||
var storyLifetimeHours: Int
|
||||
get() {
|
||||
@@ -51,32 +32,16 @@ class MediaSettings(context: Context) {
|
||||
|
||||
/**
|
||||
* 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() }
|
||||
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.
|
||||
*
|
||||
* This lives here because it was not stored anywhere at all. It was
|
||||
* a plain in-memory field on the trust object, set from the switch and
|
||||
* never written down, so it reverted to off on every launch. A person who
|
||||
* turned it on was protected until they next closed the app, and nothing
|
||||
* told them it had lapsed, the switch simply read off again, and a
|
||||
* setting that quietly disarms itself is worse than one that was never
|
||||
* offered. iOS has always persisted this in UserDefaults; Android had
|
||||
* nothing.
|
||||
*
|
||||
* Still off by default, deliberately. A relay older than the
|
||||
* transparency log has no log to prove anything against, so enforcing it
|
||||
* everywhere would refuse every peer on those deployments.
|
||||
*/
|
||||
var requireKeyLog: Boolean
|
||||
get() = prefs.getBoolean(REQUIRE_KEY_LOG, false)
|
||||
@@ -84,11 +49,6 @@ class MediaSettings(context: Context) {
|
||||
|
||||
/**
|
||||
* Generate a preview card when sending a link.
|
||||
*
|
||||
* Fetching happens on THIS device before the message goes, so the site
|
||||
* learns somebody is about to share that URL, from this address. The
|
||||
* recipient never fetches anything (§6.12), this end does, and that is
|
||||
* what the setting is for.
|
||||
*/
|
||||
var linkPreviews: Boolean
|
||||
get() = prefs.getBoolean(LINK_PREVIEWS, true)
|
||||
@@ -100,6 +60,7 @@ class MediaSettings(context: Context) {
|
||||
private const val FULL_QUALITY = "full_quality"
|
||||
private const val STORY_LIFETIME_HOURS = "stories.lifetimeHours"
|
||||
const val STORY_LIFETIME_DEFAULT = 24
|
||||
|
||||
/** Same set as iOS, so the two apps offer the same thing. */
|
||||
val STORY_LIFETIME_CHOICES = listOf(1, 6, 24, 72, 168)
|
||||
private const val LINK_PREVIEWS = "link_previews"
|
||||
@@ -107,25 +68,24 @@ class MediaSettings(context: Context) {
|
||||
|
||||
/**
|
||||
* 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() }
|
||||
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(
|
||||
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))
|
||||
lastUnsealedAt = prefs.getLong("privacy.sealing.lastUnsealedAt", 0L),
|
||||
)
|
||||
set(value) {
|
||||
prefs.edit()
|
||||
prefs
|
||||
.edit()
|
||||
.putInt("privacy.sealing.sealed", value.sealed)
|
||||
.putInt("privacy.sealing.unsealed", value.unsealed)
|
||||
.putLong("privacy.sealing.lastUnsealedAt", value.lastUnsealedAt)
|
||||
|
||||
@@ -10,9 +10,9 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -22,8 +22,8 @@ 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
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -35,12 +35,8 @@ import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.Vault
|
||||
|
||||
/**
|
||||
* The kit's `07 Messages-Listing` as its own tab: large title, search box,
|
||||
* the Recent circles row, then a flat list of conversations.
|
||||
*
|
||||
* Conversations, not contacts. Home lists everybody and manages the
|
||||
* roster; this screen is the inbox, people you talk to, unread first. Same
|
||||
* split iOS ships, so the two apps stay one product.
|
||||
* The kit's `07 Messages-Listing` as its own tab: large title, search box, the
|
||||
* Recent circles row, then a flat list of conversations.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.MessagesScreen(
|
||||
@@ -53,8 +49,10 @@ fun ColumnScope.MessagesScreen(
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
var scannedPrefill by remember { mutableStateOf("") }
|
||||
val scanContext = androidx.compose.ui.platform.LocalContext.current
|
||||
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
val askCamera =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts
|
||||
.RequestPermission(),
|
||||
) { _ ->
|
||||
// Open either way, the dialog explains itself when access is refused.
|
||||
scanning = true
|
||||
@@ -77,23 +75,29 @@ fun ColumnScope.MessagesScreen(
|
||||
KitIcon.Scan,
|
||||
contentDescription = "Scan an address",
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.clickable {
|
||||
val has = androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
scanContext, android.Manifest.permission.CAMERA
|
||||
val has =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
scanContext,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) scanning = true
|
||||
else askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
if (has) {
|
||||
scanning = true
|
||||
} else {
|
||||
askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
.testTag("messages-scan"),
|
||||
}.testTag("messages-scan"),
|
||||
)
|
||||
Spacer(Modifier.width(Space.md))
|
||||
androidx.compose.material3.Icon(
|
||||
if (addingContact) KitIcon.Close else KitIcon.Add,
|
||||
contentDescription = "Add contact",
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(26.dp)
|
||||
.clickable { addingContact = !addingContact }
|
||||
.testTag("messages-add-contact"),
|
||||
@@ -105,9 +109,14 @@ fun ColumnScope.MessagesScreen(
|
||||
onFound = { raw ->
|
||||
scanning = false
|
||||
parseScannedAddress(raw)?.let { card ->
|
||||
scannedPrefill = if (card.handle != null &&
|
||||
scannedPrefill =
|
||||
if (card.handle != null &&
|
||||
card.relay.equals(phase.relay, ignoreCase = true)
|
||||
) card.handle else "${card.fnid}@${card.relay}"
|
||||
) {
|
||||
card.handle
|
||||
} else {
|
||||
"${card.fnid}@${card.relay}"
|
||||
}
|
||||
addingContact = true
|
||||
}
|
||||
},
|
||||
@@ -132,9 +141,7 @@ 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.
|
||||
// Above the conversation list.
|
||||
MessageHits(model, query.value)
|
||||
|
||||
BuddyCirclesRow(model, phase)
|
||||
@@ -143,9 +150,6 @@ fun ColumnScope.MessagesScreen(
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
// The kit's listing holds every conversation, groups included.
|
||||
// No label of our own above the groups: GroupsSection draws its own
|
||||
// (and only when there is something to head). A second one here printed
|
||||
// "GROUPS" twice.
|
||||
GroupsSection(model, phase, query.value, creatingGroup.value) { creatingGroup.value = it }
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
val showNewChat = remember { mutableStateOf(false) }
|
||||
@@ -158,8 +162,9 @@ fun ColumnScope.MessagesScreen(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Palette.SecondaryText,
|
||||
letterSpacing = androidx.compose.ui.unit.TextUnit(
|
||||
0.8f, androidx.compose.ui.unit.TextUnitType.Sp),
|
||||
letterSpacing =
|
||||
androidx.compose.ui.unit
|
||||
.TextUnit(0.8f, androidx.compose.ui.unit.TextUnitType.Sp),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// The way in, beside the label, exactly as New group sits beside its
|
||||
@@ -168,7 +173,8 @@ fun ColumnScope.MessagesScreen(
|
||||
KitIcon.NewChat,
|
||||
contentDescription = "New chat",
|
||||
tint = Palette.Accent,
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(22.dp)
|
||||
.clickable { showNewChat.value = true }
|
||||
.testTag("new-chat"),
|
||||
@@ -184,16 +190,18 @@ fun ColumnScope.MessagesScreen(
|
||||
} else {
|
||||
Column {
|
||||
phase.buddies.forEach { buddy ->
|
||||
val rowName = buddy.displayName.ifBlank { buddy.handle }
|
||||
val rowName =
|
||||
buddy.displayName
|
||||
.ifBlank { buddy.handle }
|
||||
.ifBlank { "user ${buddy.userId}" }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
showNewChat.value = false
|
||||
model.openChat(buddy.userId, rowName)
|
||||
}
|
||||
.padding(vertical = 10.dp)
|
||||
}.padding(vertical = 10.dp)
|
||||
.testTag("new-chat-${buddy.userId}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -217,22 +225,21 @@ fun ColumnScope.MessagesScreen(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { showNewChat.value = false }) { Text("Cancel") }
|
||||
androidx.compose.material3.TextButton(onClick = { showNewChat.value = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val shown = phase.buddies
|
||||
val shown =
|
||||
phase.buddies
|
||||
.filter {
|
||||
query.value.isBlank() ||
|
||||
it.displayName.contains(query.value, true) ||
|
||||
it.handle.contains(query.value, true)
|
||||
}
|
||||
.sortedWith(
|
||||
}.sortedWith(
|
||||
compareByDescending<io.flamenet.client.MessengerApi.Buddy> {
|
||||
Vault.threadForUser(it.userId) in phase.unread
|
||||
}.thenBy { it.displayName.ifBlank { it.handle } }
|
||||
}.thenBy { it.displayName.ifBlank { it.handle } },
|
||||
)
|
||||
|
||||
if (shown.isEmpty()) {
|
||||
@@ -250,11 +257,14 @@ fun ColumnScope.MessagesScreen(
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().testTag("conversations")) {
|
||||
shown.forEach { buddy ->
|
||||
val name = buddy.displayName.ifBlank { buddy.handle }
|
||||
val name =
|
||||
buddy.displayName
|
||||
.ifBlank { buddy.handle }
|
||||
.ifBlank { "user ${buddy.userId}" }
|
||||
val unread = Vault.threadForUser(buddy.userId) in phase.unread
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.openChat(buddy.userId, name) }
|
||||
.padding(vertical = Kit.rowGap / 2)
|
||||
@@ -279,8 +289,12 @@ fun ColumnScope.MessagesScreen(
|
||||
Text(
|
||||
if (unread) "New messages" else buddy.presence,
|
||||
fontSize = 12.sp,
|
||||
color = if (unread) MaterialTheme.colorScheme.primary
|
||||
else Palette.SecondaryText,
|
||||
color =
|
||||
if (unread) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
Palette.SecondaryText
|
||||
},
|
||||
)
|
||||
}
|
||||
if (unread) UnreadDot()
|
||||
@@ -294,7 +308,8 @@ fun ColumnScope.MessagesScreen(
|
||||
@Composable
|
||||
private fun UnreadDot() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
@@ -303,13 +318,12 @@ private fun UnreadDot() {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
private fun MessageHits(
|
||||
model: MessengerViewModel,
|
||||
query: String,
|
||||
) {
|
||||
var hits by remember { mutableStateOf<List<io.flamenet.e2e.MessageSearch.Hit>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(query) {
|
||||
@@ -333,9 +347,10 @@ private fun MessageHits(model: MessengerViewModel, query: String) {
|
||||
)
|
||||
for (hit in hits) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Space.md, vertical = Space.sm)
|
||||
.testTag("search-hit")
|
||||
.testTag("search-hit"),
|
||||
) {
|
||||
Text(
|
||||
hit.snippet,
|
||||
|
||||
@@ -24,31 +24,8 @@ import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Keeps receiving while the app is not on screen.
|
||||
*
|
||||
* This is the only way to be alerted without a third party. iOS has one
|
||||
* mechanism for waking a suspended app and it is Apple's; Android does not have
|
||||
* that limit, a foreground service may hold a connection with nobody in the
|
||||
* middle. That asymmetry is the whole reason this class exists, and it is worth
|
||||
* being honest about on the privacy page rather than letting people assume the
|
||||
* two platforms behave alike.
|
||||
*
|
||||
* The persistent notification is not optional and not a bug. Android
|
||||
* requires a visible notice for a foreground service. Users will ask to remove
|
||||
* it; removing it means the OS kills the connection. It is the price of not
|
||||
* routing wake-ups through Google.
|
||||
*
|
||||
* Exactly one poller at a time. The ViewModel polls while the app is on
|
||||
* screen and this service polls while it is not, started from `onStop`,
|
||||
* stopped from `onStart`. Two pollers would race for the same envelopes and
|
||||
* double-notify. If that handover ever changes, check both ends.
|
||||
*
|
||||
* Some OEMs (Xiaomi, Huawei, Samsung on aggressive battery settings) kill
|
||||
* foreground services anyway. Nothing here can prevent that; the poll resumes
|
||||
* when the app is next opened, and no message is lost because the relay holds
|
||||
* it until the cursor advances.
|
||||
*/
|
||||
class MessengerService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val ONGOING_ID = 1
|
||||
private const val CHANNEL_ONGOING = "flamenet.ongoing"
|
||||
@@ -85,10 +62,13 @@ class MessengerService : Service() {
|
||||
createChannels()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
// startForeground within a few seconds of onStartCommand or Android
|
||||
// kills the process with a ForegroundServiceDidNotStartInTimeException.
|
||||
// Do it before any I/O, not after the session is built.
|
||||
startInForeground()
|
||||
|
||||
if (loop?.isActive == true) return START_STICKY
|
||||
@@ -107,10 +87,16 @@ class MessengerService : Service() {
|
||||
}
|
||||
|
||||
private fun startInForeground() {
|
||||
val open = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val notice = NotificationCompat.Builder(this, CHANNEL_ONGOING)
|
||||
val open =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val notice =
|
||||
NotificationCompat
|
||||
.Builder(this, CHANNEL_ONGOING)
|
||||
.setContentTitle("Flamenet is receiving")
|
||||
.setContentText("Messages arrive without Google or Apple in the middle.")
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
@@ -120,8 +106,11 @@ class MessengerService : Service() {
|
||||
.build()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(ONGOING_ID, notice,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
|
||||
startForeground(
|
||||
ONGOING_ID,
|
||||
notice,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING,
|
||||
)
|
||||
} else {
|
||||
startForeground(ONGOING_ID, notice)
|
||||
}
|
||||
@@ -129,17 +118,14 @@ class MessengerService : Service() {
|
||||
|
||||
/**
|
||||
* Rebuild the session from the vault rather than sharing the ViewModel's.
|
||||
*
|
||||
* The Activity may already be gone by the time this runs, that is the
|
||||
* situation the service exists for, so there is nothing to share. The
|
||||
* vault is the durable half and both sides read it the same way.
|
||||
*/
|
||||
private fun openSession(): Session? = runCatching {
|
||||
private fun openSession(): Session? =
|
||||
runCatching {
|
||||
val vault: Vault = KeystoreVault.open(applicationContext)
|
||||
val identity = vault.loadIdentity() ?: return null
|
||||
val credential = vault.loadCredential() ?: return null
|
||||
val relay = RelayClient(
|
||||
credential.relayBaseUrl, credential.username, credential.appPassword)
|
||||
val relay =
|
||||
RelayClient(credential.relayBaseUrl, credential.username, credential.appPassword)
|
||||
relay.useRelayAuth(identity)
|
||||
Session(
|
||||
handle = credential.username,
|
||||
@@ -147,134 +133,134 @@ class MessengerService : Service() {
|
||||
identity = identity,
|
||||
vault = vault,
|
||||
relay = relay,
|
||||
api = MessengerApi(
|
||||
credential.relayBaseUrl, credential.username, credential.appPassword),
|
||||
api =
|
||||
MessengerApi(credential.relayBaseUrl, credential.username, credential.appPassword),
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private suspend fun run() {
|
||||
val s = openSession() ?: run {
|
||||
val s =
|
||||
openSession() ?: run {
|
||||
// Nothing to receive for, signed out, or the vault is unreadable.
|
||||
// Stop rather than sit in the notification shade doing nothing.
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
session = s
|
||||
// The constant, not the literal: the wire value is "call-offer",
|
||||
// and a bare "offer" matches nothing and rings for no call ever.
|
||||
// The constant, not the literal: the wire value is "call-offer", and a
|
||||
// bare "offer" matches nothing and rings for no call ever.
|
||||
s.onCallSignal = { fromUser, signal, sentAt ->
|
||||
when (signal.action) {
|
||||
io.flamenet.e2e.Content.CallSignal.OFFER -> {
|
||||
// Park the offer BEFORE notifying. This session consumed
|
||||
// the envelope, so the SDP in hand is the only copy, the
|
||||
// UI answers from PendingIncomingCall or not at all.
|
||||
// Offers old enough that the caller gave up are notified
|
||||
// as missed, not parked as answerable.
|
||||
// Park the offer BEFORE notifying.
|
||||
if (sentAt > 0 &&
|
||||
System.currentTimeMillis() - sentAt > CallManager.DIAL_TIMEOUT_MS) {
|
||||
System.currentTimeMillis() - sentAt > CallManager.DIAL_TIMEOUT_MS
|
||||
) {
|
||||
runCatching { s.recordCall(fromUser, incoming = true, connected = false) }
|
||||
} else {
|
||||
PendingIncomingCall.park(fromUser, signal, sentAt)
|
||||
notifyCall(nameFor(fromUser, s))
|
||||
}
|
||||
}
|
||||
|
||||
io.flamenet.e2e.Content.CallSignal.END -> {
|
||||
// The caller hung up before anyone opened the app. An
|
||||
// offer left parked would ring for a dead call, and so
|
||||
// would the notification, which is now `ongoing` and will
|
||||
// NOT go away on its own.
|
||||
// The caller hung up before anyone opened the app.
|
||||
PendingIncomingCall.clear(signal.callId)
|
||||
clearCallNotification()
|
||||
}
|
||||
else -> Unit
|
||||
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val touched = runCatching { s.receive() }.getOrDefault(emptySet())
|
||||
if (touched.isNotEmpty()) notifyMessages(touched.size)
|
||||
// Reply reminders fire from THIS loop, after `receive()`, and
|
||||
// the order matters: an inbound message clears the reminder inside
|
||||
// Reply reminders fire from THIS loop, after `receive()`, and the
|
||||
// order matters: an inbound message clears the reminder inside
|
||||
// `Session.record`, so a reply that has just landed cancels its own
|
||||
// reminder before it can go off. Checking first would fire a
|
||||
// reminder for a conversation that was answered a moment ago,
|
||||
// which is the one thing that would teach people to ignore them.
|
||||
// reminder before it can go off.
|
||||
fireDueReplyReminders(s)
|
||||
// An unanswered ring has to stop ringing. The notification is
|
||||
// `ongoing`, so nothing dismisses it on its own: without this a
|
||||
// call nobody picked up leaves a permanent "incoming call" on the
|
||||
// lock screen, with an Answer button for a call that is long dead.
|
||||
// An unanswered ring has to stop ringing.
|
||||
expireStaleRing()
|
||||
// A short gap, not a poll interval: `receive()` is held open by
|
||||
// the relay for WAIT_SECONDS, so this only paces a relay that
|
||||
// answers immediately.
|
||||
// A short gap, not a poll interval: `receive()` is held open by the
|
||||
// relay for WAIT_SECONDS, so this only paces a relay that answers
|
||||
// immediately.
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller's name, from the buddy list.
|
||||
*
|
||||
* Cached for the life of the service and refreshed only on a miss. This
|
||||
* runs on a ringing phone, the user has seconds, so a name lookup must
|
||||
* not wait on the network when it already knows the answer.
|
||||
*/
|
||||
private var names: Map<Long, String> = emptyMap()
|
||||
|
||||
private fun nameFor(userId: Long, s: Session): String {
|
||||
private fun nameFor(
|
||||
userId: Long,
|
||||
s: Session,
|
||||
): String {
|
||||
names[userId]?.let { return it }
|
||||
names = runCatching {
|
||||
names =
|
||||
runCatching {
|
||||
s.roster().associate { it.userId to it.displayName.ifBlank { it.handle } }
|
||||
}.getOrDefault(names)
|
||||
// Falls back to something answerable rather than a bare number.
|
||||
return names[userId] ?: "Someone"
|
||||
}
|
||||
|
||||
private fun notifyMessages(threads: Int, from: String? = null) {
|
||||
private fun notifyMessages(
|
||||
threads: Int,
|
||||
from: String? = null,
|
||||
) {
|
||||
val settings = NotificationSettings(this)
|
||||
// The master switch and the per-kind one, both checked before a
|
||||
// notification is built rather than after. A notification suppressed
|
||||
// late still woke the screen.
|
||||
// notification is built rather than after.
|
||||
if (!settings.enabled || !settings.newMessages) return
|
||||
|
||||
val open = PendingIntent.getActivity(
|
||||
this, 1, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
// Still no sender and no preview unless it was asked for. The
|
||||
// notification is drawn on this device from locally decrypted
|
||||
// plaintext, so it *could* always have carried both, but a lock screen
|
||||
// is a shoulder-surfing surface, and the product's claim is that a
|
||||
// message is for the two people in it. `NotificationSettings` decides;
|
||||
// its default is the count-only behaviour that was here before, so
|
||||
// nobody acquires a name on their lock screen by upgrading.
|
||||
val open =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
1,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
// Still no sender and no preview unless it was asked for.
|
||||
val text = settings.messageText(threads, from)
|
||||
notify(CHANNEL_MESSAGES, 2, text, NotificationCompat.PRIORITY_HIGH, open,
|
||||
number = threads)
|
||||
notify(
|
||||
CHANNEL_MESSAGES,
|
||||
2,
|
||||
text,
|
||||
NotificationCompat.PRIORITY_HIGH,
|
||||
open,
|
||||
number = threads,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about conversations nobody answered, and forget them.
|
||||
*
|
||||
* Cleared as it fires. A reminder is a one-shot: leaving the row would
|
||||
* re-notify on the next cycle, half a second later, for ever.
|
||||
*/
|
||||
private fun fireDueReplyReminders(s: Session) {
|
||||
val due = runCatching { s.dueReplyReminders() }.getOrDefault(emptyMap())
|
||||
if (due.isEmpty()) return
|
||||
due.keys.forEach { threadId -> runCatching { s.vault.saveReplyReminder(threadId, 0L) } }
|
||||
val open = PendingIntent.getActivity(
|
||||
this, 4, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
// Same privacy rule as a message notification, and the same setting:
|
||||
val open =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
4,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
// Same privacy rule as a message notification, and the same setting.
|
||||
// who you are waiting on is as sensitive as what they said.
|
||||
val settings = NotificationSettings(this)
|
||||
if (!settings.enabled || !settings.newMessages) return
|
||||
// Only a one-to-one thread can be named. `threadForUser` writes
|
||||
// `user:<id>`, and groups and cross-relay peers use their own prefixes
|
||||
// so anything else falls through to the count, which is the safe
|
||||
// direction: a wrong name on a lock screen is worse than no name.
|
||||
val only = due.keys.singleOrNull()
|
||||
?.removePrefix("user:")?.takeIf { it.all(Char::isDigit) }
|
||||
// Only a one-to-one thread can be named.
|
||||
val only =
|
||||
due.keys
|
||||
.singleOrNull()
|
||||
?.removePrefix("user:")
|
||||
?.takeIf { it.all(Char::isDigit) }
|
||||
?.toLongOrNull()
|
||||
?.let { userId -> runCatching { nameFor(userId, s) }.getOrNull() }
|
||||
val text = settings.replyReminderText(due.size, only)
|
||||
@@ -283,38 +269,35 @@ class MessengerService : Service() {
|
||||
|
||||
/**
|
||||
* Ring, in a way that can be answered without opening the app first.
|
||||
*
|
||||
* A high-priority notification is a banner, not a ringing phone. The
|
||||
* previous version was one: on a locked device it showed a line of text and
|
||||
* the only way to act on it was to unlock, find the app, and open it, by
|
||||
* which time the caller has given up. A call is a live event with seconds
|
||||
* on the clock, so it needs a full-screen intent, which is what wakes a
|
||||
* locked screen into something answerable.
|
||||
*
|
||||
* `CallStyle` and `CATEGORY_CALL` are not decoration. Android 14 grants
|
||||
* USE_FULL_SCREEN_INTENT at install only to apps that ring like this;
|
||||
* without them the permission is refused and the call silently degrades to
|
||||
* the banner it used to be.
|
||||
*
|
||||
* The Answer button hands off through the SAME parked offer the app uses
|
||||
* on launch. The SDP in hand is the only copy, this session consumed the
|
||||
* envelope, so a second, separate answer path would be a second way to
|
||||
* lose a call.
|
||||
*/
|
||||
private fun notifyCall(from: String) {
|
||||
val full = PendingIntent.getActivity(
|
||||
this, 2,
|
||||
val full =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
2,
|
||||
Intent(this, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.putExtra(EXTRA_ANSWER_CALL, true),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val decline = PendingIntent.getBroadcast(
|
||||
this, 6, Intent(this, CallActionReceiver::class.java).setAction(ACTION_DECLINE),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val decline =
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
6,
|
||||
Intent(this, CallActionReceiver::class.java).setAction(ACTION_DECLINE),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val caller = androidx.core.app.Person.Builder().setName(from).setImportant(true).build()
|
||||
val notice = NotificationCompat.Builder(this, CHANNEL_CALLS)
|
||||
val caller =
|
||||
androidx.core.app.Person
|
||||
.Builder()
|
||||
.setName(from)
|
||||
.setImportant(true)
|
||||
.build()
|
||||
val notice =
|
||||
NotificationCompat
|
||||
.Builder(this, CHANNEL_CALLS)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
@@ -327,22 +310,23 @@ class MessengerService : Service() {
|
||||
.build()
|
||||
|
||||
runCatching {
|
||||
androidx.core.app.NotificationManagerCompat.from(this).notify(3, notice)
|
||||
androidx.core.app.NotificationManagerCompat
|
||||
.from(this)
|
||||
.notify(3, notice)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop ringing, the call was declined, or it ended on its own. */
|
||||
private fun clearCallNotification() {
|
||||
runCatching { androidx.core.app.NotificationManagerCompat.from(this).cancel(3) }
|
||||
runCatching {
|
||||
androidx.core.app.NotificationManagerCompat
|
||||
.from(this)
|
||||
.cancel(3)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ringing for an offer nobody answered in time.
|
||||
*
|
||||
* `PendingIncomingCall.take()` already refuses to hand over an offer
|
||||
* older than the dial timeout, so the offer itself expires correctly, but
|
||||
* the notification is a separate object and expires by nothing at all.
|
||||
* Left alone it sits on the lock screen offering to answer a dead call.
|
||||
*/
|
||||
private fun expireStaleRing() {
|
||||
if (PendingIncomingCall.isStale()) {
|
||||
@@ -351,9 +335,18 @@ class MessengerService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(channel: String, id: Int, text: String, priority: Int,
|
||||
open: PendingIntent, category: String? = null, number: Int = 0) {
|
||||
val n = NotificationCompat.Builder(this, channel)
|
||||
private fun notify(
|
||||
channel: String,
|
||||
id: Int,
|
||||
text: String,
|
||||
priority: Int,
|
||||
open: PendingIntent,
|
||||
category: String? = null,
|
||||
number: Int = 0,
|
||||
) {
|
||||
val n =
|
||||
NotificationCompat
|
||||
.Builder(this, channel)
|
||||
.setContentTitle("Flamenet")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
@@ -361,9 +354,7 @@ class MessengerService : Service() {
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(open)
|
||||
.also { b -> category?.let { b.setCategory(it) } }
|
||||
// The launcher badge. A dot on every launcher; the count itself on
|
||||
// launchers that show one. Same privacy rule as the text: a number,
|
||||
// never a name or a preview.
|
||||
// The launcher badge.
|
||||
.also { b -> if (number > 0) b.setNumber(number) }
|
||||
.setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL)
|
||||
.build()
|
||||
@@ -376,20 +367,32 @@ class MessengerService : Service() {
|
||||
private fun createChannels() {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ONGOING, "Receiving",
|
||||
NotificationManager.IMPORTANCE_MIN).apply {
|
||||
NotificationChannel(
|
||||
CHANNEL_ONGOING,
|
||||
"Receiving",
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
).apply {
|
||||
description = "The notice Android requires while Flamenet is receiving."
|
||||
// The always-on service notice must not put a dot on the icon:
|
||||
// The always-on service notice must not put a dot on the icon.
|
||||
// a permanent badge reads as "you have unread mail", for ever.
|
||||
setShowBadge(false)
|
||||
})
|
||||
},
|
||||
)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_MESSAGES, "Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH).apply { setShowBadge(true) })
|
||||
NotificationChannel(
|
||||
CHANNEL_MESSAGES,
|
||||
"Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply { setShowBadge(true) },
|
||||
)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_CALLS, "Calls",
|
||||
NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
NotificationChannel(
|
||||
CHANNEL_CALLS,
|
||||
"Calls",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
setBypassDnd(false)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,55 +6,49 @@ import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Strips metadata from an image on its way out, whichever door it left by.
|
||||
*
|
||||
* A photo sent as a *file* used to keep its GPS; sent as a *photo* it did
|
||||
* not (android#17, ios#69). The picker path runs through [ImagePrep], which
|
||||
* decodes to a bitmap and re-encodes, the output is pixels, with no container
|
||||
* to carry EXIF. `Session.sendFile` handed the stored bytes straight to
|
||||
* `sendAttachment`. Somebody who had learned "Flamenet strips location from my
|
||||
* photos" had learned something true of one button and false of the one beside
|
||||
* it, and the safer one looked like the shortcut.
|
||||
*
|
||||
* Sniffed, not trusted. The vault stores no MIME type and a file name is
|
||||
* a guess in both directions: `holiday.txt` can be a JPEG and `photo.jpg` can be
|
||||
* text. Deciding by extension would strip the wrong things and miss the right
|
||||
* ones.
|
||||
*
|
||||
* Only images are touched. "Send this exact file" is what a file send
|
||||
* means, and silently rewriting somebody's PDF or spreadsheet would be its own
|
||||
* surprise. Anything not recognised goes byte-for-byte.
|
||||
*
|
||||
* Matches `MetadataScrub` on iOS, format list included.
|
||||
*/
|
||||
object MetadataScrub {
|
||||
|
||||
enum class ImageKind { JPEG, PNG, HEIC, TIFF, WEBP, GIF }
|
||||
|
||||
/** What happened, so the caller can say so rather than guess. */
|
||||
data class Result(val bytes: ByteArray, val strippedKind: ImageKind?) {
|
||||
data class Result(
|
||||
val bytes: ByteArray,
|
||||
val strippedKind: ImageKind?,
|
||||
) {
|
||||
// ByteArray in a data class needs these, or equality compares identity.
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other || (other is Result &&
|
||||
bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind)
|
||||
this === other || (
|
||||
other is Result &&
|
||||
bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind
|
||||
)
|
||||
|
||||
override fun hashCode(): Int = 31 * bytes.contentHashCode() + (strippedKind?.hashCode() ?: 0)
|
||||
}
|
||||
|
||||
/** Pure, and the reason this is testable without a device. */
|
||||
fun kind(of: ByteArray): ImageKind? {
|
||||
fun at(i: Int): Int? = if (i < of.size) of[i].toInt() and 0xFF else null
|
||||
fun matches(bytes: List<Int>, offset: Int = 0): Boolean =
|
||||
bytes.withIndex().all { (i, b) -> at(offset + i) == b }
|
||||
|
||||
fun matches(
|
||||
bytes: List<Int>,
|
||||
offset: Int = 0,
|
||||
): Boolean = bytes.withIndex().all { (i, b) -> at(offset + i) == b }
|
||||
|
||||
if (matches(listOf(0xFF, 0xD8, 0xFF))) return ImageKind.JPEG
|
||||
if (matches(listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))) return ImageKind.PNG
|
||||
if (matches(listOf(0x47, 0x49, 0x46, 0x38))) return ImageKind.GIF
|
||||
// TIFF, either byte order. Also the container many RAW files use,
|
||||
// which is exactly why it should be recognised rather than sent raw.
|
||||
// TIFF, either byte order.
|
||||
if (matches(listOf(0x49, 0x49, 0x2A, 0x00)) ||
|
||||
matches(listOf(0x4D, 0x4D, 0x00, 0x2A))) return ImageKind.TIFF
|
||||
matches(listOf(0x4D, 0x4D, 0x00, 0x2A))
|
||||
) {
|
||||
return ImageKind.TIFF
|
||||
}
|
||||
// RIFF….WEBP, the size field sits between the two markers.
|
||||
if (matches(listOf(0x52, 0x49, 0x46, 0x46)) &&
|
||||
matches(listOf(0x57, 0x45, 0x42, 0x50), 8)) return ImageKind.WEBP
|
||||
matches(listOf(0x57, 0x45, 0x42, 0x50), 8)
|
||||
) {
|
||||
return ImageKind.WEBP
|
||||
}
|
||||
// ISO-BMFF: "ftyp" at offset 4, then a HEIF brand.
|
||||
if (matches(listOf(0x66, 0x74, 0x79, 0x70), 4)) {
|
||||
val brand = (8..11).mapNotNull { at(it) }.map { it.toChar() }.joinToString("")
|
||||
@@ -68,25 +62,17 @@ object MetadataScrub {
|
||||
/**
|
||||
* Re-encode an image so nothing but pixels survives; pass anything else
|
||||
* through unchanged.
|
||||
*
|
||||
* Re-encoded, not filtered. Removing the EXIF block by hand leaves
|
||||
* XMP, IPTC, MakerNote and thumbnail streams, several of which carry
|
||||
* location independently. Decoding to a bitmap and writing out again is the
|
||||
* only version of this with no list to keep up to date.
|
||||
*
|
||||
* Undecodable bytes are returned as they came. A file that will not
|
||||
* decode is not necessarily a file with location in it, and losing
|
||||
* somebody's data to a scrubber is a worse failure than the one being
|
||||
* fixed.
|
||||
*/
|
||||
fun scrub(data: ByteArray): Result {
|
||||
val kind = kind(data) ?: return Result(data, null)
|
||||
val bitmap = runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull()
|
||||
val bitmap =
|
||||
runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull()
|
||||
?: return Result(data, null)
|
||||
val out = ByteArrayOutputStream()
|
||||
// PNG where a lossy pass would be a second surprise; JPEG otherwise, at
|
||||
// a quality high enough not to be visible.
|
||||
val format = when (kind) {
|
||||
val format =
|
||||
when (kind) {
|
||||
ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG
|
||||
ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@ package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -17,6 +16,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -30,18 +30,6 @@ import io.flamenet.obfs4.Obfs4Stats
|
||||
|
||||
/**
|
||||
* Reaching Flamenet on a network that blocks it.
|
||||
*
|
||||
* This screen exists because a bad line used to be silent. A line that
|
||||
* would not parse became "no bridge", and "no bridge" is a direct connection,
|
||||
* so somebody who pasted a bridge *because* direct does not work would have
|
||||
* been put straight back on the path they were escaping, with the setting still
|
||||
* looking configured. Nothing is stored here that could not be used, and what
|
||||
* is shown afterwards is whether a handshake has actually completed, not what
|
||||
* is written in preferences.
|
||||
*
|
||||
* Every word has to stay honest about what this protects against. A
|
||||
* bridge hides traffic from whoever is between the phone and the bridge. The
|
||||
* bridge then proxies to the relay, which sees exactly what it saw before.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.NetworkSection(
|
||||
@@ -54,12 +42,11 @@ fun ColumnScope.NetworkSection(
|
||||
var stored by remember { mutableStateOf(settings.bridgeLine) }
|
||||
// Bumped on save/remove so the live status below re-reads.
|
||||
var tick by remember { mutableIntStateOf(0) }
|
||||
// Who has been handed the line this sitting. A confirmation, not a record:
|
||||
// it is not persisted, because whether somebody still has a working bridge
|
||||
// is not something this phone can know.
|
||||
// Who has been handed the line this sitting.
|
||||
var sharedWith by remember { mutableStateOf(setOf<Long>()) }
|
||||
|
||||
@Suppress("UNUSED_EXPRESSION") tick
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
tick
|
||||
|
||||
val parsed = if (draft.isBlank()) null else BridgeLine.parse(draft)
|
||||
val isUnchanged = draft.trim() == (stored ?: "")
|
||||
@@ -67,8 +54,7 @@ fun ColumnScope.NetworkSection(
|
||||
Text("Network", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
// Reads the handshake counter and the bridge in force, never the stored
|
||||
// line. The two disagree exactly when it matters, a saved bridge that is
|
||||
// not there leaves the app connecting directly, and only this says so.
|
||||
// line.
|
||||
Text(
|
||||
statusLine(),
|
||||
fontSize = 13.sp,
|
||||
@@ -76,13 +62,7 @@ fun ColumnScope.NetworkSection(
|
||||
modifier = Modifier.testTag("obfs4-status"),
|
||||
)
|
||||
|
||||
// Offers from contacts, and the reason they are a list and not a
|
||||
// setting. A bridge line routes every relay connection through a machine
|
||||
// somebody else chose. Nothing applies one on arrival, a contact, or
|
||||
// whoever has taken their account, could otherwise move this phone's
|
||||
// traffic onto a host they control. They could not read it, but they would
|
||||
// learn who is online and when, and could cut them off. Accepting is a
|
||||
// button, pressed by the person it was sent to.
|
||||
// Offers from contacts, and the reason they are a list and not a setting.
|
||||
val offers = remember(tick) { model?.bridgeOffers().orEmpty() }
|
||||
if (offers.isNotEmpty()) {
|
||||
Spacer(Modifier.height(Space.md))
|
||||
@@ -94,10 +74,7 @@ fun ColumnScope.NetworkSection(
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.testTag("obfs4-offer-${offer.fromUser}"),
|
||||
)
|
||||
// The line is shown, not hidden behind "Accept". Somebody may
|
||||
// want to check the fingerprint against another source before
|
||||
// routing their traffic through it, and they cannot check what
|
||||
// they cannot see.
|
||||
// The line is shown, not hidden behind "Accept".
|
||||
Text(
|
||||
offer.line,
|
||||
fontSize = 11.sp,
|
||||
@@ -105,19 +82,24 @@ fun ColumnScope.NetworkSection(
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
when (val parsed = BridgeLine.parse(offer.line)) {
|
||||
is BridgeLineResult.Bad -> Text(
|
||||
is BridgeLineResult.Bad -> {
|
||||
Text(
|
||||
// An offer that cannot be used says why, rather than
|
||||
// failing when it is accepted.
|
||||
parsed.problem.message,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.Danger,
|
||||
)
|
||||
is BridgeLineResult.Ok -> Text(
|
||||
}
|
||||
|
||||
is BridgeLineResult.Ok -> {
|
||||
Text(
|
||||
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
KitPrimaryButton(
|
||||
@@ -137,7 +119,10 @@ fun ColumnScope.NetworkSection(
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = "Dismiss",
|
||||
onClick = { model?.dismissBridgeOffer(offer.fromUser); tick++ },
|
||||
onClick = {
|
||||
model?.dismissBridgeOffer(offer.fromUser)
|
||||
tick++
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "obfs4-offer-dismiss-${offer.fromUser}",
|
||||
)
|
||||
@@ -157,15 +142,18 @@ fun ColumnScope.NetworkSection(
|
||||
modifier = Modifier.fillMaxWidth().testTag("obfs4-bridge-line"),
|
||||
)
|
||||
|
||||
// Under the field and always in the same place, so it reads as feedback
|
||||
// on what was typed rather than as a warning from somewhere else.
|
||||
// Under the field and always in the same place, so it reads as feedback on
|
||||
// what was typed rather than as a warning from somewhere else.
|
||||
when (parsed) {
|
||||
is BridgeLineResult.Bad -> Text(
|
||||
is BridgeLineResult.Bad -> {
|
||||
Text(
|
||||
parsed.problem.message,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.Danger,
|
||||
modifier = Modifier.testTag("obfs4-problem"),
|
||||
)
|
||||
}
|
||||
|
||||
is BridgeLineResult.Ok -> {
|
||||
Text(
|
||||
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
|
||||
@@ -182,7 +170,10 @@ fun ColumnScope.NetworkSection(
|
||||
)
|
||||
}
|
||||
}
|
||||
null -> Unit
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
@@ -191,8 +182,7 @@ fun ColumnScope.NetworkSection(
|
||||
text = "Save this bridge",
|
||||
onClick = {
|
||||
// Goes through `save`, which parses again and stores only what it
|
||||
// could use. The button being enabled is a convenience; the refusal
|
||||
// lives one layer down, where nothing can route around it.
|
||||
// could use.
|
||||
if (settings.save(draft) is BridgeLineResult.Ok) {
|
||||
stored = settings.bridgeLine
|
||||
tick++
|
||||
@@ -218,21 +208,12 @@ fun ColumnScope.NetworkSection(
|
||||
)
|
||||
}
|
||||
|
||||
// The other half of receiving one. A bridge is only useful while
|
||||
// whoever wants to block it does not have the line, and every public way of
|
||||
// handing one out is somewhere a censor can go and collect them. Passing it
|
||||
// to somebody you already talk to is the one route that does not scale for
|
||||
// them: enumerating bridges becomes infiltrating friendships one at a time.
|
||||
//
|
||||
// Only when a bridge is actually configured. "Share" on a screen with
|
||||
// nothing set would send an empty line and put an un-actionable row on
|
||||
// somebody else's settings screen.
|
||||
// The other half of receiving one.
|
||||
if (stored != null && buddies.isNotEmpty()) {
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text("Share it with", fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
Text(
|
||||
// Says who learns what. The bridge operator sees a new client; the
|
||||
// contact learns you use this bridge. Neither is nothing.
|
||||
// Says who learns what.
|
||||
"They get the line and decide for themselves. Whoever runs the bridge will " +
|
||||
"see one more person connecting, and your contact will know you use it.",
|
||||
fontSize = 12.sp,
|
||||
@@ -254,9 +235,7 @@ fun ColumnScope.NetworkSection(
|
||||
model?.shareBridge(buddy.userId)
|
||||
sharedWith = sharedWith + buddy.userId
|
||||
},
|
||||
// Not disabled after sending. An operator rotates a
|
||||
// bridge and the same person needs the new line; a button
|
||||
// that never re-arms would make that impossible from here.
|
||||
// Not disabled after sending.
|
||||
tag = "obfs4-share-${buddy.userId}",
|
||||
)
|
||||
}
|
||||
@@ -281,35 +260,32 @@ private fun parsedOk(line: String) = BridgeLine.parse(line) is BridgeLineResult.
|
||||
|
||||
/**
|
||||
* What is actually happening, as opposed to what is stored.
|
||||
*
|
||||
* Lifted out of the Composable so it can be asserted. A string built
|
||||
* inline in a `Text(...)` is a string nothing can test, which is how a screen
|
||||
* ends up quietly claiming protection it does not have. The same lesson the
|
||||
* `registeredLine` note records, applied before it bites rather than after.
|
||||
*/
|
||||
internal fun statusLine(
|
||||
bridge: io.flamenet.obfs4.Obfs4Bridge? = Obfs4Routing.bridge,
|
||||
handshakes: Int = Obfs4Stats.completedHandshakes(),
|
||||
): String = when {
|
||||
bridge == null ->
|
||||
): String =
|
||||
when {
|
||||
bridge == null -> {
|
||||
"Connecting directly to your Flamenet server. That's the right setting almost " +
|
||||
"everywhere."
|
||||
handshakes > 0 ->
|
||||
}
|
||||
|
||||
handshakes > 0 -> {
|
||||
"Going through ${bridge.host}:${bridge.port}. Traffic to your server is wrapped so " +
|
||||
"that whoever carries it can't tell what it is, your server still sees what it " +
|
||||
"always did."
|
||||
// Not "protected". A saved bridge that has never completed a handshake
|
||||
// has carried nothing, and saying otherwise is the exact claim this screen
|
||||
// was built to stop making.
|
||||
else ->
|
||||
}
|
||||
|
||||
// Not "protected".
|
||||
else -> {
|
||||
"Bridge saved: ${bridge.host}:${bridge.port}. Nothing has gone through it yet, the " +
|
||||
"app will try the next time it goes online."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Says what it does not protect, in the same breath as what it does. The
|
||||
* failure mode for this feature is somebody believing it hides them from their
|
||||
* server, behaving accordingly, and being wrong.
|
||||
* Says what it does not protect, in the same breath as what it does.
|
||||
*/
|
||||
private const val EXPLANATION =
|
||||
"Some networks block Flamenet outright, or recognise it and slow it down. A bridge is a " +
|
||||
|
||||
@@ -22,11 +22,6 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* What this phone's notifications are allowed to say.
|
||||
*
|
||||
* Android had none of this. iOS has offered these since it shipped, and
|
||||
* the parity scrub on 2026-09-06 found eight preferences on one phone and not
|
||||
* the other. `MessengerService` had already written down what was missing, at
|
||||
* the only place it could: "Count only, until there is a setting for it."
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.NotificationSection() {
|
||||
@@ -40,44 +35,56 @@ fun ColumnScope.NotificationSection() {
|
||||
Text("Notifications", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
@Composable
|
||||
fun row(label: String, tag: String, value: Boolean, onChange: (Boolean) -> Unit) {
|
||||
fun row(
|
||||
label: String,
|
||||
tag: String,
|
||||
value: Boolean,
|
||||
onChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, fontSize = 16.sp, modifier = Modifier.weight(1f))
|
||||
Switch(checked = value, onCheckedChange = onChange,
|
||||
modifier = Modifier.testTag(tag))
|
||||
Switch(
|
||||
checked = value,
|
||||
onCheckedChange = onChange,
|
||||
modifier = Modifier.testTag(tag),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
row("Notify me", "notify-enabled", enabled) { on ->
|
||||
settings.enabled = on; enabled = on
|
||||
settings.enabled = on
|
||||
enabled = on
|
||||
}
|
||||
if (enabled) {
|
||||
row("New messages", "notify-messages", newMessages) { on ->
|
||||
settings.newMessages = on; newMessages = on
|
||||
settings.newMessages = on
|
||||
newMessages = on
|
||||
}
|
||||
row("Show who it is from", "notify-previews", previews) { on ->
|
||||
settings.showPreviews = on; previews = on
|
||||
settings.showPreviews = on
|
||||
previews = on
|
||||
}
|
||||
row("Sound while the app is open", "notify-sounds", sounds) { on ->
|
||||
settings.inAppSounds = on; sounds = on
|
||||
settings.inAppSounds = on
|
||||
sounds = on
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
|
||||
// Says what turning it on costs, and does not pretend the default is
|
||||
// merely cautious. Who is writing to you is, on some phones, the more
|
||||
// sensitive of the two facts.
|
||||
// Says what turning it on costs, and does not pretend the default is merely
|
||||
// cautious.
|
||||
Text(
|
||||
if (previews)
|
||||
"Your lock screen will name whoever wrote to you. Anyone who can see your "
|
||||
+ "phone can see that, which is sometimes more revealing than the message."
|
||||
else
|
||||
"Notifications say how many messages arrived and nothing else, no name, no "
|
||||
+ "words. Calls still ring.",
|
||||
if (previews) {
|
||||
"Your lock screen will name whoever wrote to you. Anyone who can see your " +
|
||||
"phone can see that, which is sometimes more revealing than the message."
|
||||
} else {
|
||||
"Notifications say how many messages arrived and nothing else, no name, no " +
|
||||
"words. Calls still ring."
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
|
||||
@@ -4,24 +4,10 @@ import android.content.Context
|
||||
|
||||
/**
|
||||
* What a notification is allowed to say, and whether to post one at all.
|
||||
*
|
||||
* A lock screen is a shoulder-surfing surface. This app draws its
|
||||
* notifications on the phone, from plaintext it has already decrypted, so it
|
||||
* *could* put the sender and the message on the lock screen, and for most of
|
||||
* this app's life it deliberately did not, because the product's whole claim is
|
||||
* that a message is for the two people in it. `MessengerService` says so at the
|
||||
* only place it could have: "Count only, until there is a setting for it."
|
||||
*
|
||||
* This is that setting, and it defaults to the behaviour that was there before:
|
||||
* a count, no name, no words. Somebody who wants their phone to say who is
|
||||
* writing can ask for it; nobody gets it by upgrading.
|
||||
*
|
||||
* Same keys and same defaults as iOS, so the two phones behave alike for one
|
||||
* person. iOS reads these through `SettingsKeys`; the names differ because the
|
||||
* platforms store differently, and the DEFAULTS are what must not drift.
|
||||
*/
|
||||
class NotificationSettings(context: Context) {
|
||||
|
||||
class NotificationSettings(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/** The master switch. Off means no notification of any kind is posted. */
|
||||
@@ -36,13 +22,6 @@ class NotificationSettings(context: Context) {
|
||||
|
||||
/**
|
||||
* Put the sender's name, and a hint of the message, on the lock screen.
|
||||
*
|
||||
* Off by default, and that is the honest default rather than the
|
||||
* cautious one. Turning it on is a real choice with a real cost: anyone
|
||||
* who can see the phone can see who is writing to you, which on some
|
||||
* phones is a more sensitive fact than what they said. The people for whom
|
||||
* that is fine can have it; nobody should acquire it by installing an
|
||||
* update.
|
||||
*/
|
||||
var showPreviews: Boolean
|
||||
get() = prefs.getBoolean(SHOW_PREVIEWS, false)
|
||||
@@ -55,40 +34,40 @@ class NotificationSettings(context: Context) {
|
||||
|
||||
/**
|
||||
* The text for a message notification, given how many threads are waiting.
|
||||
*
|
||||
* Lifted out of the service so it can be asserted. A string built
|
||||
* inline inside a `NotificationCompat.Builder` chain is a string nothing
|
||||
* can test, and the thing being decided here is whether a name reaches a
|
||||
* lock screen. `registeredLine` records what that costs, a template that
|
||||
* shipped rendering its own source code, invisible to the compiler.
|
||||
*
|
||||
* `from` is only consulted when previews are on. A caller that passes a
|
||||
* name is not asking for it to be shown; this decides that.
|
||||
*/
|
||||
fun messageText(threads: Int, from: String?): String =
|
||||
messageText(showPreviews, threads, from)
|
||||
fun messageText(
|
||||
threads: Int,
|
||||
from: String?,
|
||||
): String = messageText(showPreviews, threads, from)
|
||||
|
||||
/** The same question for a reply reminder. */
|
||||
fun replyReminderText(count: Int, from: String?): String =
|
||||
replyReminderText(showPreviews, count, from)
|
||||
fun replyReminderText(
|
||||
count: Int,
|
||||
from: String?,
|
||||
): String = replyReminderText(showPreviews, count, from)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Pure, and takes the preference as an argument, so a test can
|
||||
* call THIS rather than a copy of it. The first version of the test
|
||||
* beside this file reimplemented the rules against a fake and passed
|
||||
* perfectly while asserting nothing about the code that ships, the
|
||||
* same shape as two identically-wrong engines agreeing. A `Context` is
|
||||
* not available in a JVM unit test, so the decision had to leave the
|
||||
* class that needs one.
|
||||
* Pure, and takes the preference as an argument, so a test can call
|
||||
* THIS rather than a copy of it.
|
||||
*/
|
||||
fun messageText(showPreviews: Boolean, threads: Int, from: String?): String = when {
|
||||
fun messageText(
|
||||
showPreviews: Boolean,
|
||||
threads: Int,
|
||||
from: String?,
|
||||
): String =
|
||||
when {
|
||||
showPreviews && threads == 1 && !from.isNullOrBlank() -> "New message from $from"
|
||||
threads == 1 -> "New message"
|
||||
else -> "New messages in $threads chats"
|
||||
}
|
||||
|
||||
fun replyReminderText(showPreviews: Boolean, count: Int, from: String?): String = when {
|
||||
fun replyReminderText(
|
||||
showPreviews: Boolean,
|
||||
count: Int,
|
||||
from: String?,
|
||||
): String =
|
||||
when {
|
||||
showPreviews && count == 1 && !from.isNullOrBlank() -> "Still no reply from $from"
|
||||
count == 1 -> "Still no reply"
|
||||
else -> "Still no reply in $count chats"
|
||||
|
||||
@@ -7,46 +7,27 @@ import io.flamenet.obfs4.BridgeLineResult
|
||||
|
||||
/**
|
||||
* The bridge line this phone uses, if any.
|
||||
*
|
||||
* Nothing is stored that [BridgeLine.parse] rejected. A line that will
|
||||
* not parse becomes "no bridge", and "no bridge" is a direct connection, so
|
||||
* storing one would put the person who pasted it back on the exact path they
|
||||
* were trying to leave, while the screen went on showing their line. The screen
|
||||
* refuses it instead and says which part is wrong.
|
||||
*
|
||||
* Ordinary SharedPreferences, not the vault. A bridge line is not a secret:
|
||||
* operators publish them, and it has to be readable on a path that runs before
|
||||
* the encrypted store is open, the app needs its transport before it can sign
|
||||
* in.
|
||||
*
|
||||
* This hides traffic from the NETWORK, not from us. The bridge proxies
|
||||
* to the relay, which still sees everything it saw before. No copy anywhere may
|
||||
* imply otherwise.
|
||||
*/
|
||||
class Obfs4Settings(context: Context) {
|
||||
|
||||
class Obfs4Settings(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* The stored line, exactly as it was pasted.
|
||||
*
|
||||
* Kept as the operator's own line rather than as parsed fields, so it can
|
||||
* be shown back unchanged. A round trip through separate fields loses the
|
||||
* fingerprint and makes a typo unrecoverable.
|
||||
*/
|
||||
var bridgeLine: String?
|
||||
get() = prefs.getString(BRIDGE_LINE, null)
|
||||
private set(value) {
|
||||
prefs.edit().apply {
|
||||
prefs
|
||||
.edit()
|
||||
.apply {
|
||||
if (value.isNullOrBlank()) remove(BRIDGE_LINE) else putString(BRIDGE_LINE, value)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a line and starts using it, or reports why it cannot be used.
|
||||
*
|
||||
* The only way in. A setter that took any string would be the silent
|
||||
* fallback all over again.
|
||||
*/
|
||||
fun save(line: String): BridgeLineResult {
|
||||
val trimmed = line.trim()
|
||||
@@ -65,22 +46,15 @@ class Obfs4Settings(context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the stored line into force. Called once at startup.
|
||||
*
|
||||
* Before any client is built. The default HTTP client consults
|
||||
* [Obfs4Routing] per socket rather than at construction, so a late call
|
||||
* still takes effect, but a request that went out first would have gone
|
||||
* out directly, and on a network that blocks the relay that is a failed
|
||||
* launch rather than a slow one.
|
||||
*
|
||||
* A stored line that no longer parses is discarded rather than kept.
|
||||
* Leaving it would show a bridge on the settings screen that nothing could
|
||||
* use.
|
||||
* Puts the stored line into force.
|
||||
*/
|
||||
fun applyAtStartup() {
|
||||
val line = bridgeLine ?: return
|
||||
when (val result = BridgeLine.parse(line)) {
|
||||
is BridgeLineResult.Ok -> Obfs4Routing.use(result.bridge)
|
||||
is BridgeLineResult.Ok -> {
|
||||
Obfs4Routing.use(result.bridge)
|
||||
}
|
||||
|
||||
is BridgeLineResult.Bad -> {
|
||||
bridgeLine = null
|
||||
Obfs4Routing.use(null)
|
||||
@@ -90,15 +64,6 @@ class Obfs4Settings(context: Context) {
|
||||
|
||||
/**
|
||||
* Accept a bridge a contact offered.
|
||||
*
|
||||
* The only path from an offer to a route, and it takes a person.
|
||||
* The transport layer stores offers and applies none of them; this is
|
||||
* called from a button. Everything else about an offer, that it arrived,
|
||||
* who sent it, that it parses, changes nothing about where this phone's
|
||||
* traffic goes.
|
||||
*
|
||||
* Returns the same result [save] does, so a line that arrived broken is
|
||||
* reported with a reason rather than dropped.
|
||||
*/
|
||||
fun accept(offeredLine: String): BridgeLineResult = save(offeredLine)
|
||||
|
||||
|
||||
@@ -6,24 +6,8 @@ import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Device-side supervision a parent configures on a child's phone.
|
||||
*
|
||||
* The honest shape for an E2E messenger: the server cannot read messages, so
|
||||
* there is nothing server-side to moderate, what a parent CAN govern is what
|
||||
* this device is allowed to do. A PIN the parent sets guards three switches:
|
||||
* adding new contacts, the stories surface, and calls.
|
||||
*
|
||||
* Threat model, stated plainly: this restrains a child using a phone the
|
||||
* parent configured. It is enforced in this app's own UI and stored on the
|
||||
* device; it is not a defence against someone who can install apps freely or
|
||||
* clear this one's data, that boundary belongs to Family Link, which parents
|
||||
* should use alongside it.
|
||||
*
|
||||
* The PIN is stored as a salted SHA-256 hash, never in the clear, and none of
|
||||
* this, the PIN, the toggles, or the fact supervision is on, ever leaves
|
||||
* the device. The server learns nothing.
|
||||
*/
|
||||
object ParentalControls {
|
||||
|
||||
private const val PREFS = "parental-controls"
|
||||
private const val PIN_HASH = "pinHash"
|
||||
private const val PIN_SALT = "pinSalt"
|
||||
@@ -31,38 +15,45 @@ object ParentalControls {
|
||||
const val STORIES_HIDDEN = "storiesHidden"
|
||||
const val CALLS_DISABLED = "callsDisabled"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun isEnabled(context: Context) = prefs(context).getString(PIN_HASH, null) != null
|
||||
|
||||
fun contactsGated(context: Context) =
|
||||
isEnabled(context) && prefs(context).getBoolean(CONTACTS_GATED, false)
|
||||
fun contactsGated(context: Context) = isEnabled(context) && prefs(context).getBoolean(CONTACTS_GATED, false)
|
||||
|
||||
fun storiesHidden(context: Context) =
|
||||
isEnabled(context) && prefs(context).getBoolean(STORIES_HIDDEN, false)
|
||||
fun storiesHidden(context: Context) = isEnabled(context) && prefs(context).getBoolean(STORIES_HIDDEN, false)
|
||||
|
||||
fun callsDisabled(context: Context) =
|
||||
isEnabled(context) && prefs(context).getBoolean(CALLS_DISABLED, false)
|
||||
fun callsDisabled(context: Context) = isEnabled(context) && prefs(context).getBoolean(CALLS_DISABLED, false)
|
||||
|
||||
fun setGate(context: Context, key: String, on: Boolean) {
|
||||
fun setGate(
|
||||
context: Context,
|
||||
key: String,
|
||||
on: Boolean,
|
||||
) {
|
||||
prefs(context).edit().putBoolean(key, on).apply()
|
||||
}
|
||||
|
||||
fun enable(context: Context, pin: String) {
|
||||
fun enable(
|
||||
context: Context,
|
||||
pin: String,
|
||||
) {
|
||||
val salt = ByteArray(16).also(SecureRandom()::nextBytes)
|
||||
prefs(context).edit()
|
||||
prefs(context)
|
||||
.edit()
|
||||
.putString(PIN_SALT, salt.joinToString("") { "%02x".format(it) })
|
||||
.putString(PIN_HASH, hash(pin, salt))
|
||||
// The gates default ON, a parent turning this on wants
|
||||
// supervision, and each is individually releasable afterwards.
|
||||
// The gates default ON, a parent turning this on wants supervision,
|
||||
// and each is individually releasable afterwards.
|
||||
.putBoolean(CONTACTS_GATED, true)
|
||||
.putBoolean(STORIES_HIDDEN, true)
|
||||
.putBoolean(CALLS_DISABLED, true)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun verify(context: Context, pin: String): Boolean {
|
||||
fun verify(
|
||||
context: Context,
|
||||
pin: String,
|
||||
): Boolean {
|
||||
val stored = prefs(context).getString(PIN_HASH, null) ?: return false
|
||||
val saltHex = prefs(context).getString(PIN_SALT, null) ?: return false
|
||||
val salt = saltHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
@@ -71,8 +62,12 @@ object ParentalControls {
|
||||
|
||||
fun disable(context: Context) = prefs(context).edit().clear().apply()
|
||||
|
||||
private fun hash(pin: String, salt: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
private fun hash(
|
||||
pin: String,
|
||||
salt: ByteArray,
|
||||
): String =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.apply { update(salt) }
|
||||
.digest(pin.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
@@ -25,8 +25,7 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Parental controls on the Account screen: a parent's PIN guarding what this
|
||||
* device may do. Opening the switches requires the PIN, they would be
|
||||
* decoration if the person they restrain could flip them.
|
||||
* device may do.
|
||||
*/
|
||||
@Composable
|
||||
fun ParentalControlsSection() {
|
||||
@@ -39,24 +38,26 @@ fun ParentalControlsSection() {
|
||||
var newPin by remember { mutableStateOf("") }
|
||||
var repeatPin by remember { mutableStateOf("") }
|
||||
|
||||
@Suppress("UNUSED_EXPRESSION") tick
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
tick
|
||||
|
||||
Text("Parental controls", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
when {
|
||||
!ParentalControls.isEnabled(context) -> {
|
||||
Text(
|
||||
"A parent sets a PIN on this phone. While supervision is on, "
|
||||
+ "adding contacts asks for the PIN, and stories and calls can "
|
||||
+ "be switched off, all enforced on this device. Nothing about "
|
||||
+ "supervision is sent to any server. Use it alongside Family "
|
||||
+ "Link, which governs the phone itself.",
|
||||
"A parent sets a PIN on this phone. While supervision is on, " +
|
||||
"adding contacts asks for the PIN, and stories and calls can " +
|
||||
"be switched off, all enforced on this device. Nothing about " +
|
||||
"supervision is sent to any server. Use it alongside Family " +
|
||||
"Link, which governs the phone itself.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = newPin, onValueChange = { newPin = it },
|
||||
value = newPin,
|
||||
onValueChange = { newPin = it },
|
||||
placeholder = { Text("Parent PIN (at least 4 digits)", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
@@ -64,7 +65,8 @@ fun ParentalControlsSection() {
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
OutlinedTextField(
|
||||
value = repeatPin, onValueChange = { repeatPin = it },
|
||||
value = repeatPin,
|
||||
onValueChange = { repeatPin = it },
|
||||
placeholder = { Text("Repeat PIN", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
@@ -74,7 +76,10 @@ fun ParentalControlsSection() {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
ParentalControls.enable(context, newPin)
|
||||
newPin = ""; repeatPin = ""; unlocked = true; tick++
|
||||
newPin = ""
|
||||
repeatPin = ""
|
||||
unlocked = true
|
||||
tick++
|
||||
},
|
||||
enabled = newPin.length >= 4 && newPin == repeatPin,
|
||||
modifier = Modifier.testTag("parental-enable"),
|
||||
@@ -82,11 +87,18 @@ fun ParentalControlsSection() {
|
||||
}
|
||||
|
||||
!unlocked -> {
|
||||
Text("Supervision is on. The settings behind this are the parent's to change.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Text(
|
||||
"Supervision is on. The settings behind this are the parent's to change.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedTextField(
|
||||
value = pinEntry, onValueChange = { pinEntry = it; wrong = false },
|
||||
value = pinEntry,
|
||||
onValueChange = {
|
||||
pinEntry = it
|
||||
wrong = false
|
||||
},
|
||||
placeholder = { Text("Parent PIN", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
@@ -94,8 +106,11 @@ fun ParentalControlsSection() {
|
||||
modifier = Modifier.fillMaxWidth().testTag("parental-unlock-pin"),
|
||||
)
|
||||
if (wrong) {
|
||||
Text("That's not the PIN.", fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
"That's not the PIN.",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
OutlinedButton(
|
||||
@@ -108,31 +123,44 @@ fun ParentalControlsSection() {
|
||||
) { Text("Unlock") }
|
||||
}
|
||||
|
||||
else -> Column {
|
||||
else -> {
|
||||
Column {
|
||||
GateRow("Ask the PIN to add a contact", ParentalControls.CONTACTS_GATED) { tick++ }
|
||||
GateRow("Hide stories", ParentalControls.STORIES_HIDDEN) { tick++ }
|
||||
GateRow("Turn off calls", ParentalControls.CALLS_DISABLED) { tick++ }
|
||||
Text(
|
||||
"With calls off, this phone neither places calls nor rings, a "
|
||||
+ "caller is answered busy.",
|
||||
fontSize = 12.sp, color = Palette.SecondaryText,
|
||||
"With calls off, this phone neither places calls nor rings, a " +
|
||||
"caller is answered busy.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
OutlinedButton(
|
||||
onClick = { ParentalControls.disable(context); unlocked = false; tick++ },
|
||||
onClick = {
|
||||
ParentalControls.disable(context)
|
||||
unlocked = false
|
||||
tick++
|
||||
},
|
||||
modifier = Modifier.testTag("parental-disable"),
|
||||
) { Text("Turn off supervision") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GateRow(label: String, key: String, onChanged: () -> Unit) {
|
||||
private fun GateRow(
|
||||
label: String,
|
||||
key: String,
|
||||
onChanged: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var on by remember(key) {
|
||||
mutableStateOf(
|
||||
context.getSharedPreferences("parental-controls", android.content.Context.MODE_PRIVATE)
|
||||
.getBoolean(key, false))
|
||||
context
|
||||
.getSharedPreferences("parental-controls", android.content.Context.MODE_PRIVATE)
|
||||
.getBoolean(key, false),
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(label, fontSize = 14.sp, modifier = Modifier.weight(1f))
|
||||
|
||||
@@ -36,10 +36,6 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Stickers, from the shipped pack.
|
||||
*
|
||||
* Tapping one SENDS it. A sticker is a whole message, there is nothing to
|
||||
* add it to and nothing further to say, which is the opposite of the emoji
|
||||
* panel below, and the reason these are two controls rather than one.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||
@@ -47,8 +43,7 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||
val stickers = StickerPack.all(context)
|
||||
|
||||
if (stickers.isEmpty()) {
|
||||
// The manifest failed to load. Say so rather than showing an empty
|
||||
// panel that reads as "there are no stickers".
|
||||
// The manifest failed to load.
|
||||
Text(
|
||||
"Stickers could not be loaded on this device.",
|
||||
fontSize = 14.sp,
|
||||
@@ -60,17 +55,21 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 64.dp),
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.clip(RoundedCornerShape(Radius.large))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.testTag("sticker-picker"),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(Space.sm),
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout
|
||||
.PaddingValues(Space.sm),
|
||||
) {
|
||||
items(stickers, key = { it.id }) { sticker ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(Space.xs)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { onPick(sticker) }
|
||||
@@ -86,31 +85,12 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||
|
||||
/**
|
||||
* Emoji, straight into the draft.
|
||||
*
|
||||
* Not a protocol feature. An emoji is a character: it goes into the text
|
||||
* of an ordinary message and is indistinguishable from any other character once
|
||||
* sent. Nothing on the receiving end needs to understand it, which is exactly
|
||||
* what makes it different from a sticker.
|
||||
*
|
||||
* The panel stays open on tap. Somebody picking three in a row expects three
|
||||
* characters in the box, and closing on each tap would make that impossible.
|
||||
*
|
||||
* Recents and grouping, matching iOS. This was a flat grid while iOS
|
||||
* kept a recents row and grouped the set the way people think about them,
|
||||
* found when a duplicate iOS picker nearly shadowed the better one. Two
|
||||
* platforms offering the same emoji through different furniture is the drift
|
||||
* nobody files a bug about and everybody notices. The mechanics mirror the
|
||||
* iOS file: recents are the last twelve picked, most recent first, in ordinary
|
||||
* SharedPreferences, which emoji somebody reaches for is not a secret worth a
|
||||
* vault entry, and the vault cannot be read before sign-in anyway.
|
||||
*
|
||||
* Still a curated set, not all of Unicode. A full picker is search, skin
|
||||
* tones and category tabs, a real feature the system keyboard already ships.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember {
|
||||
val prefs =
|
||||
remember {
|
||||
context.getSharedPreferences("flamenet-emoji", android.content.Context.MODE_PRIVATE)
|
||||
}
|
||||
var recents by remember {
|
||||
@@ -125,7 +105,8 @@ fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) {
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.clip(RoundedCornerShape(Radius.large))
|
||||
@@ -141,7 +122,11 @@ fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiSection(title: String, emoji: List<String>, onPick: (String) -> Unit) {
|
||||
private fun EmojiSection(
|
||||
title: String,
|
||||
emoji: List<String>,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
fontSize = 11.sp,
|
||||
@@ -149,14 +134,16 @@ private fun EmojiSection(title: String, emoji: List<String>, onPick: (String) ->
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
) {
|
||||
emoji.forEach { e ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { onPick(e) }
|
||||
// Scoped by section, as on iOS: the same emoji can sit in
|
||||
@@ -172,13 +159,47 @@ private fun EmojiSection(title: String, emoji: List<String>, onPick: (String) ->
|
||||
|
||||
/**
|
||||
* The same set as iOS, grouped the same way, kept in step on purpose.
|
||||
* If one platform's list changes, change the other in the same sitting.
|
||||
*/
|
||||
private val EMOJI_GROUPS = listOf(
|
||||
"Smileys" to listOf("😀", "😂", "🙂", "😉", "😍", "🤔", "😅", "😭", "😡", "🥳",
|
||||
"😴", "🤯", "🫠", "😬", "🙃", "😎"),
|
||||
private val EMOJI_GROUPS =
|
||||
listOf(
|
||||
"Smileys" to
|
||||
listOf(
|
||||
"😀",
|
||||
"😂",
|
||||
"🙂",
|
||||
"😉",
|
||||
"😍",
|
||||
"🤔",
|
||||
"😅",
|
||||
"😭",
|
||||
"😡",
|
||||
"🥳",
|
||||
"😴",
|
||||
"🤯",
|
||||
"🫠",
|
||||
"😬",
|
||||
"🙃",
|
||||
"😎",
|
||||
),
|
||||
"Gestures" to listOf("👍", "👎", "👋", "🙏", "👏", "🤝", "💪", "🫶", "✌️", "🤞"),
|
||||
"Hearts" to listOf("❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "💔", "✨", "🔥"),
|
||||
"Things" to listOf("✅", "❌", "⚠️", "📌", "📎", "🔒", "🔑", "☕", "🍕", "🎉",
|
||||
"🎂", "🚀", "🌧️", "☀️", "🌙", "⭐"),
|
||||
)
|
||||
"Things" to
|
||||
listOf(
|
||||
"✅",
|
||||
"❌",
|
||||
"⚠️",
|
||||
"📌",
|
||||
"📎",
|
||||
"🔒",
|
||||
"🔑",
|
||||
"☕",
|
||||
"🍕",
|
||||
"🎉",
|
||||
"🎂",
|
||||
"🚀",
|
||||
"🌧️",
|
||||
"☀️",
|
||||
"🌙",
|
||||
"⭐",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,38 +9,16 @@ import io.flamenet.client.LocalVault
|
||||
/**
|
||||
* Files that stay where their owner put them, reached through the Storage
|
||||
* Access Framework.
|
||||
*
|
||||
* This is the Android half of [LocalVault.ReferenceResolver], the piece the
|
||||
* vault deliberately does not know about, so that the vault itself can be
|
||||
* tested on a laptop with no emulator.
|
||||
*
|
||||
* The permission must be persisted or the row is broken tomorrow.
|
||||
* `ActivityResultContracts.GetContent` hands back a URI whose read grant dies
|
||||
* with the process. A reference made from one works perfectly for the rest of
|
||||
* the session and then fails on the next launch, the worst shape of bug, since
|
||||
* whoever added the file watched it work. `OpenDocument` plus
|
||||
* [takePersistablePermission] is the combination that survives a reboot, and it
|
||||
* is why the picker for this screen is not the picker the chat composer uses.
|
||||
*
|
||||
* A persisted grant is still revocable. The owner can move the file,
|
||||
* delete it, or clear the app's access from Settings, and the row is then
|
||||
* pointing at nothing. That is allowed, it is their file, so every path here
|
||||
* reports it rather than throwing something a screen cannot phrase.
|
||||
*/
|
||||
class SafReferences(private val context: Context) : LocalVault.ReferenceResolver {
|
||||
|
||||
class SafReferences(
|
||||
private val context: Context,
|
||||
) : LocalVault.ReferenceResolver {
|
||||
/**
|
||||
* Keep read access across relaunches.
|
||||
*
|
||||
* Returns false when the provider refuses to make the grant persistable,
|
||||
* which some do. The caller must NOT file a reference in that case: a row
|
||||
* that cannot be reopened is worse than a picker that says "this file can't
|
||||
* be linked to, copy it in instead", because the first one looks like it
|
||||
* worked.
|
||||
*/
|
||||
fun takePersistablePermission(uri: Uri): Boolean = try {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
fun takePersistablePermission(uri: Uri): Boolean =
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
true
|
||||
} catch (e: SecurityException) {
|
||||
false
|
||||
@@ -48,41 +26,38 @@ class SafReferences(private val context: Context) : LocalVault.ReferenceResolver
|
||||
|
||||
fun releasePersistablePermission(bookmark: String) {
|
||||
runCatching {
|
||||
context.contentResolver.releasePersistableUriPermission(
|
||||
Uri.parse(bookmark), Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.contentResolver.releasePersistableUriPermission(Uri.parse(bookmark), Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening the stream, not asking the provider whether the file
|
||||
* exists. `DocumentsContract` will happily describe a document that can no
|
||||
* longer be read, a cloud provider with the account signed out is the
|
||||
* everyday case, and a row that says it is fine and then fails on tap is
|
||||
* the thing this whole distinction exists to avoid.
|
||||
* Opening the stream, not asking the provider whether the file exists.
|
||||
*/
|
||||
override fun exists(bookmark: String): Boolean = try {
|
||||
override fun exists(bookmark: String): Boolean =
|
||||
try {
|
||||
context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { true } ?: false
|
||||
} catch (e: Exception) {
|
||||
// Deliberately broad. A provider that has gone away throws whatever it
|
||||
// likes. SecurityException, FileNotFoundException, IllegalArgument,
|
||||
// and on at least one vendor's provider a plain NullPointerException.
|
||||
// Every one of them means the same thing to a person: it isn't there.
|
||||
// Deliberately broad.
|
||||
false
|
||||
}
|
||||
|
||||
override fun read(bookmark: String): ByteArray =
|
||||
context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { it.readBytes() }
|
||||
?: throw LocalVault.VaultException(
|
||||
"That file isn't where it was, it may have been moved or deleted.")
|
||||
?: throw LocalVault.VaultException("That file isn't where it was, it may have been moved or deleted.")
|
||||
|
||||
/**
|
||||
* Zero when the provider will not say, which is a real answer and not a
|
||||
* failure. `OpenableColumns.SIZE` is optional, and several providers return
|
||||
* null for it, the row then shows no size rather than a wrong one.
|
||||
* failure.
|
||||
*/
|
||||
override fun size(bookmark: String): Int = try {
|
||||
context.contentResolver.query(
|
||||
Uri.parse(bookmark), arrayOf(OpenableColumns.SIZE), null, null, null
|
||||
override fun size(bookmark: String): Int =
|
||||
try {
|
||||
context.contentResolver
|
||||
.query(
|
||||
Uri.parse(bookmark),
|
||||
arrayOf(OpenableColumns.SIZE),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use 0
|
||||
val column = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
@@ -93,9 +68,15 @@ class SafReferences(private val context: Context) : LocalVault.ReferenceResolver
|
||||
}
|
||||
|
||||
/** The name the provider shows, for pre-filling the file's name in the vault. */
|
||||
fun displayName(uri: Uri): String? = try {
|
||||
context.contentResolver.query(
|
||||
uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null
|
||||
fun displayName(uri: Uri): String? =
|
||||
try {
|
||||
context.contentResolver
|
||||
.query(
|
||||
uri,
|
||||
arrayOf(OpenableColumns.DISPLAY_NAME),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
|
||||
@@ -21,26 +21,18 @@ import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
/**
|
||||
* Point the camera at somebody's address, the other half of [AddressCard].
|
||||
*
|
||||
* Nothing here trusts what it read. A QR code is a string an arbitrary
|
||||
* stranger controls, so every scan goes through [parseScannedAddress], the
|
||||
* same refusals as iOS's `ContactCard.decode`: a relay is a hostname, never a
|
||||
* URL, and a code that is not an address is ignored rather than shown as one.
|
||||
*
|
||||
* The camera pipeline is WebRTC's own, [org.webrtc.Camera2Capturer] into a
|
||||
* [org.webrtc.SurfaceViewRenderer], exactly the capture stack video calls
|
||||
* already ship. The only new code is handing each frame's Y plane to the QR
|
||||
* decoder; pulling in a second camera framework to look for a square would be
|
||||
* a dependency for decoration.
|
||||
*/
|
||||
|
||||
/** What a valid code carries. `handle` only when the QR's author included it. */
|
||||
internal data class ScannedAddress(val fnid: String, val relay: String, val handle: String?)
|
||||
internal data class ScannedAddress(
|
||||
val fnid: String,
|
||||
val relay: String,
|
||||
val handle: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`, or the bare `fnid@relay`
|
||||
* somebody retypes. Refuses anything else rather than guessing, an address
|
||||
* that is nearly right addresses somebody else.
|
||||
* `flamenet:<fnid>@<relay>[?u=<handle>&k=…]`, or the bare `fnid@relay` somebody
|
||||
* retypes.
|
||||
*/
|
||||
internal fun parseScannedAddress(text: String): ScannedAddress? {
|
||||
var s = text.trim()
|
||||
@@ -58,18 +50,22 @@ internal fun parseScannedAddress(text: String): ScannedAddress? {
|
||||
val fnid = s.substring(0, at).trim()
|
||||
val relay = s.substring(at + 1).trim().lowercase()
|
||||
if (fnid.isEmpty() || relay.isEmpty() || !relay.contains('.')) return null
|
||||
// A relay is a hostname. Accepting "https://x/y" would let a printed code
|
||||
// aim this client at a path on a host of the attacker's choosing.
|
||||
// A relay is a hostname.
|
||||
if (relay.contains('/') || relay.contains(':')) return null
|
||||
return ScannedAddress(fnid, relay, handle?.takeIf { it.isNotBlank() })
|
||||
}
|
||||
|
||||
/** Full-screen camera dialog. Calls [onFound] once with the raw decoded text. */
|
||||
@Composable
|
||||
fun ScanAddressDialog(onFound: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
fun ScanAddressDialog(
|
||||
onFound: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val granted = androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.CAMERA
|
||||
val granted =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|
||||
Dialog(
|
||||
@@ -78,12 +74,11 @@ fun ScanAddressDialog(onFound: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
if (!granted) {
|
||||
// Says what to do, not just that it failed. Once refused,
|
||||
// only Android settings can change the answer.
|
||||
// Says what to do, not just that it failed.
|
||||
Column(modifier = Modifier.align(Alignment.Center).padding(24.dp)) {
|
||||
Text(
|
||||
"Flamenet cannot use the camera. Allow it in Android "
|
||||
+ "settings, or paste their address into Add contact instead.",
|
||||
"Flamenet cannot use the camera. Allow it in Android " +
|
||||
"settings, or paste their address into Add contact instead.",
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.testTag("scan-denied"),
|
||||
@@ -103,14 +98,16 @@ fun ScanAddressDialog(onFound: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
"Point at the code on their Address screen.",
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 48.dp),
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 24.dp, end = 8.dp)
|
||||
.testTag("scan-cancel"),
|
||||
@@ -120,9 +117,7 @@ fun ScanAddressDialog(onFound: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The capture-and-decode loop. Frames render to the given view AND, four times
|
||||
* a second, have their luminance plane offered to the QR decoder. First hit
|
||||
* wins; everything after it is dropped.
|
||||
* The capture-and-decode loop.
|
||||
*/
|
||||
private class QrScan(
|
||||
context: Context,
|
||||
@@ -132,28 +127,40 @@ private class QrScan(
|
||||
private val egl = org.webrtc.EglBase.create()
|
||||
private val helper = org.webrtc.SurfaceTextureHelper.create("qr-scan", egl.eglBaseContext)
|
||||
private var capturer: org.webrtc.CameraVideoCapturer? = null
|
||||
private val done = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
private val done =
|
||||
java.util.concurrent.atomic
|
||||
.AtomicBoolean(false)
|
||||
|
||||
@Volatile private var lastDecode = 0L
|
||||
private val reader = com.google.zxing.qrcode.QRCodeReader()
|
||||
private val reader =
|
||||
com.google.zxing.qrcode
|
||||
.QRCodeReader()
|
||||
private val main = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
|
||||
init {
|
||||
renderer.init(egl.eglBaseContext, null)
|
||||
// The BACK camera: a code is on somebody else's screen, not your face.
|
||||
val enumerator = org.webrtc.Camera2Enumerator(context)
|
||||
val name = enumerator.deviceNames.firstOrNull { enumerator.isBackFacing(it) }
|
||||
val name =
|
||||
enumerator.deviceNames.firstOrNull { enumerator.isBackFacing(it) }
|
||||
?: enumerator.deviceNames.firstOrNull()
|
||||
if (name != null) {
|
||||
runCatching {
|
||||
val cap = enumerator.createCapturer(name, null)
|
||||
cap.initialize(helper, context, object : org.webrtc.CapturerObserver {
|
||||
cap.initialize(
|
||||
helper,
|
||||
context,
|
||||
object : org.webrtc.CapturerObserver {
|
||||
override fun onCapturerStarted(success: Boolean) {}
|
||||
|
||||
override fun onCapturerStopped() {}
|
||||
|
||||
override fun onFrameCaptured(frame: org.webrtc.VideoFrame) {
|
||||
renderer.onFrame(frame)
|
||||
maybeDecode(frame)
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
cap.startCapture(1280, 720, 30)
|
||||
capturer = cap
|
||||
}
|
||||
@@ -170,8 +177,7 @@ private class QrScan(
|
||||
val w = i420.width
|
||||
val h = i420.height
|
||||
// Row by row because the stride can exceed the width; the decoder
|
||||
// wants the packed plane. A QR carries its own orientation in the
|
||||
// finder patterns, so the frame's rotation does not matter.
|
||||
// wants the packed plane.
|
||||
val y = ByteArray(w * h)
|
||||
val data = i420.dataY
|
||||
for (row in 0 until h) {
|
||||
@@ -179,8 +185,11 @@ private class QrScan(
|
||||
data.get(y, row * w, w)
|
||||
}
|
||||
val source = com.google.zxing.PlanarYUVLuminanceSource(y, w, h, 0, 0, w, h, false)
|
||||
val bitmap = com.google.zxing.BinaryBitmap(
|
||||
com.google.zxing.common.HybridBinarizer(source))
|
||||
val bitmap =
|
||||
com.google.zxing.BinaryBitmap(
|
||||
com.google.zxing.common
|
||||
.HybridBinarizer(source),
|
||||
)
|
||||
val text = runCatching { reader.decode(bitmap).text }.getOrNull()
|
||||
reader.reset()
|
||||
if (text != null && done.compareAndSet(false, true)) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,45 +4,18 @@ import java.net.URI
|
||||
|
||||
/**
|
||||
* Which relay this install talks to, and where its accounts live.
|
||||
*
|
||||
* The three deployment shapes a user can be in, and the only one they have to
|
||||
* choose is the address:
|
||||
*
|
||||
* - Managed, our relay, accounts in the shared service.
|
||||
* - Self-hosted, shared identity, their relay, accounts in the shared
|
||||
* service, so one credential still covers Messenger and Files.
|
||||
* - Self-hosted, local, their relay, its own account table. Not a lesser
|
||||
* mode: everything works, it simply is not the same login as Files.
|
||||
*/
|
||||
data class SiteConfig(
|
||||
val baseUrl: String,
|
||||
/**
|
||||
* What the relay said about itself, or null until it has answered.
|
||||
*
|
||||
* A constructor property on purpose. It used to be a `var` in the
|
||||
* class body, which left it out of the generated `equals()`, and
|
||||
* `MutableStateFlow` conflates on equality, so writing back a config that
|
||||
* differed ONLY by its descriptor was silently dropped. The relay was
|
||||
* asked, it answered, and the answer went nowhere: the sign-in screen sat
|
||||
* on "Checking what this server uses…" forever and `accountProvider` stayed
|
||||
* LOCAL for a shared-accounts relay. Keep it in the constructor and write
|
||||
* it with `copy(...)`.
|
||||
*/
|
||||
val discovered: io.flamenet.client.RelayDiscovery.Descriptor? = null,
|
||||
/**
|
||||
* The probe was made and came back with nothing.
|
||||
*
|
||||
* Without this, "still asking" and "asked and could not reach it" are
|
||||
* the same state. `discovered` is null in both, so a relay that never
|
||||
* answers leaves "Checking what this server uses…" on screen forever and
|
||||
* the form permanently unusable, no error, no retry, nothing to act on.
|
||||
* Seen 2026-09-05 pointing the app at a plain-HTTP relay, which Android
|
||||
* blocks outright from API 28: the request never left, and the screen said
|
||||
* it was still thinking about it.
|
||||
*/
|
||||
val probeFailed: Boolean = false,
|
||||
) {
|
||||
|
||||
enum class AccountProvider {
|
||||
/** The shared FlameNet account service. One credential across products. */
|
||||
SHARED,
|
||||
@@ -56,10 +29,6 @@ data class SiteConfig(
|
||||
|
||||
/**
|
||||
* Accept what a person would actually type.
|
||||
*
|
||||
* Defaults to https. A bare hostname becoming `http://` would
|
||||
* hand every request, key material included, to anyone on the path,
|
||||
* and it would work, which is the worst outcome.
|
||||
*/
|
||||
fun from(typed: String): SiteConfig? {
|
||||
val trimmed = typed.trim().removeSuffix("/")
|
||||
@@ -80,20 +49,10 @@ data class SiteConfig(
|
||||
|
||||
/**
|
||||
* Where this relay's accounts live.
|
||||
*
|
||||
* Asked of the relay, except for the relays that are ours. For
|
||||
* flamenet.io and its aliases the account service is pinned in the client
|
||||
* and what they advertise is ignored, so compromising one cannot redirect
|
||||
* its users' derived secrets. Every other relay is believed: the user typed
|
||||
* that hostname, and a self-hoster running Messenger and Files together
|
||||
* needs their own account service to be usable across both. See
|
||||
* [io.flamenet.client.RelayDiscovery.PINNED_RELAYS].
|
||||
*
|
||||
* Defaults to LOCAL until the descriptor has been fetched, which is the
|
||||
* safe direction: the passphrase goes only to the server the user typed.
|
||||
*/
|
||||
val accountProvider: AccountProvider
|
||||
get() = if (discovered?.usesSharedAccounts == true) {
|
||||
get() =
|
||||
if (discovered?.usesSharedAccounts == true) {
|
||||
AccountProvider.SHARED
|
||||
} else {
|
||||
AccountProvider.LOCAL
|
||||
@@ -106,13 +65,23 @@ data class SiteConfig(
|
||||
|
||||
/** What to tell the user this relay does about identity, in their words. */
|
||||
val identityDescription: String
|
||||
get() = when {
|
||||
probeFailed ->
|
||||
get() =
|
||||
when {
|
||||
probeFailed -> {
|
||||
"Couldn't reach that server. Check the address, and note it has " +
|
||||
"to be https, not http."
|
||||
discovered == null -> "Checking what this server uses…"
|
||||
accountProvider == AccountProvider.SHARED ->
|
||||
}
|
||||
|
||||
discovered == null -> {
|
||||
"Checking what this server uses…"
|
||||
}
|
||||
|
||||
accountProvider == AccountProvider.SHARED -> {
|
||||
"One Flamenet account, shared with Flamenet Files."
|
||||
else -> "This server keeps its own accounts."
|
||||
}
|
||||
|
||||
else -> {
|
||||
"This server keeps its own accounts."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,13 @@ import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The shipped sticker set, read from the same manifest the iOS app reads.
|
||||
*
|
||||
* One manifest, both clients. `Resources/stickers/flamenet.core.json` is
|
||||
* copied into Android's assets; neither side hard-codes the list. The only
|
||||
* protocol bug this project has actually shipped was a client and a server
|
||||
* disagreeing about a field name with nothing to check it against, and a
|
||||
* sticker set defined twice is the same shape of mistake waiting to happen.
|
||||
*
|
||||
* An unknown id resolves to the fallback, never to nothing. A pack the
|
||||
* receiver does not have, an older build, a pack added later, must still draw
|
||||
* something. A sticker that renders as an empty bubble is worse than one that
|
||||
* renders as the wrong face.
|
||||
*/
|
||||
object StickerPack {
|
||||
|
||||
data class Sticker(val id: String, val glyph: String, val name: String)
|
||||
data class Sticker(
|
||||
val id: String,
|
||||
val glyph: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
const val CORE = "flamenet.core"
|
||||
|
||||
@@ -35,17 +27,22 @@ object StickerPack {
|
||||
|
||||
/**
|
||||
* What to draw for a reference.
|
||||
*
|
||||
* Returns the fallback for anything unrecognised, a different pack, an id
|
||||
* from a newer build, because the alternative is a blank bubble.
|
||||
*/
|
||||
fun glyph(context: Context, pack: String, id: String): String {
|
||||
fun glyph(
|
||||
context: Context,
|
||||
pack: String,
|
||||
id: String,
|
||||
): String {
|
||||
ensure(context)
|
||||
if (pack != CORE) return fallbackGlyph
|
||||
return loaded?.get(id)?.glyph ?: fallbackGlyph
|
||||
}
|
||||
|
||||
fun name(context: Context, pack: String, id: String): String {
|
||||
fun name(
|
||||
context: Context,
|
||||
pack: String,
|
||||
id: String,
|
||||
): String {
|
||||
ensure(context)
|
||||
if (pack != CORE) return "Sticker"
|
||||
return loaded?.get(id)?.name ?: "Sticker"
|
||||
@@ -53,15 +50,24 @@ object StickerPack {
|
||||
|
||||
private fun ensure(context: Context) {
|
||||
if (loaded != null) return
|
||||
// Failure is empty, not a crash. A missing or malformed asset should
|
||||
// cost the picker, not the app, and the fallback above keeps every
|
||||
// sticker somebody has already been sent renderable regardless.
|
||||
val text = runCatching {
|
||||
context.assets.open("stickers/$CORE.json").bufferedReader().use { it.readText() }
|
||||
}.getOrNull() ?: run { loaded = emptyMap(); return }
|
||||
// Failure is empty, not a crash.
|
||||
val text =
|
||||
runCatching {
|
||||
context.assets
|
||||
.open("stickers/$CORE.json")
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
}.getOrNull() ?: run {
|
||||
loaded = emptyMap()
|
||||
return
|
||||
}
|
||||
|
||||
val json = runCatching { JSONObject(text) }.getOrNull()
|
||||
?: run { loaded = emptyMap(); return }
|
||||
val json =
|
||||
runCatching { JSONObject(text) }.getOrNull()
|
||||
?: run {
|
||||
loaded = emptyMap()
|
||||
return
|
||||
}
|
||||
|
||||
fallbackGlyph = json.optString("fallback").ifBlank { "🙂" }
|
||||
val array = json.optJSONArray("stickers")
|
||||
@@ -70,7 +76,8 @@ object StickerPack {
|
||||
val o = array!!.optJSONObject(i) ?: continue
|
||||
val id = o.optString("id")
|
||||
if (id.isBlank()) continue
|
||||
list += Sticker(
|
||||
list +=
|
||||
Sticker(
|
||||
id = id,
|
||||
glyph = o.optString("glyph").ifBlank { fallbackGlyph },
|
||||
name = o.optString("name").ifBlank { id },
|
||||
|
||||
@@ -16,47 +16,34 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.Vault
|
||||
|
||||
/**
|
||||
* Stories as a feed you scroll, which is the kit's Timeline.
|
||||
*
|
||||
* A screen, not a strip. Stories were a horizontal row of circles on the
|
||||
* Messages screen, enough to tell you something exists and nothing more. The
|
||||
* kit's Timeline is a column of full-width cards: author, image, caption. A
|
||||
* card feed cannot sit above a contact list without pushing it off the phone,
|
||||
* so it gets a tab. The strip stays on Messages as the entry point.
|
||||
*
|
||||
* No like or comment bar, and the kit has one. Its post card carries two
|
||||
* emoji reactions and a share icon; a story here has none of those, there is
|
||||
* no comment thread, no reaction on a story, and nothing to share it to. Drawing
|
||||
* the bar and wiring it to nothing would be a lie told in pixels. What a story
|
||||
* DOES have that the kit's post does not is a deadline, so that is what the
|
||||
* card says.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.StoriesFeed(
|
||||
@@ -66,11 +53,12 @@ fun ColumnScope.StoriesFeed(
|
||||
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.
|
||||
// Post from HERE too.
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val pickStory = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.GetContent()
|
||||
val pickStory =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts
|
||||
.GetContent(),
|
||||
) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
StoryMedia.stage(context, uri, model)
|
||||
@@ -86,17 +74,14 @@ fun ColumnScope.StoriesFeed(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// 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
|
||||
modifier =
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.clickable { choosing = true }
|
||||
.testTag("new-story"),
|
||||
@@ -104,12 +89,18 @@ fun ColumnScope.StoriesFeed(
|
||||
DropdownMenu(expanded = choosing, onDismissRequest = { choosing = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Photo or video") },
|
||||
onClick = { choosing = false; pickStory.launch("*/*") },
|
||||
onClick = {
|
||||
choosing = false
|
||||
pickStory.launch("*/*")
|
||||
},
|
||||
modifier = Modifier.testTag("new-story-photo"),
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Just words") },
|
||||
onClick = { choosing = false; writingWords = true },
|
||||
onClick = {
|
||||
choosing = false
|
||||
writingWords = true
|
||||
},
|
||||
modifier = Modifier.testTag("new-story-text"),
|
||||
)
|
||||
}
|
||||
@@ -119,13 +110,15 @@ fun ColumnScope.StoriesFeed(
|
||||
if (writingWords) {
|
||||
TextStoryComposer(
|
||||
onDismiss = { writingWords = false },
|
||||
onPost = { text -> writingWords = false; model.postTextStory(text) },
|
||||
onPost = { text ->
|
||||
writingWords = false
|
||||
model.postTextStory(text)
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
StoriesCards(model, phase)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,12 +131,15 @@ private fun StoryCard(
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
story: Vault.Story,
|
||||
) {
|
||||
val author = remember(story.authorId, phase.buddies) {
|
||||
phase.buddies.firstOrNull { it.userId == story.authorId }
|
||||
val author =
|
||||
remember(story.authorId, phase.buddies) {
|
||||
phase.buddies
|
||||
.firstOrNull { it.userId == story.authorId }
|
||||
?.let { it.displayName.ifBlank { it.handle } }
|
||||
?: if (story.mine) "You" else "user ${story.authorId}"
|
||||
}
|
||||
val remaining = remember(story.id) {
|
||||
val remaining =
|
||||
remember(story.id) {
|
||||
val seconds = story.expires - System.currentTimeMillis() / 1000.0
|
||||
when {
|
||||
story.expires <= 0L -> "no expiry"
|
||||
@@ -154,7 +150,8 @@ private fun StoryCard(
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.openStory(story) }
|
||||
.testTag("story-card-${story.id}"),
|
||||
@@ -165,9 +162,8 @@ private fun StoryCard(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
||||
) {
|
||||
// The author's actual picture when one is held, your own comes
|
||||
// from myAvatar, a contact's from the avatar map. Initials remain
|
||||
// the normal case for anyone who never set one.
|
||||
// The author's actual picture when one is held, your own comes from
|
||||
// myAvatar, a contact's from the avatar map.
|
||||
Avatar(
|
||||
name = author,
|
||||
online = false,
|
||||
@@ -178,18 +174,18 @@ private fun StoryCard(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(author, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
// The deadline, in the slot the kit gives a description.
|
||||
// It is the one fact about a story that changes while you are
|
||||
// looking at it, and the point of the feature.
|
||||
Text(remaining, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
// Unseen is marked here rather than by styling the whole card: a
|
||||
// feed where read and unread look alike is a feed nobody returns to.
|
||||
// feed where read and unread look alike is a feed nobody returns
|
||||
// to.
|
||||
if (!story.seen) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.presenceDot)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -199,7 +195,8 @@ private fun StoryCard(
|
||||
// The image. 375x320 in the kit; an aspect ratio here, because the kit
|
||||
// is drawn on one canvas and this has to hold on every phone.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
@@ -208,25 +205,24 @@ private fun StoryCard(
|
||||
) {
|
||||
// The card's own image, cropped to FILL the box, not the chat
|
||||
// bubble's 260dp thumbnail, which floated small on a full-width
|
||||
// backboard and read as a broken layout.
|
||||
// §6.13, an embedded live stream. The gate comes FIRST, because
|
||||
// tapping play is what connects this device to the stream host; a
|
||||
// card that started playing on its own would disclose that after
|
||||
// the fact, which is not a disclosure. `ref` is the poster.
|
||||
// backboard and read as a broken layout. §6.13, an embedded live
|
||||
// stream.
|
||||
val stream = story.streamUrl
|
||||
val streamHost = story.streamHost
|
||||
if (stream != null && streamHost != null &&
|
||||
io.flamenet.e2e.StreamDisclosure.acceptable(stream)) {
|
||||
io.flamenet.e2e.StreamDisclosure
|
||||
.acceptable(stream)
|
||||
) {
|
||||
StoryStreamGate(stream, streamHost)
|
||||
} else {
|
||||
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.
|
||||
// Words alone, set large on a tinted band, the way every
|
||||
// other messenger draws one.
|
||||
ref == null -> TextStoryCard(story.caption)
|
||||
|
||||
ref.mime.startsWith("video/") -> StoryVideoCard(ref, model)
|
||||
|
||||
else -> StoryImage(ref, model)
|
||||
}
|
||||
}
|
||||
@@ -244,21 +240,14 @@ private fun StoryCard(
|
||||
|
||||
/**
|
||||
* 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,
|
||||
// Passed in rather than resolved here: `StoryCard` already has the
|
||||
// buddy list and has already done this, and two resolutions of the same
|
||||
// name are two chances to disagree about who is being reported.
|
||||
// Passed in rather than resolved here: `StoryCard` already has the buddy
|
||||
// list and has already done this, and two resolutions of the same name are
|
||||
// two chances to disagree about who is being reported.
|
||||
authorName: String = "",
|
||||
) {
|
||||
val quick = listOf("❤️", "😂", "😮", "😢", "🔥")
|
||||
@@ -269,8 +258,10 @@ private fun StoryActions(
|
||||
if (story.mine) {
|
||||
val tally = model.storyTally(story.id)
|
||||
if (tally.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.testTag("story-tally")) {
|
||||
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)
|
||||
}
|
||||
@@ -280,54 +271,63 @@ private fun StoryActions(
|
||||
}
|
||||
|
||||
val mine = model.myStoryReaction(story.id)
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
quick.forEach { emoji ->
|
||||
Text(
|
||||
emoji,
|
||||
fontSize = 20.sp,
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(
|
||||
if (mine == emoji) Palette.Accent.copy(alpha = 0.15f)
|
||||
else androidx.compose.ui.graphics.Color.Transparent
|
||||
)
|
||||
.clickable {
|
||||
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)
|
||||
}.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"))
|
||||
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"))
|
||||
// Reporting a story, which Android could not do. iOS has had it
|
||||
// since stories shipped; here `MessengerViewModel.reportStory` was
|
||||
// written, tested by nothing, and called by no screen. A capability
|
||||
// with no way in is, for Apple's Guideline 1.2 and Play's UGC policy,
|
||||
// the same as no capability, and a story is the most public thing
|
||||
// this app carries, so it is the surface those rules are aimed at.
|
||||
//
|
||||
// Not on your own story. Reporting yourself to the operator is not
|
||||
// something anybody means to do, and offering it invites a misfire.
|
||||
Text(
|
||||
"Mute",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.Mist,
|
||||
modifier = Modifier.clickable { muting = true }.testTag("story-mute"),
|
||||
)
|
||||
// Reporting a story, which Android could not do. iOS has had it since
|
||||
// stories shipped; here `MessengerViewModel.reportStory` was written,
|
||||
// tested by nothing, and called by no screen.
|
||||
if (!story.mine) {
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text("Report", fontSize = 13.sp, color = Palette.Mist,
|
||||
modifier = Modifier.clickable { reporting = true }
|
||||
.testTag("story-report"))
|
||||
Text(
|
||||
"Report",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.Mist,
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable { reporting = true }
|
||||
.testTag("story-report"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (reporting) {
|
||||
// The SAME sheet a message report uses, deliberately. Two sheets
|
||||
// would be two places for the consent wording to drift apart, and the
|
||||
// consent, that sending a copy hands the operator something readable,
|
||||
// is the part that matters.
|
||||
// The SAME sheet a message report uses, deliberately.
|
||||
ReportSheet(
|
||||
authorName = authorName.ifBlank { "this person" },
|
||||
preview = story.caption,
|
||||
@@ -348,16 +348,18 @@ private fun StoryActions(
|
||||
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.
|
||||
// Says where it goes.
|
||||
text = {
|
||||
Column {
|
||||
Text("Goes to them in your conversation. Nobody else who "
|
||||
+ "saw this story can read it.", fontSize = 13.sp)
|
||||
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 },
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
placeholder = { Text("Message") },
|
||||
modifier = Modifier.fillMaxWidth().testTag("story-reply-field"),
|
||||
)
|
||||
@@ -365,7 +367,10 @@ private fun StoryActions(
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { replying = false; model.replyToStory(draft.trim(), story) },
|
||||
onClick = {
|
||||
replying = false
|
||||
model.replyToStory(draft.trim(), story)
|
||||
},
|
||||
enabled = draft.isNotBlank(),
|
||||
modifier = Modifier.testTag("story-reply-send"),
|
||||
) { Text("Send") }
|
||||
@@ -378,18 +383,21 @@ private fun StoryActions(
|
||||
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.
|
||||
// Says what muting is NOT.
|
||||
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)
|
||||
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) },
|
||||
onClick = {
|
||||
muting = false
|
||||
model.setStoriesMuted(story.authorId, true)
|
||||
},
|
||||
modifier = Modifier.testTag("story-mute-confirm"),
|
||||
) { Text("Mute") }
|
||||
},
|
||||
@@ -401,7 +409,7 @@ private fun StoryActions(
|
||||
/**
|
||||
* Caption + a deliberate Post for a picked story image, at the shell level so
|
||||
* whichever screen picked it (the strip or the Stories tab) gets the same
|
||||
* sheet. Picking used to post instantly, no preview, no caption, no way back.
|
||||
* sheet.
|
||||
*/
|
||||
@Composable
|
||||
fun StoryComposerDialog(
|
||||
@@ -412,24 +420,31 @@ fun StoryComposerDialog(
|
||||
val caption = remember(draft) { androidx.compose.runtime.mutableStateOf("") }
|
||||
val streamUrl = remember(draft) { androidx.compose.runtime.mutableStateOf("") }
|
||||
// Whether the typed URL is one that could be disclosed to a viewer (§6.13).
|
||||
val streamOk = streamUrl.value.isBlank() ||
|
||||
io.flamenet.e2e.StreamDisclosure.acceptable(streamUrl.value)
|
||||
val streamOk =
|
||||
streamUrl.value.isBlank() ||
|
||||
io.flamenet.e2e.StreamDisclosure
|
||||
.acceptable(streamUrl.value)
|
||||
// Per-story audience, starting where the account setting points.
|
||||
val mode = remember(draft) {
|
||||
val mode =
|
||||
remember(draft) {
|
||||
androidx.compose.runtime.mutableStateOf(phase.storyAudience.mode)
|
||||
}
|
||||
val chosen = remember(draft) {
|
||||
val chosen =
|
||||
remember(draft) {
|
||||
androidx.compose.runtime.mutableStateOf(phase.storyAudience.userIds)
|
||||
}
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val image = remember(draft) {
|
||||
val image =
|
||||
remember(draft) {
|
||||
runCatching {
|
||||
if (draft.duration > 0)
|
||||
if (draft.duration > 0) {
|
||||
StoryMedia.posterFrame(draft.bytes, context)?.asImageBitmap()
|
||||
else
|
||||
} else {
|
||||
// Own draft, but a 50MP photo is just as much bitmap.
|
||||
ImagePrep.decodeBounded(draft.bytes)
|
||||
ImagePrep
|
||||
.decodeBounded(draft.bytes)
|
||||
?.asImageBitmap()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
androidx.compose.material3.AlertDialog(
|
||||
@@ -442,13 +457,17 @@ fun StoryComposerDialog(
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = it,
|
||||
contentDescription = "Story preview",
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
)
|
||||
if (draft.duration > 0) {
|
||||
Text("▶ ${draft.duration.toInt()}s", fontSize = 22.sp,
|
||||
color = androidx.compose.ui.graphics.Color.White)
|
||||
Text(
|
||||
"▶ ${draft.duration.toInt()}s",
|
||||
fontSize = 22.sp,
|
||||
color = androidx.compose.ui.graphics.Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,11 +477,7 @@ fun StoryComposerDialog(
|
||||
placeholder = { Text("Add a caption…") },
|
||||
modifier = Modifier.fillMaxWidth().testTag("story-caption"),
|
||||
)
|
||||
// Who THIS story goes to. The audience decides who is SENT
|
||||
// the bytes, there is no relay copy to deny anyone later,
|
||||
// so the choice belongs here, before posting. Seeded from the
|
||||
// §6.13, attach a live stream. The picked picture becomes its
|
||||
// poster: what people see before deciding whether to connect.
|
||||
// Who THIS story goes to.
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = streamUrl.value,
|
||||
onValueChange = { streamUrl.value = it },
|
||||
@@ -473,9 +488,7 @@ fun StoryComposerDialog(
|
||||
)
|
||||
if (streamUrl.value.isNotBlank()) {
|
||||
// Says what a stream costs the people watching, at the
|
||||
// moment of attaching one. Everything else in a story is
|
||||
// sealed; this is the one thing that sends viewers to
|
||||
// somebody else's server.
|
||||
// moment of attaching one.
|
||||
Text(
|
||||
if (streamOk) {
|
||||
"Your picture becomes the cover. Anyone who taps play connects " +
|
||||
@@ -486,8 +499,12 @@ fun StoryComposerDialog(
|
||||
"name in it."
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = if (streamOk) Palette.SecondaryText
|
||||
else MaterialTheme.colorScheme.error,
|
||||
color =
|
||||
if (streamOk) {
|
||||
Palette.SecondaryText
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -509,14 +526,17 @@ fun StoryComposerDialog(
|
||||
if (mode.value != Session.AudienceMode.EVERYONE) {
|
||||
phase.buddies.forEach { buddy ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
chosen.value =
|
||||
if (buddy.userId in chosen.value) chosen.value - buddy.userId
|
||||
else chosen.value + buddy.userId
|
||||
if (buddy.userId in chosen.value) {
|
||||
chosen.value - buddy.userId
|
||||
} else {
|
||||
chosen.value + buddy.userId
|
||||
}
|
||||
.padding(vertical = 4.dp)
|
||||
}.padding(vertical = 4.dp)
|
||||
.testTag("story-aud-${buddy.userId}"),
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -528,12 +548,17 @@ fun StoryComposerDialog(
|
||||
}
|
||||
Text(
|
||||
when (mode.value) {
|
||||
Session.AudienceMode.EVERYONE ->
|
||||
Session.AudienceMode.EVERYONE -> {
|
||||
"Goes to all your contacts and disappears after a day."
|
||||
Session.AudienceMode.ONLY ->
|
||||
}
|
||||
|
||||
Session.AudienceMode.ONLY -> {
|
||||
"Goes only to the ${chosen.value.size} chosen, and disappears after a day."
|
||||
Session.AudienceMode.EXCEPT ->
|
||||
}
|
||||
|
||||
Session.AudienceMode.EXCEPT -> {
|
||||
"Goes to everyone but the ${chosen.value.size} chosen, and disappears after a day."
|
||||
}
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
@@ -549,9 +574,7 @@ fun StoryComposerDialog(
|
||||
streamUrl.value,
|
||||
)
|
||||
},
|
||||
// Refuses to post a stream URL it could not disclose. A
|
||||
// destination with no host cannot be named, and a viewer cannot
|
||||
// consent to one they cannot be shown.
|
||||
// Refuses to post a stream URL it could not disclose.
|
||||
enabled = streamOk,
|
||||
modifier = Modifier.testTag("story-post"),
|
||||
) { Text("Post") }
|
||||
@@ -564,11 +587,9 @@ fun StoryComposerDialog(
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The story cards themselves, everyone's stories, or one honest message when
|
||||
* there are none. Shared by the Stories tab and the Home screen, so the two
|
||||
* renderings cannot drift.
|
||||
* there are none.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.StoriesCards(
|
||||
@@ -591,9 +612,9 @@ fun ColumnScope.StoriesCards(
|
||||
)
|
||||
Text("Nothing here yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
"A story goes to your contacts and disappears after a day, on "
|
||||
+ "your device and theirs, because their app deletes it too. "
|
||||
+ "It is not a secret you can take back.",
|
||||
"A story goes to your contacts and disappears after a day, on " +
|
||||
"your device and theirs, because their app deletes it too. " +
|
||||
"It is not a secret you can take back.",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
@@ -608,16 +629,18 @@ fun ColumnScope.StoriesCards(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A story's image at card size: fills the box, cropped. The chat bubble's
|
||||
* `AttachedImage` deliberately caps at 260dp and letterboxes, right for a
|
||||
* bubble, wrong for the kit's full-width post card.
|
||||
* A story's image at card size: fills the box, cropped.
|
||||
*/
|
||||
|
||||
/** A story's video, fetched, decrypted, and looping in the card. */
|
||||
@Composable
|
||||
private fun StoryVideoCard(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
|
||||
val bytes = remember(ref.id) {
|
||||
private fun StoryVideoCard(
|
||||
ref: io.flamenet.e2e.Attachment.Ref,
|
||||
model: MessengerViewModel,
|
||||
) {
|
||||
val bytes =
|
||||
remember(ref.id) {
|
||||
androidx.compose.runtime.mutableStateOf<ByteArray?>(null)
|
||||
}
|
||||
val failed = remember(ref.id) { androidx.compose.runtime.mutableStateOf(false) }
|
||||
@@ -627,52 +650,81 @@ private fun StoryVideoCard(ref: io.flamenet.e2e.Attachment.Ref, model: Messenger
|
||||
}
|
||||
val ready = bytes.value
|
||||
when {
|
||||
ready != null -> StoryVideoPlayer(ready, cacheKey = ref.id,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
failed.value -> Text("Video unavailable", fontSize = 14.sp,
|
||||
color = Palette.SecondaryText)
|
||||
else -> Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
ready != null -> {
|
||||
StoryVideoPlayer(
|
||||
ready,
|
||||
cacheKey = ref.id,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
failed.value -> {
|
||||
Text(
|
||||
"Video unavailable",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StoryImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
|
||||
val bitmap = remember(ref.id) {
|
||||
private fun StoryImage(
|
||||
ref: io.flamenet.e2e.Attachment.Ref,
|
||||
model: MessengerViewModel,
|
||||
) {
|
||||
val bitmap =
|
||||
remember(ref.id) {
|
||||
androidx.compose.runtime.mutableStateOf<android.graphics.Bitmap?>(null)
|
||||
}
|
||||
val failed = remember(ref.id) { androidx.compose.runtime.mutableStateOf(false) }
|
||||
LaunchedEffect(ref.id) {
|
||||
val bytes = model.attachmentBytes(ref)
|
||||
if (bytes == null) { failed.value = true; return@LaunchedEffect }
|
||||
if (bytes == null) {
|
||||
failed.value = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
// Bounded: a received story's bytes are a peer's choice.
|
||||
bitmap.value = ImagePrep.decodeBounded(bytes)
|
||||
if (bitmap.value == null) failed.value = true
|
||||
}
|
||||
val loaded = bitmap.value
|
||||
when {
|
||||
loaded != null -> androidx.compose.foundation.Image(
|
||||
loaded != null -> {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = loaded.asImageBitmap(),
|
||||
contentDescription = "Story image",
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
failed.value -> Text("Image unavailable", fontSize = 14.sp,
|
||||
color = Palette.SecondaryText)
|
||||
else -> Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
|
||||
failed.value -> {
|
||||
Text(
|
||||
"Image unavailable",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
@@ -691,15 +743,14 @@ fun TextStoryCard(text: String) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
private fun TextStoryComposer(
|
||||
onDismiss: () -> Unit,
|
||||
onPost: (String) -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
|
||||
@@ -4,39 +4,31 @@ import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.widget.VideoView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Staging a picked story, image or video, from either entry point.
|
||||
*
|
||||
* Videos are validated and scrubbed, not transcoded: this platform has no
|
||||
* built-in exporter the way AVFoundation is one, and pulling in a transcoding
|
||||
* stack is a decision for later. So the caps are enforced honestly, a clip too
|
||||
* long or too heavy is refused with the reason, and a clip that fits is
|
||||
* remuxed to drop the container metadata before it goes out. See [MediaScrub];
|
||||
* "exactly as picked" used to include the capture location.
|
||||
*/
|
||||
object StoryMedia {
|
||||
|
||||
/** Duration in seconds, display dimensions, and the container rotation. */
|
||||
private data class Meta(
|
||||
val seconds: Double,
|
||||
@@ -47,34 +39,47 @@ object StoryMedia {
|
||||
|
||||
/** A story is a moment, not a film, same cap as iOS. */
|
||||
const val MAX_DURATION_S = 30
|
||||
|
||||
/** Under the relay's 10 MB attachment cap, with room for the seal. */
|
||||
const val MAX_BYTES = 9_500_000
|
||||
|
||||
/**
|
||||
* Read the pick, validate it, and stage it on the model.
|
||||
* Reports refusals through the model's own error surface.
|
||||
*/
|
||||
fun stage(context: Context, uri: Uri, model: MessengerViewModel) {
|
||||
fun stage(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
model: MessengerViewModel,
|
||||
) {
|
||||
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||
|
||||
if (mime.startsWith("video/")) {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
val meta = runCatching {
|
||||
val meta =
|
||||
runCatching {
|
||||
retriever.setDataSource(context, uri)
|
||||
val durationMs = retriever
|
||||
val durationMs =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull() ?: 0L
|
||||
var w = retriever
|
||||
var w =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
?.toIntOrNull() ?: 0
|
||||
var h = retriever
|
||||
var h =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
?.toIntOrNull() ?: 0
|
||||
val rotation = retriever
|
||||
val rotation =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.toIntOrNull() ?: 0
|
||||
// A portrait phone clip is stored landscape plus a rotation.
|
||||
if (rotation == 90 || rotation == 270) { val t = w; w = h; h = t }
|
||||
if (rotation == 90 || rotation == 270) {
|
||||
val t = w
|
||||
w = h
|
||||
h = t
|
||||
}
|
||||
// The rotation is carried out of here now, not just used to
|
||||
// swap the dimensions: rebuilding the container to strip its
|
||||
// metadata drops the orientation with everything else, and a
|
||||
@@ -89,21 +94,20 @@ object StoryMedia {
|
||||
val (seconds, w, h, rotation) = meta
|
||||
if (seconds > MAX_DURATION_S + 0.5) {
|
||||
model.reportStoryProblem(
|
||||
"Stories are capped at $MAX_DURATION_S seconds, this one is "
|
||||
+ "${seconds.toInt()}. Trim it and try again.")
|
||||
"Stories are capped at $MAX_DURATION_S seconds, this one is " +
|
||||
"${seconds.toInt()}. Trim it and try again.",
|
||||
)
|
||||
return
|
||||
}
|
||||
val picked = runCatching {
|
||||
val picked =
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (picked == null) {
|
||||
model.reportStoryProblem("That video couldn't be read.")
|
||||
return
|
||||
}
|
||||
// Where it was shot does not go with it. See [MediaScrub].
|
||||
// A failure here refuses the post rather than falling back to the
|
||||
// original: "we could not remove the location, so we sent it
|
||||
// anyway" is the one outcome this must never produce.
|
||||
// Where it was shot does not go with it.
|
||||
val bytes = MediaScrub.videoWithoutMetadata(picked, rotation, context.cacheDir)
|
||||
if (bytes == null) {
|
||||
model.reportStoryProblem("That video couldn't be prepared for sending.")
|
||||
@@ -111,27 +115,24 @@ object StoryMedia {
|
||||
}
|
||||
if (bytes.size > MAX_BYTES) {
|
||||
model.reportStoryProblem(
|
||||
"That video is too large to send, the cap is about 9 MB. "
|
||||
+ "A shorter or lower-resolution clip will fit.")
|
||||
"That video is too large to send, the cap is about 9 MB. " +
|
||||
"A shorter or lower-resolution clip will fit.",
|
||||
)
|
||||
return
|
||||
}
|
||||
model.stageStory(bytes, mime, w, h, duration = seconds)
|
||||
return
|
||||
}
|
||||
|
||||
val picked = runCatching {
|
||||
val picked =
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (picked == null) {
|
||||
model.reportStoryProblem("That file couldn't be read.")
|
||||
return
|
||||
}
|
||||
// Prepared, not posted as picked. A story photo was the one
|
||||
// image path in the app that never went through [ImagePrep], so it was
|
||||
// uploaded with its EXIF block intact, including, for anything shot on
|
||||
// the phone, the GPS coordinates. Chat photos have always been
|
||||
// re-encoded and were safe by consequence; a story, which is shown to
|
||||
// every contact at once, was not.
|
||||
// Prepared, not posted as picked.
|
||||
val prepared = ImagePrep.prepare(picked, MediaSettings(context).fullQuality)
|
||||
if (prepared == null) {
|
||||
model.reportStoryProblem("That picture couldn't be read.")
|
||||
@@ -141,7 +142,10 @@ object StoryMedia {
|
||||
}
|
||||
|
||||
/** The first frame, for the composer's preview of a staged video. */
|
||||
fun posterFrame(bytes: ByteArray, context: Context): android.graphics.Bitmap? {
|
||||
fun posterFrame(
|
||||
bytes: ByteArray,
|
||||
context: Context,
|
||||
): android.graphics.Bitmap? {
|
||||
val f = File.createTempFile("story-poster", ".mp4", context.cacheDir)
|
||||
return try {
|
||||
f.writeBytes(bytes)
|
||||
@@ -158,18 +162,18 @@ object StoryMedia {
|
||||
|
||||
/**
|
||||
* Plays a decrypted story video, looping, with sound.
|
||||
*
|
||||
* Framework `VideoView` on purpose, playback of one local file needs no
|
||||
* player library. The decrypted bytes land in the app's cache dir, which is
|
||||
* the same exposure as a decoded story bitmap in memory plus the OS's own
|
||||
* cache lifetime.
|
||||
*/
|
||||
@Composable
|
||||
fun StoryVideoPlayer(bytes: ByteArray, cacheKey: String, modifier: Modifier = Modifier) {
|
||||
fun StoryVideoPlayer(
|
||||
bytes: ByteArray,
|
||||
cacheKey: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val file = remember(cacheKey) { mutableStateOf<File?>(null) }
|
||||
LaunchedEffect(cacheKey) {
|
||||
file.value = runCatching {
|
||||
file.value =
|
||||
runCatching {
|
||||
val f = File.createTempFile("story-$cacheKey", ".mp4", context.cacheDir)
|
||||
f.writeBytes(bytes)
|
||||
f
|
||||
@@ -193,16 +197,6 @@ fun StoryVideoPlayer(bytes: ByteArray, cacheKey: String, modifier: Modifier = Mo
|
||||
|
||||
/**
|
||||
* The consent gate in front of an embedded live stream (spec §6.13).
|
||||
*
|
||||
* Watching connects this device to the stream host. A live stream is
|
||||
* continuous and hosted elsewhere, so unlike a link preview there is no
|
||||
* sender-side trick that avoids it: the URL is hidden from the relay, but the
|
||||
* connection is not hidden from whoever serves the stream, they learn the
|
||||
* viewer's IP address and that they watched.
|
||||
*
|
||||
* So playback is an explicit tap with the host named, never autoplay. A story
|
||||
* that started playing on its own would make that disclosure after the fact,
|
||||
* which is not a disclosure.
|
||||
*/
|
||||
@Composable
|
||||
fun StoryStreamGate(
|
||||
@@ -233,7 +227,8 @@ fun StoryStreamGate(
|
||||
Text("Live stream", fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
io.flamenet.e2e.StreamDisclosure.text(host),
|
||||
io.flamenet.e2e.StreamDisclosure
|
||||
.text(host),
|
||||
fontSize = 12.sp,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
|
||||
@@ -8,35 +8,37 @@ 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` 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? {
|
||||
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) {
|
||||
val collection =
|
||||
if (isVideo) {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
} else {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
val values = ContentValues().apply {
|
||||
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
|
||||
val root =
|
||||
if (isVideo) {
|
||||
Environment.DIRECTORY_MOVIES
|
||||
} else {
|
||||
Environment.DIRECTORY_PICTURES
|
||||
}
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, "$root/Flamenet")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -11,6 +10,7 @@ import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
@@ -21,36 +21,10 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The Flamenet design system.
|
||||
*
|
||||
* Taken from the design kit, measured out of the Sketch file. This
|
||||
* comment used to say the palette came from flamenetmessenger.com and quoted
|
||||
* `--flame: #ff5a1f`, which had already been retired from the public pages,
|
||||
* so the app was carrying a brand colour the brand no longer used, on the
|
||||
* authority of a comment nobody rechecked. The values here are read from the
|
||||
* kit's own symbols; see `Palette`.
|
||||
*
|
||||
* One palette, two apps. iOS carries the same values under the same
|
||||
* names in `Sources/Theme/Theme.swift`. This file's comment used to claim the
|
||||
* two were already matched, while iOS was indigo and this was ember, so the
|
||||
* claim was simply false and nobody noticed because nothing checks it.
|
||||
* Change a value here and change it there in the same commit.
|
||||
*
|
||||
* The look: a light cool-navy ground, white cards with a soft shadow and a
|
||||
* generous corner radius, one confident accent, quiet neutrals, and space
|
||||
* between things. Screen structure and every `testTag` are deliberately
|
||||
* untouched, this is a skin over working screens.
|
||||
*/
|
||||
object Palette {
|
||||
// The design kit's palette, measured out of the file, not sampled
|
||||
// from a render and not approximated. The text colours below are the two
|
||||
// that actually appear in its symbols: #090710 on 165 text layers and
|
||||
// #505C69 on 164. The accent is the one it uses at 12pt as well as on
|
||||
// buttons, so it is their link colour, not only their fill.
|
||||
//
|
||||
// These names used to say Ember, and held orange. The orange was the
|
||||
// brand when this app was written and is not any more, the public pages
|
||||
// carry none of it, so the names go with the colour rather than outliving
|
||||
// it. A constant called `Ember` holding blue is how a palette drifts back.
|
||||
// The design kit's palette, measured out of the file, not sampled from a
|
||||
// render and not approximated.
|
||||
val Accent = Color(0xFF2298FF) // buttons, bubbles, links
|
||||
val AccentBright = Color(0xFF4FACFE) // the kit's lighter primary swatch
|
||||
val AccentSoft = Color(0x1A2298FF)
|
||||
@@ -63,14 +37,15 @@ object Palette {
|
||||
val Mist = Color(0xFFF9F9F9) // the page ground
|
||||
val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands
|
||||
val IncomingBubble = Color(0xFFF3F3F3)
|
||||
// A blue-tinted grey rather than the flat #F3F3F3: an initials avatar
|
||||
// needs to read as a circle against the surface behind it, and two greys a
|
||||
// shade apart do not.
|
||||
|
||||
// A blue-tinted grey rather than the flat #F3F3F3: an initials avatar needs
|
||||
// to read as a circle against the surface behind it, and two greys a shade
|
||||
// apart do not.
|
||||
val AvatarFill = Color(0xFFE8F1FB)
|
||||
val SecondaryText = Color(0xFF505C69)
|
||||
val Divider = Color(0x1A505C69)
|
||||
|
||||
// Semantic
|
||||
// Semantic.
|
||||
val Success = Color(0xFF3BD5B2)
|
||||
val Warning = Color(0xFFB8860B)
|
||||
val Danger = Color(0xFFED497C)
|
||||
@@ -89,14 +64,9 @@ object Radius {
|
||||
}
|
||||
|
||||
/** The 4dp spacing scale, matching `Theme.Space` on iOS. */
|
||||
|
||||
/**
|
||||
* Measurements taken from the design kit, in the kit's own terms.
|
||||
*
|
||||
* Insets, never fixed widths. The kit is drawn on a 375pt canvas and its
|
||||
* numbers are absolute, a 327-wide row is 375 minus two 24pt margins. Copying
|
||||
* 327 across would break the layout on every phone that is not an iPhone X.
|
||||
* Everything here is an inset or an intrinsic size, with the width left to the
|
||||
* layout. See `DESIGN.md`, which both apps read from.
|
||||
*/
|
||||
object Kit {
|
||||
val margin = 24.dp
|
||||
@@ -111,8 +81,10 @@ object Kit {
|
||||
val pillRadius = 25.dp
|
||||
val icon = 24.dp
|
||||
val presenceDot = 12.dp
|
||||
|
||||
/** Every bubble corner but the one that points at its author. */
|
||||
val bubbleRadius = 16.dp
|
||||
|
||||
/** Not zero: a hard point reads as a rendering artefact at this size. */
|
||||
val bubbleTail = 2.dp
|
||||
}
|
||||
@@ -126,37 +98,24 @@ object Space {
|
||||
val xxl = 32.dp
|
||||
}
|
||||
|
||||
private val FlamenetShapes = Shapes(
|
||||
private val FlamenetShapes =
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(Radius.small),
|
||||
small = RoundedCornerShape(Radius.small),
|
||||
medium = RoundedCornerShape(Radius.medium),
|
||||
large = RoundedCornerShape(Radius.large),
|
||||
extraLarge = RoundedCornerShape(Radius.large),
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* The type scale.
|
||||
*
|
||||
* Sizes only, no `fontFamily`. Bundling a font would need the licence to
|
||||
* cover redistribution, and the platform default already reads well at these
|
||||
* sizes. If a face is chosen later it goes here and in the iOS `Theme.Font`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The design kit's type scale, applied globally.
|
||||
*
|
||||
* This is the single biggest reason the app did not look like the kit.
|
||||
* Material 3's defaults are considerably larger than the kit's, and every
|
||||
* screen inherits them, so a section heading rendered at 24sp where the kit
|
||||
* says 16, and a body line at 16 where the kit says 14. Restyling individual
|
||||
* screens against those defaults is pushing against the whole theme; setting
|
||||
* the scale here fixes every screen at once, including ones nobody has
|
||||
* touched yet.
|
||||
*
|
||||
* Names on the left are Material's; sizes on the right are the kit's. See
|
||||
* `DESIGN.md`. H1 40, H2 32, H3 24, H4 20, Sub 16/14/12, Body 16/14,
|
||||
* Description 12/10.
|
||||
*/
|
||||
private val FlamenetTypography = Typography().let { base ->
|
||||
private val FlamenetTypography =
|
||||
Typography().let { base ->
|
||||
base.copy(
|
||||
headlineLarge = base.headlineLarge.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold),
|
||||
headlineMedium = base.headlineMedium.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold),
|
||||
@@ -171,40 +130,41 @@ private val FlamenetTypography = Typography().let { base ->
|
||||
labelLarge = base.labelLarge.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium),
|
||||
labelMedium = base.labelMedium.copy(fontSize = 12.sp),
|
||||
// Description 2, the timestamp above a bubble.
|
||||
labelSmall = base.labelSmall.copy(fontSize = 10.sp, letterSpacing = 0.sp,
|
||||
fontWeight = FontWeight.Normal),
|
||||
labelSmall =
|
||||
base.labelSmall.copy(
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 0.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The card: raised surface, generous radius, one soft shadow.
|
||||
*
|
||||
* A modifier rather than a component so it can wrap the existing sections
|
||||
* without restructuring them. The look depends on every card sharing one
|
||||
* depth, resist giving any single card its own elevation.
|
||||
*/
|
||||
fun Modifier.flamenetCard(
|
||||
padding: PaddingValues = PaddingValues(Space.lg),
|
||||
): Modifier = this
|
||||
fun Modifier.flamenetCard(padding: PaddingValues = PaddingValues(Space.lg)): Modifier =
|
||||
this
|
||||
.shadow(3.dp, RoundedCornerShape(Radius.medium), clip = false)
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.padding(padding)
|
||||
|
||||
@Composable
|
||||
fun MessengerTheme(content: @Composable () -> Unit) {
|
||||
// Read here rather than passed in, so every entry point picks it up,
|
||||
// the app has more than one, and a theme applied at only some of them is
|
||||
// the kind of half-wiring that looks fine until somebody opens a call
|
||||
// screen from a notification.
|
||||
// Read here rather than passed in, so every entry point picks it up, the
|
||||
// app has more than one, and a theme applied at only some of them is the
|
||||
// kind of half-wiring that looks fine until somebody opens a call screen
|
||||
// from a notification.
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val preference = remember { DisplaySettings(context).appearance }
|
||||
val dark = when (preference) {
|
||||
val dark =
|
||||
when (preference) {
|
||||
DisplaySettings.Appearance.LIGHT -> false
|
||||
DisplaySettings.Appearance.DARK -> true
|
||||
DisplaySettings.Appearance.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
MaterialTheme(
|
||||
colorScheme = if (dark) {
|
||||
colorScheme =
|
||||
if (dark) {
|
||||
darkColorScheme(
|
||||
primary = Palette.AccentBright,
|
||||
onPrimary = Color.White,
|
||||
@@ -220,10 +180,8 @@ fun MessengerTheme(content: @Composable () -> Unit) {
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
// The kit's #2298FF on white is about 3.3:1, fine for a
|
||||
// bold button label, not for small text. Anything small and
|
||||
// blue must use a darker step; the two places that do are the
|
||||
// section labels and the timestamps, and both are grey.
|
||||
// The kit's #2298FF on white is about 3.3:1, fine for a bold
|
||||
// button label, not for small text.
|
||||
primary = Palette.Accent,
|
||||
onPrimary = Color.White,
|
||||
secondary = Palette.AccentBright,
|
||||
|
||||
@@ -6,20 +6,14 @@ import java.io.File
|
||||
|
||||
/**
|
||||
* Recording and playing voice notes.
|
||||
*
|
||||
* Platform APIs only, `MediaRecorder` and `MediaPlayer`, because a voice note
|
||||
* is an ordinary encrypted attachment once it is bytes, and everything below
|
||||
* this file already handles that. Nothing here knows about the relay.
|
||||
*
|
||||
* AAC in an MP4 container (`audio/mp4`): playable by the other clients without
|
||||
* a codec conversation, and small enough that a minute of speech is tens of
|
||||
* kilobytes rather than megabytes, which matters when every byte is uploaded
|
||||
* encrypted and stored by somebody paying for it.
|
||||
*/
|
||||
object VoiceNote {
|
||||
|
||||
/** What a finished recording produced. */
|
||||
data class Recorded(val bytes: ByteArray, val mime: String, val seconds: Double) {
|
||||
data class Recorded(
|
||||
val bytes: ByteArray,
|
||||
val mime: String,
|
||||
val seconds: Double,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is Recorded && bytes.contentEquals(other.bytes) &&
|
||||
mime == other.mime && seconds == other.seconds
|
||||
@@ -31,10 +25,6 @@ object VoiceNote {
|
||||
|
||||
/**
|
||||
* A recording in progress.
|
||||
*
|
||||
* Holds a file the caller must dispose of. Both [stop] and [cancel]
|
||||
* delete it; a path that does neither leaves plaintext audio in the cache
|
||||
* directory, which is exactly the thing this product is not supposed to do.
|
||||
*/
|
||||
class Recording internal constructor(
|
||||
private val recorder: MediaRecorder,
|
||||
@@ -43,17 +33,11 @@ object VoiceNote {
|
||||
) {
|
||||
/**
|
||||
* Stop, and return the bytes.
|
||||
*
|
||||
* `MediaRecorder.stop()` throws if it never captured a frame,
|
||||
* a tap rather than a hold, or a permission that was granted but is
|
||||
* being denied by a policy. The exception is `RuntimeException` with no
|
||||
* useful message, and it reads as "recording is broken" when the truth
|
||||
* is "there was nothing to record". Treated as an empty recording,
|
||||
* because that is what it is.
|
||||
*/
|
||||
fun stop(): Recorded? {
|
||||
val elapsed = (System.currentTimeMillis() - startedAtMillis) / 1000.0
|
||||
val captured = try {
|
||||
val captured =
|
||||
try {
|
||||
recorder.stop()
|
||||
true
|
||||
} catch (_: RuntimeException) {
|
||||
@@ -64,8 +48,7 @@ object VoiceNote {
|
||||
val bytes = if (captured && file.exists()) file.readBytes() else ByteArray(0)
|
||||
file.delete()
|
||||
// Below this, the container holds a header and nothing worth
|
||||
// sending. Better to drop it than to deliver a note that plays
|
||||
// silence and looks like a delivery bug.
|
||||
// sending.
|
||||
if (!captured || bytes.size < MIN_USEFUL_BYTES || elapsed < MIN_SECONDS) return null
|
||||
return Recorded(bytes, MIME, elapsed)
|
||||
}
|
||||
@@ -75,9 +58,7 @@ object VoiceNote {
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch (_: RuntimeException) {
|
||||
// Never started, or stopped with no frames. Nothing to salvage
|
||||
// and nothing to report, this path is the user changing their
|
||||
// mind, not a failure.
|
||||
// Never started, or stopped with no frames.
|
||||
} finally {
|
||||
recorder.release()
|
||||
file.delete()
|
||||
@@ -93,20 +74,17 @@ object VoiceNote {
|
||||
|
||||
/**
|
||||
* Start recording into the cache directory.
|
||||
*
|
||||
* @throws IllegalStateException when the microphone is unavailable, most
|
||||
* often because `RECORD_AUDIO` has not been granted. The caller asks for
|
||||
* the permission first; this does not, because a library that raises
|
||||
* dialogs is a library that raises them at the wrong moment.
|
||||
* often because `RECORD_AUDIO` has not been granted.
|
||||
*/
|
||||
fun start(cacheDir: File): Recording {
|
||||
val file = File.createTempFile("voice-", ".m4a", cacheDir)
|
||||
val recorder = MediaRecorder().apply {
|
||||
val recorder =
|
||||
MediaRecorder().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
// Speech, not music. Mono at 32 kbit/s is clearly intelligible and
|
||||
// roughly a tenth the size of a stereo default nobody asked for.
|
||||
// Speech, not music.
|
||||
setAudioChannels(1)
|
||||
setAudioSamplingRate(44_100)
|
||||
setAudioEncodingBitRate(32_000)
|
||||
@@ -125,21 +103,22 @@ object VoiceNote {
|
||||
|
||||
/**
|
||||
* Play decrypted bytes.
|
||||
*
|
||||
* Writes them to a file first, because `MediaPlayer` cannot be handed a
|
||||
* byte array. The file lives in the cache and is deleted when playback
|
||||
* ends or is stopped, decrypted audio sitting on disk after the user has
|
||||
* finished with it is a leak, however briefly.
|
||||
*/
|
||||
class Player(private val cacheDir: File) {
|
||||
class Player(
|
||||
private val cacheDir: File,
|
||||
) {
|
||||
private var player: MediaPlayer? = null
|
||||
private var file: File? = null
|
||||
|
||||
fun play(bytes: ByteArray, onFinished: () -> Unit) {
|
||||
fun play(
|
||||
bytes: ByteArray,
|
||||
onFinished: () -> Unit,
|
||||
) {
|
||||
stop()
|
||||
val target = File.createTempFile("play-", ".m4a", cacheDir).also { it.writeBytes(bytes) }
|
||||
file = target
|
||||
player = MediaPlayer().apply {
|
||||
player =
|
||||
MediaPlayer().apply {
|
||||
setDataSource(target.absolutePath)
|
||||
setOnCompletionListener {
|
||||
stop()
|
||||
@@ -151,7 +130,10 @@ object VoiceNote {
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
player?.let { runCatching { it.stop() }; it.release() }
|
||||
player?.let {
|
||||
runCatching { it.stop() }
|
||||
it.release()
|
||||
}
|
||||
player = null
|
||||
file?.delete()
|
||||
file = null
|
||||
|
||||
@@ -4,40 +4,34 @@ import android.content.Context
|
||||
|
||||
/**
|
||||
* Which chain node the wallet reads from.
|
||||
*
|
||||
* Pointing at a node is a privacy decision, and the app says so rather
|
||||
* than choosing quietly. Asking a public RPC endpoint what an address holds
|
||||
* ties that address to this device's IP in somebody else's logs, every time
|
||||
* the screen refreshes. Running your own node is the only answer that removes
|
||||
* it. The default exists so the wallet works at all, not because it is private.
|
||||
*
|
||||
* https only. A balance read over plain http is one anybody on the path
|
||||
* can rewrite, and a wallet showing a number an attacker chose is worse than a
|
||||
* wallet showing nothing. A configured http endpoint is ignored rather than
|
||||
* honoured, and the screen falls back to the default.
|
||||
*/
|
||||
object WalletChain {
|
||||
|
||||
const val DEFAULT_ENDPOINT = "https://api.mainnet-beta.solana.com"
|
||||
|
||||
private const val PREFS = "wallet-chain"
|
||||
private const val ENDPOINT = "endpoint"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/** The operator's own node, or null when the default is in use. */
|
||||
fun custom(context: Context): String? =
|
||||
prefs(context).getString(ENDPOINT, null)
|
||||
prefs(context)
|
||||
.getString(ENDPOINT, null)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() && it.startsWith("https://") }
|
||||
|
||||
fun endpoint(context: Context): String = custom(context) ?: DEFAULT_ENDPOINT
|
||||
|
||||
fun setEndpoint(context: Context, url: String?) {
|
||||
fun setEndpoint(
|
||||
context: Context,
|
||||
url: String?,
|
||||
) {
|
||||
val cleaned = url?.trim().orEmpty()
|
||||
if (cleaned.isEmpty()) prefs(context).edit().remove(ENDPOINT).apply()
|
||||
else prefs(context).edit().putString(ENDPOINT, cleaned).apply()
|
||||
if (cleaned.isEmpty()) {
|
||||
prefs(context).edit().remove(ENDPOINT).apply()
|
||||
} else {
|
||||
prefs(context).edit().putString(ENDPOINT, cleaned).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** "Solana mainnet", or the host of whatever was configured. */
|
||||
@@ -48,25 +42,21 @@ object WalletChain {
|
||||
|
||||
/**
|
||||
* What a transfer costs, and what must stay behind.
|
||||
*
|
||||
* An account below the rent-exempt minimum is reclaimed by the
|
||||
* network. Emptying a wallet to the last lamport does not leave it empty;
|
||||
* it deletes it. Anything offering to send "everything" has to stop here,
|
||||
* and the two numbers are named rather than buried in an expression so that
|
||||
* the reason survives.
|
||||
*/
|
||||
const val FEE_LAMPORTS = 5_000L
|
||||
const val RENT_EXEMPT_LAMPORTS = 890_880L
|
||||
|
||||
/** What can actually be sent from a balance, or zero. */
|
||||
fun spendable(lamports: Long): Long =
|
||||
(lamports - FEE_LAMPORTS - RENT_EXEMPT_LAMPORTS).coerceAtLeast(0)
|
||||
fun spendable(lamports: Long): Long = (lamports - FEE_LAMPORTS - RENT_EXEMPT_LAMPORTS).coerceAtLeast(0)
|
||||
|
||||
/** Lamports as SOL, for display only. */
|
||||
fun sol(lamports: Long): String {
|
||||
val whole = lamports / 1_000_000_000L
|
||||
val fraction = lamports % 1_000_000_000L
|
||||
return "%d.%09d".format(whole, fraction).trimEnd('0').trimEnd('.')
|
||||
return "%d.%09d"
|
||||
.format(whole, fraction)
|
||||
.trimEnd('0')
|
||||
.trimEnd('.')
|
||||
.ifEmpty { "0" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -38,17 +38,6 @@ import java.util.Locale
|
||||
|
||||
/**
|
||||
* The wallet.
|
||||
*
|
||||
* A balance this app could not read is drawn as an em dash, never as
|
||||
* zero. Showing 0.00 because a node did not answer tells somebody their money
|
||||
* is gone, the single worst sentence this screen can say, and one it would say
|
||||
* on every flaky connection. Null is a different fact from empty and is drawn
|
||||
* as one.
|
||||
*
|
||||
* Both cards are always here once a wallet exists, empty or not. A
|
||||
* screen that hides the balance until it has one, and hides the history until
|
||||
* there is one, is a screen that looks broken exactly when somebody is looking
|
||||
* for reassurance that nothing went wrong.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.WalletScreen(
|
||||
@@ -60,7 +49,8 @@ fun ColumnScope.WalletScreen(
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
@@ -73,10 +63,7 @@ fun ColumnScope.WalletScreen(
|
||||
return@Column
|
||||
}
|
||||
|
||||
// The phrase card sits ABOVE everything about the chain. Somebody
|
||||
// who has just made a wallet has one job, write these words down, and
|
||||
// putting a balance in front of it invites them to skip it and come
|
||||
// back later, which for most people means never.
|
||||
// The phrase card sits ABOVE everything about the chain.
|
||||
wallet.phrase?.let { phrase -> PhraseCard(phrase, model, clipboard) }
|
||||
|
||||
BalanceCard(wallet, model, context)
|
||||
@@ -84,8 +71,12 @@ fun ColumnScope.WalletScreen(
|
||||
ActivityCard(wallet)
|
||||
|
||||
wallet.problem?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, fontSize = 13.sp,
|
||||
modifier = Modifier.testTag("wallet-problem"))
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.testTag("wallet-problem"),
|
||||
)
|
||||
}
|
||||
|
||||
if (wallet.phrase == null) {
|
||||
@@ -105,14 +96,12 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) {
|
||||
Card {
|
||||
Text("No wallet yet", fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
// Says what a wallet here IS before offering to make one. The two
|
||||
// facts that surprise people later are that it has nothing to do with
|
||||
// their Flamenet account, and that the phrase is the only backup.
|
||||
// Says what a wallet here IS before offering to make one.
|
||||
Text(
|
||||
"A wallet on this phone, with keys only you hold. It is not connected "
|
||||
+ "to your Flamenet account, nobody can work out one from the other, "
|
||||
+ "and the recovery phrase is the only way back if this phone is "
|
||||
+ "erased or lost.",
|
||||
"A wallet on this phone, with keys only you hold. It is not connected " +
|
||||
"to your Flamenet account, nobody can work out one from the other, " +
|
||||
"and the recovery phrase is the only way back if this phone is " +
|
||||
"erased or lost.",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -137,7 +126,10 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) {
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = "Restore from a phrase",
|
||||
onClick = { model.importWallet(phrase.trim()); phrase = "" },
|
||||
onClick = {
|
||||
model.importWallet(phrase.trim())
|
||||
phrase = ""
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tag = "import-wallet",
|
||||
)
|
||||
@@ -145,11 +137,7 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown once, and the screen says what that means. The words are the
|
||||
* whole wallet: anybody who reads them owns it, and anybody who loses them
|
||||
* loses it. Both halves have to be said at the same moment, because saying only
|
||||
* the first makes people hide it and saying only the second makes them
|
||||
* screenshot it.
|
||||
* Shown once, and the screen says what that means.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.PhraseCard(
|
||||
@@ -161,10 +149,10 @@ private fun ColumnScope.PhraseCard(
|
||||
Text("Your recovery phrase", fontWeight = FontWeight.Medium, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Text(
|
||||
"Write these words down on paper, in order, and keep them somewhere "
|
||||
+ "safe. Anybody who has them can spend from this wallet. If this "
|
||||
+ "phone is erased or lost and you do not have them, the wallet is "
|
||||
+ "gone, nobody can recover it for you, including us.",
|
||||
"Write these words down on paper, in order, and keep them somewhere " +
|
||||
"safe. Anybody who has them can spend from this wallet. If this " +
|
||||
"phone is erased or lost and you do not have them, the wallet is " +
|
||||
"gone, nobody can recover it for you, including us.",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -173,13 +161,14 @@ private fun ColumnScope.PhraseCard(
|
||||
phrase,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
// `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt,
|
||||
// and two identical greys would leave the words looking like
|
||||
// body text rather than the one thing on this screen worth
|
||||
// copying onto paper.
|
||||
// `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt, and
|
||||
// two identical greys would leave the words looking like body
|
||||
// text rather than the one thing on this screen worth copying
|
||||
// onto paper.
|
||||
.background(Palette.Mist)
|
||||
.padding(Space.sm)
|
||||
.testTag("wallet-phrase"),
|
||||
@@ -189,9 +178,7 @@ private fun ColumnScope.PhraseCard(
|
||||
KitSecondaryButton(
|
||||
text = "Copy",
|
||||
// Copying puts the phrase on a clipboard every other app can
|
||||
// read. Offered because people will otherwise photograph the
|
||||
// screen, which is worse and permanent, but it is the second
|
||||
// option, not the first, and paper is what the text asks for.
|
||||
// read.
|
||||
onClick = { clipboard.setText(AnnotatedString(phrase)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "copy-phrase",
|
||||
@@ -223,9 +210,7 @@ private fun ColumnScope.BalanceCard(
|
||||
}
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Text(
|
||||
// An em dash, never "0.00". Null means this app could not read
|
||||
// the balance; zero means the account is empty. Drawing the first
|
||||
// as the second tells somebody their money is gone.
|
||||
// An em dash, never "0.00".
|
||||
wallet.lamports?.let { "${WalletChain.sol(it)} SOL" } ?: ", ",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
@@ -233,8 +218,8 @@ private fun ColumnScope.BalanceCard(
|
||||
)
|
||||
if (wallet.lamports == null && !wallet.busy) {
|
||||
Text(
|
||||
"Not read yet. Nothing has been lost, this is what the app knows, "
|
||||
+ "not what the wallet holds.",
|
||||
"Not read yet. Nothing has been lost, this is what the app knows, " +
|
||||
"not what the wallet holds.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("balance-unknown"),
|
||||
@@ -265,13 +250,10 @@ private fun ColumnScope.ReceiveCard(
|
||||
modifier = Modifier.fillMaxWidth().testTag("wallet-address"),
|
||||
)
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
// Says the address changes BEFORE somebody saves the old one
|
||||
// somewhere. A fresh address per payment is the one linkability a
|
||||
// wallet can prevent, and an address that quietly changed would look
|
||||
// like a bug to anybody who had written it down.
|
||||
// Says the address changes BEFORE somebody saves the old one somewhere.
|
||||
Text(
|
||||
"A new address each time you ask, so payments to you are not all "
|
||||
+ "tied together on a public ledger. Older addresses keep working.",
|
||||
"A new address each time you ask, so payments to you are not all " +
|
||||
"tied together on a public ledger. Older addresses keep working.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -301,17 +283,27 @@ private fun ColumnScope.ActivityCard(wallet: MessengerViewModel.WalletState) {
|
||||
val history = wallet.history
|
||||
when {
|
||||
// Same rule as the balance: unknown is not the same as none.
|
||||
history == null -> Text(
|
||||
history == null -> {
|
||||
Text(
|
||||
"Not read yet.",
|
||||
fontSize = 13.sp, color = Palette.SecondaryText,
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("activity-unknown"),
|
||||
)
|
||||
history.isEmpty() -> Text(
|
||||
}
|
||||
|
||||
history.isEmpty() -> {
|
||||
Text(
|
||||
"Nothing yet.",
|
||||
fontSize = 13.sp, color = Palette.SecondaryText,
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("activity-empty"),
|
||||
)
|
||||
else -> history.forEach { entry -> ActivityRow(entry) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
history.forEach { entry -> ActivityRow(entry) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,8 +323,6 @@ private fun ActivityRow(entry: io.flamenet.client.ChainRpc.Entry) {
|
||||
)
|
||||
Text(
|
||||
// "Just now" for a transaction the node has not dated yet.
|
||||
// It has it and has not assigned a block time; showing 1970
|
||||
// would be the epoch leaking onto somebody's screen.
|
||||
entry.blockTimeSeconds?.let {
|
||||
SimpleDateFormat("d MMM HH:mm", Locale.getDefault())
|
||||
.format(Date(it * 1000))
|
||||
@@ -341,11 +331,14 @@ private fun ActivityRow(entry: io.flamenet.client.ChainRpc.Entry) {
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
// A failed transaction says so. Not showing it would have somebody
|
||||
// believe a payment went through.
|
||||
// A failed transaction says so.
|
||||
if (entry.failed) {
|
||||
Text("Failed", fontSize = 12.sp, color = Palette.Pink,
|
||||
modifier = Modifier.testTag("tx-failed"))
|
||||
Text(
|
||||
"Failed",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.Pink,
|
||||
modifier = Modifier.testTag("tx-failed"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,7 +346,8 @@ private fun ActivityRow(entry: io.flamenet.client.ChainRpc.Entry) {
|
||||
@Composable
|
||||
private fun ColumnScope.Card(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.background(Palette.SurfaceAlt)
|
||||
|
||||
@@ -6,35 +6,12 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Slowing down somebody who keeps trying (ios#41, Android half).
|
||||
*
|
||||
* Android had the lock and none of the back-off. iOS has counted failed
|
||||
* unlocks and imposed a growing wait since ios#41; here every attempt was free,
|
||||
* so somebody holding an unlocked phone could work through PINs as fast as the
|
||||
* prompt would reappear. Two platforms disagreeing about a security guarantee is
|
||||
* how one of them ends up being the one that leaks.
|
||||
*
|
||||
* What this defends and what it does not. It slows a person holding the
|
||||
* phone. It is not a defence of the data, the vault and the ratchet keys are
|
||||
* protected by the Android keystore, exactly as `AppLock`'s own note says, and
|
||||
* no amount of back-off changes that.
|
||||
*
|
||||
* The two ends of the schedule are what matter, and both are tested: an
|
||||
* ordinary slip costs nothing, and the wait has a ceiling.
|
||||
*
|
||||
* The schedule is a pure function, so it is tested without a `Context`. The
|
||||
* parts that need SharedPreferences are exercised on device in
|
||||
* `CrudSurfaceTest`; putting the arithmetic behind an instrumented test would
|
||||
* have meant the rule nobody could check quickly was the rule that decides
|
||||
* whether somebody is locked out of their own messages.
|
||||
*/
|
||||
class AppLockBackoffTest {
|
||||
|
||||
// ---- The schedule ---------------------------------------------------
|
||||
// ---- The schedule ---------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Three, not one. A mistyped PIN or a fingerprint that did not read is
|
||||
* ordinary, and making the first slip cost a wait would punish the owner far
|
||||
* more often than anybody else.
|
||||
* Three, not one.
|
||||
*/
|
||||
@Test
|
||||
fun `an ordinary slip costs nothing`() {
|
||||
@@ -52,10 +29,7 @@ class AppLockBackoffTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The ceiling is the important half. An unbounded back-off locks
|
||||
* somebody out of their own messages permanently, which is a worse outcome
|
||||
* than the one it defends against: there is no reset, no support desk, and
|
||||
* the data is on their own phone.
|
||||
* The ceiling is the important half.
|
||||
*/
|
||||
@Test
|
||||
fun `the wait is capped`() {
|
||||
@@ -65,14 +39,9 @@ class AppLockBackoffTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The overflow the Swift version cannot have and Kotlin can. iOS
|
||||
* computes `30 * pow(2, steps)` in a Double, which saturates to infinity and
|
||||
* still caps correctly. Shifting a Long does not: `30L shl 60` wraps to a
|
||||
* negative number, and `minOf(negative, cap)` returns the negative, a
|
||||
* "wait" in the past, which reads as no wait at all and quietly removes the
|
||||
* whole feature at exactly the point somebody is grinding attempts.
|
||||
*
|
||||
* The steps are clamped before the shift. This is the test that says so.
|
||||
* The overflow the Swift version cannot have and Kotlin can. iOS computes
|
||||
* `30 * pow(2, steps)` in a Double, which saturates to infinity and still
|
||||
* caps correctly.
|
||||
*/
|
||||
@Test
|
||||
fun `an absurd failure count still returns the ceiling, not a negative wait`() {
|
||||
@@ -89,17 +58,19 @@ class AppLockBackoffTest {
|
||||
var previous = 0L
|
||||
for (failures in 0..40) {
|
||||
val wait = AppLock.delayMillis(failures)
|
||||
assertTrue(wait >= previous,
|
||||
"delayMillis($failures) = $wait dropped below $previous")
|
||||
assertTrue(
|
||||
wait >= previous,
|
||||
"delayMillis($failures) = $wait dropped below $previous",
|
||||
)
|
||||
previous = wait
|
||||
}
|
||||
}
|
||||
|
||||
// ---- How it is said -------------------------------------------------
|
||||
// ---- How it is said -------------------------------------------------.
|
||||
|
||||
/**
|
||||
* A lock screen that says "try again in 240 seconds" is one people read
|
||||
* as broken. The unit follows the size of the number.
|
||||
* A lock screen that says "try again in 240 seconds" is one people read as
|
||||
* broken.
|
||||
*/
|
||||
@Test
|
||||
fun `the wait is said in whichever unit reads naturally`() {
|
||||
@@ -123,11 +94,6 @@ class AppLockBackoffTest {
|
||||
|
||||
/**
|
||||
* The wording rule, lifted out so it can be checked without a `Context`.
|
||||
*
|
||||
* Kept byte-identical to `AppLock.waitMessage` on purpose, if the two
|
||||
* drift, this test is asserting about a string nobody ever sees. It is a
|
||||
* copy because a `Context` is the only thing standing between the real one
|
||||
* and a plain JVM test, and the arithmetic is worth more than the purity.
|
||||
*/
|
||||
private fun sayWait(seconds: Int): String =
|
||||
if (seconds >= 60) {
|
||||
|
||||
@@ -5,6 +5,11 @@ import io.flamenet.client.RelayClient
|
||||
import io.flamenet.e2e.Attachment
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.json.JSONObject
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
@@ -14,47 +19,51 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.json.JSONObject
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Cached plaintext goes when nothing refers to it any more.
|
||||
*
|
||||
* `attachmentBytes` writes DECRYPTED bytes and nothing removed them, so
|
||||
* a disappearing message expired, the bubble went, and the photo stayed on disk
|
||||
* indefinitely, the exact failure the feature exists to prevent (android#11,
|
||||
* spec §6.11).
|
||||
*
|
||||
* The dangerous direction here is the opposite of the bug. A sweep that
|
||||
* deletes too much loses somebody's photo, which is worse than the leak. So
|
||||
* most of these are about what must SURVIVE.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class AttachmentPurgeTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var cache: File
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
cache = File.createTempFile("cache", "").let { it.delete(); it.mkdirs(); it }
|
||||
cache =
|
||||
File.createTempFile("cache", "").let {
|
||||
it.delete()
|
||||
it.mkdirs()
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
cache.deleteRecursively()
|
||||
}
|
||||
@AfterTest fun stop() { server.shutdown(); cache.deleteRecursively() }
|
||||
|
||||
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 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 key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private lateinit var vault: Vault
|
||||
|
||||
@@ -62,25 +71,39 @@ class AttachmentPurgeTest {
|
||||
val identity = DeviceIdentity.create()
|
||||
vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
return Session("alice", 1, identity, vault,
|
||||
return Session(
|
||||
"alice",
|
||||
1,
|
||||
identity,
|
||||
vault,
|
||||
RelayClient(base, "alice", "app pass word"),
|
||||
MessengerApi(base, "alice", "app pass word"))
|
||||
MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ref(id: String) = Attachment.Ref(
|
||||
kind = Attachment.IMAGE, id = id,
|
||||
key = ByteArray(32) { 3 }, digest = ByteArray(32) { 4 },
|
||||
bytes = 9, mime = "image/jpeg", width = 1, height = 1,
|
||||
private fun ref(id: String) =
|
||||
Attachment.Ref(
|
||||
kind = Attachment.IMAGE,
|
||||
id = id,
|
||||
key = ByteArray(32) { 3 },
|
||||
digest = ByteArray(32) { 4 },
|
||||
bytes = 9,
|
||||
mime = "image/jpeg",
|
||||
width = 1,
|
||||
height = 1,
|
||||
)
|
||||
|
||||
/** A cache file as `attachmentBytes` would have written it, aged past the grace. */
|
||||
private fun cached(id: String, ageMillis: Long = 10 * 60_000L): File =
|
||||
private fun cached(
|
||||
id: String,
|
||||
ageMillis: Long = 10 * 60_000L,
|
||||
): File =
|
||||
File(cache, "att-" + id.replace('/', '_')).apply {
|
||||
writeBytes("decrypted".toByteArray())
|
||||
setLastModified(System.currentTimeMillis() - ageMillis)
|
||||
}
|
||||
|
||||
// ---- The bug ----------------------------------------------------------
|
||||
// ---- The bug ----------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `an expired message's cached plaintext is deleted`() {
|
||||
@@ -92,13 +115,18 @@ class AttachmentPurgeTest {
|
||||
assertFalse(orphan.exists(), "the plaintext of an expired message is still on disk")
|
||||
}
|
||||
|
||||
// ---- What must survive ------------------------------------------------
|
||||
// ---- What must survive ------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `an attachment a live message still refers to survives`() {
|
||||
val s = session()
|
||||
vault.append("user:2", JSONObject().put("at", 1L).put("text", "")
|
||||
.put("att", ref("still-shown").toJson()))
|
||||
vault.append(
|
||||
"user:2",
|
||||
JSONObject()
|
||||
.put("at", 1L)
|
||||
.put("text", "")
|
||||
.put("att", ref("still-shown").toJson()),
|
||||
)
|
||||
val live = cached("still-shown")
|
||||
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache))
|
||||
@@ -113,9 +141,20 @@ class AttachmentPurgeTest {
|
||||
@Test
|
||||
fun `an archived story's attachment survives, long after it expired`() {
|
||||
val s = session()
|
||||
vault.saveStoryArchive(listOf(Vault.Story(
|
||||
id = "s1", authorId = 1L, mine = true, ref = ref("archived"),
|
||||
caption = "", postedAt = 0L, seen = true, expiresAt = 1.0)))
|
||||
vault.saveStoryArchive(
|
||||
listOf(
|
||||
Vault.Story(
|
||||
id = "s1",
|
||||
authorId = 1L,
|
||||
mine = true,
|
||||
ref = ref("archived"),
|
||||
caption = "",
|
||||
postedAt = 0L,
|
||||
seen = true,
|
||||
expiresAt = 1.0,
|
||||
),
|
||||
),
|
||||
)
|
||||
val archived = cached("archived")
|
||||
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache))
|
||||
@@ -128,33 +167,34 @@ class AttachmentPurgeTest {
|
||||
val s = session()
|
||||
vault.saveAvatar(7L, ref("their-face"))
|
||||
vault.saveMyAvatar(ref("my-face"))
|
||||
val a = cached("their-face"); val b = cached("my-face")
|
||||
val a = cached("their-face")
|
||||
val b = cached("my-face")
|
||||
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache))
|
||||
assertTrue(a.exists() && b.exists(), "an avatar was deleted as an orphan")
|
||||
}
|
||||
|
||||
/**
|
||||
* An id containing a slash, which is the reason the transformation
|
||||
* exists. `attachmentBytes` writes the file as `att-<id with / -> _>`, so
|
||||
* membership has to be tested against the SAME mangling. Compare raw ids to
|
||||
* mangled filenames and nothing matches, every attachment with a slash in
|
||||
* its id is deleted while still on screen.
|
||||
*
|
||||
* Added after a mutation test: removing the transformation broke nothing,
|
||||
* because every other id here is slash-free. The guard was untested by the
|
||||
* suite that was meant to be guarding it.
|
||||
* An id containing a slash, which is the reason the transformation exists.
|
||||
*/
|
||||
@Test
|
||||
fun `an id containing a slash is matched against its mangled filename`() {
|
||||
val s = session()
|
||||
vault.append("user:2", JSONObject().put("at", 1L).put("text", "")
|
||||
.put("att", ref("blobs/2026/09/live-one").toJson()))
|
||||
vault.append(
|
||||
"user:2",
|
||||
JSONObject()
|
||||
.put("at", 1L)
|
||||
.put("text", "")
|
||||
.put("att", ref("blobs/2026/09/live-one").toJson()),
|
||||
)
|
||||
val live = cached("blobs/2026/09/live-one")
|
||||
assertEquals("att-blobs_2026_09_live-one", live.name)
|
||||
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache),
|
||||
"an attachment whose id contains a slash was treated as an orphan")
|
||||
assertEquals(
|
||||
0,
|
||||
s.purgeOrphanedAttachments(cache),
|
||||
"an attachment whose id contains a slash was treated as an orphan",
|
||||
)
|
||||
assertTrue(live.exists())
|
||||
}
|
||||
|
||||
@@ -180,12 +220,10 @@ class AttachmentPurgeTest {
|
||||
assertTrue(other.exists(), "a file that is not an attachment cache entry was deleted")
|
||||
}
|
||||
|
||||
// ---- Failing closed ---------------------------------------------------
|
||||
// ---- Failing closed ---------------------------------------------------.
|
||||
|
||||
/**
|
||||
* If the in-use set cannot be built, NOTHING is deleted. A partial
|
||||
* answer to "what is still in use" is the one input that turns this from a
|
||||
* cleanup into data loss.
|
||||
* If the in-use set cannot be built, NOTHING is deleted.
|
||||
*/
|
||||
@Test
|
||||
fun `an unreadable vault record stops the sweep entirely`() {
|
||||
@@ -193,20 +231,26 @@ class AttachmentPurgeTest {
|
||||
val orphan = cached("would-have-gone")
|
||||
// A record the vault cannot open at all.
|
||||
vault.append("user:9", JSONObject().put("at", 1L))
|
||||
val storage = Vault::class.java.getDeclaredField("storage")
|
||||
.apply { isAccessible = true }.get(vault) as Vault.Storage
|
||||
val storage =
|
||||
Vault::class.java
|
||||
.getDeclaredField("storage")
|
||||
.apply { isAccessible = true }
|
||||
.get(vault) as Vault.Storage
|
||||
storage.write("history/user:9", ByteArray(30) { it.toByte() })
|
||||
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache),
|
||||
"the sweep ran on a partial view of what is in use")
|
||||
assertEquals(
|
||||
0,
|
||||
s.purgeOrphanedAttachments(cache),
|
||||
"the sweep ran on a partial view of what is in use",
|
||||
)
|
||||
assertTrue(orphan.exists())
|
||||
}
|
||||
|
||||
// ---- The throttle -----------------------------------------------------
|
||||
// ---- The throttle -----------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Building the in-use set reads every vault record, and this is called
|
||||
* from the poll loop, so a second call moments later does nothing.
|
||||
* Building the in-use set reads every vault record, and this is called from
|
||||
* the poll loop, so a second call moments later does nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `a second sweep moments later does not re-read the vault`() {
|
||||
@@ -216,7 +260,9 @@ class AttachmentPurgeTest {
|
||||
cached("second")
|
||||
assertEquals(0, s.purgeOrphanedAttachments(cache), "the throttle did not hold")
|
||||
// ...and it does run again once the interval has passed.
|
||||
assertEquals(1, s.purgeOrphanedAttachments(
|
||||
cache, now = System.currentTimeMillis() + 11 * 60_000L))
|
||||
assertEquals(
|
||||
1,
|
||||
s.purgeOrphanedAttachments(cache, now = System.currentTimeMillis() + 11 * 60_000L),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,25 +6,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A refused-because-the-key-changed failure has to say so (android#20).
|
||||
*
|
||||
* Reported from a real session: "the calls wouldn't connect because the
|
||||
* keys were changed. it wasn't obvious why the call failed." It was not
|
||||
* obvious because nothing said it. The call's signalling lambda was
|
||||
* `runCatching { … }` with no `onFailure`, so `IdentityChangedException`, the
|
||||
* send path refusing to encrypt to an unreviewed identity, which is TOFU
|
||||
* working, vanished. The offer never left and the screen sat in DIALLING.
|
||||
*
|
||||
* And the fix is on a screen the caller is not looking at. Accepting a
|
||||
* changed key is a banner in the CHAT; somebody calling from the Calls tab
|
||||
* never sees it. So the message has to name the conversation, not just the
|
||||
* problem.
|
||||
*
|
||||
* iOS has said "Their security code changed" on this path since it was
|
||||
* built. This is a parity gap, not a new idea, which is why the assertion is
|
||||
* about the substance of the sentence rather than its exact wording.
|
||||
*/
|
||||
class ChangedKeyIsExplainedTest {
|
||||
|
||||
private val message = describeFailure(IdentityChangedException(7L))
|
||||
|
||||
@Test
|
||||
@@ -37,10 +20,7 @@ class ChangedKeyIsExplainedTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The actionable half. A refusal somebody cannot act on is the same
|
||||
* as no explanation: they retry the same thing and are refused again. This
|
||||
* file's neighbours in `describe()` all say what to DO, and that rule is
|
||||
* written down there, this one has to keep it.
|
||||
* The actionable half.
|
||||
*/
|
||||
@Test
|
||||
fun `it says where to go and what to do`() {
|
||||
|
||||
@@ -6,29 +6,11 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A destructive button must not claim more than it does (android#18).
|
||||
*
|
||||
* "Unsend" did not unsend. The message menu offered it on your own
|
||||
* messages and called `deleteMessage`, which reaches `vault.deleteLine` and
|
||||
* stops there. No envelope leaves the phone; the recipient's copy stays exactly
|
||||
* where it is. Somebody who sent to the wrong person tapped it, watched the
|
||||
* bubble vanish, and had no way to find out.
|
||||
*
|
||||
* Worse than an overclaiming page, which is why this is a test and not
|
||||
* a one-line fix left to stay fixed. A page that overclaims can be read
|
||||
* sceptically. A button that reports success cannot: the feedback IS the claim,
|
||||
* and it arrives at the exact moment the person is most relieved to believe it.
|
||||
*
|
||||
* A source scan, and coarse on purpose. It cannot prove every label is honest.
|
||||
* It proves this particular word cannot come back on a local-only action
|
||||
* without somebody deleting a test that explains why, and it is a plain JVM
|
||||
* test, so it costs no emulator.
|
||||
*/
|
||||
class DestructiveLabelsTest {
|
||||
|
||||
private fun uiSources(): List<File> {
|
||||
// From `app/build/classes/...` back to the module root, whatever the
|
||||
// working directory happens to be. Resolving against a relative path
|
||||
// is how this kind of test silently scans nothing.
|
||||
// working directory happens to be.
|
||||
var dir = File(System.getProperty("user.dir"))
|
||||
while (!File(dir, "app/src/main/kotlin").isDirectory && dir.parentFile != null) {
|
||||
dir = dir.parentFile
|
||||
@@ -38,24 +20,16 @@ class DestructiveLabelsTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Comments are stripped, and that is required rather than tidy. The
|
||||
* comment explaining this bug necessarily quotes the word it is about, and
|
||||
* a guard that flags its own explanation is a guard somebody deletes.
|
||||
* Comments are stripped, and that is required rather than tidy.
|
||||
*/
|
||||
private fun codeLines(body: String): List<String> =
|
||||
body.lines()
|
||||
body
|
||||
.lines()
|
||||
.filterNot { it.trimStart().startsWith("//") || it.trimStart().startsWith("*") }
|
||||
.map { it.substringBefore("//") }
|
||||
|
||||
/**
|
||||
* The word promises the recipient's copy is gone. Nothing in this app
|
||||
* can do that yet: it would take a signed control envelope naming the `mid`,
|
||||
* which the recipient acts on, and even then it is a *request*, so whatever
|
||||
* the button eventually says must not promise more than "we asked them to
|
||||
* remove it."
|
||||
*
|
||||
* When that lands, this test is the thing to update, deliberately, in the
|
||||
* same change.
|
||||
* The word promises the recipient's copy is gone.
|
||||
*/
|
||||
@Test
|
||||
fun `no button says Unsend`() {
|
||||
@@ -75,9 +49,7 @@ class DestructiveLabelsTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The walk is the part most likely to rot. A scan that finds no files
|
||||
* passes every assertion in it, so this pins that it is actually reading
|
||||
* the screen the bug was on.
|
||||
* The walk is the part most likely to rot.
|
||||
*/
|
||||
@Test
|
||||
fun `the scan actually reaches the chat screen`() {
|
||||
|
||||
@@ -5,21 +5,11 @@ import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* The display preferences Android was missing.
|
||||
*
|
||||
* Only the pure decisions are here. Whether a switch is wired to the dot,
|
||||
* the timestamp and the theme is not something a JVM test can see, that is
|
||||
* what the instrumented suite and a person looking at the screen are for. What
|
||||
* IS worth pinning is the arithmetic, because it has a default that must not
|
||||
* move and a clamp that must not be skipped.
|
||||
*/
|
||||
class DisplaySettingsTest {
|
||||
|
||||
/**
|
||||
* 16sp with the setting off, because that is what the kit specifies
|
||||
* for a message body. A "larger type" switch that quietly restyled every
|
||||
* message when it was turned OFF would be a worse bug than the absence it
|
||||
* fixes, and the number is easy to get wrong because it lives in one file
|
||||
* and is drawn in another.
|
||||
* 16sp with the setting off, because that is what the kit specifies for a
|
||||
* message body.
|
||||
*/
|
||||
@Test
|
||||
fun `message text is the kit size until larger type is asked for`() {
|
||||
@@ -28,16 +18,17 @@ class DisplaySettingsTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* An unrecognised stored value reads as `system`, the state somebody
|
||||
* who never touched this has. A value from a future build, or a corrupted
|
||||
* preference, must not leave the app in no theme at all.
|
||||
* An unrecognised stored value reads as `system`, the state somebody who
|
||||
* never touched this has.
|
||||
*/
|
||||
@Test
|
||||
fun `an unknown appearance falls back to matching the phone`() {
|
||||
assertEquals(DisplaySettings.Appearance.SYSTEM, DisplaySettings.Appearance.from(null))
|
||||
assertEquals(DisplaySettings.Appearance.SYSTEM, DisplaySettings.Appearance.from(""))
|
||||
assertEquals(DisplaySettings.Appearance.SYSTEM,
|
||||
DisplaySettings.Appearance.from("solarized-from-a-later-build"))
|
||||
assertEquals(
|
||||
DisplaySettings.Appearance.SYSTEM,
|
||||
DisplaySettings.Appearance.from("solarized-from-a-later-build"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -9,24 +9,11 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What the three new controls actually put on the wire (§6.15/§6.16/§6.19).
|
||||
*
|
||||
* The privacy rule for a forward is the one worth testing, because it is
|
||||
* the one a "helpful" change would break. §6.16 says a forward carries the
|
||||
* words and nothing about where they came from. The obvious improvement,
|
||||
* carrying a reference so the reader can see the original, would republish a
|
||||
* private message's author to somebody who was never in that conversation. So
|
||||
* the absence of that field is asserted, not just the presence of the flag.
|
||||
*
|
||||
* These check the ENVELOPE. The `Session` methods that build and apply them
|
||||
* need a vault and a keystore, so their behaviour is covered on device; what is
|
||||
* checkable here is the shape that crosses to the other app, which is where an
|
||||
* interop bug would live.
|
||||
*/
|
||||
class EditForwardPinWireTest {
|
||||
|
||||
private fun parse(json: String): Content = Content.parse(json)!!
|
||||
|
||||
// ---- Forward (§6.16) -------------------------------------------------
|
||||
// ---- Forward (§6.16) -------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The point of the feature: forwarded, and no trail back.
|
||||
@@ -46,9 +33,7 @@ class EditForwardPinWireTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A forward gets a NEW id, not the original's. Reusing it would make the
|
||||
* copy a target for edits and reactions aimed at the original, one of
|
||||
* which would silently rewrite the other.
|
||||
* A forward gets a NEW id, not the original's.
|
||||
*/
|
||||
@Test
|
||||
fun `a forward is a new message, not the old one re-sent`() {
|
||||
@@ -60,16 +45,21 @@ class EditForwardPinWireTest {
|
||||
/** A forward into a group carries the gid, and still no origin. */
|
||||
@Test
|
||||
fun `a group forward carries the group and still no origin`() {
|
||||
val sent = parse(
|
||||
Content.groupText("passed along", groupId = "gid-1",
|
||||
messageId = "mid-new", forwarded = true),
|
||||
val sent =
|
||||
parse(
|
||||
Content.groupText(
|
||||
"passed along",
|
||||
groupId = "gid-1",
|
||||
messageId = "mid-new",
|
||||
forwarded = true,
|
||||
),
|
||||
)
|
||||
assertEquals("gid-1", sent.groupId)
|
||||
assertTrue(sent.isForwarded)
|
||||
assertNull(sent.about, "a group forward is carrying a reference to its source")
|
||||
}
|
||||
|
||||
// ---- Edit (§6.15) ----------------------------------------------------
|
||||
// ---- Edit (§6.15) ----------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `an edit names the message it replaces and carries the new wording`() {
|
||||
@@ -88,12 +78,10 @@ class EditForwardPinWireTest {
|
||||
assertEquals("mid-1", sent.editOf)
|
||||
}
|
||||
|
||||
// ---- Pin (§6.19) -----------------------------------------------------
|
||||
// ---- Pin (§6.19) -----------------------------------------------------.
|
||||
|
||||
/**
|
||||
* An id and never a copy. A copy would go stale the moment the message
|
||||
* was edited and would outlive it being deleted, a banner quoting words
|
||||
* that are no longer in the conversation.
|
||||
* An id and never a copy.
|
||||
*/
|
||||
@Test
|
||||
fun `a pin names an id and carries no copy of the message`() {
|
||||
@@ -118,12 +106,10 @@ class EditForwardPinWireTest {
|
||||
assertEquals("mid-1", sent.pins)
|
||||
}
|
||||
|
||||
// ---- The three do not collide ----------------------------------------
|
||||
// ---- The three do not collide ----------------------------------------.
|
||||
|
||||
/**
|
||||
* Each envelope sets exactly one of the three fields. A message that was
|
||||
* both an edit and a pin would be applied twice by a receiver that checks
|
||||
* them in sequence, and the order of those checks is not part of the spec.
|
||||
* Each envelope sets exactly one of the three fields.
|
||||
*/
|
||||
@Test
|
||||
fun `each control sets exactly one of the three fields`() {
|
||||
|
||||
@@ -5,18 +5,11 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* "Nobody here yet" is a claim about the ACCOUNT. It must not be made about a
|
||||
* request that failed.
|
||||
*
|
||||
* Both roster load sites swallow failures with
|
||||
* `runCatching { … }.getOrDefault(emptyList())`, so a hiccup, an expired token
|
||||
* or a rate limit all produced an empty list, indistinguishable from a real
|
||||
* empty account. The same swallow-and-look-empty shape hid a decode bug that
|
||||
* made groups invisible on iOS.
|
||||
* "Nobody here yet" is a claim about the ACCOUNT.
|
||||
*/
|
||||
class EmptyVersusFailedTest {
|
||||
|
||||
private fun phase(loaded: Boolean) = MessengerViewModel.Phase.SignedIn(
|
||||
private fun phase(loaded: Boolean) =
|
||||
MessengerViewModel.Phase.SignedIn(
|
||||
handle = "someone",
|
||||
userId = 1,
|
||||
deviceId = "d",
|
||||
@@ -35,11 +28,17 @@ class EmptyVersusFailedTest {
|
||||
fun `a failed load does not default to claiming the roster loaded`() {
|
||||
// The default matters: a phase built without saying so must not assert
|
||||
// that the fetch succeeded.
|
||||
val untouched = MessengerViewModel.Phase.SignedIn(
|
||||
handle = "someone", userId = 1, deviceId = "d",
|
||||
relay = "relay.example", status = "",
|
||||
val untouched =
|
||||
MessengerViewModel.Phase.SignedIn(
|
||||
handle = "someone",
|
||||
userId = 1,
|
||||
deviceId = "d",
|
||||
relay = "relay.example",
|
||||
status = "",
|
||||
)
|
||||
assertTrue(
|
||||
"the default must be 'not loaded', so the copy stays honest",
|
||||
!untouched.buddiesLoaded,
|
||||
)
|
||||
assertTrue("the default must be 'not loaded', so the copy stays honest",
|
||||
!untouched.buddiesLoaded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The classification vocabulary the local vault offers.
|
||||
*
|
||||
* This is a change detector, and that is exactly what it should be. The
|
||||
* ORDINAL is what travels with a file, the word is only how it is drawn, so
|
||||
* renumbering these would file somebody's Restricted document as Internal on
|
||||
* the other phone. Nothing would error and neither screen would look wrong; the
|
||||
* only symptom is a document sitting at a level nobody chose.
|
||||
*
|
||||
* These four, in this order, are `Vocabularies.local` in
|
||||
* `Sources/Views/Files/FilesView.swift` on iOS. If that list changes, both have
|
||||
* to change together and this test is where somebody is made to notice.
|
||||
*/
|
||||
class FileLevelsTest {
|
||||
|
||||
@Test
|
||||
fun `the four local levels match the ordinals iOS files with`() {
|
||||
assertEquals(
|
||||
@@ -39,12 +28,11 @@ class FileLevelsTest {
|
||||
for ((ordinal, name) in FileLevels.LOCAL_LEVELS) {
|
||||
assertEquals(name, FileLevels.levelName(ordinal))
|
||||
}
|
||||
// A file can arrive from a client with a wider vocabulary. Drawing an
|
||||
// empty chip for it would show a row with no classification at all,
|
||||
// which reads as "unclassified", the most permissive thing it could
|
||||
// possibly mean, and a guess.
|
||||
assertTrue(FileLevels.levelName(9).contains("9"),
|
||||
"an unknown level must still say WHICH level, not render as blank")
|
||||
// A file can arrive from a client with a wider vocabulary.
|
||||
assertTrue(
|
||||
FileLevels.levelName(9).contains("9"),
|
||||
"an unknown level must still say WHICH level, not render as blank",
|
||||
)
|
||||
assertTrue(FileLevels.levelName(-1).isNotBlank())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,25 +9,22 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What a document sent from Files actually puts on the wire.
|
||||
*
|
||||
* The classification is the only thing that survives the trip. The
|
||||
* recipient's phone files the document at whatever number arrives, there is
|
||||
* nothing else to consult and no server to ask, so a label dropped in transit
|
||||
* does not error anywhere: the file simply arrives Unclassified and gets filed
|
||||
* that way, on the most permissive setting it could possibly have.
|
||||
*
|
||||
* These check the ENVELOPE. `Session.sendFile` needs a relay and a vault, so
|
||||
* the send itself is covered on device; the shape that crosses to the other app
|
||||
* is what an interop bug would live in, and it is checkable here.
|
||||
*/
|
||||
class FileShareWireTest {
|
||||
|
||||
private fun ref(classification: Int, compartments: List<Int>, name: String = "Q3.txt") =
|
||||
Attachment.Ref(
|
||||
kind = Attachment.FILE, id = "blob-1",
|
||||
key = ByteArray(32) { 1 }, digest = ByteArray(32) { 2 },
|
||||
bytes = 4096, mime = "application/octet-stream",
|
||||
filename = name, classification = classification, compartments = compartments,
|
||||
private fun ref(
|
||||
classification: Int,
|
||||
compartments: List<Int>,
|
||||
name: String = "Q3.txt",
|
||||
) = Attachment.Ref(
|
||||
kind = Attachment.FILE,
|
||||
id = "blob-1",
|
||||
key = ByteArray(32) { 1 },
|
||||
digest = ByteArray(32) { 2 },
|
||||
bytes = 4096,
|
||||
mime = "application/octet-stream",
|
||||
filename = name,
|
||||
classification = classification,
|
||||
compartments = compartments,
|
||||
)
|
||||
|
||||
private fun roundTrip(r: Attachment.Ref): Attachment.Ref {
|
||||
@@ -45,10 +42,6 @@ class FileShareWireTest {
|
||||
|
||||
/**
|
||||
* Unclassified must round-trip as a real value, not as an absence.
|
||||
* Zero is the default for a missing field, so a sender that dropped the key
|
||||
* and a sender that meant zero are indistinguishable at the other end. This
|
||||
* pins that the zero case still parses as a file rather than as something
|
||||
* the recipient has to guess about.
|
||||
*/
|
||||
@Test
|
||||
fun `an unclassified file is still a file, not a missing label`() {
|
||||
@@ -60,9 +53,6 @@ class FileShareWireTest {
|
||||
|
||||
/**
|
||||
* A file envelope always carries a caption, even when nobody wrote one.
|
||||
* Builds predating this attachment kind show the caption and drop the rest,
|
||||
* so an empty one reaches them as "[unsupported attachment]" with no clue
|
||||
* what was sent.
|
||||
*/
|
||||
@Test
|
||||
fun `a file with no caption still says what it is to an older build`() {
|
||||
@@ -75,16 +65,14 @@ class FileShareWireTest {
|
||||
|
||||
@Test
|
||||
fun `a caption somebody wrote is not replaced by the filename`() {
|
||||
val parsed = assertNotNull(
|
||||
Content.parse(Content.attachment(ref(1, emptyList()), caption = "the numbers")))
|
||||
val parsed =
|
||||
assertNotNull(Content.parse(Content.attachment(ref(1, emptyList()), caption = "the numbers")))
|
||||
assertEquals("the numbers", parsed.text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compartment order is normalised on the way out, so two senders who
|
||||
* chose the same areas produce the same bytes. Unsorted, the label would
|
||||
* still be *correct* and would still encode differently, which matters
|
||||
* because the canonical encoding is authenticated data elsewhere.
|
||||
* Compartment order is normalised on the way out, so two senders who chose
|
||||
* the same areas produce the same bytes.
|
||||
*/
|
||||
@Test
|
||||
fun `compartments arrive in a stable order`() {
|
||||
@@ -92,15 +80,20 @@ class FileShareWireTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* An image must NOT gain a label. `label` is written only for the file
|
||||
* kind, a photo carrying `cls: 0` would tell the other end there is a
|
||||
* classification here to respect, and there is not.
|
||||
* An image must NOT gain a label.
|
||||
*/
|
||||
@Test
|
||||
fun `an image carries no label at all`() {
|
||||
val image = Attachment.Ref(
|
||||
kind = Attachment.IMAGE, id = "b", key = ByteArray(32), digest = ByteArray(32),
|
||||
bytes = 10, mime = "image/jpeg", width = 4, height = 4,
|
||||
val image =
|
||||
Attachment.Ref(
|
||||
kind = Attachment.IMAGE,
|
||||
id = "b",
|
||||
key = ByteArray(32),
|
||||
digest = ByteArray(32),
|
||||
bytes = 10,
|
||||
mime = "image/jpeg",
|
||||
width = 4,
|
||||
height = 4,
|
||||
)
|
||||
val json = org.json.JSONObject(Content.attachment(image))
|
||||
assertTrue(!json.has("cls"), "an image envelope is claiming a classification")
|
||||
|
||||
@@ -6,23 +6,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Files asks for the Flamenet passphrase, not a second secret.
|
||||
*
|
||||
* The screen used to say the files were encrypted "with a passphrase of
|
||||
* their own", and iOS never said anything like it. iOS says "your
|
||||
* passphrase" and takes whatever is typed, so people use their Flamenet one
|
||||
* and it works. Android's copy sent them off to invent something else, and
|
||||
* forgetting it is a one-way door: the label keys are derived from the
|
||||
* identity it protects and exist nowhere else, so every vaulted file becomes
|
||||
* permanently unreadable.
|
||||
*
|
||||
* This is about the words, and the words are the whole feature here.
|
||||
* Nothing in the derivation changed: it is still a free-choice string
|
||||
* protecting the identity record, so anyone who already set a different one
|
||||
* keeps opening their files with it. A guard on the copy is therefore the only
|
||||
* guard there can be.
|
||||
*/
|
||||
class FilesUsesTheAccountPassphraseTest {
|
||||
|
||||
private fun source(name: String): String {
|
||||
var dir = File(System.getProperty("user.dir"))
|
||||
while (!File(dir, "app/src/main/kotlin").isDirectory && dir.parentFile != null) {
|
||||
@@ -51,9 +36,7 @@ class FilesUsesTheAccountPassphraseTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact phrase that caused it. Kept as a literal because a person
|
||||
* rewording this screen should have to read the comment above the string
|
||||
* and decide deliberately, not discover the consequence from a user.
|
||||
* The exact phrase that caused it.
|
||||
*/
|
||||
@Test
|
||||
fun `no screen tells somebody to invent a second secret`() {
|
||||
|
||||
@@ -9,6 +9,9 @@ import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -16,32 +19,16 @@ import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* The roster gate that turns a group-name CLAIM into a stored name.
|
||||
*
|
||||
* This is the check, and it lives here rather than in `Conversations`.
|
||||
* The layer that consumes control traffic does not know who is in which group,
|
||||
* so it queues claims and `Session` decides. Anyone who can reach us can send
|
||||
* one naming any gid they like; adopting on the sender's word would put
|
||||
* whatever a stranger wants into somebody's chat list, under a group heading
|
||||
* the reader has no reason to doubt.
|
||||
*
|
||||
* It was tested only through `Conversations`, which asserts the claim is
|
||||
* NOT adopted there, and says nothing about what happens next. The half that
|
||||
* decides was covered one layer removed on Android while iOS had it directly.
|
||||
* That is the gap this closes.
|
||||
*/
|
||||
// Robolectric for the same reason as OutboxTest: `:app` is an Android
|
||||
// module, so its unit-test classpath has the stubbed `org.json` that returns
|
||||
// null from everything, and the vault stores JSON.
|
||||
// Robolectric for the same reason as OutboxTest: `:app` is an Android module,
|
||||
// so its unit-test classpath has the stubbed `org.json` that returns null from
|
||||
// everything, and the vault stores JSON.
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GroupNameClaimTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
/** Who the relay will say is in the group, for this run. */
|
||||
@@ -49,47 +36,72 @@ class GroupNameClaimTest {
|
||||
|
||||
/**
|
||||
* An empty inbox and whatever roster the test asked for.
|
||||
*
|
||||
* Answers by PATH rather than by queue order. `receive()` polls, and
|
||||
* sending or draining can ask for a log head on the way, a fixture matched
|
||||
* on order hands one of those the other's body and fails on a parse error
|
||||
* a long way from the cause.
|
||||
*/
|
||||
private inner class Relay : okhttp3.mockwebserver.Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/log/" in path -> MockResponse().setResponseCode(404)
|
||||
"/e2e/groups" in path -> json(
|
||||
"/e2e/log/" in path -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
"/e2e/groups" in path -> {
|
||||
json(
|
||||
"""{"groups":[{"gid":"g1","name":"Group","owner":7,"members":[""" +
|
||||
roster.joinToString(",") { """{"user_id":$it}""" } + "]}]}")
|
||||
"/e2e/messages" in path -> json("""{"messages":[]}""")
|
||||
else -> json("{}")
|
||||
roster.joinToString(",") { """{"user_id":$it}""" } + "]}]}",
|
||||
)
|
||||
}
|
||||
|
||||
"/e2e/messages" in path -> {
|
||||
json("""{"messages":[]}""")
|
||||
}
|
||||
|
||||
else -> {
|
||||
json("{}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.dispatcher = Relay(); it.start() }
|
||||
server =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = Relay()
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
@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 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 key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun session(): Pair<Session, Vault> {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
val s = Session(
|
||||
val s =
|
||||
Session(
|
||||
handle = "alice",
|
||||
userId = 1,
|
||||
identity = identity,
|
||||
@@ -100,17 +112,25 @@ class GroupNameClaimTest {
|
||||
return s to vault
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
/** Put a claim in the queue the way an arriving announcement does. */
|
||||
private fun claim(session: Session, fromUser: Long, gid: String, name: String) {
|
||||
private fun claim(
|
||||
session: Session,
|
||||
fromUser: Long,
|
||||
gid: String,
|
||||
name: String,
|
||||
) {
|
||||
session.conversations.consumeControlForTesting(
|
||||
fromUser,
|
||||
Content.parse(SealedSender.deliveryKeyEnvelope(
|
||||
ByteArray(32), groupId = gid, groupName = name)))
|
||||
Content.parse(
|
||||
SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = gid, groupName = name),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -125,8 +145,8 @@ class GroupNameClaimTest {
|
||||
}
|
||||
|
||||
/**
|
||||
*A stranger naming a group they are not in must get
|
||||
* nowhere, however well-formed their envelope is.
|
||||
* A stranger naming a group they are not in must get nowhere, however well-
|
||||
* formed their envelope is.
|
||||
*/
|
||||
@Test
|
||||
fun `a name from a non-member is refused`() {
|
||||
@@ -140,8 +160,7 @@ class GroupNameClaimTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A gid the relay does not list for us is not our group. Adopting it
|
||||
* would let anyone create an entry in our chat list out of nothing.
|
||||
* A gid the relay does not list for us is not our group.
|
||||
*/
|
||||
@Test
|
||||
fun `a name for a group we are not in is refused`() {
|
||||
@@ -155,9 +174,7 @@ class GroupNameClaimTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Draining is not the same as accepting. A refused claim must not sit in
|
||||
* the queue waiting to be retried against a roster that might later differ
|
||||
* and it must not be re-checked on every poll for ever.
|
||||
* Draining is not the same as accepting.
|
||||
*/
|
||||
@Test
|
||||
fun `a refused claim is consumed, not left to retry`() {
|
||||
@@ -168,7 +185,7 @@ class GroupNameClaimTest {
|
||||
session.receive()
|
||||
assertNull(vault.groupName("g1"))
|
||||
|
||||
// Now the stranger IS a member. The old claim must not be waiting.
|
||||
// Now the stranger IS a member.
|
||||
roster = listOf(1L, 7L, 99L)
|
||||
session.receive()
|
||||
assertNull(vault.groupName("g1"))
|
||||
|
||||
@@ -4,6 +4,10 @@ import io.flamenet.client.MessengerApi
|
||||
import io.flamenet.client.RelayClient
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -12,43 +16,43 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* A pin in a GROUP resolves to the message it names (§6.19).
|
||||
*
|
||||
* Written to settle a published claim. The spec says a group pin *"is
|
||||
* carried correctly on the wire but cannot resolve yet, because a group
|
||||
* transcript does not record"* a message id. `Session.sendGroup` mints a `mid`
|
||||
* and passes it to `record(...)`, so the stated reason is already false, but
|
||||
* relay#46's rule is that a claim is flipped by a run, not by reading the code,
|
||||
* and nothing exercised the group case.
|
||||
*
|
||||
* The 1:1 case was never in doubt. This is specifically about a group
|
||||
* thread, because that is what the spec singles out.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GroupPinResolvesTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { 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 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 key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private val gid = "abc123"
|
||||
private val thread = Vault.threadForGroup("abc123")
|
||||
@@ -57,17 +61,25 @@ class GroupPinResolvesTest {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
return Session("alice", 1, identity, vault,
|
||||
return Session(
|
||||
"alice",
|
||||
1,
|
||||
identity,
|
||||
vault,
|
||||
RelayClient(base, "alice", "app pass word"),
|
||||
MessengerApi(base, "alice", "app pass word"))
|
||||
MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group transcript records a message id`() {
|
||||
val s = session()
|
||||
s.record(thread, "in a group", mine = true, author = 1L, messageId = "gm1")
|
||||
assertEquals("gm1", s.history(thread).single().id,
|
||||
"a group line carries no id, the spec's stated reason would hold")
|
||||
assertEquals(
|
||||
"gm1",
|
||||
s.history(thread).single().id,
|
||||
"a group line carries no id, the spec's stated reason would hold",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -82,14 +94,15 @@ class GroupPinResolvesTest {
|
||||
|
||||
// The resolve: the banner needs the MESSAGE, not just its id.
|
||||
val target = s.history(thread).firstOrNull { it.id == pinned }
|
||||
assertEquals("worth keeping", assertNotNull(target).text,
|
||||
"the pin named a message the group transcript could not find")
|
||||
assertEquals(
|
||||
"worth keeping",
|
||||
assertNotNull(target).text,
|
||||
"the pin named a message the group transcript could not find",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An id, never a copy. A stored copy would go stale the moment the
|
||||
* message was edited and would survive it being deleted, a banner quoting
|
||||
* words no longer in the conversation.
|
||||
* An id, never a copy.
|
||||
*/
|
||||
@Test
|
||||
fun `the pin follows an edit rather than quoting the old words`() {
|
||||
@@ -100,8 +113,11 @@ class GroupPinResolvesTest {
|
||||
|
||||
val pinned = assertNotNull(s.pinnedMessage(thread))
|
||||
val target = s.history(thread).firstOrNull { it.id == pinned }
|
||||
assertEquals("after", assertNotNull(target).text,
|
||||
"the banner is quoting words that are no longer in the conversation")
|
||||
assertEquals(
|
||||
"after",
|
||||
assertNotNull(target).text,
|
||||
"the banner is quoting words that are no longer in the conversation",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -8,33 +8,14 @@ import kotlin.test.assertTrue
|
||||
/**
|
||||
* Edits, pins and unknown control envelopes must never become bubbles
|
||||
* (ios#6.15/#6.16/#6.19, Android half).
|
||||
*
|
||||
* The bug this closes was an interop bug, not a missing feature. The
|
||||
* engine has parsed `editOf`, `pins` and `fwd` for as long as they have
|
||||
* existed, and it exposes `isRenderable`, "does this belong in a thread", as
|
||||
* the single answer. `Session`'s inbound loop never consulted it.
|
||||
*
|
||||
* So an edit from the iPhone app arrived carrying its new wording in `text` and
|
||||
* was filed as an ORDINARY MESSAGE: a correction drawn as a duplicate beside the
|
||||
* typo it was meant to replace. A pin was filed as a bubble whose text was
|
||||
* empty. And any control envelope from a newer peer, which carries `ctl` and a
|
||||
* kind this build has never heard of, would have drawn a bubble full of JSON
|
||||
* in somebody's conversation.
|
||||
*
|
||||
* These test the ENGINE's classification, which is what the app now branches
|
||||
* on. The app-side application (`applyEdit`, `applyPin`) needs a `Vault` and a
|
||||
* keystore, so it is covered on device in `FeatureUseTest`; what is checkable
|
||||
* here is the decision that used to be missing.
|
||||
*/
|
||||
class InboundControlTest {
|
||||
|
||||
private fun parse(json: String): Content = Content.parse(json)!!
|
||||
|
||||
// ---- What must not be drawn -----------------------------------------
|
||||
// ---- What must not be drawn -----------------------------------------.
|
||||
|
||||
/**
|
||||
* The case that shipped a duplicate bubble. An edit carries the new
|
||||
* wording, so it looks exactly like a message to anything that does not ask.
|
||||
* The case that shipped a duplicate bubble.
|
||||
*/
|
||||
@Test
|
||||
fun `an edit is not renderable, even though it carries text`() {
|
||||
@@ -61,27 +42,24 @@ class InboundControlTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The case an allowlist cannot cover, and the reason the app branches
|
||||
* on `isRenderable` rather than on a list of kinds it knows. A control
|
||||
* envelope from a NEWER peer has a kind nothing here has heard of. Keying on
|
||||
* known kinds ships a bubble full of JSON to every older install the first
|
||||
* time a new control message is added on the other platform.
|
||||
* The case an allowlist cannot cover, and the reason the app branches on
|
||||
* `isRenderable` rather than on a list of kinds it knows.
|
||||
*/
|
||||
@Test
|
||||
fun `a control envelope from a newer peer is not renderable`() {
|
||||
val fromTheFuture = parse(
|
||||
"""{"fnc":1,"kind":"something-invented-later","ctl":1,"text":"{}"}"""
|
||||
val fromTheFuture =
|
||||
parse(
|
||||
"""{"fnc":1,"kind":"something-invented-later","ctl":1,"text":"{}"}""",
|
||||
)
|
||||
assertTrue(fromTheFuture.isControl, "the ctl flag was not honoured")
|
||||
assertFalse(fromTheFuture.isRenderable,
|
||||
"an unknown control kind would be drawn as a message")
|
||||
assertFalse(
|
||||
fromTheFuture.isRenderable,
|
||||
"an unknown control kind would be drawn as a message",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Both shapes of the flag. `org.json`'s `optBoolean` does not coerce a
|
||||
* number, so an engine sending `"ctl":1` rather than `"ctl":true` would slip
|
||||
* past a reader that checked only one, which is how this class of bug got
|
||||
* shipped once already.
|
||||
* Both shapes of the flag.
|
||||
*/
|
||||
@Test
|
||||
fun `the control flag is honoured as a number and as a boolean`() {
|
||||
@@ -89,11 +67,10 @@ class InboundControlTest {
|
||||
assertFalse(parse("""{"fnc":1,"kind":"x","ctl":true,"text":"{}"}""").isRenderable)
|
||||
}
|
||||
|
||||
// ---- What must still be drawn ---------------------------------------
|
||||
// ---- What must still be drawn ---------------------------------------.
|
||||
|
||||
/**
|
||||
* The guard must not swallow ordinary messages. A `continue` in the wrong
|
||||
* place is a conversation that silently stops receiving.
|
||||
* The guard must not swallow ordinary messages.
|
||||
*/
|
||||
@Test
|
||||
fun `an ordinary message is still renderable`() {
|
||||
@@ -103,21 +80,25 @@ class InboundControlTest {
|
||||
|
||||
@Test
|
||||
fun `a reply is still renderable`() {
|
||||
val reply = parse(
|
||||
Content.text("answering", messageId = "mid-2",
|
||||
about = Content.Reference("mid-1", 7L, "the question")),
|
||||
val reply =
|
||||
parse(
|
||||
Content.text(
|
||||
"answering",
|
||||
messageId = "mid-2",
|
||||
about = Content.Reference("mid-1", 7L, "the question"),
|
||||
),
|
||||
)
|
||||
assertTrue(reply.isRenderable)
|
||||
assertTrue(reply.about?.messageId == "mid-1")
|
||||
}
|
||||
|
||||
/**
|
||||
* A forward IS a message. It is second-hand words, not a control
|
||||
* envelope, the flag changes how it is drawn, not whether it is.
|
||||
* A forward IS a message.
|
||||
*/
|
||||
@Test
|
||||
fun `a forwarded message is renderable, and says so`() {
|
||||
val forwarded = parse(
|
||||
val forwarded =
|
||||
parse(
|
||||
Content.text("passed along", messageId = "mid-3", forwarded = true),
|
||||
)
|
||||
assertTrue(forwarded.isRenderable, "a forward was treated as control")
|
||||
@@ -125,9 +106,7 @@ class InboundControlTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* And the same both-shapes rule for `fwd`, for the same reason. An engine
|
||||
* writing `1` where this one writes `true` would have its forwards drawn as
|
||||
* the sender's own words.
|
||||
* And the same both-shapes rule for `fwd`, for the same reason.
|
||||
*/
|
||||
@Test
|
||||
fun `the forwarded flag is honoured as a number and as a boolean`() {
|
||||
|
||||
@@ -4,6 +4,10 @@ import io.flamenet.client.MessengerApi
|
||||
import io.flamenet.client.RelayClient
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -12,46 +16,43 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* An inbound edit changes the message it names (§6.15).
|
||||
*
|
||||
* Written to settle a claim, not to add coverage. The published spec
|
||||
* says Android *"cannot yet apply one, because its transcript does not record a
|
||||
* `mid` for each line"*, and that both blocks replies and reactions too. The
|
||||
* transcript does record a `mid`, `MessageReactionTest` folds a reaction onto
|
||||
* a message by id, so the stated reason is already false.
|
||||
*
|
||||
* But relay#46's rule is the point: do not flip a claim because the code
|
||||
* looks present; flip it because a run proved it, and say which run.
|
||||
* `applyEdit` was implemented and wired with nothing exercising it, because
|
||||
* `InboundControlTest` covers the codec and says in its own header that the
|
||||
* app-side application needs a `Vault` and is out of scope there. This is that
|
||||
* run.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class InboundEditAppliedTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { 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 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 key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private val me = 1L
|
||||
private val them = 2L
|
||||
@@ -61,13 +62,17 @@ class InboundEditAppliedTest {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
return Session("alice", me, identity, vault,
|
||||
return Session(
|
||||
"alice",
|
||||
me,
|
||||
identity,
|
||||
vault,
|
||||
RelayClient(base, "alice", "app pass word"),
|
||||
MessengerApi(base, "alice", "app pass word"))
|
||||
MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Session.textOf(mid: String): String? =
|
||||
history(thread).firstOrNull { it.id == mid }?.text
|
||||
private fun Session.textOf(mid: String): String? = history(thread).firstOrNull { it.id == mid }?.text
|
||||
|
||||
@Test
|
||||
fun `an edit from the author rewrites the message it names`() {
|
||||
@@ -87,14 +92,14 @@ class InboundEditAppliedTest {
|
||||
s.record(thread, "before", mine = false, author = them, messageId = "m1")
|
||||
s.applyEdit(thread, "m1", author = them, newText = "after")
|
||||
assertEquals("after", s.textOf("m1"))
|
||||
assertTrue(s.history(thread).single().editedAt != null,
|
||||
"the bubble has no way to show that it was edited")
|
||||
assertTrue(
|
||||
s.history(thread).single().editedAt != null,
|
||||
"the bubble has no way to show that it was edited",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit from anyone but the author is refused. Without this, one
|
||||
* contact could rewrite another's words in a transcript, which reads to
|
||||
* the person as the sender having said something they never said.
|
||||
* An edit from anyone but the author is refused.
|
||||
*/
|
||||
@Test
|
||||
fun `an edit from somebody who did not write it is refused`() {
|
||||
|
||||
@@ -7,17 +7,18 @@ 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")
|
||||
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
|
||||
|
||||
@@ -40,8 +41,7 @@ class MentionPickerTest {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* rather than by a flag.
|
||||
*/
|
||||
@Test
|
||||
fun `a space after the at closes the list`() {
|
||||
@@ -70,9 +70,7 @@ class MentionPickerTest {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* contacts has nothing to show.
|
||||
*/
|
||||
@Test
|
||||
fun `a member who is not a contact is still mentionable`() {
|
||||
|
||||
@@ -6,6 +6,9 @@ import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -16,46 +19,56 @@ 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)
|
||||
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() }
|
||||
server =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = NoKeyLog()
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
@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 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 key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun session(): Session {
|
||||
val identity = DeviceIdentity.create()
|
||||
@@ -71,7 +84,8 @@ class MessageReactionTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
@@ -82,9 +96,8 @@ class MessageReactionTest {
|
||||
// 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.
|
||||
* A sent message carries an id, and the copy in our own transcript carries
|
||||
* the SAME one.
|
||||
*/
|
||||
@Test
|
||||
fun `a sent message and its local copy share one id`() {
|
||||
@@ -99,9 +112,7 @@ class MessageReactionTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The queued retry has to be the SAME message, id included.
|
||||
*/
|
||||
@Test
|
||||
fun `the queued retry carries the same id as the local copy`() {
|
||||
@@ -112,9 +123,15 @@ class MessageReactionTest {
|
||||
|
||||
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")
|
||||
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
|
||||
@@ -130,15 +147,12 @@ class MessageReactionTest {
|
||||
|
||||
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.
|
||||
// And it stays one message.
|
||||
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.
|
||||
* One emoji per person.
|
||||
*/
|
||||
@Test
|
||||
fun `reacting again replaces the previous one`() {
|
||||
@@ -180,9 +194,8 @@ class MessageReactionTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A reaction to a message this device does not have does nothing, and says
|
||||
* so.
|
||||
*/
|
||||
@Test
|
||||
fun `a reaction to a message we do not have is ignored, not invented`() {
|
||||
@@ -196,9 +209,8 @@ class MessageReactionTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A line stored before ids existed has none, and must not be given one on
|
||||
* read.
|
||||
*/
|
||||
@Test
|
||||
fun `a message stored before ids existed keeps no id`() {
|
||||
|
||||
@@ -7,35 +7,25 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A photo sent as a file must lose its location, like one sent as a photo.
|
||||
*
|
||||
* The bug this pins was found by an audit, not by a test, because no test
|
||||
* went down the file path at all (android#17, ios#69). The picker path was
|
||||
* checked and correct; `Session.sendFile` handed stored bytes straight to
|
||||
* `sendAttachment`. The two doors disagreed, and the safer one was the one that
|
||||
* looked like a shortcut.
|
||||
*
|
||||
* The format sniffing is what is tested here, not the re-encode.
|
||||
* `BitmapFactory` needs a device, so the pixel half belongs to the instrumented
|
||||
* suite; iOS covers the round trip with a real GPS-tagged JPEG. What a JVM test
|
||||
* CAN prove is the decision, and the decision is where the bug was, because
|
||||
* the old code never asked the question at all.
|
||||
*/
|
||||
class MetadataScrubTest {
|
||||
|
||||
private fun bytes(vararg v: Int) = v.map { it.toByte() }.toByteArray()
|
||||
|
||||
@Test
|
||||
fun `an image is recognised from its first bytes`() {
|
||||
assertEquals(MetadataScrub.ImageKind.JPEG, MetadataScrub.kind(bytes(0xFF, 0xD8, 0xFF, 0xE0)))
|
||||
assertEquals(MetadataScrub.ImageKind.PNG,
|
||||
MetadataScrub.kind(bytes(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)))
|
||||
assertEquals(
|
||||
MetadataScrub.ImageKind.PNG,
|
||||
MetadataScrub.kind(bytes(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)),
|
||||
)
|
||||
assertEquals(MetadataScrub.ImageKind.GIF, MetadataScrub.kind(bytes(0x47, 0x49, 0x46, 0x38)))
|
||||
assertEquals(MetadataScrub.ImageKind.TIFF, MetadataScrub.kind(bytes(0x49, 0x49, 0x2A, 0x00)))
|
||||
assertEquals(MetadataScrub.ImageKind.TIFF, MetadataScrub.kind(bytes(0x4D, 0x4D, 0x00, 0x2A)))
|
||||
}
|
||||
|
||||
/** Both markers, with the size field between them, matching only the
|
||||
* RIFF header would claim every WAV file is an image. */
|
||||
/**
|
||||
* Both markers, with the size field between them, matching only the.
|
||||
*/
|
||||
@Test
|
||||
fun `webp needs both of its markers`() {
|
||||
val webp = bytes(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50)
|
||||
@@ -47,20 +37,17 @@ class MetadataScrubTest {
|
||||
/** HEIC is an ISO-BMFF box, so the brand at offset 8 is what decides. */
|
||||
@Test
|
||||
fun `heic is recognised and its cousins are not`() {
|
||||
fun ftyp(brand: String) = bytes(0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70) +
|
||||
fun ftyp(brand: String) =
|
||||
bytes(0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70) +
|
||||
brand.toByteArray(Charsets.US_ASCII)
|
||||
assertEquals(MetadataScrub.ImageKind.HEIC, MetadataScrub.kind(ftyp("heic")))
|
||||
assertEquals(MetadataScrub.ImageKind.HEIC, MetadataScrub.kind(ftyp("mif1")))
|
||||
// An MP4 shares the ftyp box. Claiming it is an image would send it
|
||||
// through a bitmap decoder, which fails, and then be passed through
|
||||
// anyway, harmless here, but the check should still be honest.
|
||||
// An MP4 shares the ftyp box.
|
||||
assertNull(MetadataScrub.kind(ftyp("isom")))
|
||||
}
|
||||
|
||||
/**
|
||||
* Only images are touched. "Send this exact file" is what a file send
|
||||
* means; a scrubber that mangles somebody's PDF is worse than the leak it
|
||||
* was fixing.
|
||||
* Only images are touched.
|
||||
*/
|
||||
@Test
|
||||
fun `anything that is not an image goes byte for byte`() {
|
||||
|
||||
@@ -8,42 +8,31 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What is in the tab bar.
|
||||
*
|
||||
* This test exists because nothing could see the bar. It was a hand-written
|
||||
* run of `NavButton` calls inside a Composable, so its contents were invisible to
|
||||
* every test in the project: a destination could be dropped in a refactor and
|
||||
* nothing would go red. That is not hypothetical. Files and Wallet lived only
|
||||
* inside the Account screen, most of a scroll down, while iOS carried both in
|
||||
* its bar. Somebody testing the Android build reasonably concluded they had not
|
||||
* been built at all.
|
||||
*
|
||||
* Pinning the exact list is the point, not an accident of writing it this
|
||||
* way. A test that only asserted "at least four tabs" would have passed
|
||||
* throughout the disagreement it is here to prevent.
|
||||
*/
|
||||
class NavDestinationsTest {
|
||||
|
||||
/**
|
||||
* The order matches iOS, which puts Files and Wallet between Stories
|
||||
* and Account. The same destination in the same place on both phones is
|
||||
* most of what "the apps stay in sync" means to somebody holding one of
|
||||
* each.
|
||||
* The order matches iOS, which puts Files and Wallet between Stories and
|
||||
* Account.
|
||||
*/
|
||||
@Test
|
||||
fun `the bar is the seven destinations iOS has, in the same order`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
Screen.CONTACTS, Screen.MESSAGES, Screen.CALLS, Screen.STORIES,
|
||||
Screen.FILES, Screen.WALLET, Screen.ACCOUNT,
|
||||
Screen.CONTACTS,
|
||||
Screen.MESSAGES,
|
||||
Screen.CALLS,
|
||||
Screen.STORIES,
|
||||
Screen.FILES,
|
||||
Screen.WALLET,
|
||||
Screen.ACCOUNT,
|
||||
),
|
||||
NavDestinations.tabs(storiesHidden = false).map { it.screen },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervision removes the surface rather than showing an empty one,
|
||||
* and removes only that one. A parental control that quietly took Files or
|
||||
* the wallet with it would be a data-access change nobody asked for.
|
||||
* Supervision removes the surface rather than showing an empty one, and
|
||||
* removes only that one.
|
||||
*/
|
||||
@Test
|
||||
fun `supervision hides stories and nothing else`() {
|
||||
@@ -56,25 +45,27 @@ class NavDestinationsTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags are how the instrumented suite finds the bar. A renamed tag
|
||||
* is a test that stops testing and still passes, because
|
||||
* `onNodeWithTag(...).performClick()` on a node that does not exist is
|
||||
* caught, but `nodesWithTag(...).isEmpty()` guards throughout that suite
|
||||
* turn a missing tag into a silent early return.
|
||||
* The tags are how the instrumented suite finds the bar.
|
||||
*/
|
||||
@Test
|
||||
fun `every tab carries the tag the instrumented suite looks for`() {
|
||||
assertEquals(
|
||||
listOf("nav-contacts", "nav-messages", "nav-calls", "nav-stories",
|
||||
"nav-files", "nav-wallet", "nav-account"),
|
||||
listOf(
|
||||
"nav-contacts",
|
||||
"nav-messages",
|
||||
"nav-calls",
|
||||
"nav-stories",
|
||||
"nav-files",
|
||||
"nav-wallet",
|
||||
"nav-account",
|
||||
),
|
||||
NavDestinations.tabs(storiesHidden = false).map { it.tag },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The label is not decoration: the bar draws icons only, so this string
|
||||
* is the accessibility name and the only thing TalkBack reads. A blank one
|
||||
* makes the tab an unnamed button.
|
||||
* The label is not decoration: the bar draws icons only, so this string is
|
||||
* the accessibility name and the only thing TalkBack reads.
|
||||
*/
|
||||
@Test
|
||||
fun `every tab is named for a screen reader`() {
|
||||
@@ -84,10 +75,8 @@ class NavDestinationsTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every destination a person can reach from the bar has to be somewhere
|
||||
* the app can actually draw. `NOTIFICATIONS` is deliberately NOT in the bar
|
||||
* it left on 2026-08-28 and is reached by tapping the Account badge, so
|
||||
* this checks the bar is a subset of the screens, not equal to them.
|
||||
* Every destination a person can reach from the bar has to be somewhere the
|
||||
* app can actually draw.
|
||||
*/
|
||||
@Test
|
||||
fun `no tab points at a screen that does not exist`() {
|
||||
|
||||
@@ -7,16 +7,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What the Network section says is happening.
|
||||
*
|
||||
* A settings screen that reads the stored line and calls it a status is
|
||||
* the failure this feature is built around. A saved bridge that is not there
|
||||
* leaves the app connecting directly; the preference cannot tell you that and
|
||||
* a completed handshake can. `statusLine` is lifted out of the Composable
|
||||
* precisely so this can be asserted, a string built inline in a `Text(...)` is
|
||||
* a string nothing can test.
|
||||
*/
|
||||
class NetworkStatusLineTest {
|
||||
|
||||
private val bridge = Obfs4Bridge("198.51.100.7", 4444, ByteArray(20), ByteArray(32))
|
||||
|
||||
@Test
|
||||
@@ -26,9 +18,7 @@ class NetworkStatusLineTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The load-bearing case. A bridge is saved and nothing has gone
|
||||
* through it. Reporting that as protection is exactly the claim the screen
|
||||
* exists to stop making.
|
||||
* The load-bearing case.
|
||||
*/
|
||||
@Test
|
||||
fun `a saved bridge that has carried nothing does not claim to be working`() {
|
||||
@@ -47,9 +37,7 @@ class NetworkStatusLineTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every state has to repeat that the relay is unaffected. Somebody who
|
||||
* reads "wrapped so nobody can tell what it is" and stops there has been
|
||||
* told a true thing that leads to a false conclusion.
|
||||
* Every state has to repeat that the relay is unaffected.
|
||||
*/
|
||||
@Test
|
||||
fun `the working state does not imply anything is hidden from the server`() {
|
||||
|
||||
@@ -6,28 +6,20 @@ import kotlin.test.assertFalse
|
||||
|
||||
/**
|
||||
* What a notification is allowed to say.
|
||||
*
|
||||
* The default is the load-bearing part. Previews off means a lock screen
|
||||
* says how many messages arrived and nothing else, no name, no words. That was
|
||||
* the behaviour before this setting existed, and nobody may acquire a name on
|
||||
* their lock screen by installing an update.
|
||||
*
|
||||
* The text is built by `NotificationSettings` rather than inline in the
|
||||
* `NotificationCompat.Builder` chain precisely so it can be asserted here. A
|
||||
* string built inside that chain is a string nothing can test, and what is
|
||||
* being decided is whether somebody's name reaches a lock screen.
|
||||
*/
|
||||
class NotificationTextTest {
|
||||
// Calls the SHIPPING functions.
|
||||
private fun messageText(
|
||||
previews: Boolean,
|
||||
threads: Int,
|
||||
from: String?,
|
||||
) = NotificationSettings.messageText(previews, threads, from)
|
||||
|
||||
// Calls the SHIPPING functions. The first version of this file
|
||||
// reimplemented the rules against a fake and passed perfectly while
|
||||
// asserting nothing about the code that runs, two identically-wrong ends
|
||||
// agreeing, which is the shape this codebase has been bitten by before.
|
||||
private fun messageText(previews: Boolean, threads: Int, from: String?) =
|
||||
NotificationSettings.messageText(previews, threads, from)
|
||||
|
||||
private fun replyText(previews: Boolean, count: Int, from: String?) =
|
||||
NotificationSettings.replyReminderText(previews, count, from)
|
||||
private fun replyText(
|
||||
previews: Boolean,
|
||||
count: Int,
|
||||
from: String?,
|
||||
) = NotificationSettings.replyReminderText(previews, count, from)
|
||||
|
||||
@Test
|
||||
fun `with previews off no name reaches the lock screen`() {
|
||||
@@ -44,9 +36,7 @@ class NotificationTextTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Several threads means no name even with previews on. Naming one of
|
||||
* three would be arbitrary, and naming all of them turns a notification
|
||||
* into a contact list on the lock screen.
|
||||
* Several threads means no name even with previews on.
|
||||
*/
|
||||
@Test
|
||||
fun `several threads are counted, never named`() {
|
||||
@@ -55,9 +45,8 @@ class NotificationTextTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller that has no name must not produce "New message from null" or
|
||||
* a dangling "from". The service cannot always resolve one, a group, a
|
||||
* cross-relay peer, or a contact it has never seen.
|
||||
* A caller that has no name must not produce "New message from null" or a
|
||||
* dangling "from".
|
||||
*/
|
||||
@Test
|
||||
fun `a missing name falls back to the count rather than showing a hole`() {
|
||||
|
||||
@@ -6,6 +6,9 @@ import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -13,71 +16,63 @@ import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* A message to somebody who is signed out waits, and goes when they come back.
|
||||
*
|
||||
* The question this answers is "how does the sender know to try again".
|
||||
* They should not have to. Marking a message undelivered and leaving it there
|
||||
* puts the work on the person who did nothing wrong, they would have to
|
||||
* remember, notice their friend was back, and retype it. So the message is
|
||||
* held and retried, and the only thing the sender ever does is write it once.
|
||||
*
|
||||
* Nothing expires. There is no TTL and nothing is discarded: a contact
|
||||
* who reinstalls in a month gets it in a month. Timing out would mean deleting
|
||||
* something somebody wrote to tidy up a label.
|
||||
*
|
||||
* The honest limit, which the UI wording has to respect: the queue is on
|
||||
* the sender's phone, because an envelope is sealed to a device's keys and
|
||||
* somebody with no device has none to seal to. So it goes out the next time
|
||||
* THIS app runs and polls, not the instant the recipient signs in.
|
||||
*/
|
||||
// Robolectric, for one boring reason: `:app` is an Android module, so its
|
||||
// unit-test classpath has the stubbed `org.json` that returns null from every
|
||||
// call. The vault stores JSON, so without a real implementation every one of
|
||||
// these fails inside `saveIdentity` before reaching anything worth testing.
|
||||
// Robolectric, for one boring reason: `:app` is an Android module, so its unit-
|
||||
// test classpath has the stubbed `org.json` that returns null from every call.
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class OutboxTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
/**
|
||||
* A relay from before §19, answering the queue for everything else.
|
||||
*
|
||||
* Not incidental setup. Sending now asks for a transparency-log head on
|
||||
* its own, because key checking is automatic on any relay that serves one,
|
||||
* so a queue matched purely by ORDER would hand the head request the device
|
||||
* listing and fail on a parse error miles from the cause. This says plainly
|
||||
* what the fixture is: a deployment with no log, where these tests are
|
||||
* about the outbox and nothing else.
|
||||
*/
|
||||
private class NoKeyLog : okhttp3.mockwebserver.QueueDispatcher() {
|
||||
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse =
|
||||
// `contains`, not `startsWith`: every route sits under the REST
|
||||
// mount, so the recorded path begins with that prefix.
|
||||
if ("/e2e/log/" in request.path.orEmpty()) MockResponse().setResponseCode(404)
|
||||
else super.dispatch(request)
|
||||
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() }
|
||||
server =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = NoKeyLog()
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
@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 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 key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun session(): Session {
|
||||
val identity = DeviceIdentity.create()
|
||||
@@ -93,7 +88,8 @@ class OutboxTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
@@ -116,8 +112,11 @@ class OutboxTest {
|
||||
assertEquals(Session.Delivery.PENDING, thread[0].delivery)
|
||||
|
||||
// And it is queued, so nobody has to remember to send it again.
|
||||
assertEquals(1, session.vault.outbox().length(),
|
||||
"the message must be waiting in the outbox")
|
||||
assertEquals(
|
||||
1,
|
||||
session.vault.outbox().length(),
|
||||
"the message must be waiting in the outbox",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -130,8 +129,11 @@ class OutboxTest {
|
||||
val touched = session.flushOutbox()
|
||||
|
||||
assertTrue(touched.isEmpty(), "nothing changed, so nothing to redraw")
|
||||
assertEquals(1, session.vault.outbox().length(),
|
||||
"a failed retry must not discard the message")
|
||||
assertEquals(
|
||||
1,
|
||||
session.vault.outbox().length(),
|
||||
"a failed retry must not discard the message",
|
||||
)
|
||||
assertEquals(
|
||||
Session.Delivery.PENDING,
|
||||
session.history(Vault.threadForUser(peer))[0].delivery,
|
||||
@@ -149,22 +151,15 @@ class OutboxTest {
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
session.flushOutbox()
|
||||
|
||||
assertEquals(1, session.vault.outbox().length(),
|
||||
"a transport failure must not eat the message either")
|
||||
assertEquals(
|
||||
1,
|
||||
session.vault.outbox().length(),
|
||||
"a transport failure must not eat the message either",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal waiting cannot fix must STOP (ios#22).
|
||||
*
|
||||
* The outbox retries for ever, which is right for a contact who is offline
|
||||
* or has no device yet. It is wrong for a send the trust layer refuses:
|
||||
* the peer's identity keys changed, so every retry is refused identically
|
||||
* until a person accepts them, while the bubble goes on promising
|
||||
* "will send", which is a promise the app cannot keep.
|
||||
*
|
||||
* Asserted on the OUTBOX and the BUBBLE together. A version that
|
||||
* dropped the entry but left the bubble pending would pass a test that
|
||||
* only checked one, and would read as "still sending" for ever.
|
||||
*/
|
||||
@Test
|
||||
fun `a send refused for changed keys stops retrying and says why`() {
|
||||
@@ -179,52 +174,68 @@ class OutboxTest {
|
||||
val bundleOf = { id: DeviceIdentity ->
|
||||
val opk = id.generateOneTimePreKeys(1).first()
|
||||
id.registrationWire().put(
|
||||
"opk", org.json.JSONObject()
|
||||
"opk",
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("id", opk.id)
|
||||
.put("pub", android.util.Base64.encodeToString(
|
||||
opk.publicKey, android.util.Base64.NO_WRAP)))
|
||||
.put(
|
||||
"pub",
|
||||
android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP),
|
||||
),
|
||||
)
|
||||
}
|
||||
val first = bundleOf(them)
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(first)).toString()))
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(first)).toString()))
|
||||
server.enqueue(
|
||||
json(
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(first))
|
||||
.toString(),
|
||||
),
|
||||
)
|
||||
server.enqueue(
|
||||
json(
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(first))
|
||||
.toString(),
|
||||
),
|
||||
)
|
||||
server.enqueue(json("""{"ok":true}"""))
|
||||
session.flushOutbox()
|
||||
assertEquals(0, session.vault.outbox().length())
|
||||
|
||||
// Now they reinstall: a DIFFERENT identity under the same user. The
|
||||
// trust layer refuses, and no amount of retrying changes that.
|
||||
// Now they reinstall: a DIFFERENT identity under the same user.
|
||||
server.enqueue(json("""{"devices":[]}""")) // queues the second message
|
||||
session.send(peer, "you there?")
|
||||
assertEquals(1, session.vault.outbox().length())
|
||||
|
||||
val second = bundleOf(DeviceIdentity.create())
|
||||
val newDirectory = org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(second)).toString()
|
||||
val newDirectory =
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(second))
|
||||
.toString()
|
||||
// Enqueued generously: the refusal happens at the directory, but the
|
||||
// exact number of calls before it is not what this test is about.
|
||||
repeat(4) { server.enqueue(json(newDirectory)) }
|
||||
session.flushOutbox()
|
||||
|
||||
assertEquals(0, session.vault.outbox().length(),
|
||||
"a permanently refused send must not sit in the queue for ever")
|
||||
assertEquals(
|
||||
0,
|
||||
session.vault.outbox().length(),
|
||||
"a permanently refused send must not sit in the queue for ever",
|
||||
)
|
||||
val line = session.history(Vault.threadForUser(peer)).last { it.mine }
|
||||
assertEquals(Session.Delivery.FAILED, line.delivery,
|
||||
"the bubble must stop promising it will send")
|
||||
assertEquals(
|
||||
Session.Delivery.FAILED,
|
||||
line.delivery,
|
||||
"the bubble must stop promising it will send",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The promise, end to end. Everything above only proves the message
|
||||
* is not lost; this proves it is eventually sent, which is the part the
|
||||
* sender is being asked to trust. Both halves are asserted deliberately: a
|
||||
* version that delivered the message but left the bubble reading
|
||||
* "undelivered" would pass a test that only checked the outbox, and would
|
||||
* look broken to the person reading the thread.
|
||||
*
|
||||
* Serves a REAL bundle built from a second identity, not a hand-written
|
||||
* fake, so the send has to complete X3DH against it. A fake bundle
|
||||
* would agree with whatever the client did, including the wrong thing.
|
||||
* The promise, end to end.
|
||||
*/
|
||||
@Test
|
||||
fun `when they register a device the queued message goes and the bubble updates`() {
|
||||
@@ -236,27 +247,50 @@ class OutboxTest {
|
||||
// They reinstall: a device with real keys appears.
|
||||
val them = DeviceIdentity.create()
|
||||
val opk = them.generateOneTimePreKeys(1).first()
|
||||
val bundle = them.registrationWire().put(
|
||||
"opk", org.json.JSONObject()
|
||||
val bundle =
|
||||
them.registrationWire().put(
|
||||
"opk",
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("id", opk.id)
|
||||
// `Bytes` is internal to :engine, so base64 by hand rather
|
||||
// than widening its visibility for a test.
|
||||
.put("pub", android.util.Base64.encodeToString(
|
||||
opk.publicKey, android.util.Base64.NO_WRAP)))
|
||||
// `Bytes` is internal to :engine, so base64 by hand rather than
|
||||
// widening its visibility for a test.
|
||||
.put(
|
||||
"pub",
|
||||
android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP),
|
||||
),
|
||||
)
|
||||
// The send asks the directory first (free), THEN the key route (which
|
||||
// pops a prekey), two requests now, same payload shape.
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle)).toString()))
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle)).toString()))
|
||||
server.enqueue(
|
||||
json(
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle))
|
||||
.toString(),
|
||||
),
|
||||
)
|
||||
server.enqueue(
|
||||
json(
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle))
|
||||
.toString(),
|
||||
),
|
||||
)
|
||||
server.enqueue(json("""{"ok":true}""")) // the envelope post
|
||||
|
||||
val touched = session.flushOutbox()
|
||||
|
||||
assertEquals(0, session.vault.outbox().length(),
|
||||
"a delivered message must leave the outbox")
|
||||
assertTrue(Vault.threadForUser(peer) in touched,
|
||||
"the thread has to be redrawn or the bubble keeps its old label")
|
||||
assertEquals(
|
||||
0,
|
||||
session.vault.outbox().length(),
|
||||
"a delivered message must leave the outbox",
|
||||
)
|
||||
assertTrue(
|
||||
Vault.threadForUser(peer) in touched,
|
||||
"the thread has to be redrawn or the bubble keeps its old label",
|
||||
)
|
||||
assertEquals(
|
||||
Session.Delivery.DELIVERED,
|
||||
session.history(Vault.threadForUser(peer))[0].delivery,
|
||||
@@ -265,11 +299,10 @@ class OutboxTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The sticker version of the promise, because it was broken separately:
|
||||
* a sticker to somebody signed out was marked "Undelivered, will send"
|
||||
* and then never entered the outbox, the label was a lie for exactly one
|
||||
* content kind. This asserts all three parts: queued, delivered AS A
|
||||
* STICKER once a device exists, and the bubble restated.
|
||||
* The sticker version of the promise, because it was broken separately: a
|
||||
* sticker to somebody signed out was marked "Undelivered, will send" and
|
||||
* then never entered the outbox, the label was a lie for exactly one
|
||||
* content kind.
|
||||
*/
|
||||
@Test
|
||||
fun `a sticker to somebody with no device is queued and later delivered as a sticker`() {
|
||||
@@ -281,47 +314,70 @@ class OutboxTest {
|
||||
assertEquals(1, thread.size)
|
||||
assertEquals("wave", thread[0].sticker?.id, "the bubble is a sticker, not text")
|
||||
assertEquals(Session.Delivery.PENDING, thread[0].delivery)
|
||||
assertEquals(1, session.vault.outbox().length(),
|
||||
"the sticker must be waiting in the outbox")
|
||||
assertEquals(
|
||||
1,
|
||||
session.vault.outbox().length(),
|
||||
"the sticker must be waiting in the outbox",
|
||||
)
|
||||
|
||||
// They reinstall.
|
||||
val them = DeviceIdentity.create()
|
||||
val opk = them.generateOneTimePreKeys(1).first()
|
||||
val bundle = them.registrationWire().put(
|
||||
"opk", org.json.JSONObject()
|
||||
val bundle =
|
||||
them.registrationWire().put(
|
||||
"opk",
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("id", opk.id)
|
||||
.put("pub", android.util.Base64.encodeToString(
|
||||
opk.publicKey, android.util.Base64.NO_WRAP)))
|
||||
.put(
|
||||
"pub",
|
||||
android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP),
|
||||
),
|
||||
)
|
||||
// Directory first, then keys, see the note in the first test.
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle)).toString()))
|
||||
server.enqueue(json(org.json.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle)).toString()))
|
||||
server.enqueue(
|
||||
json(
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle))
|
||||
.toString(),
|
||||
),
|
||||
)
|
||||
server.enqueue(
|
||||
json(
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put("devices", org.json.JSONArray().put(bundle))
|
||||
.toString(),
|
||||
),
|
||||
)
|
||||
server.enqueue(json("""{"ok":true}"""))
|
||||
|
||||
session.flushOutbox()
|
||||
|
||||
assertEquals(0, session.vault.outbox().length())
|
||||
assertEquals(Session.Delivery.DELIVERED,
|
||||
assertEquals(
|
||||
Session.Delivery.DELIVERED,
|
||||
session.history(Vault.threadForUser(peer))[0].delivery,
|
||||
"the sticker bubble must stop saying undelivered")
|
||||
assertEquals("wave",
|
||||
"the sticker bubble must stop saying undelivered",
|
||||
)
|
||||
assertEquals(
|
||||
"wave",
|
||||
session.history(Vault.threadForUser(peer))[0].sticker?.id,
|
||||
"restating the delivery must not turn the sticker into text")
|
||||
"restating the delivery must not turn the sticker into text",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The group half of the promise, with its completion rule made
|
||||
* observable: the bubble flips only when the LAST queued member is
|
||||
* reached. Restating on the first success would mark the message delivered
|
||||
* while other members are still owed it, "will send" would quietly stop
|
||||
* being true for exactly the people it was still true for.
|
||||
* The group half of the promise, with its completion rule made observable.
|
||||
* the bubble flips only when the LAST queued member is reached.
|
||||
*/
|
||||
@Test
|
||||
fun `a group message waits for every queued member before the bubble flips`() {
|
||||
val session = session()
|
||||
val group = io.flamenet.client.RelayClient.Group(
|
||||
id = "g1", name = "test", owner = 1, memberIds = listOf(101L, 102L))
|
||||
val group =
|
||||
io.flamenet.client.RelayClient
|
||||
.Group(id = "g1", name = "test", owner = 1, memberIds = listOf(101L, 102L))
|
||||
|
||||
// Both members signed out: two device lookups, both empty.
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
@@ -330,36 +386,71 @@ class OutboxTest {
|
||||
|
||||
val thread = Vault.threadForGroup("g1")
|
||||
assertEquals(Session.Delivery.PENDING, session.history(thread)[0].delivery)
|
||||
assertEquals(2, session.vault.outbox().length(),
|
||||
"one entry per member nothing reached")
|
||||
assertEquals(
|
||||
2,
|
||||
session.vault.outbox().length(),
|
||||
"one entry per member nothing reached",
|
||||
)
|
||||
|
||||
// Member 101 registers; 102 is still away.
|
||||
val m1 = DeviceIdentity.create()
|
||||
val opk1 = m1.generateOneTimePreKeys(1).first()
|
||||
val m1Wire = org.json.JSONObject().put("devices",
|
||||
org.json.JSONArray().put(m1.registrationWire().put("opk",
|
||||
org.json.JSONObject().put("id", opk1.id).put("pub",
|
||||
android.util.Base64.encodeToString(opk1.publicKey,
|
||||
android.util.Base64.NO_WRAP))))).toString()
|
||||
val m1Wire =
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put(
|
||||
"devices",
|
||||
org.json.JSONArray().put(
|
||||
m1.registrationWire().put(
|
||||
"opk",
|
||||
org.json.JSONObject().put("id", opk1.id).put(
|
||||
"pub",
|
||||
android.util.Base64.encodeToString(
|
||||
opk1.publicKey,
|
||||
android.util.Base64.NO_WRAP,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toString()
|
||||
server.enqueue(json(m1Wire)) // the directory
|
||||
server.enqueue(json(m1Wire)) // the key route
|
||||
server.enqueue(json("""{"ok":true}"""))
|
||||
server.enqueue(json("""{"devices":[]}""")) // 102 still gone
|
||||
session.flushOutbox()
|
||||
|
||||
assertEquals(1, session.vault.outbox().length(),
|
||||
"the reached member leaves the queue; the other stays")
|
||||
assertEquals(Session.Delivery.PENDING, session.history(thread)[0].delivery,
|
||||
"one member reached is not the group delivered")
|
||||
assertEquals(
|
||||
1,
|
||||
session.vault.outbox().length(),
|
||||
"the reached member leaves the queue; the other stays",
|
||||
)
|
||||
assertEquals(
|
||||
Session.Delivery.PENDING,
|
||||
session.history(thread)[0].delivery,
|
||||
"one member reached is not the group delivered",
|
||||
)
|
||||
|
||||
// Member 102 comes back.
|
||||
val m2 = DeviceIdentity.create()
|
||||
val opk2 = m2.generateOneTimePreKeys(1).first()
|
||||
val m2Wire = org.json.JSONObject().put("devices",
|
||||
org.json.JSONArray().put(m2.registrationWire().put("opk",
|
||||
org.json.JSONObject().put("id", opk2.id).put("pub",
|
||||
android.util.Base64.encodeToString(opk2.publicKey,
|
||||
android.util.Base64.NO_WRAP))))).toString()
|
||||
val m2Wire =
|
||||
org.json
|
||||
.JSONObject()
|
||||
.put(
|
||||
"devices",
|
||||
org.json.JSONArray().put(
|
||||
m2.registrationWire().put(
|
||||
"opk",
|
||||
org.json.JSONObject().put("id", opk2.id).put(
|
||||
"pub",
|
||||
android.util.Base64.encodeToString(
|
||||
opk2.publicKey,
|
||||
android.util.Base64.NO_WRAP,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toString()
|
||||
server.enqueue(json(m2Wire)) // the directory
|
||||
server.enqueue(json(m2Wire)) // the key route
|
||||
server.enqueue(json("""{"ok":true}"""))
|
||||
@@ -368,8 +459,11 @@ class OutboxTest {
|
||||
assertEquals(0, session.vault.outbox().length())
|
||||
assertTrue(thread in touched, "the GROUP thread is what gets redrawn")
|
||||
assertEquals(Session.Delivery.DELIVERED, session.history(thread)[0].delivery)
|
||||
assertEquals("meeting moved to three", session.history(thread)[0].text,
|
||||
"the group body must not be replaced by the wire form")
|
||||
assertEquals(
|
||||
"meeting moved to three",
|
||||
session.history(thread)[0].text,
|
||||
"the group body must not be replaced by the wire form",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -380,14 +474,14 @@ class OutboxTest {
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
session.send(peer, "same words")
|
||||
|
||||
// Two identical messages. `restate` matches on the timestamp the
|
||||
// send stamped, not on the text, matching on text would restate
|
||||
// whichever came first every time somebody repeats themselves.
|
||||
// Two identical messages.
|
||||
val outbox = session.vault.outbox()
|
||||
assertEquals(2, outbox.length())
|
||||
val first = outbox.getJSONObject(0).getLong("at")
|
||||
val second = outbox.getJSONObject(1).getLong("at")
|
||||
assertTrue(first != second || session.history(Vault.threadForUser(peer)).size == 2,
|
||||
"two sends must be two distinguishable entries")
|
||||
assertTrue(
|
||||
first != second || session.history(Vault.threadForUser(peer)).size == 2,
|
||||
"two sends must be two distinguishable entries",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,19 +11,12 @@ import kotlin.test.assertTrue
|
||||
/**
|
||||
* The parked incoming offer, which is what makes a call answerable from a
|
||||
* locked phone.
|
||||
*
|
||||
* The ringing notification is `ongoing`, so it cannot be swiped away by
|
||||
* accident mid-ring, which also means it never dismisses itself, and every
|
||||
* path that ends a call has to cancel it: declined, answered, the caller hung
|
||||
* up, and nobody picked up. The last one is the one with no event behind it,
|
||||
* and [PendingIncomingCall.isStale] is how it is noticed.
|
||||
*/
|
||||
class PendingCallTest {
|
||||
|
||||
@AfterTest fun tidy() = PendingIncomingCall.clearAll()
|
||||
|
||||
private fun offer(id: String = "call-1") = Content.CallSignal(
|
||||
action = Content.CallSignal.OFFER, callId = id, sdp = "v=0", reason = null, video = false)
|
||||
private fun offer(id: String = "call-1") =
|
||||
Content.CallSignal(action = Content.CallSignal.OFFER, callId = id, sdp = "v=0", reason = null, video = false)
|
||||
|
||||
@Test
|
||||
fun `nothing is stale when nothing is parked`() {
|
||||
@@ -40,15 +33,12 @@ class PendingCallTest {
|
||||
/** Without this the lock screen keeps offering to answer a dead call. */
|
||||
@Test
|
||||
fun `an offer older than the dial timeout is stale`() {
|
||||
PendingIncomingCall.park(
|
||||
42L, offer(), System.currentTimeMillis() - CallManager.DIAL_TIMEOUT_MS - 1_000)
|
||||
PendingIncomingCall.park(42L, offer(), System.currentTimeMillis() - CallManager.DIAL_TIMEOUT_MS - 1_000)
|
||||
assertTrue(PendingIncomingCall.isStale())
|
||||
}
|
||||
|
||||
/**
|
||||
* Asking must not consume. A staleness check that swallowed a live offer
|
||||
* would drop a call that was still ringing, the exact opposite of the bug
|
||||
* it exists to prevent.
|
||||
* Asking must not consume.
|
||||
*/
|
||||
@Test
|
||||
fun `checking staleness does not take the offer`() {
|
||||
|
||||
@@ -6,20 +6,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Presence, and the vocabulary it shares with the relay.
|
||||
*
|
||||
* The bug this guards shipped here and hid for months. This screen has
|
||||
* always offered `busy` and `offline` chips. The relay's status route parsed
|
||||
* with a plain `rawValue:` init inside an `if let` with no `else`, so both were
|
||||
* accepted with 200 and stored nothing, and `setPresence` then set its local
|
||||
* state from the value it had *asked* for. Tapping "offline" lit the chip,
|
||||
* changed nothing, and left the user visible to every contact, a privacy
|
||||
* control that reported success and did nothing.
|
||||
*
|
||||
* Both ends are fixed. These assertions are what stops the vocabulary drifting
|
||||
* apart again, since nothing in either build checks it.
|
||||
*/
|
||||
class PresenceTest {
|
||||
|
||||
/** Every chip must be a word `ChosenStatus` in flamenet-relay accepts. */
|
||||
@Test
|
||||
fun `the chips are exactly the statuses the relay accepts`() {
|
||||
@@ -31,8 +19,8 @@ class PresenceTest {
|
||||
|
||||
/**
|
||||
* The relay stores `offline` as `invisible`, the same choice under two
|
||||
* names, so the reply has to be mapped back or no chip appears selected
|
||||
* and the screen looks broken right after a successful change.
|
||||
* names, so the reply has to be mapped back or no chip appears selected and
|
||||
* the screen looks broken right after a successful change.
|
||||
*/
|
||||
@Test
|
||||
fun `invisible is shown as offline`() {
|
||||
@@ -56,6 +44,5 @@ class PresenceTest {
|
||||
assertEquals("dnd", displayPresenceFor("dnd"))
|
||||
}
|
||||
|
||||
private fun displayPresenceFor(stored: String): String =
|
||||
if (stored == "invisible") "offline" else stored
|
||||
private fun displayPresenceFor(stored: String): String = if (stored == "invisible") "offline" else stored
|
||||
}
|
||||
|
||||
@@ -7,26 +7,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The one button on a dead-end screen has to be a way out.
|
||||
*
|
||||
* "Erase and start fresh" erased nothing. The `VaultUnreadable` screen,
|
||||
* shown when the keys on this phone cannot be opened, offered exactly one
|
||||
* action, and it called `model::signOut`. `signOut` defaults to
|
||||
* `erasingDevice = false`, the door that deliberately KEEPS the device so
|
||||
* coming back is silent. So it set the phase to SignedOut, left the unreadable
|
||||
* vault exactly where it was, and the next `restore()` opened it, failed again,
|
||||
* and came straight back to the same screen.
|
||||
*
|
||||
* It looked like it worked. The screen changed. That is the whole
|
||||
* problem with a recovery action that does not recover: the feedback is the
|
||||
* claim, and the person only finds out on the next launch, or, as here, when
|
||||
* they try to sign in and land back where they started.
|
||||
*
|
||||
* A source scan, coarse on purpose, in the same shape as
|
||||
* `DestructiveLabelsTest`. It cannot prove the recovery is correct. It proves
|
||||
* this particular wrong call cannot come back.
|
||||
*/
|
||||
class RecoveryActuallyRecoversTest {
|
||||
|
||||
private fun source(name: String): String {
|
||||
val f = File("src/main/kotlin/io/flamenet/messenger/app/$name")
|
||||
assertTrue(f.isFile, "cannot find $name, this test reads the sources")
|
||||
@@ -61,9 +43,7 @@ class RecoveryActuallyRecoversTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* And that the discard actually destroys something. A method with the
|
||||
* right name that does not reach the vault would satisfy the test above and
|
||||
* fail the person in exactly the same way.
|
||||
* And that the discard actually destroys something.
|
||||
*/
|
||||
@Test
|
||||
fun `discarding an unreadable vault destroys it and reopens a fresh one`() {
|
||||
@@ -71,12 +51,18 @@ class RecoveryActuallyRecoversTest {
|
||||
val start = s.indexOf("fun discardUnreadableVault(")
|
||||
assertTrue(start > 0, "discardUnreadableVault is gone")
|
||||
val body = s.substring(start, minOf(start + 1200, s.length))
|
||||
assertTrue(body.contains("KeystoreVault.destroy"),
|
||||
"discardUnreadableVault never destroys the vault")
|
||||
assertTrue(body.contains("KeystoreVault.open"),
|
||||
assertTrue(
|
||||
body.contains("KeystoreVault.destroy"),
|
||||
"discardUnreadableVault never destroys the vault",
|
||||
)
|
||||
assertTrue(
|
||||
body.contains("KeystoreVault.open"),
|
||||
"discardUnreadableVault destroys the vault without opening a fresh one, " +
|
||||
"so the next sign-in has nowhere to store keys")
|
||||
assertTrue(body.contains("Phase.SignedOut"),
|
||||
"discardUnreadableVault does not leave the app on the sign-in screen")
|
||||
"so the next sign-in has nowhere to store keys",
|
||||
)
|
||||
assertTrue(
|
||||
body.contains("Phase.SignedOut"),
|
||||
"discardUnreadableVault does not leave the app on the sign-in screen",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The device row's "Registered <date>" line.
|
||||
*
|
||||
* This shipped rendering its own source code. The string was written
|
||||
* with an escaped dollar, so the template was never evaluated and the screen
|
||||
* showed `Registered ${fmt.format(instant)}` to the user. Nothing could warn
|
||||
* about it, escaping a dollar is legitimate, just never there, and nothing
|
||||
* could test it either, because it was built inline inside a `Text(...)`.
|
||||
*/
|
||||
class RegisteredLineTest {
|
||||
|
||||
@Test
|
||||
fun `a real timestamp becomes a date, not template source`() {
|
||||
val line = registeredLine("2026-08-29T14:35:00Z")
|
||||
|
||||
@@ -8,15 +8,8 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Starting a reply, on the wire (android#16).
|
||||
*
|
||||
* The receiving half already worked, `InboundControlTest` has "a reply
|
||||
* is still renderable", and `Content.Reference` was in use for reactions. What
|
||||
* was missing was any way to START one: no Reply in the menu, no composer
|
||||
* state, and `Session.send` had nowhere to put a reference. So these tests are
|
||||
* about the outbound envelope, which is the half that did not exist.
|
||||
*/
|
||||
class ReplyWireTest {
|
||||
|
||||
private fun parse(json: String) = Content.parse(json)
|
||||
|
||||
@Test
|
||||
@@ -31,11 +24,7 @@ class ReplyWireTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview travels, and this is why. A recipient who joined the
|
||||
* thread later, or cleared their history, has never seen the message being
|
||||
* answered. A reply rendering as "> [missing]" is worse than one that shows
|
||||
* what was said, so the sender's own copy of the quoted words goes with
|
||||
* it, and dropping the field to save bytes is not a free optimisation.
|
||||
* The preview travels, and this is why.
|
||||
*/
|
||||
@Test
|
||||
fun `the quoted words travel with the reply`() {
|
||||
@@ -45,9 +34,8 @@ class ReplyWireTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A message with nothing extra to carry still goes out as raw bytes, so
|
||||
* an older peer reads it as text. Adding a reply must not have changed
|
||||
* that for messages that are not replies.
|
||||
* A message with nothing extra to carry still goes out as raw bytes, so an
|
||||
* older peer reads it as text.
|
||||
*/
|
||||
@Test
|
||||
fun `a message that is not a reply is still a bare body`() {
|
||||
|
||||
@@ -8,23 +8,20 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Scheduled send: the drain rule.
|
||||
*
|
||||
* "Not due yet" must mean KEEP, never fail. The outbox loop is also the
|
||||
* retry queue, so a scheduled entry that fell through the failure path would be
|
||||
* dropped outright, the message would simply never exist. This asserts the
|
||||
* predicate the drain uses, which is the whole of the risk.
|
||||
*/
|
||||
class ScheduledSendTest {
|
||||
|
||||
private val now = 1_700_000_000_000L
|
||||
|
||||
/** The drain's rule, mirrored: hold while `not_before` is in the future. */
|
||||
private fun isHeld(item: JSONObject, at: Long) = item.optLong("not_before", 0L) > at
|
||||
private fun isHeld(
|
||||
item: JSONObject,
|
||||
at: Long,
|
||||
) = item.optLong("not_before", 0L) > at
|
||||
|
||||
@Test
|
||||
fun `an entry with no schedule sends immediately`() {
|
||||
// Entries queued before scheduling existed carry no `not_before`,
|
||||
// and must not be held for a date they have no value for.
|
||||
// Entries queued before scheduling existed carry no `not_before`, and
|
||||
// must not be held for a date they have no value for.
|
||||
assertTrue(!isHeld(JSONObject().put("to", 1).put("text", "hi"), now))
|
||||
}
|
||||
|
||||
@@ -50,10 +47,12 @@ class ScheduledSendTest {
|
||||
/** Soonest-first ordering is what the banner names and Cancel removes. */
|
||||
@Test
|
||||
fun `scheduled entries are ordered soonest first`() {
|
||||
val pending = JSONArray()
|
||||
val pending =
|
||||
JSONArray()
|
||||
.put(JSONObject().put("to", 1).put("text", "later").put("not_before", now + 7_200_000))
|
||||
.put(JSONObject().put("to", 1).put("text", "sooner").put("not_before", now + 60_000))
|
||||
val ordered = (0 until pending.length())
|
||||
val ordered =
|
||||
(0 until pending.length())
|
||||
.map { pending.getJSONObject(it) }
|
||||
.filter { it.optLong("not_before") > now }
|
||||
.sortedBy { it.optLong("not_before") }
|
||||
|
||||
@@ -2,13 +2,13 @@ package io.flamenet.messenger.app
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.flamenet.client.MessengerApi
|
||||
@@ -21,43 +21,20 @@ import org.robolectric.annotation.GraphicsMode
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Renders screens to PNG on the JVM. No emulator, no device, no 8 GB AVD.
|
||||
*
|
||||
* This exists because looking at a layout should not cost an emulator.
|
||||
* Verifying the design pass meant booting an AVD, installing an APK, tapping
|
||||
* to a screen and pulling a screencap, and the emulator was killed by memory
|
||||
* pressure four times in one session, each time mid-check. Robolectric's
|
||||
* native graphics mode draws the same Compose pixels on the JVM in seconds.
|
||||
*
|
||||
* Not assertions, output. These write files and pass as long as
|
||||
* nothing throws. That is the honest description: a screenshot test that
|
||||
* asserts against a stored baseline is a different thing, worth adding once
|
||||
* the design has stopped moving. Right now the design IS moving, and a
|
||||
* baseline would be re-blessed on every change, which proves nothing.
|
||||
*
|
||||
* Rendered at the kit's own canvas, 375x812, so a PNG here can be put
|
||||
* beside the Sketch artboard and compared directly rather than mentally
|
||||
* rescaled from a 1080x2400 phone.
|
||||
*
|
||||
* Output lands in `app/build/screenshots`.
|
||||
* Renders screens to PNG on the JVM.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav")
|
||||
class ScreenshotTest {
|
||||
|
||||
// An Activity rule, not `createComposeRule()`. The plain rule hosts
|
||||
// the tree in a bare ComposeView, and `captureToImage` then goes down the
|
||||
// window-capture path looking for a real window to redraw, which under
|
||||
// Robolectric never signals, and the whole thing dies as
|
||||
// "Condition still not satisfied after 2000 ms". That reads like a hung
|
||||
// composition and is nothing of the sort.
|
||||
// An Activity rule, not `createComposeRule()`.
|
||||
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val out = File("build/screenshots").also { it.mkdirs() }
|
||||
|
||||
/** Your address, with the QR the Kotlin encoder produces. */
|
||||
@Test fun addressCard() = shoot("address-card") {
|
||||
@Test fun addressCard() =
|
||||
shoot("address-card") {
|
||||
AddressCard(
|
||||
fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn",
|
||||
relay = "relay.flamenet.io",
|
||||
@@ -65,134 +42,181 @@ class ScreenshotTest {
|
||||
}
|
||||
|
||||
/** The same card when the address could not be derived. */
|
||||
@Test fun addressCardMissing() = shoot("address-card-missing") {
|
||||
@Test fun addressCardMissing() =
|
||||
shoot("address-card-missing") {
|
||||
AddressCard(fnid = null, relay = "relay.flamenet.io")
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole screen, with a fabricated phase and an idle view model.
|
||||
*
|
||||
* The screens take their state as `phase` and only touch the model when
|
||||
* something is tapped, so an unstarted model is enough to render them. It
|
||||
* does open the Keystore vault in `init`, under Robolectric that fails
|
||||
* inside a `runCatching` on a background dispatcher, which is why this
|
||||
* works at all rather than by design. If it starts hanging, that is the
|
||||
* first place to look.
|
||||
*/
|
||||
@Test fun contactsScreen() = shootFullScreen("screen-contacts") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { SignedIn(model, phase) }
|
||||
@Test fun contactsScreen() =
|
||||
shootFullScreen("screen-contacts") { model, phase ->
|
||||
androidx.compose.foundation.layout
|
||||
.Column { SignedIn(model, phase) }
|
||||
}
|
||||
|
||||
@Test fun accountScreen() = shootFullScreen("screen-account") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { AccountScreen(model, phase) }
|
||||
@Test fun accountScreen() =
|
||||
shootFullScreen("screen-account") { model, phase ->
|
||||
androidx.compose.foundation.layout
|
||||
.Column { AccountScreen(model, phase) }
|
||||
}
|
||||
|
||||
/** The feed, with a couple of stories on it. */
|
||||
@Test fun storiesFeed() = shootFullScreen("screen-stories") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { StoriesFeed(model, phase) }
|
||||
@Test fun storiesFeed() =
|
||||
shootFullScreen("screen-stories") { model, phase ->
|
||||
androidx.compose.foundation.layout
|
||||
.Column { StoriesFeed(model, phase) }
|
||||
}
|
||||
|
||||
@Test fun storiesFeedEmpty() = shoot("screen-stories-empty") {
|
||||
val app = androidx.test.core.app.ApplicationProvider
|
||||
@Test fun storiesFeedEmpty() =
|
||||
shoot("screen-stories-empty") {
|
||||
val app =
|
||||
androidx.test.core.app.ApplicationProvider
|
||||
.getApplicationContext<android.app.Application>()
|
||||
androidx.compose.foundation.layout.Column {
|
||||
StoriesFeed(MessengerViewModel(app), samplePhase().copy(stories = emptyList()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun callsScreen() = shootFullScreen("screen-calls") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { CallsScreen(model, phase) }
|
||||
@Test fun callsScreen() =
|
||||
shootFullScreen("screen-calls") { model, phase ->
|
||||
androidx.compose.foundation.layout
|
||||
.Column { CallsScreen(model, phase) }
|
||||
}
|
||||
|
||||
@Test fun callsEmpty() = shoot("screen-calls-empty") {
|
||||
val app = androidx.test.core.app.ApplicationProvider
|
||||
@Test fun callsEmpty() =
|
||||
shoot("screen-calls-empty") {
|
||||
val app =
|
||||
androidx.test.core.app.ApplicationProvider
|
||||
.getApplicationContext<android.app.Application>()
|
||||
androidx.compose.foundation.layout.Column {
|
||||
CallsScreen(MessengerViewModel(app),
|
||||
samplePhase().copy(calls = emptyList()))
|
||||
CallsScreen(
|
||||
MessengerViewModel(app),
|
||||
samplePhase().copy(calls = emptyList()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun alertsScreen() = shootFullScreen("screen-alerts") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) }
|
||||
@Test fun alertsScreen() =
|
||||
shootFullScreen("screen-alerts") { model, phase ->
|
||||
androidx.compose.foundation.layout
|
||||
.Column { NotificationsScreen(model, phase) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation. The one screen with no render until now, it was checked
|
||||
* on the emulator before the palette changed, and not since.
|
||||
* A conversation.
|
||||
*/
|
||||
@Test fun chatScreen() = shootFullScreen("screen-chat") { model, phase ->
|
||||
@Test fun chatScreen() =
|
||||
shootFullScreen("screen-chat") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column {
|
||||
ChatScreen(model, sampleChat(), error = null, buddies = phase.buddies)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sampleChat() = MessengerViewModel.Phase.Chat(
|
||||
private fun sampleChat() =
|
||||
MessengerViewModel.Phase.Chat(
|
||||
threadId = "t1",
|
||||
title = "Hoang Nguyen",
|
||||
peerId = 33,
|
||||
trust = io.flamenet.client.IdentityTrust.PINNED,
|
||||
safetyNumber = "12345 67890 12345 67890 12345 67890",
|
||||
lines = listOf(
|
||||
lines =
|
||||
listOf(
|
||||
Session.Line("sup", mine = true, at = 1_756_300_200_000),
|
||||
Session.Line("Notta", mine = false, at = 1_756_300_260_000),
|
||||
Session.Line(
|
||||
"A longer one, so the corner that points at its author is "
|
||||
+ "visible on a bubble that actually wraps.",
|
||||
mine = false, at = 1_756_300_320_000),
|
||||
Session.Line("And the same on this side.", mine = true, at = 1_756_300_380_000,
|
||||
delivery = Session.Delivery.DELIVERED),
|
||||
"A longer one, so the corner that points at its author is " +
|
||||
"visible on a bubble that actually wraps.",
|
||||
mine = false,
|
||||
at = 1_756_300_320_000,
|
||||
),
|
||||
Session.Line(
|
||||
"And the same on this side.",
|
||||
mine = true,
|
||||
at = 1_756_300_380_000,
|
||||
delivery = Session.Delivery.DELIVERED,
|
||||
),
|
||||
// The state that used to be invisible: sent while they were signed
|
||||
// out, so no device took it. It waits in the outbox and says so.
|
||||
Session.Line("Are you around later?", mine = true, at = 1_756_300_440_000,
|
||||
delivery = Session.Delivery.PENDING),
|
||||
// out, so no device took it.
|
||||
Session.Line(
|
||||
"Are you around later?",
|
||||
mine = true,
|
||||
at = 1_756_300_440_000,
|
||||
delivery = Session.Delivery.PENDING,
|
||||
),
|
||||
// A sticker draws bare, no bubble, so this catches the case where
|
||||
// it falls through to the text path and renders as an empty one.
|
||||
Session.Line("", mine = false, at = 1_756_300_500_000,
|
||||
sticker = Session.Sticker("flamenet.core", "wave")),
|
||||
Session.Line(
|
||||
"",
|
||||
mine = false,
|
||||
at = 1_756_300_500_000,
|
||||
sticker = Session.Sticker("flamenet.core", "wave"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/** One contact, the kit's way: cover, name plate, floating action rail. */
|
||||
@Test fun contactDetail() = shootFullScreen("screen-contact") { model, phase ->
|
||||
@Test fun contactDetail() =
|
||||
shootFullScreen("screen-contact") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column {
|
||||
ContactDetail(model, phase, phase.buddies[1]) {}
|
||||
}
|
||||
}
|
||||
|
||||
/** A call in progress, renderable because the look takes plain values. */
|
||||
@Test fun callActive() = shoot("screen-call-active") {
|
||||
@Test fun callActive() =
|
||||
shoot("screen-call-active") {
|
||||
CallScreenBody(
|
||||
peerName = "Hoang Nguyen",
|
||||
status = "On a call",
|
||||
actions = listOf(
|
||||
actions =
|
||||
listOf(
|
||||
CallAction("mute", KitIcon.Mic, "Mute") {},
|
||||
CallAction("end", KitIcon.CallEnd, "End", isPrimary = true,
|
||||
CallAction(
|
||||
"end",
|
||||
KitIcon.CallEnd,
|
||||
"End",
|
||||
isPrimary = true,
|
||||
tint = androidx.compose.material3.MaterialTheme.colorScheme.error,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White) {},
|
||||
iconColor = androidx.compose.ui.graphics.Color.White,
|
||||
) {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun callRinging() = shoot("screen-call-ringing") {
|
||||
@Test fun callRinging() =
|
||||
shoot("screen-call-ringing") {
|
||||
CallScreenBody(
|
||||
peerName = "Hoang Nguyen",
|
||||
status = "Incoming call",
|
||||
actions = listOf(
|
||||
CallAction("decline", KitIcon.CallEnd, "Decline",
|
||||
actions =
|
||||
listOf(
|
||||
CallAction(
|
||||
"decline",
|
||||
KitIcon.CallEnd,
|
||||
"Decline",
|
||||
tint = androidx.compose.material3.MaterialTheme.colorScheme.error,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White) {},
|
||||
CallAction("accept", KitIcon.Call, "Answer", isPrimary = true,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White,
|
||||
) {},
|
||||
CallAction(
|
||||
"accept",
|
||||
KitIcon.Call,
|
||||
"Answer",
|
||||
isPrimary = true,
|
||||
tint = Palette.Teal,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White) {},
|
||||
iconColor = androidx.compose.ui.graphics.Color.White,
|
||||
) {},
|
||||
),
|
||||
warning = "This server has no call relay, so the call only connects "
|
||||
+ "if a direct path exists between the two networks.",
|
||||
warning =
|
||||
"This server has no call relay, so the call only connects " +
|
||||
"if a direct path exists between the two networks.",
|
||||
)
|
||||
}
|
||||
|
||||
/** The kit's buttons, so a change to one is visible without hunting. */
|
||||
@Test fun buttons() = shoot("buttons") {
|
||||
@Test fun buttons() =
|
||||
shoot("buttons") {
|
||||
Column {
|
||||
KitPrimaryButton(text = "Create group", onClick = {})
|
||||
KitSecondaryButton(text = "Accept new keys", onClick = {}, danger = true)
|
||||
@@ -203,81 +227,149 @@ class ScreenshotTest {
|
||||
|
||||
/**
|
||||
* Render one composable inside the app's theme and write it out.
|
||||
*
|
||||
* `Surface` with the theme background, not a bare composable: without a
|
||||
* background the PNG has a transparent ground and every colour judgement
|
||||
* made from it is wrong.
|
||||
*/
|
||||
|
||||
/** A phase with enough in it to exercise every row shape on a screen. */
|
||||
private fun samplePhase() = MessengerViewModel.Phase.SignedIn(
|
||||
private fun samplePhase() =
|
||||
MessengerViewModel.Phase.SignedIn(
|
||||
handle = "fnreviewalpha",
|
||||
userId = 31,
|
||||
deviceId = "74d869d7-4667-4d97-854b-80ee684aaacc",
|
||||
relay = "relay.flamenet.io",
|
||||
fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn",
|
||||
status = "Signed in on this device.",
|
||||
buddies = listOf(
|
||||
MessengerApi.Buddy(userId = 32, handle = "fnreviewbeta",
|
||||
displayName = "fnreviewbeta", presence = "online"),
|
||||
MessengerApi.Buddy(userId = 33, handle = "hoang",
|
||||
displayName = "Hoang Nguyen", presence = "away"),
|
||||
buddies =
|
||||
listOf(
|
||||
MessengerApi.Buddy(
|
||||
userId = 32,
|
||||
handle = "fnreviewbeta",
|
||||
displayName = "fnreviewbeta",
|
||||
presence = "online",
|
||||
),
|
||||
MessengerApi.Buddy(
|
||||
userId = 33,
|
||||
handle = "hoang",
|
||||
displayName = "Hoang Nguyen",
|
||||
presence = "away",
|
||||
),
|
||||
),
|
||||
buddiesLoaded = true,
|
||||
groupsLoaded = true,
|
||||
// The row's right-hand column and second line only exist when there is
|
||||
// a thread behind them, so the render has to have one or it photographs
|
||||
// the empty state and looks like the change never landed.
|
||||
previews = mapOf(
|
||||
32L to Session.Preview(
|
||||
previews =
|
||||
mapOf(
|
||||
32L to
|
||||
Session.Preview(
|
||||
text = "Sure! Please give me the campaign details soon",
|
||||
at = System.currentTimeMillis() - 25 * 60_000L, mine = false),
|
||||
33L to Session.Preview(
|
||||
at = System.currentTimeMillis() - 25 * 60_000L,
|
||||
mine = false,
|
||||
),
|
||||
33L to
|
||||
Session.Preview(
|
||||
text = "okay let me see",
|
||||
at = System.currentTimeMillis() - 26 * 3600_000L, mine = true),
|
||||
at = System.currentTimeMillis() - 26 * 3600_000L,
|
||||
mine = true,
|
||||
),
|
||||
unread = setOf(io.flamenet.e2e.Vault.threadForUser(32)),
|
||||
stories = listOf(
|
||||
),
|
||||
unread =
|
||||
setOf(
|
||||
io.flamenet.e2e.Vault
|
||||
.threadForUser(32),
|
||||
),
|
||||
stories =
|
||||
listOf(
|
||||
io.flamenet.e2e.Vault.Story(
|
||||
id = "s1", authorId = 33, mine = false,
|
||||
ref = io.flamenet.e2e.Attachment.Ref(
|
||||
id = "s1",
|
||||
authorId = 33,
|
||||
mine = false,
|
||||
ref =
|
||||
io.flamenet.e2e.Attachment.Ref(
|
||||
kind = io.flamenet.e2e.Attachment.STORY,
|
||||
id = "blob1", key = ByteArray(32), digest = ByteArray(32),
|
||||
bytes = 1024, mime = "image/jpeg", width = 1200, height = 900,
|
||||
expires = System.currentTimeMillis() / 1000.0 + 7 * 3600),
|
||||
id = "blob1",
|
||||
key = ByteArray(32),
|
||||
digest = ByteArray(32),
|
||||
bytes = 1024,
|
||||
mime = "image/jpeg",
|
||||
width = 1200,
|
||||
height = 900,
|
||||
expires = System.currentTimeMillis() / 1000.0 + 7 * 3600,
|
||||
),
|
||||
caption = "The view from the workshop this morning.",
|
||||
postedAt = System.currentTimeMillis(), seen = false),
|
||||
postedAt = System.currentTimeMillis(),
|
||||
seen = false,
|
||||
),
|
||||
io.flamenet.e2e.Vault.Story(
|
||||
id = "s2", authorId = 32, mine = false,
|
||||
ref = io.flamenet.e2e.Attachment.Ref(
|
||||
id = "s2",
|
||||
authorId = 32,
|
||||
mine = false,
|
||||
ref =
|
||||
io.flamenet.e2e.Attachment.Ref(
|
||||
kind = io.flamenet.e2e.Attachment.STORY,
|
||||
id = "blob2", key = ByteArray(32), digest = ByteArray(32),
|
||||
bytes = 1024, mime = "image/jpeg", width = 1200, height = 900,
|
||||
expires = System.currentTimeMillis() / 1000.0 + 1800),
|
||||
caption = "", postedAt = System.currentTimeMillis(), seen = true),
|
||||
id = "blob2",
|
||||
key = ByteArray(32),
|
||||
digest = ByteArray(32),
|
||||
bytes = 1024,
|
||||
mime = "image/jpeg",
|
||||
width = 1200,
|
||||
height = 900,
|
||||
expires = System.currentTimeMillis() / 1000.0 + 1800,
|
||||
),
|
||||
calls = listOf(
|
||||
Session.CallEntry(peerId = 32, incoming = true, connected = false,
|
||||
at = System.currentTimeMillis() - 40 * 60_000L),
|
||||
Session.CallEntry(peerId = 33, incoming = false, connected = true,
|
||||
at = System.currentTimeMillis() - 5 * 3600_000L),
|
||||
Session.CallEntry(peerId = 32, incoming = true, connected = true,
|
||||
at = System.currentTimeMillis() - 30 * 3600_000L),
|
||||
caption = "",
|
||||
postedAt = System.currentTimeMillis(),
|
||||
seen = true,
|
||||
),
|
||||
),
|
||||
calls =
|
||||
listOf(
|
||||
Session.CallEntry(
|
||||
peerId = 32,
|
||||
incoming = true,
|
||||
connected = false,
|
||||
at = System.currentTimeMillis() - 40 * 60_000L,
|
||||
),
|
||||
Session.CallEntry(
|
||||
peerId = 33,
|
||||
incoming = false,
|
||||
connected = true,
|
||||
at = System.currentTimeMillis() - 5 * 3600_000L,
|
||||
),
|
||||
Session.CallEntry(
|
||||
peerId = 32,
|
||||
incoming = true,
|
||||
connected = true,
|
||||
at = System.currentTimeMillis() - 30 * 3600_000L,
|
||||
),
|
||||
),
|
||||
notifications =
|
||||
listOf(
|
||||
MessengerApi.Notification(
|
||||
1,
|
||||
"contact request",
|
||||
"Hoang Nguyen wants to add you",
|
||||
read = false,
|
||||
),
|
||||
MessengerApi.Notification(
|
||||
2,
|
||||
"missed call",
|
||||
"You missed a call from fnreviewbeta",
|
||||
read = true,
|
||||
),
|
||||
notifications = listOf(
|
||||
MessengerApi.Notification(1, "contact request",
|
||||
"Hoang Nguyen wants to add you", read = false),
|
||||
MessengerApi.Notification(2, "missed call",
|
||||
"You missed a call from fnreviewbeta", read = true),
|
||||
),
|
||||
)
|
||||
|
||||
/** The same screen with nothing on it, the state a new account sees. */
|
||||
@Test fun alertsEmpty() = shoot("screen-alerts-empty") {
|
||||
val app = androidx.test.core.app.ApplicationProvider
|
||||
@Test fun alertsEmpty() =
|
||||
shoot("screen-alerts-empty") {
|
||||
val app =
|
||||
androidx.test.core.app.ApplicationProvider
|
||||
.getApplicationContext<android.app.Application>()
|
||||
androidx.compose.foundation.layout.Column {
|
||||
NotificationsScreen(MessengerViewModel(app),
|
||||
samplePhase().copy(notifications = emptyList()))
|
||||
NotificationsScreen(
|
||||
MessengerViewModel(app),
|
||||
samplePhase().copy(notifications = emptyList()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,14 +377,18 @@ class ScreenshotTest {
|
||||
name: String,
|
||||
content: @Composable (MessengerViewModel, MessengerViewModel.Phase.SignedIn) -> Unit,
|
||||
) {
|
||||
val app = androidx.test.core.app.ApplicationProvider
|
||||
val app =
|
||||
androidx.test.core.app.ApplicationProvider
|
||||
.getApplicationContext<android.app.Application>()
|
||||
val model = MessengerViewModel(app)
|
||||
val phase = samplePhase()
|
||||
shoot(name) { content(model, phase) }
|
||||
}
|
||||
|
||||
private fun shoot(name: String, content: @Composable () -> Unit) {
|
||||
private fun shoot(
|
||||
name: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
compose.setContent {
|
||||
MessengerTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -302,16 +398,12 @@ class ScreenshotTest {
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
// Draw the view, do not `captureToImage()`. Compose's capture
|
||||
// helper goes through `PixelCopy` and waits for a frame-commit
|
||||
// callback that Robolectric never delivers, so every render died as
|
||||
// "Condition still not satisfied after 2000 ms", which reads as a
|
||||
// hung composition and is nothing of the sort. `View.draw` into a
|
||||
// software canvas produces the same pixels with no window involved.
|
||||
// Draw the view, do not `captureToImage()`.
|
||||
var shot: Bitmap? = null
|
||||
compose.activityRule.scenario.onActivity { activity ->
|
||||
val view = activity.window.decorView
|
||||
val bitmap = Bitmap.createBitmap(
|
||||
val bitmap =
|
||||
Bitmap.createBitmap(
|
||||
view.width.coerceAtLeast(1),
|
||||
view.height.coerceAtLeast(1),
|
||||
Bitmap.Config.ARGB_8888,
|
||||
@@ -326,6 +418,8 @@ class ScreenshotTest {
|
||||
println("wrote ${File(out, "$name.png").absolutePath} (${bitmap.width}x${bitmap.height})")
|
||||
}
|
||||
|
||||
private fun <T : Any> requireNonNull(value: T?, message: String): T =
|
||||
value ?: throw AssertionError(message)
|
||||
private fun <T : Any> requireNonNull(
|
||||
value: T?,
|
||||
message: String,
|
||||
): T = value ?: throw AssertionError(message)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
@@ -15,27 +18,13 @@ import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Which roster the group list believes, when the relay offers a different one.
|
||||
*
|
||||
* The relay must not win. A sealed group's members come out of a blob
|
||||
* the owner signed. `GET /e2e/groups` carries no signature at all, so if the
|
||||
* plaintext answer were preferred, or merely allowed to shadow the sealed one
|
||||
* for the same gid, the server would be back in charge of deciding who is in
|
||||
* a group, which is the whole thing sealing exists to stop.
|
||||
*
|
||||
* Robolectric for the same reason as the other `:app` tests: this is an
|
||||
* Android module, so its unit-test classpath has the stubbed `org.json` that
|
||||
* returns null from everything, and the vault stores JSON.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class SealedGroupListTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
/** The blob the relay hands back for the sealed fetch. */
|
||||
@@ -52,38 +41,66 @@ class SealedGroupListTest {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/groups/fetch" in path -> json(
|
||||
"/e2e/groups/fetch" in path -> {
|
||||
json(
|
||||
"""{"groups":[{"gid":"g1","owner_key":"","roster_blob":"$sealedBlob",""" +
|
||||
""""roster_version":1}]}""")
|
||||
"/e2e/groups" in path -> json(
|
||||
""""roster_version":1}]}""",
|
||||
)
|
||||
}
|
||||
|
||||
"/e2e/groups" in path -> {
|
||||
json(
|
||||
"""{"groups":[{"gid":"g1","name":"Group","owner":1,"members":[""" +
|
||||
plaintextRoster.joinToString(",") { """{"user_id":$it}""" } + "]}]}")
|
||||
else -> json("{}")
|
||||
plaintextRoster.joinToString(",") { """{"user_id":$it}""" } + "]}]}",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
json("{}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.dispatcher = Relay(); it.start() }
|
||||
server =
|
||||
MockWebServer().also {
|
||||
it.dispatcher = Relay()
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
@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 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 key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun session(): Pair<Session, Vault> {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
val s = Session(
|
||||
val s =
|
||||
Session(
|
||||
handle = "alice",
|
||||
userId = 1,
|
||||
identity = identity,
|
||||
@@ -94,18 +111,25 @@ class SealedGroupListTest {
|
||||
return s to vault
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
/** Hold the tag and key for `g1`, sealing the roster the test names. */
|
||||
private fun holdSealed(vault: Vault, members: List<Long>, name: String) {
|
||||
private fun holdSealed(
|
||||
vault: Vault,
|
||||
members: List<Long>,
|
||||
name: String,
|
||||
) {
|
||||
val groupKey = SealedRoster.newGroupKey()
|
||||
val tags = members.associate { it.toString() to SealedRoster.newTag() }
|
||||
sealedBlob = SealedRoster.seal(SealedRoster.Contents(members, tags, name), groupKey)
|
||||
vault.saveGroupSecrets("g1", Vault.GroupSecrets(
|
||||
tag = tags["1"] ?: "", key = groupKey, ownerPrivate = null, version = 1))
|
||||
vault.saveGroupSecrets(
|
||||
"g1",
|
||||
Vault.GroupSecrets(tag = tags["1"] ?: "", key = groupKey, ownerPrivate = null, version = 1),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -123,7 +147,7 @@ class SealedGroupListTest {
|
||||
|
||||
/**
|
||||
* The relay names a third account as a member of a group whose roster is
|
||||
* sealed. The blob says otherwise, and the blob is what the owner signed.
|
||||
* sealed.
|
||||
*/
|
||||
@Test
|
||||
fun `the relay cannot add a member to a sealed group`() {
|
||||
@@ -133,8 +157,11 @@ class SealedGroupListTest {
|
||||
plaintextRoster = listOf(1L, 2L, 99L)
|
||||
|
||||
val g1 = session.namedGroups().first { it.id == "g1" }
|
||||
assertEquals(listOf(1L, 2L), g1.memberIds.sorted(),
|
||||
"the relay's roster replaced the one out of the blob")
|
||||
assertEquals(
|
||||
listOf(1L, 2L),
|
||||
g1.memberIds.sorted(),
|
||||
"the relay's roster replaced the one out of the blob",
|
||||
)
|
||||
assertTrue(99L !in g1.memberIds, "a relay-supplied member entered a sealed group")
|
||||
}
|
||||
|
||||
@@ -145,14 +172,15 @@ class SealedGroupListTest {
|
||||
holdSealed(vault, listOf(1L, 2L), "Book club")
|
||||
plaintextRoster = listOf(1L, 2L)
|
||||
|
||||
assertEquals(1, session.namedGroups().count { it.id == "g1" },
|
||||
"the group is listed twice, sealed and plaintext")
|
||||
assertEquals(
|
||||
1,
|
||||
session.namedGroups().count { it.id == "g1" },
|
||||
"the group is listed twice, sealed and plaintext",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A group we hold no secrets for is still the relay's to describe. That
|
||||
* is the unsealed model, unchanged, dropping those would make somebody's
|
||||
* older groups vanish the moment sealing shipped.
|
||||
* A group we hold no secrets for is still the relay's to describe.
|
||||
*/
|
||||
@Test
|
||||
fun `an unsealed group still comes from the relay`() {
|
||||
|
||||
@@ -9,17 +9,10 @@ import org.junit.Test
|
||||
|
||||
/**
|
||||
* The relay is asked what it is; the answer has to survive the trip to the UI.
|
||||
*
|
||||
* These guard a bug that shipped silently: `discovered` was a `var` in the
|
||||
* class body, so it was excluded from the data class `equals()`. Writing the
|
||||
* discovered config back into a `MutableStateFlow`, which conflates on
|
||||
* equality, was a no-op. The relay answered correctly and the app ignored it:
|
||||
* the sign-in screen sat on "Checking what this server uses…" forever, and the
|
||||
* account mode stayed LOCAL for a relay that had just said SHARED.
|
||||
*/
|
||||
class SiteConfigIdentityTest {
|
||||
|
||||
private val shared = RelayDiscovery.Descriptor(
|
||||
private val shared =
|
||||
RelayDiscovery.Descriptor(
|
||||
audience = "relay.flamenet.io",
|
||||
accounts = RelayDiscovery.Accounts.SHARED,
|
||||
accountsUrl = "https://accounts.flamenet.io",
|
||||
|
||||
@@ -7,24 +7,28 @@ 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.
|
||||
* The wire values must match what the relay accepts.
|
||||
*/
|
||||
@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())
|
||||
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. */
|
||||
@@ -38,22 +42,26 @@ class StoryReportTest {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 })
|
||||
assertTrue(
|
||||
MessengerViewModel.ReportReason.entries
|
||||
.any { it == MessengerViewModel.ReportReason.CSAM },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Without one, the picker forces a person to mis-categorise to
|
||||
* report at all.
|
||||
* Without one, the picker forces a person to mis-categorise to report at
|
||||
* all.
|
||||
*/
|
||||
@Test
|
||||
fun `there is a reason for anything else`() {
|
||||
assertTrue(MessengerViewModel.ReportReason.entries
|
||||
.any { it == MessengerViewModel.ReportReason.OTHER })
|
||||
assertTrue(
|
||||
MessengerViewModel.ReportReason.entries
|
||||
.any { it == MessengerViewModel.ReportReason.OTHER },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,9 @@ import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* How the wallet screen states a number.
|
||||
*
|
||||
* The rule this suite exists for: an unread balance is NOT zero. A screen
|
||||
* that draws 0.00 because a node did not answer has told somebody their money is
|
||||
* gone, on every flaky connection. The screen renders null as an em dash, and
|
||||
* these cover the arithmetic underneath it.
|
||||
*/
|
||||
class WalletDisplayTest {
|
||||
|
||||
// ---- Lamports as SOL -------------------------------------------------
|
||||
// ---- Lamports as SOL -------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a whole number of SOL reads without a decimal tail`() {
|
||||
@@ -30,8 +24,7 @@ class WalletDisplayTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero is a real balance and reads as "0", not as an empty string. The
|
||||
* trimming that removes a decimal tail must not eat the whole number.
|
||||
* Zero is a real balance and reads as "0", not as an empty string.
|
||||
*/
|
||||
@Test
|
||||
fun `an empty account reads as zero, not as nothing`() {
|
||||
@@ -39,20 +32,17 @@ class WalletDisplayTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Five SOL already exceeds Int.MAX_VALUE in lamports. Anything holding a
|
||||
* balance in an Int would overflow long before the number got interesting.
|
||||
* Five SOL already exceeds Int.MAX_VALUE in lamports.
|
||||
*/
|
||||
@Test
|
||||
fun `a large balance is not truncated`() {
|
||||
assertEquals("9223372036.854775807", WalletChain.sol(Long.MAX_VALUE))
|
||||
}
|
||||
|
||||
// ---- What can actually be sent ---------------------------------------
|
||||
// ---- What can actually be sent ---------------------------------------.
|
||||
|
||||
/**
|
||||
* An account below the rent-exempt minimum is RECLAIMED by the
|
||||
* network. Emptying a wallet to the last lamport does not leave it empty,
|
||||
* it deletes it, so "send everything" has to stop short of everything.
|
||||
* An account below the rent-exempt minimum is RECLAIMED by the network.
|
||||
*/
|
||||
@Test
|
||||
fun `spendable leaves the fee and the rent-exempt minimum behind`() {
|
||||
@@ -78,12 +68,10 @@ class WalletDisplayTest {
|
||||
assertEquals(1L, WalletChain.spendable(floor + 1))
|
||||
}
|
||||
|
||||
// ---- Which node ------------------------------------------------------
|
||||
// ---- Which node ------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* http is ignored rather than honoured. A balance read over plain
|
||||
* http is one anybody on the path can rewrite, and a wallet showing a number
|
||||
* an attacker chose is worse than a wallet showing nothing.
|
||||
* http is ignored rather than honoured.
|
||||
*/
|
||||
@Test
|
||||
fun `the default endpoint is https`() {
|
||||
|
||||
@@ -4,52 +4,23 @@ import io.flamenet.e2e.Ed25519
|
||||
import io.flamenet.e2e.FNID
|
||||
|
||||
/**
|
||||
* Turning a passphrase into an address, and telling relays where to deliver.
|
||||
*
|
||||
* ## Why this exists at all
|
||||
*
|
||||
* A relay's `user_id` is a row number it assigned itself. Register at one relay
|
||||
* and everything about you, your name there, your devices, your mailbox, is
|
||||
* that relay's to keep or to lose. This makes the *identity* the user's
|
||||
* instead: an Ed25519 key stretched from the passphrase, whose public half is
|
||||
* the address. No server issues it, so no server can take it away.
|
||||
*
|
||||
* The practical consequence is the one worth stating: sign in with the same
|
||||
* passphrase at a relay that has never heard of you and you get the same
|
||||
* address. Contacts who accepted you before still see the same person. A
|
||||
* relay shutting down costs a mailbox, not an identity.
|
||||
*
|
||||
* ## Why it is here and not in the engine
|
||||
*
|
||||
* The engine holds no network. Deriving the key and building the bytes to sign
|
||||
* are engine work and live in [FNID] and `AccountKdf`; presenting the result to
|
||||
* a relay is client work and lives here, so the engine stays testable without
|
||||
* one and this stays swappable without touching crypto.
|
||||
* Turning a passphrase into an address, and telling relays where to deliver. ##
|
||||
* Why this exists at all A relay's `user_id` is a row number it assigned
|
||||
* itself.
|
||||
*/
|
||||
object AccountIdentity {
|
||||
|
||||
/** The address for an account seed. Derived, never fetched. */
|
||||
fun address(seed: ByteArray): String = FNID.address(Ed25519.fromSeed(seed).publicKey)
|
||||
|
||||
/**
|
||||
* Claim the address at [relay], learning which audience to bind from the
|
||||
* relay itself.
|
||||
*
|
||||
* The audience is not the caller's to guess. It is the string the
|
||||
* relay checks the signature against, so a caller that inferred it from a
|
||||
* URL would produce a claim that verifies nowhere, silently, because at
|
||||
* the point of failure a wrong audience and a wrong key look identical.
|
||||
* Reading it from the descriptor means the only way to get it wrong is for
|
||||
* the relay to lie about its own name, which costs it the claim rather than
|
||||
* us the identity.
|
||||
*
|
||||
* Self-certifying: the signature is made with the very key being
|
||||
* registered, so the relay confirms possession and forms no opinion about
|
||||
* whose key it is, the property that lets a hostile relay be tolerated.
|
||||
*
|
||||
* Returns the address the relay recorded.
|
||||
*/
|
||||
fun claim(relay: RelayClient, seed: ByteArray, userId: Long): String {
|
||||
fun claim(
|
||||
relay: RelayClient,
|
||||
seed: ByteArray,
|
||||
userId: Long,
|
||||
): String {
|
||||
val key = Ed25519.fromSeed(seed)
|
||||
val pub = b64(key.publicKey)
|
||||
val audience = relay.relayDescriptor().getString("audience")
|
||||
@@ -59,26 +30,12 @@ object AccountIdentity {
|
||||
|
||||
/**
|
||||
* Publish where this account keeps mailboxes, to every relay in the list.
|
||||
*
|
||||
* Every listed relay is told, not just the one being added: a home that
|
||||
* does not know it is one will still accept mail, but a *sender* asking it
|
||||
* for the record would be handed a stale list and would skip the others.
|
||||
* Delivering the record everywhere is what keeps them from disagreeing.
|
||||
*
|
||||
* Takes a client per home, and that is the shape of the feature rather
|
||||
* than an implementation detail: a relay will not accept a record from
|
||||
* somebody with no account there, and a token minted by one relay is
|
||||
* rejected by every other because the audience is bound. So homing
|
||||
* somewhere means registering there first, and a map makes that a
|
||||
* compile-time fact instead of a runtime surprise.
|
||||
*
|
||||
* Returns the relays that refused, with why. Reported rather than thrown on
|
||||
* the first failure: one home being unreachable while the rest accept is a
|
||||
* partial success the user can act on, and collapsing it into a single
|
||||
* error would hide which.
|
||||
*/
|
||||
fun publishHomes(
|
||||
seed: ByteArray, fnid: String, serial: Int, homes: Map<String, RelayClient>
|
||||
seed: ByteArray,
|
||||
fnid: String,
|
||||
serial: Int,
|
||||
homes: Map<String, RelayClient>,
|
||||
): Map<String, Throwable> {
|
||||
val relays = homes.keys.toList()
|
||||
val sig = b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays)))
|
||||
@@ -93,12 +50,11 @@ object AccountIdentity {
|
||||
/**
|
||||
* The next serial to publish, read from what is already out there rather
|
||||
* than from local state.
|
||||
*
|
||||
* A device that was offline while another added a relay would otherwise
|
||||
* reuse a serial and have its record refused as a rollback, by the very
|
||||
* check that exists to stop an attacker doing the same thing.
|
||||
*/
|
||||
fun nextSerial(fnid: String, clients: Collection<RelayClient>): Int {
|
||||
fun nextSerial(
|
||||
fnid: String,
|
||||
clients: Collection<RelayClient>,
|
||||
): Int {
|
||||
var highest = 0
|
||||
for (c in clients) {
|
||||
val record = runCatching { c.homes(fnid) }.getOrNull() ?: continue
|
||||
@@ -109,12 +65,13 @@ object AccountIdentity {
|
||||
|
||||
/**
|
||||
* Check a home record against the address it claims to be for.
|
||||
*
|
||||
* Needs nothing but the record, because the address *is* the public key,
|
||||
* which is the point. A sender must not have to ask a relay whether that
|
||||
* relay is telling the truth about where to deliver.
|
||||
*/
|
||||
fun verifyHomes(fnid: String, serial: Int, relays: List<String>, signatureB64: String): Boolean {
|
||||
fun verifyHomes(
|
||||
fnid: String,
|
||||
serial: Int,
|
||||
relays: List<String>,
|
||||
signatureB64: String,
|
||||
): Boolean {
|
||||
val pub = FNID.decode(fnid) ?: return false
|
||||
val sig = runCatching { unb64(signatureB64) }.getOrNull() ?: return false
|
||||
return Ed25519.verify(pub, sig, FNID.homesCanonical(fnid, serial, relays))
|
||||
@@ -123,27 +80,6 @@ object AccountIdentity {
|
||||
/**
|
||||
* Relays named by a record, but only once it verifies AND is not older than
|
||||
* one already seen.
|
||||
*
|
||||
* A signature alone does not stop a rollback, and that is the whole
|
||||
* reason the serial exists. An older record is *signed*: it was
|
||||
* published by the account holder, before they added a second home. A relay
|
||||
* that wants to be somebody's only mailbox does not have to forge anything
|
||||
* it serves the record it saw first, that record verifies perfectly, and
|
||||
* the sender delivers to one relay believing it is delivering to all of
|
||||
* them.
|
||||
*
|
||||
* That is the attack multi-homing exists to survive, and until this both
|
||||
* clients checked only the signature. The relay refuses a lower serial on
|
||||
* WRITE; nothing refused one on READ.
|
||||
*
|
||||
* [floor] is the highest serial this device has already accepted for
|
||||
* this address, or null on first sight. Equal is fine, the same record
|
||||
* re-read is the ordinary case. Only strictly lower is a rollback.
|
||||
*
|
||||
* A refusal here is not proof of an attack, and the caller must not
|
||||
* present it as one. A relay that has fallen behind serves an old
|
||||
* record for honest reasons; what the client can say is that it has seen a
|
||||
* newer one, and that it will keep using it.
|
||||
*/
|
||||
fun verifiedRelays(
|
||||
record: org.json.JSONObject,
|
||||
@@ -171,9 +107,8 @@ object AccountIdentity {
|
||||
}
|
||||
|
||||
// The module's own helper, so this encodes identically to every other blob
|
||||
// the transport sends. `android.util.Base64` is deliberately not reachable
|
||||
// here: this module is plain JVM precisely so its tests run against a real
|
||||
// relay with no emulator.
|
||||
// the transport sends.
|
||||
private fun b64(b: ByteArray): String = Wire.b64(b)
|
||||
|
||||
private fun unb64(s: String): ByteArray = Wire.unb64(s)
|
||||
}
|
||||
|
||||
@@ -12,50 +12,30 @@ import java.security.SecureRandom
|
||||
/**
|
||||
* The shared FlameNet account service, one credential across Messenger and
|
||||
* Files.
|
||||
*
|
||||
* The passphrase never leaves the device. It is stretched here and only
|
||||
* the derived secret is sent, which is why sign-in is two round trips rather
|
||||
* than one: the salt and iteration count are per account, and the service
|
||||
* cannot derive the secret itself. That is the point, and collapsing it
|
||||
* to one call would mean sending the passphrase.
|
||||
*
|
||||
* Used only for relays whose accounts live in the shared service. A self-hosted
|
||||
* relay authenticates against its own table and never touches this, see
|
||||
* [SiteConfig], which decides from the hostname rather than from anything the
|
||||
* relay says about itself.
|
||||
*/
|
||||
class AccountsClient(
|
||||
baseUrl: String = "https://accounts.flamenet.io",
|
||||
private val http: OkHttpClient = RelayClient.defaultHttpClient(),
|
||||
) {
|
||||
|
||||
private val base = baseUrl.trimEnd('/').toHttpUrl()
|
||||
|
||||
/**
|
||||
* The product a session is scoped to.
|
||||
*
|
||||
* Sessions are per product, and that is the privacy property. The
|
||||
* token carries a pseudonym derived for this product, so Messenger and
|
||||
* Files receive different opaque ids for the same person and cannot
|
||||
* correlate their users with each other.
|
||||
*/
|
||||
private val defaultProduct = "messenger"
|
||||
|
||||
/**
|
||||
* What the service advertises for a handle.
|
||||
*
|
||||
* An unknown handle gets a deterministic decoy salt, not an error,
|
||||
* so this route cannot be used to test whether somebody has an account.
|
||||
* Treat a success as "here are parameters", never as "this handle exists",
|
||||
* a UI that says "no such account" here undoes the whole design.
|
||||
*/
|
||||
data class Params(val salt: ByteArray, val kdfId: Int, val iterations: Int)
|
||||
data class Params(
|
||||
val salt: ByteArray,
|
||||
val kdfId: Int,
|
||||
val iterations: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* @property accountKeySeed the 32-byte Ed25519 seed whose public half is
|
||||
* this account's address. Derived from the same stretch as the auth
|
||||
* secret and never sent anywhere, see [AccountKdf.signInSecrets] for
|
||||
* why it comes back here rather than being asked for separately.
|
||||
* this account's address.
|
||||
*/
|
||||
data class Session(
|
||||
val token: String,
|
||||
@@ -64,54 +44,64 @@ class AccountsClient(
|
||||
val accountKeySeed: ByteArray? = null,
|
||||
)
|
||||
|
||||
sealed class Failure(message: String) : Exception(message) {
|
||||
sealed class Failure(
|
||||
message: String,
|
||||
) : Exception(message) {
|
||||
/** Wrong handle and wrong passphrase are the same answer, on purpose. */
|
||||
object BadCredentials : Failure("that handle or passphrase isn't right")
|
||||
|
||||
/**
|
||||
* The session is no longer accepted, expired, or revoked elsewhere.
|
||||
*
|
||||
* Not the same as a wrong passphrase, and saying so matters.
|
||||
* Every 401 and 403 used to become [BadCredentials], so a session that
|
||||
* simply aged out told the user "that handle or passphrase isn't
|
||||
* right", in the middle of sending a message, hours after they signed
|
||||
* in successfully. The natural reading is that their password stopped
|
||||
* working or was changed by someone else, and the natural response is
|
||||
* to change a password that was never wrong.
|
||||
*/
|
||||
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. */
|
||||
/**
|
||||
* The handle folds onto an existing one, see the relay's.
|
||||
*/
|
||||
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")
|
||||
|
||||
class Service(
|
||||
val status: Int,
|
||||
) : Failure("the account service returned $status")
|
||||
|
||||
class Transport(
|
||||
cause: String,
|
||||
) : Failure("couldn't reach the account service: $cause")
|
||||
}
|
||||
|
||||
fun parameters(handle: String): Params {
|
||||
val url = base.newBuilder().addPathSegment("accounts").addPathSegment(handle)
|
||||
.addPathSegment("params").build()
|
||||
val json = send(Request.Builder().url(url).get().build())
|
||||
val url =
|
||||
base
|
||||
.newBuilder()
|
||||
.addPathSegment("accounts")
|
||||
.addPathSegment(handle)
|
||||
.addPathSegment("params")
|
||||
.build()
|
||||
val json =
|
||||
send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build(),
|
||||
)
|
||||
return Params(
|
||||
salt = Wire.unb64(json.getString("kdfSalt")),
|
||||
kdfId = json.getInt("kdfID"),
|
||||
@@ -119,26 +109,30 @@ class AccountsClient(
|
||||
)
|
||||
}
|
||||
|
||||
fun signIn(handle: String, passphrase: String, product: String = defaultProduct): Session {
|
||||
fun signIn(
|
||||
handle: String,
|
||||
passphrase: String,
|
||||
product: String = defaultProduct,
|
||||
): Session {
|
||||
val params = parameters(handle)
|
||||
// The ADVERTISED iteration count wins over the local constant. An
|
||||
// account created under an older count must still be derivable, and the
|
||||
// service is the one that knows which, hardcoding it here silently
|
||||
// locks out every older account.
|
||||
val effective = AccountKdf.Params(
|
||||
// The ADVERTISED iteration count wins over the local constant.
|
||||
val effective =
|
||||
AccountKdf.Params(
|
||||
id = params.kdfId,
|
||||
iterations = params.iterations,
|
||||
saltBytes = params.salt.size,
|
||||
)
|
||||
// Both secrets from ONE stretch. Asking for the account key separately
|
||||
// would re-run 650,000 iterations for a value already in hand, several
|
||||
// seconds on a phone, spent twice, to sign in once.
|
||||
// Both secrets from ONE stretch.
|
||||
val (secret, accountKeySeed) = AccountKdf.signInSecrets(passphrase, params.salt, effective)
|
||||
|
||||
val json = post("sessions", JSONObject()
|
||||
val json =
|
||||
post(
|
||||
"sessions",
|
||||
JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("authSecret", Wire.b64(secret))
|
||||
.put("product", product))
|
||||
.put("product", product),
|
||||
)
|
||||
return Session(
|
||||
token = json.getString("token"),
|
||||
pseudonym = json.optString("pseudonym", ""),
|
||||
@@ -148,19 +142,30 @@ class AccountsClient(
|
||||
}
|
||||
|
||||
/** Create an account. The salt is generated here, not by the service. */
|
||||
fun register(handle: String, passphrase: String) {
|
||||
fun register(
|
||||
handle: String,
|
||||
passphrase: String,
|
||||
) {
|
||||
val params = AccountKdf.Params.PBKDF2_V1
|
||||
val salt = ByteArray(params.saltBytes).also { SecureRandom().nextBytes(it) }
|
||||
val secret = AccountKdf.authSecret(passphrase, salt, params)
|
||||
post("accounts", JSONObject()
|
||||
post(
|
||||
"accounts",
|
||||
JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("authSecret", Wire.b64(secret))
|
||||
.put("kdfSalt", Wire.b64(salt))
|
||||
.put("kdfIterations", params.iterations))
|
||||
.put("kdfIterations", params.iterations),
|
||||
)
|
||||
}
|
||||
|
||||
private fun post(path: String, body: JSONObject): JSONObject = send(
|
||||
Request.Builder()
|
||||
private fun post(
|
||||
path: String,
|
||||
body: JSONObject,
|
||||
): JSONObject =
|
||||
send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(base.newBuilder().addPathSegments(path).build())
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Accept", "application/json")
|
||||
@@ -170,8 +175,12 @@ class AccountsClient(
|
||||
carriesCredentials = path == "sessions" || path == "accounts",
|
||||
)
|
||||
|
||||
private fun send(request: Request, carriesCredentials: Boolean = false): JSONObject {
|
||||
val response = try {
|
||||
private fun send(
|
||||
request: Request,
|
||||
carriesCredentials: Boolean = false,
|
||||
): JSONObject {
|
||||
val response =
|
||||
try {
|
||||
http.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw Failure.Transport(e.message ?: e.toString())
|
||||
@@ -179,49 +188,62 @@ class AccountsClient(
|
||||
response.use {
|
||||
val text = it.body?.string().orEmpty()
|
||||
when (it.code) {
|
||||
in 200..299 -> return if (text.isBlank()) JSONObject() else try {
|
||||
in 200..299 -> {
|
||||
return if (text.isBlank()) {
|
||||
JSONObject()
|
||||
} else {
|
||||
try {
|
||||
JSONObject(text)
|
||||
} catch (_: Exception) {
|
||||
throw Failure.Transport("unreadable response")
|
||||
}
|
||||
// The SAME status means different things depending on what
|
||||
// was sent. Rejecting a passphrase is BadCredentials; refusing
|
||||
// a token the user cannot see is an expired session.
|
||||
401, 403 ->
|
||||
throw if (carriesCredentials) Failure.BadCredentials
|
||||
else Failure.SessionExpired
|
||||
// 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 SAME status means different things depending on what was
|
||||
// sent.
|
||||
401, 403 -> {
|
||||
throw if (carriesCredentials) {
|
||||
Failure.BadCredentials
|
||||
} else {
|
||||
Failure.SessionExpired
|
||||
}
|
||||
}
|
||||
|
||||
// Read the relay's own code, not just the status.
|
||||
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 {
|
||||
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.
|
||||
* success or a crash.
|
||||
*/
|
||||
private fun failureFor(code: String?, status: Int): Failure = when (code) {
|
||||
private fun failureFor(
|
||||
code: String?,
|
||||
status: Int,
|
||||
): Failure =
|
||||
when (code) {
|
||||
"username_taken" -> Failure.HandleTaken
|
||||
"email_taken" -> Failure.EmailTaken
|
||||
"username_confusable" -> Failure.HandleConfusable
|
||||
|
||||
@@ -9,42 +9,39 @@ import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Talking to a chain node, for the wallet.
|
||||
*
|
||||
* The node is somebody else's server and it is not trusted with
|
||||
* anything. It is asked what a public address holds and handed transactions
|
||||
* that are already signed; it never sees a key, and it cannot move money. What
|
||||
* it CAN do is lie about a balance or withhold a history, so nothing here
|
||||
* treats an answer as authoritative beyond "this is what that node said".
|
||||
*
|
||||
* Which node somebody points at is a privacy decision, and the app has to
|
||||
* say so. Asking a public RPC endpoint for the balance of an address ties
|
||||
* that address to this device's IP, on somebody else's logs. Running your own
|
||||
* node is the only answer that removes it; the default exists so the wallet
|
||||
* works at all, not because it is private.
|
||||
*/
|
||||
class ChainRpc(
|
||||
val endpoint: String,
|
||||
private val http: OkHttpClient = OkHttpClient(),
|
||||
) {
|
||||
|
||||
sealed class Failure(message: String) : Exception(message) {
|
||||
sealed class Failure(
|
||||
message: String,
|
||||
) : Exception(message) {
|
||||
/** The transport refused before any JSON-RPC happened. */
|
||||
class Http(val code: Int) : Failure("The node answered with $code.")
|
||||
class Http(
|
||||
val code: Int,
|
||||
) : Failure("The node answered with $code.")
|
||||
|
||||
/**
|
||||
* The node's own words. It knows why it refused and we do not, so its
|
||||
* message is carried rather than replaced with a guess.
|
||||
* The node's own words.
|
||||
*/
|
||||
class Node(val detail: String) : Failure(detail)
|
||||
class Node(
|
||||
val detail: String,
|
||||
) : Failure(detail)
|
||||
|
||||
class Malformed(val what: String) :
|
||||
Failure("That node's answer wasn't something this app understands.")
|
||||
class Malformed(
|
||||
val what: String,
|
||||
) : Failure("That node's answer wasn't something this app understands.")
|
||||
}
|
||||
|
||||
// ---- Wire ------------------------------------------------------------
|
||||
// ---- Wire ------------------------------------------------------------.
|
||||
|
||||
private fun call(method: String, params: JSONArray): Any {
|
||||
val body = JSONObject()
|
||||
private fun call(
|
||||
method: String,
|
||||
params: JSONArray,
|
||||
): Any {
|
||||
val body =
|
||||
JSONObject()
|
||||
.put("jsonrpc", "2.0")
|
||||
.put("id", 1)
|
||||
.put("method", method)
|
||||
@@ -52,20 +49,23 @@ class ChainRpc(
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder().url(endpoint).post(body).build()
|
||||
val text = http.newCall(request).execute().use { response ->
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(endpoint)
|
||||
.post(body)
|
||||
.build()
|
||||
val text =
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw Failure.Http(response.code)
|
||||
response.body?.string().orEmpty()
|
||||
}
|
||||
|
||||
val json = runCatching { JSONObject(text) }.getOrNull()
|
||||
val json =
|
||||
runCatching { JSONObject(text) }.getOrNull()
|
||||
?: throw Failure.Malformed("not an object")
|
||||
|
||||
// A JSON-RPC error arrives with HTTP 200. Reading only the status
|
||||
// code takes a refusal for an answer, and then something fails further
|
||||
// down with the node's own explanation already thrown away, which is
|
||||
// the difference between "insufficient funds for rent" on screen and a
|
||||
// parse error nobody can act on.
|
||||
// A JSON-RPC error arrives with HTTP 200.
|
||||
json.optJSONObject("error")?.let { error ->
|
||||
throw Failure.Node(error.optString("message").ifEmpty { "The node refused." })
|
||||
}
|
||||
@@ -73,14 +73,10 @@ class ChainRpc(
|
||||
return json.get("result")
|
||||
}
|
||||
|
||||
// ---- Reading ---------------------------------------------------------
|
||||
// ---- Reading ---------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The balance in lamports, the chain's smallest unit.
|
||||
*
|
||||
* Lamports, not SOL, all the way to the edge of the UI. Converting early
|
||||
* means carrying a fractional value through code that compares and
|
||||
* subtracts, and a rounding error in a balance is money.
|
||||
*/
|
||||
fun balance(address: String): Long {
|
||||
val result = call("getBalance", JSONArray().put(address))
|
||||
@@ -90,8 +86,12 @@ class ChainRpc(
|
||||
}
|
||||
|
||||
/** What has happened to an address, newest first. */
|
||||
fun history(address: String, limit: Int = 25): List<Entry> {
|
||||
val result = call(
|
||||
fun history(
|
||||
address: String,
|
||||
limit: Int = 25,
|
||||
): List<Entry> {
|
||||
val result =
|
||||
call(
|
||||
"getSignaturesForAddress",
|
||||
JSONArray().put(address).put(JSONObject().put("limit", limit)),
|
||||
)
|
||||
@@ -101,34 +101,33 @@ class ChainRpc(
|
||||
|
||||
/**
|
||||
* One line of an address's history.
|
||||
*
|
||||
* A FAILED transaction still appears in an address's history. Not
|
||||
* showing that it failed would be the worst kind of wrong: somebody would
|
||||
* believe a payment went through.
|
||||
*/
|
||||
data class Entry(
|
||||
val signature: String,
|
||||
val slot: Long,
|
||||
/**
|
||||
* Absent while a transaction is very new, the node has it but has not
|
||||
* dated it yet. Null means "just now", never 1970.
|
||||
* dated it yet.
|
||||
*/
|
||||
val blockTimeSeconds: Long?,
|
||||
val failed: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
fun from(json: JSONObject?): Entry? {
|
||||
val signature = json?.optString("signature")?.takeIf { it.isNotEmpty() }
|
||||
val signature =
|
||||
json?.optString("signature")?.takeIf { it.isNotEmpty() }
|
||||
?: return null
|
||||
return Entry(
|
||||
signature = signature,
|
||||
slot = json.optLong("slot", 0),
|
||||
blockTimeSeconds = if (json.isNull("blockTime")) null
|
||||
else json.optLong("blockTime"),
|
||||
// `err` is an object or an array depending on the
|
||||
// failure, and `null` when there was none. Testing for
|
||||
// "not null" rather than for a shape is what makes both
|
||||
// kinds of failure visible.
|
||||
blockTimeSeconds =
|
||||
if (json.isNull("blockTime")) {
|
||||
null
|
||||
} else {
|
||||
json.optLong("blockTime")
|
||||
},
|
||||
// `err` is an object or an array depending on the failure,
|
||||
// and `null` when there was none.
|
||||
failed = !json.isNull("err"),
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,10 @@
|
||||
package io.flamenet.client
|
||||
|
||||
/**
|
||||
* What the client already knows about delivery, kept where a person can read
|
||||
* it (ios#21, ios#23, the Android half).
|
||||
*
|
||||
* The client has always known this; it only ever told the log. Every
|
||||
* refusal went to `System.err` and nowhere else, so a send blocked by the
|
||||
* identity guard reached the person sending it as "Something went wrong
|
||||
* reaching that server", which was neither true nor actionable.
|
||||
*
|
||||
* Shape only, never content. Counts, reasons and timestamps. No message
|
||||
* text, no key material, no payloads, and no identifiers, see
|
||||
* [normalisedReason]. A diagnostic reading that leaks what it is diagnosing is
|
||||
* worse than none, and this one is meant to be safe to screenshot.
|
||||
*
|
||||
* In memory, deliberately. It describes the session you are in; persisting
|
||||
* it would be a growing record of who you talked to and when, which is the
|
||||
* metadata this project spends its effort not keeping.
|
||||
* What the client already knows about delivery, kept where a person can read it
|
||||
* (ios#21, ios#23, the Android half).
|
||||
*/
|
||||
object DeliveryDiagnostics {
|
||||
|
||||
data class Peer(
|
||||
var envelopesSeen: Int = 0,
|
||||
var delivered: Int = 0,
|
||||
@@ -33,9 +18,7 @@ object DeliveryDiagnostics {
|
||||
val dropTotal: Int get() = drops.values.sum()
|
||||
|
||||
/**
|
||||
* A client that repeatedly repairs is a symptom, not a cure. One is
|
||||
* ordinary, a contact reinstalled. Three against the same peer means
|
||||
* something is not settling, and that is worth saying out loud.
|
||||
* A client that repeatedly repairs is a symptom, not a cure.
|
||||
*/
|
||||
val repairsLookAbnormal: Boolean get() = repairs >= 3
|
||||
}
|
||||
@@ -54,27 +37,39 @@ object DeliveryDiagnostics {
|
||||
peers.getOrPut(from) { Peer() }.delivered++
|
||||
}
|
||||
|
||||
@Synchronized fun dropped(reason: String, from: Long) {
|
||||
@Synchronized fun dropped(
|
||||
reason: String,
|
||||
from: Long,
|
||||
) {
|
||||
val p = peers.getOrPut(from) { Peer() }
|
||||
val slug = normalisedReason(reason)
|
||||
p.drops[slug] = (p.drops[slug] ?: 0) + 1
|
||||
}
|
||||
|
||||
@Synchronized fun repaired(userId: Long, at: Long = System.currentTimeMillis()) {
|
||||
@Synchronized fun repaired(
|
||||
userId: Long,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val p = peers.getOrPut(userId) { Peer() }
|
||||
p.repairs++
|
||||
p.lastRepairAt = at
|
||||
}
|
||||
|
||||
@Synchronized fun sendFailed(reason: String, to: Long,
|
||||
at: Long = System.currentTimeMillis()) {
|
||||
@Synchronized fun sendFailed(
|
||||
reason: String,
|
||||
to: Long,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val p = peers.getOrPut(to) { Peer() }
|
||||
p.lastSendFailure = reason
|
||||
p.lastSendFailureAt = at
|
||||
}
|
||||
|
||||
@Synchronized fun clearSendFailure(to: Long) {
|
||||
peers[to]?.let { it.lastSendFailure = null; it.lastSendFailureAt = 0L }
|
||||
peers[to]?.let {
|
||||
it.lastSendFailure = null
|
||||
it.lastSendFailureAt = 0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Signing out ends the session this describes. */
|
||||
@@ -82,16 +77,6 @@ object DeliveryDiagnostics {
|
||||
|
||||
/**
|
||||
* Strip anything identifying before a reason is counted.
|
||||
*
|
||||
* Refusal messages name the thing that was wrong, which sometimes means
|
||||
* naming a device id, "device … has published keys, but none match".
|
||||
* A log may carry that; a screen the user is invited to screenshot may not.
|
||||
* Runs of digits and hex become "…".
|
||||
*
|
||||
* Short hex ids are the ones that slip through: `9f0e` is neither a long
|
||||
* hex run nor an all-digit run. Hence "four or more hex characters
|
||||
* containing at least one digit", which catches truncated device ids
|
||||
* without eating ordinary words like `base64`.
|
||||
*/
|
||||
fun normalisedReason(reason: String): String =
|
||||
reason
|
||||
|
||||
@@ -9,46 +9,11 @@ import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* What the time is, when the device's own answer cannot be trusted (ios#45).
|
||||
*
|
||||
* A wrong device clock breaks a promise made to somebody else. Almost
|
||||
* everything time-dependent here is a commitment: a story disappears after an
|
||||
* hour, a message clears itself after a day. Those are promises the SENDER
|
||||
* made, and they are enforced on the READER's device, so a reader whose clock
|
||||
* is running behind keeps a message the sender was told would be gone, and
|
||||
* nobody involved can tell.
|
||||
*
|
||||
* That asymmetry decides the whole design. Being wrong in one direction costs
|
||||
* the reader something they were going to lose anyway; being wrong in the other
|
||||
* direction breaks somebody else's expectation silently. So:
|
||||
*
|
||||
* For expiry, take the LATEST plausible time. Never the device's alone,
|
||||
* never the server's alone, whichever is further ahead. A device clock set back
|
||||
* cannot extend a message's life, and neither can a relay that lies about the
|
||||
* time, because the other source overrules it. The cost is that a device clock
|
||||
* set FORWARD expires things early, which is the failure worth having.
|
||||
*
|
||||
* The relay is a hint, not an authority. It is told the time by nobody we
|
||||
* trust, and a hostile one would like to move time backwards so that
|
||||
* disappearing messages linger. Taking the later of the two is exactly what
|
||||
* stops that being useful to it.
|
||||
*
|
||||
* Process-wide and synchronised, not a singleton object with plain vars.
|
||||
* The Swift version can be `nonisolated(unsafe) static var` because the places
|
||||
* that write it are on one actor; here the observation is written from whatever
|
||||
* OkHttp thread finished a call and read from the UI thread, so the fields are
|
||||
* `@Volatile` and the pair is updated under a lock. A torn read of "skew from
|
||||
* now, timestamp from an hour ago" would age out an observation that had just
|
||||
* been made, or trust one that had not.
|
||||
*/
|
||||
object DeviceClock {
|
||||
|
||||
/**
|
||||
* Difference between the last server time seen and this device's clock, in
|
||||
* milliseconds. Positive means the device is BEHIND.
|
||||
*
|
||||
* Not persisted. A skew learned yesterday says nothing about today, the
|
||||
* clock may have been corrected, or the phone rebooted with a fresh answer
|
||||
* from the network.
|
||||
* milliseconds.
|
||||
*/
|
||||
@Volatile
|
||||
private var skewMillis: Long? = null
|
||||
@@ -61,10 +26,6 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* How far out the clock has to be before it is worth saying anything.
|
||||
*
|
||||
* Five minutes. Ordinary drift is seconds; a phone whose clock is out by
|
||||
* more than this will have visible symptoms, stories that vanish, messages
|
||||
* that will not send, and being told why is better than guessing.
|
||||
*/
|
||||
const val SUSPECT_THRESHOLD_MS = 300_000L
|
||||
|
||||
@@ -73,11 +34,11 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* Learn the skew from a response's `Date` header.
|
||||
*
|
||||
* Free: every HTTP response already carries one, so this costs no extra
|
||||
* request and no extra round trip.
|
||||
*/
|
||||
fun observe(serverMillis: Long, deviceNow: Long = System.currentTimeMillis()) {
|
||||
fun observe(
|
||||
serverMillis: Long,
|
||||
deviceNow: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
synchronized(lock) {
|
||||
skewMillis = serverMillis - deviceNow
|
||||
observedAt = deviceNow
|
||||
@@ -85,7 +46,10 @@ object DeviceClock {
|
||||
}
|
||||
|
||||
/** Parse and record a `Date` header, if there is a usable one. */
|
||||
fun observe(header: String?, deviceNow: Long = System.currentTimeMillis()): Boolean {
|
||||
fun observe(
|
||||
header: String?,
|
||||
deviceNow: Long = System.currentTimeMillis(),
|
||||
): Boolean {
|
||||
val parsed = header?.let { parseHttpDate(it) } ?: return false
|
||||
observe(parsed, deviceNow)
|
||||
return true
|
||||
@@ -100,7 +64,8 @@ object DeviceClock {
|
||||
}
|
||||
|
||||
/** The skew, if it is recent enough to mean anything. */
|
||||
fun usableSkew(now: Long = System.currentTimeMillis()): Long? = synchronized(lock) {
|
||||
fun usableSkew(now: Long = System.currentTimeMillis()): Long? =
|
||||
synchronized(lock) {
|
||||
val skew = skewMillis ?: return null
|
||||
val at = observedAt ?: return null
|
||||
if (now - at >= OBSERVATION_LIFETIME_MS) return null
|
||||
@@ -115,10 +80,6 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* What to say about it, or null when there is nothing to say.
|
||||
*
|
||||
* Names the symptom before the cause. Somebody whose stories keep
|
||||
* vanishing does not think "my clock is wrong"; they think the app is
|
||||
* broken.
|
||||
*/
|
||||
fun warning(now: Long = System.currentTimeMillis()): String? {
|
||||
val skew = usableSkew(now) ?: return null
|
||||
@@ -133,14 +94,6 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* The time to judge an expiry against, in milliseconds.
|
||||
*
|
||||
* The later of the device's clock and the server's. Neither alone: a
|
||||
* device set back would keep a disappearing message past the moment its
|
||||
* sender was told it would go, and a relay that lied about the time could
|
||||
* arrange the same thing. Taking whichever is further ahead means both have
|
||||
* to be wrong in the same direction to extend anything, and the remaining
|
||||
* failure, expiring early, costs the reader something they were about to
|
||||
* lose anyway.
|
||||
*/
|
||||
fun nowForExpiry(deviceNow: Long = System.currentTimeMillis()): Long {
|
||||
val skew = usableSkew(deviceNow) ?: return deviceNow
|
||||
@@ -149,21 +102,11 @@ object DeviceClock {
|
||||
|
||||
/**
|
||||
* The same in seconds, which is how the vault stores expiries.
|
||||
*
|
||||
* A Double of seconds, matching `Vault.Story.expires`, converting to a
|
||||
* Long of seconds here would floor away sub-second precision on a boundary
|
||||
* that decides whether a story is still on screen.
|
||||
*/
|
||||
fun expirySeconds(deviceNow: Long = System.currentTimeMillis()): Double =
|
||||
nowForExpiry(deviceNow) / 1000.0
|
||||
fun expirySeconds(deviceNow: Long = System.currentTimeMillis()): Double = nowForExpiry(deviceNow) / 1000.0
|
||||
|
||||
/**
|
||||
* RFC 7231 `Date`, which is what an HTTP header carries.
|
||||
*
|
||||
* Fixed locale and zone. A formatter that picked those up from the device
|
||||
* parses nothing on a phone set to most of the world, and a `SimpleDateFormat`
|
||||
* is not thread-safe, so one is built per call rather than shared. This runs
|
||||
* once per HTTP response, which is nowhere near hot enough to care.
|
||||
*/
|
||||
private fun parseHttpDate(header: String): Long? {
|
||||
val formatter = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.Content
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Fetches a preview for a link the user is about to send (spec §6.12).
|
||||
*
|
||||
* This runs on the SENDER's device, and the result travels inside the
|
||||
* ciphertext. The obvious implementation, each recipient fetching the URL to
|
||||
* draw a card, is the one that must not be built: it tells the site the
|
||||
* reader's IP address, that they received a link, and roughly when they opened
|
||||
* it, once per member on a group message. A messenger that hides the message
|
||||
* and then has every reader announce its contents to a third party has given
|
||||
* away what it was protecting.
|
||||
*
|
||||
* Sender-side is a smaller leak, not no leak: the site learns somebody is
|
||||
* about to share that URL, from this address, before the message is sent. That
|
||||
* is what the setting is for.
|
||||
*/
|
||||
object LinkPreviewFetcher {
|
||||
|
||||
/** A short timeout: a preview is a decoration and must never delay a send. */
|
||||
private val http = OkHttpClient.Builder()
|
||||
private val http =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(4, TimeUnit.SECONDS)
|
||||
.readTimeout(4, TimeUnit.SECONDS)
|
||||
.callTimeout(6, TimeUnit.SECONDS)
|
||||
@@ -38,23 +27,26 @@ object LinkPreviewFetcher {
|
||||
fun firstUrl(text: String): String? = URL_RE.find(text)?.value?.trimEnd('.', ',', ')')
|
||||
|
||||
/**
|
||||
* Fetch and parse Open Graph tags. Null for anything unusual, no title, a
|
||||
* refusal, a timeout, each of which leaves a message with a plain URL in
|
||||
* it, which is a perfectly good thing to be.
|
||||
* Fetch and parse Open Graph tags.
|
||||
*/
|
||||
fun fetch(url: String): Content.LinkPreview? = runCatching {
|
||||
val request = Request.Builder()
|
||||
fun fetch(url: String): Content.LinkPreview? =
|
||||
runCatching {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "text/html")
|
||||
// Identifies as a link preview rather than impersonating a browser:
|
||||
// Identifies as a link preview rather than impersonating a browser.
|
||||
// a site that wants to refuse these should be able to.
|
||||
.header("User-Agent", "FlamenetLinkPreview/1.0")
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val head = response.body?.source()?.let { source ->
|
||||
val head =
|
||||
response.body?.source()?.let { source ->
|
||||
source.request(MAX_BYTES.toLong())
|
||||
source.buffer.snapshot(minOf(MAX_BYTES.toLong(), source.buffer.size).toInt())
|
||||
source.buffer
|
||||
.snapshot(minOf(MAX_BYTES.toLong(), source.buffer.size).toInt())
|
||||
.utf8()
|
||||
} ?: return null
|
||||
|
||||
@@ -64,32 +56,48 @@ object LinkPreviewFetcher {
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
// Regex over a bounded prefix, deliberately. A full HTML parser for two
|
||||
// attributes is a dependency and an attack surface; this reads what it
|
||||
// recognises and gives up otherwise, which is the correct failure here.
|
||||
// Regex over a bounded prefix, deliberately.
|
||||
|
||||
private fun meta(html: String, property: String): String? =
|
||||
Regex("""<meta[^>]+property=["']${Regex.escape(property)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
?: Regex("""<meta[^>]+content=["']([^"']*)["'][^>]+property=["']${Regex.escape(property)}["']""",
|
||||
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
private fun meta(
|
||||
html: String,
|
||||
property: String,
|
||||
): String? =
|
||||
Regex(
|
||||
"""<meta[^>]+property=["']${Regex.escape(property)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
?: Regex(
|
||||
"""<meta[^>]+content=["']([^"']*)["'][^>]+property=["']${Regex.escape(property)}["']""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
|
||||
private fun metaName(html: String, name: String): String? =
|
||||
Regex("""<meta[^>]+name=["']${Regex.escape(name)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
private fun metaName(
|
||||
html: String,
|
||||
name: String,
|
||||
): String? =
|
||||
Regex(
|
||||
"""<meta[^>]+name=["']${Regex.escape(name)}["'][^>]+content=["']([^"']*)["']""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
|
||||
private fun titleTag(html: String): String? =
|
||||
Regex("""<title[^>]*>([^<]*)</title>""", RegexOption.IGNORE_CASE)
|
||||
.find(html)?.groupValues?.get(1)?.let(::clean)
|
||||
.find(html)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.let(::clean)
|
||||
|
||||
/**
|
||||
* The handful of entities that actually appear in titles. Anything else is
|
||||
* left alone, a literal `©` is a cosmetic flaw, and expanding
|
||||
* arbitrary entities is how a parser grows teeth.
|
||||
* The handful of entities that actually appear in titles.
|
||||
*/
|
||||
private fun clean(s: String): String = s
|
||||
.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace(""", "\"").replace("'", "'").replace("'", "'")
|
||||
private fun clean(s: String): String =
|
||||
s
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() } ?: ""
|
||||
|
||||
@@ -9,6 +9,8 @@ import io.flamenet.e2e.FilesKdf
|
||||
import io.flamenet.e2e.Hkdf
|
||||
import io.flamenet.e2e.Label
|
||||
import io.flamenet.e2e.Random
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
@@ -16,42 +18,9 @@ import java.util.Base64
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Files that live on this phone.
|
||||
*
|
||||
* The server is not storage any more. It is a courier: it holds
|
||||
* ciphertext for as long as it takes to reach the other device, and then it does
|
||||
* not. Everything a person owns is here, in this app's own container, and a
|
||||
* phone that never syncs still has all of it.
|
||||
*
|
||||
* What that costs is worth stating plainly, because it changed and it changed
|
||||
* silently everywhere else:
|
||||
*
|
||||
* Read-down is no longer enforced by anything that can refuse. When the
|
||||
* rows lived in Postgres, `session_dominates(classification, compartments)` was
|
||||
* an RLS predicate, the database itself would not return a row above your
|
||||
* clearance, whatever the client asked for. There is no database here. A label
|
||||
* still gates *decryption*, absolutely: without the label key the bytes are
|
||||
* bytes. But nothing stops a sender transferring a file above a recipient's
|
||||
* level, and the clearance shown in the interface now means "the keys this
|
||||
* device holds", not "what a server will show you". That is a deliberate trade,
|
||||
* and the interface must not keep using the old words for the new thing.
|
||||
*
|
||||
* The index file is device-local and is NOT an interchange format. The
|
||||
* node *fields* match iOS's exactly, deliberately, so that a courier transfer is
|
||||
* a copy rather than a translation. The JSON around them is not: iOS encodes
|
||||
* dates as Swift's reference-date doubles, which is an accident of `JSONEncoder`
|
||||
* rather than a decision, and pinning Kotlin to it would make an accident into a
|
||||
* format. Anybody building vault migration between phones should move nodes
|
||||
* through a declared wire shape, not by copying this file.
|
||||
*
|
||||
* Synchronous, and it touches the disk. Call it off the main thread. The
|
||||
* lock below makes concurrent calls safe, not fast, a `ReentrantLock` rather
|
||||
* than a coroutine `Mutex` because this module deliberately has no coroutines
|
||||
* dependency, which is what lets its tests run without an emulator.
|
||||
*/
|
||||
class LocalVault(
|
||||
private val root: File,
|
||||
@@ -59,60 +28,39 @@ class LocalVault(
|
||||
private val identityRawPrivate: ByteArray,
|
||||
/**
|
||||
* How a referenced file is reached.
|
||||
*
|
||||
* Required, with no default. A no-op default would make every reference
|
||||
* row read as "missing" in an app that simply forgot to wire this up, a
|
||||
* vault that looks like it has lost half its contents, with nothing
|
||||
* anywhere saying why.
|
||||
*/
|
||||
private val references: ReferenceResolver,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Where a row's bytes actually are.
|
||||
*
|
||||
* The two are not equally protected and the interface must not present
|
||||
* them as if they were.
|
||||
*
|
||||
* - [VAULT]: copied in and encrypted under the label key. A stolen backup or
|
||||
* container yields ciphertext, and the classification is a control.
|
||||
* - [REFERENCE]: a pointer to a file that stays where it is on the phone,
|
||||
* encrypted only at the moment it is sent. Nothing is duplicated, and the
|
||||
* file keeps whatever protection the platform gives it, but we are not
|
||||
* adding any, the label is advisory until it is sent, and the row breaks
|
||||
* if the file is moved or deleted.
|
||||
*/
|
||||
enum class Storage(val wire: String) {
|
||||
enum class Storage(
|
||||
val wire: String,
|
||||
) {
|
||||
VAULT("vault"),
|
||||
REFERENCE("reference");
|
||||
REFERENCE("reference"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Absence means [VAULT], and that is not a nicety. iOS shipped a
|
||||
* `storage` field with a Swift default and discovered that a default
|
||||
* does not make a `Codable` key optional, every index written
|
||||
* before referencing existed failed to decode, which presents as a
|
||||
* vault that has lost everything. Reading an unknown or missing
|
||||
* value as `vault` is what those rows actually are.
|
||||
* `storage` field with a Swift default and discovered that a
|
||||
* default does not make a `Codable` key optional, every index
|
||||
* written before referencing existed failed to decode, which
|
||||
* presents as a vault that has lost everything.
|
||||
*/
|
||||
fun parse(raw: String?): Storage =
|
||||
entries.firstOrNull { it.wire == raw } ?: VAULT
|
||||
fun parse(raw: String?): Storage = entries.firstOrNull { it.wire == raw } ?: VAULT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a referenced file is reached again after a relaunch.
|
||||
*
|
||||
* The [bookmark] is opaque here on purpose. On Android it is a Storage
|
||||
* Access Framework document URI whose read permission was persisted; on iOS
|
||||
* it is a security-scoped bookmark. Neither belongs in this module, which
|
||||
* has no platform in it.
|
||||
*/
|
||||
interface ReferenceResolver {
|
||||
/**
|
||||
* Whether the file is still there, asked separately from reading it,
|
||||
* because a row that silently does nothing when tapped is worse than one
|
||||
* that says the file has moved.
|
||||
* because a row that silently does nothing when tapped is worse than
|
||||
* one that says the file has moved.
|
||||
*/
|
||||
fun exists(bookmark: String): Boolean
|
||||
|
||||
@@ -148,19 +96,6 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* Every field, with the byte arrays compared by CONTENT.
|
||||
*
|
||||
* This was `other.id == id`, written to dodge the fact that a data
|
||||
* class compares `ByteArray` by identity, and wrong in a way that broke
|
||||
* a whole feature. Promoting a link to the vault keeps the node's id and
|
||||
* changes its `storage`, so the new row compared EQUAL to the old one:
|
||||
* the state object it was in compared equal too, `StateFlow` conflated
|
||||
* the update away, and Compose never saw it. On screen the file vanished
|
||||
* from Linked and did not appear in the vault until the app was
|
||||
* restarted, which reads as losing somebody's file.
|
||||
*
|
||||
* The identity-vs-content trap is real, so it is handled rather than
|
||||
* avoided: `contentEquals` for the four byte arrays, ordinary equality
|
||||
* for the rest.
|
||||
*/
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is Node &&
|
||||
@@ -172,8 +107,10 @@ class LocalVault(
|
||||
other.wrappedNodeKey.contentEquals(wrappedNodeKey) &&
|
||||
other.nameDigest.contentEquals(nameDigest) &&
|
||||
other.wrappedName.contentEquals(wrappedName) &&
|
||||
(other.wrappedContentKey?.contentEquals(wrappedContentKey ?: ByteArray(0))
|
||||
?: (wrappedContentKey == null)) &&
|
||||
(
|
||||
other.wrappedContentKey?.contentEquals(wrappedContentKey ?: ByteArray(0))
|
||||
?: (wrappedContentKey == null)
|
||||
) &&
|
||||
other.byteCount == byteCount &&
|
||||
other.addedAtMillis == addedAtMillis &&
|
||||
other.storage == storage &&
|
||||
@@ -203,7 +140,6 @@ class LocalVault(
|
||||
val name: String?,
|
||||
/**
|
||||
* True when this is a reference whose file is no longer where it was.
|
||||
* Surfaced rather than hidden.
|
||||
*/
|
||||
val isMissing: Boolean,
|
||||
) {
|
||||
@@ -223,32 +159,25 @@ class LocalVault(
|
||||
val isCompletelyDone: Boolean get() = keptNonEmpty.isEmpty()
|
||||
}
|
||||
|
||||
class VaultException(message: String) : Exception(message)
|
||||
class VaultException(
|
||||
message: String,
|
||||
) : Exception(message)
|
||||
|
||||
private val lock = ReentrantLock()
|
||||
private var nodes = mutableListOf<Node>()
|
||||
|
||||
private val indexFile: File get() = File(root, "index.json")
|
||||
private val blobs: File get() = File(root, "blobs")
|
||||
|
||||
private fun blobFile(id: UUID) = File(blobs, id.toString())
|
||||
|
||||
// ---- Keys ------------------------------------------------------------
|
||||
// ---- Keys ------------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* The key for a label, derived rather than fetched.
|
||||
*
|
||||
* Deterministic from the identity key, so every device holding the
|
||||
* identity derives the same one and a vault restored from a courier transfer
|
||||
* opens without a grant round-trip. Sharing an area means sending this
|
||||
* value, wrapped to the recipient, the same grant the server used to
|
||||
* broker, minus the broker.
|
||||
*
|
||||
* Binds the whole canonical label encoding, not just the
|
||||
* classification. Deriving from the level alone would give one key to
|
||||
* every compartment at that level, which is precisely the separation
|
||||
* compartments exist for.
|
||||
*/
|
||||
internal fun labelKey(label: Label): ByteArray = Hkdf.derive(
|
||||
internal fun labelKey(label: Label): ByteArray =
|
||||
Hkdf.derive(
|
||||
ikm = identityRawPrivate,
|
||||
salt = label.bytes(),
|
||||
info = FilesKdf.Context.LOCAL_LABEL.toByteArray(Charsets.UTF_8),
|
||||
@@ -257,14 +186,9 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* The name, or null when this device cannot read the row.
|
||||
*
|
||||
* On a local vault every label the identity can derive is readable, so
|
||||
* this is non-null for anything this device filed itself. It exists for the
|
||||
* case that is not that: a node that arrived from somebody else, filed under
|
||||
* a label whose key was never granted. Those decrypt to null and the row
|
||||
* says so.
|
||||
*/
|
||||
private fun nameOf(node: Node): String? = try {
|
||||
private fun nameOf(node: Node): String? =
|
||||
try {
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
FileNames.decrypt(nodeKey, node.wrappedName, node.nameDigest, node.label)
|
||||
} catch (e: Aead.DecryptionFailed) {
|
||||
@@ -273,9 +197,10 @@ class LocalVault(
|
||||
null
|
||||
}
|
||||
|
||||
// ---- Storage ---------------------------------------------------------
|
||||
// ---- Storage ---------------------------------------------------------.
|
||||
|
||||
fun load() = lock.withLock {
|
||||
fun load() =
|
||||
lock.withLock {
|
||||
blobs.mkdirs()
|
||||
val text = if (indexFile.exists()) indexFile.readText() else return@withLock
|
||||
val root = JSONObject(text)
|
||||
@@ -284,9 +209,7 @@ class LocalVault(
|
||||
}
|
||||
|
||||
/**
|
||||
* Written to a temporary file and moved into place. An index truncated by
|
||||
* a crash mid-write is a vault that has lost every file at once, and the
|
||||
* blobs would still be sitting there, unreferenced, taking up the space.
|
||||
* Written to a temporary file and moved into place.
|
||||
*/
|
||||
private fun save() {
|
||||
root.mkdirs()
|
||||
@@ -295,18 +218,22 @@ class LocalVault(
|
||||
JSONObject()
|
||||
.put("version", 1)
|
||||
.put("nodes", JSONArray().also { array -> nodes.forEach { array.put(encode(it)) } })
|
||||
.toString()
|
||||
.toString(),
|
||||
)
|
||||
Files.move(
|
||||
temporary.toPath(), indexFile.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
|
||||
temporary.toPath(),
|
||||
indexFile.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun b64(bytes: ByteArray) = Base64.getEncoder().encodeToString(bytes)
|
||||
|
||||
private fun unb64(text: String) = Base64.getDecoder().decode(text)
|
||||
|
||||
private fun encode(node: Node): JSONObject = JSONObject()
|
||||
private fun encode(node: Node): JSONObject =
|
||||
JSONObject()
|
||||
.put("id", node.id.toString())
|
||||
.put("parentID", node.parentId?.toString() ?: JSONObject.NULL)
|
||||
.put("isFolder", node.isFolder)
|
||||
@@ -322,32 +249,13 @@ class LocalVault(
|
||||
.put("bookmark", node.bookmark ?: JSONObject.NULL)
|
||||
|
||||
/**
|
||||
* `optString` is not safe on a nullable field, and the JVM will not
|
||||
* tell you. Android's bundled `org.json` coerces a JSON `null` to the
|
||||
* four-character string `"null"`; the Maven `org.json` these tests run
|
||||
* against returns `""`. Every nullable field below therefore passed on a
|
||||
* laptop and broke on a phone:
|
||||
*
|
||||
* - `parentID` threw `Invalid UUID string: null`, so a vault holding any
|
||||
* top-level file failed to load AT ALL. Adding the first file worked,
|
||||
* because it was already in memory; reopening the app lost everything,
|
||||
* with an error nobody could act on.
|
||||
* - `wrappedContentKey` was worse, because it did NOT throw: `"null"` is
|
||||
* valid base64, so the node came back with a plausible three-byte content
|
||||
* key and simply refused to decrypt.
|
||||
* - `bookmark` produced a reference pointing at `"null"`, permanently
|
||||
* missing.
|
||||
*
|
||||
* So absence is decided by [JSONObject.isNull], never by comparing a
|
||||
* decoded string to a sentinel. The literal `"null"` is rejected as well:
|
||||
* it is not a valid value for any of these fields, and tolerating it costs
|
||||
* nothing next to reintroducing this.
|
||||
*
|
||||
* Found by running the app on a device (android!52). No JVM test could have
|
||||
* caught it, which is the argument for the device run, not against the
|
||||
* tests.
|
||||
* `optString` is not safe on a nullable field, and the JVM will not tell
|
||||
* you.
|
||||
*/
|
||||
private fun optional(o: JSONObject, key: String): String? {
|
||||
private fun optional(
|
||||
o: JSONObject,
|
||||
key: String,
|
||||
): String? {
|
||||
if (o.isNull(key)) return null
|
||||
val value = o.optString(key, "")
|
||||
return value.takeIf { it.isNotEmpty() && it != "null" }
|
||||
@@ -372,17 +280,19 @@ class LocalVault(
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Reading ---------------------------------------------------------
|
||||
// ---- Reading ---------------------------------------------------------.
|
||||
|
||||
fun list(parentId: UUID?): List<Entry> = lock.withLock {
|
||||
nodes.filter { it.parentId == parentId }
|
||||
fun list(parentId: UUID?): List<Entry> =
|
||||
lock.withLock {
|
||||
nodes
|
||||
.filter { it.parentId == parentId }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.sortedWith(
|
||||
// Folders first, then by name; unreadable rows last, where they
|
||||
// are least in the way of the ones you can act on.
|
||||
compareByDescending<Entry> { it.isFolder }
|
||||
.thenBy { it.name == null }
|
||||
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" }
|
||||
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -396,16 +306,9 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* Every id in a subtree, so a screen can offer "and everything in it".
|
||||
*
|
||||
* Bounded by what it has already seen, not by a step counter. A cycle
|
||||
* cannot be built through this API, but the index is a file on disk and
|
||||
* something else could write one, so this has to terminate. iOS bounds it
|
||||
* with `guardRail < 10_000`, which terminates and then hands back ten
|
||||
* thousand copies of the same two ids: a screen offering to delete a folder
|
||||
* "and everything in it" would say 10,000 things. Refusing to revisit a node
|
||||
* both terminates AND returns the right answer, and needs no magic number.
|
||||
*/
|
||||
fun descendants(of: UUID): List<UUID> = lock.withLock {
|
||||
fun descendants(of: UUID): List<UUID> =
|
||||
lock.withLock {
|
||||
val found = mutableListOf<UUID>()
|
||||
val seen = mutableSetOf(of)
|
||||
val frontier = ArrayDeque(listOf(of))
|
||||
@@ -423,20 +326,11 @@ class LocalVault(
|
||||
/**
|
||||
* Every file in the whole tree, for a picker that has no business showing
|
||||
* folders.
|
||||
*
|
||||
* The whole tree, not the top of it. iOS's picker lists `list(nil)`,
|
||||
* so a document filed into a folder cannot be sent from a conversation at
|
||||
* all, and nothing says so; the file is simply absent from the list, which
|
||||
* reads as "it isn't there". Filed as ios#68.
|
||||
*
|
||||
* Unreadable and missing rows are dropped here rather than shown
|
||||
* disabled. Everywhere else this class insists on showing a row you cannot
|
||||
* open, because hiding it would leave somebody unable to see that anything
|
||||
* is there. A picker is the exception: its only purpose is choosing
|
||||
* something to send, and a row that can never be chosen is furniture.
|
||||
*/
|
||||
fun allFiles(): List<Entry> = lock.withLock {
|
||||
nodes.filter { !it.isFolder }
|
||||
fun allFiles(): List<Entry> =
|
||||
lock.withLock {
|
||||
nodes
|
||||
.filter { !it.isFolder }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.filter { it.name != null && !it.isMissing }
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" })
|
||||
@@ -450,13 +344,13 @@ class LocalVault(
|
||||
if (node.storage == Storage.REFERENCE) {
|
||||
val bookmark = node.bookmark
|
||||
if (bookmark == null || !references.exists(bookmark)) {
|
||||
throw VaultException(
|
||||
"That file isn't where it was, it may have been moved or deleted.")
|
||||
throw VaultException("That file isn't where it was, it may have been moved or deleted.")
|
||||
}
|
||||
return references.read(bookmark)
|
||||
}
|
||||
|
||||
val wrappedContentKey = node.wrappedContentKey
|
||||
val wrappedContentKey =
|
||||
node.wrappedContentKey
|
||||
?: throw VaultException("That file has no contents.")
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
val contentKey = FileKeys.unwrapContentKey(wrappedContentKey, nodeKey, node.label)
|
||||
@@ -465,43 +359,49 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* The bytes, encrypted for sending, whichever way they are stored.
|
||||
*
|
||||
* This is where a referenced file first becomes ciphertext. It is
|
||||
* sealed under a fresh content key bound to the label, so the courier
|
||||
* carries something it cannot read and the classification finally gates
|
||||
* something. Returns the sealed blob and the key to hand the recipient.
|
||||
*/
|
||||
data class Sealed(val blob: ByteArray, val contentKey: ByteArray)
|
||||
data class Sealed(
|
||||
val blob: ByteArray,
|
||||
val contentKey: ByteArray,
|
||||
)
|
||||
|
||||
fun sealedForSending(node: Node): Sealed = lock.withLock {
|
||||
fun sealedForSending(node: Node): Sealed =
|
||||
lock.withLock {
|
||||
val plaintext = readContents(node)
|
||||
val contentKey = Random.bytes(32)
|
||||
Sealed(FileContent.encrypt(plaintext, contentKey, node.label), contentKey)
|
||||
}
|
||||
|
||||
// ---- Writing ---------------------------------------------------------
|
||||
// ---- Writing ---------------------------------------------------------.
|
||||
|
||||
fun add(bytes: ByteArray, name: String, label: Label, parentId: UUID? = null): Node =
|
||||
fun add(
|
||||
bytes: ByteArray,
|
||||
name: String,
|
||||
label: Label,
|
||||
parentId: UUID? = null,
|
||||
): Node =
|
||||
lock.withLock {
|
||||
val node = makeNode(name, label, parentId, isFolder = false, byteCount = bytes.size)
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(label), label)
|
||||
val contentKey = Random.bytes(32)
|
||||
|
||||
// Bytes to disk BEFORE the index. The other order leaves an index
|
||||
// entry for a file that is not there, which reads as corruption;
|
||||
// this order leaves at worst an unreferenced blob, which is a
|
||||
// cleanup job nobody notices.
|
||||
// Bytes to disk BEFORE the index.
|
||||
blobs.mkdirs()
|
||||
writeAtomically(blobFile(node.id), FileContent.encrypt(bytes, contentKey, label))
|
||||
|
||||
val stored = node.copy(
|
||||
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, label))
|
||||
val stored =
|
||||
node.copy(wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, label))
|
||||
nodes += stored
|
||||
save()
|
||||
stored
|
||||
}
|
||||
|
||||
fun createFolder(name: String, label: Label, parentId: UUID? = null): Node = lock.withLock {
|
||||
fun createFolder(
|
||||
name: String,
|
||||
label: Label,
|
||||
parentId: UUID? = null,
|
||||
): Node =
|
||||
lock.withLock {
|
||||
val node = makeNode(name, label, parentId, isFolder = true, byteCount = 0)
|
||||
nodes += node
|
||||
save()
|
||||
@@ -510,15 +410,20 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* File a document that stays where it is.
|
||||
*
|
||||
* Nothing is copied and nothing is encrypted here, see [Storage]. The
|
||||
* bytes get their content key at send time, not now, so until then this
|
||||
* row's label is a statement of intent rather than a lock.
|
||||
*/
|
||||
fun reference(bookmark: String, name: String, label: Label, parentId: UUID? = null): Node =
|
||||
fun reference(
|
||||
bookmark: String,
|
||||
name: String,
|
||||
label: Label,
|
||||
parentId: UUID? = null,
|
||||
): Node =
|
||||
lock.withLock {
|
||||
val node = makeNode(
|
||||
name, label, parentId, isFolder = false,
|
||||
val node =
|
||||
makeNode(
|
||||
name,
|
||||
label,
|
||||
parentId,
|
||||
isFolder = false,
|
||||
byteCount = references.size(bookmark),
|
||||
).copy(storage = Storage.REFERENCE, bookmark = bookmark)
|
||||
nodes += node
|
||||
@@ -528,22 +433,9 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* Turn a reference into an encrypted copy, in place.
|
||||
*
|
||||
* The bridge between the two ways of holding a file, and the reason
|
||||
* both exist. Referencing avoids duplicating anything; vaulting is what
|
||||
* makes a classification a control rather than a note. Most files want the
|
||||
* first and a few want the second, and nobody knows which is which until
|
||||
* after they have added them, so this promotes one without re-picking it.
|
||||
*
|
||||
* It does NOT delete the person's original: that is their file, and
|
||||
* removing it is a separate decision the interface asks about afterwards.
|
||||
* Until they do, this is two copies, the honest trade for having
|
||||
* the encrypted one.
|
||||
*
|
||||
* Returns the bookmark the original was read from, so a caller can offer
|
||||
* that, or null when the row was already vaulted.
|
||||
*/
|
||||
fun encrypt(id: UUID): String? = lock.withLock {
|
||||
fun encrypt(id: UUID): String? =
|
||||
lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) throw VaultException("That file isn't here any more.")
|
||||
val node = nodes[position]
|
||||
@@ -561,12 +453,10 @@ class LocalVault(
|
||||
blobs.mkdirs()
|
||||
writeAtomically(blobFile(node.id), FileContent.encrypt(plaintext, contentKey, node.label))
|
||||
|
||||
nodes[position] = node.copy(
|
||||
nodes[position] =
|
||||
node.copy(
|
||||
storage = Storage.VAULT,
|
||||
// The bookmark goes. Keeping it would leave a vaulted row still
|
||||
// able to read through to a plaintext original, so the encrypted
|
||||
// copy would not be the only way in, which is the point of
|
||||
// vaulting it.
|
||||
// The bookmark goes.
|
||||
bookmark = null,
|
||||
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label),
|
||||
byteCount = plaintext.size,
|
||||
@@ -590,10 +480,7 @@ class LocalVault(
|
||||
isFolder = isFolder,
|
||||
classification = label.classification,
|
||||
compartments = label.compartments.sorted(),
|
||||
// Wrapped to the node's OWN label key, never the parent's. The
|
||||
// tree allows a Restricted file inside a Confidential folder, so a
|
||||
// folder-derived key would hand every folder-key holder content
|
||||
// above their clearance.
|
||||
// Wrapped to the node's OWN label key, never the parent's.
|
||||
wrappedNodeKey = FileKeys.wrapNodeKey(nodeKey, labelKey(label), label),
|
||||
nameDigest = sealed.digest,
|
||||
wrappedName = sealed.ciphertext,
|
||||
@@ -603,25 +490,27 @@ class LocalVault(
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeAtomically(target: File, bytes: ByteArray) {
|
||||
private fun writeAtomically(
|
||||
target: File,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
val temporary = File(target.parentFile, target.name + ".new")
|
||||
temporary.writeBytes(bytes)
|
||||
Files.move(
|
||||
temporary.toPath(), target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
|
||||
temporary.toPath(),
|
||||
target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Deleting --------------------------------------------------------
|
||||
// ---- Deleting --------------------------------------------------------.
|
||||
|
||||
/**
|
||||
* Removes a node and its bytes.
|
||||
*
|
||||
* Refuses a folder that still has anything in it. Deleting a folder and
|
||||
* orphaning its contents would leave rows nothing can reach and bytes
|
||||
* nothing will ever free.
|
||||
*/
|
||||
fun remove(id: UUID) = lock.withLock {
|
||||
fun remove(id: UUID) =
|
||||
lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) return@withLock
|
||||
val node = nodes[position]
|
||||
@@ -636,19 +525,9 @@ class LocalVault(
|
||||
|
||||
/**
|
||||
* Delete several things at once.
|
||||
*
|
||||
* This reports rather than returning nothing. Selecting eleven things
|
||||
* and having nine disappear is a data-loss-shaped event in the other
|
||||
* direction: the two that survived are the ones somebody now thinks are
|
||||
* gone. The screen says which, and why.
|
||||
*
|
||||
* Deletes deepest-first, so selecting a folder AND everything in it
|
||||
* works. A folder whose contents were NOT all selected is kept, because the
|
||||
* alternative is a bulk gesture that silently takes rows the person never
|
||||
* selected, including ones filed above what this device can even read,
|
||||
* which they could not have reviewed before tapping.
|
||||
*/
|
||||
fun remove(ids: List<UUID>): Removal = lock.withLock {
|
||||
fun remove(ids: List<UUID>): Removal =
|
||||
lock.withLock {
|
||||
val removed = mutableListOf<UUID>()
|
||||
val keptNonEmpty = mutableListOf<UUID>()
|
||||
val removedVaultIds = mutableListOf<UUID>()
|
||||
@@ -664,7 +543,9 @@ class LocalVault(
|
||||
}
|
||||
return steps
|
||||
}
|
||||
val ordered = ids.filter { id -> nodes.any { it.id == id } }
|
||||
val ordered =
|
||||
ids
|
||||
.filter { id -> nodes.any { it.id == id } }
|
||||
.sortedByDescending { depth(it) }
|
||||
|
||||
for (id in ordered) {
|
||||
@@ -683,15 +564,9 @@ class LocalVault(
|
||||
removed += id
|
||||
}
|
||||
|
||||
// One save for the whole batch, after the index is consistent. A save
|
||||
// per row would leave a half-deleted tree on disk if the process died
|
||||
// mid-loop; the blobs go afterwards, so a crash costs orphaned bytes
|
||||
// rather than rows pointing at files that are gone.
|
||||
// One save for the whole batch, after the index is consistent.
|
||||
save()
|
||||
// Only ever our own blobs. A referenced row's bytes are the person's
|
||||
// own file, sitting where they put it, deleting the row must not delete
|
||||
// their document, and `blobFile` for a reference names a file that was
|
||||
// never written.
|
||||
// Only ever our own blobs.
|
||||
removedVaultIds.forEach { blobFile(it).delete() }
|
||||
Removal(removed, keptNonEmpty)
|
||||
}
|
||||
|
||||
@@ -4,34 +4,9 @@ import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
|
||||
/**
|
||||
* Keeping a mailbox at more than one relay (spec §20.4).
|
||||
*
|
||||
* ## What this buys, and what it does not
|
||||
*
|
||||
* Your address already survives a relay disappearing: it comes from your
|
||||
* passphrase, so signing in elsewhere produces the same one. What does not
|
||||
* survive is your mailbox, mail sitting at a relay that goes away is gone,
|
||||
* and contacts holding only that relay's name have nowhere to write.
|
||||
*
|
||||
* A second home fixes both, and only in advance. Senders deliver a copy to
|
||||
* every relay in a signed record, so losing one loses no message, and a contact
|
||||
* whose delivery fails at the first home finds the second in the same record.
|
||||
* Added after a relay disappears, none of that helps: there is nothing left
|
||||
* to publish an updated record from and no way to tell anybody where you went.
|
||||
*
|
||||
* ## Why it is several requests
|
||||
*
|
||||
* Homing somewhere means having an account there. A relay will not take a
|
||||
* mailbox record from a stranger, and a token minted by one relay is refused by
|
||||
* every other because the audience is bound. So: get a credential at the new
|
||||
* relay, register this device so there is a mailbox to deliver into, claim the
|
||||
* same address so a sender can find it, and only then publish the record, at
|
||||
* every home rather than only the new one.
|
||||
*
|
||||
* That last part is not tidiness. A home that does not know it is one still
|
||||
* accepts mail, but a sender asking *it* for the record is handed a stale list
|
||||
* and skips the others, so the redundancy quietly depends on which relay the
|
||||
* sender happened to ask.
|
||||
* Keeping a mailbox at more than one relay (spec §20.4). ## What this buys, and
|
||||
* what it does not Your address already survives a relay disappearing: it comes
|
||||
* from your passphrase, so signing in elsewhere produces the same one.
|
||||
*/
|
||||
class MailboxHoming(
|
||||
private val vault: Vault,
|
||||
@@ -40,17 +15,13 @@ class MailboxHoming(
|
||||
private val primaryRelay: String,
|
||||
private val primary: RelayClient,
|
||||
) {
|
||||
|
||||
class Refused(message: String) : Exception(message)
|
||||
class Refused(
|
||||
message: String,
|
||||
) : Exception(message)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Normalise what somebody typed into a hostname, or return null.
|
||||
*
|
||||
* Refuses a URL rather than parsing one out. A relay is a hostname,
|
||||
* and accepting `https://x/y` here would let a pasted string point this
|
||||
* device at a path on somebody else's host, and then send it a signed
|
||||
* record naming that host as a mailbox.
|
||||
*/
|
||||
fun hostname(typed: String): String? {
|
||||
val host = typed.trim().lowercase()
|
||||
@@ -65,12 +36,7 @@ class MailboxHoming(
|
||||
|
||||
/**
|
||||
* Sign in at [typed], register this device, claim the address, and publish
|
||||
* the record everywhere. Returns the home list as it now stands.
|
||||
*
|
||||
* Nothing is written to the vault until the relay has actually accepted
|
||||
* a registration. A half-added home, stored locally, unknown to the relay
|
||||
* would be polled forever and would show on screen as a mailbox that
|
||||
* never has anything in it.
|
||||
* the record everywhere.
|
||||
*/
|
||||
fun add(
|
||||
typed: String,
|
||||
@@ -82,56 +48,41 @@ class MailboxHoming(
|
||||
/**
|
||||
* How to reach [typed], which is a separate question from what it is
|
||||
* called.
|
||||
*
|
||||
* The signed record names a hostname, it is inside the
|
||||
* signature, so it can carry no scheme and no port, while this is how
|
||||
* this device dials that host. `https://<host>` is the only correct
|
||||
* answer in production and the default; naming the two separately is
|
||||
* what lets a self-hosted or test deployment reachable some other way
|
||||
* work without the record lying about its own name.
|
||||
*/
|
||||
dialAs: String? = null,
|
||||
): List<String> {
|
||||
val host = hostname(typed) ?: throw Refused(
|
||||
val host =
|
||||
hostname(typed) ?: throw Refused(
|
||||
"That does not look like a relay's address. It is a hostname, like " +
|
||||
"relay.example.com, no https:// and no path.")
|
||||
"relay.example.com, no https:// and no path.",
|
||||
)
|
||||
if (host == primaryRelay || host in existingHomes) {
|
||||
throw Refused("That relay already holds a mailbox for you.")
|
||||
}
|
||||
|
||||
val base = dialAs ?: "https://$host"
|
||||
val account = try {
|
||||
val account =
|
||||
try {
|
||||
RelayAccounts(base).signIn(handle, passphrase)
|
||||
} catch (e: Exception) {
|
||||
// Says which of the two things went wrong. "No account there" and
|
||||
// "wrong passphrase" call for opposite actions, and one message
|
||||
// covering both leaves somebody retyping a correct passphrase.
|
||||
// Says which of the two things went wrong.
|
||||
throw Refused(
|
||||
"$host did not accept those details. Sign up there with the same " +
|
||||
"passphrase first, that is what gives you the same address.")
|
||||
"passphrase first, that is what gives you the same address.",
|
||||
)
|
||||
}
|
||||
|
||||
val client = RelayClient(base, account.username, account.appPassword)
|
||||
client.useRelayAuth(identity)
|
||||
|
||||
// A mailbox to deliver into.
|
||||
//
|
||||
// No one-time prekeys here. A one-time prekey is only usable if
|
||||
// this device kept its private half, and those live in the identity
|
||||
// archive keyed to no particular relay, publishing keys at a second
|
||||
// home whose privates this device would not offer back makes every
|
||||
// session opened with one undecryptable, which is worse than offering
|
||||
// none. Without them a sender falls back to the signed prekey: it costs
|
||||
// initial forward secrecy on the first message and nothing else.
|
||||
try {
|
||||
client.registerDevice(identity, emptyList())
|
||||
} catch (e: Exception) {
|
||||
throw Refused("$host refused the registration: ${e.message}")
|
||||
}
|
||||
|
||||
// The same address here as everywhere. A sender resolving `fnid@host`
|
||||
// asks that relay, and until the claim lands the answer is "no such
|
||||
// address", an unclaimed home is a mailbox nobody can address.
|
||||
// The same address here as everywhere.
|
||||
try {
|
||||
AccountIdentity.claim(client, accountKeySeed, account.userId)
|
||||
} catch (e: Exception) {
|
||||
@@ -142,40 +93,31 @@ class MailboxHoming(
|
||||
val clients = LinkedHashMap<String, RelayClient>()
|
||||
clients[primaryRelay] = primary
|
||||
for (existing in vault.extraHomes()) {
|
||||
clients[existing.relay] = RelayClient(
|
||||
existing.baseUrl, existing.username, existing.appPassword
|
||||
clients[existing.relay] =
|
||||
RelayClient(
|
||||
existing.baseUrl,
|
||||
existing.username,
|
||||
existing.appPassword,
|
||||
).also { it.useRelayAuth(identity) }
|
||||
}
|
||||
clients[host] = client
|
||||
|
||||
val serial = AccountIdentity.nextSerial(fnid, clients.values)
|
||||
val failures = AccountIdentity.publishHomes(
|
||||
accountKeySeed, fnid, serial, homes.associateWith { clients[it] ?: primary })
|
||||
val failures =
|
||||
AccountIdentity.publishHomes(accountKeySeed, fnid, serial, homes.associateWith { clients[it] ?: primary })
|
||||
|
||||
// The NEW relay accepting is what decides. A record every old home
|
||||
// took but the new one refused describes a mailbox that does not exist,
|
||||
// and a sender told to deliver there gets nothing.
|
||||
// The NEW relay accepting is what decides.
|
||||
failures[host]?.let { throw Refused("$host refused the mailbox: ${it.message}") }
|
||||
|
||||
vault.saveExtraHomes(
|
||||
vault.extraHomes() +
|
||||
Vault.ExtraHome(host, account.username, account.appPassword, base))
|
||||
Vault.ExtraHome(host, account.username, account.appPassword, base),
|
||||
)
|
||||
return homes
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop keeping a mailbox at [relay].
|
||||
*
|
||||
* The shorter list is published first, and the local credential goes
|
||||
* only if that worked. Forgetting locally while senders are still told to
|
||||
* deliver there leaves mail arriving at a mailbox this device no longer
|
||||
* reads, indistinguishable, from the sender's side, from delivery.
|
||||
*
|
||||
* The relay being removed is not asked to accept the new record. It
|
||||
* is the one party with a reason to refuse, and it is not in the list any
|
||||
* more so it would refuse on those grounds regardless. Its copy simply goes
|
||||
* stale, which costs nothing: the record is self-certifying and every other
|
||||
* home serves the current one.
|
||||
*/
|
||||
fun remove(
|
||||
relay: String,
|
||||
@@ -186,7 +128,8 @@ class MailboxHoming(
|
||||
if (relay == primaryRelay) {
|
||||
throw Refused(
|
||||
"This is the relay you are signed in to, so it cannot be removed " +
|
||||
"while you are using it.")
|
||||
"while you are using it.",
|
||||
)
|
||||
}
|
||||
val remaining = currentHomes.filterNot { it == relay }
|
||||
if (remaining.isEmpty()) throw Refused("An account needs at least one mailbox.")
|
||||
@@ -195,14 +138,17 @@ class MailboxHoming(
|
||||
clients[primaryRelay] = primary
|
||||
for (existing in vault.extraHomes()) {
|
||||
if (existing.relay == relay) continue
|
||||
clients[existing.relay] = RelayClient(
|
||||
existing.baseUrl, existing.username, existing.appPassword
|
||||
clients[existing.relay] =
|
||||
RelayClient(
|
||||
existing.baseUrl,
|
||||
existing.username,
|
||||
existing.appPassword,
|
||||
).also { it.useRelayAuth(identity) }
|
||||
}
|
||||
|
||||
val serial = AccountIdentity.nextSerial(fnid, clients.values)
|
||||
val failures = AccountIdentity.publishHomes(
|
||||
accountKeySeed, fnid, serial, remaining.associateWith { clients[it] ?: primary })
|
||||
val failures =
|
||||
AccountIdentity.publishHomes(accountKeySeed, fnid, serial, remaining.associateWith { clients[it] ?: primary })
|
||||
failures[primaryRelay]?.let {
|
||||
throw Refused("Your own relay would not take the new list: ${it.message}")
|
||||
}
|
||||
|
||||
@@ -12,12 +12,6 @@ import org.json.JSONObject
|
||||
/**
|
||||
* The non-cryptographic half of the product: who your contacts are, who is
|
||||
* online, what you have been notified about, who you have blocked.
|
||||
*
|
||||
* These routes take the account credential, not the relay token. The
|
||||
* `/e2e/…` routes deliberately refuse the account password because a
|
||||
* credential good for every device is too much authority for key material.
|
||||
* These routes are the opposite case, they are about the account, not a
|
||||
* device, so Basic is correct here and a device-scoped token would be wrong.
|
||||
*/
|
||||
class MessengerApi(
|
||||
baseUrl: String,
|
||||
@@ -25,19 +19,11 @@ class MessengerApi(
|
||||
appPassword: String,
|
||||
private val http: OkHttpClient = RelayClient.defaultHttpClient(),
|
||||
) {
|
||||
|
||||
private val base: HttpUrl = baseUrl.trimEnd('/').toHttpUrl()
|
||||
private val appPassword = appPassword.replace(" ", "")
|
||||
|
||||
/**
|
||||
* Someone in your list.
|
||||
*
|
||||
* `handle` is the name they signed up with and the only one another
|
||||
* person can type to reach them. It was null on this relay for a while,
|
||||
* having started life as an OSCAR screen name, which meant you could not
|
||||
* find your own contacts by the name you knew them by. Show it.
|
||||
*
|
||||
* `presence` is their chosen status, not an inference from activity.
|
||||
*/
|
||||
data class Buddy(
|
||||
val userId: Long,
|
||||
@@ -47,11 +33,6 @@ class MessengerApi(
|
||||
/**
|
||||
* Whether this person is somebody the user chose, or somebody who
|
||||
* turned up because they wrote first.
|
||||
*
|
||||
* Defaults to true, and that default is the compatibility story.
|
||||
* A relay that predates this field says nothing about acceptance, and
|
||||
* the only safe reading of silence is "these are your contacts", the
|
||||
* alternative labels every contact on every older relay a stranger.
|
||||
*/
|
||||
val accepted: Boolean = true,
|
||||
)
|
||||
@@ -67,8 +48,8 @@ class MessengerApi(
|
||||
val list = get("buddies").optJSONArray("buddies") ?: JSONArray()
|
||||
return (0 until list.length()).map {
|
||||
val b = list.getJSONObject(it)
|
||||
// `id`, `display_name`, `screen_name`, `presence`, this route
|
||||
// does NOT use the `user_id`/`status` shape the §8 routes use, and
|
||||
// `id`, `display_name`, `screen_name`, `presence`, this route does
|
||||
// NOT use the `user_id`/`status` shape the §8 routes use, and
|
||||
// guessing otherwise threw "No value for user_id" at the exact
|
||||
// moment a contact was added.
|
||||
Buddy(
|
||||
@@ -82,20 +63,17 @@ class MessengerApi(
|
||||
}
|
||||
|
||||
/**
|
||||
* By username, not by id. A person types a name; the relay is the only
|
||||
* thing that can turn that into an id, and doing the lookup client-side
|
||||
* would need a directory this product deliberately does not have.
|
||||
* By username, not by id.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param auto true when the app is adopting somebody who messaged first,
|
||||
* rather than the user choosing to add them.
|
||||
*
|
||||
* The distinction is the whole feature. Adopting a stranger keeps their
|
||||
* first message visible, which is why adoption exists, while leaving the
|
||||
* decision about whether they are a contact to the person, instead of
|
||||
* making it for them silently.
|
||||
*/
|
||||
fun addBuddy(username: String, auto: Boolean = false) {
|
||||
fun addBuddy(
|
||||
username: String,
|
||||
auto: Boolean = false,
|
||||
) {
|
||||
post("buddies", JSONObject().put("username", username).put("auto", auto))
|
||||
}
|
||||
|
||||
@@ -106,16 +84,9 @@ class MessengerApi(
|
||||
|
||||
/**
|
||||
* Resolve a user id to a name, `GET /messenger/users/:id`.
|
||||
*
|
||||
* This is how a message from a stranger becomes visible. An E2E
|
||||
* envelope names its sender by id only, and the buddy list is what the
|
||||
* Messages screen renders, so first contact from somebody who added US
|
||||
* arrived, decrypted, filed, counted as unread, and never appeared
|
||||
* anywhere. The receive path uses this to name the sender and add them.
|
||||
*
|
||||
* Null when the relay does not know the id (or predates the route).
|
||||
*/
|
||||
fun userInfo(userId: Long): Buddy? = runCatching {
|
||||
fun userInfo(userId: Long): Buddy? =
|
||||
runCatching {
|
||||
val b = get("users/$userId")
|
||||
Buddy(
|
||||
userId = b.getLong("id"),
|
||||
@@ -136,26 +107,12 @@ class MessengerApi(
|
||||
|
||||
/**
|
||||
* Set our presence, and return what the relay says it now is.
|
||||
*
|
||||
* Return the server's answer, never the value we asked for. The
|
||||
* relay used to accept an unknown status with 200 and store nothing, so a
|
||||
* caller that assumed success showed a state the server had discarded,
|
||||
* "offline" on this screen while every contact still saw us online. The
|
||||
* relay refuses unknown values now, but a client that reports its own
|
||||
* request back to itself is wrong regardless of how the server behaves.
|
||||
*
|
||||
* The answer is not always the word sent: `offline` is stored as
|
||||
* `invisible`, which is what "appear offline" has always meant.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Read the status the relay currently holds for us, without changing it.
|
||||
*
|
||||
* An empty body is a READ. The route only writes when `status` is
|
||||
* present, and answers with the stored value either way, so this needs no
|
||||
* route of its own, and cannot accidentally set something.
|
||||
*/
|
||||
fun currentStatus(): String =
|
||||
post("status", JSONObject()).optString("status").ifEmpty { "online" }
|
||||
fun currentStatus(): String = post("status", JSONObject()).optString("status").ifEmpty { "online" }
|
||||
|
||||
fun setStatus(status: String): String {
|
||||
val reply = post("status", JSONObject().put("status", status))
|
||||
@@ -168,9 +125,6 @@ class MessengerApi(
|
||||
return (0 until list.length()).map {
|
||||
val n = list.getJSONObject(it)
|
||||
// `type`/`message`/`is_read`, the relay's actual field names.
|
||||
// This read `kind`/`body`/`read` for months, which exist nowhere,
|
||||
// so every alert decoded as an empty unread row and the screen
|
||||
// looked permanently blank.
|
||||
Notification(
|
||||
id = n.optLong("id"),
|
||||
kind = n.optString("type", ""),
|
||||
@@ -186,32 +140,29 @@ class MessengerApi(
|
||||
|
||||
/**
|
||||
* The ids this account has blocked.
|
||||
*
|
||||
* `id`, not `user_id`. This route answers `{id, name, slug}`, the
|
||||
* same shape as the buddy list and NOT the `user_id` shape the §8 routes
|
||||
* use. Reading `user_id` here threw `No value for user_id`, and the reason
|
||||
* it survived so long is the reason it was dangerous: the list is empty
|
||||
* until somebody blocks their first person, so the map never ran and every
|
||||
* test passed. The first block anybody performed broke it.
|
||||
*
|
||||
* It threw where a caller was defaulting to "nobody is blocked", which
|
||||
* turned a field-name typo into a blocked person being auto-added back to
|
||||
* your contacts. See `adoptStrangers`.
|
||||
*/
|
||||
fun blocked(): List<Long> {
|
||||
val list = get("blocks").optJSONArray("blocked") ?: JSONArray()
|
||||
return (0 until list.length()).map { list.getJSONObject(it).getLong("id") }
|
||||
}
|
||||
|
||||
fun setBlocked(userId: Long, blocked: Boolean) {
|
||||
fun setBlocked(
|
||||
userId: Long,
|
||||
blocked: Boolean,
|
||||
) {
|
||||
if (blocked) post("blocks/$userId", JSONObject()) else delete("blocks/$userId")
|
||||
}
|
||||
|
||||
// -- The account itself --------------------------------------------------
|
||||
// -- The account itself --------------------------------------------------.
|
||||
|
||||
data class Me(val userId: Long, val displayName: String, val handle: String)
|
||||
data class Me(
|
||||
val userId: Long,
|
||||
val displayName: String,
|
||||
val handle: String,
|
||||
)
|
||||
|
||||
fun me(): Me = raw("api/flamenet/v1/messenger/me", "GET", null).let {
|
||||
fun me(): Me =
|
||||
raw("api/flamenet/v1/messenger/me", "GET", null).let {
|
||||
Me(it.getLong("id"), it.optString("name"), it.optString("slug"))
|
||||
}
|
||||
|
||||
@@ -220,10 +171,8 @@ class MessengerApi(
|
||||
}
|
||||
|
||||
/**
|
||||
* Changing the password invalidates other credentials, which is the
|
||||
* point of changing it, so this device has to sign in again afterwards.
|
||||
* A UI that changes the password and carries on looks fine until the next
|
||||
* request 401s for no visible reason.
|
||||
* Changing the password invalidates other credentials, which is the point
|
||||
* of changing it, so this device has to sign in again afterwards.
|
||||
*/
|
||||
fun updatePassword(password: String) {
|
||||
raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("password", password))
|
||||
@@ -235,27 +184,13 @@ class MessengerApi(
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact-username lookup. Deliberately not a directory: no listing exists.
|
||||
*
|
||||
* The relay answers with an ARRAY, empty for a miss and one element
|
||||
* for a hit, `RoutesAccount.lookup` returns `[MeResponse]` precisely so a
|
||||
* miss and a hit differ only in length and neither leaks timing. This went
|
||||
* through `send()`, which does `JSONObject(text)` and throws on an array,
|
||||
* and the throw was swallowed by `runCatching { }.getOrNull()`.
|
||||
*
|
||||
* The result: every lookup reported "no account called X here" even when
|
||||
* the relay had found the account and returned it. Adding a contact was
|
||||
* impossible on Android, which is the first thing anyone does. Measured
|
||||
* against relay.flamenet.io, the relay returned
|
||||
* `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` while the
|
||||
* app said no such account.
|
||||
*
|
||||
* So parse the array here, and keep `getOrNull` for TRANSPORT failure
|
||||
* only, a miss is an empty array, not an exception.
|
||||
* Exact-username lookup.
|
||||
*/
|
||||
fun lookup(username: String): Me? {
|
||||
val text = rawText(
|
||||
"api/flamenet/v1/messenger/users", query = mapOf("slug" to username)
|
||||
val text =
|
||||
rawText(
|
||||
"api/flamenet/v1/messenger/users",
|
||||
query = mapOf("slug" to username),
|
||||
) ?: return null
|
||||
val found = runCatching { JSONArray(text) }.getOrNull() ?: return null
|
||||
if (found.length() == 0) return null
|
||||
@@ -263,10 +198,13 @@ class MessengerApi(
|
||||
return Me(o.getLong("id"), o.optString("name"), o.optString("slug"))
|
||||
}
|
||||
|
||||
// -- Internals -----------------------------------------------------------
|
||||
// -- Internals -----------------------------------------------------------.
|
||||
|
||||
private fun url(path: String) = base.newBuilder()
|
||||
.addPathSegments("api/flamenet/v1/messenger/$path").build()
|
||||
private fun url(path: String) =
|
||||
base
|
||||
.newBuilder()
|
||||
.addPathSegments("api/flamenet/v1/messenger/$path")
|
||||
.build()
|
||||
|
||||
/** For the routes that are not under the messenger prefix. */
|
||||
private fun raw(
|
||||
@@ -275,8 +213,12 @@ class MessengerApi(
|
||||
body: JSONObject?,
|
||||
query: Map<String, String> = emptyMap(),
|
||||
): JSONObject {
|
||||
val target = base.newBuilder().addPathSegments(path)
|
||||
.also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }.build()
|
||||
val target =
|
||||
base
|
||||
.newBuilder()
|
||||
.addPathSegments(path)
|
||||
.also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }
|
||||
.build()
|
||||
val builder = Request.Builder().url(target).header("Accept", "application/json")
|
||||
when (method) {
|
||||
"GET" -> builder.get()
|
||||
@@ -287,33 +229,60 @@ class MessengerApi(
|
||||
return send(builder)
|
||||
}
|
||||
|
||||
private fun get(path: String) = send(
|
||||
Request.Builder().url(url(path)).get().header("Accept", "application/json"))
|
||||
private fun get(path: String) =
|
||||
send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url(path))
|
||||
.get()
|
||||
.header("Accept", "application/json"),
|
||||
)
|
||||
|
||||
private fun post(path: String, body: JSONObject) = send(
|
||||
Request.Builder().url(url(path))
|
||||
private fun post(
|
||||
path: String,
|
||||
body: JSONObject,
|
||||
) = send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url(path))
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Accept", "application/json"))
|
||||
.header("Accept", "application/json"),
|
||||
)
|
||||
|
||||
private fun delete(path: String) = send(
|
||||
Request.Builder().url(url(path)).delete().header("Accept", "application/json"))
|
||||
private fun delete(path: String) =
|
||||
send(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url(path))
|
||||
.delete()
|
||||
.header("Accept", "application/json"),
|
||||
)
|
||||
|
||||
/**
|
||||
* A GET whose body is returned as text rather than forced into a
|
||||
* `JSONObject`.
|
||||
*
|
||||
* Exists for the ONE route that answers with an array. Everything else
|
||||
* goes through `send()`; do not widen this without checking the route,
|
||||
* because losing the typed error mapping is a real cost.
|
||||
*/
|
||||
private fun rawText(path: String, query: Map<String, String> = emptyMap()): String? {
|
||||
val target = base.newBuilder().addPathSegments(path)
|
||||
.also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }.build()
|
||||
val request = Request.Builder().url(target)
|
||||
private fun rawText(
|
||||
path: String,
|
||||
query: Map<String, String> = emptyMap(),
|
||||
): String? {
|
||||
val target =
|
||||
base
|
||||
.newBuilder()
|
||||
.addPathSegments(path)
|
||||
.also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }
|
||||
.build()
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(target)
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", "Basic " + Wire.b64(
|
||||
"$username:$appPassword".toByteArray(Charsets.UTF_8)))
|
||||
.get().build()
|
||||
.header(
|
||||
"Authorization",
|
||||
"Basic " +
|
||||
Wire.b64("$username:$appPassword".toByteArray(Charsets.UTF_8)),
|
||||
).get()
|
||||
.build()
|
||||
return runCatching {
|
||||
http.newCall(request).execute().use {
|
||||
if (it.isSuccessful) it.body?.string() else null
|
||||
@@ -322,11 +291,15 @@ class MessengerApi(
|
||||
}
|
||||
|
||||
private fun send(builder: Request.Builder): JSONObject {
|
||||
val request = builder
|
||||
.header("Authorization", "Basic " + Wire.b64(
|
||||
"$username:$appPassword".toByteArray(Charsets.UTF_8)))
|
||||
.build()
|
||||
val response = try {
|
||||
val request =
|
||||
builder
|
||||
.header(
|
||||
"Authorization",
|
||||
"Basic " +
|
||||
Wire.b64("$username:$appPassword".toByteArray(Charsets.UTF_8)),
|
||||
).build()
|
||||
val response =
|
||||
try {
|
||||
http.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw RelayException.Transport(e.message ?: e.toString(), e)
|
||||
@@ -334,17 +307,28 @@ class MessengerApi(
|
||||
response.use {
|
||||
val text = it.body?.string().orEmpty()
|
||||
if (it.isSuccessful) {
|
||||
return if (text.isBlank()) JSONObject() else runCatching { JSONObject(text) }
|
||||
return if (text.isBlank()) {
|
||||
JSONObject()
|
||||
} else {
|
||||
runCatching { JSONObject(text) }
|
||||
.getOrElse { throw RelayException.Malformed("the relay's reply was not JSON") }
|
||||
}
|
||||
val message = runCatching { JSONObject(text).optString("message") }
|
||||
.getOrNull().orEmpty().ifBlank { text }
|
||||
}
|
||||
val message =
|
||||
runCatching { JSONObject(text).optString("message") }
|
||||
.getOrNull()
|
||||
.orEmpty()
|
||||
.ifBlank { text }
|
||||
throw when (it.code) {
|
||||
401, 403 -> RelayException.Unauthorized(message)
|
||||
|
||||
404 -> RelayException.NotFound(message.ifBlank { request.url.encodedPath })
|
||||
|
||||
429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
|
||||
// The geo-gate, not the relay. Needs a sentence, not a number.
|
||||
|
||||
// The geo-gate, not the relay.
|
||||
451 -> RelayException.Unavailable(message)
|
||||
|
||||
else -> RelayException.Http(it.code, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,38 +6,13 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Which bridge, if any, the app's HTTP clients route through.
|
||||
*
|
||||
* A holder rather than a constructor argument, because there are ten
|
||||
* construction sites and no way to be sure of catching them all. `RelayClient`,
|
||||
* `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`,
|
||||
* `RelayDiscovery` and the background service each build their own client, some
|
||||
* from default parameters evaluated at field-initialisation time. Threading a
|
||||
* bridge through every one would work until somebody adds an eleventh, and the
|
||||
* eleventh would connect directly, successfully, with nothing to show that it
|
||||
* had. A missed site here does not fail; it silently does not protect.
|
||||
*
|
||||
* The socket factory is installed whether or not a bridge is set. A
|
||||
* client is built once and the setting changes later, so the factory has to be
|
||||
* able to follow it. With no bridge it delegates to the platform's own factory,
|
||||
* which is exactly what OkHttp would have used.
|
||||
*/
|
||||
object Obfs4Routing {
|
||||
|
||||
@Volatile
|
||||
private var current: Obfs4Bridge? = null
|
||||
|
||||
/**
|
||||
* One pool for every default client.
|
||||
*
|
||||
* Shared so it can be emptied. OkHttp keeps connections alive across
|
||||
* requests; a connection opened before the bridge changed still runs over
|
||||
* the old transport, and would go on serving requests for minutes after the
|
||||
* user switched bridges, or after they turned one OFF, which is the
|
||||
* dangerous direction. Eviction on change is the only thing that stops that,
|
||||
* and it needs a pool we hold.
|
||||
*
|
||||
* The idle window is short for the same reason a bridge that goes away
|
||||
* must not leave requests queued behind a dead tunnel.
|
||||
*/
|
||||
internal val pool = ConnectionPool(8, 2, TimeUnit.MINUTES)
|
||||
|
||||
@@ -46,9 +21,6 @@ object Obfs4Routing {
|
||||
|
||||
/**
|
||||
* Route through [bridge] from now on, or directly when it is null.
|
||||
*
|
||||
* Existing pooled connections are dropped, because they are already
|
||||
* committed to the previous transport.
|
||||
*/
|
||||
fun use(bridge: Obfs4Bridge?) {
|
||||
if (bridge == current) return
|
||||
|
||||
@@ -9,21 +9,11 @@ import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A relay's own account table, the identity provider a self-hosted relay is.
|
||||
*
|
||||
* This is what keeps self-hosting one service rather than two. A relay with
|
||||
* no shared account service configured still signs people up, signs them in and
|
||||
* resets their passwords, on its own.
|
||||
*
|
||||
* What comes back is not the password. The relay mints a long-lived
|
||||
* credential, returns it once, and keeps only its hash. That credential is what
|
||||
* every later request carries; the password is used at this moment and then
|
||||
* should not be kept.
|
||||
*/
|
||||
class RelayAccounts(
|
||||
baseUrl: String,
|
||||
private val http: OkHttpClient = RelayClient.defaultHttpClient(),
|
||||
) {
|
||||
|
||||
private val base = baseUrl.trimEnd('/').toHttpUrl()
|
||||
|
||||
/** Who the relay says you are, and the credential to prove it with. */
|
||||
@@ -36,17 +26,15 @@ class RelayAccounts(
|
||||
|
||||
/**
|
||||
* 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()
|
||||
fun signUp(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
displayName: String? = null,
|
||||
): Account {
|
||||
fun body() =
|
||||
JSONObject()
|
||||
.put("username", username)
|
||||
.put("email", email)
|
||||
.put("password", password)
|
||||
@@ -55,61 +43,59 @@ class RelayAccounts(
|
||||
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 // 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.
|
||||
// Rethrown, not swallowed, when we cannot answer.
|
||||
val solved = SignupProofOfWork.satisfy(base.toString(), http) ?: throw asked
|
||||
account(post("account/signup", body()
|
||||
account(
|
||||
post(
|
||||
"account/signup",
|
||||
body()
|
||||
.put("challenge", solved.challenge)
|
||||
.put("nonce", solved.nonce)))
|
||||
.put("nonce", solved.nonce),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The field is "username or email" and always has been. A client
|
||||
* that only sends usernames rejects half the people who typed exactly what
|
||||
* they were asked for.
|
||||
* The field is "username or email" and always has been.
|
||||
*/
|
||||
fun signIn(usernameOrEmail: String, password: String): Account =
|
||||
account(post("account/signin", JSONObject()
|
||||
fun signIn(
|
||||
usernameOrEmail: String,
|
||||
password: String,
|
||||
): Account =
|
||||
account(
|
||||
post(
|
||||
"account/signin",
|
||||
JSONObject()
|
||||
.put("username", usernameOrEmail)
|
||||
.put("password", password)))
|
||||
.put("password", password),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Exchange a shared FlameNet account session for a relay session.
|
||||
*
|
||||
* Present only on a relay configured to trust an account service. A
|
||||
* self-hosted relay answers 404 here, and that is the correct answer rather
|
||||
* than a misconfiguration, it is its own identity provider.
|
||||
*
|
||||
* The account service's token carries a product-scoped pseudonym, not
|
||||
* the handle, so the relay learns an opaque id and nothing that ties the
|
||||
* person to any other FlameNet product.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The handle travels beside the token, not inside it. The account
|
||||
* service will not put a handle in a token, so that a relay cannot
|
||||
* correlate its users with another product's, the token carries a
|
||||
* product-scoped pseudonym instead. The relay uses the handle only the
|
||||
* first time it sees that pseudonym; after that the pseudonym decides, and
|
||||
* honouring the handle would let somebody rename themselves into an
|
||||
* existing account.
|
||||
* The handle travels beside the token, not inside it.
|
||||
*/
|
||||
fun sharedSession(accountToken: String, handle: String, displayName: String? = null): Account =
|
||||
account(post("api/flamenet/v1/messenger/session", JSONObject()
|
||||
fun sharedSession(
|
||||
accountToken: String,
|
||||
handle: String,
|
||||
displayName: String? = null,
|
||||
): Account =
|
||||
account(
|
||||
post(
|
||||
"api/flamenet/v1/messenger/session",
|
||||
JSONObject()
|
||||
.put("token", accountToken)
|
||||
.put("handle", handle)
|
||||
.also { if (!displayName.isNullOrEmpty()) it.put("display_name", displayName) }))
|
||||
.also { if (!displayName.isNullOrEmpty()) it.put("display_name", displayName) },
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Ask the relay to send a reset code.
|
||||
*
|
||||
* The relay answers identically whatever the truth is, unknown handle,
|
||||
* no address on file, an account that authenticates elsewhere. So this
|
||||
* cannot report whether it worked, and a UI that claims it can is lying.
|
||||
* Anything thrown here is a transport or server failure, never "no such
|
||||
* account".
|
||||
*/
|
||||
fun requestPasswordReset(handle: String) {
|
||||
post("account/reset/request", JSONObject().put("handle", handle))
|
||||
@@ -117,36 +103,46 @@ class RelayAccounts(
|
||||
|
||||
/**
|
||||
* 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()
|
||||
fun confirmPasswordReset(
|
||||
handle: String,
|
||||
code: String,
|
||||
newPassword: String,
|
||||
) {
|
||||
post(
|
||||
"account/reset/confirm",
|
||||
JSONObject()
|
||||
.put("handle", handle)
|
||||
.put("code", code)
|
||||
.put("password", newPassword))
|
||||
.put("password", newPassword),
|
||||
)
|
||||
}
|
||||
|
||||
private fun account(json: JSONObject) = Account(
|
||||
private fun account(json: JSONObject) =
|
||||
Account(
|
||||
userId = json.getLong("user_id"),
|
||||
username = json.getString("username"),
|
||||
displayName = json.optString("display_name", json.getString("username")),
|
||||
appPassword = json.getString("app_password"),
|
||||
)
|
||||
|
||||
private fun post(path: String, body: JSONObject): JSONObject {
|
||||
val request = Request.Builder()
|
||||
private fun post(
|
||||
path: String,
|
||||
body: JSONObject,
|
||||
): JSONObject {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(base.newBuilder().addPathSegments(path).build())
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
val response = try {
|
||||
val response =
|
||||
try {
|
||||
http.newCall(request).execute()
|
||||
} catch (e: Exception) {
|
||||
throw RelayException.Transport(e.message ?: e.toString(), e)
|
||||
@@ -154,44 +150,74 @@ class RelayAccounts(
|
||||
response.use {
|
||||
val text = it.body?.string().orEmpty()
|
||||
if (it.isSuccessful) {
|
||||
return if (text.isBlank()) JSONObject() else try {
|
||||
return if (text.isBlank()) {
|
||||
JSONObject()
|
||||
} else {
|
||||
try {
|
||||
JSONObject(text)
|
||||
} catch (_: Exception) {
|
||||
throw RelayException.Malformed("the relay's reply was not a JSON object")
|
||||
}
|
||||
}
|
||||
}
|
||||
// The relay puts a human-readable reason in the body, and it is
|
||||
// better than anything this layer could invent from a status code.
|
||||
val message = runCatching { JSONObject(text).optString("message") }
|
||||
.getOrNull().orEmpty().ifBlank { text }
|
||||
val code = runCatching { JSONObject(text).optString("code") }
|
||||
.getOrNull().orEmpty()
|
||||
val message =
|
||||
runCatching { JSONObject(text).optString("message") }
|
||||
.getOrNull()
|
||||
.orEmpty()
|
||||
.ifBlank { text }
|
||||
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
|
||||
// Decided by the relay's own `code`, never by the status.
|
||||
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" ->
|
||||
// which.
|
||||
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 ->
|
||||
}
|
||||
|
||||
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.
|
||||
it.code == 451 -> RelayException.Unavailable(message)
|
||||
else -> RelayException.Http(it.code, message)
|
||||
}
|
||||
|
||||
// The geo-gate, not the relay.
|
||||
it.code == 451 -> {
|
||||
RelayException.Unavailable(message)
|
||||
}
|
||||
|
||||
else -> {
|
||||
RelayException.Http(it.code, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,82 +6,68 @@ import java.util.UUID
|
||||
|
||||
/**
|
||||
* Authentication for the relay's `/e2e/…` routes (spec §8.3).
|
||||
*
|
||||
* The relay refuses the account's own password on these routes, and that is a
|
||||
* design decision rather than an oversight to work around. A credential good
|
||||
* for every route on every one of an account's devices means a token lifted
|
||||
* from one device drains another's prekey pool and reads its inbox. What the
|
||||
* relay wants instead is a short-lived token scoped to one device and one
|
||||
* relay, plus a per-request signature proving the sender holds the identity
|
||||
* key that token names, so a stolen token, on its own, is inert.
|
||||
*
|
||||
* The nonce is not decoration. Without it, two inbox polls in the same
|
||||
* second with the same empty body produce byte-identical signatures, and the
|
||||
* relay's replay cache cannot tell an honest repeat from a captured one. It
|
||||
* then refuses the honest client, and the symptom is an app that stops
|
||||
* receiving mail for no visible reason.
|
||||
*
|
||||
* A deployment with no token endpoint answers 404 once and is then asked no
|
||||
* more; the caller falls back to Basic. Discovered rather than configured, so
|
||||
* one build talks to both.
|
||||
*/
|
||||
class RelayAuth(
|
||||
private val deviceId: String,
|
||||
private val identityKey: Ed25519.KeyPair,
|
||||
private val mint: (deviceId: String, identityPublicB64: String) -> Minted?,
|
||||
) {
|
||||
|
||||
/** A minted token and the absolute second it stops being accepted. */
|
||||
data class Minted(val token: String, val lifetimeSeconds: Int)
|
||||
data class Minted(
|
||||
val token: String,
|
||||
val lifetimeSeconds: Int,
|
||||
)
|
||||
|
||||
private class Cached(val token: String, val expiresAt: Long)
|
||||
private class Cached(
|
||||
val token: String,
|
||||
val expiresAt: Long,
|
||||
)
|
||||
|
||||
@Volatile private var cached: Cached? = null
|
||||
|
||||
/**
|
||||
* Set once the relay answers "no such route".
|
||||
*
|
||||
* Without it, a deployment that will never mint a token is asked on every
|
||||
* single request, a round trip per call, forever, to learn the same thing.
|
||||
*/
|
||||
@Volatile private var unavailable = false
|
||||
|
||||
/**
|
||||
* A valid token, minting one when the cache is empty or close to expiry.
|
||||
* Null when this deployment does not issue them.
|
||||
*/
|
||||
/** Plain Kotlin module: no android.util.Log here. On device this lands in
|
||||
* logcat as `W/System.err`, which is what matters, it is READABLE. */
|
||||
|
||||
/**
|
||||
* Plain Kotlin module: no android.util.Log here.
|
||||
*/
|
||||
private fun warn(message: String) = System.err.println("fnmsg-auth: $message")
|
||||
|
||||
fun token(now: Long = System.currentTimeMillis() / 1000): String? {
|
||||
if (unavailable) return null
|
||||
cached?.let { if (it.expiresAt - SLACK_SECONDS > now) return it.token }
|
||||
val minted = try {
|
||||
val minted =
|
||||
try {
|
||||
mint(deviceId, Wire.b64(identityKey.publicKey))
|
||||
} catch (e: RelayException.NotFound) {
|
||||
// Say it. Every failure here used to be swallowed in
|
||||
// silence, and the consequence was invisible: with no token the
|
||||
// client falls back to Basic, the relay refuses `/e2e/*`, which it
|
||||
// does by design, and the user sees "the call failed", "could not
|
||||
// finish setting up", or an inbox that 410s and wipes itself. Four
|
||||
// hours of debugging produced symptoms and no cause, because the
|
||||
// one line that knew what went wrong discarded it.
|
||||
warn("no token: relay has no /account/token route; " +
|
||||
// Say it.
|
||||
warn(
|
||||
"no token: relay has no /account/token route; " +
|
||||
"falling back to Basic, which /e2e/* refuses. Calls and device " +
|
||||
"registration will fail until this is fixed." + " [" + e + "]")
|
||||
"registration will fail until this is fixed." + " [" + e + "]",
|
||||
)
|
||||
unavailable = true
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
warn("no token: minting failed (${e.javaClass.simpleName}: " +
|
||||
"${e.message}); falling back to Basic, which /e2e/* refuses.")
|
||||
warn(
|
||||
"no token: minting failed (${e.javaClass.simpleName}: " +
|
||||
"${e.message}); falling back to Basic, which /e2e/* refuses.",
|
||||
)
|
||||
return null
|
||||
} ?: run {
|
||||
// A non-2xx that is not 404 arrives here as null, not as a
|
||||
// throw, so this branch is NOT unreachable, it was the quietest
|
||||
// path of the three.
|
||||
warn("no token: /account/token answered a non-success " +
|
||||
"status; falling back to Basic, which /e2e/* refuses.")
|
||||
// A non-2xx that is not 404 arrives here as null, not as a throw,
|
||||
// so this branch is NOT unreachable, it was the quietest path of
|
||||
// the three.
|
||||
warn(
|
||||
"no token: /account/token answered a non-success " +
|
||||
"status; falling back to Basic, which /e2e/* refuses.",
|
||||
)
|
||||
return null
|
||||
}
|
||||
cached = Cached(minted.token, now + minted.lifetimeSeconds)
|
||||
@@ -90,15 +76,6 @@ class RelayAuth(
|
||||
|
||||
/**
|
||||
* Discard the cached token, so one stale token costs a single retry.
|
||||
*
|
||||
* Also un-latches [unavailable]. That flag is a told-once marker: it
|
||||
* concludes "this deployment has no token endpoint" from a single 404, but
|
||||
* a reverse proxy answers 404 for *every* route while the relay behind it
|
||||
* is down, so an outage at the wrong moment latched it against a relay that
|
||||
* does issue tokens, and every request thereafter went out with no token at
|
||||
* all. The 401 that brings callers here is direct evidence the conclusion
|
||||
* was wrong: a deployment that didn't want tokens would not refuse us for
|
||||
* lacking one.
|
||||
*/
|
||||
fun invalidate() {
|
||||
cached = null
|
||||
@@ -107,13 +84,6 @@ class RelayAuth(
|
||||
|
||||
/**
|
||||
* The proof headers for one request.
|
||||
*
|
||||
* `path` is the logical §8 path, not the URL's path. The relay mounts
|
||||
* these routes at both `/e2e/…` and under a REST prefix, and signs against
|
||||
* the former, so the proof must be computed over the same string whichever
|
||||
* mount the request is addressed to. Signing the URL path instead produces
|
||||
* a valid signature over the wrong thing, and the relay rejects it with no
|
||||
* hint as to why.
|
||||
*/
|
||||
fun proofHeaders(
|
||||
method: String,
|
||||
@@ -123,7 +93,10 @@ class RelayAuth(
|
||||
): Map<String, String> {
|
||||
val timestamp = now.toString()
|
||||
val nonce = UUID.randomUUID().toString()
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(body)
|
||||
val digest =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest(body)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
val canonical = "$method\n$path\n$timestamp\n$nonce\n$digest".toByteArray(Charsets.UTF_8)
|
||||
return mapOf(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,36 +7,13 @@ import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* What a relay says about itself (`GET /.well-known/flamenet/relay`).
|
||||
*
|
||||
* A client must know where accounts live before it sends a credential
|
||||
* anywhere. The two modes stretch the passphrase differently and send the
|
||||
* result to different hosts; guessing wrong means sending an authentication
|
||||
* secret to a service that was never meant to receive it.
|
||||
*
|
||||
* This replaces a hardcoded list of hostnames. The list was safe but wrong: it
|
||||
* meant a self-hosted relay could never use the shared account service,
|
||||
* because it would never be on the list, so the "self-hosted, shared identity"
|
||||
* deployment shape was unreachable in practice.
|
||||
*/
|
||||
object RelayDiscovery {
|
||||
|
||||
/**
|
||||
* Relays whose account service is pinned here and not taken from them.
|
||||
*
|
||||
* These are ours, and what they advertise is ignored: a compromise of
|
||||
* one must not be able to redirect its users' passphrase-derived secrets to
|
||||
* a host the attacker controls.
|
||||
*
|
||||
* Every other relay is believed, and that is deliberate rather than
|
||||
* lax. A self-hoster running Messenger and Files together wants one account
|
||||
* across both, held by their own service, the file server has taken
|
||||
* `ENCLAVE_ACCOUNTS_URL` for exactly that since it shipped, and a client
|
||||
* that refused the relay's answer would make "run both" mean "run them with
|
||||
* two separate logins". The user typed that hostname themselves; a server
|
||||
* they chose naming its own account service is a choice they already made,
|
||||
* and it cannot redirect anybody else's users.
|
||||
*/
|
||||
val PINNED_RELAYS = mapOf(
|
||||
val PINNED_RELAYS =
|
||||
mapOf(
|
||||
"flamenet.io" to OUR_ACCOUNT_SERVICE,
|
||||
"www.flamenet.io" to OUR_ACCOUNT_SERVICE,
|
||||
"messenger.flamenet.io" to OUR_ACCOUNT_SERVICE,
|
||||
@@ -59,11 +36,6 @@ object RelayDiscovery {
|
||||
|
||||
/**
|
||||
* The service to actually authenticate against.
|
||||
*
|
||||
* For a relay we pin, ours, whatever it advertised. For anyone
|
||||
* else, what they advertised. That asymmetry is the whole rule: our
|
||||
* relays cannot be made to redirect their users, and a relay somebody
|
||||
* chose to type can name its own service.
|
||||
*/
|
||||
val effectiveAccountsUrl: String?
|
||||
get() = PINNED_RELAYS[audience.lowercase()] ?: accountsUrl
|
||||
@@ -71,30 +43,43 @@ object RelayDiscovery {
|
||||
|
||||
/**
|
||||
* Ask a relay what it is.
|
||||
*
|
||||
* Returns null when the relay does not answer, an older deployment, or
|
||||
* something that is not a relay at all. The caller then falls back to
|
||||
* local, which is the safe default: it sends the passphrase only to the
|
||||
* server the user typed in.
|
||||
*/
|
||||
fun describe(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Descriptor? {
|
||||
val url = baseUrl.trimEnd('/').toHttpUrl().newBuilder()
|
||||
.addPathSegments(".well-known/flamenet/relay").build()
|
||||
fun describe(
|
||||
baseUrl: String,
|
||||
http: OkHttpClient = RelayClient.defaultHttpClient(),
|
||||
): Descriptor? {
|
||||
val url =
|
||||
baseUrl
|
||||
.trimEnd('/')
|
||||
.toHttpUrl()
|
||||
.newBuilder()
|
||||
.addPathSegments(".well-known/flamenet/relay")
|
||||
.build()
|
||||
return try {
|
||||
http.newCall(Request.Builder().url(url).get().build()).execute().use { response ->
|
||||
http
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val json = JSONObject(response.body?.string().orEmpty())
|
||||
val versions = json.optJSONArray("protocolVersions")
|
||||
Descriptor(
|
||||
audience = json.optString("audience"),
|
||||
accounts = if (json.optString("accounts") == "shared") {
|
||||
accounts =
|
||||
if (json.optString("accounts") == "shared") {
|
||||
Accounts.SHARED
|
||||
} else {
|
||||
Accounts.LOCAL
|
||||
},
|
||||
accountsUrl = json.optString("accountsURL").takeIf { it.isNotEmpty() },
|
||||
federation = json.optString("federation", "closed"),
|
||||
protocolVersions = (0 until (versions?.length() ?: 0))
|
||||
protocolVersions =
|
||||
(0 until (versions?.length() ?: 0))
|
||||
.map { versions!!.getInt(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,126 +2,116 @@ package io.flamenet.client
|
||||
|
||||
/**
|
||||
* What can go wrong talking to a relay.
|
||||
*
|
||||
* Typed rather than a single message-carrying exception because two of these
|
||||
* are decisions, not diagnostics: [NotFound] tells [RelayAuth] this deployment
|
||||
* mints no tokens, and [Unauthorized] triggers exactly one retry. A caller that
|
||||
* has to match on a string to make those decisions eventually matches wrong.
|
||||
*/
|
||||
sealed class RelayException(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
||||
|
||||
sealed class RelayException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
) : Exception(message, cause) {
|
||||
/** The network, TLS, or a response that was not HTTP at all. */
|
||||
class Transport(message: String, cause: Throwable? = null) : RelayException(message, cause)
|
||||
class Transport(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
) : RelayException(message, cause)
|
||||
|
||||
/** 401. The caller discards its token and tries once more. */
|
||||
class Unauthorized(val body: String) : RelayException("relay refused the credential")
|
||||
class Unauthorized(
|
||||
val body: String,
|
||||
) : RelayException("relay refused the credential")
|
||||
|
||||
/**
|
||||
* 404. On the token route this means "this deployment has no token
|
||||
* endpoint", which is a supported configuration and not an error.
|
||||
* 404.
|
||||
*/
|
||||
class NotFound(val path: String) : RelayException("no such route: $path")
|
||||
class NotFound(
|
||||
val path: String,
|
||||
) : RelayException("no such route: $path")
|
||||
|
||||
/** 429, with the relay's own retry hint when it gave one. */
|
||||
class RateLimited(val retryAfterSeconds: Int?) :
|
||||
RelayException("rate limited" + (retryAfterSeconds?.let { ", retry in ${it}s" } ?: ""))
|
||||
class RateLimited(
|
||||
val retryAfterSeconds: Int?,
|
||||
) : RelayException("rate limited" + (retryAfterSeconds?.let { ", retry in ${it}s" } ?: ""))
|
||||
|
||||
/**
|
||||
* 451, this service is not offered in the caller's country.
|
||||
*
|
||||
* Its own type because the generic one reads as a broken app. The
|
||||
* edge geo-gate answers 451 before the request reaches the relay, and the
|
||||
* download site is deliberately NOT gated, so somebody can read the page,
|
||||
* install the app, choose a password, tap sign up, and be shown "relay
|
||||
* returned 451". Nothing in that mentions their country, and it is not a
|
||||
* failure they can retry past, so the only sensible reading is that the app
|
||||
* does not work.
|
||||
*/
|
||||
class Unavailable(val body: String) : RelayException(
|
||||
class Unavailable(
|
||||
val body: String,
|
||||
) : RelayException(
|
||||
"Flamenet isn't available in your country yet. " +
|
||||
"This isn't a problem with your account or your connection.")
|
||||
"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(
|
||||
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.")
|
||||
"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(
|
||||
class ChallengeFailed(
|
||||
val body: String,
|
||||
) : RelayException(
|
||||
"That check took too long. Tap Create account again and it will " +
|
||||
"start a fresh one.")
|
||||
"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 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")
|
||||
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)
|
||||
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(
|
||||
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.")
|
||||
"anyway. It will seal again once they message you.",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 for this module.
|
||||
*
|
||||
* The engine keeps its own helper `internal` on purpose, its public surface is
|
||||
* the protocol, not its utilities, and widening that so the transport can
|
||||
* reach in would be the tail wagging the dog. Base64 has one right answer, so a
|
||||
* second call site cannot drift.
|
||||
*/
|
||||
internal object Wire {
|
||||
fun b64(data: ByteArray): String = java.util.Base64.getEncoder().encodeToString(data)
|
||||
fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decode(text)
|
||||
fun b64(data: ByteArray): String =
|
||||
java.util.Base64
|
||||
.getEncoder()
|
||||
.encodeToString(data)
|
||||
|
||||
/// The engine keeps its own `Bytes.sha256` `internal`, for the reason
|
||||
/// above: its public surface is the protocol, not its utilities. §21.4
|
||||
/// needs one here to hash a capability before uploading it, and SHA-256 has
|
||||
/// one right answer, so a second call site cannot drift.
|
||||
fun unb64(text: String): ByteArray =
|
||||
java.util.Base64
|
||||
.getDecoder()
|
||||
.decode(text)
|
||||
|
||||
// / The engine keeps its own `Bytes.sha256` `internal`, for the reason
|
||||
// / above: its public surface is the protocol, not its utilities. §21.4
|
||||
// / needs one here to hash a capability before uploading it, and SHA-256 has
|
||||
// / one right answer, so a second call site cannot drift.
|
||||
fun sha256(data: ByteArray): ByteArray =
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(data)
|
||||
java.security.MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest(data)
|
||||
}
|
||||
|
||||
@@ -2,48 +2,16 @@ package io.flamenet.client
|
||||
|
||||
/**
|
||||
* Which relay this is, beyond which certificate it presented (ios#19).
|
||||
*
|
||||
* TLS answers "is this host who DNS says it is". It does not answer "is
|
||||
* this the same relay I have been talking to". A mis-issued certificate, a
|
||||
* hijacked DNS record, or a hostile party who obtains the domain all produce a
|
||||
* server with a perfectly valid certificate for the right name, and every one
|
||||
* of them is a different relay wearing the same address.
|
||||
*
|
||||
* The relay publishes an issuer public key in its descriptor. Pinning that on
|
||||
* first contact turns "the certificate is valid" into "this is the same relay",
|
||||
* which is a materially stronger statement and the one that matters.
|
||||
*
|
||||
* Android already pinned this key and drew the wrong conclusion from it.
|
||||
* `Trust.verifyHeadSignature` pins `issuerPublicKey` the first time a signed log
|
||||
* head arrives, and if the key later changes the signature simply fails to
|
||||
* verify, so the client raises `KeyLogForkException`, sets `forkDetected`, and
|
||||
* tells somebody the relay's key log does not add up.
|
||||
*
|
||||
* A rotated relay key is not a forked log. They need different responses: a
|
||||
* fork means the relay is showing two different histories and nothing it says
|
||||
* can be trusted; a rotation means somebody ran a command, or somebody else is
|
||||
* answering on that address. Reporting the second as the first is both alarming
|
||||
* and unactionable, and an alarm people cannot act on is one they learn to
|
||||
* dismiss, which costs the real fork detection its credibility too.
|
||||
*
|
||||
* A relay that publishes no key is a weaker deployment, not an attack.
|
||||
* Somebody self-hosting an older build has nothing to pin, and refusing to talk
|
||||
* to them would be a worse failure than the one this prevents. That case is
|
||||
* distinguished from a key that CHANGED, which is not weakness.
|
||||
*
|
||||
* A change is surfaced, never silently accepted and never silently refused.
|
||||
* The same shape as a contact's identity changing: stop, say so, and let a
|
||||
* person decide, because a legitimate rotation exists and an app that cannot
|
||||
* survive one is an app that gets uninstalled.
|
||||
*/
|
||||
object RelayIdentityPin {
|
||||
|
||||
/** What a descriptor's key means against what was pinned before. */
|
||||
sealed interface Verdict {
|
||||
/** Nothing pinned and a key published: pin it. */
|
||||
data class FirstSight(val offered: ByteArray) : Verdict {
|
||||
override fun equals(other: Any?) =
|
||||
other is FirstSight && offered.contentEquals(other.offered)
|
||||
data class FirstSight(
|
||||
val offered: ByteArray,
|
||||
) : Verdict {
|
||||
override fun equals(other: Any?) = other is FirstSight && offered.contentEquals(other.offered)
|
||||
|
||||
override fun hashCode() = offered.contentHashCode()
|
||||
}
|
||||
|
||||
@@ -54,41 +22,43 @@ object RelayIdentityPin {
|
||||
data object NotPublished : Verdict
|
||||
|
||||
/**
|
||||
* A different key than the one pinned. Either a deliberate rotation
|
||||
* or a different server answering on this address, and nothing here can
|
||||
* tell those apart, only a person can.
|
||||
* A different key than the one pinned.
|
||||
*/
|
||||
data class Changed(val pinned: ByteArray, val offered: ByteArray) : Verdict {
|
||||
override fun equals(other: Any?) = other is Changed &&
|
||||
data class Changed(
|
||||
val pinned: ByteArray,
|
||||
val offered: ByteArray,
|
||||
) : Verdict {
|
||||
override fun equals(other: Any?) =
|
||||
other is Changed &&
|
||||
pinned.contentEquals(other.pinned) && offered.contentEquals(other.offered)
|
||||
|
||||
override fun hashCode() = 31 * pinned.contentHashCode() + offered.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare what the relay just published against what is pinned.
|
||||
*
|
||||
* An empty or unparseable key counts as "not published" rather than as a
|
||||
* change. A relay that answers with rubbish is broken, and treating broken
|
||||
* as hostile would raise an alarm nobody can act on.
|
||||
*/
|
||||
fun check(offeredBase64: String?, pinned: ByteArray?): Verdict {
|
||||
val offered = offeredBase64
|
||||
fun check(
|
||||
offeredBase64: String?,
|
||||
pinned: ByteArray?,
|
||||
): Verdict {
|
||||
val offered =
|
||||
offeredBase64
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { runCatching { Wire.unb64(it) }.getOrNull() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return Verdict.NotPublished
|
||||
if (pinned == null || pinned.isEmpty()) return Verdict.FirstSight(offered)
|
||||
return if (offered.contentEquals(pinned)) Verdict.Unchanged
|
||||
else Verdict.Changed(pinned = pinned, offered = offered)
|
||||
return if (offered.contentEquals(pinned)) {
|
||||
Verdict.Unchanged
|
||||
} else {
|
||||
Verdict.Changed(pinned = pinned, offered = offered)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to tell somebody when it changed.
|
||||
*
|
||||
* Names the two possibilities and does not pretend to know which. An
|
||||
* alarm that asserts an attack when a rotation is equally likely trains
|
||||
* people to dismiss alarms.
|
||||
*/
|
||||
fun warning(host: String): String =
|
||||
"The server at $host is presenting a different identity than the one " +
|
||||
@@ -101,12 +71,7 @@ object RelayIdentityPin {
|
||||
|
||||
/**
|
||||
* The relay is not the one this app pinned.
|
||||
*
|
||||
* Deliberately NOT a [KeyLogForkException]. A fork means the relay is
|
||||
* showing two different histories and nothing it says can be trusted. This means
|
||||
* the server's identity key is not the one recorded, a rotation somebody
|
||||
* performed, or somebody else's server. The remedies are different, so the
|
||||
* exceptions are different.
|
||||
*/
|
||||
class RelayIdentityChangedException(val host: String) :
|
||||
RelayException(RelayIdentityPin.warning(host))
|
||||
class RelayIdentityChangedException(
|
||||
val host: String,
|
||||
) : RelayException(RelayIdentityPin.warning(host))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user