diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 06a3f98b..cc29c747 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -5,6 +5,7 @@ import android.util.Log import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.HandshakeRequest import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.DeliveryAck import com.bitchat.android.model.ReadReceipt @@ -556,7 +557,6 @@ class BluetoothMeshService(private val context: Context) { ) try { - // TODO: THIS FORMAT FOR DELIVERY ACKS SHOULD BE DEPRECATED val ackData = ack.encode() ?: return val typeMarker = MessageType.DELIVERY_ACK.value.toByte() val payloadWithMarker = byteArrayOf(typeMarker) + ackData @@ -591,36 +591,41 @@ class BluetoothMeshService(private val context: Context) { */ fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { serviceScope.launch { + // Create the read receipt + val receipt = ReadReceipt( + originalMessageID = messageID, + readerID = myPeerID, + readerNickname = readerNickname + ) + try { - Log.d(TAG, "Sending read receipt for message $messageID to $recipientPeerID") - - // Create the read receipt - val receipt = ReadReceipt( - originalMessageID = messageID, - readerID = myPeerID, - readerNickname = readerNickname - ) - // Encode the receipt val receiptData = receipt.encode() - - // Create inner read receipt packet - val innerPacket = BitchatPacket( - version = 1u, - type = MessageType.READ_RECEIPT.value, + val typeMarker = MessageType.READ_RECEIPT.value.toByte() + val payloadWithMarker = byteArrayOf(typeMarker) + receiptData + val encryptedPayload = securityManager.encryptForPeer(payloadWithMarker, recipientPeerID) + + if (encryptedPayload == null) { + Log.w(TAG, "Failed to encrypt delivery ACK for $recipientPeerID") + return@launch + } + + // Create inner packet with the delivery ACK data + val packet = BitchatPacket( + type = MessageType.NOISE_ENCRYPTED.value, senderID = hexStringToByteArray(myPeerID), recipientID = hexStringToByteArray(recipientPeerID), timestamp = System.currentTimeMillis().toULong(), - payload = receiptData, + payload = encryptedPayload, signature = null, ttl = 3u ) - - // Encrypt the entire inner packet and send as NOISE_ENCRYPTED - encryptAndBroadcastNoisePacket(innerPacket, recipientPeerID) - - Log.d(TAG, "Sent read receipt for message $messageID to $recipientPeerID") - + + Log.d(TAG, "Sending read receipt for message $messageID to $recipientPeerID") + + // Use the new encrypt and broadcast function + connectionManager.broadcastPacket(RoutedPacket(packet)) + } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt for message $messageID: ${e.message}") } @@ -708,10 +713,9 @@ class BluetoothMeshService(private val context: Context) { } /** - * Send Noise identity announcement (broadcast our static public key and signing key) - * Now properly formatted as NoiseIdentityAnnouncement to match iOS + * Send key exchange to newly connected device */ - private fun sendKeyExchangeToDevice() { + fun sendKeyExchangeToDevice() { serviceScope.launch { try { val nickname = delegate?.getNickname() ?: myPeerID @@ -743,6 +747,43 @@ class BluetoothMeshService(private val context: Context) { } } + /** + * Send handshake request to target peer for pending messages + */ + fun sendHandshakeRequest(targetPeerID: String, pendingCount: UByte) { + serviceScope.launch { + try { + // Create handshake request + val request = HandshakeRequest( + requesterID = myPeerID, + requesterNickname = delegate?.getNickname() ?: myPeerID, + targetID = targetPeerID, + pendingMessageCount = pendingCount + ) + + val requestData = request.toBinaryData() + + // Create packet for handshake request + val packet = BitchatPacket( + version = 1u, + type = MessageType.HANDSHAKE_REQUEST.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(targetPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = requestData, + ttl = 6u + ) + + // Broadcast the packet (Android equivalent of both direct and relay attempts) + connectionManager.broadcastPacket(RoutedPacket(packet)) + Log.d(TAG, "Sent handshake request to $targetPeerID (pending: $pendingCount, ${requestData.size} bytes)") + + } catch (e: Exception) { + Log.e(TAG, "Failed to send handshake request to $targetPeerID: ${e.message}") + } + } + } + /** * Create a properly formatted NoiseIdentityAnnouncement exactly like iOS */ @@ -822,6 +863,14 @@ class BluetoothMeshService(private val context: Context) { return encryptionService.hasEstablishedSession(peerID) } + /** + * Initiate Noise handshake with a specific peer (public API) + */ + fun initiateNoiseHandshake(peerID: String) { + // Delegate to the existing implementation in the MessageHandler delegate + messageHandler.delegate?.initiateNoiseHandshake(peerID) + } + /** * Get peer fingerprint for identity management */ diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index f9252634..87e266cc 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -63,7 +63,6 @@ class MessageHandler(private val myPeerID: String) { } // Check for read receipt with type marker - // NOTE: THIS DOESN'T WORK WITH IOS, IT SENDS AN INNER PACKET INSTEAD if (typeMarker == MessageType.READ_RECEIPT.value) { val receiptData = decryptedData.sliceArray(1 until decryptedData.size) val receipt = ReadReceipt.decode(receiptData) diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index 396c05ba..8cadeddd 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -139,8 +139,8 @@ class PacketProcessor(private val myPeerID: String) { MessageType.NOISE_HANDSHAKE_INIT -> handleNoiseHandshake(routed, 1) MessageType.NOISE_HANDSHAKE_RESP -> handleNoiseHandshake(routed, 2) MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed) - //MessageType.DELIVERY_ACK -> handleDeliveryAck(routed) // custom packet type... - MessageType.READ_RECEIPT -> handleReadReceipt(routed) + // MessageType.DELIVERY_ACK -> handleDeliveryAck(routed) // custom packet type... + // MessageType.READ_RECEIPT -> handleReadReceipt(routed) else -> { validPacket = false Log.w(TAG, "Unknown message type: ${packet.type}") diff --git a/app/src/main/java/com/bitchat/android/model/HandshakeRequest.kt b/app/src/main/java/com/bitchat/android/model/HandshakeRequest.kt new file mode 100644 index 00000000..176979f6 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/HandshakeRequest.kt @@ -0,0 +1,122 @@ +package com.bitchat.android.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import com.bitchat.android.util.* +import java.util.* + +/** + * Handshake request for pending messages - exact same as iOS version + * Uses binary encoding for efficient protocol communication + */ +@Parcelize +data class HandshakeRequest( + val requestID: String, + val requesterID: String, // Who needs the handshake + val requesterNickname: String, // Nickname of requester + val targetID: String, // Who should initiate handshake + val pendingMessageCount: UByte, // Number of messages queued + val timestamp: Date +) : Parcelable { + + // Primary constructor for creating new requests + constructor(requesterID: String, requesterNickname: String, targetID: String, pendingMessageCount: UByte) : this( + requestID = UUID.randomUUID().toString(), + requesterID = requesterID, + requesterNickname = requesterNickname, + targetID = targetID, + pendingMessageCount = pendingMessageCount, + timestamp = Date() + ) + + /** + * Encode to binary data matching iOS toBinaryData implementation + */ + fun toBinaryData(): ByteArray { + val builder = BinaryDataBuilder() + + // Append request ID UUID + builder.appendUUID(requestID) + + // RequesterID as 8-byte hex string + val requesterData = ByteArray(8) { 0 } + var tempID = requesterID + var index = 0 + + while (tempID.length >= 2 && index < 8) { + val hexByte = tempID.substring(0, 2) + val byte = hexByte.toIntOrNull(16)?.toByte() + if (byte != null) { + requesterData[index] = byte + } + tempID = tempID.substring(2) + index++ + } + + builder.buffer.addAll(requesterData.toList()) + + // TargetID as 8-byte hex string + val targetData = ByteArray(8) { 0 } + tempID = targetID + index = 0 + + while (tempID.length >= 2 && index < 8) { + val hexByte = tempID.substring(0, 2) + val byte = hexByte.toIntOrNull(16)?.toByte() + if (byte != null) { + targetData[index] = byte + } + tempID = tempID.substring(2) + index++ + } + + builder.buffer.addAll(targetData.toList()) + + // Append pending message count (UInt8) + builder.appendUInt8(pendingMessageCount) + + // Append timestamp + builder.appendDate(timestamp) + + // Append requester nickname as string + builder.appendString(requesterNickname) + + return builder.toByteArray() + } + + companion object { + /** + * Decode from binary data matching iOS fromBinaryData implementation + */ + fun fromBinaryData(data: ByteArray): HandshakeRequest? { + // Create defensive copy + val dataCopy = data.copyOf() + + // Minimum size: UUID (16) + requesterID (8) + targetID (8) + count (1) + timestamp (8) + min nickname + if (dataCopy.size < 42) return null + + val offset = intArrayOf(0) + + val requestID = dataCopy.readUUID(offset) ?: return null + + val requesterIDData = dataCopy.readFixedBytes(offset, 8) ?: return null + val requesterID = requesterIDData.hexEncodedString() + + val targetIDData = dataCopy.readFixedBytes(offset, 8) ?: return null + val targetID = targetIDData.hexEncodedString() + + val pendingMessageCount = dataCopy.readUInt8(offset) ?: return null + val timestamp = dataCopy.readDate(offset) ?: return null + val requesterNickname = dataCopy.readString(offset) ?: return null + + return HandshakeRequest( + requestID = requestID, + requesterID = requesterID, + requesterNickname = requesterNickname, + targetID = targetID, + pendingMessageCount = pendingMessageCount, + timestamp = timestamp + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 6bb6fe1e..3b0a8db5 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -32,6 +32,7 @@ enum class MessageType(val value: UByte) { CHANNEL_KEY_VERIFY_RESPONSE(0x15u), // Response to key verification request CHANNEL_PASSWORD_UPDATE(0x16u), // Distribute new password to channel members CHANNEL_METADATA(0x17u), // Announce channel creator and metadata + HANDSHAKE_REQUEST(0x25u), // Request handshake initiation for pending messages // Protocol version negotiation VERSION_HELLO(0x20u), // Initial version announcement diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 6f49d7a6..fda892db 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -32,7 +32,17 @@ class ChatViewModel( private val dataManager = DataManager(application.applicationContext) private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) - val privateChatManager = PrivateChatManager(state, messageManager, dataManager) + + // Create Noise session delegate for clean dependency injection + private val noiseSessionDelegate = object : NoiseSessionDelegate { + override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID) + override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID) + override fun sendIdentityAnnouncement() = meshService.sendKeyExchangeToDevice() + override fun sendHandshakeRequest(targetPeerID: String, pendingCount: UByte) = meshService.sendHandshakeRequest(targetPeerID, pendingCount) + override fun getMyPeerID(): String = meshService.myPeerID + } + + val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) private val notificationManager = NotificationManager(application.applicationContext) diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 27606bfe..b3a8aadf 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -6,6 +6,18 @@ import com.bitchat.android.mesh.PeerFingerprintManager import java.util.* import android.util.Log +/** + * Interface for Noise session operations needed by PrivateChatManager + * This avoids reflection and makes dependencies explicit + */ +interface NoiseSessionDelegate { + fun hasEstablishedSession(peerID: String): Boolean + fun initiateHandshake(peerID: String) + fun sendIdentityAnnouncement() + fun sendHandshakeRequest(targetPeerID: String, pendingCount: UByte) + fun getMyPeerID(): String +} + /** * Handles private chat functionality including peer management and blocking * Now uses centralized PeerFingerprintManager for all fingerprint operations @@ -13,7 +25,8 @@ import android.util.Log class PrivateChatManager( private val state: ChatState, private val messageManager: MessageManager, - private val dataManager: DataManager + private val dataManager: DataManager, + private val noiseSessionDelegate: NoiseSessionDelegate? = null ) { companion object { @@ -41,6 +54,9 @@ class PrivateChatManager( return false } + // Establish Noise session if needed before starting the chat + establishNoiseSessionIfNeeded(peerID, meshService) + state.setSelectedPrivateChatPeer(peerID) // Clear unread @@ -300,6 +316,117 @@ class PrivateChatManager( Log.d(TAG, "Cleaned up unread messages for disconnected peer $peerID") } + // MARK: - Noise Session Management + + /** + * Establish Noise session if needed before starting private chat + * Uses same lexicographical logic as MessageHandler.handleNoiseIdentityAnnouncement + */ + private fun establishNoiseSessionIfNeeded(peerID: String, meshService: Any) { + // If we have a clean delegate, use it; otherwise fall back to reflection for backward compatibility + if (noiseSessionDelegate != null) { + if (noiseSessionDelegate.hasEstablishedSession(peerID)) { + Log.d(TAG, "Noise session already established with $peerID") + return + } + + Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake") + + val myPeerID = noiseSessionDelegate.getMyPeerID() + + // Use lexicographical comparison to decide who initiates (same logic as MessageHandler) + if (myPeerID < peerID) { + // We should initiate the handshake + Log.d(TAG, "Our peer ID lexicographically < target peer ID, initiating Noise handshake with $peerID") + noiseSessionDelegate.initiateHandshake(peerID) + } else { + // They should initiate, we send a Noise identity announcement + Log.d(TAG, "Our peer ID lexicographically >= target peer ID, sending Noise identity announcement to prompt handshake from $peerID") + noiseSessionDelegate.sendIdentityAnnouncement() + // Also send handshake request to this peer + noiseSessionDelegate.sendHandshakeRequest(peerID, 1u) // 1 pending message (the chat we're trying to start) + } + } else { + // Fallback to reflection-based approach for backward compatibility + establishNoiseSessionIfNeededLegacy(peerID, meshService) + } + } + + /** + * Legacy reflection-based implementation for backward compatibility + */ + private fun establishNoiseSessionIfNeededLegacy(peerID: String, meshService: Any) { + try { + // Check if we already have an established Noise session with this peer + val hasSessionMethod = meshService::class.java.getDeclaredMethod("hasEstablishedSession", String::class.java) + val hasSession = hasSessionMethod.invoke(meshService, peerID) as Boolean + + if (hasSession) { + Log.d(TAG, "Noise session already established with $peerID") + return + } + + Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake") + + // Get our peer ID from mesh service for lexicographical comparison + val myPeerIDField = meshService::class.java.getField("myPeerID") + val myPeerID = myPeerIDField.get(meshService) as String + + // Use lexicographical comparison to decide who initiates (same logic as MessageHandler) + if (myPeerID < peerID) { + // We should initiate the handshake + Log.d(TAG, "Our peer ID lexicographically < target peer ID, initiating Noise handshake with $peerID") + initiateHandshakeWithPeer(peerID, meshService) + } else { + // They should initiate, we send a Noise identity announcement + Log.d(TAG, "Our peer ID lexicographically >= target peer ID, sending Noise identity announcement to prompt handshake from $peerID") + sendNoiseIdentityAnnouncement(meshService) + } + + } catch (e: Exception) { + Log.e(TAG, "Failed to establish Noise session with $peerID: ${e.message}") + } + } + + /** + * Initiate handshake with specific peer using the existing delegate pattern + */ + private fun initiateHandshakeWithPeer(peerID: String, meshService: Any) { + try { + // Use the existing MessageHandler delegate approach to initiate handshake + // This calls the same code that's in MessageHandler's delegate.initiateNoiseHandshake() + val messageHandler = meshService::class.java.getDeclaredField("messageHandler") + messageHandler.isAccessible = true + val handler = messageHandler.get(meshService) + + val delegate = handler::class.java.getDeclaredField("delegate") + delegate.isAccessible = true + val handlerDelegate = delegate.get(handler) + + val method = handlerDelegate::class.java.getMethod("initiateNoiseHandshake", String::class.java) + method.invoke(handlerDelegate, peerID) + + Log.d(TAG, "Successfully initiated Noise handshake with $peerID using delegate pattern") + } catch (e: Exception) { + Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${e.message}") + } + } + + /** + * Send Noise identity announcement to prompt other peer to initiate handshake + * This follows the same pattern as sendKeyExchangeToDevice() in BluetoothMeshService + */ + private fun sendNoiseIdentityAnnouncement(meshService: Any) { + try { + // Call sendKeyExchangeToDevice which sends a NoiseIdentityAnnouncement + val method = meshService::class.java.getDeclaredMethod("sendKeyExchangeToDevice") + method.invoke(meshService) + Log.d(TAG, "Successfully sent Noise identity announcement") + } catch (e: Exception) { + Log.e(TAG, "Failed to send Noise identity announcement: ${e.message}") + } + } + // MARK: - Utility Functions private fun getPeerIDForNickname(nickname: String, meshService: Any): String? {