mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Fix background Nostr event processing
This commit is contained in:
parent
b22184940b
commit
d8e380efae
@ -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}")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 -> {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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})")
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user