Open a stored account from the phone, and let the relay catch up after
DESIGN 47. A start with stored credentials waited on the relay for the contact list and the groups before leaving the spinner: up to its call timeout twice with the network down, and then without any groups, since those came only from the relay. The chats now open from what is on the phone, the stored contacts and the groups as last listed, which the phone now keeps, and the relay's lists replace them when it answers. The saved Today page already opened from disk. Sign-in still shows only with no stored credentials. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5d01528bcc
commit
db59a61d52
@@ -1248,6 +1248,7 @@ on 2026-10-04.
|
||||
| Parental controls hide the Today tab and the directory (DESIGN 31) | yes | yes | iOS `ParentalControls.swift`, `MainTabView.swift`; Android `ParentalControls.todayHidden`, `NavDestinationsTest` |
|
||||
| Sign-out and a lost-device wipe clear the Today file and the weather picks, and a fetch still on its way is dropped when it returns (DESIGN 32) | yes | yes | iOS `TodayStore.forgetOnThisPhone`, `TodayStore.generation`; Android `TodayData.forget`, the `TodayFeed` epoch, `TodayDataIsClearedTest`, `TodayFeedTest` |
|
||||
| Edits, reactions, pins and story reactions go with the relay's silent flag and never notify (owner, 0.32) | partly | yes | iOS `E2EInbox.warrantsAlert` at `d7d0c83e` wakes for any envelope with text, an edit among them, and the owner has approved making all four silent there; Android `Conversations.warrantsAlert`, every kind pinned in `SilentKindsTest` |
|
||||
| A stored account opens to its chats and the saved Today page with the relay out of reach, and connects after (DESIGN 47) | partly | yes | iOS is being changed to do so; Android `Session.storedRoster`, `Session.storedGroups`, `catchUpAfterStart`, `StoredStartStateTest` |
|
||||
| A snoozed chat holds every notification, not only a game's | no | yes | Owner, 0.32. iOS `NotificationManager.postIncomingMessage` checks mute and never snooze, though `AttentionSummary` says a snoozed chat "stops asking"; Android `NotificationSettings.due`, `ASnoozedChatHoldsItsNotificationsTest` |
|
||||
| A group photo that reached nobody is queued, and leaves pending when it goes | yes | yes | iOS `E2EInbox.sendGroupImage`; Android `Session.sendGroupImage`, `OutboxTest` |
|
||||
| A photo, voice note or file to one person, and a file to a group, that reached nobody is queued and leaves pending when it goes (owner, 0.32) | no | yes | iOS `E2EInbox.sendAttachment` and `sendFile` stop with an error when the person has no device, and nothing waits; iOS has no group file send; Android `Session.sendUploaded`, `Session.sendGroupFile`, `OutboxTest` |
|
||||
|
||||
@@ -392,8 +392,12 @@ class MessengerViewModel(
|
||||
// So a story that comes down takes its decrypted bytes with it.
|
||||
it.attachmentCacheDir = getApplication<Application>().cacheDir
|
||||
}
|
||||
val fetchedBuddies = runCatching { session!!.roster() }
|
||||
val fetchedGroups = runCatching { session!!.namedGroups() }
|
||||
// DESIGN 47: from what is on this phone, so a relay
|
||||
// out of reach never keeps the chats waiting; the
|
||||
// network catches up after (catchUpAfterStart).
|
||||
val storedBuddies = session!!.storedRoster()
|
||||
val storedGroups = session!!.storedGroups()
|
||||
val ids = storedBuddies.orEmpty().map { b -> b.userId }
|
||||
stored to
|
||||
Phase.SignedIn(
|
||||
handle = credential.username,
|
||||
@@ -404,26 +408,20 @@ class MessengerViewModel(
|
||||
// passphrase to derive it from.
|
||||
fnid = credential.fnid,
|
||||
status = "Signed in on this device.",
|
||||
buddies = fetchedBuddies.getOrDefault(emptyList()),
|
||||
buddiesLoaded = fetchedBuddies.isSuccess,
|
||||
buddies = storedBuddies.orEmpty(),
|
||||
buddiesLoaded = storedBuddies != null,
|
||||
// From the vault, or every badge is gone at
|
||||
// each launch whether or not the messages
|
||||
// were read. The markers were UI state
|
||||
// alone until now.
|
||||
unread = session?.vault?.unreadThreads() ?: emptySet(),
|
||||
avatars =
|
||||
session?.avatars(
|
||||
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId },
|
||||
) ?: emptyMap(),
|
||||
previews =
|
||||
session?.previews(
|
||||
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId },
|
||||
) ?: emptyMap(),
|
||||
avatars = session?.avatars(ids) ?: emptyMap(),
|
||||
previews = session?.previews(ids) ?: emptyMap(),
|
||||
calls = session?.callLog() ?: emptyList(),
|
||||
storyAudience = session?.storyAudience() ?: Session.StoryAudience(),
|
||||
myAvatar = session?.myAvatar(),
|
||||
groups = fetchedGroups.getOrDefault(emptyList()),
|
||||
groupsLoaded = fetchedGroups.isSuccess,
|
||||
groups = storedGroups.orEmpty(),
|
||||
groupsLoaded = storedGroups != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -434,6 +432,7 @@ class MessengerViewModel(
|
||||
_phase.value = restored.second ?: Phase.SignedOut()
|
||||
if (restored.second != null) {
|
||||
startPolling()
|
||||
catchUpAfterStart()
|
||||
republishIfMissingFromTheLog()
|
||||
}
|
||||
},
|
||||
@@ -444,6 +443,24 @@ class MessengerViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The network's half of a start from what is on the phone (DESIGN 47):
|
||||
* the contact list and the groups as the relay has them now, whenever it
|
||||
* answers. A relay still out of reach leaves the stored ones showing.
|
||||
*/
|
||||
private fun catchUpAfterStart() =
|
||||
viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val buddies = withContext(Dispatchers.IO) { runCatching { current.roster() } }
|
||||
val groups = withContext(Dispatchers.IO) { runCatching { current.namedGroups() } }
|
||||
buddies.getOrNull()?.let { list ->
|
||||
val ids = list.map { it.userId }
|
||||
val (avatars, previews) = withContext(Dispatchers.IO) { current.avatars(ids) to current.previews(ids) }
|
||||
update { it.copy(buddies = list, buddiesLoaded = true, avatars = it.avatars + avatars, previews = it.previews + previews) }
|
||||
}
|
||||
groups.getOrNull()?.let { list -> update { it.copy(groups = list, groupsLoaded = true) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Put this device back in the transparency log if it is not in it
|
||||
* (android#6).
|
||||
|
||||
@@ -3206,11 +3206,47 @@ class Session(
|
||||
// from `listGroups`, and dropping those would make somebody's older
|
||||
// groups vanish the moment sealing shipped.
|
||||
val plain = relay.listGroups().filterNot { it.id in sealedIds }
|
||||
return (sealed + plain).map { group ->
|
||||
val all = sealed + plain
|
||||
// Kept for a start with the relay out of reach (DESIGN 47).
|
||||
runCatching {
|
||||
vault.saveGroupList(
|
||||
org.json.JSONArray().also { list ->
|
||||
all.forEach { g ->
|
||||
list.put(
|
||||
JSONObject()
|
||||
.put("id", g.id)
|
||||
.put("name", g.name)
|
||||
.put("owner", g.owner)
|
||||
.put("members", org.json.JSONArray(g.memberIds)),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return all.map { group ->
|
||||
vault.groupName(group.id)?.let { group.copy(name = it) } ?: group
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The groups as last listed, from this phone alone, or null when they
|
||||
* never were (DESIGN 47).
|
||||
*/
|
||||
fun storedGroups(): List<RelayClient.Group>? {
|
||||
val list = vault.loadGroupList() ?: return null
|
||||
return (0 until list.length()).mapNotNull { i ->
|
||||
val o = list.optJSONObject(i) ?: return@mapNotNull null
|
||||
val id = o.optString("id").takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
val members = o.optJSONArray("members") ?: org.json.JSONArray()
|
||||
RelayClient.Group(
|
||||
id = id,
|
||||
name = vault.groupName(id) ?: o.optString("name"),
|
||||
owner = o.optLong("owner"),
|
||||
memberIds = (0 until members.length()).map { members.optLong(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this group's roster is the sealed kind. For offering the upgrade. */
|
||||
fun isSealed(gid: String): Boolean = vault.groupSecrets(gid) != null
|
||||
|
||||
@@ -3380,19 +3416,29 @@ class Session(
|
||||
/**
|
||||
* The roster, from wherever it authoritatively lives.
|
||||
*/
|
||||
/**
|
||||
* The contact list kept on this phone, everybody shown offline, or null
|
||||
* when none was ever kept (DESIGN 47).
|
||||
*/
|
||||
fun storedRoster(): List<io.flamenet.client.MessengerApi.Buddy>? = if (vault.hasRoster()) storedBuddies() else null
|
||||
|
||||
private fun storedBuddies(): List<io.flamenet.client.MessengerApi.Buddy> {
|
||||
val items = vault.loadRoster().first
|
||||
return (0 until items.length()).map {
|
||||
val e = items.getJSONObject(it)
|
||||
io.flamenet.client.MessengerApi.Buddy(
|
||||
userId = e.optLong("id"),
|
||||
displayName = e.optString("name", ""),
|
||||
handle = e.optString("handle", ""),
|
||||
presence = "offline",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun roster(): List<io.flamenet.client.MessengerApi.Buddy> {
|
||||
val (items, offServer) = vault.loadRoster()
|
||||
|
||||
fun stored() =
|
||||
(0 until items.length()).map {
|
||||
val e = items.getJSONObject(it)
|
||||
io.flamenet.client.MessengerApi.Buddy(
|
||||
userId = e.optLong("id"),
|
||||
displayName = e.optString("name", ""),
|
||||
handle = e.optString("handle", ""),
|
||||
presence = "offline",
|
||||
)
|
||||
}
|
||||
fun stored() = storedBuddies()
|
||||
if (offServer) return stored()
|
||||
return try {
|
||||
val answer = api.roster()
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* With the relay unreachable at launch, a stored account opens to its chats
|
||||
* and the saved Today page from what is on the phone, and connects in the
|
||||
* background (DESIGN 47). Sign-in shows only with no stored credentials.
|
||||
*/
|
||||
class AStoredAccountOpensOfflineTest {
|
||||
private val vm = File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText()
|
||||
|
||||
private val restore = vm.substringAfter("private fun restore() =").substringBefore("\n /**")
|
||||
|
||||
@Test
|
||||
fun `the chats open before the relay is asked anything`() {
|
||||
val shown = restore.indexOf("_phase.value = restored.second")
|
||||
assertTrue(shown > 0, "the restore no longer sets the phase where this test looks")
|
||||
val before = restore.substring(0, shown)
|
||||
assertFalse(before.contains(".roster()"), "the chats wait on the relay for the contact list")
|
||||
assertFalse(before.contains(".namedGroups()"), "the chats wait on the relay for the groups")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only missing credentials mean signed out`() {
|
||||
assertTrue(restore.contains("if (stored == null || credential == null)"))
|
||||
assertTrue(restore.contains("restored.second ?: Phase.SignedOut()"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import io.flamenet.client.MessengerApi
|
||||
import io.flamenet.client.RelayClient
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.SocketPolicy
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* What a start reads from the phone alone (DESIGN 47): the contact list and
|
||||
* the groups as they were last listed, with nothing asked of the relay.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class StoredStartStateTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun session(vault: Vault): Session {
|
||||
val identity = DeviceIdentity.create()
|
||||
vault.saveIdentity(identity)
|
||||
val base = server.url("/").toString()
|
||||
return Session("alice", 1, identity, vault, RelayClient(base, "alice", "pw"), MessengerApi(base, "alice", "pw"))
|
||||
}
|
||||
|
||||
private fun vault() = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
|
||||
@Test
|
||||
fun `a phone that never listed anything says so, and asks nobody`() {
|
||||
val s = session(vault())
|
||||
assertNull(s.storedRoster())
|
||||
assertNull(s.storedGroups())
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the stored contacts and groups come back with the relay out of reach`() {
|
||||
val v = vault()
|
||||
v.saveRoster(JSONArray().put(JSONObject().put("id", 9).put("handle", "ada").put("name", "Ada")), offServer = false)
|
||||
val s = session(v)
|
||||
// Once, while the relay answered.
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"groups":[{"gid":"g-1","name":"Saturday","owner":9,"members":[{"user_id":1},{"user_id":9}]}]}"""),
|
||||
)
|
||||
assertEquals(listOf("g-1"), s.namedGroups().map { it.id })
|
||||
|
||||
// Then out of reach: nothing is asked.
|
||||
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE))
|
||||
val asked = server.requestCount
|
||||
assertEquals(listOf("ada"), s.storedRoster()?.map { it.handle })
|
||||
val groups = s.storedGroups()!!
|
||||
assertEquals(listOf("g-1"), groups.map { it.id })
|
||||
assertEquals("Saturday", groups.single().name)
|
||||
assertEquals(listOf(1L, 9L), groups.single().memberIds)
|
||||
assertEquals(asked, server.requestCount, "a start asked the relay")
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ class Vault(
|
||||
private const val REPLY_REMINDER = "reply-reminder"
|
||||
private const val ROSTER = "roster"
|
||||
private const val GROUP_NAMES = "group-names"
|
||||
private const val GROUP_LIST = "group-list"
|
||||
private const val DRAFTS = "drafts"
|
||||
private const val GROUP_SECRETS = "group-secrets"
|
||||
private const val DEVICE_OWNER_PREFIX = "devowner/"
|
||||
@@ -1583,6 +1584,18 @@ class Vault(
|
||||
offServer: Boolean,
|
||||
) = writeJson(ROSTER, JSONObject().put("items", items).put("offServer", offServer))
|
||||
|
||||
/** Whether a roster was ever kept here, empty or not. */
|
||||
fun hasRoster(): Boolean = readJson(ROSTER) != null
|
||||
|
||||
/**
|
||||
* The groups as the relay last listed them, so the app can open to them
|
||||
* with the relay out of reach (DESIGN 47). Null when never listed here.
|
||||
* Rebuilt from the relay, so not carried in a backup.
|
||||
*/
|
||||
fun loadGroupList(): JSONArray? = readJson(GROUP_LIST)?.optJSONArray("groups")
|
||||
|
||||
fun saveGroupList(groups: JSONArray) = writeJson(GROUP_LIST, JSONObject().put("groups", groups))
|
||||
|
||||
/** Rewrite a thread wholesale, for restating a message's delivery state. */
|
||||
fun replaceHistory(
|
||||
threadId: String,
|
||||
|
||||
Reference in New Issue
Block a user