Keep your own stories in a private archive
Android half of ios#54. Expired stories were gone from the author's device too, so there was no way to look back at what you posted. 🔴 This keeps something the interface told everybody would disappear, so the shape of the feature is the argument for it being acceptable: off unless switched on, labelled as what it is, and only ever holding stories this account posted. `Vault.archive` refuses anything else — the caller filters too, and this is the guard that survives the caller being rewritten. 🔴 The archive is deliberately NOT written through `saveStories`. Everything in it is expired by definition, and that path drops expired stories on write — so reusing it would empty the archive on the next pass. Separate record, and a test named for exactly that. Archiving happens in `stories()`, before `saveStories` drops the expired ones: the only moment they can be caught, since expired bytes leave on write. That runs on every load, so the fold is idempotent. ⚠️ The setting is injected into `Session` as a lambda. The engine layer has no Android dependency and must not read preferences, and an absent value has to read as OFF. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ddd280092e
commit
242391119b
@@ -83,6 +83,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
private const val CALL_LOG = "call-log"
|
||||
private const val STORY_AUDIENCE = "story-audience"
|
||||
private const val MUTED_STORIES = "muted-stories"
|
||||
|
||||
private const val STORY_ARCHIVE = "story-archive"
|
||||
private const val DISAPPEAR = "disappear"
|
||||
private const val SNOOZE = "snooze"
|
||||
private const val REPLY_REMINDER = "reply-reminder"
|
||||
@@ -152,6 +154,28 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
*/
|
||||
fun isSaveable(mine: Boolean, hasBlob: Boolean): Boolean = mine && hasBlob
|
||||
|
||||
/**
|
||||
* Fold newly-expired stories into the archive (ios#54).
|
||||
*
|
||||
* 🔴 **Refuses anything not the author's own.** The caller filters as
|
||||
* well, and this is the guard that survives the caller being
|
||||
* rewritten — an archive holding somebody else's expired story breaks
|
||||
* the promise the app made to them, and "it is private to you" does
|
||||
* not repair that.
|
||||
*
|
||||
* ⚠️ De-duplicated by id, because the sweep runs on every load and a
|
||||
* story that expired weeks ago would otherwise be appended each time.
|
||||
* Newest first, so the screen needs no sort.
|
||||
*/
|
||||
fun archive(existing: List<Story>, adding: List<Story>): List<Story> {
|
||||
val seen = existing.map { it.id }.toMutableSet()
|
||||
val result = existing.toMutableList()
|
||||
adding.forEach { story ->
|
||||
if (story.mine && seen.add(story.id)) result += story
|
||||
}
|
||||
return result.sortedByDescending { it.postedAt }
|
||||
}
|
||||
|
||||
fun threadForUser(userId: Long) = "user:$userId"
|
||||
fun threadForGroup(groupId: String) = "group:$groupId"
|
||||
|
||||
@@ -705,8 +729,11 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
if (ref == null) storage.delete(MY_AVATAR) else writeJson(MY_AVATAR, ref.toJson())
|
||||
}
|
||||
|
||||
fun loadStories(): List<Story> {
|
||||
val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList()
|
||||
fun loadStories(): List<Story> = storiesFrom(readJson(STORIES))
|
||||
|
||||
/** Shared by the live set and the archive (ios#54). */
|
||||
private fun storiesFrom(record: JSONObject?): List<Story> {
|
||||
val array = record?.optJSONArray("items") ?: return emptyList()
|
||||
return (0 until array.length()).mapNotNull { i ->
|
||||
val o = array.getJSONObject(i)
|
||||
// §6.21 — a text story has no `ref`. Absent is legitimate now, so
|
||||
@@ -741,8 +768,13 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
*/
|
||||
fun saveStories(stories: List<Story>, nowSeconds: Double) {
|
||||
val live = stories.filter { it.isActive(nowSeconds) }
|
||||
writeJson(STORIES, JSONObject().put("items", storiesToJson(live)))
|
||||
}
|
||||
|
||||
/** Shared by the live set and the archive (ios#54). */
|
||||
private fun storiesToJson(stories: List<Story>): org.json.JSONArray {
|
||||
val array = org.json.JSONArray()
|
||||
live.forEach { story ->
|
||||
stories.forEach { story ->
|
||||
array.put(JSONObject()
|
||||
.put("id", story.id)
|
||||
.put("author", story.authorId)
|
||||
@@ -756,7 +788,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
.put("at", story.postedAt)
|
||||
.put("seen", story.seen))
|
||||
}
|
||||
writeJson(STORIES, JSONObject().put("items", array))
|
||||
return array
|
||||
}
|
||||
|
||||
// -- Key-log pin (§19.3) -------------------------------------------------
|
||||
@@ -1198,6 +1230,17 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
* ⚠️ In the vault. An audience list is a statement about who somebody
|
||||
* trusts, which is exactly the metadata this app exists to keep private.
|
||||
*/
|
||||
/**
|
||||
* Stories this account posted, kept past their expiry (ios#54).
|
||||
*
|
||||
* 🔴 Only ever your own, and only when the setting is on. This keeps
|
||||
* something the interface told everybody would disappear.
|
||||
*/
|
||||
fun storyArchive(): List<Story> = storiesFrom(readJson(STORY_ARCHIVE))
|
||||
|
||||
fun saveStoryArchive(stories: List<Story>) =
|
||||
writeJson(STORY_ARCHIVE, JSONObject().put("items", storiesToJson(stories)))
|
||||
|
||||
fun storyAudience(): JSONObject? = readJson(STORY_AUDIENCE)
|
||||
|
||||
fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A private archive of your own stories (ios#54).
|
||||
*
|
||||
* 🔴 **This keeps something the interface said would disappear.** So it is
|
||||
* opt-in, and it only ever holds stories this account posted — an archive of
|
||||
* other people's expiring stories is the exact opposite of what they were
|
||||
* promised, and "it is private to you" does not repair that.
|
||||
*/
|
||||
class StoryArchiveTest {
|
||||
|
||||
private fun story(id: String, mine: Boolean, at: Long) = Vault.Story(
|
||||
id = id, authorId = if (mine) 1L else 7L, mine = mine,
|
||||
ref = Attachment.Ref(
|
||||
kind = Attachment.STORY, id = "b$id", key = ByteArray(32),
|
||||
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
|
||||
width = 1, height = 1, duration = 0.0, expires = 1.0,
|
||||
),
|
||||
caption = "c$id", postedAt = at,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `your own expired story is kept`() {
|
||||
assertEquals(listOf("1"),
|
||||
Vault.archive(emptyList(), listOf(story("1", true, 100))).map { it.id })
|
||||
}
|
||||
|
||||
/** 🔴 The rule the whole feature turns on. */
|
||||
@Test
|
||||
fun `somebody elses expired story is never kept`() {
|
||||
assertTrue(Vault.archive(emptyList(), listOf(story("2", false, 100))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mixed batch keeps only your own`() {
|
||||
val result = Vault.archive(emptyList(), listOf(
|
||||
story("1", true, 100), story("2", false, 200), story("3", true, 300)))
|
||||
assertEquals(setOf("1", "3"), result.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
/** ⚠️ The sweep runs on every load; without this, one story piles up. */
|
||||
@Test
|
||||
fun `archiving is idempotent`() {
|
||||
val one = story("1", true, 100)
|
||||
val first = Vault.archive(emptyList(), listOf(one))
|
||||
assertEquals(1, Vault.archive(first, listOf(one)).size)
|
||||
}
|
||||
|
||||
/** ⚠️ Newest first, so the screen needs no sort. */
|
||||
@Test
|
||||
fun `the archive is newest first`() {
|
||||
val result = Vault.archive(emptyList(), listOf(
|
||||
story("1", true, 100), story("3", true, 300), story("2", true, 200)))
|
||||
assertEquals(listOf("3", "2", "1"), result.map { it.id })
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
/** ⚠️ Kept in the vault, like everything else about somebody's stories. */
|
||||
@Test
|
||||
fun `the archive round-trips through the vault`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
assertTrue(vault.storyArchive().isEmpty())
|
||||
vault.saveStoryArchive(listOf(story("1", true, 100)))
|
||||
assertEquals(listOf("1"), vault.storyArchive().map { it.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The archive must NOT be swept by the expiry rule the live set uses —
|
||||
* everything in it is expired by definition, so reusing `saveStories`
|
||||
* would empty it on the next write.
|
||||
*/
|
||||
@Test
|
||||
fun `an expired story survives in the archive`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
vault.saveStoryArchive(listOf(story("1", true, 100)))
|
||||
assertEquals(1, vault.storyArchive().size)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user