From 15af9bf68e73798e95a07e89cba5a2560288549d Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 17 Aug 2026 10:21:25 +0530 Subject: [PATCH] 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. --- .../android/nostr/LocationNotesManager.kt | 48 ++++++++++++- .../nostr/LocationNotesCaseAndExpiryTest.kt | 71 +++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/nostr/LocationNotesCaseAndExpiryTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index d2526f26..810d8576 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -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, nowMillis: Long): List = + 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 -> diff --git a/app/src/test/java/com/bitchat/android/nostr/LocationNotesCaseAndExpiryTest.kt b/app/src/test/java/com/bitchat/android/nostr/LocationNotesCaseAndExpiryTest.kt new file mode 100644 index 00000000..455b026b --- /dev/null +++ b/app/src/test/java/com/bitchat/android/nostr/LocationNotesCaseAndExpiryTest.kt @@ -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>) = 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()) + } +}