mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
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.
This commit is contained in:
parent
26100f447b
commit
15af9bf68e
@ -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
|
||||
@ -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 ->
|
||||
|
||||
@ -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