mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 09:25:19 +00:00
Coalesces commits: - Nostr DMs: use relay createdAt for display; keep arrival order for sorting - remove comments as requested by calle
176 lines
7.4 KiB
Kotlin
176 lines
7.4 KiB
Kotlin
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<String>()
|
|
private val seen = HashSet<String>()
|
|
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
|
|
}
|
|
}
|
|
}
|