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()) + } +}