Believe the relay about its own roster, including an empty one
Same as the iPhone. Yesterday's guard refused every empty list and stranded a removed last contact on every other device; the relay says which kind of empty it means now, so the rule is simply whether the relay is the one holding the roster. MessengerApi.roster() carries both halves; buddies() stays for callers that only want the list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dafb4f8adb
commit
55d8442ccc
@@ -2104,13 +2104,19 @@ class Session(
|
||||
}
|
||||
if (offServer) return stored()
|
||||
return try {
|
||||
val fetched = api.buddies()
|
||||
// §8.9c. An empty answer means this relay no longer keeps the
|
||||
// contact graph, not that the contacts are gone. Taken literally
|
||||
// it would erase the stored roster on the first poll after an
|
||||
// operator threw that switch, and on any transient fault that
|
||||
// answered empty. Nothing on the server can empty a roster now.
|
||||
if (!shouldAdoptRoster(incoming = fetched.size, held = items.length())) {
|
||||
val answer = api.roster()
|
||||
val fetched = answer.buddies
|
||||
// §8.9c. A relay that no longer keeps the contact graph is not the
|
||||
// source of one, whatever it sends, so its answer is ignored and
|
||||
// the stored roster stands. A relay that DOES keep it is the
|
||||
// source, so its answer is taken even when empty: an account that
|
||||
// removed its last contact has an empty list.
|
||||
if (!shouldAdoptRoster(
|
||||
incoming = fetched.size,
|
||||
held = items.length(),
|
||||
relayKeepsRoster = answer.relayKeepsRoster,
|
||||
)
|
||||
) {
|
||||
return stored()
|
||||
}
|
||||
val mirror = org.json.JSONArray()
|
||||
@@ -2168,18 +2174,26 @@ class Session(
|
||||
internal fun shouldAdoptRoster(
|
||||
incoming: Int,
|
||||
held: Int,
|
||||
): Boolean = shouldAdoptRosterForTest(incoming, held)
|
||||
relayKeepsRoster: Boolean,
|
||||
): Boolean = shouldAdoptRosterForTest(incoming, held, relayKeepsRoster)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The rule, reachable without building a Session so a test can ask it
|
||||
* directly rather than grep for it.
|
||||
*
|
||||
* A relay that keeps no contact graph is not a source of one, whatever
|
||||
* it sends. One that does keep it is the source, so its answer stands
|
||||
* including an empty one. `incoming` and `held` are still taken so the
|
||||
* shape survives if a third rule is ever needed, and so a caller
|
||||
* cannot pass the wrong thing by position.
|
||||
*/
|
||||
@JvmStatic
|
||||
internal fun shouldAdoptRosterForTest(
|
||||
incoming: Int,
|
||||
held: Int,
|
||||
): Boolean = incoming > 0 || held == 0
|
||||
relayKeepsRoster: Boolean,
|
||||
): Boolean = relayKeepsRoster
|
||||
}
|
||||
|
||||
private fun presenceFromTokens(): Map<Long, String> {
|
||||
|
||||
@@ -27,37 +27,47 @@ class AnEmptyRosterNeverErasesOneTest {
|
||||
* somebody's contact list.
|
||||
*/
|
||||
@Test
|
||||
fun `an empty list never replaces a roster that is held`() {
|
||||
assertFalse(Session.shouldAdoptRosterForTest(incoming = 0, held = 7))
|
||||
assertFalse(Session.shouldAdoptRosterForTest(incoming = 0, held = 1))
|
||||
fun `a relay that keeps no roster is never adopted from`() {
|
||||
for ((incoming, held) in listOf(0 to 7, 0 to 0, 3 to 7, 3 to 0)) {
|
||||
assertFalse(
|
||||
Session.shouldAdoptRosterForTest(incoming, held, relayKeepsRoster = false),
|
||||
"adopted $incoming over $held from a relay that keeps none",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty onto empty is allowed, or a new account could never take its first
|
||||
* contacts from the server.
|
||||
* When the relay IS the source its answer stands, including an empty one:
|
||||
* an account that removed its last contact has an empty list and should
|
||||
* see one. Before the relay said which kind of empty it meant, every empty
|
||||
* answer had to be refused, which left a removed last contact on screen on
|
||||
* every other device for ever.
|
||||
*/
|
||||
@Test
|
||||
fun `an empty store still accepts an empty answer`() {
|
||||
assertTrue(Session.shouldAdoptRosterForTest(incoming = 0, held = 0))
|
||||
fun `a relay that keeps the roster is believed, empty included`() {
|
||||
assertTrue(Session.shouldAdoptRosterForTest(0, 7, relayKeepsRoster = true))
|
||||
assertTrue(Session.shouldAdoptRosterForTest(0, 0, relayKeepsRoster = true))
|
||||
assertTrue(Session.shouldAdoptRosterForTest(9, 1, relayKeepsRoster = true))
|
||||
}
|
||||
|
||||
/**
|
||||
* And a real list still lands. A guard that refused everything would pass
|
||||
* the test above and break the app.
|
||||
*/
|
||||
/** An older relay cannot say, and absent has to read as "still keeps one". */
|
||||
@Test
|
||||
fun `a list with anything in it is always adopted`() {
|
||||
assertTrue(Session.shouldAdoptRosterForTest(incoming = 1, held = 0))
|
||||
assertTrue(Session.shouldAdoptRosterForTest(incoming = 1, held = 9))
|
||||
fun `a relay that says nothing is taken to keep one`() {
|
||||
val api =
|
||||
File("../client/src/main/kotlin/io/flamenet/client/MessengerApi.kt").readText()
|
||||
assertTrue(
|
||||
api.contains("optBoolean(\"roster_on_server\", true)"),
|
||||
"an older relay would be read as keeping no roster",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard has to sit before the mirror is written, or the empty list is
|
||||
* The guard has to sit before the mirror is written, or the answer is
|
||||
* saved on the way past and the fallback reads it back.
|
||||
*/
|
||||
@Test
|
||||
fun `the guard comes before the write`() {
|
||||
val guard = session.indexOf("shouldAdoptRoster(incoming = fetched.size")
|
||||
val guard = session.indexOf("shouldAdoptRoster(")
|
||||
val write = session.indexOf("vault.saveRoster(mirror, offServer = false)")
|
||||
assertTrue(guard in 0..<write, "guard at $guard, write at $write")
|
||||
}
|
||||
|
||||
@@ -44,8 +44,31 @@ class MessengerApi(
|
||||
val read: Boolean,
|
||||
)
|
||||
|
||||
fun buddies(): List<Buddy> {
|
||||
val list = get("buddies").optJSONArray("buddies") ?: JSONArray()
|
||||
/**
|
||||
* The buddy list, and whether the relay is still the one holding it.
|
||||
*
|
||||
* §8.9c. A relay that has stopped keeping a contact graph answers with an
|
||||
* empty list, and so does an account that genuinely has no contacts. They
|
||||
* are different things and only the relay can tell them apart, so it says
|
||||
* which. Absent on an older relay, and absent reads as "still keeps one",
|
||||
* because that is what an older relay does.
|
||||
*/
|
||||
data class Roster(
|
||||
val buddies: List<Buddy>,
|
||||
val relayKeepsRoster: Boolean,
|
||||
)
|
||||
|
||||
fun roster(): Roster {
|
||||
val body = get("buddies")
|
||||
return Roster(
|
||||
buddies = parseBuddies(body.optJSONArray("buddies") ?: JSONArray()),
|
||||
relayKeepsRoster = body.optBoolean("roster_on_server", true),
|
||||
)
|
||||
}
|
||||
|
||||
fun buddies(): List<Buddy> = roster().buddies
|
||||
|
||||
private fun parseBuddies(list: JSONArray): List<Buddy> {
|
||||
return (0 until list.length()).map {
|
||||
val b = list.getJSONObject(it)
|
||||
// `id`, `display_name`, `screen_name`, `presence`, this route does
|
||||
|
||||
Reference in New Issue
Block a user