Merge d44c42fa05e93b5a28e10ad27348a9ad8892150d into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
Taksh Kothari 2026-09-14 09:43:25 +05:30 committed by GitHub
commit cee501f51b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 156 additions and 21 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
@ -422,23 +449,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" }
@ -450,7 +484,8 @@ class LocationNotesManager private constructor() {
pubkey = event.pubkey,
content = event.content,
createdAt = event.createdAt,
nickname = nickname
nickname = nickname,
expiresAtSeconds = expiresAt
)
// Add to collection
@ -470,6 +505,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
*/
@ -502,6 +558,8 @@ class LocationNotesManager private constructor() {
subscribeRetryJob = null
initialLoadJob?.cancel()
initialLoadJob = null
expiryPruneJob?.cancel()
expiryPruneJob = null
if (subscriptionIDs.isNotEmpty()) {
subscriptionIDs.values.forEach { subId ->

View File

@ -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
}

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