Fix background Nostr event processing

This commit is contained in:
callebtc 2026-07-27 18:23:26 +02:00
parent b22184940b
commit d8e380efae
13 changed files with 316 additions and 84 deletions

View File

@ -3,8 +3,6 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.MessageManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.Date
@ -17,11 +15,10 @@ import java.util.Date
*/
class GeohashMessageHandler(
private val application: Application,
private val state: ChatState,
private val messageManager: MessageManager,
private val repo: GeohashRepository,
private val scope: CoroutineScope,
private val dataManager: com.bitchat.android.ui.DataManager
private val dataManager: com.bitchat.android.ui.DataManager,
private val addChannelMessage: (String, BitchatMessage) -> Unit
) {
companion object { private const val TAG = "GeohashMessageHandler" }
@ -103,7 +100,7 @@ class GeohashMessageHandler(
if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null
} catch (_: Exception) { null }
)
messageManager.addChannelMessage("geo:$subscribedGeohash", msg)
addChannelMessage("geo:$subscribedGeohash", msg)
} catch (e: Exception) {
Log.e(TAG, "onEvent error: ${e.message}")
}

View File

@ -28,14 +28,17 @@ class GeohashRepository(
// conversation key (e.g., "nostr_<pub16>") -> source geohash it belongs to
private val conversationGeohash: MutableMap<String, String> = mutableMapOf()
@Synchronized
fun setConversationGeohash(convKey: String, geohash: String) {
if (geohash.isNotEmpty()) {
conversationGeohash[convKey] = geohash
}
}
@Synchronized
fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey]
@Synchronized
fun findPubkeyByNickname(targetNickname: String): String? {
return geoNicknames.entries.firstOrNull { (_, nickname) ->
val base = nickname.split("#").firstOrNull() ?: nickname
@ -43,6 +46,7 @@ class GeohashRepository(
}?.key
}
@Synchronized
fun findPubkeyByShortId(shortId: String): String? {
// First check cached nicknames (fastest)
var found = geoNicknames.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) }
@ -66,6 +70,7 @@ class GeohashRepository(
fun setCurrentGeohash(geo: String?) { currentGeohash = geo }
fun getCurrentGeohash(): String? = currentGeohash
@Synchronized
fun clearAll() {
geohashParticipants.clear()
geoNicknames.clear()
@ -76,6 +81,7 @@ class GeohashRepository(
currentGeohash = null
}
@Synchronized
fun cacheNickname(pubkeyHex: String, nickname: String) {
val lower = pubkeyHex.lowercase()
val previous = geoNicknames[lower]
@ -85,8 +91,10 @@ class GeohashRepository(
}
}
@Synchronized
fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()]
@Synchronized
fun markTeleported(pubkeyHex: String) {
val set = state.getTeleportedGeoValue().toMutableSet()
val key = pubkeyHex.lowercase()
@ -97,10 +105,12 @@ class GeohashRepository(
}
}
@Synchronized
fun isPersonTeleported(pubkeyHex: String): Boolean {
return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase())
}
@Synchronized
fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) {
val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() }
// Cap to now: prevents future-timestamped events (clock skew / malicious created_at)
@ -117,6 +127,7 @@ class GeohashRepository(
updateReactiveParticipantCounts()
}
@Synchronized
fun geohashParticipantCount(geohash: String): Int {
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
val participants = geohashParticipants[geohash] ?: return 0
@ -130,6 +141,7 @@ class GeohashRepository(
return participants.keys.count { !dataManager.isGeohashUserBlocked(it) }
}
@Synchronized
fun refreshGeohashPeople() {
val geohash = currentGeohash
if (geohash == null) {
@ -168,6 +180,7 @@ class GeohashRepository(
state.setGeohashPeople(people)
}
@Synchronized
fun updateReactiveParticipantCounts() {
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
val counts = mutableMapOf<String, Int>()
@ -180,12 +193,15 @@ class GeohashRepository(
state.setGeohashParticipantCounts(counts)
}
@Synchronized
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
nostrKeyMapping[tempKeyOrPeer] = pubkeyHex
}
@Synchronized
fun getNostrKeyMapping(): Map<String, String> = nostrKeyMapping.toMap()
@Synchronized
fun displayNameForNostrPubkey(pubkeyHex: String): String {
val suffix = pubkeyHex.takeLast(4)
val lower = pubkeyHex.lowercase()
@ -203,6 +219,7 @@ class GeohashRepository(
return "$nick#$suffix"
}
@Synchronized
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String {
val lower = pubkeyHex.lowercase()
val suffix = pubkeyHex.takeLast(4)
@ -234,6 +251,7 @@ class GeohashRepository(
/**
* Get display name for any geohash (not just current one) for header titles
*/
@Synchronized
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String {
val lower = pubkeyHex.lowercase()
val suffix = pubkeyHex.takeLast(4)

View File

@ -0,0 +1,115 @@
package com.bitchat.android.nostr
import android.app.Application
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.MessageManager
import com.bitchat.android.ui.NoiseSessionDelegate
import com.bitchat.android.ui.PrivateChatManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Process-owned Nostr event processing.
*
* Relay subscriptions must remain useful when no Activity exists, so their handlers cannot be
* borrowed from a ViewModel. This processor owns only application-scoped collaborators and writes
* messages to [AppStateStore], which the next UI instance hydrates from.
*/
internal class NostrBackgroundEventProcessor(
application: Application,
parentScope: CoroutineScope
) {
private val scope = CoroutineScope(
parentScope.coroutineContext + Dispatchers.IO.limitedParallelism(1)
)
private val state = ChatState(scope)
private val dataManager = DataManager(application.applicationContext).apply {
state.setNickname(loadNickname())
loadBlockedUsers()
loadGeohashBlockedUsers()
}
private val messageManager = MessageManager(state)
private val geohashRepository = GeohashRepository(application, state, dataManager)
private val privateChatManager = PrivateChatManager(
state = state,
messageManager = messageManager,
dataManager = dataManager,
noiseSessionDelegate = object : NoiseSessionDelegate {
override fun hasEstablishedSession(peerID: String): Boolean = false
override fun initiateHandshake(peerID: String) = Unit
override fun getMyPeerID(): String = ""
},
trackUnreadMessages = false
)
private val geohashMessageHandler = GeohashMessageHandler(
application = application,
repo = geohashRepository,
scope = scope,
dataManager = dataManager,
addChannelMessage = AppStateStore::addChannelMessage
)
private val directMessageHandler = NostrDirectMessageHandler(
application = application,
state = state,
privateChatManager = privateChatManager,
updateDeliveryStatus = ::updateDeliveryStatus,
scope = scope,
repo = geohashRepository,
dataManager = dataManager
)
init {
// Keep the headless state aligned with messages sent or received through other transports.
// This preserves duplicate detection and focused-conversation behavior without retaining UI.
scope.launch {
AppStateStore.privateMessages.collect(state::setPrivateChats)
}
scope.launch {
AppStateStore.nickname.collect(state::setNickname)
}
scope.launch {
AppStateStore.selectedPrivateChatPeer.collect(state::setSelectedPrivateChatPeer)
}
}
fun onAccountDm(event: NostrEvent, identity: NostrIdentity) {
refreshBlockLists()
directMessageHandler.onGiftWrap(event, "", identity)
}
fun onGeohashMessage(event: NostrEvent, geohash: String) {
refreshBlockLists()
geohashMessageHandler.onEvent(event, geohash)
}
fun onGeohashDm(event: NostrEvent, geohash: String, identity: NostrIdentity) {
refreshBlockLists()
directMessageHandler.onGiftWrap(event, geohash, identity)
}
fun conversationGeohash(conversationKey: String): String? =
geohashRepository.getConversationGeohash(conversationKey)
?: GeohashConversationRegistry.get(conversationKey)
fun displayNameForNostrPubkey(pubkeyHex: String): String =
geohashRepository.displayNameForNostrPubkeyUI(pubkeyHex)
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String =
geohashRepository.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
private fun updateDeliveryStatus(messageId: String, status: DeliveryStatus) {
messageManager.updateMessageDeliveryStatus(messageId, status)
// The headless state may not yet contain a just-sent UI message. Update the process store
// unconditionally so a delivery/read receipt can never be lost during Activity handoff.
AppStateStore.updatePrivateMessageStatus(messageId, status)
}
private fun refreshBlockLists() {
dataManager.loadBlockedUsers()
dataManager.loadGeohashBlockedUsers()
}
}

View File

@ -23,28 +23,16 @@ import kotlin.random.asKotlinRandom
/**
* Process-owned Nostr connectivity and low-volume background subscriptions.
*
* Stable subscription handlers delegate to the most recently attached UI-independent handlers.
* This avoids relay subscriptions retaining a cleared ViewModel while keeping DMs and the selected
* geohash channel alive for the lifetime of the foreground-service process.
* Stable subscriptions dispatch directly to a process-owned event processor. The UI hydrates from
* the process state store, so relay events remain useful without retaining a cleared ViewModel.
*/
object NostrBackgroundRuntime {
private const val TAG = "NostrBackground"
private const val MAX_PENDING_EVENTS = 256
data class Handlers(
val accountDm: (NostrEvent, NostrIdentity) -> Unit,
val geohashMessage: (NostrEvent, String) -> Unit,
val geohashDm: (NostrEvent, String, NostrIdentity) -> Unit
)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
internal val eventScope: CoroutineScope
get() = scope
private val random = SecureRandom().asKotlinRandom()
private val lock = Any()
private val pendingEvents = ArrayDeque<(Handlers) -> Unit>()
@Volatile private var handlers: Handlers? = null
@Volatile private var initialized = false
@Volatile private var activeGeohash: String? = null
@Volatile private var activeGeohashLiveToken: Long? = null
@ -52,17 +40,20 @@ object NostrBackgroundRuntime {
private lateinit var application: Application
private lateinit var subscriptions: NostrSubscriptionManager
private lateinit var locationChannels: LocationChannelManager
private lateinit var eventProcessor: NostrBackgroundEventProcessor
fun initialize(app: Application) {
synchronized(lock) {
if (initialized) return
initialized = true
application = app
locationChannels = LocationChannelManager.getInstance(app)
eventProcessor = NostrBackgroundEventProcessor(app, scope)
subscriptions = NostrSubscriptionManager(
app,
owner = NostrRelayManager.OWNER_BACKGROUND
)
// Publish readiness only after every process-owned dependency is available.
initialized = true
}
subscriptions.connect()
@ -71,14 +62,6 @@ object NostrBackgroundRuntime {
startPresenceScheduler()
}
fun attachHandlers(newHandlers: Handlers) {
handlers = newHandlers
val pending = synchronized(lock) {
pendingEvents.toList().also { pendingEvents.clear() }
}
pending.forEach { event -> runCatching { event(newHandlers) } }
}
fun resetSubscriptions() {
if (!initialized) return
subscriptions.unsubscribeAllOwned()
@ -116,7 +99,7 @@ object NostrBackgroundRuntime {
sinceMs = System.currentTimeMillis() - 172_800_000L,
id = "chat-messages",
handler = { event ->
dispatch { it.accountDm(event, identity) }
eventProcessor.onAccountDm(event, identity)
}
)
}
@ -164,7 +147,7 @@ object NostrBackgroundRuntime {
sinceMs = System.currentTimeMillis() - 3_600_000L,
limit = 200,
id = "geohash-$geohash",
handler = { event -> dispatch { it.geohashMessage(event, geohash) } },
handler = { event -> eventProcessor.onGeohashMessage(event, geohash) },
liveLocationToken = liveLocationToken
)
subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken)
@ -183,7 +166,7 @@ object NostrBackgroundRuntime {
sinceMs = System.currentTimeMillis() - 172_800_000L,
id = subscriptionId,
handler = { event ->
dispatch { it.geohashDm(event, geohash, identity) }
eventProcessor.onGeohashDm(event, geohash, identity)
},
liveLocationToken = liveLocationToken
)
@ -271,15 +254,19 @@ object NostrBackgroundRuntime {
}
}
private fun dispatch(event: (Handlers) -> Unit) {
val current = handlers
if (current != null) {
event(current)
return
}
synchronized(lock) {
if (pendingEvents.size >= MAX_PENDING_EVENTS) pendingEvents.removeFirst()
pendingEvents.addLast(event)
}
fun conversationGeohash(conversationKey: String): String? =
if (initialized) eventProcessor.conversationGeohash(conversationKey)
else GeohashConversationRegistry.get(conversationKey)
fun displayNameForNostrPubkey(pubkeyHex: String): String? =
if (initialized) eventProcessor.displayNameForNostrPubkey(pubkeyHex) else null
fun displayNameForGeohashConversation(
pubkeyHex: String,
sourceGeohash: String
): String? = if (initialized) {
eventProcessor.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
} else {
null
}
}

View File

@ -15,7 +15,6 @@ import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.MeshDelegateHandler
import com.bitchat.android.ui.PrivateChatManager
import com.bitchat.android.ui.PrivateMessageOrigin
import kotlinx.coroutines.CoroutineScope
@ -28,7 +27,7 @@ class NostrDirectMessageHandler(
private val application: Application,
private val state: ChatState,
private val privateChatManager: PrivateChatManager,
private val meshDelegateHandler: MeshDelegateHandler,
private val updateDeliveryStatus: (String, DeliveryStatus) -> Unit,
private val scope: CoroutineScope,
private val repo: GeohashRepository,
private val dataManager: com.bitchat.android.ui.DataManager
@ -54,7 +53,7 @@ class NostrDirectMessageHandler(
}
fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) {
scope.launch(Dispatchers.Default) {
scope.launch {
try {
if (dedupe(giftWrap.id)) return@launch
@ -178,13 +177,19 @@ class NostrDirectMessageHandler(
NoisePayloadType.DELIVERED -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, conversationID)
updateDeliveryStatus(
messageId,
DeliveryStatus.Delivered(conversationID, Date())
)
}
}
NoisePayloadType.READ_RECEIPT -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveReadReceipt(messageId, conversationID)
updateDeliveryStatus(
messageId,
DeliveryStatus.Read(conversationID, Date())
)
}
}
NoisePayloadType.FILE_TRANSFER -> {

View File

@ -31,6 +31,12 @@ object AppStateStore {
private val _privateMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val privateMessages: StateFlow<Map<String, List<BitchatMessage>>> = _privateMessages.asStateFlow()
private val _nickname = MutableStateFlow("")
val nickname: StateFlow<String> = _nickname.asStateFlow()
private val _selectedPrivateChatPeer = MutableStateFlow<String?>(null)
val selectedPrivateChatPeer: StateFlow<String?> = _selectedPrivateChatPeer.asStateFlow()
// Channel messages by channel name
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
@ -41,6 +47,14 @@ object AppStateStore {
}
}
fun setNickname(nickname: String) {
_nickname.value = nickname
}
fun setSelectedPrivateChatPeer(peerID: String?) {
_selectedPrivateChatPeer.value = peerID
}
fun setTransportPeers(transportId: String, ids: List<String>) {
synchronized(this) {
peerIdsByTransport[transportId] = ids.toSet()
@ -221,6 +235,8 @@ object AppStateStore {
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
_channelMessages.value = emptyMap()
_nickname.value = ""
_selectedPrivateChatPeer.value = null
}
}

View File

@ -211,6 +211,7 @@ class ChatState(
fun setNickname(nickname: String) {
_nickname.value = nickname
com.bitchat.android.services.AppStateStore.setNickname(nickname)
}
fun setIsConnected(connected: Boolean) {
@ -223,6 +224,7 @@ class ChatState(
fun setSelectedPrivateChatPeer(peerID: String?) {
_selectedPrivateChatPeer.value = peerID
com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(peerID)
}
fun setUnreadPrivateMessages(unread: Set<String>) {

View File

@ -154,8 +154,6 @@ class ChatViewModel(
application = application,
state = state,
messageManager = messageManager,
privateChatManager = privateChatManager,
meshDelegateHandler = meshDelegateHandler,
dataManager = dataManager,
notificationManager = notificationManager
)
@ -341,6 +339,7 @@ class ChatViewModel(
override fun onCleared() {
geohashViewModel.shutdownUiSubscriptions()
com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null)
super.onCleared()
// Note: Mesh service lifecycle is now managed by MainActivity
}

View File

@ -198,8 +198,10 @@ class DataManager(private val context: Context) {
// MARK: - Blocked Users Management
@Synchronized
fun loadBlockedUsers() {
val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet()
_blockedUsers.clear()
_blockedUsers.addAll(savedBlockedUsers)
}
@ -217,6 +219,7 @@ class DataManager(private val context: Context) {
saveBlockedUsers()
}
@Synchronized
fun isUserBlocked(fingerprint: String): Boolean {
return _blockedUsers.contains(fingerprint)
}
@ -226,8 +229,10 @@ class DataManager(private val context: Context) {
private val _geohashBlockedUsers = mutableSetOf<String>() // Set of nostr pubkey hex
val geohashBlockedUsers: Set<String> get() = _geohashBlockedUsers.toSet()
@Synchronized
fun loadGeohashBlockedUsers() {
val savedGeohashBlockedUsers = prefs.getStringSet("geohash_blocked_users", emptySet()) ?: emptySet()
_geohashBlockedUsers.clear()
_geohashBlockedUsers.addAll(savedGeohashBlockedUsers)
}
@ -245,6 +250,7 @@ class DataManager(private val context: Context) {
saveGeohashBlockedUsers()
}
@Synchronized
fun isGeohashUserBlocked(pubkeyHex: String): Boolean {
return _geohashBlockedUsers.contains(pubkeyHex)
}

View File

@ -13,7 +13,6 @@ import com.bitchat.android.geohash.LiveLocationPrivacyGate
import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository
import com.bitchat.android.nostr.NostrBackgroundRuntime
import com.bitchat.android.nostr.NostrDirectMessageHandler
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.nostr.NostrProtocol
import com.bitchat.android.nostr.NostrRelayManager
@ -33,8 +32,6 @@ class GeohashViewModel(
application: Application,
private val state: ChatState,
private val messageManager: MessageManager,
private val privateChatManager: PrivateChatManager,
private val meshDelegateHandler: MeshDelegateHandler,
private val dataManager: DataManager,
private val notificationManager: NotificationManager
) : AndroidViewModel(application), DefaultLifecycleObserver {
@ -49,20 +46,10 @@ class GeohashViewModel(
)
private val geohashMessageHandler = GeohashMessageHandler(
application = application,
state = state,
messageManager = messageManager,
repo = repo,
scope = NostrBackgroundRuntime.eventScope,
dataManager = dataManager
)
private val dmHandler = NostrDirectMessageHandler(
application = application,
state = state,
privateChatManager = privateChatManager,
meshDelegateHandler = meshDelegateHandler,
scope = NostrBackgroundRuntime.eventScope,
repo = repo,
dataManager = dataManager
scope = viewModelScope,
dataManager = dataManager,
addChannelMessage = messageManager::addChannelMessage
)
// Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded.
@ -103,19 +90,6 @@ class GeohashViewModel(
}
try {
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
NostrBackgroundRuntime.attachHandlers(
NostrBackgroundRuntime.Handlers(
accountDm = { event, identity ->
dmHandler.onGiftWrap(event, "", identity)
},
geohashMessage = { event, geohash ->
geohashMessageHandler.onEvent(event, geohash)
},
geohashDm = { event, geohash, identity ->
dmHandler.onGiftWrap(event, geohash, identity)
}
)
)
viewModelScope.launch {
locationChannelManager?.selectedChannel?.collect { channel ->
state.setSelectedLocationChannel(channel)
@ -329,14 +303,29 @@ class GeohashViewModel(
}
fun ensureGeohashDMSubscriptionForConversation(conversationKey: String) {
val geohash = repo.getConversationGeohash(conversationKey) ?: return
val geohash = repo.getConversationGeohash(conversationKey)
?: NostrBackgroundRuntime.conversationGeohash(conversationKey)
?: return
NostrBackgroundRuntime.ensureConversationDm(geohash)
}
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String {
val foregroundName = repo.displayNameForNostrPubkeyUI(pubkeyHex)
return foregroundName.takeUnless { it == "anon" }
?: NostrBackgroundRuntime.displayNameForNostrPubkey(pubkeyHex)
?: foregroundName
}
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String {
val foregroundName = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
return foregroundName.takeUnless { it == "anon" }
?: NostrBackgroundRuntime.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
?: foregroundName
}
fun conversationGeohash(conversationKey: String): String? =
repo.getConversationGeohash(conversationKey)
?: NostrBackgroundRuntime.conversationGeohash(conversationKey)
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
PeerIdentity.nostr(pubkeyHex)

View File

@ -33,7 +33,8 @@ class PrivateChatManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val dataManager: DataManager,
private val noiseSessionDelegate: NoiseSessionDelegate
private val noiseSessionDelegate: NoiseSessionDelegate,
private val trackUnreadMessages: Boolean = true
) {
companion object {
@ -337,7 +338,7 @@ class PrivateChatManager(
// Nostr messages originate here and must be added explicitly, even after their
// sender alias has canonicalized to a contact_* conversation ID.
if (origin == PrivateMessageOrigin.NOSTR) {
if (suppressUnread) {
if (suppressUnread || !trackUnreadMessages) {
messageManager.addPrivateMessageNoUnread(conversationID, message)
} else {
messageManager.addPrivateMessage(conversationID, message)
@ -345,7 +346,10 @@ class PrivateChatManager(
}
// Track as unread for read receipt purposes if not focused
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != conversationID) {
if (trackUnreadMessages &&
!suppressUnread &&
state.getSelectedPrivateChatPeerValue() != conversationID
) {
val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() }
unreadList.add(message)
Log.d(TAG, "Queued unread from $conversationID (count=${unreadList.size})")

View File

@ -0,0 +1,65 @@
package com.bitchat.android.nostr
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.services.AppStateStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class NostrBackgroundEventProcessorTest {
private lateinit var scope: CoroutineScope
@Before
fun setUp() {
AppStateStore.clear()
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
}
@After
fun tearDown() {
scope.cancel()
AppStateStore.clear()
}
@Test
fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking {
val application = ApplicationProvider.getApplicationContext<Application>()
val processor = NostrBackgroundEventProcessor(application, scope)
repeat(300) { index ->
processor.onGeohashMessage(
event = NostrEvent(
id = "cold-start-$index",
pubkey = index.toString(16).padStart(64, '0'),
createdAt = 1,
kind = NostrKind.EPHEMERAL_EVENT,
tags = listOf(listOf("g", "u4pruy")),
content = "message-$index"
),
geohash = "u4pruy"
)
}
withTimeout(5_000) {
while (AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().size < 300) {
kotlinx.coroutines.yield()
}
}
assertEquals(
(0 until 300).map { "cold-start-$it" }.toSet(),
AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet()
)
}
}

View File

@ -10,6 +10,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@ -67,6 +68,34 @@ class PrivateChatManagerTest {
assertEquals(listOf(message), state.getPrivateChatsValue()[conversationID])
}
@Test
fun `headless Nostr processing stores messages without retaining UI unread work`() {
val headlessManager = PrivateChatManager(
state = state,
messageManager = MessageManager(state),
dataManager = DataManager(RuntimeEnvironment.getApplication()),
noiseSessionDelegate = mock(),
trackUnreadMessages = false
)
val message = BitchatMessage(
id = "background-nostr-message",
sender = "alice",
content = "background",
timestamp = Date(1),
isPrivate = true,
senderPeerID = "nostr_background"
)
headlessManager.handleIncomingPrivateMessage(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.NOSTR
)
assertEquals(listOf(message), AppStateStore.privateMessages.value["nostr_background"])
assertTrue(state.getUnreadPrivateMessagesValue().isEmpty())
}
@Test
fun `canonical conversation sends read receipt through live mesh peer id`() {
val noiseKey = ByteArray(32) { 9 }