A story reply is kept on this side, and says it was about a story
Two faults, neither of which raised anything. Replying to somebody's story sent the message and wrote nothing here, so the person who typed it could not see what they had said. The iPhone has recorded its own copy since story replies existed. The local copy is written now, whether or not the send reached anybody: what somebody typed is theirs either way. And `Content.repliesToStory` had been parsed, unit tested and read by NOTHING since the day it was added. It stopped at the parser, so an arriving reply filed as an ordinary one quoting a `mid` that matches no message here, because a story key is not a message id and the bubble had nothing to resolve. Wiring it took four links: `record` writes `reStory`, the ingest passes it, `Line` surfaces it, and the export carries it in both directions under the name the other engine already uses. That last one is its own lesson. The tests around it asserted the parser set the field correctly, and it did. None of them asked whether anybody read it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ca6265e7a6
commit
ae618cd1f3
@@ -71,6 +71,14 @@ class Session(
|
||||
* Second-hand words (§6.16).
|
||||
*/
|
||||
val forwarded: Boolean = false,
|
||||
/**
|
||||
* Whether the quoted thing is a STORY rather than a message (§6.20).
|
||||
*
|
||||
* A story key is not a message id, so a reply that does not say this
|
||||
* quotes something the thread cannot resolve and the bubble has
|
||||
* nothing to draw.
|
||||
*/
|
||||
val replyToStory: Boolean = false,
|
||||
/**
|
||||
* When this message was last edited (§6.15), or null if never.
|
||||
*/
|
||||
@@ -132,6 +140,9 @@ class Session(
|
||||
// number, so reading only that would miss a `"fwd":1` written
|
||||
// by any other engine.
|
||||
forwarded = m.optBoolean("fwd", false) || m.optInt("fwd", 0) != 0,
|
||||
// Both shapes, for the reason above.
|
||||
replyToStory =
|
||||
m.optBoolean("reStory", false) || m.optInt("reStory", 0) != 0,
|
||||
editedAt = m.optLong("edited", 0L).takeIf { it > 0L },
|
||||
lifetimeSeconds = m.optInt("secs", 0),
|
||||
)
|
||||
@@ -158,6 +169,17 @@ class Session(
|
||||
* Whether the sender is passing on somebody else's words (§6.16).
|
||||
*/
|
||||
forwarded: Boolean = false,
|
||||
/**
|
||||
* Whether what this replies to is a STORY rather than a message
|
||||
* (§6.20).
|
||||
*
|
||||
* `Content.repliesToStory` has been parsed and tested since story
|
||||
* replies existed and was read by nothing, so it stopped at the
|
||||
* parser. Without it a story reply files as an ordinary reply quoting
|
||||
* a `mid` that matches no message here, because a story key is not a
|
||||
* message id, and the bubble has nothing to resolve.
|
||||
*/
|
||||
aboutAStory: Boolean = false,
|
||||
) {
|
||||
val row =
|
||||
JSONObject()
|
||||
@@ -178,6 +200,10 @@ class Session(
|
||||
// Written only when true, for the same reason `secs` is.
|
||||
.also { if (forwarded) it.put("fwd", true) }
|
||||
.also { if (about != null) it.put("re", about.toJson()) }
|
||||
// Written only when true, like `fwd` above. The iPhone calls
|
||||
// this `reStory` in §16 and so does the row, so the two agree
|
||||
// without a rename in the middle.
|
||||
.also { if (aboutAStory) it.put("reStory", true) }
|
||||
|
||||
// Off the record: the row is held in memory and nothing reaches disk.
|
||||
// This is the one place a message is ever written, which is what makes
|
||||
@@ -1402,6 +1428,7 @@ class Session(
|
||||
// THEIR id, not one we make up.
|
||||
messageId = message.content.messageId,
|
||||
about = message.content.about,
|
||||
aboutAStory = message.content.repliesToStory,
|
||||
lifetimeSeconds = message.content.lifetimeSeconds ?: 0,
|
||||
// §6.16, so second-hand words are not drawn as the sender's
|
||||
// own.
|
||||
@@ -1563,11 +1590,26 @@ class Session(
|
||||
body: String,
|
||||
story: Vault.Story,
|
||||
) {
|
||||
conversations.replyToStory(
|
||||
val preview = story.caption.take(80)
|
||||
val messageId =
|
||||
conversations.replyToStory(
|
||||
body,
|
||||
story.id,
|
||||
story.authorId,
|
||||
preview,
|
||||
)
|
||||
// The sender's own copy, which was never written: a reply to somebody's
|
||||
// story went out and left nothing behind, so the person who typed it
|
||||
// could not see what they had said. The iPhone has recorded its own
|
||||
// copy since story replies existed.
|
||||
record(
|
||||
Vault.threadForUser(story.authorId),
|
||||
body,
|
||||
story.id,
|
||||
story.authorId,
|
||||
story.caption.take(80),
|
||||
mine = true,
|
||||
author = userId,
|
||||
messageId = messageId,
|
||||
about = Content.Reference(story.id, story.authorId, preview),
|
||||
aboutAStory = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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 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.assertTrue
|
||||
|
||||
/**
|
||||
* Replying to somebody's story.
|
||||
*
|
||||
* Two things were wrong and neither showed up as an error. The reply went
|
||||
* out and nothing was written on this side, so the person who typed it could
|
||||
* not see what they had said. And `Content.repliesToStory` had been parsed
|
||||
* and unit tested since story replies existed while nothing read it, so an
|
||||
* arriving reply was filed as an ordinary one quoting a `mid` that matches no
|
||||
* message here: a story key is not a message id.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class AStoryReplyIsAStoryReplyTest {
|
||||
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()
|
||||
|
||||
private fun session(): Session {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
val base = server.url("/").toString()
|
||||
return Session(
|
||||
handle = "alice",
|
||||
userId = 1,
|
||||
identity = identity,
|
||||
vault = vault,
|
||||
relay = RelayClient(base, "alice", "app pass word"),
|
||||
api = MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun json(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
private fun story() =
|
||||
Vault.Story(
|
||||
id = "story-key-1",
|
||||
authorId = 99L,
|
||||
mine = false,
|
||||
ref = null,
|
||||
caption = "look at this",
|
||||
postedAt = 1_700_000_000_000L,
|
||||
seen = true,
|
||||
expiresAt = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `replying to a story leaves a copy in this side's thread`() {
|
||||
val session = session()
|
||||
// The send reaches nobody; the local copy is written regardless, which
|
||||
// is the point: what somebody typed is theirs whether or not it landed.
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
|
||||
session.replyToStory("that is a great photo", story())
|
||||
|
||||
val line = session.history(Vault.threadForUser(99L)).single()
|
||||
assertEquals("that is a great photo", line.text)
|
||||
assertTrue(line.mine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the copy says it was replying to a STORY`() {
|
||||
val session = session()
|
||||
server.enqueue(json("""{"devices":[]}"""))
|
||||
|
||||
session.replyToStory("that is a great photo", story())
|
||||
|
||||
val line = session.history(Vault.threadForUser(99L)).single()
|
||||
// Without this the bubble quotes `story-key-1` as though it were a
|
||||
// message id and finds nothing to show.
|
||||
assertTrue(line.replyToStory, "the reply does not know it was about a story")
|
||||
assertEquals("story-key-1", line.about?.messageId)
|
||||
assertEquals("look at this", line.about?.preview)
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,10 @@ object BackupPayload {
|
||||
if (m.optBoolean("fwd", false) || m.optInt("fwd", 0) != 0) out.put("forwarded", true)
|
||||
m.optInt("secs", 0).takeIf { it > 0 }?.let { out.put("lifetime", it) }
|
||||
m.optJSONObject("re")?.let { out.put("re", it) }
|
||||
// §6.20. Whether the quoted thing is a STORY rather than a message.
|
||||
// Without it a story reply restores as an ordinary reply quoting a
|
||||
// `mid` that matches nothing, because a story key is not a message id.
|
||||
if (m.optBoolean("reStory", false)) out.put("reStory", true)
|
||||
m.optJSONObject("stk")?.let { out.put("stk", it) }
|
||||
m.optJSONObject("lnk")?.let { out.put("lnk", it) }
|
||||
m.optString("delivery").takeIf { it.isNotEmpty() }?.let { out.put("delivery", it.lowercase()) }
|
||||
@@ -433,6 +437,10 @@ object BackupPayload {
|
||||
if (m.optBoolean("forwarded", false) || m.optInt("forwarded", 0) != 0) out.put("fwd", true)
|
||||
m.optInt("lifetime", 0).takeIf { it > 0 }?.let { out.put("secs", it) }
|
||||
m.optJSONObject("re")?.let { out.put("re", it) }
|
||||
// §6.20. Whether the quoted thing is a STORY rather than a message.
|
||||
// Without it a story reply restores as an ordinary reply quoting a
|
||||
// `mid` that matches nothing, because a story key is not a message id.
|
||||
if (m.optBoolean("reStory", false)) out.put("reStory", true)
|
||||
m.optJSONObject("stk")?.let { out.put("stk", it) }
|
||||
m.optJSONObject("lnk")?.let { out.put("lnk", it) }
|
||||
// Stored upper case here, lower case on the wire and in the file.
|
||||
|
||||
Reference in New Issue
Block a user