Count the sealed-to-unsealed fallback, and offer to refuse it (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, and the refusal is there for the people who would rather a message did not go than go attributable. The counters answer a question nobody could ask before. `Sent.sealed` says whether one send was sealed; the record says how often this happens to you, which is what somebody actually needs to decide whether the switch is worth throwing. ⚠️ Refusing costs nothing: `sealed` is false exactly when neither a capability nor a key was found, so there is no token to hand back. ⚠️ Both the policy and the counters are wired in ONE helper called from both sign-in paths. Two call sites building a `Session` and only one applying the policy is how a setting silently stops working on one of them. Taken by hand rather than cherry-picked — the original also rewrote a ChatScreens that has since changed underneath it. The settings switch for it is UI work still to come.
This commit is contained in:
@@ -104,4 +104,31 @@ class MediaSettings(context: Context) {
|
||||
val STORY_LIFETIME_CHOICES = listOf(1, 6, 24, 72, 168)
|
||||
private const val LINK_PREVIEWS = "link_previews"
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to send at all when the send cannot be sealed (§14.5, ios#16).
|
||||
*
|
||||
* 🔴 **Off by default, and that is the honest default rather than the lazy
|
||||
* one.** An unsealed send is still end-to-end encrypted; what leaks is that
|
||||
* this account sent something to that one. Refusing turns a metadata leak
|
||||
* into an undelivered message, which for most people is the worse outcome
|
||||
* — so the default degrades and SAYS SO.
|
||||
*/
|
||||
var requireSealedSending: Boolean
|
||||
get() = prefs.getBoolean("privacy.requireSealed", false)
|
||||
set(value) { prefs.edit().putBoolean("privacy.requireSealed", value).apply() }
|
||||
|
||||
/** Counters for how often sealing degraded (ios#16). */
|
||||
var sealingRecord: io.flamenet.e2e.SealingRecord
|
||||
get() = io.flamenet.e2e.SealingRecord(
|
||||
sealed = prefs.getInt("privacy.sealing.sealed", 0),
|
||||
unsealed = prefs.getInt("privacy.sealing.unsealed", 0),
|
||||
lastUnsealedAt = prefs.getLong("privacy.sealing.lastUnsealedAt", 0L))
|
||||
set(value) {
|
||||
prefs.edit()
|
||||
.putInt("privacy.sealing.sealed", value.sealed)
|
||||
.putInt("privacy.sealing.unsealed", value.unsealed)
|
||||
.putLong("privacy.sealing.lastUnsealedAt", value.lastUnsealedAt)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +361,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
api = MessengerApi(
|
||||
credential.relayBaseUrl, credential.username, credential.appPassword),
|
||||
)
|
||||
session?.let { wireSealingPolicy(it) }
|
||||
val fetchedBuddies = runCatching { session!!.roster() }
|
||||
val fetchedGroups = runCatching { session!!.namedGroups() }
|
||||
stored to Phase.SignedIn(
|
||||
@@ -571,6 +572,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
relay = relay,
|
||||
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
|
||||
)
|
||||
session?.let { wireSealingPolicy(it) }
|
||||
|
||||
// 🔴 **Our return address, before the first federated send.** A relay
|
||||
// that accepts a federated envelope records no sender, and our user id
|
||||
@@ -2052,6 +2054,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the sealing policy and counters into a fresh session (ios#16).
|
||||
*
|
||||
* ⚠️ In one place, because two call sites building a `Session` and only
|
||||
* one of them applying the policy is how a setting silently stops working
|
||||
* on one of the two sign-in paths.
|
||||
*/
|
||||
private fun wireSealingPolicy(session: Session) {
|
||||
val settings = MediaSettings(getApplication())
|
||||
session.conversations.requireSealed = { settings.requireSealedSending }
|
||||
session.onSendSealed = { wasSealed, at ->
|
||||
settings.sealingRecord = settings.sealingRecord.recording(wasSealed, at)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reasons a report may give (§6.25).
|
||||
*
|
||||
|
||||
@@ -384,6 +384,10 @@ class Session(
|
||||
record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
|
||||
link = link, messageId = mid, lifetimeSeconds = lifetimeSeconds,
|
||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
||||
// ios#16 — every send's outcome, not just this one's. `Sent.sealed`
|
||||
// answers "was that sealed"; the record answers "how often does this
|
||||
// happen to me", which nobody could ask before.
|
||||
onSendSealed(sent.sealed, at)
|
||||
// 🔴 Queued, not abandoned. A friend who is simply signed out should
|
||||
// not cost the sender a reminder to try again later.
|
||||
//
|
||||
@@ -1158,6 +1162,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 = { _, _ -> }
|
||||
|
||||
fun reactionsFor(storyKey: String): Map<Long, String> = storyReactions[storyKey].orEmpty()
|
||||
|
||||
fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId)
|
||||
|
||||
Reference in New Issue
Block a user