diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 1fc802d9..88d41466 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -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}") } diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt index 3a7ced32..50c72cd4 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -28,14 +28,17 @@ class GeohashRepository( // conversation key (e.g., "nostr_") -> source geohash it belongs to private val conversationGeohash: MutableMap = 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() @@ -180,12 +193,15 @@ class GeohashRepository( state.setGeohashParticipantCounts(counts) } + @Synchronized fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) { nostrKeyMapping[tempKeyOrPeer] = pubkeyHex } + @Synchronized fun getNostrKeyMapping(): Map = 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) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt new file mode 100644 index 00000000..442840eb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt @@ -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() + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt index 3931ea72..a895f612 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -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 } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 7a49057d..ac830096 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -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 -> { diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c88e1bc5..2007abf6 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -31,6 +31,12 @@ object AppStateStore { private val _privateMessages = MutableStateFlow>>(emptyMap()) val privateMessages: StateFlow>> = _privateMessages.asStateFlow() + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() + + private val _selectedPrivateChatPeer = MutableStateFlow(null) + val selectedPrivateChatPeer: StateFlow = _selectedPrivateChatPeer.asStateFlow() + // Channel messages by channel name private val _channelMessages = MutableStateFlow>>(emptyMap()) val channelMessages: StateFlow>> = _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) { 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 } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 53a298ef..40593970 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 72507d8b..efdab660 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index 4f37d47a..8517ec15 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -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() // Set of nostr pubkey hex val geohashBlockedUsers: Set 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) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 46504649..d2b706ec 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 1af48a04..61102664 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -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})") diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt new file mode 100644 index 00000000..d7fffc2c --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt @@ -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() + 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() + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt index 852411e8..e8d1f19e 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -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 }