package com.bitchat.android.nostr import android.app.Application import android.util.Log import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.protocol.BitchatPacket 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Date class NostrDirectMessageHandler( private val application: Application, private val state: ChatState, private val privateChatManager: PrivateChatManager, private val meshDelegateHandler: MeshDelegateHandler, private val scope: CoroutineScope, private val repo: GeohashRepository ) { companion object { private const val TAG = "NostrDirectMessageHandler" } private val seenStore by lazy { SeenMessageStore.getInstance(application) } // Simple event deduplication private val processedIds = ArrayDeque() private val seen = HashSet() private val max = 2000 private fun dedupe(id: String): Boolean { if (seen.contains(id)) return true seen.add(id) processedIds.addLast(id) if (processedIds.size > max) { val old = processedIds.removeFirst() seen.remove(old) } return false } fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) { scope.launch(Dispatchers.Default) { try { if (dedupe(giftWrap.id)) return@launch val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt if (messageAge > 173700) return@launch // 48 hours + 15 mins val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) if (decryptResult == null) { Log.w(TAG, "Failed to decrypt Nostr message") return@launch } val (content, senderPubkey, rumorTimestamp) = decryptResult if (!content.startsWith("bitchat1:")) return@launch val base64Content = content.removePrefix("bitchat1:") val packetData = base64URLDecode(base64Content) ?: return@launch val packet = BitchatPacket.fromBinaryData(packetData) ?: return@launch if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch val messageTimestamp = Date(giftWrap.createdAt * 1000L) val convKey = "nostr_${senderPubkey.take(16)}" repo.putNostrKeyMapping(convKey, senderPubkey) com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey) if (geohash.isNotEmpty()) { // Remember which geohash this conversation belongs to so we can subscribe on-demand repo.setConversationGeohash(convKey, geohash) GeohashConversationRegistry.set(convKey, geohash) } // Ensure sender appears in geohash people list even if they haven't posted publicly yet if (geohash.isNotEmpty()) { // Cache a best-effort nickname and mark as participant val cached = repo.getCachedNickname(senderPubkey) if (cached == null) { val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#") repo.cacheNickname(senderPubkey, base) } repo.updateParticipant(geohash, senderPubkey, messageTimestamp) } val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey) processNoisePayload(noisePayload, convKey, senderNickname, messageTimestamp, senderPubkey, identity) } catch (e: Exception) { Log.e(TAG, "onGiftWrap error: ${e.message}") } } } private suspend fun processNoisePayload( payload: com.bitchat.android.model.NoisePayload, convKey: String, senderNickname: String, timestamp: Date, senderPubkey: String, recipientIdentity: NostrIdentity ) { when (payload.type) { com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList() if (existingMessages.any { it.id == pm.messageID }) return val message = BitchatMessage( id = pm.messageID, sender = senderNickname, content = pm.content, timestamp = timestamp, isRelay = false, isPrivate = true, recipientNickname = state.getNicknameValue(), senderPeerID = convKey, deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date()) ) val isViewing = state.getSelectedPrivateChatPeerValue() == convKey val suppressUnread = seenStore.hasRead(pm.messageID) withContext(Dispatchers.Main) { privateChatManager.handleIncomingPrivateMessage(message, suppressUnread) } if (!seenStore.hasDelivered(pm.messageID)) { val nostrTransport = NostrTransport.getInstance(application) nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity) seenStore.markDelivered(pm.messageID) } if (isViewing && !suppressUnread) { val nostrTransport = NostrTransport.getInstance(application) nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity) seenStore.markRead(pm.messageID) } } com.bitchat.android.model.NoisePayloadType.DELIVERED -> { val messageId = String(payload.data, Charsets.UTF_8) withContext(Dispatchers.Main) { meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey) } } com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { val messageId = String(payload.data, Charsets.UTF_8) withContext(Dispatchers.Main) { meshDelegateHandler.didReceiveReadReceipt(messageId, convKey) } } } } private fun base64URLDecode(input: String): ByteArray? { return try { val padded = input.replace("-", "+") .replace("_", "/") .let { str -> val padding = (4 - str.length % 4) % 4 str + "=".repeat(padding) } android.util.Base64.decode(padded, android.util.Base64.DEFAULT) } catch (e: Exception) { Log.e(TAG, "Failed to decode base64url: ${e.message}") null } } }