package com.bitchat.android.mesh import android.util.Log import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.sync.PacketIdUtil import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import java.util.* import kotlin.random.Random sealed class AnnounceHandlingResult { data class Accepted(val isFirst: Boolean) : AnnounceHandlingResult() object Rejected : AnnounceHandlingResult() } /** * Handles processing of different message types * Extracted from BluetoothMeshService for better separation of concerns */ class MessageHandler(private val myPeerID: String, private val appContext: android.content.Context) { companion object { private const val TAG = "MessageHandler" private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L } // Delegate for callbacks var delegate: MessageHandlerDelegate? = null // Reference to PacketProcessor for recursive packet handling var packetProcessor: PacketProcessor? = null // Coroutines private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) /** * Handle Noise encrypted transport message - SIMPLIFIED iOS-compatible version * Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService */ suspend fun handleNoiseEncrypted(routed: RoutedPacket) { val packet = routed.packet val peerID = routed.peerID ?: "unknown" Log.d(TAG, "Processing Noise encrypted message from $peerID (${packet.payload.size} bytes)") // Skip our own messages if (peerID == myPeerID) return // Check if this message is for us val recipientID = packet.recipientID?.toHexString() if (recipientID != myPeerID) { Log.d(TAG, "🔐 Encrypted message not for me (for $recipientID, I am $myPeerID)") return } try { // Decrypt the message using the Noise service val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID) if (decryptedData == null) { Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake") return } if (decryptedData.isEmpty()) { Log.w(TAG, "Decrypted data is empty from $peerID") return } // NEW: Use NoisePayload system exactly like iOS val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData) if (noisePayload == null) { Log.w(TAG, "Failed to parse NoisePayload from $peerID") return } Log.d(TAG, "🔓 Decrypted NoisePayload type ${noisePayload.type} from $peerID") when (noisePayload.type) { com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { // Decode TLV private message exactly like iOS val privateMessage = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) if (privateMessage != null) { Log.d(TAG, "🔓 Decrypted TLV PM from $peerID: ${privateMessage.content.take(30)}...") // Handle favorite/unfavorite notifications embedded as PMs val pmContent = privateMessage.content if (pmContent.startsWith("[FAVORITED]") || pmContent.startsWith("[UNFAVORITED]")) { handleFavoriteNotificationFromMesh(pmContent, peerID) // Acknowledge delivery for UX parity sendDeliveryAck(privateMessage.messageID, peerID) return } // Create BitchatMessage - preserve source packet timestamp val message = BitchatMessage( id = privateMessage.messageID, sender = delegate?.getPeerNickname(peerID) ?: "Unknown", content = privateMessage.content, timestamp = java.util.Date(packet.timestamp.toLong()), isRelay = false, originalSender = null, isPrivate = true, recipientNickname = delegate?.getMyNickname(), senderPeerID = peerID, mentions = null // TODO: Parse mentions if needed ) // Notify delegate delegate?.onMessageReceived(message) // Send delivery ACK exactly like iOS sendDeliveryAck(privateMessage.messageID, peerID) } } com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> { // Handle encrypted file transfer; generate unique message ID val file = com.bitchat.android.model.BitchatFilePacket.decode(noisePayload.data) if (file != null) { Log.d(TAG, "🔓 Decrypted encrypted file from $peerID: name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}'") val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase() val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file) val message = BitchatMessage( id = uniqueMsgId, sender = delegate?.getPeerNickname(peerID) ?: "Unknown", content = savedPath, type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), timestamp = java.util.Date(packet.timestamp.toLong()), isRelay = false, isPrivate = true, recipientNickname = delegate?.getMyNickname(), senderPeerID = peerID ) Log.d(TAG, "📄 Saved encrypted incoming file to $savedPath (msgId=$uniqueMsgId)") delegate?.onMessageReceived(message) // Send delivery ACK with generated message ID sendDeliveryAck(uniqueMsgId, peerID) } else { Log.w(TAG, "⚠️ Failed to decode encrypted file transfer from $peerID") } } com.bitchat.android.model.NoisePayloadType.DELIVERED -> { // Handle delivery ACK exactly like iOS val messageID = String(noisePayload.data, Charsets.UTF_8) Log.d(TAG, "📬 Delivery ACK received from $peerID for message $messageID") // Simplified: Call delegate with messageID and peerID directly delegate?.onDeliveryAckReceived(messageID, peerID) } com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { // Handle read receipt exactly like iOS val messageID = String(noisePayload.data, Charsets.UTF_8) Log.d(TAG, "👁️ Read receipt received from $peerID for message $messageID") // Simplified: Call delegate with messageID and peerID directly delegate?.onReadReceiptReceived(messageID, peerID) } com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> { Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)") delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong()) } com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> { Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)") delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong()) } } } catch (e: Exception) { Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}") } } /** * Send delivery ACK for a received private message - exactly like iOS */ private suspend fun sendDeliveryAck(messageID: String, senderPeerID: String) { try { // Create ACK payload: [type byte] + [message ID] - exactly like iOS val ackPayload = com.bitchat.android.model.NoisePayload( type = com.bitchat.android.model.NoisePayloadType.DELIVERED, data = messageID.toByteArray(Charsets.UTF_8) ) // Encrypt the payload val encryptedPayload = delegate?.encryptForPeer(ackPayload.encode(), senderPeerID) if (encryptedPayload == null) { Log.w(TAG, "Failed to encrypt delivery ACK for $senderPeerID") return } // Create NOISE_ENCRYPTED packet exactly like iOS val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, senderID = hexStringToByteArray(myPeerID), recipientID = hexStringToByteArray(senderPeerID), timestamp = System.currentTimeMillis().toULong(), payload = encryptedPayload, signature = null, ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS // Same TTL as iOS messageTTL ) delegate?.sendPacket(packet) Log.d(TAG, "📤 Sent delivery ACK to $senderPeerID for message $messageID") } catch (e: Exception) { Log.e(TAG, "Failed to send delivery ACK to $senderPeerID: ${e.message}") } } /** * Handle announce message with TLV decoding and signature verification - exactly like iOS */ suspend fun handleAnnounce(routed: RoutedPacket): Boolean { return (handleAnnounceWithResult(routed) as? AnnounceHandlingResult.Accepted)?.isFirst ?: false } suspend fun handleAnnounceWithResult(routed: RoutedPacket): AnnounceHandlingResult { val packet = routed.packet val peerID = routed.peerID ?: "unknown" if (peerID == myPeerID) return AnnounceHandlingResult.Rejected // Peers use wall-clock packet timestamps; tolerate moderate device clock skew // during identity learning, or later signed messages cannot be verified. val now = System.currentTimeMillis() val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong()) if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) { Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)") return AnnounceHandlingResult.Rejected } else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) { Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)") } val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key -> delegate?.verifyEd25519Signature(signature, data, key) ?: false } if (announcement == null) { Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from ${peerID.take(8)}") return AnnounceHandlingResult.Rejected } var verified = true // Check for existing peer with different noise public key // If existing peer has a different noise public key, do not consider this verified val existingPeer = delegate?.getPeerInfo(peerID) if (existingPeer != null && existingPeer.noisePublicKey != null && !existingPeer.noisePublicKey!!.contentEquals(announcement.noisePublicKey)) { Log.w(TAG, "⚠️ Announce key mismatch for ${peerID.take(8)}... — keeping unverified") verified = false } if ( existingPeer?.signingPublicKey != null && !existingPeer.signingPublicKey!!.contentEquals(announcement.signingPublicKey) ) { Log.w( TAG, "Rejecting signing-key replacement for ${peerID.take(8)} without authenticated peer-state proof" ) verified = false } // Require verified announce; ignore otherwise (no backward compatibility) if (!verified) { Log.w(TAG, "❌ Ignoring unverified announce from ${peerID.take(8)}...") return AnnounceHandlingResult.Rejected } // Successfully decoded TLV format exactly like iOS Log.d(TAG, "✅ Verified announce from $peerID: nickname=${announcement.nickname}, " + "noisePublicKey=${announcement.noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., " + "signingPublicKey=${announcement.signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}...") // Extract nickname and public keys from TLV data val nickname = announcement.nickname val noisePublicKey = announcement.noisePublicKey val signingPublicKey = announcement.signingPublicKey // Update peer info with verification status through new method val isFirstAnnounce = delegate?.updatePeerInfoFromVerifiedAnnouncement( peerID = peerID, nickname = nickname, noisePublicKey = noisePublicKey, signingPublicKey = signingPublicKey, isVerified = true, capabilities = announcement.capabilities ) ?: false // Update mesh graph from gossip neighbors (only if TLV present) try { val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload) com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() .updateFromAnnouncement(peerID, nickname, neighborsOrNull, packet.timestamp) } catch (_: Exception) { } Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID") return AnnounceHandlingResult.Accepted(isFirstAnnounce) } /** * Handle Noise handshake - SIMPLIFIED iOS-compatible version * Single handshake type (0x10) with response determined by payload analysis */ suspend fun handleNoiseHandshake(routed: RoutedPacket) { val packet = routed.packet val peerID = routed.peerID ?: "unknown" Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)") // Skip our own handshake messages if (peerID == myPeerID) return // Check if handshake is addressed to us val recipientID = packet.recipientID?.toHexString() if (recipientID != myPeerID) { Log.d(TAG, "Handshake not for me (for $recipientID, I am $myPeerID)") return } try { // Process handshake message through delegate (simplified approach) val response = delegate?.processNoiseHandshakeMessage(packet.payload, peerID) if (response != null) { Log.d(TAG, "Generated handshake response for $peerID (${response.size} bytes)") // Send response using same packet type (simplified iOS approach) val responsePacket = BitchatPacket( version = 1u, type = MessageType.NOISE_HANDSHAKE.value, senderID = hexStringToByteArray(myPeerID), recipientID = hexStringToByteArray(peerID), timestamp = System.currentTimeMillis().toULong(), payload = response, signature = null, ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS // Same TTL as iOS ) delegate?.sendPacket(responsePacket) Log.d(TAG, "📤 Sent handshake response to $peerID") } // Check if session is now established val hasSession = delegate?.hasNoiseSession(peerID) ?: false if (hasSession) { Log.d(TAG, "✅ Noise session established with $peerID") } } catch (e: Exception) { Log.e(TAG, "Failed to process Noise handshake from $peerID: ${e.message}") } } /** * Handle broadcast or private message */ suspend fun handleMessage(routed: RoutedPacket) { val packet = routed.packet val peerID = routed.peerID ?: "unknown" if (peerID == myPeerID) return val senderNickname = delegate?.getPeerNickname(peerID) if (senderNickname != null) { Log.d(TAG, "Received message from $senderNickname") delegate?.updatePeerNickname(peerID, senderNickname) } val recipientID = packet.recipientID?.takeIf { !it.contentEquals(delegate?.getBroadcastRecipient()) } if (recipientID == null) { // BROADCAST MESSAGE handleBroadcastMessage(routed) } else if (recipientID.toHexString() == myPeerID) { // PRIVATE MESSAGE FOR US handlePrivateMessage(packet, peerID) } // Message relay is now handled by centralized PacketRelayManager } /** * Handle broadcast message with verification enforcement */ private suspend fun handleBroadcastMessage(routed: RoutedPacket) { val packet = routed.packet val peerID = routed.peerID ?: "unknown" // Enforce: only accept public messages from verified peers we know val peerInfo = delegate?.getPeerInfo(peerID) if (peerInfo == null || !peerInfo.isVerifiedNickname) { Log.w(TAG, "🚫 Dropping public message from unverified or unknown peer ${peerID.take(8)}...") return } try { // Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload) if (file != null) { if (isFileTransfer) { Log.d(TAG, "📥 FILE_TRANSFER decode success (broadcast): name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}', from=${peerID.take(8)}") } val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file) val message = BitchatMessage( id = PacketIdUtil.computeIdHex(packet).uppercase(), sender = delegate?.getPeerNickname(peerID) ?: "unknown", content = savedPath, type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), senderPeerID = peerID, timestamp = Date(packet.timestamp.toLong()) ) Log.d(TAG, "📄 Saved incoming file to $savedPath") delegate?.onMessageReceived(message) return } else if (isFileTransfer) { Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)} payloadSize=${packet.payload.size}") } // Fallback: plain text val message = BitchatMessage( id = PacketIdUtil.computeIdHex(packet).uppercase(), sender = delegate?.getPeerNickname(peerID) ?: "unknown", content = String(packet.payload, Charsets.UTF_8), senderPeerID = peerID, timestamp = Date(packet.timestamp.toLong()) ) delegate?.onMessageReceived(message) } catch (e: Exception) { Log.e(TAG, "Failed to process broadcast message: ${e.message}") } } /** * Handle (decrypted) private message addressed to us */ private suspend fun handlePrivateMessage(packet: BitchatPacket, peerID: String) { try { val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER val signatureIsValid = packet.signature != null && delegate?.verifySignature(packet, peerID) == true // Migration fallback is visible to relays, so sender authenticity // is mandatory. Never accept an unsigned directed raw file. if (isFileTransfer && !signatureIsValid) { Log.w(TAG, "Unsigned or invalid signed private file from $peerID") return } // Preserve prior behavior for other directed packet types: verify // a signature whenever one is present. if (!isFileTransfer && packet.signature != null && !signatureIsValid) { Log.w(TAG, "Invalid signature for private message from $peerID") return } // Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload) if (file != null) { if (isFileTransfer) { Log.d(TAG, "📥 FILE_TRANSFER decode success (private): name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}', from=${peerID.take(8)}") } val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file) val message = BitchatMessage( id = java.util.UUID.randomUUID().toString().uppercase(), sender = delegate?.getPeerNickname(peerID) ?: "unknown", content = savedPath, type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), senderPeerID = peerID, timestamp = Date(packet.timestamp.toLong()), isPrivate = true, recipientNickname = delegate?.getMyNickname() ) Log.d(TAG, "📄 Saved incoming file to $savedPath") delegate?.onMessageReceived(message) return } else if (isFileTransfer) { Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (private) from ${peerID.take(8)} payloadSize=${packet.payload.size}") } // Fallback: plain text val message = BitchatMessage( sender = delegate?.getPeerNickname(peerID) ?: "unknown", content = String(packet.payload, Charsets.UTF_8), senderPeerID = peerID, timestamp = Date(packet.timestamp.toLong()) ) delegate?.onMessageReceived(message) } catch (e: Exception) { Log.e(TAG, "Failed to process private message from $peerID: ${e.message}") } } /** * Handle leave message */ suspend fun handleLeave(routed: RoutedPacket) { val packet = routed.packet val peerID = routed.peerID ?: "unknown" val content = String(packet.payload, Charsets.UTF_8) if (content.startsWith("#")) { // Channel leave delegate?.onChannelLeave(content, peerID) } else { // Peer disconnect delegate?.removePeer(peerID) } // Leave message relay is now handled by centralized PacketRelayManager } /** * Get debug information */ fun getDebugInfo(): String { return buildString { appendLine("=== Message Handler Debug Info ===") appendLine("Handler Scope Active: ${handlerScope.isActive}") appendLine("My Peer ID: $myPeerID") } } /** * Convert hex string peer ID to binary data (8 bytes) - same as iOS implementation */ private fun hexStringToByteArray(hexString: String): ByteArray { val result = ByteArray(8) { 0 } // Initialize with zeros, exactly 8 bytes 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 } /** * Shutdown the handler */ fun shutdown() { handlerScope.cancel() } /** * Handle favorite/unfavorite notification received over mesh as a private message. * Content format: "[FAVORITED]:npub..." or "[UNFAVORITED]:npub..." */ private fun handleFavoriteNotificationFromMesh(content: String, fromPeerID: String) { try { val isFavorite = content.startsWith("[FAVORITED]") val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") } // Update mutual favorite status in persistence // Resolve full Noise key if available via delegate peer info val peerInfo = delegate?.getPeerInfo(fromPeerID) val noiseKey = peerInfo?.noisePublicKey if (noiseKey != null) { com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite) if (npub != null) { // Index by noise key and current mesh peerID for fast Nostr routing com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub) com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, npub) } // Determine iOS-style guidance text val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) val guidance = 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." } // Emit system message via delegate callback val action = if (isFavorite) "favorited" else "unfavorited" val sys = com.bitchat.android.model.BitchatMessage( sender = "system", content = "${peerInfo.nickname} $action you$guidance", timestamp = java.util.Date(), isRelay = false ) delegate?.onMessageReceived(sys) } } catch (_: Exception) { // Best-effort; ignore errors } } } /** * Delegate interface for message handler callbacks */ interface MessageHandlerDelegate { // Peer management fun addOrUpdatePeer(peerID: String, nickname: String): Boolean fun removePeer(peerID: String) fun updatePeerNickname(peerID: String, nickname: String) fun getPeerNickname(peerID: String): String? fun getNetworkSize(): Int fun getMyNickname(): String? fun getPeerInfo(peerID: String): PeerInfo? fun updatePeerInfoFromVerifiedAnnouncement( peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean, capabilities: com.bitchat.android.model.PeerCapabilities? = null ): Boolean fun onVerifiedAnnouncementProcessed(peerID: String) {} // Packet operations fun sendPacket(packet: BitchatPacket) fun relayPacket(routed: RoutedPacket) fun getBroadcastRecipient(): ByteArray // Cryptographic operations fun verifySignature(packet: BitchatPacket, peerID: String): Boolean fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean // Noise protocol operations fun hasNoiseSession(peerID: String): Boolean fun initiateNoiseHandshake(peerID: String) fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? // Message operations fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? // Callbacks fun onMessageReceived(message: BitchatMessage) fun onChannelLeave(channel: String, fromPeer: String) fun onDeliveryAckReceived(messageID: String, peerID: String) fun onReadReceiptReceived(messageID: String, peerID: String) fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) }