Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 71 additions & 13 deletions app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -417,23 +444,30 @@ class LocationNotesManager private constructor() {
return
}

// Check for geohash tag
val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }
if (geohashTag == null) {
Log.v(TAG, "Ignoring event without geohash tag: ${event.id.take(16)}...")
return
// 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())
}
Comment on lines +451 to 453

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize geohash tags before client filtering

For relay-delivered events with an uppercase G tag or uppercase geohash, this handler still will not run: LocationNotesInitializer subscribes with the original NostrFilter, and NostrRelayManager.handleMessage calls subInfo.filter.matches(response.event) before dispatch; NostrFilter.matches still compares tag names and values exactly (it[0] == tagName, eventValues.contains(requiredValue)). Because the lowercasing happens only here, those events are dropped in the relay layer and Android still diverges from the intended case-insensitive behavior unless the filter match/subscription path is normalized too.

AGENTS.md reference: AGENTS.md:L90-L91

Useful? React with 👍 / 👎.


// Check if matches current geohash
val eventGeohash = geohashTag[1]
if (!subscribedGeohashes.contains(eventGeohash)) {
if (geohashTag == null) {
Log.v(TAG, "Ignoring event without a matching geohash tag: ${event.id.take(16)}...")
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()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Schedule removal when expiration passes

When a note arrives before its NIP-40 expiration time, this check admits it into _notes, but the manager does not store expiresAt or schedule/poll any later removal. In a long-lived nearby-notes session, a 24h dead-drop received shortly before expiry therefore remains visible in the sheet/header until the user refreshes, moves cells, or stops the subscription, so the new expiration enforcement still misses the common “expires while displayed” case.

AGENTS.md reference: AGENTS.md:L90-L91

Useful? React with 👍 / 👎.

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" }
Expand All @@ -445,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
Expand All @@ -465,6 +500,27 @@ 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()
}

/**
* 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
*/
Expand Down Expand Up @@ -497,6 +553,8 @@ class LocationNotesManager private constructor() {
subscribeRetryJob = null
initialLoadJob?.cancel()
initialLoadJob = null
expiryPruneJob?.cancel()
expiryPruneJob = null

if (subscriptionIDs.isNotEmpty()) {
subscriptionIDs.values.forEach { subId ->
Expand Down
22 changes: 14 additions & 8 deletions app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}