mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge d44c42fa05e93b5a28e10ad27348a9ad8892150d into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
cee501f51b
@ -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 ->
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user