Story reactions survive a restart, and ride the backup
They lived in a map in memory. An author watched a tally empty itself overnight and the reactor watched their own reaction disappear, and there was nothing on screen to say either had happened. The iPhone was fixed yesterday; this is the other half. The vault keeps them now, loaded when the session opens and written on every change, withdrawals included. The export carries them too, in the same shape the iPhone reads, so a tally survives a move between the two phones as well as a restart. Import merges per reactor rather than per story: two devices can hold different halves of one story's tally, and taking a story's map wholesale would drop the other half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
38e31fc5ee
commit
fd3a52b35c
@@ -1425,8 +1425,16 @@ class Session(
|
||||
|
||||
/**
|
||||
* Who reacted to which story, and with what.
|
||||
*
|
||||
* Loaded from the vault rather than started empty, and written back on
|
||||
* every change: these lived only in memory, so a tally emptied itself at
|
||||
* the next launch and the reactor's own reaction disappeared with it.
|
||||
*/
|
||||
private val storyReactions = mutableMapOf<String, MutableMap<Long, String>>()
|
||||
private val storyReactions: MutableMap<String, MutableMap<Long, String>> =
|
||||
vault
|
||||
.storyReactions()
|
||||
.mapValues { (_, byUser) -> byUser.toMutableMap() }
|
||||
.toMutableMap()
|
||||
|
||||
fun applyStoryReaction(
|
||||
storyKey: String,
|
||||
@@ -1436,6 +1444,7 @@ class Session(
|
||||
val forStory = storyReactions.getOrPut(storyKey) { mutableMapOf() }
|
||||
if (emoji.isEmpty()) forStory.remove(who) else forStory[who] = emoji
|
||||
if (forStory.isEmpty()) storyReactions.remove(storyKey)
|
||||
vault.saveStoryReactions(storyReactions)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Who reacted to a story has to still be there after the app is closed.
|
||||
*
|
||||
* It was not. Reactions went into a map in memory and were gone at the next
|
||||
* launch, so an author watched a tally empty itself overnight and the reactor
|
||||
* watched their own reaction disappear. Neither is anything the other person
|
||||
* did, and there was nothing on screen to say otherwise.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class StoryReactionsSurviveARestartTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
/** The same storage and the same key, opened twice. A restart, in effect. */
|
||||
private fun sessions(): Pair<Session, () -> Session> {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
val base = server.url("/").toString()
|
||||
fun open(): Session {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(storage, secret).also { it.saveIdentity(identity) }
|
||||
return Session(
|
||||
handle = "alice",
|
||||
userId = 1,
|
||||
identity = identity,
|
||||
vault = vault,
|
||||
relay = RelayClient(base, "alice", "app pass word"),
|
||||
api = MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
}
|
||||
return open() to ::open
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reaction to our story is still there after a restart`() {
|
||||
val (first, reopen) = sessions()
|
||||
first.applyStoryReaction("story-1", 99L, "❤️")
|
||||
first.applyStoryReaction("story-1", 77L, "😂")
|
||||
|
||||
val after = reopen()
|
||||
assertEquals("❤️", after.reactionsFor("story-1")[99L])
|
||||
assertEquals("😂", after.reactionsFor("story-1")[77L])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `our own reaction is still ours after a restart`() {
|
||||
val (first, reopen) = sessions()
|
||||
// userId 1 is this account, so this is the reaction the picker shows
|
||||
// back to the person who sent it.
|
||||
first.applyStoryReaction("story-1", 1L, "🔥")
|
||||
|
||||
assertEquals("🔥", reopen().myReactionTo("story-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `taking a reaction back survives the restart too`() {
|
||||
val (first, reopen) = sessions()
|
||||
first.applyStoryReaction("story-1", 99L, "❤️")
|
||||
// An empty emoji withdraws it, and a withdrawal that came back at the
|
||||
// next launch would be worse than never having removed it.
|
||||
first.applyStoryReaction("story-1", 99L, "")
|
||||
|
||||
val after = reopen()
|
||||
assertTrue(after.reactionsFor("story-1").isEmpty())
|
||||
assertNull(after.myReactionTo("story-1"))
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,13 @@ object BackupPayload {
|
||||
root.put("groupSecrets", out)
|
||||
}
|
||||
|
||||
// Who reacted to which story, keyed by story then by reactor. The
|
||||
// iPhone writes and reads the same shape, so a tally survives a move
|
||||
// between the two.
|
||||
internal.optJSONObject("storyReactions")?.takeIf { it.length() > 0 }?.let {
|
||||
root.put("storyReactions", it)
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -299,6 +306,10 @@ object BackupPayload {
|
||||
out.put("groupSecrets", secrets)
|
||||
}
|
||||
|
||||
shared.optJSONObject("storyReactions")?.takeIf { it.length() > 0 }?.let {
|
||||
out.put("storyReactions", it)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ class Vault(
|
||||
private const val STORY_AUDIENCE = "story-audience"
|
||||
private const val STORY_MUTES = "story-mutes"
|
||||
private const val STORY_ARCHIVE = "story-archive"
|
||||
private const val STORY_REACTIONS = "story-reactions"
|
||||
private const val DISAPPEAR = "disappear"
|
||||
private const val OFF_RECORD = "offrecord"
|
||||
private const val POLLS = "polls"
|
||||
@@ -1197,6 +1198,40 @@ class Vault(
|
||||
writeJson(STORY_MUTES, JSONObject().put("ids", array))
|
||||
}
|
||||
|
||||
/**
|
||||
* Who reacted to which story, and with what, keyed by story then reactor.
|
||||
*
|
||||
* Kept, because it was not: a reaction arrived, went into a map in memory,
|
||||
* and was gone at the next launch. The author watched a tally empty itself
|
||||
* overnight and the reactor watched their own reaction disappear, neither
|
||||
* of which is anything the other person did.
|
||||
*/
|
||||
fun storyReactions(): Map<String, Map<Long, String>> {
|
||||
val root = readJson(STORY_REACTIONS) ?: return emptyMap()
|
||||
val out = mutableMapOf<String, Map<Long, String>>()
|
||||
for (storyKey in root.keys()) {
|
||||
val byUser = root.optJSONObject(storyKey) ?: continue
|
||||
val one = mutableMapOf<Long, String>()
|
||||
for (who in byUser.keys()) {
|
||||
val id = who.toLongOrNull() ?: continue
|
||||
byUser.optString(who).takeIf { it.isNotEmpty() }?.let { one[id] = it }
|
||||
}
|
||||
if (one.isNotEmpty()) out[storyKey] = one
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun saveStoryReactions(reactions: Map<String, Map<Long, String>>) {
|
||||
val root = JSONObject()
|
||||
for ((storyKey, byUser) in reactions) {
|
||||
if (byUser.isEmpty()) continue
|
||||
val one = JSONObject()
|
||||
byUser.forEach { (who, emoji) -> one.put(who.toString(), emoji) }
|
||||
root.put(storyKey, one)
|
||||
}
|
||||
writeJson(STORY_REACTIONS, root)
|
||||
}
|
||||
|
||||
// -- Key-log issuer pin (audit M1) ---------------------------------------.
|
||||
|
||||
/**
|
||||
@@ -1322,6 +1357,9 @@ class Vault(
|
||||
.also { p -> readJson(GROUP_NAMES)?.let { p.put("groupNames", it) } }
|
||||
// Without these a restored device loses its sealed groups for good.
|
||||
.also { p -> readJson(GROUP_SECRETS)?.let { p.put("groupSecrets", it) } }
|
||||
// Who reacted to which story. An export that leaves these behind
|
||||
// restores an author whose stories have quietly lost their tallies.
|
||||
.also { p -> readJson(STORY_REACTIONS)?.let { p.put("storyReactions", it) } }
|
||||
}
|
||||
|
||||
data class ImportCounts(
|
||||
@@ -1439,6 +1477,27 @@ class Vault(
|
||||
if (changed) writeJson(GROUP_SECRETS, existing)
|
||||
}
|
||||
|
||||
// Reactions merge per reactor, not per story: two devices can each
|
||||
// hold a different half of the same story's tally, and taking one
|
||||
// story wholesale would drop the other half.
|
||||
payload.optJSONObject("storyReactions")?.let { imported ->
|
||||
val existing = readJson(STORY_REACTIONS) ?: JSONObject()
|
||||
var changed = false
|
||||
for (storyKey in imported.keys()) {
|
||||
val incoming = imported.optJSONObject(storyKey) ?: continue
|
||||
val current = existing.optJSONObject(storyKey) ?: JSONObject()
|
||||
for (who in incoming.keys()) {
|
||||
if (current.has(who)) continue
|
||||
val emoji = incoming.optString(who)
|
||||
if (emoji.isEmpty()) continue
|
||||
current.put(who, emoji)
|
||||
changed = true
|
||||
}
|
||||
if (current.length() > 0) existing.put(storyKey, current)
|
||||
}
|
||||
if (changed) writeJson(STORY_REACTIONS, existing)
|
||||
}
|
||||
|
||||
return ImportCounts(newMessages, newStories)
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ class BackupPayloadTest {
|
||||
.put("version", 4)
|
||||
.put("owner", "p"),
|
||||
),
|
||||
).put(
|
||||
"storyReactions",
|
||||
JSONObject().put("story-1", JSONObject().put("9", "\u2764\ufe0f")),
|
||||
)
|
||||
|
||||
private fun roundTrip(): JSONObject = BackupPayload.fromShared(BackupPayload.toShared(internalPayload()))
|
||||
@@ -189,6 +192,22 @@ class BackupPayloadTest {
|
||||
assertEquals("p", roundTrip().getJSONObject("groupSecrets").getJSONObject("gid-1").getString("owner"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `story reactions survive the round trip`() {
|
||||
// These lived only in memory on both platforms, so a tally emptied
|
||||
// itself at the next launch. Exporting without them repeats that on a
|
||||
// new phone, quietly, at the moment somebody most needs the history.
|
||||
val shared = BackupPayload.toShared(internalPayload())
|
||||
assertEquals(
|
||||
"\u2764\ufe0f",
|
||||
shared.getJSONObject("storyReactions").getJSONObject("story-1").getString("9"),
|
||||
)
|
||||
assertEquals(
|
||||
"\u2764\ufe0f",
|
||||
roundTrip().getJSONObject("storyReactions").getJSONObject("story-1").getString("9"),
|
||||
)
|
||||
}
|
||||
|
||||
// -- And back again ------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
|
||||
@@ -47,6 +47,42 @@ class VaultPersistenceTest {
|
||||
return read(Vault(storage, secret))
|
||||
}
|
||||
|
||||
// -- Story reactions -----------------------------------------------------.
|
||||
|
||||
/**
|
||||
* These lived in a map in memory, so an author watched a tally empty
|
||||
* itself overnight and the reactor watched their own reaction vanish.
|
||||
*/
|
||||
@Test
|
||||
fun `story reactions survive a restart`() {
|
||||
val restored =
|
||||
roundTrip(
|
||||
{
|
||||
it.saveStoryReactions(
|
||||
mapOf(
|
||||
"story-1" to mapOf(9L to "\u2764\ufe0f", 11L to "\ud83d\ude02"),
|
||||
"story-2" to mapOf(9L to "\ud83d\udd25"),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ it.storyReactions() },
|
||||
)
|
||||
assertEquals("\u2764\ufe0f", restored["story-1"]?.get(9L))
|
||||
assertEquals("\ud83d\ude02", restored["story-1"]?.get(11L))
|
||||
assertEquals("\ud83d\udd25", restored["story-2"]?.get(9L))
|
||||
}
|
||||
|
||||
/** Taking a reaction back has to survive the restart as well. */
|
||||
@Test
|
||||
fun `a story with no reactions left is not written back`() {
|
||||
val restored =
|
||||
roundTrip(
|
||||
{ it.saveStoryReactions(mapOf("story-1" to emptyMap())) },
|
||||
{ it.storyReactions() },
|
||||
)
|
||||
assertTrue(restored.isEmpty(), "an empty tally came back as $restored")
|
||||
}
|
||||
|
||||
// -- Credential ----------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user