fix(notes): re-check displayed notes against their expiry

A dead drop can cross its NIP-40 expiry while it is on screen. Filtering
at ingest alone kept it visible until the subscription was recreated, so
the note outlived its expiry exactly in the session where someone is
reading it.

Notes now carry their expiry, and a 60s job started with the
subscription (and cancelled with it) drops the ones that have passed --
the same interval and behaviour as the iOS manager. Ids stay in noteIDs
so a relay replay cannot resurrect a dropped note.
This commit is contained in:
Taksh 2026-08-17 10:21:25 +05:30
parent 26100f447b
commit 15af9bf68e
2 changed files with 116 additions and 3 deletions

View File

@ -20,6 +20,19 @@ class LocationNotesManager private constructor() {
private const val TAG = "LocationNotesManager"
private const val MAX_NOTES_IN_MEMORY = 500
/** How often displayed notes are re-checked against their NIP-40 expiry. */
private const val EXPIRY_PRUNE_INTERVAL_MS = 60_000L
/**
* Drops notes whose NIP-40 expiry has passed. Their ids stay in
* `noteIDs`, so a relay replay cannot resurrect them.
*/
internal fun pruneExpired(notes: List<Note>, nowMillis: Long): List<Note> =
notes.filter { note ->
val expiresAt = note.expiresAtSeconds ?: return@filter true
expiresAt * 1000L > nowMillis
}
@Volatile
private var INSTANCE: LocationNotesManager? = null
@ -38,7 +51,9 @@ class LocationNotesManager private constructor() {
val pubkey: String,
val content: String,
val createdAt: Int,
val nickname: String?
val nickname: String?,
/** NIP-40 expiry as unix seconds, when the note carries one. */
val expiresAtSeconds: Long? = null
) {
/**
* Display name for the note - matches iOS exactly
@ -99,6 +114,7 @@ class LocationNotesManager private constructor() {
private var liveLocationToken: Long? = null
private var subscribeRetryJob: Job? = null
private var initialLoadJob: Job? = null
private var expiryPruneJob: Job? = null
init {
LiveLocationPrivacyGate.addRevocationListener(::stop)
@ -370,7 +386,18 @@ class LocationNotesManager private constructor() {
}
_state.value = State.LOADING
// A note can expire while it is on screen (a 24h dead drop crossing its
// boundary). Filtering at ingest alone would keep it visible until the
// subscription is recreated, so re-check the displayed notes as well.
expiryPruneJob?.cancel()
expiryPruneJob = scope.launch {
while (isActive) {
delay(EXPIRY_PRUNE_INTERVAL_MS)
pruneExpiredNotes()
}
}
// Subscribe for each geohash in the ±1 set
subscribedGeohashes.forEach { gh ->
if (!LiveLocationPrivacyGate.accepts(token)) return
@ -452,7 +479,8 @@ class LocationNotesManager private constructor() {
pubkey = event.pubkey,
content = event.content,
createdAt = event.createdAt,
nickname = nickname
nickname = nickname,
expiresAtSeconds = expiresAt
)
// Add to collection
@ -481,6 +509,18 @@ class LocationNotesManager private constructor() {
return tag[1].toLongOrNull()
}
/**
* Drops displayed notes whose NIP-40 expiry has passed.
*/
private fun pruneExpiredNotes() {
val current = _notes.value ?: return
val remaining = pruneExpired(current, System.currentTimeMillis())
// Ids stay in noteIDs so a relay replay cannot resurrect a dropped note.
if (remaining.size != current.size) {
_notes.value = remaining
}
}
/**
* Trim oldest notes to stay within memory limit
*/
@ -513,6 +553,8 @@ class LocationNotesManager private constructor() {
subscribeRetryJob = null
initialLoadJob?.cancel()
initialLoadJob = null
expiryPruneJob?.cancel()
expiryPruneJob = null
if (subscriptionIDs.isNotEmpty()) {
subscriptionIDs.values.forEach { subId ->

View File

@ -0,0 +1,71 @@
package com.bitchat.android.nostr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Two places Android disagreed with iOS on the same note event: the case of the
* geohash tag, and a NIP-40 expiry that passes while the note is displayed.
*/
class LocationNotesCaseAndExpiryTest {
private fun note(id: String, expiresAtSeconds: Long?) = LocationNotesManager.Note(
id = id,
pubkey = "a".repeat(64),
content = "hi",
createdAt = 1_700_000_000,
nickname = null,
expiresAtSeconds = expiresAtSeconds
)
private fun event(tags: List<List<String>>) = NostrEvent(
id = "b".repeat(64),
pubkey = "a".repeat(64),
createdAt = 1_700_000_000,
kind = NostrKind.TEXT_NOTE,
tags = tags,
content = "hi"
)
@Test
fun `an uppercase geohash tag passes the subscription filter`() {
val filter = NostrFilter.geohashNotes(geohash = "u4pruyd")
assertTrue(filter.matches(event(listOf(listOf("g", "u4pruyd")))))
// iOS has no client-side filter and lowercases where it reads the tag,
// so this note is visible there; it has to reach the handler here too.
assertTrue(filter.matches(event(listOf(listOf("G", "U4PRUYD")))))
}
@Test
fun `a different geohash is still rejected`() {
val filter = NostrFilter.geohashNotes(geohash = "u4pruyd")
assertFalse(filter.matches(event(listOf(listOf("g", "u4pruye")))))
assertFalse(filter.matches(event(listOf(listOf("g")))))
}
@Test
fun `a note is dropped once its expiry passes`() {
val now = 1_700_000_000_000L
val notes = listOf(
note("plain", expiresAtSeconds = null),
note("later", expiresAtSeconds = 1_700_000_060L),
note("gone", expiresAtSeconds = 1_699_999_940L)
)
val remaining = LocationNotesManager.pruneExpired(notes, now)
assertEquals(listOf("plain", "later"), remaining.map { it.id })
}
@Test
fun `pruning at the expiry instant drops the note`() {
val notes = listOf(note("edge", expiresAtSeconds = 1_700_000_000L))
assertTrue(LocationNotesManager.pruneExpired(notes, 1_699_999_999_000L).isNotEmpty())
assertTrue(LocationNotesManager.pruneExpired(notes, 1_700_000_000_000L).isEmpty())
}
}