mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 06:25:21 +00:00
Nip17 dms to extend the mesh (#313)
* favorite each other * show favorites as system messsages * wip show glove when offline but mayb edoesnt work * send and receive works * wip kinda works but no * getting there * kinda * show offline peers in peer list * kinda wonky but almost works * fixes * nostr user goes offline works * nostr -> mesh works * handoff works * background message processing * read works * seen message store was missing
This commit is contained in:
@@ -16,6 +16,8 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
@@ -75,7 +77,7 @@ class NostrGeohashService(
|
||||
private val processedNostrEvents = mutableSetOf<String>()
|
||||
private val processedNostrEventOrder = mutableListOf<String>()
|
||||
private val maxProcessedNostrEvents = 2000
|
||||
private val processedNostrAcks = mutableSetOf<String>()
|
||||
// removed unused processedNostrAcks
|
||||
private val nostrKeyMapping = mutableMapOf<String, String>() // senderPeerID -> nostrPubkey
|
||||
|
||||
// MARK: - Geohash Participant Tracking Properties
|
||||
@@ -221,9 +223,11 @@ class NostrGeohashService(
|
||||
* Handle incoming Nostr message (gift wrap)
|
||||
*/
|
||||
private fun handleNostrMessage(giftWrap: NostrEvent) {
|
||||
// Simple deduplication
|
||||
if (processedNostrEvents.contains(giftWrap.id)) return
|
||||
processedNostrEvents.add(giftWrap.id)
|
||||
// Offload processing to avoid blocking UI
|
||||
coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) {
|
||||
// Simple deduplication
|
||||
if (processedNostrEvents.contains(giftWrap.id)) return@launch
|
||||
processedNostrEvents.add(giftWrap.id)
|
||||
|
||||
// Manage deduplication cache size
|
||||
processedNostrEventOrder.add(giftWrap.id)
|
||||
@@ -235,16 +239,18 @@ class NostrGeohashService(
|
||||
// Client-side filtering: ignore messages older than 24 hours + 15 minutes buffer
|
||||
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
||||
if (messageAge > 173700) { // 48 hours + 15 minutes
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "Processing Nostr message: ${giftWrap.id.take(16)}...")
|
||||
Log.d(TAG, "Processing Nostr message: ${giftWrap.id.take(16)}...")
|
||||
|
||||
// Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID
|
||||
|
||||
val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application)
|
||||
if (currentIdentity == null) {
|
||||
Log.w(TAG, "No Nostr identity available for decryption")
|
||||
return
|
||||
}
|
||||
val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application)
|
||||
if (currentIdentity == null) {
|
||||
Log.w(TAG, "No Nostr identity available for decryption")
|
||||
return@launch
|
||||
}
|
||||
|
||||
try {
|
||||
val decryptResult = NostrProtocol.decryptPrivateMessage(
|
||||
@@ -254,7 +260,7 @@ class NostrGeohashService(
|
||||
|
||||
if (decryptResult == null) {
|
||||
Log.w(TAG, "Failed to decrypt Nostr message")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
val (content, senderPubkey, rumorTimestamp) = decryptResult
|
||||
@@ -262,26 +268,26 @@ class NostrGeohashService(
|
||||
// Expect embedded BitChat packet content
|
||||
if (!content.startsWith("bitchat1:")) {
|
||||
Log.d(TAG, "Ignoring non-embedded Nostr DM content")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
val base64Content = content.removePrefix("bitchat1:")
|
||||
val packetData = base64URLDecode(base64Content)
|
||||
if (packetData == null) {
|
||||
Log.e(TAG, "Failed to decode base64url BitChat packet")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData)
|
||||
if (packet == null) {
|
||||
Log.e(TAG, "Failed to parse embedded BitChat packet from Nostr DM")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Only process noiseEncrypted envelope for private messages/receipts
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) {
|
||||
Log.w(TAG, "Unsupported embedded packet type: ${packet.type}")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Validate recipient if present
|
||||
@@ -295,24 +301,50 @@ class NostrGeohashService(
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload)
|
||||
if (noisePayload == null) {
|
||||
Log.e(TAG, "Failed to parse embedded NoisePayload")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Map sender by Nostr pubkey to Noise key when possible
|
||||
val senderNoiseKey = findNoiseKeyForNostrPubkey(senderPubkey)
|
||||
val messageTimestamp = Date(rumorTimestamp * 1000L)
|
||||
val senderNickname = if (senderNoiseKey != null) {
|
||||
// Get nickname from favorites
|
||||
getFavoriteNickname(senderNoiseKey) ?: "Unknown"
|
||||
|
||||
// If we know the Noise key, try to resolve a currently connected mesh peer ID for it
|
||||
val targetPeerID: String = if (senderNoiseKey != null) {
|
||||
val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey)
|
||||
if (meshPeerId != null) {
|
||||
// Also unify existing noise-hex/nostr-temp chats into this mesh peer
|
||||
val noiseHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||
val tempKey = "nostr_${senderPubkey.take(16)}"
|
||||
unifyChatsIntoPeer(meshPeerId, listOf(noiseHex, tempKey))
|
||||
|
||||
// If currently viewing the temporary or noise-hex chat, auto-switch to mesh peer
|
||||
val selected = state.getSelectedPrivateChatPeerValue()
|
||||
if (selected == noiseHex || selected == tempKey) {
|
||||
state.setSelectedPrivateChatPeer(meshPeerId)
|
||||
}
|
||||
meshPeerId
|
||||
} else {
|
||||
senderNoiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||
}
|
||||
} else {
|
||||
"nostr_${senderPubkey.take(16)}"
|
||||
}
|
||||
|
||||
// Prefer nickname from mesh when connected; else fallback to favorites
|
||||
val senderNickname: String = if (senderNoiseKey != null) {
|
||||
val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey)
|
||||
if (meshPeerId != null) {
|
||||
// Use live mesh nickname if available
|
||||
meshDelegateHandler.getPeerInfo(meshPeerId)?.nickname
|
||||
?: getFavoriteNickname(senderNoiseKey)
|
||||
?: "Unknown"
|
||||
} else {
|
||||
getFavoriteNickname(senderNoiseKey) ?: "Unknown"
|
||||
}
|
||||
} else {
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
||||
val targetPeerID = senderNoiseKey?.let {
|
||||
it.joinToString("") { byte -> "%02x".format(byte) }
|
||||
} ?: "nostr_${senderPubkey.take(16)}"
|
||||
|
||||
// Store Nostr key mapping
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
@@ -323,17 +355,19 @@ class NostrGeohashService(
|
||||
if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) {
|
||||
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)
|
||||
pm?.let { pmsg ->
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
// Prefer mapped peer route; fallback to direct Nostr using sender pubkey
|
||||
if (senderNoiseKey != null) {
|
||||
val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||
nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex)
|
||||
} else {
|
||||
// Fallback: direct to sender’s Nostr pubkey (geohash-style)
|
||||
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application)
|
||||
if (identity != null) {
|
||||
nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity)
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application)
|
||||
if (!seen.hasDelivered(pmsg.messageID)) {
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
if (senderNoiseKey != null) {
|
||||
val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||
nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex)
|
||||
} else {
|
||||
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application)
|
||||
if (identity != null) {
|
||||
nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity)
|
||||
}
|
||||
}
|
||||
seen.markDelivered(pmsg.messageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,12 +375,13 @@ class NostrGeohashService(
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing Nostr message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process NoisePayload from Nostr message
|
||||
*/
|
||||
private fun processNoisePayload(
|
||||
private suspend fun processNoisePayload(
|
||||
noisePayload: com.bitchat.android.model.NoisePayload,
|
||||
targetPeerID: String,
|
||||
senderNickname: String,
|
||||
@@ -362,6 +397,8 @@ class NostrGeohashService(
|
||||
|
||||
val messageId = pm.messageID
|
||||
val messageContent = pm.content
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application)
|
||||
val suppressUnread = seen.hasRead(messageId)
|
||||
|
||||
// Handle favorite/unfavorite notifications
|
||||
if (messageContent.startsWith("[FAVORITED]") || messageContent.startsWith("[UNFAVORITED]")) {
|
||||
@@ -399,13 +436,18 @@ class NostrGeohashService(
|
||||
)
|
||||
)
|
||||
|
||||
// Add to private chats
|
||||
privateChatManager.handleIncomingPrivateMessage(message)
|
||||
|
||||
// Send read receipt if viewing
|
||||
if (isViewingThisChat) {
|
||||
// Note: meshService needs to be passed as parameter
|
||||
// privateChatManager.sendReadReceiptsForPeer(targetPeerID, meshService)
|
||||
// Add to private chats on Main
|
||||
withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread)
|
||||
}
|
||||
|
||||
// Send read receipt if viewing (only once across restarts)
|
||||
if (isViewingThisChat && !seen.hasRead(messageId)) {
|
||||
try {
|
||||
val rr = com.bitchat.android.model.ReadReceipt(originalMessageID = messageId)
|
||||
NostrTransport.getInstance(application).sendReadReceipt(rr, targetPeerID)
|
||||
seen.markRead(messageId)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
Log.i(TAG, "📥 Processed Nostr private message from $senderNickname")
|
||||
@@ -413,15 +455,19 @@ class NostrGeohashService(
|
||||
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
|
||||
val messageId = String(noisePayload.data, Charsets.UTF_8)
|
||||
// Use the existing delegate to handle delivery acknowledgment
|
||||
meshDelegateHandler.didReceiveDeliveryAck(messageId, targetPeerID)
|
||||
// Use the existing delegate to handle delivery acknowledgment on Main
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveDeliveryAck(messageId, targetPeerID)
|
||||
}
|
||||
Log.d(TAG, "📥 Processed Nostr delivery ACK for message $messageId")
|
||||
}
|
||||
|
||||
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
|
||||
val messageId = String(noisePayload.data, Charsets.UTF_8)
|
||||
// Use the existing delegate to handle read receipt
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageId, targetPeerID)
|
||||
// Use the existing delegate to handle read receipt on Main
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageId, targetPeerID)
|
||||
}
|
||||
Log.d(TAG, "📥 Processed Nostr read receipt for message $messageId")
|
||||
}
|
||||
}
|
||||
@@ -440,6 +486,28 @@ class NostrGeohashService(
|
||||
private fun getFavoriteNickname(noiseKey: ByteArray): String? {
|
||||
return com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)?.peerNickname
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a currently-connected mesh peer ID for a given Noise public key
|
||||
* by matching against the mesh service's peer info noisePublicKey values.
|
||||
*/
|
||||
private fun resolveMeshPeerIdForNoiseKey(noiseKey: ByteArray): String? {
|
||||
return try {
|
||||
val peers: List<String> = state.getConnectedPeersValue()
|
||||
peers.firstOrNull { peerId: String ->
|
||||
val info = meshDelegateHandler.getPeerInfo(peerId)
|
||||
info?.noisePublicKey?.contentEquals(noiseKey) == true
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge any chats stored under the given keys into the target peer's chat entry
|
||||
* so messages received while offline appear in the same chat when the peer connects.
|
||||
*/
|
||||
private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle favorite/unfavorite notification
|
||||
@@ -447,16 +515,72 @@ class NostrGeohashService(
|
||||
private fun handleFavoriteNotification(content: String, fromPeerID: String, senderNickname: String) {
|
||||
val isFavorite = content.startsWith("[FAVORITED]")
|
||||
val action = if (isFavorite) "favorited" else "unfavorited"
|
||||
|
||||
|
||||
// Try to extract npub after colon, if present
|
||||
val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") }
|
||||
|
||||
// Resolve noise key if possible and persist relationship + npub mapping
|
||||
try {
|
||||
var noiseKey: ByteArray? = null
|
||||
// If fromPeerID looks like hex (noise key), decode
|
||||
val hexRegex = Regex("^[0-9a-fA-F]+$")
|
||||
if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) {
|
||||
// Expect 64 hex chars for full Curve25519 key; accept others best-effort
|
||||
val bytes = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
// Use only if length is plausible
|
||||
if (bytes.isNotEmpty()) noiseKey = bytes
|
||||
} else {
|
||||
// fromPeerID likely a temporary key like "nostr_..."; map to Nostr pubkey
|
||||
val senderPubkey = nostrKeyMapping[fromPeerID]
|
||||
if (senderPubkey != null) {
|
||||
noiseKey = findNoiseKeyForNostrPubkey(senderPubkey)
|
||||
}
|
||||
}
|
||||
|
||||
if (noiseKey != null) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
|
||||
if (npub != null) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Best-effort
|
||||
}
|
||||
|
||||
// Determine guidance text based on mutual status (iOS-style)
|
||||
val guidance = try {
|
||||
val rel = run {
|
||||
// Try to resolve via noise key directly or mapping
|
||||
var key: ByteArray? = null
|
||||
val hexRegex = Regex("^[0-9a-fA-F]+$")
|
||||
if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) {
|
||||
key = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
} else {
|
||||
val mappedNostr = nostrKeyMapping[fromPeerID]
|
||||
key = mappedNostr?.let { findNoiseKeyForNostrPubkey(it) }
|
||||
}
|
||||
key?.let { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it) }
|
||||
}
|
||||
if (isFavorite) {
|
||||
if (rel?.isFavorite == true) {
|
||||
" — mutual! You can continue DMs via Nostr when out of mesh."
|
||||
} else {
|
||||
" — favorite back to continue DMs later."
|
||||
}
|
||||
} else {
|
||||
". DMs over Nostr will pause unless you both favorite again."
|
||||
}
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
// Show system message
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = "$senderNickname $action you",
|
||||
content = "$senderNickname $action you$guidance",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addMessage(systemMessage)
|
||||
|
||||
|
||||
Log.i(TAG, "📥 Processed favorite notification: $senderNickname $action you")
|
||||
}
|
||||
|
||||
@@ -953,25 +1077,26 @@ class NostrGeohashService(
|
||||
* Handles participant tracking, nickname caching, message display, and teleport state
|
||||
*/
|
||||
private fun handleUnifiedGeohashEvent(event: NostrEvent, geohash: String) {
|
||||
coroutineScope.launch(Dispatchers.Default) {
|
||||
try {
|
||||
Log.v(TAG, "🔍 handleUnifiedGeohashEvent called - eventGeohash: $geohash, currentGeohash: $currentGeohash, eventKind: ${event.kind}, eventId: ${event.id.take(8)}...")
|
||||
|
||||
// Only handle ephemeral kind 20000 events
|
||||
if (event.kind != 20000) {
|
||||
Log.v(TAG, "❌ Skipping non-ephemeral event (kind ${event.kind})")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check if this user is blocked in geohash channels BEFORE any processing
|
||||
if (isGeohashUserBlocked(event.pubkey)) {
|
||||
Log.v(TAG, "🚫 Skipping event from blocked geohash user: ${event.pubkey.take(8)}...")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Deduplicate events
|
||||
if (processedNostrEvents.contains(event.id)) {
|
||||
Log.v(TAG, "❌ Skipping duplicate event ${event.id.take(8)}...")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
processedNostrEvents.add(event.id)
|
||||
|
||||
@@ -984,7 +1109,7 @@ class NostrGeohashService(
|
||||
|
||||
// STEP 1: Always update participant activity for all geohashes (for location channel list)
|
||||
val timestamp = Date(event.createdAt * 1000L)
|
||||
updateGeohashParticipant(geohash, event.pubkey, timestamp)
|
||||
withContext(Dispatchers.Main) { updateGeohashParticipant(geohash, event.pubkey, timestamp) }
|
||||
|
||||
// STEP 2: Always cache nickname from tag if present (for all geohashes)
|
||||
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { nickTag ->
|
||||
@@ -996,7 +1121,7 @@ class NostrGeohashService(
|
||||
|
||||
// If this is a new nickname or nickname change for current geohash, refresh people list
|
||||
if (previousNick != nick && currentGeohash == geohash) {
|
||||
refreshGeohashPeople()
|
||||
withContext(Dispatchers.Main) { refreshGeohashPeople() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,7 +1131,7 @@ class NostrGeohashService(
|
||||
val currentTeleported = state.getTeleportedGeoValue().toMutableSet()
|
||||
if (!currentTeleported.contains(key)) {
|
||||
currentTeleported.add(key)
|
||||
state.setTeleportedGeo(currentTeleported)
|
||||
withContext(Dispatchers.Main) { state.setTeleportedGeo(currentTeleported) }
|
||||
Log.d(TAG, "📍 Marked geohash participant as teleported: ${event.pubkey.take(8)}...")
|
||||
}
|
||||
}
|
||||
@@ -1017,7 +1142,7 @@ class NostrGeohashService(
|
||||
context = application
|
||||
)
|
||||
if (myGeoIdentity.publicKeyHex.lowercase() == event.pubkey.lowercase()) {
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
// STEP 5: Store mapping for potential geohash DM initiation
|
||||
@@ -1032,7 +1157,7 @@ class NostrGeohashService(
|
||||
event.content.trim().isEmpty()
|
||||
if (isTeleportPresence) {
|
||||
Log.v(TAG, "Skipping empty teleport presence event")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
val senderName = displayNameForNostrPubkey(event.pubkey)
|
||||
@@ -1065,7 +1190,7 @@ class NostrGeohashService(
|
||||
selectedLocationChannel.channel.geohash == geohash)
|
||||
|
||||
if (shouldShowMessage) {
|
||||
messageManager.addMessage(message)
|
||||
withContext(Dispatchers.Main) { messageManager.addMessage(message) }
|
||||
}
|
||||
|
||||
// NOTIFICATION LOGIC: Check for mentions and first messages
|
||||
@@ -1076,12 +1201,13 @@ class NostrGeohashService(
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling unified geohash event: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and trigger geohash notifications for mentions and first messages
|
||||
*/
|
||||
private fun checkAndTriggerGeohashNotifications(
|
||||
private suspend fun checkAndTriggerGeohashNotifications(
|
||||
geohash: String,
|
||||
senderName: String,
|
||||
content: String,
|
||||
@@ -1104,13 +1230,15 @@ class NostrGeohashService(
|
||||
if (isMention || isFirstMessage) {
|
||||
Log.d(TAG, "🔔 Triggering geohash notification - geohash: $geohash, mention: $isMention, first: $isFirstMessage")
|
||||
|
||||
notificationManager.showGeohashNotification(
|
||||
geohash = geohash,
|
||||
senderNickname = senderName,
|
||||
messageContent = content,
|
||||
isMention = isMention,
|
||||
isFirstMessage = isFirstMessage
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
notificationManager.showGeohashNotification(
|
||||
geohash = geohash,
|
||||
senderNickname = senderName,
|
||||
messageContent = content,
|
||||
isMention = isMention,
|
||||
isFirstMessage = isFirstMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking geohash notifications: ${e.message}")
|
||||
@@ -1184,10 +1312,13 @@ class NostrGeohashService(
|
||||
geohash: String,
|
||||
identity: NostrIdentity
|
||||
) {
|
||||
coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) {
|
||||
try {
|
||||
// Deduplicate
|
||||
if (processedNostrEvents.contains(giftWrap.id)) return
|
||||
if (processedNostrEvents.contains(giftWrap.id)) return@launch
|
||||
processedNostrEvents.add(giftWrap.id)
|
||||
|
||||
// Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID
|
||||
|
||||
// Decrypt with per-geohash identity
|
||||
val decryptResult = NostrProtocol.decryptPrivateMessage(
|
||||
@@ -1197,28 +1328,28 @@ class NostrGeohashService(
|
||||
|
||||
if (decryptResult == null) {
|
||||
Log.d(TAG, "Skipping geohash DM: unwrap/open failed (non-fatal)")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
val (content, senderPubkey, rumorTimestamp) = decryptResult
|
||||
|
||||
// Only process BitChat embedded messages
|
||||
if (!content.startsWith("bitchat1:")) return
|
||||
if (!content.startsWith("bitchat1:")) return@launch
|
||||
|
||||
val base64Content = content.removePrefix("bitchat1:")
|
||||
val packetData = base64URLDecode(base64Content) ?: return
|
||||
val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) ?: return
|
||||
val packetData = base64URLDecode(base64Content) ?: return@launch
|
||||
val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) ?: return@launch
|
||||
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
|
||||
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
|
||||
val messageTimestamp = Date(rumorTimestamp * 1000L)
|
||||
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||
nostrKeyMapping[convKey] = senderPubkey
|
||||
|
||||
when (noisePayload.type) {
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return
|
||||
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return@launch
|
||||
val messageId = pm.messageID
|
||||
|
||||
Log.d(TAG, "📥 Received geohash DM from ${senderPubkey.take(8)}...")
|
||||
@@ -1232,7 +1363,7 @@ class NostrGeohashService(
|
||||
break
|
||||
}
|
||||
}
|
||||
if (messageExists) return
|
||||
if (messageExists) return@launch
|
||||
|
||||
val senderName = displayNameForNostrPubkey(senderPubkey)
|
||||
val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == convKey
|
||||
@@ -1252,17 +1383,23 @@ class NostrGeohashService(
|
||||
)
|
||||
)
|
||||
|
||||
// Add to private chats
|
||||
privateChatManager.handleIncomingPrivateMessage(message)
|
||||
// Add to private chats (suppress unread if already read)
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application)
|
||||
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = seen.hasRead(messageId))
|
||||
|
||||
// Always send delivery ACK for geohash DMs
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity)
|
||||
// Send delivery ACK for geohash DMs only once
|
||||
if (!seen.hasDelivered(messageId)) {
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity)
|
||||
seen.markDelivered(messageId)
|
||||
}
|
||||
|
||||
// Send read receipt if viewing this chat
|
||||
if (isViewingThisChat) {
|
||||
if (isViewingThisChat && !seen.hasRead(messageId)) {
|
||||
// Send read receipt via Nostr for geohash DM
|
||||
sendGeohashReadReceipt(messageId, senderPubkey, geohash)
|
||||
// Mark message as read persistently
|
||||
try { seen.markRead(messageId) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1280,6 +1417,7 @@ class NostrGeohashService(
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling geohash DM event: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,7 +70,7 @@ object NostrProtocol {
|
||||
return null
|
||||
}
|
||||
|
||||
Log.v(TAG, "Successfully unwrapped gift wrap")
|
||||
Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...")
|
||||
|
||||
// 2. Open the seal
|
||||
val rumor = openSeal(seal, recipientIdentity.privateKeyHex)
|
||||
|
||||
@@ -479,28 +479,11 @@ class NostrTransport(
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to byte array (8 bytes)
|
||||
* Convert full hex string to byte array
|
||||
*/
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
if (hexString.length % 2 != 0) {
|
||||
return ByteArray(8) // Return 8-byte array filled with zeros
|
||||
}
|
||||
|
||||
val result = ByteArray(8) { 0 }
|
||||
var tempID = hexString
|
||||
var index = 0
|
||||
|
||||
while (tempID.length >= 2 && index < 8) {
|
||||
val hexByte = tempID.substring(0, 2)
|
||||
val byte = hexByte.toIntOrNull(16)?.toByte()
|
||||
if (byte != null) {
|
||||
result[index] = byte
|
||||
}
|
||||
tempID = tempID.substring(2)
|
||||
index++
|
||||
}
|
||||
|
||||
return result
|
||||
val clean = if (hexString.length % 2 == 0) hexString else "0$hexString"
|
||||
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
|
||||
Reference in New Issue
Block a user