From 293a7515960f8431677b34dcf0c624892acb2663 Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 17 Aug 2026 08:57:13 +0530 Subject: [PATCH 1/4] fix(notes): drop expired notes and match geohash tags case-insensitively Two ways the Android note handler disagrees with the iOS one, on the same event: - NIP-40. Relays are not required to drop expired events, so iOS checks the `expiration` tag client-side "so 24h dead drops actually vanish". Android had no such check, so an expired note kept showing until the list was cleared - the opposite of what a dead drop promises. - Case. Tag names and geohashes are case-insensitive, and iOS lowercases both before matching. Android compared them exactly, so a note tagged ["G", "U4PRUYD"] was visible on iOS and invisible on Android. Both now follow the iOS handler. --- .../android/nostr/LocationNotesManager.kt | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) 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 a6927d71..d2526f26 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -417,23 +417,30 @@ class LocationNotesManager private constructor() { return } - // Check for geohash tag - val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" } + // Check for geohash tag. Tag names and geohashes are case-insensitive, + // and iOS matches them lowercased, so a note tagged ["G", "U4PRUYD"] + // has to be the same note on both platforms. + val validGeohashes = subscribedGeohashes.map { it.lowercase() }.toSet() + val geohashTag = event.tags.firstOrNull { + it.size >= 2 && it[0].lowercase() == "g" && validGeohashes.contains(it[1].lowercase()) + } if (geohashTag == null) { - Log.v(TAG, "Ignoring event without geohash tag: ${event.id.take(16)}...") + Log.v(TAG, "Ignoring event without a matching geohash tag: ${event.id.take(16)}...") return } - - // Check if matches current geohash - val eventGeohash = geohashTag[1] - if (!subscribedGeohashes.contains(eventGeohash)) { - return - } - + // Deduplicate if (noteIDs.contains(event.id)) { return } + + // NIP-40: relays are not required to drop expired events, so enforce it + // here - otherwise a 24h dead drop stays visible past its expiry. + val expiresAt = expirationSeconds(event) + if (expiresAt != null && expiresAt * 1000L <= System.currentTimeMillis()) { + Log.v(TAG, "Ignoring expired note: ${event.id.take(16)}...") + return + } // Extract nickname from tags val nicknameTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "n" } @@ -465,6 +472,15 @@ class LocationNotesManager private constructor() { _state.value = State.READY } + /** + * The NIP-40 `expiration` tag as unix seconds, when the event carries one. + */ + private fun expirationSeconds(event: NostrEvent): Long? { + val tag = event.tags.firstOrNull { it.size >= 2 && it[0].lowercase() == "expiration" } + ?: return null + return tag[1].toLongOrNull() + } + /** * Trim oldest notes to stay within memory limit */ From 26100f447bdfcf97f67511cddee7e1da144d674a Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 17 Aug 2026 10:21:25 +0530 Subject: [PATCH 2/4] fix(nostr): match tag filters case-insensitively in the client-side gate NostrRelayManager runs every incoming event through NostrFilter.matches before any handler sees it, and that comparison was exact. So the case-insensitive handling in the previous commit was unreachable over the relay path: a note tagged ["G", "U4PRUYD"] was dropped at the gate. iOS has no such client-side filter and lowercases the tag name and geohash where it reads them, so the note is visible there. The values this app filters on are hex ids or geohashes, both of which encode the same value in either case. --- .../com/bitchat/android/nostr/NostrFilter.kt | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt index df67822f..c9800594 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt @@ -191,18 +191,24 @@ data class NostrFilter( return false } - // Check tag filters + // Check tag filters. + // + // Tag names and values are compared case-insensitively. The values this + // app filters on are hex ids (`e`, `p`) or geohashes (`g`), and both + // encode the same value in either case; iOS has no client-side filter at + // all and lowercases the tag name and the geohash where it reads them, + // so an event tagged ["G", "U4PRUYD"] is a note there. Comparing exactly + // here dropped it before any handler saw it. if (tagFilters != null) { for ((tagName, requiredValues) in tagFilters) { - val eventTags = event.tags.filter { it.isNotEmpty() && it[0] == tagName } - val eventValues = eventTags.mapNotNull { tag -> - if (tag.size > 1) tag[1] else null - } - + val eventValues = event.tags + .filter { it.size > 1 && it[0].equals(tagName, ignoreCase = true) } + .map { it[1].lowercase() } + val hasMatch = requiredValues.any { requiredValue -> - eventValues.contains(requiredValue) + eventValues.contains(requiredValue.lowercase()) } - + if (!hasMatch) { return false } From 15af9bf68e73798e95a07e89cba5a2560288549d Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 17 Aug 2026 10:21:25 +0530 Subject: [PATCH 3/4] 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()) + } +}