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
@@ -671,3 +671,23 @@ dropped — on exactly the path a story reply normally takes.
|
|||||||
⚠️ **The disclosure comes before the first reply**, on both platforms.
|
⚠️ **The disclosure comes before the first reply**, on both platforms.
|
||||||
Replying tells the author you watched, which stories otherwise never reveal.
|
Replying tells the author you watched, which stories otherwise never reveal.
|
||||||
Neither platform sends anything automatic when a story is merely opened.
|
Neither platform sends anything automatic when a story is merely opened.
|
||||||
|
||||||| parent of 4c004eb (Keep your own stories in a private archive)
|
||||||
|
## Private story archive (ios#54)
|
||||||
|
Your own stories, kept past their expiry. Local only; no protocol change.
|
||||||
|
🔴 **This keeps something the interface told everybody would disappear**, so
|
||||||
|
the shape is the argument for it being acceptable: off unless switched on,
|
||||||
|
labelled as what it is rather than as a feature, and **only ever your own**.
|
||||||
|
`Vault.archive` / `E2EInbox.archive` refuse anything not the author's own — the
|
||||||
|
callers filter too, but these are the guards that survive a caller being
|
||||||
|
rewritten. Both are tested from both directions.
|
||||||
|
⚠️ **Archiving happens at the moment expiry is applied**, which is the only
|
||||||
|
moment it can. iOS catches it on restore (a story past its expiry is never
|
||||||
|
loaded into `stories`); Android catches it in `Session.stories()` before
|
||||||
|
`saveStories` drops it. Both run repeatedly, so the fold is idempotent —
|
||||||
|
otherwise one long-expired story would be appended on every pass.
|
||||||
|
🔴 **The archive is not swept by the expiry rule.** Everything in it is expired
|
||||||
|
by definition, so reusing `saveStories` would empty it on the next write.
|
||||||
|
Android has a separate record and a test named for exactly that.
|
||||||
|
⚠️ Android injects the setting into `Session` as a lambda rather than reading
|
||||||
|
preferences in the engine layer, which has no Android dependency. An absent
|
||||||
|
value reads as OFF on both platforms.
|
||||||
|
|||||||
@@ -738,6 +738,9 @@ fun ColumnScope.AccountScreen(
|
|||||||
|
|
||||||
SectionBand()
|
SectionBand()
|
||||||
SettingsSection { StoryAudienceSection(model, phase) }
|
SettingsSection { StoryAudienceSection(model, phase) }
|
||||||
|
// ios#54 — beside the audience setting, because both answer "what happens
|
||||||
|
// to my stories".
|
||||||
|
SettingsSection { StoryArchiveSection(model, phase) }
|
||||||
|
|
||||||
SectionBand()
|
SectionBand()
|
||||||
SettingsSection { AppLockSection() }
|
SettingsSection { AppLockSection() }
|
||||||
@@ -1110,6 +1113,67 @@ private fun storyLifetimeLabel(hours: Int): String = when {
|
|||||||
else -> (hours / 24).let { if (it == 1) "1 day" else "$it days" }
|
else -> (hours / 24).let { if (it == 1) "1 day" else "$it days" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stories this account posted, kept past their expiry (ios#54).
|
||||||
|
*
|
||||||
|
* 🔴 **Labelled as what it is, not as a feature.** This holds copies of things
|
||||||
|
* the app told everybody would disappear. Somebody looking at it should be in
|
||||||
|
* no doubt that these are kept, that they are kept only here, and that nobody
|
||||||
|
* else's stories are or can be in it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ColumnScope.StoryArchiveSection(
|
||||||
|
model: MessengerViewModel,
|
||||||
|
phase: MessengerViewModel.Phase.SignedIn,
|
||||||
|
) {
|
||||||
|
LaunchedEffect(Unit) { model.refreshStoryArchive() }
|
||||||
|
Text("Your story archive", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("Keep my stories", modifier = Modifier.weight(1f))
|
||||||
|
androidx.compose.material3.Switch(
|
||||||
|
checked = phase.storyArchiveOn,
|
||||||
|
onCheckedChange = { model.setStoryArchiveEnabled(it) },
|
||||||
|
modifier = Modifier.testTag("story-archive-toggle"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Your stories disappear for everyone, including you. With this on, a copy of "
|
||||||
|
+ "the ones YOU posted stays on this device after they expire. It never "
|
||||||
|
+ "keeps anybody else's — theirs disappear as they were told they would.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Palette.SecondaryText,
|
||||||
|
)
|
||||||
|
if (phase.storyArchive.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
if (phase.storyArchiveOn) {
|
||||||
|
"Nothing kept yet. Stories you post from now on appear here once they expire."
|
||||||
|
} else {
|
||||||
|
"Nothing kept. Turn the switch on to start keeping your own."
|
||||||
|
},
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Palette.SecondaryText,
|
||||||
|
modifier = Modifier.padding(top = Space.sm),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
phase.storyArchive.forEach { kept ->
|
||||||
|
Text(
|
||||||
|
kept.caption.ifBlank { "No caption" },
|
||||||
|
modifier = Modifier.padding(top = Space.sm).testTag("kept-${kept.id}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(Space.sm))
|
||||||
|
KitChip(
|
||||||
|
text = "Delete everything kept",
|
||||||
|
selected = false,
|
||||||
|
onClick = { model.clearStoryArchive() },
|
||||||
|
tag = "clear-story-archive",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.StoryAudienceSection(
|
private fun ColumnScope.StoryAudienceSection(
|
||||||
model: MessengerViewModel,
|
model: MessengerViewModel,
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ class MediaSettings(context: Context) {
|
|||||||
/** Seconds, as [io.flamenet.client.Conversations.postStory] wants it. */
|
/** Seconds, as [io.flamenet.client.Conversations.postStory] wants it. */
|
||||||
val storyLifetimeSeconds: Double get() = storyLifetimeHours * 60.0 * 60.0
|
val storyLifetimeSeconds: Double get() = storyLifetimeHours * 60.0 * 60.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep your own stories after they expire, in a private archive (ios#54).
|
||||||
|
*
|
||||||
|
* 🔴 **Off unless switched on, and an absent value reads as false.** This
|
||||||
|
* keeps something the interface told everybody would disappear, so it
|
||||||
|
* cannot be the quiet default — and it only ever holds stories this
|
||||||
|
* account posted.
|
||||||
|
*/
|
||||||
|
var storyArchiveEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean("stories.archiveEnabled", false)
|
||||||
|
set(value) { prefs.edit().putBoolean("stories.archiveEnabled", value).apply() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refuse to send unless the relay can prove the recipient's keys appear in
|
* Refuse to send unless the relay can prove the recipient's keys appear in
|
||||||
* the log it publishes.
|
* the log it publishes.
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
/** Who may see this account's stories. */
|
/** Who may see this account's stories. */
|
||||||
val storyAudience: Session.StoryAudience = Session.StoryAudience(),
|
val storyAudience: Session.StoryAudience = Session.StoryAudience(),
|
||||||
|
/** Stories this account posted, kept past expiry (ios#54). */
|
||||||
|
val storyArchive: List<Vault.Story> = emptyList(),
|
||||||
|
/** Whether the archive is switched on (ios#54). */
|
||||||
|
val storyArchiveOn: Boolean = false,
|
||||||
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
|
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
|
||||||
/** Live stories, newest first (§6.6). */
|
/** Live stories, newest first (§6.6). */
|
||||||
val stories: List<Vault.Story> = emptyList(),
|
val stories: List<Vault.Story> = emptyList(),
|
||||||
@@ -360,7 +364,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
relay = relay,
|
relay = relay,
|
||||||
api = MessengerApi(
|
api = MessengerApi(
|
||||||
credential.relayBaseUrl, credential.username, credential.appPassword),
|
credential.relayBaseUrl, credential.username, credential.appPassword),
|
||||||
)
|
).also {
|
||||||
|
// ios#54 — injected, because the engine layer has no
|
||||||
|
// Android dependency and must not read preferences.
|
||||||
|
it.archiveEnabled = {
|
||||||
|
MediaSettings(getApplication()).storyArchiveEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
val fetchedBuddies = runCatching { session!!.roster() }
|
val fetchedBuddies = runCatching { session!!.roster() }
|
||||||
val fetchedGroups = runCatching { session!!.namedGroups() }
|
val fetchedGroups = runCatching { session!!.namedGroups() }
|
||||||
stored to Phase.SignedIn(
|
stored to Phase.SignedIn(
|
||||||
@@ -570,7 +580,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
vault = vault,
|
vault = vault,
|
||||||
relay = relay,
|
relay = relay,
|
||||||
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
|
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
|
||||||
)
|
).also {
|
||||||
|
it.archiveEnabled = { MediaSettings(getApplication()).storyArchiveEnabled }
|
||||||
|
}
|
||||||
|
|
||||||
// 🔴 **Our return address, before the first federated send.** A relay
|
// 🔴 **Our return address, before the first federated send.** A relay
|
||||||
// that accepts a federated envelope records no sender, and our user id
|
// that accepts a federated envelope records no sender, and our user id
|
||||||
@@ -2017,6 +2029,36 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep your own stories after they expire (ios#54).
|
||||||
|
*
|
||||||
|
* 🔴 Off unless switched on. This keeps something the interface told
|
||||||
|
* everybody would disappear, so it cannot be the quiet default — and it
|
||||||
|
* only ever holds stories this account posted.
|
||||||
|
*/
|
||||||
|
fun setStoryArchiveEnabled(on: Boolean) = viewModelScope.launch {
|
||||||
|
MediaSettings(getApplication()).storyArchiveEnabled = on
|
||||||
|
update { it.copy(storyArchiveOn = on) }
|
||||||
|
refreshStoryArchive()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshStoryArchive() = viewModelScope.launch {
|
||||||
|
val current = session ?: return@launch
|
||||||
|
val on = MediaSettings(getApplication()).storyArchiveEnabled
|
||||||
|
update { it.copy(storyArchiveOn = on) }
|
||||||
|
val kept = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { current.storyArchive() }.getOrNull()
|
||||||
|
} ?: return@launch
|
||||||
|
update { it.copy(storyArchive = kept) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty the archive (ios#54). */
|
||||||
|
fun clearStoryArchive() = viewModelScope.launch {
|
||||||
|
val current = session ?: return@launch
|
||||||
|
withContext(Dispatchers.IO) { runCatching { current.clearStoryArchive() } }
|
||||||
|
update { it.copy(storyArchive = emptyList()) }
|
||||||
|
}
|
||||||
|
|
||||||
/** 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
|
||||||
|
|||||||
@@ -887,7 +887,18 @@ class Session(
|
|||||||
/** Everything still live, newest first. */
|
/** Everything still live, newest first. */
|
||||||
fun stories(): List<Vault.Story> {
|
fun stories(): List<Vault.Story> {
|
||||||
val now = System.currentTimeMillis() / 1000.0
|
val now = System.currentTimeMillis() / 1000.0
|
||||||
val live = vault.loadStories().filter { it.isActive(now) }
|
val all = vault.loadStories()
|
||||||
|
val live = all.filter { it.isActive(now) }
|
||||||
|
// ios#54 — anything of YOUR OWN that has expired is archived here,
|
||||||
|
// before `saveStories` drops it. This is the only moment it can be
|
||||||
|
// caught: expired stories leave on write, so nothing downstream sees
|
||||||
|
// them again.
|
||||||
|
if (archiveEnabled()) {
|
||||||
|
val expiredMine = all.filter { it.mine && !it.isActive(now) }
|
||||||
|
if (expiredMine.isNotEmpty()) {
|
||||||
|
vault.saveStoryArchive(Vault.archive(vault.storyArchive(), expiredMine))
|
||||||
|
}
|
||||||
|
}
|
||||||
// Written back so expired bytes leave the device rather than lingering
|
// Written back so expired bytes leave the device rather than lingering
|
||||||
// until the next post.
|
// until the next post.
|
||||||
//
|
//
|
||||||
@@ -1105,6 +1116,21 @@ class Session(
|
|||||||
return reached
|
return reached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the private story archive is switched on (ios#54).
|
||||||
|
*
|
||||||
|
* ⚠️ Injected rather than read from a context here: the engine layer has
|
||||||
|
* no Android dependency, and an unset value must read as OFF — the archive
|
||||||
|
* keeps something the interface said would disappear.
|
||||||
|
*/
|
||||||
|
var archiveEnabled: () -> Boolean = { false }
|
||||||
|
|
||||||
|
/** Stories this account posted, kept past their expiry (ios#54). */
|
||||||
|
fun storyArchive(): List<Vault.Story> = vault.storyArchive()
|
||||||
|
|
||||||
|
/** Empty the archive (ios#54). */
|
||||||
|
fun clearStoryArchive() = vault.saveStoryArchive(emptyList())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
private const val CALL_LOG = "call-log"
|
private const val CALL_LOG = "call-log"
|
||||||
private const val STORY_AUDIENCE = "story-audience"
|
private const val STORY_AUDIENCE = "story-audience"
|
||||||
private const val MUTED_STORIES = "muted-stories"
|
private const val MUTED_STORIES = "muted-stories"
|
||||||
|
|
||||||
|
private const val STORY_ARCHIVE = "story-archive"
|
||||||
private const val DISAPPEAR = "disappear"
|
private const val DISAPPEAR = "disappear"
|
||||||
private const val SNOOZE = "snooze"
|
private const val SNOOZE = "snooze"
|
||||||
private const val REPLY_REMINDER = "reply-reminder"
|
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
|
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 threadForUser(userId: Long) = "user:$userId"
|
||||||
fun threadForGroup(groupId: String) = "group:$groupId"
|
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())
|
if (ref == null) storage.delete(MY_AVATAR) else writeJson(MY_AVATAR, ref.toJson())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadStories(): List<Story> {
|
fun loadStories(): List<Story> = storiesFrom(readJson(STORIES))
|
||||||
val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList()
|
|
||||||
|
/** 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 ->
|
return (0 until array.length()).mapNotNull { i ->
|
||||||
val o = array.getJSONObject(i)
|
val o = array.getJSONObject(i)
|
||||||
// §6.21 — a text story has no `ref`. Absent is legitimate now, so
|
// §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) {
|
fun saveStories(stories: List<Story>, nowSeconds: Double) {
|
||||||
val live = stories.filter { it.isActive(nowSeconds) }
|
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()
|
val array = org.json.JSONArray()
|
||||||
live.forEach { story ->
|
stories.forEach { story ->
|
||||||
array.put(JSONObject()
|
array.put(JSONObject()
|
||||||
.put("id", story.id)
|
.put("id", story.id)
|
||||||
.put("author", story.authorId)
|
.put("author", story.authorId)
|
||||||
@@ -756,7 +788,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
.put("at", story.postedAt)
|
.put("at", story.postedAt)
|
||||||
.put("seen", story.seen))
|
.put("seen", story.seen))
|
||||||
}
|
}
|
||||||
writeJson(STORIES, JSONObject().put("items", array))
|
return array
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Key-log pin (§19.3) -------------------------------------------------
|
// -- 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
|
* ⚠️ In the vault. An audience list is a statement about who somebody
|
||||||
* trusts, which is exactly the metadata this app exists to keep private.
|
* 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 storyAudience(): JSONObject? = readJson(STORY_AUDIENCE)
|
||||||
|
|
||||||
fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, 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