Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1d4b0ff41 | ||
|
|
332d3803e9 | ||
|
|
fed6388de7 | ||
|
|
d72a852899 |
@@ -43,8 +43,8 @@ android {
|
||||
// is a label. Code 24 with name 0.24 closes it by skipping the name
|
||||
// 0.23, which costs nothing because a name is a label and may skip.
|
||||
// From here they move together and match: 25 with 0.25, 26 with 0.26.
|
||||
versionCode = 30
|
||||
versionName = "0.30"
|
||||
versionCode = 31
|
||||
versionName = "0.31"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
||||
@@ -740,7 +740,11 @@ fun ColumnScope.AccountScreen(
|
||||
SettingsSection { ContactListPrivacySection(model) }
|
||||
|
||||
PortalBand()
|
||||
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) }
|
||||
SettingsSection {
|
||||
// Beside the address it replaced (audit F5).
|
||||
phase.addressChangedFrom?.let { AddressChangedNotice(it) }
|
||||
AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle)
|
||||
}
|
||||
|
||||
PortalBand()
|
||||
SettingsSection { MailboxesSection(model, phase) }
|
||||
@@ -1691,6 +1695,8 @@ fun ColumnScope.ChatScreen(
|
||||
* changed.
|
||||
*/
|
||||
heldMessages: Map<Long, Int> = emptyMap(),
|
||||
/** Conversations where a send could not go sealed (§14.5). */
|
||||
unsealedSendAt: Map<Long, Long> = emptyMap(),
|
||||
) {
|
||||
// Keyed on the thread, so switching conversations re-reads rather than
|
||||
// carrying one person's half-written message into somebody else's composer.
|
||||
@@ -1818,6 +1824,13 @@ fun ColumnScope.ChatScreen(
|
||||
|
||||
// One line, not three panels.
|
||||
if (chat.peerId != null) ConversationStatusLine(model, chat)
|
||||
|
||||
// §14.5, told at the time rather than only counted in Privacy.
|
||||
if (chat.peerId != null && unsealedSendAt[chat.peerId] != null) {
|
||||
val row = buddies.firstOrNull { it.userId == chat.peerId }
|
||||
val name = row?.displayName?.ifBlank { row.handle }.orEmpty()
|
||||
UnsealedSendNotice(name.ifBlank { "This person" })
|
||||
}
|
||||
}
|
||||
|
||||
// §6.15/§6.19, which message a sheet is currently about.
|
||||
|
||||
@@ -308,6 +308,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
myUserId = current.userId,
|
||||
groups = current.groups,
|
||||
heldMessages = current.heldMessages,
|
||||
unsealedSendAt = current.unsealedSendAt,
|
||||
)
|
||||
} else {
|
||||
// The content area is weighted, and the tab bar is not.
|
||||
|
||||
@@ -109,6 +109,17 @@ class MessengerViewModel(
|
||||
* not be derived.
|
||||
*/
|
||||
val fnid: String? = null,
|
||||
/**
|
||||
* The address this device had before this sign-in derived a
|
||||
* different one (audit F5). Null is the ordinary case. See
|
||||
* [AddressChange].
|
||||
*/
|
||||
val addressChangedFrom: String? = null,
|
||||
/**
|
||||
* Conversations where a send could not go sealed, and when
|
||||
* (§14.5, audit F2). In memory only. See [UnsealedSends].
|
||||
*/
|
||||
val unsealedSendAt: Map<Long, Long> = emptyMap(),
|
||||
val buddies: List<MessengerApi.Buddy> = emptyList(),
|
||||
/**
|
||||
* Whether the contact list was actually FETCHED, as opposed to
|
||||
@@ -633,6 +644,10 @@ class MessengerViewModel(
|
||||
runCatching { AccountIdentity.claim(relay, accountKeySeed, account.userId) }
|
||||
}
|
||||
|
||||
// Compared before it is saved, not only saved: the new fnid used to
|
||||
// replace the old one here without anything looking at either.
|
||||
val addressChangedFrom = AddressChange.previousAddress(previous, sameAccount, fnid, config.host)
|
||||
|
||||
vault.saveCredential(
|
||||
Vault.Credential(
|
||||
relayBaseUrl = config.baseUrl,
|
||||
@@ -672,6 +687,7 @@ class MessengerViewModel(
|
||||
deviceId = device.deviceId,
|
||||
relay = config.host,
|
||||
fnid = fnid,
|
||||
addressChangedFrom = addressChangedFrom,
|
||||
status = "Registered with ${preKeys.size} one-time prekeys published.",
|
||||
// The switch has to read what is actually enforced.
|
||||
requireKeyLog = strictKeyLog,
|
||||
@@ -3562,8 +3578,16 @@ class MessengerViewModel(
|
||||
private fun wireSealingPolicy(session: Session) {
|
||||
val settings = MediaSettings(getApplication())
|
||||
session.conversations.requireSealed = { settings.requireSealedSending }
|
||||
session.onSendSealed = { wasSealed, at ->
|
||||
session.onSendSealed = { peerUserId, wasSealed, at ->
|
||||
settings.sealingRecord = settings.sealingRecord.recording(wasSealed, at)
|
||||
// And on the conversation, not only in a counter somebody has to go
|
||||
// looking for. On the main thread, because `update` reads and then
|
||||
// writes the whole state and a send finishes on IO.
|
||||
if (!wasSealed) {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
update { it.copy(unsealedSendAt = UnsealedSends.record(it.unsealedSendAt, peerUserId, wasSealed, at)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.Vault
|
||||
|
||||
/**
|
||||
* Two things the iPhone app started saying on 2026-09-23 (audit F2 and F5),
|
||||
* said here too. Both were facts the app already had and kept to itself.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The address this device had before this sign-in derived a different one, or
|
||||
* null, which is the ordinary case (finding F5).
|
||||
*
|
||||
* The account key comes from the passphrase and a salt the account service
|
||||
* holds and hands back at every sign-in. It is never written to this phone. So
|
||||
* that service can serve a different salt, the phone derives a different fnid,
|
||||
* and the same service verifies it: the sign-in succeeds and the person now
|
||||
* holds a different identity. The new fnid used to replace the old one in the
|
||||
* saved credential without anything comparing the two, which made the
|
||||
* substitution silent, and silence is what makes it worth doing.
|
||||
*
|
||||
* Only for the SAME account on the SAME relay. A different account on a shared
|
||||
* phone is a different person, not a changed address.
|
||||
*/
|
||||
object AddressChange {
|
||||
fun previousAddress(
|
||||
previous: Vault.Credential?,
|
||||
sameAccount: Boolean,
|
||||
fnid: String?,
|
||||
host: String,
|
||||
): String? {
|
||||
if (previous == null || !sameAccount) return null
|
||||
val was = previous.fnid ?: return null
|
||||
// No new address is not a changed one: it means this sign-in could not
|
||||
// derive one, and AddressCard already says so.
|
||||
val now = fnid ?: return null
|
||||
if (was == now) return null
|
||||
return "$was@$host"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversations where a send fell back from sealed to identified, and when
|
||||
* (SPEC §14.5, finding F2).
|
||||
*
|
||||
* §14.5: "a user who has enabled sealed sending SHOULD be told when a message
|
||||
* could not be sent sealed." The sealing record in Privacy settings counts
|
||||
* the fallback, which answers "how often" and never "did this one".
|
||||
*
|
||||
* In memory only, as on iOS. The fallback is a fact about a moment, the notice
|
||||
* exists to be seen at that moment, and the running total already carries it
|
||||
* across restarts. A later sealed send does not clear it: the message that went
|
||||
* identified still did.
|
||||
*/
|
||||
object UnsealedSends {
|
||||
fun record(
|
||||
seen: Map<Long, Long>,
|
||||
peerUserId: Long,
|
||||
wasSealed: Boolean,
|
||||
at: Long,
|
||||
): Map<Long, Long> = if (wasSealed) seen else seen + (peerUserId to at)
|
||||
}
|
||||
|
||||
/**
|
||||
* Said in the conversation where it happened. A note, not an alarm: nothing
|
||||
* was readable by anyone, and the usual cause is the other person reinstalling.
|
||||
* Same words as the iPhone app.
|
||||
*/
|
||||
@Composable
|
||||
fun UnsealedSendNotice(name: String) {
|
||||
Surface(
|
||||
color = Palette.SurfaceAlt,
|
||||
shape = RoundedCornerShape(Radius.medium),
|
||||
modifier = Modifier.fillMaxWidth().testTag("unsealed-send"),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(Space.md)) {
|
||||
Text(
|
||||
"The server was told you sent this",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"$name's delivery key had gone stale, so this message could not be sent " +
|
||||
"sealed. It is encrypted either way; what changed is that the server " +
|
||||
"knows it came from you. It usually sorts itself out once they write back.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Beside the address it replaced. Same words as the iPhone app. */
|
||||
@Composable
|
||||
fun AddressChangedNotice(was: String) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(bottom = Space.md).testTag("address-changed")) {
|
||||
Text(
|
||||
"Your address changed",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"This device used to be $was. Signing in just now produced a different " +
|
||||
"address. That happens if you changed your passphrase. If you did not, the " +
|
||||
"account server has given this device a different identity, and people who " +
|
||||
"had your old address can no longer reach you.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -533,8 +533,9 @@ class Session(
|
||||
lifetimeSeconds = lifetimeSeconds,
|
||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING,
|
||||
)
|
||||
// ios#16, every send's outcome, not just this one's.
|
||||
onSendSealed(sent.sealed, at)
|
||||
// ios#16, every send's outcome, not just this one's. And who it was to,
|
||||
// so the conversation can say so when it was not sealed (§14.5).
|
||||
onSendSealed(peerUserId, sent.sealed, at)
|
||||
// Queued, not abandoned.
|
||||
if (!sent.reachedSomeone) enqueue(peerUserId, plaintext, at)
|
||||
return sent
|
||||
@@ -1675,9 +1676,10 @@ class Session(
|
||||
}
|
||||
|
||||
/**
|
||||
* Told after every 1:1 send whether it was sealed (ios#16).
|
||||
* Told after every 1:1 send who it was to and whether it was sealed
|
||||
* (ios#16).
|
||||
*/
|
||||
var onSendSealed: (Boolean, Long) -> Unit = { _, _ -> }
|
||||
var onSendSealed: (peerUserId: Long, wasSealed: Boolean, at: Long) -> Unit = { _, _, _ -> }
|
||||
|
||||
fun reactionsFor(storyKey: String): Map<Long, String> = storyReactions[storyKey].orEmpty()
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import io.flamenet.e2e.Vault
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Audit F2 and F5 on Android: two facts the app had and kept to itself are
|
||||
* said at the time, as the iPhone app has said them since 2026-09-23.
|
||||
*
|
||||
* F2, SPEC §14.5: a message that could not go sealed is named in its
|
||||
* conversation, not only counted in Privacy settings.
|
||||
*
|
||||
* F5: an address that comes back different at sign-in is compared with the
|
||||
* one it replaces and shown beside it, rather than overwritten unseen.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class TheAppSaysItAtTheTimeTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun credential(
|
||||
fnid: String?,
|
||||
userId: Long = 7,
|
||||
) = Vault.Credential(
|
||||
relayBaseUrl = "https://relay.example",
|
||||
username = "ada",
|
||||
appPassword = "x",
|
||||
userId = userId,
|
||||
fnid = fnid,
|
||||
)
|
||||
|
||||
// -- F5, which address changed ------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `a different address on the same account is named, with the old one`() {
|
||||
val was = AddressChange.previousAddress(credential("fnid:old"), sameAccount = true, fnid = "fnid:new", host = "relay.example")
|
||||
assertEquals("fnid:old@relay.example", was)
|
||||
}
|
||||
|
||||
/** The control: without it, a function that always answered would pass. */
|
||||
@Test
|
||||
fun `the same address is not a change`() {
|
||||
assertNull(AddressChange.previousAddress(credential("fnid:same"), sameAccount = true, fnid = "fnid:same", host = "relay.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a first sign-in, another account, or no address at all is not a change`() {
|
||||
assertNull(AddressChange.previousAddress(null, sameAccount = true, fnid = "fnid:new", host = "h"), "first sign-in")
|
||||
assertNull(
|
||||
AddressChange.previousAddress(credential("fnid:old"), sameAccount = false, fnid = "fnid:new", host = "h"),
|
||||
"somebody else signing in on a shared phone is not a changed address",
|
||||
)
|
||||
assertNull(AddressChange.previousAddress(credential(null), sameAccount = true, fnid = "fnid:new", host = "h"), "nothing saved before")
|
||||
assertNull(AddressChange.previousAddress(credential("fnid:old"), sameAccount = true, fnid = null, host = "h"), "nothing derived now")
|
||||
}
|
||||
|
||||
// -- F2, which conversation went unsealed --------------------------------.
|
||||
|
||||
@Test
|
||||
fun `an unsealed send names its conversation and a sealed one does not`() {
|
||||
val none = emptyMap<Long, Long>()
|
||||
assertEquals(none, UnsealedSends.record(none, peerUserId = 4, wasSealed = true, at = 100))
|
||||
assertEquals(mapOf(4L to 100L), UnsealedSends.record(none, peerUserId = 4, wasSealed = false, at = 100))
|
||||
}
|
||||
|
||||
/** The message that went identified still did, whatever the next one does. */
|
||||
@Test
|
||||
fun `a later sealed send does not take it back`() {
|
||||
val after = UnsealedSends.record(mapOf(4L to 100L), peerUserId = 4, wasSealed = true, at = 200)
|
||||
assertEquals(mapOf(4L to 100L), after)
|
||||
}
|
||||
|
||||
// -- What they say --------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
fun `the unsealed notice names the person and says it is still encrypted`() {
|
||||
compose.setContent { UnsealedSendNotice("Grace") }
|
||||
compose.onNodeWithTag("unsealed-send").assertIsDisplayed()
|
||||
compose.onNodeWithText("The server was told you sent this").assertIsDisplayed()
|
||||
compose.onNodeWithText("Grace's delivery key had gone stale", substring = true).assertIsDisplayed()
|
||||
compose.onNodeWithText("It is encrypted either way", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing was readable by anyone, so the notice must not suggest it was.
|
||||
* Read from the notice's own source, bounded, because ChatScreens carries
|
||||
* other notices where some of these words are correct.
|
||||
*/
|
||||
@Test
|
||||
fun `the unsealed notice does not overstate what happened`() {
|
||||
val source = File("src/main/kotlin/io/flamenet/messenger/app/SaidAtTheTime.kt").readText()
|
||||
val start = source.indexOf("fun UnsealedSendNotice")
|
||||
assertTrue(start >= 0, "SaidAtTheTime.kt was not read")
|
||||
val notice = source.substring(start, minOf(source.length, start + 1400)).lowercase()
|
||||
for (forbidden in listOf("not encrypted", "unencrypted", "in the clear", "insecure")) {
|
||||
assertFalse(notice.contains(forbidden), "the notice suggests the message was readable: \"$forbidden\"")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the address notice shows the address it replaced`() {
|
||||
compose.setContent { AddressChangedNotice("fnid:old@relay.example") }
|
||||
compose.onNodeWithTag("address-changed").assertIsDisplayed()
|
||||
compose.onNodeWithText("Your address changed").assertIsDisplayed()
|
||||
compose.onNodeWithText("This device used to be fnid:old@relay.example.", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
// -- Wired, not only defined ---------------------------------------------.
|
||||
//
|
||||
// Each of these is a call site on a path that nothing else would notice
|
||||
// losing: the message still sends and the sign-in still succeeds.
|
||||
|
||||
private fun main(name: String) = File("src/main/kotlin/io/flamenet/messenger/app/$name").readText()
|
||||
|
||||
@Test
|
||||
fun `the send tells the view model who it was to`() {
|
||||
assertTrue(main("Session.kt").contains("onSendSealed(peerUserId, sent.sealed, at)"))
|
||||
val vm = main("MessengerViewModel.kt")
|
||||
val wiring = vm.substring(vm.indexOf("private fun wireSealingPolicy"))
|
||||
assertTrue(
|
||||
wiring.take(1200).contains("UnsealedSends.record(it.unsealedSendAt, peerUserId, wasSealed, at)"),
|
||||
"the fallback is counted and no longer names the conversation",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the conversation reads what the send recorded`() {
|
||||
val chat = main("ChatScreens.kt")
|
||||
assertTrue(chat.contains("unsealedSendAt[chat.peerId] != null"), "the notice is not keyed on the record")
|
||||
assertTrue(chat.contains("UnsealedSendNotice(name.ifBlank"), "the notice is defined and never placed")
|
||||
assertTrue(
|
||||
main("MainActivity.kt").contains("unsealedSendAt = current.unsealedSendAt"),
|
||||
"the chat screen is never handed the record, so it reads an empty map",
|
||||
)
|
||||
}
|
||||
|
||||
/** Compared BEFORE the new address is saved, or there is nothing left to compare with. */
|
||||
@Test
|
||||
fun `sign-in compares the address before it saves the new one`() {
|
||||
val vm = main("MessengerViewModel.kt")
|
||||
val compared = vm.indexOf("AddressChange.previousAddress(previous, sameAccount, fnid, config.host)")
|
||||
assertTrue(compared >= 0, "sign-in no longer compares the address")
|
||||
// The next save after the comparison is the one right below it.
|
||||
val saved = vm.indexOf("vault.saveCredential(", compared)
|
||||
assertTrue(
|
||||
saved > compared && saved - compared < 600,
|
||||
"the comparison is not directly before the save, so it may compare the new address with itself",
|
||||
)
|
||||
assertTrue(vm.contains("addressChangedFrom = addressChangedFrom,"), "compared and then dropped")
|
||||
assertTrue(
|
||||
main("ChatScreens.kt").contains("phase.addressChangedFrom?.let { AddressChangedNotice(it) }"),
|
||||
"the notice is never placed beside the address",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user