Post a story that is only words, or a link
Android half of ios#32, spec §6.21. `att` becomes optional and the caption becomes the story. 🔴 The decode sits before the attachment branch, and that placement is the feature. The branch requires `att` for a story; a text story failing it falls through to the ordinary-message path and shows as a chat bubble in the conversation that never expires — both of the things §6.6 says a story must not be. There is a test named for it. `Vault.Story.ref` is nullable rather than carrying a sentinel, so nothing can read a blob id off a story that has none. That forced a `key` for seen-marks and dedup, and an `expiresAt` that folds the two cases so no caller has to know which kind it is holding. ⚠️ The stored record omits `ref` entirely rather than writing an empty object — a reload would otherwise resurrect a blob that fails to fetch, and `loadStories` had to stop treating an absent ref as malformed. A "Write" entry on the Stories tab posts one, and says who enforces the expiry: there is no photo to delete, so it relies on the receiving app honouring `exp`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
021dc2c24d
commit
7c51bc2521
@@ -619,3 +619,22 @@ runs BEFORE the rate-limit check on both, so a suppression window cannot make an
|
|||||||
untimed conversation start reporting.
|
untimed conversation start reporting.
|
||||||
⚠️ **Last-notice times are in memory on both.** Persisting them would store who
|
⚠️ **Last-notice times are in memory on both.** Persisting them would store who
|
||||||
screenshotted whom, on disk, to enforce a five-second window.
|
screenshotted whom, on disk, to enforce a five-second window.
|
||||||
|
||||||| parent of 487cead (Post a story that is only words, or a link)
|
||||||
|
## Text and link stories (§6.21, ios#32)
|
||||||
|
A story may carry no attachment; the caption is then the whole story. Both
|
||||||
|
platforms encode, decode, store, render and post one.
|
||||||
|
🔴 **The decode sits BEFORE the attachment branch on both.** That branch
|
||||||
|
requires `att` for `kind: "story"`; a text story failing it falls through to
|
||||||
|
the ordinary-message path and appears as a chat bubble in the conversation that
|
||||||
|
never expires — both of the things §6.6 says a story must not be. Each engine
|
||||||
|
has a test named for exactly that.
|
||||||
|
🔴 **`ref` is nullable/optional rather than a sentinel**, so nothing can read a
|
||||||
|
blob id off a story that has no blob. Both platforms therefore need a `key` for
|
||||||
|
seen-marks and de-duplication: with no blob id, a text story is named by what
|
||||||
|
cannot repeat for one author — when it was posted and what it said.
|
||||||
|
⚠️ **Android's stored record omits `ref` entirely** rather than writing an
|
||||||
|
empty object; a reload would otherwise resurrect a blob that fails to fetch.
|
||||||
|
`expiresAt` folds the two cases so no caller has to know which kind it holds.
|
||||||
|
⚠️ A text story has no blob, so nothing dies at the server's retention purge.
|
||||||
|
Client-enforced expiry is the whole mechanism rather than a backstop to one,
|
||||||
|
and both composers say so instead of implying more.
|
||||||
|
|||||||
@@ -2482,7 +2482,7 @@ fun ColumnScope.StoryStrip(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) {
|
private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) {
|
||||||
val remaining = remember(story.id) {
|
val remaining = remember(story.id) {
|
||||||
val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0
|
val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0
|
||||||
when {
|
when {
|
||||||
seconds <= 0 -> "expired"
|
seconds <= 0 -> "expired"
|
||||||
seconds < 3600 -> "${(seconds / 60).toInt()}m left"
|
seconds < 3600 -> "${(seconds / 60).toInt()}m left"
|
||||||
@@ -2493,7 +2493,8 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
|
|||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("story-viewer"),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("story-viewer"),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
AttachedImage(story.ref, model)
|
// §6.21 — a text story has no blob; the caption IS the story.
|
||||||
|
story.ref?.let { AttachedImage(it, model) }
|
||||||
if (story.caption.isNotBlank()) Text(story.caption)
|
if (story.caption.isNotBlank()) Text(story.caption)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -1927,6 +1927,42 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
/** Whether this contact's stories are hidden on this device. */
|
/** Whether this contact's stories are hidden on this device. */
|
||||||
fun isStoryMuted(authorId: Long): Boolean = session?.isStoryMuted(authorId) ?: false
|
fun isStoryMuted(authorId: Long): Boolean = session?.isStoryMuted(authorId) ?: false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a story that is only words (§6.21).
|
||||||
|
*
|
||||||
|
* ⚠️ No blob, so nothing is uploaded and nothing lands in the server's
|
||||||
|
* retention purge. The whole lifetime of a text story is the `exp` honest
|
||||||
|
* clients enforce — for a story with a picture that is a backstop to the
|
||||||
|
* blob dying anyway; here it is the only mechanism.
|
||||||
|
*/
|
||||||
|
fun postTextStory(caption: String, background: Int? = null,
|
||||||
|
audienceOverride: Session.StoryAudience? = null) =
|
||||||
|
viewModelScope.launch {
|
||||||
|
val current = session ?: return@launch
|
||||||
|
val body = caption.trim()
|
||||||
|
if (body.isEmpty()) return@launch
|
||||||
|
val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty()
|
||||||
|
val recipients = (audienceOverride ?: current.storyAudience()).recipients(contacts)
|
||||||
|
if (recipients.isEmpty()) {
|
||||||
|
update {
|
||||||
|
it.copy(error = "Nobody is in your story audience, so this "
|
||||||
|
+ "would go to no one. Change who can see your stories on Account.")
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
update { it.copy(busy = true, error = null) }
|
||||||
|
val done = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
current.postTextStory(
|
||||||
|
body, recipients, background,
|
||||||
|
lifetimeSeconds = MediaSettings(getApplication()).storyLifetimeSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done.onSuccess {
|
||||||
|
update { it.copy(busy = false, stories = current.stories()) }
|
||||||
|
}.onFailure { f -> update { it.copy(busy = false, error = describe(f)) } }
|
||||||
|
}
|
||||||
|
|
||||||
/** Change who may see this account's stories. */
|
/** Change who may see this account's stories. */
|
||||||
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
||||||
val current = session ?: return@launch
|
val current = session ?: return@launch
|
||||||
|
|||||||
@@ -1041,6 +1041,58 @@ class Session(
|
|||||||
runCatching { conversations.send(peerUserId, Content.screenshot(nowMillis / 1000)) }
|
runCatching { conversations.send(peerUserId, Content.screenshot(nowMillis / 1000)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a story that is only its caption (§6.21).
|
||||||
|
*
|
||||||
|
* ⚠️ No blob, so there is no upload and nothing for the server's retention
|
||||||
|
* purge to collect. The whole lifetime of a text story is the `exp` honest
|
||||||
|
* clients enforce.
|
||||||
|
*
|
||||||
|
* 🔴 The link preview is fetched by the CALLER and rides inside the
|
||||||
|
* ciphertext (§6.12). A card drawn by each viewer would tell the site the
|
||||||
|
* address of everyone who saw the story — for a story that is the whole
|
||||||
|
* audience, not one correspondent.
|
||||||
|
*/
|
||||||
|
fun postTextStory(
|
||||||
|
caption: String,
|
||||||
|
toUserIds: List<Long>,
|
||||||
|
background: Int? = null,
|
||||||
|
link: Content.LinkPreview? = null,
|
||||||
|
lifetimeSeconds: Double,
|
||||||
|
): Int {
|
||||||
|
val body = caption.trim()
|
||||||
|
require(body.isNotEmpty()) { "a text story with no words is not a story" }
|
||||||
|
val expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds
|
||||||
|
val plaintext = Content.textStory(body, expires, background, link)
|
||||||
|
var reached = 0
|
||||||
|
toUserIds.forEach { peer ->
|
||||||
|
if (runCatching { conversations.send(peer, plaintext) }
|
||||||
|
.getOrNull()?.reachedSomeone == true) reached++
|
||||||
|
}
|
||||||
|
if (reached > 0) {
|
||||||
|
val now = System.currentTimeMillis() / 1000.0
|
||||||
|
val at = System.currentTimeMillis()
|
||||||
|
vault.saveStories(
|
||||||
|
vault.loadStories() + Vault.Story(
|
||||||
|
// No blob, so no blob id. Named by what cannot repeat for
|
||||||
|
// one author, matching Story.key.
|
||||||
|
id = "txt-$userId-$at",
|
||||||
|
authorId = userId,
|
||||||
|
mine = true,
|
||||||
|
ref = null,
|
||||||
|
caption = body,
|
||||||
|
postedAt = at,
|
||||||
|
seen = true,
|
||||||
|
textExpiry = expires,
|
||||||
|
background = background,
|
||||||
|
link = link,
|
||||||
|
),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return reached
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Members of a group we are in, or null if the relay does not list it for
|
* Members of a group we are in, or null if the relay does not list it for
|
||||||
* us (audit H1). Cached for a short window: a poll pass can carry many
|
* us (audit H1). Cached for a short window: a poll pass can carry many
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ fun ColumnScope.StoriesFeed(
|
|||||||
// Post from HERE too. The strip on Messages was the only way in, which
|
// Post from HERE too. The strip on Messages was the only way in, which
|
||||||
// means the tab named Stories could show them but not make one.
|
// means the tab named Stories could show them but not make one.
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val writing = androidx.compose.runtime.saveable.rememberSaveable {
|
||||||
|
androidx.compose.runtime.mutableStateOf(false)
|
||||||
|
}
|
||||||
val pickStory = androidx.activity.compose.rememberLauncherForActivityResult(
|
val pickStory = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||||
androidx.activity.result.contract.ActivityResultContracts.GetContent()
|
androidx.activity.result.contract.ActivityResultContracts.GetContent()
|
||||||
) { uri ->
|
) { uri ->
|
||||||
@@ -74,6 +77,14 @@ fun ColumnScope.StoriesFeed(
|
|||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
// §6.21 — a sentence is often the whole thought, and needs no camera.
|
||||||
|
KitChip(
|
||||||
|
text = "Write",
|
||||||
|
selected = false,
|
||||||
|
onClick = { writing.value = true },
|
||||||
|
tag = "new-text-story",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(Space.sm))
|
||||||
Icon(
|
Icon(
|
||||||
KitIcon.Add,
|
KitIcon.Add,
|
||||||
contentDescription = "New story",
|
contentDescription = "New story",
|
||||||
@@ -84,6 +95,46 @@ fun ColumnScope.StoriesFeed(
|
|||||||
.testTag("new-story"),
|
.testTag("new-story"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (writing.value) {
|
||||||
|
val draft = remember { androidx.compose.runtime.mutableStateOf("") }
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { writing.value = false },
|
||||||
|
title = { Text("Write a story") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
androidx.compose.material3.OutlinedTextField(
|
||||||
|
value = draft.value,
|
||||||
|
onValueChange = { draft.value = it },
|
||||||
|
placeholder = { Text("Say something") },
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag("text-story-field"),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
// ⚠️ Says who enforces it. A text story has no blob, so
|
||||||
|
// nothing dies at the server's retention purge — the
|
||||||
|
// client honouring `exp` is the whole of it.
|
||||||
|
Text(
|
||||||
|
"Disappears after your story lifetime. There is no photo to "
|
||||||
|
+ "delete, so this relies on their app honouring it.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Palette.SecondaryText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(
|
||||||
|
onClick = { model.postTextStory(draft.value); writing.value = false },
|
||||||
|
enabled = draft.value.isNotBlank(),
|
||||||
|
modifier = Modifier.testTag("text-story-post"),
|
||||||
|
) { Text("Post") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
androidx.compose.material3.TextButton(
|
||||||
|
onClick = { writing.value = false }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
|
||||||
StoriesCards(model, phase)
|
StoriesCards(model, phase)
|
||||||
@@ -106,9 +157,9 @@ private fun StoryCard(
|
|||||||
?: if (story.mine) "You" else "user ${story.authorId}"
|
?: if (story.mine) "You" else "user ${story.authorId}"
|
||||||
}
|
}
|
||||||
val remaining = remember(story.id) {
|
val remaining = remember(story.id) {
|
||||||
val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0
|
val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0
|
||||||
when {
|
when {
|
||||||
story.ref.expires <= 0L -> "no expiry"
|
story.expiresAt <= 0L -> "no expiry"
|
||||||
seconds <= 0 -> "expired"
|
seconds <= 0 -> "expired"
|
||||||
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
|
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
|
||||||
else -> "${(seconds / 3600).toInt()} h left"
|
else -> "${(seconds / 3600).toInt()} h left"
|
||||||
@@ -180,10 +231,15 @@ private fun StoryCard(
|
|||||||
if (stream != null && streamHost != null &&
|
if (stream != null && streamHost != null &&
|
||||||
io.flamenet.e2e.StreamDisclosure.acceptable(stream)) {
|
io.flamenet.e2e.StreamDisclosure.acceptable(stream)) {
|
||||||
StoryStreamGate(stream, streamHost)
|
StoryStreamGate(stream, streamHost)
|
||||||
} else if (story.ref.mime.startsWith("video/")) {
|
|
||||||
StoryVideoCard(story.ref, model)
|
|
||||||
} else {
|
} else {
|
||||||
StoryImage(story.ref, model)
|
// §6.21 — a text story has nothing to fetch. Skipped rather
|
||||||
|
// than rendered as a failed image: "no blob" is not an error.
|
||||||
|
val ref = story.ref
|
||||||
|
if (ref != null && ref.mime.startsWith("video/")) {
|
||||||
|
StoryVideoCard(ref, model)
|
||||||
|
} else if (ref != null) {
|
||||||
|
StoryImage(ref, model)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,21 @@ data class Content(
|
|||||||
* 🔴 An id and never a copy of the message.
|
* 🔴 An id and never a copy of the message.
|
||||||
*/
|
*/
|
||||||
val pins: String? = null,
|
val pins: String? = null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set iff this is a story with no attachment — the caption IS the story
|
||||||
|
* (§6.21). Belongs to the strip, never the thread.
|
||||||
|
*/
|
||||||
|
val isTextStory: Boolean = false,
|
||||||
|
/** A text story's top-level `exp` (§6.21). */
|
||||||
|
val storyExpiry: Double? = null,
|
||||||
|
/**
|
||||||
|
* A text story's background style (§6.21).
|
||||||
|
*
|
||||||
|
* ⚠️ A hint. A value this build does not know falls back to the default
|
||||||
|
* rather than refusing the story.
|
||||||
|
*/
|
||||||
|
val storyBackground: Int? = null,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -390,6 +405,20 @@ data class Content(
|
|||||||
link = link, forwarded = forwarded,
|
link = link, forwarded = forwarded,
|
||||||
lifetimeSeconds = lifetimeSeconds)
|
lifetimeSeconds = lifetimeSeconds)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A story that is only its caption (§6.21).
|
||||||
|
*
|
||||||
|
* ⚠️ `caption` must not be empty. A story with neither a blob nor
|
||||||
|
* words is nothing at all, and a receiver is required to ignore it.
|
||||||
|
*/
|
||||||
|
fun textStory(
|
||||||
|
caption: String,
|
||||||
|
expires: Double,
|
||||||
|
background: Int? = null,
|
||||||
|
link: LinkPreview? = null,
|
||||||
|
): String = assemble(Attachment.STORY, caption = caption, expires = expires,
|
||||||
|
link = link, background = background)
|
||||||
|
|
||||||
fun groupText(
|
fun groupText(
|
||||||
body: String,
|
body: String,
|
||||||
groupId: String,
|
groupId: String,
|
||||||
@@ -503,6 +532,8 @@ data class Content(
|
|||||||
forwarded: Boolean = false,
|
forwarded: Boolean = false,
|
||||||
mentions: List<Long> = emptyList(),
|
mentions: List<Long> = emptyList(),
|
||||||
lifetimeSeconds: Int? = null,
|
lifetimeSeconds: Int? = null,
|
||||||
|
|
||||||
|
background: Int? = null,
|
||||||
): String {
|
): String {
|
||||||
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
||||||
// ⚠️ `1`, not `true` — §6.10's shape for every flag on this
|
// ⚠️ `1`, not `true` — §6.10's shape for every flag on this
|
||||||
@@ -534,6 +565,8 @@ data class Content(
|
|||||||
attachment?.let { parts += "\"att\":${it.toJson()}" }
|
attachment?.let { parts += "\"att\":${it.toJson()}" }
|
||||||
link?.let { parts += "\"lnk\":${it.toJson()}" }
|
link?.let { parts += "\"lnk\":${it.toJson()}" }
|
||||||
streamUrl?.let { parts += "\"stream\":${quote(it)}" }
|
streamUrl?.let { parts += "\"stream\":${quote(it)}" }
|
||||||
|
// §6.21 — a text story's background style. A hint, never required.
|
||||||
|
background?.let { parts += "\"bg\":$it" }
|
||||||
return parts.joinToString(",", prefix = "{", postfix = "}")
|
return parts.joinToString(",", prefix = "{", postfix = "}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,6 +752,22 @@ data class Content(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §6.21 — a story with no attachment: the caption is the story.
|
||||||
|
// 🔴 BEFORE the attachment branch below, which requires `att` and
|
||||||
|
// would otherwise drop this onto the ordinary-message path — a
|
||||||
|
// chat bubble in the conversation that never expires, both things
|
||||||
|
// §6.6 says a story must not be.
|
||||||
|
if (kind == Attachment.STORY && json.optJSONObject("att") == null &&
|
||||||
|
caption.isNotEmpty()) {
|
||||||
|
return Content(
|
||||||
|
caption, kind = kind, raw = json, messageId = messageId,
|
||||||
|
link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) },
|
||||||
|
isTextStory = true,
|
||||||
|
storyExpiry = json.optDouble("exp", 0.0).takeIf { it > 0.0 },
|
||||||
|
storyBackground = if (json.has("bg")) json.optInt("bg") else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (kind == Kind.TEXT) {
|
if (kind == Kind.TEXT) {
|
||||||
val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }
|
val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }
|
||||||
return Content(caption, kind = kind, raw = json, groupId = groupId, link = link,
|
return Content(caption, kind = kind, raw = json, groupId = groupId, link = link,
|
||||||
|
|||||||
@@ -434,7 +434,14 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
val id: String,
|
val id: String,
|
||||||
val authorId: Long,
|
val authorId: Long,
|
||||||
val mine: Boolean,
|
val mine: Boolean,
|
||||||
val ref: Attachment.Ref,
|
/**
|
||||||
|
* The sealed blob, when this story has one.
|
||||||
|
*
|
||||||
|
* ⚠️ Nullable since §6.21: a text story is its caption and carries no
|
||||||
|
* attachment. Nullable rather than a sentinel so nothing can read a
|
||||||
|
* blob id off a story that has no blob.
|
||||||
|
*/
|
||||||
|
val ref: Attachment.Ref?,
|
||||||
val caption: String,
|
val caption: String,
|
||||||
val postedAt: Long,
|
val postedAt: Long,
|
||||||
val seen: Boolean = false,
|
val seen: Boolean = false,
|
||||||
@@ -449,8 +456,41 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
* POSTER, and playback is an explicit, host-named tap.
|
* POSTER, and playback is an explicit, host-named tap.
|
||||||
*/
|
*/
|
||||||
val streamUrl: String? = null,
|
val streamUrl: String? = null,
|
||||||
|
/** Top-level `exp` for a story with no attachment (§6.21). */
|
||||||
|
val textExpiry: Double? = null,
|
||||||
|
/** A text story's background style hint (§6.21). */
|
||||||
|
val background: Int? = null,
|
||||||
|
/**
|
||||||
|
* A preview of a link in a text story (§6.12, §6.21).
|
||||||
|
*
|
||||||
|
* 🔴 Fetched by the SENDER and carried inside the ciphertext. Drawing
|
||||||
|
* it viewer-side would tell the site the address of every viewer — for
|
||||||
|
* a story that is the whole audience, not one correspondent.
|
||||||
|
*/
|
||||||
|
val link: Content.LinkPreview? = null,
|
||||||
) {
|
) {
|
||||||
fun isActive(nowSeconds: Double): Boolean = ref.expires <= 0 || ref.expires > nowSeconds
|
/**
|
||||||
|
* When this story stops rendering, unix seconds. 0 = never.
|
||||||
|
*
|
||||||
|
* ⚠️ A story with a blob carries its expiry on the ref; a text story
|
||||||
|
* (§6.21) has none, so the envelope's top-level `exp` is kept here.
|
||||||
|
* One accessor, so no caller has to know which kind it is holding.
|
||||||
|
*/
|
||||||
|
val expiresAt: Double get() = ref?.expires ?: textExpiry ?: 0.0
|
||||||
|
|
||||||
|
fun isActive(nowSeconds: Double): Boolean =
|
||||||
|
expiresAt <= 0 || expiresAt > nowSeconds
|
||||||
|
|
||||||
|
/** True when the caption IS the story (§6.21). */
|
||||||
|
val isTextOnly: Boolean get() = ref == null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What names this story for seen-marks and de-duplication.
|
||||||
|
*
|
||||||
|
* ⚠️ A text story has no blob id, so it is named by what cannot repeat
|
||||||
|
* for one author: when it was posted, and what it said.
|
||||||
|
*/
|
||||||
|
val key: String get() = ref?.id ?: "txt-$authorId-$postedAt-${caption.hashCode()}"
|
||||||
|
|
||||||
/** The host a viewer would connect to, for the disclosure. */
|
/** The host a viewer would connect to, for the disclosure. */
|
||||||
val streamHost: String?
|
val streamHost: String?
|
||||||
@@ -654,9 +694,14 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList()
|
val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList()
|
||||||
return (0 until array.length()).mapNotNull { i ->
|
return (0 until array.length()).mapNotNull { i ->
|
||||||
val o = array.getJSONObject(i)
|
val o = array.getJSONObject(i)
|
||||||
val ref = Attachment.Ref.fromJson(
|
// §6.21 — a text story has no `ref`. Absent is legitimate now, so
|
||||||
Attachment.STORY, o.getJSONObject("ref"), o.getJSONObject("ref").optDouble("exp", 0.0)
|
// it cannot mean "malformed, drop it".
|
||||||
) ?: return@mapNotNull null
|
val refJson = o.optJSONObject("ref")
|
||||||
|
val ref = refJson?.let {
|
||||||
|
Attachment.Ref.fromJson(Attachment.STORY, it, it.optDouble("exp", 0.0))
|
||||||
|
}
|
||||||
|
if (refJson != null && ref == null) return@mapNotNull null
|
||||||
|
if (ref == null && o.optString("caption").isEmpty()) return@mapNotNull null
|
||||||
Story(
|
Story(
|
||||||
id = o.getString("id"),
|
id = o.getString("id"),
|
||||||
authorId = o.getLong("author"),
|
authorId = o.getLong("author"),
|
||||||
@@ -665,6 +710,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
caption = o.optString("caption"),
|
caption = o.optString("caption"),
|
||||||
postedAt = o.optLong("at"),
|
postedAt = o.optLong("at"),
|
||||||
seen = o.optBoolean("seen", false),
|
seen = o.optBoolean("seen", false),
|
||||||
|
textExpiry = o.optDouble("exp", 0.0).takeIf { it > 0.0 },
|
||||||
|
background = if (o.has("bg")) o.optInt("bg") else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -685,7 +732,11 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
.put("id", story.id)
|
.put("id", story.id)
|
||||||
.put("author", story.authorId)
|
.put("author", story.authorId)
|
||||||
.put("mine", story.mine)
|
.put("mine", story.mine)
|
||||||
.put("ref", story.ref.toJson())
|
// §6.21 — absent for a text story rather than an empty object,
|
||||||
|
// so a reload cannot resurrect it as a blob that fails to fetch.
|
||||||
|
.also { if (story.ref != null) it.put("ref", story.ref.toJson()) }
|
||||||
|
.also { if (story.textExpiry != null) it.put("exp", story.textExpiry) }
|
||||||
|
.also { if (story.background != null) it.put("bg", story.background) }
|
||||||
.put("caption", story.caption)
|
.put("caption", story.caption)
|
||||||
.put("at", story.postedAt)
|
.put("at", story.postedAt)
|
||||||
.put("seen", story.seen))
|
.put("seen", story.seen))
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package io.flamenet.e2e
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/** Stories that are only words, and the links in them (spec §6.21, ios#32). */
|
||||||
|
class TextStoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a text story carries its caption and expiry`() {
|
||||||
|
val content = Content.parse(Content.textStory("back Thursday", expires = 1788000000.0))
|
||||||
|
assertTrue(content.isTextStory)
|
||||||
|
assertEquals("back Thursday", content.text)
|
||||||
|
assertEquals(1788000000.0, content.storyExpiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ⚠️ No `att`: an empty attachment would be fetched, fail, and show broken. */
|
||||||
|
@Test
|
||||||
|
fun `a text story carries no attachment`() {
|
||||||
|
val json = JSONObject(Content.textStory("hello", expires = 1.0))
|
||||||
|
assertFalse(json.has("att"))
|
||||||
|
assertEquals(Attachment.STORY, json.getString("kind"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔴 The decode must run BEFORE the attachment branch. If it does not, a
|
||||||
|
* story with no `att` falls through to the ordinary-message path and shows
|
||||||
|
* as a chat bubble that never expires — both things §6.6 forbids.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a text story is never an ordinary message`() {
|
||||||
|
val content = Content.parse("""{"fnc":1,"kind":"story","caption":"hi","exp":99}""")
|
||||||
|
assertTrue(content.isTextStory)
|
||||||
|
assertNull(content.attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A story with neither blob nor words is nothing, and is not a story. */
|
||||||
|
@Test
|
||||||
|
fun `a story with no attachment and no caption is not a text story`() {
|
||||||
|
assertFalse(Content.parse("""{"fnc":1,"kind":"story","caption":"","exp":9}""").isTextStory)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unknown background falls back rather than refusing the story. */
|
||||||
|
@Test
|
||||||
|
fun `an unknown background still yields a story`() {
|
||||||
|
val content = Content.parse(
|
||||||
|
"""{"fnc":1,"kind":"story","caption":"hi","exp":99,"bg":9999}""")
|
||||||
|
assertTrue(content.isTextStory)
|
||||||
|
assertEquals(9999, content.storyBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the marker stays first`() {
|
||||||
|
assertTrue(Content.textStory("hi", expires = 1.0).startsWith("{\"fnc\":1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- The model and its storage --------------------------------------------
|
||||||
|
|
||||||
|
private class MapStorage : Vault.Storage {
|
||||||
|
val records = LinkedHashMap<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() = javax.crypto.KeyGenerator.getInstance("AES")
|
||||||
|
.apply { init(256) }.generateKey()
|
||||||
|
|
||||||
|
private fun textStory(expires: Double) = Vault.Story(
|
||||||
|
id = "s1", authorId = 7, mine = false, ref = null, caption = "hi",
|
||||||
|
postedAt = 100, textExpiry = expires,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a text story expires on its top-level stamp`() {
|
||||||
|
val now = System.currentTimeMillis() / 1000.0
|
||||||
|
assertFalse(textStory(now - 60).isActive(now))
|
||||||
|
assertTrue(textStory(now + 60).isActive(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a text story is marked as having no blob`() {
|
||||||
|
assertTrue(textStory(0.0).isTextOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔴 The stored record must omit `ref` entirely rather than write an empty
|
||||||
|
* object — a reload would otherwise resurrect a blob that fails to fetch.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a text story round-trips through the vault without a ref`() {
|
||||||
|
val vault = Vault(MapStorage(), key())
|
||||||
|
val now = System.currentTimeMillis() / 1000.0
|
||||||
|
vault.saveStories(listOf(textStory(now + 600)), now)
|
||||||
|
val back = vault.loadStories()
|
||||||
|
assertEquals(1, back.size)
|
||||||
|
assertNull(back[0].ref)
|
||||||
|
assertEquals("hi", back[0].caption)
|
||||||
|
assertTrue(back[0].isTextOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ⚠️ An expired text story must leave on write, like any other. */
|
||||||
|
@Test
|
||||||
|
fun `an expired text story is not written back`() {
|
||||||
|
val vault = Vault(MapStorage(), key())
|
||||||
|
val now = System.currentTimeMillis() / 1000.0
|
||||||
|
vault.saveStories(listOf(textStory(now - 10)), now)
|
||||||
|
assertTrue(vault.loadStories().isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user