Count the sealed-to-unsealed fallback, and offer to refuse it

Android half of ios#16. `Sent.sealed` already reported each send's
outcome, but nothing accumulated, so nobody could answer "how often does
this happen to me".

🔴 The copy distinguishes sender from content, with tests asserting it.
An unsealed send is still end-to-end encrypted; what the relay learns is
who sent it. Somebody who reads "sealed" as "encrypted" makes the wrong
decision in both directions.

⚠️ Aggregate only. Per-contact counts would be more useful and would also
build a record of who this account talks to and how often — the precise
thing sealing exists to keep off a server.

Refusing is offered and off by default: it turns a metadata leak into an
undelivered message, which for most people is the worse outcome. It has
its own exception type so the refusal can explain itself.

⚠️ Refusing spends nothing. `sealed` is false exactly when neither a
capability nor a key was found, so `takePeerToken` returned null and
there is no token to hand back — my first cut had a line handing one
back, which was unreachable.

⚠️ The policy and the counter are injected as lambdas, since `client` and
the engine have no Android dependency, and both are wired in one helper.
Two sign-in paths build a session, and only one of them applying the
policy is how a setting silently stops working on half the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 17:57:00 -04:00
co-authored by Claude Opus 4.8
parent 1316996ac5
commit 4c3f30e180
9 changed files with 284 additions and 0 deletions
@@ -737,6 +737,8 @@ fun ColumnScope.AccountScreen(
SettingsSection { PresencePicker(model, phase.presence) }
SectionBand()
// ios#16 — beside the other privacy settings.
SettingsSection { SealingSection(model) }
SettingsSection { StoryAudienceSection(model, phase) }
// ios#54 — beside the audience setting, because both answer "what happens
// to my stories".
@@ -1174,6 +1176,45 @@ fun ColumnScope.StoryArchiveSection(
}
}
/**
* The fallback from sealed to unsealed, which used to happen silently (ios#16).
*
* 🔴 Names exactly what sealing protects, and what it does not. Somebody who
* reads "sealed" as "encrypted" makes the wrong decision here in both
* directions — relaxed when it is off, and alarmed when it degrades on a
* message that was never readable by the server anyway.
*/
@Composable
fun ColumnScope.SealingSection(model: MessengerViewModel) {
val context = LocalContext.current
val settings = remember { MediaSettings(context) }
var refuse by remember { mutableStateOf(settings.requireSealedSending) }
val record = remember(refuse) { settings.sealingRecord }
Text("Who the server can see is sending", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Refuse to send if it can't be sealed", modifier = Modifier.weight(1f))
androidx.compose.material3.Switch(
checked = refuse,
onCheckedChange = { settings.requireSealedSending = it; refuse = it },
modifier = Modifier.testTag("require-sealed"),
)
}
Text(record.summary, fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.size(Space.sm))
Text(
"Your messages are always end-to-end encrypted. Sealing hides something " +
"further: which account sent them. It needs a key your contact's device " +
"supplies, so it can't always be done — usually before they have replied " +
"to you for the first time. Refusing means those messages don't go at all.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
@Composable
private fun ColumnScope.StoryAudienceSection(
model: MessengerViewModel,
@@ -61,6 +61,33 @@ class MediaSettings(context: Context) {
get() = prefs.getBoolean("stories.archiveEnabled", false)
set(value) { prefs.edit().putBoolean("stories.archiveEnabled", value).apply() }
/**
* Refuse to send at all when the send cannot be sealed (§14.5, ios#16).
*
* 🔴 **Off by default, and that is the honest default rather than the lazy
* one.** An unsealed send is still end-to-end encrypted; what leaks is that
* this account sent something to that one. Refusing turns a metadata leak
* into an undelivered message, which for most people is the worse outcome
* — so the default degrades and SAYS SO.
*/
var requireSealedSending: Boolean
get() = prefs.getBoolean("privacy.requireSealed", false)
set(value) { prefs.edit().putBoolean("privacy.requireSealed", value).apply() }
/** Counters for how often sealing degraded (ios#16). */
var sealingRecord: io.flamenet.e2e.SealingRecord
get() = io.flamenet.e2e.SealingRecord(
sealed = prefs.getInt("privacy.sealing.sealed", 0),
unsealed = prefs.getInt("privacy.sealing.unsealed", 0),
lastUnsealedAt = prefs.getLong("privacy.sealing.lastUnsealedAt", 0L))
set(value) {
prefs.edit()
.putInt("privacy.sealing.sealed", value.sealed)
.putInt("privacy.sealing.unsealed", value.unsealed)
.putLong("privacy.sealing.lastUnsealedAt", value.lastUnsealedAt)
.apply()
}
/**
* Refuse to send unless the relay can prove the recipient's keys appear in
* the log it publishes.
@@ -370,6 +370,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
it.archiveEnabled = {
MediaSettings(getApplication()).storyArchiveEnabled
}
wireSealingPolicy(it)
}
val fetchedBuddies = runCatching { session!!.roster() }
val fetchedGroups = runCatching { session!!.namedGroups() }
@@ -582,6 +583,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
).also {
it.archiveEnabled = { MediaSettings(getApplication()).storyArchiveEnabled }
wireSealingPolicy(it)
}
// 🔴 **Our return address, before the first federated send.** A relay
@@ -2125,6 +2127,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
update { it.copy(error = message) }
}
/**
* Wire the sealing policy and counters into a fresh session (ios#16).
*
* ⚠️ In one place, because two call sites building a `Session` and only
* one of them applying the policy is how a setting silently stops working
* on one of the two sign-in paths.
*/
private fun wireSealingPolicy(session: Session) {
val settings = MediaSettings(getApplication())
session.conversations.requireSealed = { settings.requireSealedSending }
session.onSendSealed = { wasSealed, at ->
settings.sealingRecord = settings.sealingRecord.recording(wasSealed, at)
}
}
/** Change who may see this account's stories. */
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
val current = session ?: return@launch
@@ -360,6 +360,11 @@ class Session(
// 🔴 The sender's own copy expires too. A timer that only cleared the
// recipient's phone would be the opposite of the promise: the person
// who set it would be the one still holding the message.
// ios#16 — every send's outcome, not just this one's. `Sent.sealed`
// answers "was that sealed"; the record answers "how often does this
// happen to me", which nobody could ask before.
onSendSealed(sent.sealed, at)
record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
link = link, lifetimeSeconds = lifetimeSeconds,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
@@ -1228,6 +1233,14 @@ class Session(
)
}
/**
* Told after every 1:1 send whether it was sealed (ios#16).
*
* ⚠️ Injected: the counters live in Android preferences and this layer has
* no Android dependency.
*/
var onSendSealed: (Boolean, Long) -> Unit = { _, _ -> }
/**
* Members of a group we are in, or null if the relay does not list it for
* us (audit H1). Cached for a short window: a poll pass can carry many