Changes bitchat protocol (#265)

* create payload

* compiles and can send messages

* identityannouncement

* DMs work, read receipt not sent yet

* works

* delete old code

* simplify

* working

* fragment wip

* compression wip

* use zlib compression

* clean

* nice

* mesh

* remove comments
This commit is contained in:
callebtc
2025-08-18 21:16:27 +02:00
committed by GitHub
parent b86f2cdb11
commit 9795e2ce8a
25 changed files with 1533 additions and 1819 deletions
+1 -4
View File
@@ -91,10 +91,7 @@ dependencies {
// Bluetooth // Bluetooth
implementation(libs.nordic.ble) implementation(libs.nordic.ble)
// Compression
implementation(libs.lz4.java)
// Security preferences // Security preferences
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
@@ -3,13 +3,10 @@ package com.bitchat.android.mesh
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.protocol.MessagePadding
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.HandshakeRequest import com.bitchat.android.protocol.MessagePadding
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.model.DeliveryAck import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.model.NoiseIdentityAnnouncement
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.protocol.SpecialRecipients
@@ -64,7 +61,7 @@ class BluetoothMeshService(private val context: Context) {
init { init {
setupDelegates() setupDelegates()
messageHandler.packetProcessor = packetProcessor messageHandler.packetProcessor = packetProcessor
startPeriodicDebugLogging() //startPeriodicDebugLogging()
} }
/** /**
@@ -95,7 +92,6 @@ class BluetoothMeshService(private val context: Context) {
try { try {
delay(30000) // 30 seconds delay(30000) // 30 seconds
sendBroadcastAnnounce() sendBroadcastAnnounce()
broadcastNoiseIdentityAnnouncement()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
} }
@@ -131,7 +127,7 @@ class BluetoothMeshService(private val context: Context) {
// Send Noise handshake response // Send Noise handshake response
val responsePacket = BitchatPacket( val responsePacket = BitchatPacket(
version = 1u, version = 1u,
type = MessageType.NOISE_HANDSHAKE_RESP.value, type = MessageType.NOISE_HANDSHAKE.value,
senderID = hexStringToByteArray(myPeerID), senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(peerID), recipientID = hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(), timestamp = System.currentTimeMillis().toULong(),
@@ -224,18 +220,18 @@ class BluetoothMeshService(private val context: Context) {
try { try {
// Initiate proper Noise handshake with specific peer // Initiate proper Noise handshake with specific peer
val handshakeData = encryptionService.initiateHandshake(peerID) val handshakeData = encryptionService.initiateHandshake(peerID)
if (handshakeData != null) { if (handshakeData != null) {
val packet = BitchatPacket( val packet = BitchatPacket(
version = 1u, version = 1u,
type = MessageType.NOISE_HANDSHAKE_INIT.value, type = MessageType.NOISE_HANDSHAKE.value,
senderID = hexStringToByteArray(myPeerID), senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(peerID), recipientID = hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(), timestamp = System.currentTimeMillis().toULong(),
payload = handshakeData, payload = handshakeData,
ttl = MAX_TTL ttl = MAX_TTL
) )
connectionManager.broadcastPacket(RoutedPacket(packet)) connectionManager.broadcastPacket(RoutedPacket(packet))
Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)") Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)")
} else { } else {
@@ -247,6 +243,15 @@ class BluetoothMeshService(private val context: Context) {
} }
} }
override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? {
return try {
encryptionService.processHandshakeMessage(payload, peerID)
} catch (e: Exception) {
Log.e(TAG, "Failed to process handshake message from $peerID: ${e.message}")
null
}
}
override fun updatePeerIDBinding(newPeerID: String, nickname: String, override fun updatePeerIDBinding(newPeerID: String, nickname: String,
publicKey: ByteArray, previousPeerID: String?) { publicKey: ByteArray, previousPeerID: String?) {
@@ -270,10 +275,6 @@ class BluetoothMeshService(private val context: Context) {
return delegate?.decryptChannelMessage(encryptedContent, channel) return delegate?.decryptChannelMessage(encryptedContent, channel)
} }
override fun sendDeliveryAck(message: BitchatMessage, senderPeerID: String) {
this@BluetoothMeshService.sendDeliveryAck(message, senderPeerID)
}
// Callbacks // Callbacks
override fun onMessageReceived(message: BitchatMessage) { override fun onMessageReceived(message: BitchatMessage) {
delegate?.didReceiveMessage(message) delegate?.didReceiveMessage(message)
@@ -283,12 +284,12 @@ class BluetoothMeshService(private val context: Context) {
delegate?.didReceiveChannelLeave(channel, fromPeer) delegate?.didReceiveChannelLeave(channel, fromPeer)
} }
override fun onDeliveryAckReceived(ack: DeliveryAck) { override fun onDeliveryAckReceived(messageID: String, peerID: String) {
delegate?.didReceiveDeliveryAck(ack) delegate?.didReceiveDeliveryAck(messageID, peerID)
} }
override fun onReadReceiptReceived(receipt: ReadReceipt) { override fun onReadReceiptReceived(messageID: String, peerID: String) {
delegate?.didReceiveReadReceipt(receipt) delegate?.didReceiveReadReceipt(messageID, peerID)
} }
} }
@@ -315,18 +316,14 @@ class BluetoothMeshService(private val context: Context) {
return SpecialRecipients.BROADCAST return SpecialRecipients.BROADCAST
} }
override fun handleNoiseHandshake(routed: RoutedPacket, step: Int): Boolean { override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
return runBlocking { securityManager.handleNoiseHandshake(routed, step) } return runBlocking { securityManager.handleNoiseHandshake(routed) }
} }
override fun handleNoiseEncrypted(routed: RoutedPacket) { override fun handleNoiseEncrypted(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) }
} }
override fun handleNoiseIdentityAnnouncement(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleNoiseIdentityAnnouncement(routed) }
}
override fun handleAnnounce(routed: RoutedPacket) { override fun handleAnnounce(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleAnnounce(routed) } serviceScope.launch { messageHandler.handleAnnounce(routed) }
} }
@@ -343,14 +340,6 @@ class BluetoothMeshService(private val context: Context) {
return fragmentManager.handleFragment(packet) return fragmentManager.handleFragment(packet)
} }
// override fun handleDeliveryAck(routed: RoutedPacket) {
// serviceScope.launch { messageHandler.handleDeliveryAck(routed) }
// }
override fun handleReadReceipt(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleReadReceipt(routed) }
}
override fun sendAnnouncementToPeer(peerID: String) { override fun sendAnnouncementToPeer(peerID: String) {
this@BluetoothMeshService.sendAnnouncementToPeer(peerID) this@BluetoothMeshService.sendAnnouncementToPeer(peerID)
} }
@@ -376,11 +365,6 @@ class BluetoothMeshService(private val context: Context) {
delay(200) delay(200)
sendBroadcastAnnounce() sendBroadcastAnnounce()
} }
// Send key exchange to newly connected device
serviceScope.launch {
delay(400) // Ensure connection is stable
broadcastNoiseIdentityAnnouncement()
}
} }
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
@@ -453,371 +437,199 @@ class BluetoothMeshService(private val context: Context) {
if (content.isEmpty()) return if (content.isEmpty()) return
serviceScope.launch { serviceScope.launch {
val nickname = delegate?.getNickname() ?: myPeerID val packet = BitchatPacket(
version = 1u,
val message = BitchatMessage( type = MessageType.MESSAGE.value,
sender = nickname, senderID = hexStringToByteArray(myPeerID),
content = content, recipientID = SpecialRecipients.BROADCAST,
timestamp = Date(), timestamp = System.currentTimeMillis().toULong(),
isRelay = false, payload = content.toByteArray(Charsets.UTF_8),
senderPeerID = myPeerID, signature = null,
mentions = if (mentions.isNotEmpty()) mentions else null, ttl = MAX_TTL
channel = channel
) )
message.toBinaryPayload()?.let { messageData -> connectionManager.broadcastPacket(RoutedPacket(packet))
// Sign the message: TODO: NOT SIGNED
// val signature = securityManager.signPacket(messageData)
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = messageData,
signature = null,
ttl = MAX_TTL
)
// Send with random delay and retry for reliability
// delay(Random.nextLong(50, 500))
connectionManager.broadcastPacket(RoutedPacket(packet))
}
} }
} }
/** /**
* Send private message * Send private message - SIMPLIFIED iOS-compatible version
* Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService
*/ */
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
if (content.isEmpty() || recipientPeerID.isEmpty() || recipientNickname.isEmpty()) return if (content.isEmpty() || recipientPeerID.isEmpty() || recipientNickname.isEmpty()) return
val nickname = delegate?.getNickname() ?: myPeerID
val message = BitchatMessage(
id = messageID ?: UUID.randomUUID().toString(),
sender = nickname,
content = content,
timestamp = Date(),
isRelay = false,
isPrivate = true,
recipientNickname = recipientNickname,
senderPeerID = myPeerID
)
message.toBinaryPayload()?.let { messageData ->
try {
// Create inner packet with the padded message data
val innerPacket = BitchatPacket(
type = MessageType.MESSAGE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = messageData,
signature = null,
ttl = MAX_TTL
)
// Cache for offline favorites
if (storeForwardManager.shouldCacheForPeer(recipientPeerID)) {
storeForwardManager.cacheMessage(innerPacket, messageID ?: message.id)
}
// Use the new encrypt and broadcast function
encryptAndBroadcastNoisePacket(innerPacket, recipientPeerID)
} catch (e: Exception) {
Log.e(TAG, "Failed to send private message: ${e.message}")
}
}
}
/**
* Send delivery acknowledgment for a received private message
*/
fun sendDeliveryAck(message: BitchatMessage, senderPeerID: String) {
val nickname = delegate?.getNickname() ?: myPeerID
val ack = DeliveryAck(
originalMessageID = message.id,
recipientID = myPeerID,
recipientNickname = nickname,
hopCount = 0u.toUByte() // Will be calculated during relay
)
try {
val ackData = ack.encode() ?: return
val typeMarker = MessageType.DELIVERY_ACK.value.toByte()
val payloadWithMarker = byteArrayOf(typeMarker) + ackData
val encryptedPayload = securityManager.encryptForPeer(payloadWithMarker, senderPeerID)
if (encryptedPayload == null) {
Log.w(TAG, "Failed to encrypt delivery ACK for $senderPeerID")
return
}
// Create inner packet with the delivery ACK data
val packet = BitchatPacket(
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(senderPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encryptedPayload,
signature = null,
ttl = 3u
)
// Use the new encrypt and broadcast function
connectionManager.broadcastPacket(RoutedPacket(packet))
} catch (e: Exception) {
Log.e(TAG, "Failed to send delivery ACK: ${e.message}")
}
}
/**
* Send read receipt for a received private message
*/
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
serviceScope.launch { serviceScope.launch {
// Create the read receipt val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
val receipt = ReadReceipt(
originalMessageID = messageID, Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...")
readerID = myPeerID,
readerNickname = readerNickname // Check if we have an established Noise session
) if (encryptionService.hasEstablishedSession(recipientPeerID)) {
try {
try { // Create TLV-encoded private message exactly like iOS
// Encode the receipt val privateMessage = com.bitchat.android.model.PrivateMessagePacket(
val receiptData = receipt.encode() messageID = finalMessageID,
val typeMarker = MessageType.READ_RECEIPT.value.toByte() content = content
val payloadWithMarker = byteArrayOf(typeMarker) + receiptData )
val encryptedPayload = securityManager.encryptForPeer(payloadWithMarker, recipientPeerID)
val tlvData = privateMessage.encode()
if (encryptedPayload == null) { if (tlvData == null) {
Log.w(TAG, "Failed to encrypt delivery ACK for $recipientPeerID") Log.e(TAG, "Failed to encode private message with TLV")
return@launch return@launch
} }
// Create inner packet with the delivery ACK data // Create message payload with NoisePayloadType prefix: [type byte] + [TLV data]
val packet = BitchatPacket( val messagePayload = com.bitchat.android.model.NoisePayload(
type = MessageType.NOISE_ENCRYPTED.value, type = com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
senderID = hexStringToByteArray(myPeerID), data = tlvData
recipientID = hexStringToByteArray(recipientPeerID), )
timestamp = System.currentTimeMillis().toULong(),
payload = encryptedPayload, // Encrypt the payload
signature = null, val encrypted = encryptionService.encrypt(messagePayload.encode(), recipientPeerID)
ttl = 3u
) // Create NOISE_ENCRYPTED packet exactly like iOS
val packet = BitchatPacket(
Log.d(TAG, "Sending read receipt for message $messageID to $recipientPeerID") version = 1u,
// 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}")
}
}
}
/**
* Encrypt a BitchatPacket and broadcast it as a NOISE_ENCRYPTED message
* This is the correct protocol implementation - encrypt the entire packet, not just the payload
*/
private fun encryptAndBroadcastNoisePacket(innerPacket: BitchatPacket, recipientPeerID: String) {
serviceScope.launch {
try {
// Serialize the inner packet to binary data
val innerPacketData = innerPacket.toBinaryData()
if (innerPacketData == null) {
Log.e(TAG, "Failed to serialize inner packet for encryption")
return@launch
}
// Encrypt the serialized packet using Noise encryption
val encryptedPayload = securityManager.encryptForPeer(innerPacketData, recipientPeerID)
if (encryptedPayload != null) {
// Create the outer NOISE_ENCRYPTED packet
val outerPacket = BitchatPacket(
type = MessageType.NOISE_ENCRYPTED.value, type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID), senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID), recipientID = hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(), timestamp = System.currentTimeMillis().toULong(),
payload = encryptedPayload, payload = encrypted,
signature = null, signature = null,
ttl = MAX_TTL ttl = MAX_TTL
) )
// Broadcast the encrypted packet connectionManager.broadcastPacket(RoutedPacket(packet))
connectionManager.broadcastPacket(RoutedPacket(outerPacket)) Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
Log.d(TAG, "Encrypted and sent packet type ${innerPacket.type} to $recipientPeerID (${encryptedPayload.size} bytes encrypted)") // FIXED: Don't send didReceiveMessage for our own sent messages
} else { // This was causing self-notifications - iOS doesn't do this
Log.w(TAG, "Failed to encrypt packet for $recipientPeerID - no session available") // The UI handles showing sent messages through its own message sending logic
} catch (e: Exception) {
Log.e(TAG, "Failed to encrypt private message for $recipientPeerID: ${e.message}")
} }
} else {
// Fire and forget - initiate handshake but don't queue exactly like iOS
Log.d(TAG, "🤝 No session with $recipientPeerID, initiating handshake")
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
} catch (e: Exception) { // FIXED: Don't send didReceiveMessage for our own sent messages
Log.e(TAG, "Failed to encrypt and broadcast Noise packet to $recipientPeerID: ${e.message}") // The UI will handle showing the message in the chat interface
} }
} }
} }
/** /**
* Send broadcast announce * Send read receipt for a received private message - NEW NoisePayloadType implementation
* Uses same encryption approach as iOS SimplifiedBluetoothService
*/
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
serviceScope.launch {
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
try {
// Create read receipt payload using NoisePayloadType exactly like iOS
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
data = messageID.toByteArray(Charsets.UTF_8)
)
// Encrypt the payload
val encrypted = encryptionService.encrypt(readReceiptPayload.encode(), recipientPeerID)
// Create NOISE_ENCRYPTED packet exactly like iOS
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
ttl = 7u // Same TTL as iOS messageTTL
)
connectionManager.broadcastPacket(RoutedPacket(packet))
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
} catch (e: Exception) {
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
}
}
}
/**
* Send broadcast announce with TLV-encoded identity announcement - exactly like iOS
*/ */
fun sendBroadcastAnnounce() { fun sendBroadcastAnnounce() {
Log.d(TAG, "Sending broadcast announce") Log.d(TAG, "Sending broadcast announce")
serviceScope.launch { serviceScope.launch {
val nickname = delegate?.getNickname() ?: myPeerID val nickname = delegate?.getNickname() ?: myPeerID
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
if (staticKey == null) {
Log.e(TAG, "No static public key available for announcement")
return@launch
}
// Create iOS-compatible IdentityAnnouncement with TLV encoding
val announcement = IdentityAnnouncement(nickname, staticKey)
val tlvPayload = announcement.encode()
if (tlvPayload == null) {
Log.e(TAG, "Failed to encode announcement as TLV")
return@launch
}
val announcePacket = BitchatPacket( val announcePacket = BitchatPacket(
type = MessageType.ANNOUNCE.value, type = MessageType.ANNOUNCE.value,
ttl = MAX_TTL, ttl = MAX_TTL,
senderID = myPeerID, senderID = myPeerID,
payload = nickname.toByteArray() payload = tlvPayload
) )
connectionManager.broadcastPacket(RoutedPacket(announcePacket)) connectionManager.broadcastPacket(RoutedPacket(announcePacket))
Log.d(TAG, "Sent iOS-compatible TLV announce (${tlvPayload.size} bytes)")
} }
} }
/** /**
* Send announcement to specific peer * Send announcement to specific peer with TLV-encoded identity announcement - exactly like iOS
*/ */
private fun sendAnnouncementToPeer(peerID: String) { fun sendAnnouncementToPeer(peerID: String) {
if (peerManager.hasAnnouncedToPeer(peerID)) return if (peerManager.hasAnnouncedToPeer(peerID)) return
val nickname = delegate?.getNickname() ?: myPeerID val nickname = delegate?.getNickname() ?: myPeerID
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
if (staticKey == null) {
Log.e(TAG, "No static public key available for peer announcement")
return
}
// Create iOS-compatible IdentityAnnouncement with TLV encoding
val announcement = IdentityAnnouncement(nickname, staticKey)
val tlvPayload = announcement.encode()
if (tlvPayload == null) {
Log.e(TAG, "Failed to encode peer announcement as TLV")
return
}
val packet = BitchatPacket( val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value, type = MessageType.ANNOUNCE.value,
ttl = MAX_TTL, ttl = MAX_TTL,
senderID = myPeerID, senderID = myPeerID,
payload = nickname.toByteArray() payload = tlvPayload
) )
connectionManager.broadcastPacket(RoutedPacket(packet)) connectionManager.broadcastPacket(RoutedPacket(packet))
peerManager.markPeerAsAnnouncedTo(peerID) peerManager.markPeerAsAnnouncedTo(peerID)
Log.d(TAG, "Sent iOS-compatible TLV peer announce to $peerID (${tlvPayload.size} bytes)")
} }
/**
* Send key exchange to newly connected device
*/
fun broadcastNoiseIdentityAnnouncement() {
serviceScope.launch {
try {
val nickname = delegate?.getNickname() ?: myPeerID
// Create the identity announcement using proper binary format
val announcement = createNoiseIdentityAnnouncement(nickname, null)
if (announcement != null) {
val announcementData = announcement.toBinaryData()
val packet = BitchatPacket(
type = MessageType.NOISE_IDENTITY_ANNOUNCE.value,
ttl = MAX_TTL,
senderID = myPeerID,
payload = announcementData,
)
connectionManager.broadcastPacket(RoutedPacket(packet))
Log.d(TAG, "Sent NoiseIdentityAnnouncement (${announcementData.size} bytes)")
} else {
Log.e(TAG, "Failed to create NoiseIdentityAnnouncement")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send NoiseIdentityAnnouncement: ${e.message}")
}
}
}
/**
* 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
*/
private fun createNoiseIdentityAnnouncement(nickname: String, previousPeerID: String?): NoiseIdentityAnnouncement? {
return try {
// Get the static public key for Noise protocol
val staticKey = encryptionService.getStaticPublicKey()
if (staticKey == null) {
Log.e(TAG, "No static public key available for identity announcement")
return null
}
// Get the signing public key for Ed25519 signatures
val signingKey = encryptionService.getSigningPublicKey()
if (signingKey == null) {
Log.e(TAG, "No signing public key available for identity announcement")
return null
}
val now = Date()
// Create the binding data to sign (same format as iOS)
val timestampMs = now.time
val bindingData = myPeerID.toByteArray(Charsets.UTF_8) +
staticKey +
timestampMs.toString().toByteArray(Charsets.UTF_8)
// Sign the binding with our Ed25519 signing key
val signature = encryptionService.signData(bindingData) ?: ByteArray(0)
// Create the identity announcement
NoiseIdentityAnnouncement(
peerID = myPeerID,
publicKey = staticKey,
signingPublicKey = signingKey,
nickname = nickname,
timestamp = now,
previousPeerID = previousPeerID,
signature = signature
)
} catch (e: Exception) {
Log.e(TAG, "Failed to create NoiseIdentityAnnouncement: ${e.message}")
null
}
}
/** /**
* Send leave announcement * Send leave announcement
*/ */
@@ -1014,8 +826,8 @@ interface BluetoothMeshDelegate {
fun didReceiveMessage(message: BitchatMessage) fun didReceiveMessage(message: BitchatMessage)
fun didUpdatePeerList(peers: List<String>) fun didUpdatePeerList(peers: List<String>)
fun didReceiveChannelLeave(channel: String, fromPeer: String) fun didReceiveChannelLeave(channel: String, fromPeer: String)
fun didReceiveDeliveryAck(ack: DeliveryAck) fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
fun didReceiveReadReceipt(receipt: ReadReceipt) fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun getNickname(): String? fun getNickname(): String?
fun isFavorite(peerID: String): Boolean fun isFavorite(peerID: String): Boolean
@@ -3,25 +3,33 @@ package com.bitchat.android.mesh
import android.util.Log import android.util.Log
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.FragmentPayload
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
* Manages message fragmentation and reassembly * Manages message fragmentation and reassembly - 100% iOS Compatible
* Extracted from BluetoothMeshService for better separation of concerns *
* This implementation exactly matches iOS SimplifiedBluetoothService fragmentation:
* - Same fragment payload structure (13-byte header + data)
* - Same MTU thresholds and fragment sizes
* - Same reassembly logic and timeout handling
* - Uses new FragmentPayload model for type safety
*/ */
class FragmentManager { class FragmentManager {
companion object { companion object {
private const val TAG = "FragmentManager" private const val TAG = "FragmentManager"
private const val FRAGMENT_SIZE_THRESHOLD = 512 // 512 bytes // iOS values: 512 MTU threshold, 469 max fragment size (512 MTU - headers)
private const val MAX_FRAGMENT_SIZE = 500 // Match iOS/Rust for BLE compatibility (185 byte MTU limit) private const val FRAGMENT_SIZE_THRESHOLD = 512 // Matches iOS: if data.count > 512
private const val FRAGMENT_TIMEOUT = 30000L // 30 seconds private const val MAX_FRAGMENT_SIZE = 469 // Matches iOS: maxFragmentSize = 469
private const val CLEANUP_INTERVAL = 10000L // 10 seconds private const val FRAGMENT_TIMEOUT = 30000L // Matches iOS: 30 seconds cleanup
private const val CLEANUP_INTERVAL = 10000L // 10 seconds cleanup check
} }
// Fragment storage // Fragment storage - iOS equivalent: incomingFragments: [String: [Int: Data]]
private val incomingFragments = ConcurrentHashMap<String, MutableMap<Int, ByteArray>>() private val incomingFragments = ConcurrentHashMap<String, MutableMap<Int, ByteArray>>()
// iOS equivalent: fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)]
private val fragmentMetadata = ConcurrentHashMap<String, Triple<UByte, Int, Long>>() // originalType, totalFragments, timestamp private val fragmentMetadata = ConcurrentHashMap<String, Triple<UByte, Int, Long>>() // originalType, totalFragments, timestamp
// Delegate for callbacks // Delegate for callbacks
@@ -35,51 +43,52 @@ class FragmentManager {
} }
/** /**
* Create fragments from a large packet * Create fragments from a large packet - 100% iOS Compatible
* Matches iOS sendFragmentedPacket() implementation exactly
*/ */
fun createFragments(packet: BitchatPacket): List<BitchatPacket> { fun createFragments(packet: BitchatPacket): List<BitchatPacket> {
val data = packet.toBinaryData() ?: return emptyList() val fullData = packet.toBinaryData() ?: return emptyList()
if (data.size <= FRAGMENT_SIZE_THRESHOLD) { // iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) {
return listOf(packet) // No fragmentation needed return listOf(packet) // No fragmentation needed
} }
val fragments = mutableListOf<BitchatPacket>() val fragments = mutableListOf<BitchatPacket>()
val fragmentID = generateFragmentID()
// Fragment overhead: 13 bytes (fragment metadata) + 21 bytes (packet header) = 34 bytes total // iOS: let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
// With 150 byte fragments, total packet = ~184 bytes (within iOS 185 byte MTU) val fragmentID = FragmentPayload.generateFragmentID()
val totalFragments = (data.size + MAX_FRAGMENT_SIZE - 1) / MAX_FRAGMENT_SIZE
Log.d(TAG, "Creating ${totalFragments} fragments for ${data.size} byte packet") // iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
val fragmentChunks = stride(0, fullData.size, MAX_FRAGMENT_SIZE) { offset ->
val endOffset = minOf(offset + MAX_FRAGMENT_SIZE, fullData.size)
fullData.sliceArray(offset..<endOffset)
}
for (i in 0 until totalFragments) { Log.d(TAG, "Creating ${fragmentChunks.size} fragments for ${fullData.size} byte packet (iOS compatible)")
val start = i * MAX_FRAGMENT_SIZE
val end = minOf(start + MAX_FRAGMENT_SIZE, data.size) // iOS: for (index, fragment) in fragments.enumerated()
val fragmentData = data.sliceArray(start until end) for (index in fragmentChunks.indices) {
val fragmentData = fragmentChunks[index]
val fragmentPayload = createFragmentPayload( // Create iOS-compatible fragment payload
val fragmentPayload = FragmentPayload(
fragmentID = fragmentID, fragmentID = fragmentID,
index = i, index = index,
total = totalFragments, total = fragmentChunks.size,
originalType = packet.type, originalType = packet.type,
data = fragmentData data = fragmentData
) )
val fragmentType = when (i) { // iOS: MessageType.fragment.rawValue (single fragment type)
0 -> MessageType.FRAGMENT_START
totalFragments - 1 -> MessageType.FRAGMENT_END
else -> MessageType.FRAGMENT_CONTINUE
}
val fragmentPacket = BitchatPacket( val fragmentPacket = BitchatPacket(
type = fragmentType.value, type = MessageType.FRAGMENT.value,
ttl = packet.ttl, ttl = packet.ttl,
senderID = packet.senderID, senderID = packet.senderID,
recipientID = packet.recipientID, recipientID = packet.recipientID,
timestamp = packet.timestamp, timestamp = packet.timestamp,
payload = fragmentPayload, payload = fragmentPayload.encode(),
signature = null // Fragments aren't individually signed signature = null // iOS: signature: nil
) )
fragments.add(fragmentPacket) fragments.add(fragmentPacket)
@@ -89,62 +98,82 @@ class FragmentManager {
} }
/** /**
* Handle incoming fragment * Handle incoming fragment - 100% iOS Compatible
* Matches iOS handleFragment() implementation exactly
*/ */
fun handleFragment(packet: BitchatPacket): BitchatPacket? { fun handleFragment(packet: BitchatPacket): BitchatPacket? {
if (packet.payload.size < 13) { // iOS: guard packet.payload.count > 13 else { return }
if (packet.payload.size < FragmentPayload.HEADER_SIZE) {
Log.w(TAG, "Fragment packet too small: ${packet.payload.size}") Log.w(TAG, "Fragment packet too small: ${packet.payload.size}")
return null return null
} }
// Don't process our own fragments - iOS equivalent check
// This would be done at a higher level but we'll include for safety
try { try {
// Extract fragment metadata (same format as iOS) // Use FragmentPayload for type-safe decoding
val fragmentIDData = packet.payload.sliceArray(0..7) val fragmentPayload = FragmentPayload.decode(packet.payload)
val fragmentID = fragmentIDData.contentHashCode().toString() if (fragmentPayload == null || !fragmentPayload.isValid()) {
Log.w(TAG, "Invalid fragment payload")
val index = ((packet.payload[8].toInt() and 0xFF) shl 8) or (packet.payload[9].toInt() and 0xFF) return null
val total = ((packet.payload[10].toInt() and 0xFF) shl 8) or (packet.payload[11].toInt() and 0xFF)
val originalType = packet.payload[12].toUByte()
val fragmentData = packet.payload.sliceArray(13 until packet.payload.size)
Log.d(TAG, "Received fragment $index/$total for fragmentID: $fragmentID, originalType: $originalType")
// Store fragment
if (!incomingFragments.containsKey(fragmentID)) {
incomingFragments[fragmentID] = mutableMapOf()
fragmentMetadata[fragmentID] = Triple(originalType, total, System.currentTimeMillis())
} }
incomingFragments[fragmentID]?.put(index, fragmentData) // iOS: let fragmentID = packet.payload[0..<8].map { String(format: "%02x", $0) }.joined()
val fragmentIDString = fragmentPayload.getFragmentIDString()
// Check if we have all fragments Log.d(TAG, "Received fragment ${fragmentPayload.index}/${fragmentPayload.total} for fragmentID: $fragmentIDString, originalType: ${fragmentPayload.originalType}")
if (incomingFragments[fragmentID]?.size == total) {
Log.d(TAG, "All fragments received for $fragmentID, reassembling...") // iOS: if incomingFragments[fragmentID] == nil
if (!incomingFragments.containsKey(fragmentIDString)) {
incomingFragments[fragmentIDString] = mutableMapOf()
fragmentMetadata[fragmentIDString] = Triple(
fragmentPayload.originalType,
fragmentPayload.total,
System.currentTimeMillis()
)
}
// iOS: incomingFragments[fragmentID]?[index] = Data(fragmentData)
incomingFragments[fragmentIDString]?.put(fragmentPayload.index, fragmentPayload.data)
// iOS: if let fragments = incomingFragments[fragmentID], fragments.count == total
val fragmentMap = incomingFragments[fragmentIDString]
if (fragmentMap != null && fragmentMap.size == fragmentPayload.total) {
Log.d(TAG, "All fragments received for $fragmentIDString, reassembling...")
// Reassemble message // iOS reassembly logic: for i in 0..<total { if let fragment = fragments[i] { reassembled.append(fragment) } }
val reassembledData = mutableListOf<Byte>() val reassembledData = mutableListOf<Byte>()
for (i in 0 until total) { for (i in 0 until fragmentPayload.total) {
incomingFragments[fragmentID]?.get(i)?.let { data -> fragmentMap[i]?.let { data ->
reassembledData.addAll(data.asIterable()) reassembledData.addAll(data.asIterable())
} }
} }
// Parse and return reassembled packet // iOS: if let metadata = fragmentMetadata[fragmentID]
val reassembledPacket = BitchatPacket.fromBinaryData(reassembledData.toByteArray()) val metadata = fragmentMetadata[fragmentIDString]
if (metadata != null) {
// Cleanup // iOS: let reassembledPacket = BitchatPacket(type: metadata.type, ...)
incomingFragments.remove(fragmentID) val reassembledPacket = BitchatPacket(
fragmentMetadata.remove(fragmentID) type = metadata.first,
senderID = packet.senderID,
if (reassembledPacket != null) { recipientID = packet.recipientID,
timestamp = packet.timestamp,
payload = reassembledData.toByteArray(),
signature = packet.signature,
ttl = if (packet.ttl > 0u) (packet.ttl - 1u).toUByte() else 0u
)
// iOS cleanup: incomingFragments.removeValue(forKey: fragmentID)
incomingFragments.remove(fragmentIDString)
fragmentMetadata.remove(fragmentIDString)
Log.d(TAG, "Successfully reassembled packet of ${reassembledData.size} bytes") Log.d(TAG, "Successfully reassembled packet of ${reassembledData.size} bytes")
return reassembledPacket return reassembledPacket
} else {
Log.e(TAG, "Failed to parse reassembled packet")
} }
} else { } else {
val received = incomingFragments[fragmentID]?.size ?: 0 val received = fragmentMap?.size ?: 0
Log.d(TAG, "Fragment $index stored, have $received/$total fragments for $fragmentID") Log.d(TAG, "Fragment ${fragmentPayload.index} stored, have $received/${fragmentPayload.total} fragments for $fragmentIDString")
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -155,53 +184,51 @@ class FragmentManager {
} }
/** /**
* Create fragment payload with metadata * Helper function to match iOS stride functionality
* stride(from: 0, to: fullData.count, by: maxFragmentSize)
*/ */
private fun createFragmentPayload( private fun <T> stride(from: Int, to: Int, by: Int, transform: (Int) -> T): List<T> {
fragmentID: ByteArray, val result = mutableListOf<T>()
index: Int, var current = from
total: Int, while (current < to) {
originalType: UByte, result.add(transform(current))
data: ByteArray current += by
): ByteArray { }
val payload = ByteArray(13 + data.size) return result
// Fragment ID (8 bytes)
System.arraycopy(fragmentID, 0, payload, 0, 8)
// Index (2 bytes, big-endian)
payload[8] = ((index shr 8) and 0xFF).toByte()
payload[9] = (index and 0xFF).toByte()
// Total (2 bytes, big-endian)
payload[10] = ((total shr 8) and 0xFF).toByte()
payload[11] = (total and 0xFF).toByte()
// Original type (1 byte)
payload[12] = originalType.toByte()
// Fragment data
System.arraycopy(data, 0, payload, 13, data.size)
return payload
} }
/** /**
* Generate unique fragment ID (8 random bytes to match iOS/Rust) * iOS cleanup - exactly matching performCleanup() implementation
* Clean old fragments (> 30 seconds old)
*/ */
private fun generateFragmentID(): ByteArray { private fun cleanupOldFragments() {
val fragmentID = ByteArray(8) val now = System.currentTimeMillis()
kotlin.random.Random.nextBytes(fragmentID) val cutoff = now - FRAGMENT_TIMEOUT
return fragmentID
// iOS: let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key }
val oldFragments = fragmentMetadata.filter { it.value.third < cutoff }.map { it.key }
// iOS: for fragmentID in oldFragments { incomingFragments.removeValue(forKey: fragmentID) }
for (fragmentID in oldFragments) {
incomingFragments.remove(fragmentID)
fragmentMetadata.remove(fragmentID)
}
if (oldFragments.isNotEmpty()) {
Log.d(TAG, "Cleaned up ${oldFragments.size} old fragment sets (iOS compatible)")
}
} }
/** /**
* Get debug information * Get debug information - matches iOS debugging
*/ */
fun getDebugInfo(): String { fun getDebugInfo(): String {
return buildString { return buildString {
appendLine("=== Fragment Manager Debug Info ===") appendLine("=== Fragment Manager Debug Info (iOS Compatible) ===")
appendLine("Active Fragment Sets: ${incomingFragments.size}") appendLine("Active Fragment Sets: ${incomingFragments.size}")
appendLine("Fragment Size Threshold: $FRAGMENT_SIZE_THRESHOLD bytes")
appendLine("Max Fragment Size: $MAX_FRAGMENT_SIZE bytes")
fragmentMetadata.forEach { (fragmentID, metadata) -> fragmentMetadata.forEach { (fragmentID, metadata) ->
val (originalType, totalFragments, timestamp) = metadata val (originalType, totalFragments, timestamp) = metadata
val received = incomingFragments[fragmentID]?.size ?: 0 val received = incomingFragments[fragmentID]?.size ?: 0
@@ -212,7 +239,7 @@ class FragmentManager {
} }
/** /**
* Start periodic cleanup of old fragments * Start periodic cleanup of old fragments - matches iOS maintenance timer
*/ */
private fun startPeriodicCleanup() { private fun startPeriodicCleanup() {
managerScope.launch { managerScope.launch {
@@ -223,29 +250,6 @@ class FragmentManager {
} }
} }
/**
* Clean up old fragments (older than 30 seconds)
*/
private fun cleanupOldFragments() {
val cutoffTime = System.currentTimeMillis() - FRAGMENT_TIMEOUT
val fragmentsToRemove = mutableListOf<String>()
fragmentMetadata.entries.forEach { (fragmentID, metadata) ->
if (metadata.third < cutoffTime) {
fragmentsToRemove.add(fragmentID)
}
}
fragmentsToRemove.forEach { fragmentID ->
incomingFragments.remove(fragmentID)
fragmentMetadata.remove(fragmentID)
}
if (fragmentsToRemove.isNotEmpty()) {
Log.d(TAG, "Cleaned up ${fragmentsToRemove.size} old fragment sets")
}
}
/** /**
* Clear all fragments * Clear all fragments
*/ */
@@ -2,9 +2,7 @@ package com.bitchat.android.mesh
import android.util.Log import android.util.Log
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryAck import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.NoiseIdentityAnnouncement
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
@@ -33,7 +31,8 @@ class MessageHandler(private val myPeerID: String) {
private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/** /**
* Handle Noise encrypted transport message * Handle Noise encrypted transport message - SIMPLIFIED iOS-compatible version
* Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService
*/ */
suspend fun handleNoiseEncrypted(routed: RoutedPacket) { suspend fun handleNoiseEncrypted(routed: RoutedPacket) {
val packet = routed.packet val packet = routed.packet
@@ -44,6 +43,13 @@ class MessageHandler(private val myPeerID: String) {
// Skip our own messages // Skip our own messages
if (peerID == myPeerID) return 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 { try {
// Decrypt the message using the Noise service // Decrypt the message using the Noise service
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID) val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
@@ -52,44 +58,66 @@ class MessageHandler(private val myPeerID: String) {
return return
} }
// Check if it's a special format message (type marker + payload) if (decryptedData.isEmpty()) {
if (decryptedData.size > 1) { Log.w(TAG, "Decrypted data is empty from $peerID")
val typeMarker = decryptedData[0].toUByte() return
// Check if this is a delivery ACK with the new format
if (typeMarker == MessageType.DELIVERY_ACK.value) {
handleDeliveryAck(decryptedData)
Log.d(TAG, "Processed delivery ACK from $peerID")
}
// Check for read receipt with type marker
if (typeMarker == MessageType.READ_RECEIPT.value) {
val receiptData = decryptedData.sliceArray(1 until decryptedData.size)
val receipt = ReadReceipt.decode(receiptData)
if (receipt != null) {
delegate?.onReadReceiptReceived(receipt)
Log.d(TAG, "Processed read receipt from $peerID")
return
}
}
} }
// Try to parse as a full inner packet (for compatibility with other message types) // NEW: Use NoisePayload system exactly like iOS
val innerPacket = BitchatPacket.fromBinaryData(decryptedData) val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData)
if (innerPacket != null) { if (noisePayload == null) {
Log.d(TAG, "Decrypted inner packet type ${innerPacket.type} from $peerID") 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)}...")
// Create BitchatMessage exactly like iOS
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)
}
}
// Create a new routed packet with the decrypted inner packet com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
val innerRouted = RoutedPacket(innerPacket, peerID, routed.relayAddress) // 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)
}
// Use PacketProcessor to handle the inner packet recursively com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
if (packetProcessor != null) { // Handle read receipt exactly like iOS
packetProcessor!!.processPacket(innerRouted) val messageID = String(noisePayload.data, Charsets.UTF_8)
} else { Log.d(TAG, "👁️ Read receipt received from $peerID for message $messageID")
Log.w(TAG, "PacketProcessor reference is null; cannot recursively process inner packet.")
// Simplified: Call delegate with messageID and peerID directly
delegate?.onReadReceiptReceived(messageID, peerID)
} }
} else {
Log.w(TAG, "Failed to parse decrypted data as packet from $peerID")
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -98,77 +126,45 @@ class MessageHandler(private val myPeerID: String) {
} }
/** /**
* Handle Noise identity announcement - supports peer ID rotation * Send delivery ACK for a received private message - exactly like iOS
*/ */
suspend fun handleNoiseIdentityAnnouncement(routed: RoutedPacket) { private suspend fun sendDeliveryAck(messageID: String, senderPeerID: String) {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing Noise identity announcement from $peerID (${packet.payload.size} bytes)")
// Skip our own announcements
if (peerID == myPeerID) return
try { try {
// Parse the identity announcement // Create ACK payload: [type byte] + [message ID] - exactly like iOS
val announcement = NoiseIdentityAnnouncement.fromBinaryData(packet.payload) val ackPayload = com.bitchat.android.model.NoisePayload(
if (announcement == null) { type = com.bitchat.android.model.NoisePayloadType.DELIVERED,
Log.w(TAG, "Failed to parse Noise identity announcement from $peerID") data = messageID.toByteArray(Charsets.UTF_8)
return
}
Log.d(TAG, "Parsed identity announcement: peerID=${announcement.peerID}, " +
"nickname=${announcement.nickname}, fingerprint=${announcement.fingerprint?.take(16)}...")
// Verify the announcement signature using Ed25519 (iOS compatibility)
if (announcement.signature.isEmpty()) {
Log.w(TAG, "❌ Identity announcement from $peerID has no signature - rejecting")
return
}
// Verify signature using the same format as iOS
val timestampMs = announcement.timestamp.time
val bindingData = announcement.peerID.toByteArray(Charsets.UTF_8) +
announcement.publicKey +
timestampMs.toString().toByteArray(Charsets.UTF_8)
val isSignatureValid = delegate?.verifyEd25519Signature(announcement.signature, bindingData, announcement.signingPublicKey) ?: false
if (!isSignatureValid) {
Log.w(TAG, "❌ Signature verification failed for identity announcement from $peerID - rejecting")
return
}
Log.d(TAG, "✅ Signature verification successful for identity announcement from $peerID")
// Update peer binding in the delegate (ChatViewModel/BluetoothMeshService)
delegate?.updatePeerIDBinding(
newPeerID = announcement.peerID,
nickname = announcement.nickname,
publicKey = announcement.publicKey,
previousPeerID = announcement.previousPeerID
) )
// Check if we need to initiate a handshake with this peer // Encrypt the payload
val hasSession = delegate?.hasNoiseSession(announcement.peerID) ?: false val encryptedPayload = delegate?.encryptForPeer(ackPayload.encode(), senderPeerID)
if (!hasSession) { if (encryptedPayload == null) {
Log.d(TAG, "No session with ${announcement.peerID}, may need handshake") Log.w(TAG, "Failed to encrypt delivery ACK for $senderPeerID")
return
// Use lexicographic comparison to decide who initiates (prevents both sides from initiating)
if (myPeerID < announcement.peerID) {
delegate?.initiateNoiseHandshake(announcement.peerID)
}
} }
Log.d(TAG, "Successfully processed identity announcement from $peerID") // 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 = 7u // Same TTL as iOS messageTTL
)
delegate?.sendPacket(packet)
Log.d(TAG, "📤 Sent delivery ACK to $senderPeerID for message $messageID")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error processing Noise identity announcement from $peerID: ${e.message}") Log.e(TAG, "Failed to send delivery ACK to $senderPeerID: ${e.message}")
} }
} }
/** /**
* Handle announce message * Handle announce message with TLV decoding support - exactly like iOS
*/ */
suspend fun handleAnnounce(routed: RoutedPacket): Boolean { suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val packet = routed.packet val packet = routed.packet
@@ -176,17 +172,90 @@ class MessageHandler(private val myPeerID: String) {
if (peerID == myPeerID) return false if (peerID == myPeerID) return false
val nickname = String(packet.payload, Charsets.UTF_8) // Try to decode as iOS-compatible IdentityAnnouncement with TLV format
Log.d(TAG, "Received announce from $peerID: $nickname") val announcement = IdentityAnnouncement.decode(packet.payload)
if (announcement == null) {
Log.w(TAG, "Failed to decode announce from $peerID as iOS-compatible TLV format")
return false
}
// Notify delegate to handle peer management // Successfully decoded TLV format exactly like iOS
Log.d(TAG, "Received iOS-compatible announce from $peerID: nickname=${announcement.nickname}, " +
"publicKey=${announcement.publicKey.joinToString("") { "%02x".format(it) }.take(16)}...")
// Extract nickname and public key from TLV data
val nickname = announcement.nickname
val publicKey = announcement.publicKey
// Notify delegate to handle peer management with nickname
val isFirstAnnounce = delegate?.addOrUpdatePeer(peerID, nickname) ?: false val isFirstAnnounce = delegate?.addOrUpdatePeer(peerID, nickname) ?: false
// Update peer ID binding with public key for identity management
delegate?.updatePeerIDBinding(
newPeerID = peerID,
nickname = nickname,
publicKey = publicKey,
previousPeerID = null
)
// Announce relay is now handled by centralized PacketRelayManager Log.d(TAG, "Processed iOS-compatible TLV announce: stored public key for $peerID")
return isFirstAnnounce return 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 = 7u // 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 * Handle broadcast or private message
*/ */
@@ -194,6 +263,11 @@ class MessageHandler(private val myPeerID: String) {
val packet = routed.packet val packet = routed.packet
val peerID = routed.peerID ?: "unknown" val peerID = routed.peerID ?: "unknown"
if (peerID == myPeerID) return 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()) } val recipientID = packet.recipientID?.takeIf { !it.contentEquals(delegate?.getBroadcastRecipient()) }
@@ -215,35 +289,14 @@ class MessageHandler(private val myPeerID: String) {
val peerID = routed.peerID ?: "unknown" val peerID = routed.peerID ?: "unknown"
try { try {
// Parse message // Parse message
val message = BitchatMessage.fromBinaryPayload(packet.payload) val message = BitchatMessage(
if (message != null) { sender = delegate?.getPeerNickname(peerID) ?: "unknown",
// Check for cover traffic (dummy messages) content = String(packet.payload, Charsets.UTF_8),
if (message.content.startsWith("☂DUMMY☂")) { senderPeerID = peerID,
Log.d(TAG, "Discarding cover traffic from $peerID") timestamp = Date()
return // Silently discard )
}
delegate?.onMessageReceived(message)
delegate?.updatePeerNickname(peerID, message.sender)
// Handle encrypted channel messages
val finalContent = if (message.channel != null && message.isEncrypted && message.encryptedContent != null) {
delegate?.decryptChannelMessage(message.encryptedContent, message.channel)
?: "[Encrypted message - password required]"
} else {
message.content
}
// Replace timestamp with current time (same as iOS)
val messageWithCurrentTime = message.copy(
content = finalContent,
senderPeerID = peerID,
timestamp = Date() // Use current time instead of original timestamp
)
delegate?.onMessageReceived(messageWithCurrentTime)
}
// Broadcast message relay is now handled by centralized PacketRelayManager
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to process broadcast message: ${e.message}") Log.e(TAG, "Failed to process broadcast message: ${e.message}")
@@ -262,21 +315,14 @@ class MessageHandler(private val myPeerID: String) {
} }
// Parse message // Parse message
val message = BitchatMessage.fromBinaryPayload(packet.payload) val message = BitchatMessage(
if (message != null) { sender = delegate?.getPeerNickname(peerID) ?: "unknown",
// Check for cover traffic (dummy messages) content = String(packet.payload, Charsets.UTF_8),
if (message.content.startsWith("☂DUMMY☂")) { senderPeerID = peerID,
Log.d(TAG, "Discarding private cover traffic from $peerID") timestamp = Date()
return // Silently discard )
} delegate?.onMessageReceived(message)
delegate?.updatePeerNickname(peerID, message.sender)
delegate?.onMessageReceived(message)
// Send delivery ACK
delegate?.sendDeliveryAck(message, peerID)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to process private message from $peerID: ${e.message}") Log.e(TAG, "Failed to process private message from $peerID: ${e.message}")
} }
@@ -301,31 +347,6 @@ class MessageHandler(private val myPeerID: String) {
// Leave message relay is now handled by centralized PacketRelayManager // Leave message relay is now handled by centralized PacketRelayManager
} }
/**
* Handle delivery acknowledgment
*/
suspend fun handleDeliveryAck(decryptedData: ByteArray) {
val ackData = decryptedData.sliceArray(1 until decryptedData.size)
val ack = DeliveryAck.decode(ackData)
if (ack != null) {
delegate?.onDeliveryAckReceived(ack)
}
return
}
/**
* Handle read receipt
*/
suspend fun handleReadReceipt(routed: RoutedPacket) {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
val receipt = ReadReceipt.decode(routed.packet.payload)
if (receipt != null) {
delegate?.onReadReceiptReceived(receipt)
}
return
}
/** /**
* Get debug information * Get debug information
*/ */
@@ -392,16 +413,16 @@ interface MessageHandlerDelegate {
// Noise protocol operations // Noise protocol operations
fun hasNoiseSession(peerID: String): Boolean fun hasNoiseSession(peerID: String): Boolean
fun initiateNoiseHandshake(peerID: String) fun initiateNoiseHandshake(peerID: String)
fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray?
fun updatePeerIDBinding(newPeerID: String, nickname: String, fun updatePeerIDBinding(newPeerID: String, nickname: String,
publicKey: ByteArray, previousPeerID: String?) publicKey: ByteArray, previousPeerID: String?)
// Message operations // Message operations
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun sendDeliveryAck(message: BitchatMessage, senderPeerID: String)
// Callbacks // Callbacks
fun onMessageReceived(message: BitchatMessage) fun onMessageReceived(message: BitchatMessage)
fun onChannelLeave(channel: String, fromPeer: String) fun onChannelLeave(channel: String, fromPeer: String)
fun onDeliveryAckReceived(ack: DeliveryAck) fun onDeliveryAckReceived(messageID: String, peerID: String)
fun onReadReceiptReceived(receipt: ReadReceipt) fun onReadReceiptReceived(messageID: String, peerID: String)
} }
@@ -133,22 +133,16 @@ class PacketProcessor(private val myPeerID: String) {
// Handle public packet types (no address check needed) // Handle public packet types (no address check needed)
when (messageType) { when (messageType) {
MessageType.NOISE_IDENTITY_ANNOUNCE -> handleNoiseIdentityAnnouncement(routed)
MessageType.ANNOUNCE -> handleAnnounce(routed) MessageType.ANNOUNCE -> handleAnnounce(routed)
MessageType.MESSAGE -> handleMessage(routed) MessageType.MESSAGE -> handleMessage(routed)
MessageType.LEAVE -> handleLeave(routed) MessageType.LEAVE -> handleLeave(routed)
MessageType.FRAGMENT_START, MessageType.FRAGMENT -> handleFragment(routed)
MessageType.FRAGMENT_CONTINUE,
MessageType.FRAGMENT_END -> handleFragment(routed)
else -> { else -> {
// Handle private packet types (address check required) // Handle private packet types (address check required)
if (packetRelayManager.isPacketAddressedToMe(packet)) { if (packetRelayManager.isPacketAddressedToMe(packet)) {
when (messageType) { when (messageType) {
MessageType.NOISE_HANDSHAKE_INIT -> handleNoiseHandshake(routed, 1) MessageType.NOISE_HANDSHAKE -> handleNoiseHandshake(routed)
MessageType.NOISE_HANDSHAKE_RESP -> handleNoiseHandshake(routed, 2)
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed) MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
// MessageType.DELIVERY_ACK -> handleDeliveryAck(routed) // custom packet type...
// MessageType.READ_RECEIPT -> handleReadReceipt(routed)
else -> { else -> {
validPacket = false validPacket = false
Log.w(TAG, "Unknown message type: ${packet.type}") Log.w(TAG, "Unknown message type: ${packet.type}")
@@ -170,12 +164,12 @@ class PacketProcessor(private val myPeerID: String) {
} }
/** /**
* Handle Noise handshake message * Handle Noise handshake message - SIMPLIFIED iOS-compatible version
*/ */
private fun handleNoiseHandshake(routed: RoutedPacket, step: Int) { private suspend fun handleNoiseHandshake(routed: RoutedPacket) {
val peerID = routed.peerID ?: "unknown" val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing Noise handshake step $step from ${formatPeerForLog(peerID)}") Log.d(TAG, "Processing Noise handshake from ${formatPeerForLog(peerID)}")
delegate?.handleNoiseHandshake(routed, step) delegate?.handleNoiseHandshake(routed)
} }
/** /**
@@ -187,15 +181,6 @@ class PacketProcessor(private val myPeerID: String) {
delegate?.handleNoiseEncrypted(routed) delegate?.handleNoiseEncrypted(routed)
} }
/**
* Handle Noise identity announcement (after peer ID rotation)
*/
private suspend fun handleNoiseIdentityAnnouncement(routed: RoutedPacket) {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing Noise identity announcement from ${formatPeerForLog(peerID)}")
delegate?.handleNoiseIdentityAnnouncement(routed)
}
/** /**
* Handle announce message * Handle announce message
*/ */
@@ -248,15 +233,6 @@ class PacketProcessor(private val myPeerID: String) {
// delegate?.handleDeliveryAck(routed) // delegate?.handleDeliveryAck(routed)
// } // }
/**
* Handle read receipt
*/
private suspend fun handleReadReceipt(routed: RoutedPacket) {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing read receipt from ${formatPeerForLog(peerID)}")
delegate?.handleReadReceipt(routed)
}
/** /**
* Get debug information * Get debug information
*/ */
@@ -314,15 +290,12 @@ interface PacketProcessorDelegate {
fun getBroadcastRecipient(): ByteArray fun getBroadcastRecipient(): ByteArray
// Message type handlers // Message type handlers
fun handleNoiseHandshake(routed: RoutedPacket, step: Int): Boolean fun handleNoiseHandshake(routed: RoutedPacket): Boolean
fun handleNoiseEncrypted(routed: RoutedPacket) fun handleNoiseEncrypted(routed: RoutedPacket)
fun handleNoiseIdentityAnnouncement(routed: RoutedPacket)
fun handleAnnounce(routed: RoutedPacket) fun handleAnnounce(routed: RoutedPacket)
fun handleMessage(routed: RoutedPacket) fun handleMessage(routed: RoutedPacket)
fun handleLeave(routed: RoutedPacket) fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket? fun handleFragment(packet: BitchatPacket): BitchatPacket?
// fun handleDeliveryAck(routed: RoutedPacket)
fun handleReadReceipt(routed: RoutedPacket)
// Communication // Communication
fun sendAnnouncementToPeer(peerID: String) fun sendAnnouncementToPeer(peerID: String)
@@ -1,7 +1,6 @@
package com.bitchat.android.mesh package com.bitchat.android.mesh
import android.util.Log import android.util.Log
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
@@ -88,9 +88,10 @@ class SecurityManager(private val encryptionService: EncryptionService, private
} }
/** /**
* Handle Noise handshake packet * Handle Noise handshake packet - SIMPLIFIED iOS-compatible version
* Single handshake type with automatic response handling
*/ */
suspend fun handleNoiseHandshake(routed: RoutedPacket, step: Int): Boolean { suspend fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
val packet = routed.packet val packet = routed.packet
val peerID = routed.peerID ?: "unknown" val peerID = routed.peerID ?: "unknown"
@@ -120,7 +121,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
Log.d(TAG, "Already processed handshake: $exchangeKey") Log.d(TAG, "Already processed handshake: $exchangeKey")
return false return false
} }
Log.d(TAG, "Processing Noise handshake step $step from $peerID (${packet.payload.size} bytes)") Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)")
processedKeyExchanges.add(exchangeKey) processedKeyExchanges.add(exchangeKey)
try { try {
@@ -128,7 +129,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
val response = encryptionService.processHandshakeMessage(packet.payload, peerID) val response = encryptionService.processHandshakeMessage(packet.payload, peerID)
if (response != null) { if (response != null) {
Log.d(TAG, "Successfully processed Noise handshake step $step from $peerID, sending response") Log.d(TAG, "Successfully processed Noise handshake from $peerID, sending response")
// Send handshake response through delegate // Send handshake response through delegate
delegate?.sendHandshakeResponse(peerID, response) delegate?.sendHandshakeResponse(peerID, response)
} }
@@ -212,7 +213,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
*/ */
private fun generateMessageID(packet: BitchatPacket, peerID: String): String { private fun generateMessageID(packet: BitchatPacket, peerID: String): String {
return when (MessageType.fromValue(packet.type)) { return when (MessageType.fromValue(packet.type)) {
MessageType.FRAGMENT_START, MessageType.FRAGMENT_CONTINUE, MessageType.FRAGMENT_END -> { MessageType.FRAGMENT -> {
// For fragments, include the payload hash to distinguish different fragments // For fragments, include the payload hash to distinguish different fragments
"${packet.timestamp}-$peerID-${packet.type}-${packet.payload.contentHashCode()}" "${packet.timestamp}-$peerID-${packet.type}-${packet.payload.contentHashCode()}"
} }
@@ -53,10 +53,8 @@ class StoreForwardManager {
*/ */
fun cacheMessage(packet: BitchatPacket, messageID: String) { fun cacheMessage(packet: BitchatPacket, messageID: String) {
// Skip certain message types (same as iOS) // Skip certain message types (same as iOS)
if (packet.type == MessageType.NOISE_HANDSHAKE_INIT.value || if (packet.type == MessageType.NOISE_HANDSHAKE.value ||
packet.type == MessageType.NOISE_HANDSHAKE_RESP.value ||
packet.type == MessageType.NOISE_ENCRYPTED.value || packet.type == MessageType.NOISE_ENCRYPTED.value ||
packet.type == MessageType.NOISE_IDENTITY_ANNOUNCE.value ||
packet.type == MessageType.ANNOUNCE.value || packet.type == MessageType.ANNOUNCE.value ||
packet.type == MessageType.LEAVE.value) { packet.type == MessageType.LEAVE.value) {
Log.d(TAG, "Skipping cache for message type: ${packet.type}") Log.d(TAG, "Skipping cache for message type: ${packet.type}")
@@ -13,22 +13,22 @@ import java.util.*
sealed class DeliveryStatus : Parcelable { sealed class DeliveryStatus : Parcelable {
@Parcelize @Parcelize
object Sending : DeliveryStatus() object Sending : DeliveryStatus()
@Parcelize @Parcelize
object Sent : DeliveryStatus() object Sent : DeliveryStatus()
@Parcelize @Parcelize
data class Delivered(val to: String, val at: Date) : DeliveryStatus() data class Delivered(val to: String, val at: Date) : DeliveryStatus()
@Parcelize @Parcelize
data class Read(val by: String, val at: Date) : DeliveryStatus() data class Read(val by: String, val at: Date) : DeliveryStatus()
@Parcelize @Parcelize
data class Failed(val reason: String) : DeliveryStatus() data class Failed(val reason: String) : DeliveryStatus()
@Parcelize @Parcelize
data class PartiallyDelivered(val reached: Int, val total: Int) : DeliveryStatus() data class PartiallyDelivered(val reached: Int, val total: Int) : DeliveryStatus()
fun getDisplayText(): String { fun getDisplayText(): String {
return when (this) { return when (this) {
is Sending -> "Sending..." is Sending -> "Sending..."
@@ -68,7 +68,7 @@ data class BitchatMessage(
fun toBinaryPayload(): ByteArray? { fun toBinaryPayload(): ByteArray? {
try { try {
val buffer = ByteBuffer.allocate(4096).apply { order(ByteOrder.BIG_ENDIAN) } val buffer = ByteBuffer.allocate(4096).apply { order(ByteOrder.BIG_ENDIAN) }
// Message format: // Message format:
// - Flags: 1 byte (bit flags for optional fields) // - Flags: 1 byte (bit flags for optional fields)
// - Timestamp: 8 bytes (milliseconds since epoch, big-endian) // - Timestamp: 8 bytes (milliseconds since epoch, big-endian)
@@ -76,7 +76,7 @@ data class BitchatMessage(
// - Sender length: 1 byte + sender data // - Sender length: 1 byte + sender data
// - Content length: 2 bytes + content data (or encrypted content) // - Content length: 2 bytes + content data (or encrypted content)
// Optional fields based on flags... // Optional fields based on flags...
var flags: UByte = 0u var flags: UByte = 0u
if (isRelay) flags = flags or 0x01u if (isRelay) flags = flags or 0x01u
if (isPrivate) flags = flags or 0x02u if (isPrivate) flags = flags or 0x02u
@@ -86,23 +86,23 @@ data class BitchatMessage(
if (mentions != null && mentions.isNotEmpty()) flags = flags or 0x20u if (mentions != null && mentions.isNotEmpty()) flags = flags or 0x20u
if (channel != null) flags = flags or 0x40u if (channel != null) flags = flags or 0x40u
if (isEncrypted) flags = flags or 0x80u if (isEncrypted) flags = flags or 0x80u
buffer.put(flags.toByte()) buffer.put(flags.toByte())
// Timestamp (in milliseconds, 8 bytes big-endian) // Timestamp (in milliseconds, 8 bytes big-endian)
val timestampMillis = timestamp.time val timestampMillis = timestamp.time
buffer.putLong(timestampMillis) buffer.putLong(timestampMillis)
// ID // ID
val idBytes = id.toByteArray(Charsets.UTF_8) val idBytes = id.toByteArray(Charsets.UTF_8)
buffer.put(minOf(idBytes.size, 255).toByte()) buffer.put(minOf(idBytes.size, 255).toByte())
buffer.put(idBytes.take(255).toByteArray()) buffer.put(idBytes.take(255).toByteArray())
// Sender // Sender
val senderBytes = sender.toByteArray(Charsets.UTF_8) val senderBytes = sender.toByteArray(Charsets.UTF_8)
buffer.put(minOf(senderBytes.size, 255).toByte()) buffer.put(minOf(senderBytes.size, 255).toByte())
buffer.put(senderBytes.take(255).toByteArray()) buffer.put(senderBytes.take(255).toByteArray())
// Content or encrypted content // Content or encrypted content
if (isEncrypted && encryptedContent != null) { if (isEncrypted && encryptedContent != null) {
val length = minOf(encryptedContent.size, 65535) val length = minOf(encryptedContent.size, 65535)
@@ -114,26 +114,26 @@ data class BitchatMessage(
buffer.putShort(length.toShort()) buffer.putShort(length.toShort())
buffer.put(contentBytes.take(length).toByteArray()) buffer.put(contentBytes.take(length).toByteArray())
} }
// Optional fields // Optional fields
originalSender?.let { origSender -> originalSender?.let { origSender ->
val origBytes = origSender.toByteArray(Charsets.UTF_8) val origBytes = origSender.toByteArray(Charsets.UTF_8)
buffer.put(minOf(origBytes.size, 255).toByte()) buffer.put(minOf(origBytes.size, 255).toByte())
buffer.put(origBytes.take(255).toByteArray()) buffer.put(origBytes.take(255).toByteArray())
} }
recipientNickname?.let { recipient -> recipientNickname?.let { recipient ->
val recipBytes = recipient.toByteArray(Charsets.UTF_8) val recipBytes = recipient.toByteArray(Charsets.UTF_8)
buffer.put(minOf(recipBytes.size, 255).toByte()) buffer.put(minOf(recipBytes.size, 255).toByte())
buffer.put(recipBytes.take(255).toByteArray()) buffer.put(recipBytes.take(255).toByteArray())
} }
senderPeerID?.let { peerID -> senderPeerID?.let { peerID ->
val peerBytes = peerID.toByteArray(Charsets.UTF_8) val peerBytes = peerID.toByteArray(Charsets.UTF_8)
buffer.put(minOf(peerBytes.size, 255).toByte()) buffer.put(minOf(peerBytes.size, 255).toByte())
buffer.put(peerBytes.take(255).toByteArray()) buffer.put(peerBytes.take(255).toByteArray())
} }
// Mentions array // Mentions array
mentions?.let { mentionList -> mentions?.let { mentionList ->
buffer.put(minOf(mentionList.size, 255).toByte()) buffer.put(minOf(mentionList.size, 255).toByte())
@@ -143,24 +143,24 @@ data class BitchatMessage(
buffer.put(mentionBytes.take(255).toByteArray()) buffer.put(mentionBytes.take(255).toByteArray())
} }
} }
// Channel hashtag // Channel hashtag
channel?.let { channelName -> channel?.let { channelName ->
val channelBytes = channelName.toByteArray(Charsets.UTF_8) val channelBytes = channelName.toByteArray(Charsets.UTF_8)
buffer.put(minOf(channelBytes.size, 255).toByte()) buffer.put(minOf(channelBytes.size, 255).toByte())
buffer.put(channelBytes.take(255).toByteArray()) buffer.put(channelBytes.take(255).toByteArray())
} }
val result = ByteArray(buffer.position()) val result = ByteArray(buffer.position())
buffer.rewind() buffer.rewind()
buffer.get(result) buffer.get(result)
return result return result
} catch (e: Exception) { } catch (e: Exception) {
return null return null
} }
} }
companion object { companion object {
/** /**
* Parse message from binary payload - exactly same logic as iOS version * Parse message from binary payload - exactly same logic as iOS version
@@ -168,9 +168,9 @@ data class BitchatMessage(
fun fromBinaryPayload(data: ByteArray): BitchatMessage? { fun fromBinaryPayload(data: ByteArray): BitchatMessage? {
try { try {
if (data.size < 13) return null if (data.size < 13) return null
val buffer = ByteBuffer.wrap(data).apply { order(ByteOrder.BIG_ENDIAN) } val buffer = ByteBuffer.wrap(data).apply { order(ByteOrder.BIG_ENDIAN) }
// Flags // Flags
val flags = buffer.get().toUByte() val flags = buffer.get().toUByte()
val isRelay = (flags and 0x01u) != 0u.toUByte() val isRelay = (flags and 0x01u) != 0u.toUByte()
@@ -181,32 +181,32 @@ data class BitchatMessage(
val hasMentions = (flags and 0x20u) != 0u.toUByte() val hasMentions = (flags and 0x20u) != 0u.toUByte()
val hasChannel = (flags and 0x40u) != 0u.toUByte() val hasChannel = (flags and 0x40u) != 0u.toUByte()
val isEncrypted = (flags and 0x80u) != 0u.toUByte() val isEncrypted = (flags and 0x80u) != 0u.toUByte()
// Timestamp // Timestamp
val timestampMillis = buffer.getLong() val timestampMillis = buffer.getLong()
val timestamp = Date(timestampMillis) val timestamp = Date(timestampMillis)
// ID // ID
val idLength = buffer.get().toInt() and 0xFF val idLength = buffer.get().toInt() and 0xFF
if (buffer.remaining() < idLength) return null if (buffer.remaining() < idLength) return null
val idBytes = ByteArray(idLength) val idBytes = ByteArray(idLength)
buffer.get(idBytes) buffer.get(idBytes)
val id = String(idBytes, Charsets.UTF_8) val id = String(idBytes, Charsets.UTF_8)
// Sender // Sender
val senderLength = buffer.get().toInt() and 0xFF val senderLength = buffer.get().toInt() and 0xFF
if (buffer.remaining() < senderLength) return null if (buffer.remaining() < senderLength) return null
val senderBytes = ByteArray(senderLength) val senderBytes = ByteArray(senderLength)
buffer.get(senderBytes) buffer.get(senderBytes)
val sender = String(senderBytes, Charsets.UTF_8) val sender = String(senderBytes, Charsets.UTF_8)
// Content // Content
val contentLength = buffer.getShort().toInt() and 0xFFFF val contentLength = buffer.getShort().toInt() and 0xFFFF
if (buffer.remaining() < contentLength) return null if (buffer.remaining() < contentLength) return null
val content: String val content: String
val encryptedContent: ByteArray? val encryptedContent: ByteArray?
if (isEncrypted) { if (isEncrypted) {
val encryptedBytes = ByteArray(contentLength) val encryptedBytes = ByteArray(contentLength)
buffer.get(encryptedBytes) buffer.get(encryptedBytes)
@@ -218,7 +218,7 @@ data class BitchatMessage(
content = String(contentBytes, Charsets.UTF_8) content = String(contentBytes, Charsets.UTF_8)
encryptedContent = null encryptedContent = null
} }
// Optional fields // Optional fields
val originalSender = if (hasOriginalSender && buffer.hasRemaining()) { val originalSender = if (hasOriginalSender && buffer.hasRemaining()) {
val length = buffer.get().toInt() and 0xFF val length = buffer.get().toInt() and 0xFF
@@ -228,7 +228,7 @@ data class BitchatMessage(
String(bytes, Charsets.UTF_8) String(bytes, Charsets.UTF_8)
} else null } else null
} else null } else null
val recipientNickname = if (hasRecipientNickname && buffer.hasRemaining()) { val recipientNickname = if (hasRecipientNickname && buffer.hasRemaining()) {
val length = buffer.get().toInt() and 0xFF val length = buffer.get().toInt() and 0xFF
if (buffer.remaining() >= length) { if (buffer.remaining() >= length) {
@@ -237,7 +237,7 @@ data class BitchatMessage(
String(bytes, Charsets.UTF_8) String(bytes, Charsets.UTF_8)
} else null } else null
} else null } else null
val senderPeerID = if (hasSenderPeerID && buffer.hasRemaining()) { val senderPeerID = if (hasSenderPeerID && buffer.hasRemaining()) {
val length = buffer.get().toInt() and 0xFF val length = buffer.get().toInt() and 0xFF
if (buffer.remaining() >= length) { if (buffer.remaining() >= length) {
@@ -246,7 +246,7 @@ data class BitchatMessage(
String(bytes, Charsets.UTF_8) String(bytes, Charsets.UTF_8)
} else null } else null
} else null } else null
// Mentions array // Mentions array
val mentions = if (hasMentions && buffer.hasRemaining()) { val mentions = if (hasMentions && buffer.hasRemaining()) {
val mentionCount = buffer.get().toInt() and 0xFF val mentionCount = buffer.get().toInt() and 0xFF
@@ -263,7 +263,7 @@ data class BitchatMessage(
} }
if (mentionList.isNotEmpty()) mentionList else null if (mentionList.isNotEmpty()) mentionList else null
} else null } else null
// Channel // Channel
val channel = if (hasChannel && buffer.hasRemaining()) { val channel = if (hasChannel && buffer.hasRemaining()) {
val length = buffer.get().toInt() and 0xFF val length = buffer.get().toInt() and 0xFF
@@ -273,7 +273,7 @@ data class BitchatMessage(
String(bytes, Charsets.UTF_8) String(bytes, Charsets.UTF_8)
} else null } else null
} else null } else null
return BitchatMessage( return BitchatMessage(
id = id, id = id,
sender = sender, sender = sender,
@@ -289,19 +289,19 @@ data class BitchatMessage(
encryptedContent = encryptedContent, encryptedContent = encryptedContent,
isEncrypted = isEncrypted isEncrypted = isEncrypted
) )
} catch (e: Exception) { } catch (e: Exception) {
return null return null
} }
} }
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
other as BitchatMessage other as BitchatMessage
if (id != other.id) return false if (id != other.id) return false
if (sender != other.sender) return false if (sender != other.sender) return false
if (content != other.content) return false if (content != other.content) return false
@@ -319,10 +319,10 @@ data class BitchatMessage(
} else if (other.encryptedContent != null) return false } else if (other.encryptedContent != null) return false
if (isEncrypted != other.isEncrypted) return false if (isEncrypted != other.isEncrypted) return false
if (deliveryStatus != other.deliveryStatus) return false if (deliveryStatus != other.deliveryStatus) return false
return true return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = id.hashCode() var result = id.hashCode()
result = 31 * result + sender.hashCode() result = 31 * result + sender.hashCode()
@@ -1,106 +0,0 @@
package com.bitchat.android.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import com.bitchat.android.util.*
import java.util.*
/**
* Delivery acknowledgment structure - exact same as iOS version
* Uses binary encoding for efficient protocol communication
*/
@Parcelize
data class DeliveryAck(
val originalMessageID: String,
val ackID: String = UUID.randomUUID().toString(),
val recipientID: String,
val recipientNickname: String,
val timestamp: Date = Date(),
val hopCount: UByte
) : Parcelable {
// Primary constructor for creating new acks
constructor(originalMessageID: String, recipientID: String, recipientNickname: String, hopCount: UByte) : this(
originalMessageID = originalMessageID,
ackID = UUID.randomUUID().toString(),
recipientID = recipientID,
recipientNickname = recipientNickname,
timestamp = Date(),
hopCount = hopCount
)
/**
* Encode to binary data matching iOS toBinaryData implementation
*/
fun encode(): ByteArray {
val builder = BinaryDataBuilder()
// Append original message UUID
builder.appendUUID(originalMessageID)
// Append ack ID UUID
builder.appendUUID(ackID)
// Append recipient ID as 8-byte hex string
val recipientData = ByteArray(8) { 0 }
var tempID = recipientID
var index = 0
while (tempID.length >= 2 && index < 8) {
val hexByte = tempID.substring(0, 2)
val byte = hexByte.toIntOrNull(16)?.toByte()
if (byte != null) {
recipientData[index] = byte
}
tempID = tempID.substring(2)
index++
}
builder.buffer.addAll(recipientData.toList())
// Append hop count (UInt8)
builder.appendUInt8(hopCount)
// Append timestamp
builder.appendDate(timestamp)
// Append recipient nickname as string
builder.appendString(recipientNickname)
return builder.toByteArray()
}
companion object {
/**
* Decode from binary data matching iOS fromBinaryData implementation
*/
fun decode(data: ByteArray): DeliveryAck? {
// Create defensive copy
val dataCopy = data.copyOf()
// Minimum size: 2 UUIDs (32) + recipientID (8) + hopCount (1) + timestamp (8) + min nickname
if (dataCopy.size < 50) return null
val offset = intArrayOf(0)
val originalMessageID = dataCopy.readUUID(offset) ?: return null
val ackID = dataCopy.readUUID(offset) ?: return null
val recipientIDData = dataCopy.readFixedBytes(offset, 8) ?: return null
val recipientID = recipientIDData.hexEncodedString()
val hopCount = dataCopy.readUInt8(offset) ?: return null
val timestamp = dataCopy.readDate(offset) ?: return null
val recipientNickname = dataCopy.readString(offset) ?: return null
return DeliveryAck(
originalMessageID = originalMessageID,
ackID = ackID,
recipientID = recipientID,
recipientNickname = recipientNickname,
timestamp = timestamp,
hopCount = hopCount
)
}
}
}
@@ -0,0 +1,155 @@
package com.bitchat.android.model
import com.bitchat.android.protocol.MessageType
/**
* FragmentPayload - 100% iOS-compatible fragment payload structure
*
* This class handles the encoding and decoding of fragment payloads exactly
* as implemented in iOS bitchat SimplifiedBluetoothService.
*
* Fragment payload structure (matching iOS):
* - 8 bytes: Fragment ID (random bytes)
* - 2 bytes: Index (big-endian)
* - 2 bytes: Total count (big-endian)
* - 1 byte: Original message type
* - Variable: Fragment data
*
* Total header size: 13 bytes
*/
data class FragmentPayload(
val fragmentID: ByteArray, // 8 bytes - random fragment identifier
val index: Int, // Fragment index (0-based)
val total: Int, // Total number of fragments
val originalType: UByte, // Original message type before fragmentation
val data: ByteArray // Fragment data
) {
companion object {
const val HEADER_SIZE = 13
const val FRAGMENT_ID_SIZE = 8
/**
* Decode fragment payload from binary data
* Matches iOS implementation exactly
*/
fun decode(payloadData: ByteArray): FragmentPayload? {
if (payloadData.size < HEADER_SIZE) {
return null
}
try {
// Extract fragment ID (8 bytes)
val fragmentID = payloadData.sliceArray(0..<FRAGMENT_ID_SIZE)
// Extract index (2 bytes, big-endian) - matching iOS
val index = ((payloadData[8].toInt() and 0xFF) shl 8) or
(payloadData[9].toInt() and 0xFF)
// Extract total (2 bytes, big-endian) - matching iOS
val total = ((payloadData[10].toInt() and 0xFF) shl 8) or
(payloadData[11].toInt() and 0xFF)
// Extract original type (1 byte)
val originalType = payloadData[12].toUByte()
// Extract fragment data (remaining bytes)
val data = if (payloadData.size > HEADER_SIZE) {
payloadData.sliceArray(HEADER_SIZE..<payloadData.size)
} else {
ByteArray(0)
}
return FragmentPayload(fragmentID, index, total, originalType, data)
} catch (e: Exception) {
return null
}
}
/**
* Generate random fragment ID (8 bytes)
* Matches iOS implementation: Data((0..<8).map { _ in UInt8.random(in: 0...255) })
*/
fun generateFragmentID(): ByteArray {
val fragmentID = ByteArray(FRAGMENT_ID_SIZE)
kotlin.random.Random.nextBytes(fragmentID)
return fragmentID
}
}
/**
* Encode fragment payload to binary data
* Matches iOS implementation exactly
*/
fun encode(): ByteArray {
val payload = ByteArray(HEADER_SIZE + data.size)
// Fragment ID (8 bytes)
System.arraycopy(fragmentID, 0, payload, 0, FRAGMENT_ID_SIZE)
// Index (2 bytes, big-endian) - matching iOS withUnsafeBytes(of: UInt16(index).bigEndian)
payload[8] = ((index shr 8) and 0xFF).toByte()
payload[9] = (index and 0xFF).toByte()
// Total (2 bytes, big-endian) - matching iOS withUnsafeBytes(of: UInt16(fragments.count).bigEndian)
payload[10] = ((total shr 8) and 0xFF).toByte()
payload[11] = (total and 0xFF).toByte()
// Original type (1 byte)
payload[12] = originalType.toByte()
// Fragment data
if (data.isNotEmpty()) {
System.arraycopy(data, 0, payload, HEADER_SIZE, data.size)
}
return payload
}
/**
* Get fragment ID as hex string for logging/debugging
*/
fun getFragmentIDString(): String {
return fragmentID.joinToString("") { "%02x".format(it) }
}
/**
* Validate fragment payload constraints
*/
fun isValid(): Boolean {
return fragmentID.size == FRAGMENT_ID_SIZE &&
index >= 0 &&
total > 0 &&
index < total &&
data.isNotEmpty()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FragmentPayload
if (!fragmentID.contentEquals(other.fragmentID)) return false
if (index != other.index) return false
if (total != other.total) return false
if (originalType != other.originalType) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = fragmentID.contentHashCode()
result = 31 * result + index
result = 31 * result + total
result = 31 * result + originalType.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
override fun toString(): String {
return "FragmentPayload(fragmentID=${getFragmentIDString()}, index=$index, total=$total, originalType=$originalType, dataSize=${data.size})"
}
}
@@ -1,122 +0,0 @@
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
)
}
}
}
@@ -0,0 +1,127 @@
package com.bitchat.android.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import com.bitchat.android.util.*
/**
* Identity announcement structure with TLV encoding
* Compatible with iOS AnnouncementPacket TLV format
*/
@Parcelize
data class IdentityAnnouncement(
val nickname: String,
val publicKey: ByteArray // FIXED: Made non-nullable to match iOS and AnnouncementPacket
) : Parcelable {
/**
* TLV types matching iOS implementation
*/
private enum class TLVType(val value: UByte) {
NICKNAME(0x01u),
NOISE_PUBLIC_KEY(0x02u);
companion object {
fun fromValue(value: UByte): TLVType? {
return values().find { it.value == value }
}
}
}
/**
* Encode to TLV binary data matching iOS implementation
*/
fun encode(): ByteArray? {
val nicknameData = nickname.toByteArray(Charsets.UTF_8)
// Check size limits
if (nicknameData.size > 255 || publicKey.size > 255) {
return null
}
val result = mutableListOf<Byte>()
// TLV for nickname
result.add(TLVType.NICKNAME.value.toByte())
result.add(nicknameData.size.toByte())
result.addAll(nicknameData.toList())
// TLV for public key
result.add(TLVType.NOISE_PUBLIC_KEY.value.toByte())
result.add(publicKey.size.toByte())
result.addAll(publicKey.toList())
return result.toByteArray()
}
companion object {
/**
* Decode from TLV binary data matching iOS implementation
*/
fun decode(data: ByteArray): IdentityAnnouncement? {
// Create defensive copy
val dataCopy = data.copyOf()
var offset = 0
var nickname: String? = null
var publicKey: ByteArray? = null
while (offset + 2 <= dataCopy.size) {
// Read TLV type
val typeValue = dataCopy[offset].toUByte()
val type = TLVType.fromValue(typeValue) ?: return null
offset += 1
// Read TLV length
val length = dataCopy[offset].toUByte().toInt()
offset += 1
// Check bounds
if (offset + length > dataCopy.size) return null
// Read TLV value
val value = dataCopy.sliceArray(offset until offset + length)
offset += length
when (type) {
TLVType.NICKNAME -> {
nickname = String(value, Charsets.UTF_8)
}
TLVType.NOISE_PUBLIC_KEY -> {
publicKey = value
}
}
}
// Both fields are required
return if (nickname != null && publicKey != null) {
IdentityAnnouncement(nickname, publicKey)
} else {
null
}
}
}
// Override equals and hashCode since we use ByteArray
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as IdentityAnnouncement
if (nickname != other.nickname) return false
if (!publicKey.contentEquals(other.publicKey)) return false
return true
}
override fun hashCode(): Int {
var result = nickname.hashCode()
result = 31 * result + publicKey.contentHashCode()
return result
}
override fun toString(): String {
return "IdentityAnnouncement(nickname='$nickname', publicKey=${publicKey.joinToString("") { "%02x".format(it) }.take(16)}...)"
}
}
@@ -0,0 +1,193 @@
package com.bitchat.android.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Noise encrypted payload types and handling - 100% compatible with iOS SimplifiedBluetoothService
*
* This handles all encrypted content that goes through noiseEncrypted packets:
* - Private messages with TLV encoding
* - Delivery acknowledgments
* - Read receipts
* - Future encrypted payload types
*/
/**
* Types of payloads embedded within noiseEncrypted messages
* Matches iOS NoisePayloadType exactly
*/
enum class NoisePayloadType(val value: UByte) {
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
READ_RECEIPT(0x02u), // Message was read
DELIVERED(0x03u); // Message was delivered
companion object {
fun fromValue(value: UByte): NoisePayloadType? {
return values().find { it.value == value }
}
}
}
/**
* Helper class for creating and parsing Noise payloads
* Matches iOS NoisePayload helper exactly
*/
@Parcelize
data class NoisePayload(
val type: NoisePayloadType,
val data: ByteArray
) : Parcelable {
/**
* Encode payload with type prefix - exactly like iOS
* Format: [type_byte][payload_data]
*/
fun encode(): ByteArray {
val result = ByteArray(1 + data.size)
result[0] = type.value.toByte()
data.copyInto(result, destinationOffset = 1)
return result
}
companion object {
/**
* Decode payload from data - exactly like iOS
* Expects: [type_byte][payload_data]
*/
fun decode(data: ByteArray): NoisePayload? {
if (data.isEmpty()) return null
val typeValue = data[0].toUByte()
val type = NoisePayloadType.fromValue(typeValue) ?: return null
// Extract payload data (remaining bytes after type byte)
val payloadData = if (data.size > 1) {
data.copyOfRange(1, data.size)
} else {
ByteArray(0)
}
return NoisePayload(type, payloadData)
}
}
// Override equals and hashCode since we use ByteArray
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as NoisePayload
if (type != other.type) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = type.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
/**
* Private message packet with TLV encoding - matches iOS PrivateMessagePacket exactly
*/
@Parcelize
data class PrivateMessagePacket(
val messageID: String,
val content: String
) : Parcelable {
/**
* TLV types matching iOS implementation exactly
*/
private enum class TLVType(val value: UByte) {
MESSAGE_ID(0x00u),
CONTENT(0x01u);
companion object {
fun fromValue(value: UByte): TLVType? {
return values().find { it.value == value }
}
}
}
/**
* Encode to TLV binary data - exactly like iOS
* Format: [type][length][value] for each field
*/
fun encode(): ByteArray? {
val messageIDData = messageID.toByteArray(Charsets.UTF_8)
val contentData = content.toByteArray(Charsets.UTF_8)
// Check size limits (TLV length field is 1 byte = max 255)
if (messageIDData.size > 255 || contentData.size > 255) {
return null
}
val result = mutableListOf<Byte>()
// TLV for messageID
result.add(TLVType.MESSAGE_ID.value.toByte())
result.add(messageIDData.size.toByte())
result.addAll(messageIDData.toList())
// TLV for content
result.add(TLVType.CONTENT.value.toByte())
result.add(contentData.size.toByte())
result.addAll(contentData.toList())
return result.toByteArray()
}
companion object {
/**
* Decode from TLV binary data - exactly like iOS
*/
fun decode(data: ByteArray): PrivateMessagePacket? {
var offset = 0
var messageID: String? = null
var content: String? = null
while (offset + 2 <= data.size) {
// Read TLV type
val typeValue = data[offset].toUByte()
val type = TLVType.fromValue(typeValue) ?: return null
offset += 1
// Read TLV length
val length = data[offset].toUByte().toInt()
offset += 1
// Check bounds
if (offset + length > data.size) return null
// Read TLV value
val value = data.copyOfRange(offset, offset + length)
offset += length
when (type) {
TLVType.MESSAGE_ID -> {
messageID = String(value, Charsets.UTF_8)
}
TLVType.CONTENT -> {
content = String(value, Charsets.UTF_8)
}
}
}
return if (messageID != null && content != null) {
PrivateMessagePacket(messageID, content)
} else {
null
}
}
}
override fun toString(): String {
return "PrivateMessagePacket(messageID='$messageID', content='${content.take(50)}${if (content.length > 50) "..." else ""}')"
}
}
@@ -1,199 +0,0 @@
package com.bitchat.android.model
import android.util.Log
import com.bitchat.android.util.*
import java.security.MessageDigest
import java.util.*
/**
* Noise Identity Announcement data class (compatible with iOS version)
* Enhanced identity announcement with rotation support and binary protocol
*/
data class NoiseIdentityAnnouncement(
val peerID: String, // Current ephemeral peer ID
val publicKey: ByteArray, // Noise static public key
val signingPublicKey: ByteArray, // Ed25519 signing public key
val nickname: String, // Current nickname
val timestamp: Date, // When this binding was created
val previousPeerID: String?, // Previous peer ID (for smooth transition)
val signature: ByteArray // Signature proving ownership
) {
// Computed fingerprint from public key
val fingerprint: String by lazy {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(publicKey)
hash.joinToString("") { "%02x".format(it) }
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as NoiseIdentityAnnouncement
if (peerID != other.peerID) return false
if (nickname != other.nickname) return false
if (!publicKey.contentEquals(other.publicKey)) return false
if (!signingPublicKey.contentEquals(other.signingPublicKey)) return false
if (timestamp != other.timestamp) return false
if (!signature.contentEquals(other.signature)) return false
if (previousPeerID != other.previousPeerID) return false
return true
}
override fun hashCode(): Int {
var result = peerID.hashCode()
result = 31 * result + nickname.hashCode()
result = 31 * result + publicKey.contentHashCode()
result = 31 * result + signingPublicKey.contentHashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + signature.contentHashCode()
result = 31 * result + (previousPeerID?.hashCode() ?: 0)
return result
}
// MARK: - Binary Encoding
fun toBinaryData(): ByteArray {
val builder = BinaryDataBuilder()
// Flags byte: bit 0 = hasPreviousPeerID
var flags: UByte = 0u
if (previousPeerID != null) flags = flags or 0x01u
builder.appendUInt8(flags)
// PeerID as 8-byte hex string
val peerData = hexStringToByteArray(peerID)
// Directly append the 8 bytes without length prefix since this is a fixed field
for (byte in peerData) {
builder.buffer.add(byte)
}
builder.appendData(publicKey)
builder.appendData(signingPublicKey)
builder.appendString(nickname)
builder.appendDate(timestamp)
if (previousPeerID != null) {
// Previous PeerID as 8-byte hex string
val prevData = hexStringToByteArray(previousPeerID)
// Directly append the 8 bytes without length prefix since this is a fixed field
for (byte in prevData) {
builder.buffer.add(byte)
}
}
builder.appendData(signature)
return builder.toByteArray()
}
companion object {
private const val TAG = "NoiseIdentityAnnouncement"
/**
* Parse Noise identity announcement from binary payload with proper iOS compatibility
*/
fun fromBinaryData(data: ByteArray): NoiseIdentityAnnouncement? {
return try {
// Create defensive copy
val dataCopy = data.copyOf()
// Minimum size check: flags(1) + peerID(8) + min data lengths
if (dataCopy.size < 20) {
Log.w(TAG, "Data too small for NoiseIdentityAnnouncement: ${dataCopy.size} bytes")
return null
}
val offsetArray = intArrayOf(0)
val flags = dataCopy.readUInt8(offsetArray) ?: run {
Log.w(TAG, "Failed to read flags")
return null
}
val hasPreviousPeerID = (flags.toInt() and 0x01) != 0
// Read peerID using safe method
val peerIDBytes = dataCopy.readFixedBytes(offsetArray, 8) ?: run {
Log.w(TAG, "Failed to read peerID bytes")
return null
}
val peerID = peerIDBytes.hexEncodedString()
val publicKey = dataCopy.readData(offsetArray) ?: run {
Log.w(TAG, "Failed to read public key")
return null
}
val signingPublicKey = dataCopy.readData(offsetArray) ?: run {
Log.w(TAG, "Failed to read signing public key")
return null
}
val nickname = dataCopy.readString(offsetArray) ?: run {
Log.w(TAG, "Failed to read nickname")
return null
}
val timestamp = dataCopy.readDate(offsetArray) ?: run {
Log.w(TAG, "Failed to read timestamp")
return null
}
var previousPeerID: String? = null
if (hasPreviousPeerID) {
// Read previousPeerID using safe method
val prevIDBytes = dataCopy.readFixedBytes(offsetArray, 8) ?: run {
Log.w(TAG, "Failed to read previousPeerID bytes")
return null
}
previousPeerID = prevIDBytes.hexEncodedString()
}
val signature = dataCopy.readData(offsetArray) ?: run {
Log.w(TAG, "Failed to read signature")
return null
}
Log.d(TAG, "Successfully parsed NoiseIdentityAnnouncement: peerID=$peerID, nickname=$nickname")
return NoiseIdentityAnnouncement(
peerID = peerID,
publicKey = publicKey,
signingPublicKey = signingPublicKey,
nickname = nickname,
timestamp = timestamp,
previousPeerID = previousPeerID,
signature = signature
)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse Noise identity announcement: ${e.message}", e)
null
}
}
/**
* Convert hex string peer ID to binary data (8 bytes) - exactly same as iOS
*/
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
}
}
}
@@ -1,99 +0,0 @@
package com.bitchat.android.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import com.bitchat.android.util.*
import java.util.*
/**
* Read receipt structure - exact same as iOS version
* Uses binary encoding for efficient protocol communication
*/
@Parcelize
data class ReadReceipt(
val originalMessageID: String,
val receiptID: String = UUID.randomUUID().toString(),
val readerID: String,
val readerNickname: String,
val timestamp: Date = Date()
) : Parcelable {
// Primary constructor for creating new read receipts
constructor(originalMessageID: String, readerID: String, readerNickname: String) : this(
originalMessageID = originalMessageID,
receiptID = UUID.randomUUID().toString(),
readerID = readerID,
readerNickname = readerNickname,
timestamp = Date()
)
/**
* Encode to binary data matching iOS toBinaryData implementation
*/
fun encode(): ByteArray {
val builder = BinaryDataBuilder()
// Append original message UUID
builder.appendUUID(originalMessageID)
// Append receipt ID UUID
builder.appendUUID(receiptID)
// Append reader ID as 8-byte hex string
val readerData = ByteArray(8) { 0 }
var tempID = readerID
var index = 0
while (tempID.length >= 2 && index < 8) {
val hexByte = tempID.substring(0, 2)
val byte = hexByte.toIntOrNull(16)?.toByte()
if (byte != null) {
readerData[index] = byte
}
tempID = tempID.substring(2)
index++
}
builder.buffer.addAll(readerData.toList())
// Append timestamp
builder.appendDate(timestamp)
// Append reader nickname as string
builder.appendString(readerNickname)
return builder.toByteArray()
}
companion object {
/**
* Decode from binary data matching iOS fromBinaryData implementation
*/
fun decode(data: ByteArray): ReadReceipt? {
// Create defensive copy
val dataCopy = data.copyOf()
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
if (dataCopy.size < 49) return null
val offset = intArrayOf(0)
val originalMessageID = dataCopy.readUUID(offset) ?: return null
val receiptID = dataCopy.readUUID(offset) ?: return null
val readerIDData = dataCopy.readFixedBytes(offset, 8) ?: return null
val readerID = readerIDData.hexEncodedString()
val timestamp = dataCopy.readDate(offset) ?: return null
val readerNickname = dataCopy.readString(offset) ?: return null
return ReadReceipt(
originalMessageID = originalMessageID,
receiptID = receiptID,
readerID = readerID,
readerNickname = readerNickname,
timestamp = timestamp
)
}
}
}
@@ -11,32 +11,11 @@ import android.util.Log
*/ */
enum class MessageType(val value: UByte) { enum class MessageType(val value: UByte) {
ANNOUNCE(0x01u), ANNOUNCE(0x01u),
// 0x02 was legacy keyExchange - removed MESSAGE(0x02u), // All user messages (private and broadcast)
LEAVE(0x03u), LEAVE(0x03u),
MESSAGE(0x04u), // All user messages (private and broadcast) NOISE_HANDSHAKE(0x10u), // Noise handshake
FRAGMENT_START(0x05u), NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
FRAGMENT_CONTINUE(0x06u), FRAGMENT(0x20u); // Fragmentation for large packets
FRAGMENT_END(0x07u),
CHANNEL_ANNOUNCE(0x08u), // Announce password-protected channel status
CHANNEL_RETENTION(0x09u), // Announce channel retention status
DELIVERY_ACK(0x0Au), // Acknowledge message received
DELIVERY_STATUS_REQUEST(0x0Bu), // Request delivery status update
READ_RECEIPT(0x0Cu), // Message has been read/viewed
// Noise Protocol messages - exact same as iOS
NOISE_HANDSHAKE_INIT(0x10u), // Noise handshake initiation
NOISE_HANDSHAKE_RESP(0x11u), // Noise handshake response
NOISE_ENCRYPTED(0x12u), // Noise encrypted transport message
NOISE_IDENTITY_ANNOUNCE(0x13u), // Announce static public key for discovery
CHANNEL_KEY_VERIFY_REQUEST(0x14u), // Request key verification for a channel
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
VERSION_ACK(0x21u); // Version acknowledgment
companion object { companion object {
fun fromValue(value: UByte): MessageType? { fun fromValue(value: UByte): MessageType? {
@@ -351,6 +330,7 @@ object BinaryProtocol {
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
return null return null
} }
} }
@@ -1,12 +1,13 @@
package com.bitchat.android.protocol package com.bitchat.android.protocol
import android.util.Log
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.util.zip.Deflater import java.util.zip.Deflater
import java.util.zip.Inflater import java.util.zip.Inflater
/** /**
* Compression utilities - LZ4-like functionality using Deflater/Inflater * Compression utilities - 100% iOS-compatible zlib implementation
* Android doesn't have native LZ4, so we use Java's built-in compression * Uses the same zlib algorithm as iOS CompressionUtil.swift
*/ */
object CompressionUtil { object CompressionUtil {
private const val COMPRESSION_THRESHOLD = 100 // bytes - same as iOS private const val COMPRESSION_THRESHOLD = 100 // bytes - same as iOS
@@ -15,14 +16,12 @@ object CompressionUtil {
* Helper to check if compression is worth it - exact same logic as iOS * Helper to check if compression is worth it - exact same logic as iOS
*/ */
fun shouldCompress(data: ByteArray): Boolean { fun shouldCompress(data: ByteArray): Boolean {
// TODO: COMPRESSION DOESN'T WORK WITH IOS YET
return false
// Don't compress if: // Don't compress if:
// 1. Data is too small // 1. Data is too small
// 2. Data appears to be already compressed (high entropy) // 2. Data appears to be already compressed (high entropy)
if (data.size < COMPRESSION_THRESHOLD) return false if (data.size < COMPRESSION_THRESHOLD) return false
// Simple entropy check - count unique bytes // Simple entropy check - count unique bytes (exact same as iOS)
val byteFrequency = mutableMapOf<Byte, Int>() val byteFrequency = mutableMapOf<Byte, Int>()
for (byte in data) { for (byte in data) {
byteFrequency[byte] = (byteFrequency[byte] ?: 0) + 1 byteFrequency[byte] = (byteFrequency[byte] ?: 0) + 1
@@ -34,55 +33,142 @@ object CompressionUtil {
} }
/** /**
* Compress data using Deflater (closest to LZ4 available on Android) * Compress data using deflate algorithm - exact same as iOS
* iOS COMPRESSION_ZLIB actually produces raw deflate data (no zlib headers)
*/ */
fun compress(data: ByteArray): ByteArray? { fun compress(data: ByteArray): ByteArray? {
// Skip compression for small data // Skip compression for small data
if (data.size < COMPRESSION_THRESHOLD) return null if (data.size < COMPRESSION_THRESHOLD) return null
try { try {
val deflater = Deflater(Deflater.BEST_SPEED) // Fast compression like LZ4 // Use raw deflate format (no headers) to match iOS COMPRESSION_ZLIB behavior
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true) // true = raw deflate, no headers
deflater.setInput(data) deflater.setInput(data)
deflater.finish() deflater.finish()
val buffer = ByteArray(data.size + 16) // Some overhead space val outputStream = ByteArrayOutputStream(data.size)
val compressedSize = deflater.deflate(buffer) val buffer = ByteArray(1024)
while (!deflater.finished()) {
val count = deflater.deflate(buffer)
outputStream.write(buffer, 0, count)
}
deflater.end() deflater.end()
// Only return if compression was beneficial val compressedData = outputStream.toByteArray()
if (compressedSize > 0 && compressedSize < data.size) {
return buffer.copyOfRange(0, compressedSize)
}
return null // Only return if compression was beneficial (same logic as iOS)
return if (compressedData.size > 0 && compressedData.size < data.size) {
compressedData
} else {
null
}
} catch (e: Exception) { } catch (e: Exception) {
return null return null
} }
} }
/** /**
* Decompress data using Inflater * Decompress deflate compressed data - exact same as iOS
* iOS COMPRESSION_ZLIB produces raw deflate data (no headers)
*/ */
fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? { fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? {
// iOS COMPRESSION_ZLIB produces raw deflate format (no headers)
try { try {
val inflater = Inflater() val inflater = Inflater(true) // true = raw deflate, no headers
inflater.setInput(compressedData) inflater.setInput(compressedData)
val result = ByteArray(originalSize) val decompressedBuffer = ByteArray(originalSize)
val decompressedSize = inflater.inflate(result) val actualSize = inflater.inflate(decompressedBuffer)
inflater.end() inflater.end()
if (decompressedSize > 0) { // Verify decompressed size matches expected (same validation as iOS)
return if (decompressedSize == originalSize) { return if (actualSize == originalSize) {
result decompressedBuffer
} else if (actualSize > 0) {
// Handle case where actual size is different
decompressedBuffer.copyOfRange(0, actualSize)
} else {
null
}
} catch (e: Exception) {
Log.d("CompressionUtil", "Raw deflate decompression failed: ${e.message}, trying with zlib headers...")
// Fallback: try with zlib headers in case of mixed usage
try {
val inflater = Inflater(false) // false = expect zlib headers
inflater.setInput(compressedData)
val decompressedBuffer = ByteArray(originalSize)
val actualSize = inflater.inflate(decompressedBuffer)
inflater.end()
return if (actualSize == originalSize) {
decompressedBuffer
} else if (actualSize > 0) {
decompressedBuffer.copyOfRange(0, actualSize)
} else { } else {
result.copyOfRange(0, decompressedSize) null
} }
} catch (fallbackException: Exception) {
Log.e("CompressionUtil", "Both raw deflate and zlib decompression failed: ${fallbackException.message}")
return null
}
}
}
/**
* Test function to verify deflate compression works correctly
* This can be called during app initialization to ensure compatibility
*/
fun testCompression(): Boolean {
try {
// Create test data that should compress well (repeating pattern like iOS would use)
val testMessage = "This is a test message that should compress well. ".repeat(10)
val originalData = testMessage.toByteArray()
Log.d("CompressionUtil", "Testing deflate compression with ${originalData.size} bytes")
// Test shouldCompress
val shouldCompress = shouldCompress(originalData)
Log.d("CompressionUtil", "shouldCompress() returned: $shouldCompress")
if (!shouldCompress) {
Log.e("CompressionUtil", "shouldCompress failed for test data")
return false
} }
return null // Test compression
val compressed = compress(originalData)
if (compressed == null) {
Log.e("CompressionUtil", "Compression failed")
return false
}
Log.d("CompressionUtil", "Compressed ${originalData.size} bytes to ${compressed.size} bytes (${(compressed.size.toDouble() / originalData.size * 100).toInt()}%)")
// Test decompression
val decompressed = decompress(compressed, originalData.size)
if (decompressed == null) {
Log.e("CompressionUtil", "Decompression failed")
return false
}
// Verify data integrity
val isIdentical = originalData.contentEquals(decompressed)
Log.d("CompressionUtil", "Data integrity check: $isIdentical")
if (!isIdentical) {
Log.e("CompressionUtil", "Decompressed data doesn't match original")
return false
}
Log.i("CompressionUtil", "✅ deflate compression test PASSED - ready for iOS compatibility")
return true
} catch (e: Exception) { } catch (e: Exception) {
return null Log.e("CompressionUtil", "deflate compression test failed: ${e.message}")
return false
} }
} }
} }
@@ -1,219 +1,218 @@
package com.bitchat.android.services //package com.bitchat.android.services
//
import android.content.Context //import android.content.Context
import android.content.SharedPreferences //import android.content.SharedPreferences
import android.util.Log //import android.util.Log
import com.bitchat.android.model.BitchatMessage //import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers //import kotlinx.coroutines.withContext
import kotlinx.coroutines.withContext //import java.io.File
import java.io.File //import java.io.FileInputStream
import java.io.FileInputStream //import java.io.FileOutputStream
import java.io.FileOutputStream //import java.io.ObjectInputStream
import java.io.ObjectInputStream //import java.io.ObjectOutputStream
import java.io.ObjectOutputStream //import java.util.*
import java.util.* //
///**
/** // * Message retention service for saving channel messages locally
* Message retention service for saving channel messages locally // * Matches iOS MessageRetentionService functionality
* Matches iOS MessageRetentionService functionality // */
*/ //class MessageRetentionService private constructor(private val context: Context) {
class MessageRetentionService private constructor(private val context: Context) { //
// companion object {
companion object { // private const val TAG = "MessageRetentionService"
private const val TAG = "MessageRetentionService" // private const val PREF_NAME = "message_retention"
private const val PREF_NAME = "message_retention" // private const val KEY_FAVORITE_CHANNELS = "favorite_channels"
private const val KEY_FAVORITE_CHANNELS = "favorite_channels" //
// @Volatile
@Volatile // private var INSTANCE: MessageRetentionService? = null
private var INSTANCE: MessageRetentionService? = null //
// fun getInstance(context: Context): MessageRetentionService {
fun getInstance(context: Context): MessageRetentionService { // return INSTANCE ?: synchronized(this) {
return INSTANCE ?: synchronized(this) { // INSTANCE ?: MessageRetentionService(context.applicationContext).also { INSTANCE = it }
INSTANCE ?: MessageRetentionService(context.applicationContext).also { INSTANCE = it } // }
} // }
} // }
} //
// private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) // private val retentionDir = File(context.filesDir, "retained_messages")
private val retentionDir = File(context.filesDir, "retained_messages") //
// init {
init { // if (!retentionDir.exists()) {
if (!retentionDir.exists()) { // retentionDir.mkdirs()
retentionDir.mkdirs() // }
} // }
} //
// // MARK: - Channel Bookmarking (Favorites)
// MARK: - Channel Bookmarking (Favorites) //
// fun getFavoriteChannels(): Set<String> {
fun getFavoriteChannels(): Set<String> { // return prefs.getStringSet(KEY_FAVORITE_CHANNELS, emptySet()) ?: emptySet()
return prefs.getStringSet(KEY_FAVORITE_CHANNELS, emptySet()) ?: emptySet() // }
} //
// fun toggleFavoriteChannel(channel: String): Boolean {
fun toggleFavoriteChannel(channel: String): Boolean { // val currentFavorites = getFavoriteChannels().toMutableSet()
val currentFavorites = getFavoriteChannels().toMutableSet() // val wasAdded = if (currentFavorites.contains(channel)) {
val wasAdded = if (currentFavorites.contains(channel)) { // currentFavorites.remove(channel)
currentFavorites.remove(channel) // false
false // } else {
} else { // currentFavorites.add(channel)
currentFavorites.add(channel) // true
true // }
} //
// prefs.edit().putStringSet(KEY_FAVORITE_CHANNELS, currentFavorites).apply()
prefs.edit().putStringSet(KEY_FAVORITE_CHANNELS, currentFavorites).apply() //
// if (!wasAdded) {
if (!wasAdded) { // // Channel removed from favorites - delete saved messages in background
// Channel removed from favorites - delete saved messages in background // Thread {
Thread { // try {
try { // val channelFile = getChannelFile(channel)
val channelFile = getChannelFile(channel) // if (channelFile.exists()) {
if (channelFile.exists()) { // channelFile.delete()
channelFile.delete() // Log.d(TAG, "Deleted saved messages for channel $channel")
Log.d(TAG, "Deleted saved messages for channel $channel") // }
} // } catch (e: Exception) {
} catch (e: Exception) { // Log.e(TAG, "Failed to delete messages for channel $channel", e)
Log.e(TAG, "Failed to delete messages for channel $channel", e) // }
} // }.start()
}.start() // }
} //
// Log.d(TAG, "Channel $channel ${if (wasAdded) "bookmarked" else "unbookmarked"}")
Log.d(TAG, "Channel $channel ${if (wasAdded) "bookmarked" else "unbookmarked"}") // return wasAdded
return wasAdded // }
} //
// fun isChannelBookmarked(channel: String): Boolean {
fun isChannelBookmarked(channel: String): Boolean { // return getFavoriteChannels().contains(channel)
return getFavoriteChannels().contains(channel) // }
} //
// // MARK: - Message Storage
// MARK: - Message Storage //
// suspend fun saveMessage(message: BitchatMessage, forChannel: String) = withContext(Dispatchers.IO) {
suspend fun saveMessage(message: BitchatMessage, forChannel: String) = withContext(Dispatchers.IO) { // if (!isChannelBookmarked(forChannel)) {
if (!isChannelBookmarked(forChannel)) { // Log.w(TAG, "Attempted to save message for non-bookmarked channel: $forChannel")
Log.w(TAG, "Attempted to save message for non-bookmarked channel: $forChannel") // return@withContext
return@withContext // }
} //
// try {
try { // val channelFile = getChannelFile(forChannel)
val channelFile = getChannelFile(forChannel) // val existingMessages = loadMessagesFromFile(channelFile).toMutableList()
val existingMessages = loadMessagesFromFile(channelFile).toMutableList() //
// // Check if message already exists (by ID)
// Check if message already exists (by ID) // if (existingMessages.any { it.id == message.id }) {
if (existingMessages.any { it.id == message.id }) { // Log.d(TAG, "Message ${message.id} already saved for channel $forChannel")
Log.d(TAG, "Message ${message.id} already saved for channel $forChannel") // return@withContext
return@withContext // }
} //
// // Add new message
// Add new message // existingMessages.add(message)
existingMessages.add(message) //
// // Sort by timestamp
// Sort by timestamp // existingMessages.sortBy { it.timestamp }
existingMessages.sortBy { it.timestamp } //
// // Save back to file
// Save back to file // saveMessagesToFile(channelFile, existingMessages)
saveMessagesToFile(channelFile, existingMessages) //
// Log.d(TAG, "Saved message ${message.id} for channel $forChannel")
Log.d(TAG, "Saved message ${message.id} for channel $forChannel") //
// } catch (e: Exception) {
} catch (e: Exception) { // Log.e(TAG, "Failed to save message for channel $forChannel", e)
Log.e(TAG, "Failed to save message for channel $forChannel", e) // }
} // }
} //
// suspend fun loadMessagesForChannel(channel: String): List<BitchatMessage> = withContext(Dispatchers.IO) {
suspend fun loadMessagesForChannel(channel: String): List<BitchatMessage> = withContext(Dispatchers.IO) { // if (!isChannelBookmarked(channel)) {
if (!isChannelBookmarked(channel)) { // Log.d(TAG, "Channel $channel not bookmarked, returning empty list")
Log.d(TAG, "Channel $channel not bookmarked, returning empty list") // return@withContext emptyList()
return@withContext emptyList() // }
} //
// try {
try { // val channelFile = getChannelFile(channel)
val channelFile = getChannelFile(channel) // val messages = loadMessagesFromFile(channelFile)
val messages = loadMessagesFromFile(channelFile) // Log.d(TAG, "Loaded ${messages.size} messages for channel $channel")
Log.d(TAG, "Loaded ${messages.size} messages for channel $channel") // return@withContext messages
return@withContext messages // } catch (e: Exception) {
} catch (e: Exception) { // Log.e(TAG, "Failed to load messages for channel $channel", e)
Log.e(TAG, "Failed to load messages for channel $channel", e) // return@withContext emptyList()
return@withContext emptyList() // }
} // }
} //
// suspend fun deleteMessagesForChannel(channel: String): Unit = withContext(Dispatchers.IO) {
suspend fun deleteMessagesForChannel(channel: String): Unit = withContext(Dispatchers.IO) { // try {
try { // val channelFile = getChannelFile(channel)
val channelFile = getChannelFile(channel) // if (channelFile.exists()) {
if (channelFile.exists()) { // channelFile.delete()
channelFile.delete() // Log.d(TAG, "Deleted saved messages for channel $channel")
Log.d(TAG, "Deleted saved messages for channel $channel") // }
} // } catch (e: Exception) {
} catch (e: Exception) { // Log.e(TAG, "Failed to delete messages for channel $channel", e)
Log.e(TAG, "Failed to delete messages for channel $channel", e) // }
} // }
} //
// suspend fun deleteAllStoredMessages(): Unit = withContext(Dispatchers.IO) {
suspend fun deleteAllStoredMessages(): Unit = withContext(Dispatchers.IO) { // try {
try { // if (retentionDir.exists()) {
if (retentionDir.exists()) { // retentionDir.listFiles()?.forEach { file ->
retentionDir.listFiles()?.forEach { file -> // file.delete()
file.delete() // }
} // Log.d(TAG, "Deleted all stored messages")
Log.d(TAG, "Deleted all stored messages") // }
} // } catch (e: Exception) {
} catch (e: Exception) { // Log.e(TAG, "Failed to delete all stored messages", e)
Log.e(TAG, "Failed to delete all stored messages", e) // }
} // }
} //
// // MARK: - File Operations
// MARK: - File Operations //
// private fun getChannelFile(channel: String): File {
private fun getChannelFile(channel: String): File { // // Sanitize channel name for filename
// Sanitize channel name for filename // val sanitizedChannel = channel.replace("[^a-zA-Z0-9_-]".toRegex(), "_")
val sanitizedChannel = channel.replace("[^a-zA-Z0-9_-]".toRegex(), "_") // return File(retentionDir, "channel_${sanitizedChannel}.dat")
return File(retentionDir, "channel_${sanitizedChannel}.dat") // }
} //
// private fun loadMessagesFromFile(file: File): List<BitchatMessage> {
private fun loadMessagesFromFile(file: File): List<BitchatMessage> { // if (!file.exists()) {
if (!file.exists()) { // return emptyList()
return emptyList() // }
} //
// return try {
return try { // FileInputStream(file).use { fis ->
FileInputStream(file).use { fis -> // ObjectInputStream(fis).use { ois ->
ObjectInputStream(fis).use { ois -> // @Suppress("UNCHECKED_CAST")
@Suppress("UNCHECKED_CAST") // ois.readObject() as List<BitchatMessage>
ois.readObject() as List<BitchatMessage> // }
} // }
} // } catch (e: Exception) {
} catch (e: Exception) { // Log.w(TAG, "Failed to load messages from ${file.name}, returning empty list", e)
Log.w(TAG, "Failed to load messages from ${file.name}, returning empty list", e) // emptyList()
emptyList() // }
} // }
} //
// private fun saveMessagesToFile(file: File, messages: List<BitchatMessage>) {
private fun saveMessagesToFile(file: File, messages: List<BitchatMessage>) { // FileOutputStream(file).use { fos ->
FileOutputStream(file).use { fos -> // ObjectOutputStream(fos).use { oos ->
ObjectOutputStream(fos).use { oos -> // oos.writeObject(messages)
oos.writeObject(messages) // }
} // }
} // }
} //
// // MARK: - Statistics
// MARK: - Statistics //
// fun getBookmarkedChannelsCount(): Int {
fun getBookmarkedChannelsCount(): Int { // return getFavoriteChannels().size
return getFavoriteChannels().size // }
} //
// suspend fun getTotalStoredMessagesCount(): Int = withContext(Dispatchers.IO) {
suspend fun getTotalStoredMessagesCount(): Int = withContext(Dispatchers.IO) { // var totalCount = 0
var totalCount = 0 //
// try {
try { // retentionDir.listFiles()?.forEach { file ->
retentionDir.listFiles()?.forEach { file -> // if (file.name.startsWith("channel_") && file.name.endsWith(".dat")) {
if (file.name.startsWith("channel_") && file.name.endsWith(".dat")) { // val messages = loadMessagesFromFile(file)
val messages = loadMessagesFromFile(file) // totalCount += messages.size
totalCount += messages.size // }
} // }
} // } catch (e: Exception) {
} catch (e: Exception) { // Log.e(TAG, "Failed to count stored messages", e)
Log.e(TAG, "Failed to count stored messages", e) // }
} //
// totalCount
totalCount // }
} //}
}
@@ -119,20 +119,7 @@ class ChannelManager(
// MARK: - Channel Password and Encryption // MARK: - Channel Password and Encryption
private fun verifyChannelPassword(channel: String, password: String): Boolean { private fun verifyChannelPassword(channel: String, password: String): Boolean {
val key = deriveChannelKey(password, channel) // TODO: REMOVE THIS - FOR TESTING ONLY
// Verify against existing messages if available
val existingMessages = state.getChannelMessagesValue()[channel]?.filter { it.isEncrypted }
if (!existingMessages.isNullOrEmpty()) {
val testMessage = existingMessages.first()
val decryptedContent = decryptChannelMessage(testMessage.encryptedContent ?: byteArrayOf(), channel, key)
if (decryptedContent == null) {
return false
}
}
channelKeys[channel] = key
channelPasswords[channel] = password
return true return true
} }
@@ -183,48 +170,8 @@ class ChannelManager(
onEncryptedPayload: (ByteArray) -> Unit, onEncryptedPayload: (ByteArray) -> Unit,
onFallback: () -> Unit onFallback: () -> Unit
) { ) {
val key = channelKeys[channel] // TODO: REIMPLEMENT REMOVED FOR NOW
if (key == null) { return
onFallback()
return
}
coroutineScope.launch {
try {
val contentBytes = content.toByteArray(Charsets.UTF_8)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv
val encryptedData = cipher.doFinal(contentBytes)
// Combine IV and encrypted data
val combined = ByteArray(iv.size + encryptedData.size)
System.arraycopy(iv, 0, combined, 0, iv.size)
System.arraycopy(encryptedData, 0, combined, iv.size, encryptedData.size)
val encryptedMessage = BitchatMessage(
sender = senderNickname ?: myPeerID,
content = "",
timestamp = Date(),
isRelay = false,
senderPeerID = myPeerID,
mentions = if (mentions.isNotEmpty()) mentions else null,
channel = channel,
encryptedContent = combined,
isEncrypted = true
)
// Send encrypted message via mesh
encryptedMessage.toBinaryPayload()?.let { messageData ->
onEncryptedPayload(messageData)
} ?: onFallback()
} catch (e: Exception) {
// Fallback to unencrypted
onFallback()
}
}
} }
// MARK: - Channel Management // MARK: - Channel Management
@@ -9,8 +9,7 @@ import androidx.lifecycle.viewModelScope
import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryAck import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.model.ReadReceipt
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.util.* import java.util.*
@@ -41,8 +40,6 @@ class ChatViewModel(
private val noiseSessionDelegate = object : NoiseSessionDelegate { private val noiseSessionDelegate = object : NoiseSessionDelegate {
override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID) override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID)
override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID) override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID)
override fun broadcastNoiseIdentityAnnouncement() = meshService.broadcastNoiseIdentityAnnouncement()
override fun sendHandshakeRequest(targetPeerID: String, pendingCount: UByte) = meshService.sendHandshakeRequest(targetPeerID, pendingCount)
override fun getMyPeerID(): String = meshService.myPeerID override fun getMyPeerID(): String = meshService.myPeerID
} }
@@ -384,12 +381,12 @@ class ChatViewModel(
meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer)
} }
override fun didReceiveDeliveryAck(ack: DeliveryAck) { override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
meshDelegateHandler.didReceiveDeliveryAck(ack) meshDelegateHandler.didReceiveDeliveryAck(messageID, recipientPeerID)
} }
override fun didReceiveReadReceipt(receipt: ReadReceipt) { override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
meshDelegateHandler.didReceiveReadReceipt(receipt) meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID)
} }
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
@@ -1,5 +1,6 @@
package com.bitchat.android.ui package com.bitchat.android.ui
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import java.util.* import java.util.*
@@ -28,7 +29,7 @@ class CommandProcessor(
// MARK: - Command Processing // MARK: - Command Processing
fun processCommand(command: String, meshService: Any, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit): Boolean { fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit): Boolean {
if (!command.startsWith("/")) return false if (!command.startsWith("/")) return false
val parts = command.split(" ") val parts = command.split(" ")
@@ -77,7 +78,7 @@ class CommandProcessor(
} }
} }
private fun handleMessageCommand(parts: List<String>, meshService: Any) { private fun handleMessageCommand(parts: List<String>, meshService: BluetoothMeshService) {
if (parts.size > 1) { if (parts.size > 1) {
val targetName = parts[1].removePrefix("@") val targetName = parts[1].removePrefix("@")
val peerID = getPeerIDForNickname(targetName, meshService) val peerID = getPeerIDForNickname(targetName, meshService)
@@ -129,7 +130,7 @@ class CommandProcessor(
} }
} }
private fun handleWhoCommand(meshService: Any) { private fun handleWhoCommand(meshService: BluetoothMeshService) {
val connectedPeers = state.getConnectedPeersValue() val connectedPeers = state.getConnectedPeersValue()
val peerList = connectedPeers.joinToString(", ") { peerID -> val peerList = connectedPeers.joinToString(", ") { peerID ->
// Convert peerID to nickname using the mesh service // Convert peerID to nickname using the mesh service
@@ -214,7 +215,7 @@ class CommandProcessor(
} }
} }
private fun handleBlockCommand(parts: List<String>, meshService: Any) { private fun handleBlockCommand(parts: List<String>, meshService: BluetoothMeshService) {
if (parts.size > 1) { if (parts.size > 1) {
val targetName = parts[1].removePrefix("@") val targetName = parts[1].removePrefix("@")
privateChatManager.blockPeerByNickname(targetName, meshService) privateChatManager.blockPeerByNickname(targetName, meshService)
@@ -231,7 +232,7 @@ class CommandProcessor(
} }
} }
private fun handleUnblockCommand(parts: List<String>, meshService: Any) { private fun handleUnblockCommand(parts: List<String>, meshService: BluetoothMeshService) {
if (parts.size > 1) { if (parts.size > 1) {
val targetName = parts[1].removePrefix("@") val targetName = parts[1].removePrefix("@")
privateChatManager.unblockPeerByNickname(targetName, meshService) privateChatManager.unblockPeerByNickname(targetName, meshService)
@@ -250,7 +251,7 @@ class CommandProcessor(
parts: List<String>, parts: List<String>,
verb: String, verb: String,
object_: String, object_: String,
meshService: Any, meshService: BluetoothMeshService,
myPeerID: String, myPeerID: String,
onSendMessage: (String, List<String>, String?) -> Unit onSendMessage: (String, List<String>, String?) -> Unit
) { ) {
@@ -382,7 +383,7 @@ class CommandProcessor(
// MARK: - Mention Autocomplete // MARK: - Mention Autocomplete
fun updateMentionSuggestions(input: String, meshService: Any) { fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService) {
// Check if input contains @ and we're at the end of a word or at the end of input // Check if input contains @ and we're at the end of a word or at the end of input
val atIndex = input.lastIndexOf('@') val atIndex = input.lastIndexOf('@')
if (atIndex == -1) { if (atIndex == -1) {
@@ -401,14 +402,8 @@ class CommandProcessor(
return return
} }
// Get all connected peer nicknames // Get all connected peer nicknames - now using direct access instead of reflection
val peerNicknames = try { val peerNicknames = meshService.getPeerNicknames().values.toList()
val method = meshService::class.java.getDeclaredMethod("getPeerNicknames")
val peerNicknamesMap = method.invoke(meshService) as? Map<String, String>
peerNicknamesMap?.values?.toList() ?: emptyList()
} catch (e: Exception) {
emptyList()
}
// Filter nicknames based on the text after @ // Filter nicknames based on the text after @
val filteredNicknames = peerNicknames.filter { nickname -> val filteredNicknames = peerNicknames.filter { nickname ->
@@ -439,50 +434,21 @@ class CommandProcessor(
return "$textBeforeAt@$nickname " return "$textBeforeAt@$nickname "
} }
// MARK: - Utility Functions (would access mesh service) // MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: Any): String? { private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
return try { return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
val method = meshService::class.java.getDeclaredMethod("getPeerNicknames")
val peerNicknames = method.invoke(meshService) as? Map<String, String>
peerNicknames?.entries?.find { it.value == nickname }?.key
} catch (e: Exception) {
null
}
} }
private fun getPeerNickname(peerID: String, meshService: Any): String { private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String {
return try { return meshService.getPeerNicknames()[peerID] ?: peerID
val method = meshService::class.java.getDeclaredMethod("getPeerNicknames")
val peerNicknames = method.invoke(meshService) as? Map<String, String>
peerNicknames?.get(peerID) ?: peerID
} catch (e: Exception) {
peerID
}
} }
private fun getMyPeerID(meshService: Any): String { private fun getMyPeerID(meshService: BluetoothMeshService): String {
return try { return meshService.myPeerID
val field = meshService::class.java.getDeclaredField("myPeerID")
field.isAccessible = true
field.get(meshService) as? String ?: "unknown"
} catch (e: Exception) {
"unknown"
}
} }
private fun sendPrivateMessageVia(meshService: Any, content: String, peerID: String, recipientNickname: String, messageId: String) { private fun sendPrivateMessageVia(meshService: BluetoothMeshService, content: String, peerID: String, recipientNickname: String, messageId: String) {
try { meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId)
val method = meshService::class.java.getDeclaredMethod(
"sendPrivateMessage",
String::class.java,
String::class.java,
String::class.java,
String::class.java
)
method.invoke(meshService, content, peerID, recipientNickname, messageId)
} catch (e: Exception) {
// Handle error
}
} }
} }
@@ -2,10 +2,9 @@ package com.bitchat.android.ui
import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleCoroutineScope
import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryAck
import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.model.ReadReceipt
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.*
@@ -22,7 +21,7 @@ class MeshDelegateHandler(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val onHapticFeedback: () -> Unit, private val onHapticFeedback: () -> Unit,
private val getMyPeerID: () -> String, private val getMyPeerID: () -> String,
private val getMeshService: () -> Any private val getMeshService: () -> BluetoothMeshService
) : BluetoothMeshDelegate { ) : BluetoothMeshDelegate {
override fun didReceiveMessage(message: BitchatMessage) { override fun didReceiveMessage(message: BitchatMessage) {
@@ -103,15 +102,15 @@ class MeshDelegateHandler(
} }
} }
override fun didReceiveDeliveryAck(ack: DeliveryAck) { override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
coroutineScope.launch { coroutineScope.launch {
messageManager.updateMessageDeliveryStatus(ack.originalMessageID, DeliveryStatus.Delivered(ack.recipientNickname, ack.timestamp)) messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Delivered(recipientPeerID, Date()))
} }
} }
override fun didReceiveReadReceipt(receipt: ReadReceipt) { override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
coroutineScope.launch { coroutineScope.launch {
messageManager.updateMessageDeliveryStatus(receipt.originalMessageID, DeliveryStatus.Read(receipt.readerNickname, receipt.timestamp)) messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date()))
} }
} }
@@ -3,6 +3,7 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.PeerFingerprintManager import com.bitchat.android.mesh.PeerFingerprintManager
import com.bitchat.android.mesh.BluetoothMeshService
import java.util.* import java.util.*
import android.util.Log import android.util.Log
@@ -13,8 +14,6 @@ import android.util.Log
interface NoiseSessionDelegate { interface NoiseSessionDelegate {
fun hasEstablishedSession(peerID: String): Boolean fun hasEstablishedSession(peerID: String): Boolean
fun initiateHandshake(peerID: String) fun initiateHandshake(peerID: String)
fun broadcastNoiseIdentityAnnouncement()
fun sendHandshakeRequest(targetPeerID: String, pendingCount: UByte)
fun getMyPeerID(): String fun getMyPeerID(): String
} }
@@ -26,22 +25,22 @@ class PrivateChatManager(
private val state: ChatState, private val state: ChatState,
private val messageManager: MessageManager, private val messageManager: MessageManager,
private val dataManager: DataManager, private val dataManager: DataManager,
private val noiseSessionDelegate: NoiseSessionDelegate? = null private val noiseSessionDelegate: NoiseSessionDelegate
) { ) {
companion object { companion object {
private const val TAG = "PrivateChatManager" private const val TAG = "PrivateChatManager"
} }
// Use centralized fingerprint management - NO LOCAL STORAGE // Use centralized fingerprint management - NO LOCAL STORAGE
private val fingerprintManager = PeerFingerprintManager.getInstance() private val fingerprintManager = PeerFingerprintManager.getInstance()
// Track received private messages that need read receipts // Track received private messages that need read receipts
private val unreadReceivedMessages = mutableMapOf<String, MutableList<BitchatMessage>>() private val unreadReceivedMessages = mutableMapOf<String, MutableList<BitchatMessage>>()
// MARK: - Private Chat Lifecycle // MARK: - Private Chat Lifecycle
fun startPrivateChat(peerID: String, meshService: Any): Boolean { fun startPrivateChat(peerID: String, meshService: BluetoothMeshService): Boolean {
if (isPeerBlocked(peerID)) { if (isPeerBlocked(peerID)) {
val peerNickname = getPeerNickname(peerID, meshService) val peerNickname = getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage( val systemMessage = BitchatMessage(
@@ -53,28 +52,28 @@ class PrivateChatManager(
messageManager.addMessage(systemMessage) messageManager.addMessage(systemMessage)
return false return false
} }
// Establish Noise session if needed before starting the chat // Establish Noise session if needed before starting the chat
establishNoiseSessionIfNeeded(peerID, meshService) establishNoiseSessionIfNeeded(peerID, meshService)
state.setSelectedPrivateChatPeer(peerID) state.setSelectedPrivateChatPeer(peerID)
// Clear unread // Clear unread
messageManager.clearPrivateUnreadMessages(peerID) messageManager.clearPrivateUnreadMessages(peerID)
// Initialize chat if needed // Initialize chat if needed
messageManager.initializePrivateChat(peerID) messageManager.initializePrivateChat(peerID)
// Send read receipts for all unread messages from this peer // Send read receipts for all unread messages from this peer
sendReadReceiptsForPeer(peerID, meshService) sendReadReceiptsForPeer(peerID, meshService)
return true return true
} }
fun endPrivateChat() { fun endPrivateChat() {
state.setSelectedPrivateChatPeer(null) state.setSelectedPrivateChatPeer(null)
} }
fun sendPrivateMessage( fun sendPrivateMessage(
content: String, content: String,
peerID: String, peerID: String,
@@ -93,7 +92,7 @@ class PrivateChatManager(
messageManager.addMessage(systemMessage) messageManager.addMessage(systemMessage)
return false return false
} }
val message = BitchatMessage( val message = BitchatMessage(
sender = senderNickname ?: myPeerID, sender = senderNickname ?: myPeerID,
content = content, content = content,
@@ -104,31 +103,31 @@ class PrivateChatManager(
senderPeerID = myPeerID, senderPeerID = myPeerID,
deliveryStatus = DeliveryStatus.Sending deliveryStatus = DeliveryStatus.Sending
) )
messageManager.addPrivateMessage(peerID, message) messageManager.addPrivateMessage(peerID, message)
onSendMessage(content, peerID, recipientNickname ?: "", message.id) onSendMessage(content, peerID, recipientNickname ?: "", message.id)
return true return true
} }
// MARK: - Peer Management // MARK: - Peer Management
fun isPeerBlocked(peerID: String): Boolean { fun isPeerBlocked(peerID: String): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
return fingerprint != null && dataManager.isUserBlocked(fingerprint) return fingerprint != null && dataManager.isUserBlocked(fingerprint)
} }
fun toggleFavorite(peerID: String) { fun toggleFavorite(peerID: String) {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return
Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint") Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint")
val wasFavorite = dataManager.isFavorite(fingerprint) val wasFavorite = dataManager.isFavorite(fingerprint)
Log.d(TAG, "Current favorite status: $wasFavorite") Log.d(TAG, "Current favorite status: $wasFavorite")
val currentFavorites = state.getFavoritePeersValue() val currentFavorites = state.getFavoritePeersValue()
Log.d(TAG, "Current UI state favorites: $currentFavorites") Log.d(TAG, "Current UI state favorites: $currentFavorites")
if (wasFavorite) { if (wasFavorite) {
dataManager.removeFavorite(fingerprint) dataManager.removeFavorite(fingerprint)
Log.d(TAG, "Removed from favorites: $fingerprint") Log.d(TAG, "Removed from favorites: $fingerprint")
@@ -136,37 +135,37 @@ class PrivateChatManager(
dataManager.addFavorite(fingerprint) dataManager.addFavorite(fingerprint)
Log.d(TAG, "Added to favorites: $fingerprint") Log.d(TAG, "Added to favorites: $fingerprint")
} }
// Always update state to trigger UI refresh - create new set to ensure change detection // Always update state to trigger UI refresh - create new set to ensure change detection
val newFavorites = dataManager.favoritePeers.toSet() val newFavorites = dataManager.favoritePeers.toSet()
state.setFavoritePeers(newFavorites) state.setFavoritePeers(newFavorites)
Log.d(TAG, "Force updated favorite peers state. New favorites: $newFavorites") Log.d(TAG, "Force updated favorite peers state. New favorites: $newFavorites")
Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}") Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}")
} }
fun isFavorite(peerID: String): Boolean { fun isFavorite(peerID: String): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
val isFav = dataManager.isFavorite(fingerprint) val isFav = dataManager.isFavorite(fingerprint)
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$isFav") Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$isFav")
return isFav return isFav
} }
fun getPeerFingerprint(peerID: String): String? { fun getPeerFingerprint(peerID: String): String? {
return fingerprintManager.getFingerprintForPeer(peerID) return fingerprintManager.getFingerprintForPeer(peerID)
} }
fun getPeerFingerprints(): Map<String, String> { fun getPeerFingerprints(): Map<String, String> {
return fingerprintManager.getAllPeerFingerprints() return fingerprintManager.getAllPeerFingerprints()
} }
// MARK: - Block/Unblock Operations // MARK: - Block/Unblock Operations
fun blockPeer(peerID: String, meshService: Any): Boolean { fun blockPeer(peerID: String, meshService: BluetoothMeshService): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
if (fingerprint != null) { if (fingerprint != null) {
dataManager.addBlockedUser(fingerprint) dataManager.addBlockedUser(fingerprint)
val peerNickname = getPeerNickname(peerID, meshService) val peerNickname = getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage( val systemMessage = BitchatMessage(
sender = "system", sender = "system",
@@ -175,22 +174,22 @@ class PrivateChatManager(
isRelay = false isRelay = false
) )
messageManager.addMessage(systemMessage) messageManager.addMessage(systemMessage)
// End private chat if currently in one with this peer // End private chat if currently in one with this peer
if (state.getSelectedPrivateChatPeerValue() == peerID) { if (state.getSelectedPrivateChatPeerValue() == peerID) {
endPrivateChat() endPrivateChat()
} }
return true return true
} }
return false return false
} }
fun unblockPeer(peerID: String, meshService: Any): Boolean { fun unblockPeer(peerID: String, meshService: BluetoothMeshService): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) { if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
dataManager.removeBlockedUser(fingerprint) dataManager.removeBlockedUser(fingerprint)
val peerNickname = getPeerNickname(peerID, meshService) val peerNickname = getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage( val systemMessage = BitchatMessage(
sender = "system", sender = "system",
@@ -203,10 +202,10 @@ class PrivateChatManager(
} }
return false return false
} }
fun blockPeerByNickname(targetName: String, meshService: Any): Boolean { fun blockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService) val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) { if (peerID != null) {
return blockPeer(peerID, meshService) return blockPeer(peerID, meshService)
} else { } else {
@@ -220,10 +219,10 @@ class PrivateChatManager(
return false return false
} }
} }
fun unblockPeerByNickname(targetName: String, meshService: Any): Boolean { fun unblockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService) val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) { if (peerID != null) {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) { if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
@@ -249,7 +248,7 @@ class PrivateChatManager(
return false return false
} }
} }
fun listBlockedUsers(): String { fun listBlockedUsers(): String {
val blockedCount = dataManager.blockedUsers.size val blockedCount = dataManager.blockedUsers.size
return if (blockedCount == 0) { return if (blockedCount == 0) {
@@ -258,136 +257,137 @@ class PrivateChatManager(
"blocked users: $blockedCount fingerprints" "blocked users: $blockedCount fingerprints"
} }
} }
// MARK: - Message Handling // MARK: - Message Handling
fun handleIncomingPrivateMessage(message: BitchatMessage) { fun handleIncomingPrivateMessage(message: BitchatMessage) {
message.senderPeerID?.let { senderPeerID -> message.senderPeerID?.let { senderPeerID ->
if (!isPeerBlocked(senderPeerID)) { if (!isPeerBlocked(senderPeerID)) {
// Add to private messages // Add to private messages
messageManager.addPrivateMessage(senderPeerID, message) messageManager.addPrivateMessage(senderPeerID, message)
// Track as unread for read receipt purposes // Track as unread for read receipt purposes
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() } val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
unreadList.add(message) unreadList.add(message)
Log.d(TAG, "Added received message ${message.id} from $senderPeerID to unread list (${unreadList.size} unread)") Log.d(
TAG,
"Added received message ${message.id} from $senderPeerID to unread list (${unreadList.size} unread)"
)
} }
} }
} }
/** /**
* Send read receipts for all unread messages from a specific peer * Send read receipts for all unread messages from a specific peer
* Called when the user focuses on a private chat * Called when the user focuses on a private chat
*/ */
fun sendReadReceiptsForPeer(peerID: String, meshService: Any) { fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
val unreadList = unreadReceivedMessages[peerID] val unreadList = unreadReceivedMessages[peerID]
if (unreadList.isNullOrEmpty()) { if (unreadList.isNullOrEmpty()) {
Log.d(TAG, "No unread messages to send read receipts for peer $peerID") Log.d(TAG, "No unread messages to send read receipts for peer $peerID")
return return
} }
Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID") Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID")
// Send read receipt for each unread message // Send read receipt for each unread message - now using direct method call
unreadList.forEach { message -> unreadList.forEach { message ->
try { try {
val method = meshService::class.java.getDeclaredMethod("sendReadReceipt", String::class.java, String::class.java, String::class.java)
val myNickname = state.getNicknameValue() ?: "unknown" val myNickname = state.getNicknameValue() ?: "unknown"
method.invoke(meshService, message.id, peerID, myNickname) meshService.sendReadReceipt(message.id, peerID, myNickname)
Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID") Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID")
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}") Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}")
} }
} }
// Clear the unread list since we've sent read receipts // Clear the unread list since we've sent read receipts
unreadReceivedMessages.remove(peerID) unreadReceivedMessages.remove(peerID)
} }
fun cleanupDisconnectedPeer(peerID: String) { fun cleanupDisconnectedPeer(peerID: String) {
// End private chat if peer disconnected // End private chat if peer disconnected
if (state.getSelectedPrivateChatPeerValue() == peerID) { if (state.getSelectedPrivateChatPeerValue() == peerID) {
endPrivateChat() endPrivateChat()
} }
// Clean up unread messages for disconnected peer // Clean up unread messages for disconnected peer
unreadReceivedMessages.remove(peerID) unreadReceivedMessages.remove(peerID)
Log.d(TAG, "Cleaned up unread messages for disconnected peer $peerID") Log.d(TAG, "Cleaned up unread messages for disconnected peer $peerID")
} }
// MARK: - Noise Session Management // MARK: - Noise Session Management
/** /**
* Establish Noise session if needed before starting private chat * Establish Noise session if needed before starting private chat
* Uses same lexicographical logic as MessageHandler.handleNoiseIdentityAnnouncement * Uses same lexicographical logic as MessageHandler.handleNoiseIdentityAnnouncement
*/ */
private fun establishNoiseSessionIfNeeded(peerID: String, meshService: Any) { private fun establishNoiseSessionIfNeeded(peerID: String, meshService: BluetoothMeshService) {
// If we have a clean delegate, use it; otherwise fall back to reflection for backward compatibility if (noiseSessionDelegate.hasEstablishedSession(peerID)) {
if (noiseSessionDelegate != null) { Log.d(TAG, "Noise session already established with $peerID")
if (noiseSessionDelegate.hasEstablishedSession(peerID)) { return
Log.d(TAG, "Noise session already established with $peerID") }
return
} Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake")
Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake") val myPeerID = noiseSessionDelegate.getMyPeerID()
val myPeerID = noiseSessionDelegate.getMyPeerID() // Use lexicographical comparison to decide who initiates (same logic as MessageHandler)
if (myPeerID < peerID) {
// Use lexicographical comparison to decide who initiates (same logic as MessageHandler) // We should initiate the handshake
if (myPeerID < peerID) { Log.d(
// We should initiate the handshake TAG,
Log.d(TAG, "Our peer ID lexicographically < target peer ID, initiating Noise handshake with $peerID") "Our peer ID lexicographically < target peer ID, initiating Noise handshake with $peerID"
noiseSessionDelegate.initiateHandshake(peerID) )
} else { noiseSessionDelegate.initiateHandshake(peerID)
// 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.broadcastNoiseIdentityAnnouncement()
// Also send handshake request to this peer
noiseSessionDelegate.sendHandshakeRequest(peerID, 1u) // 1 pending message (the chat we're trying to start)
}
} else { } else {
// Fallback to reflection-based approach for backward compatibility // They should initiate, we send identity announcement through standard announce
establishNoiseSessionIfNeededLegacy(peerID, meshService) Log.d(
TAG,
"Our peer ID lexicographically >= target peer ID, sending identity announcement to prompt handshake from $peerID"
)
meshService.sendAnnouncementToPeer(peerID)
} }
} }
/** // /**
* Legacy reflection-based implementation for backward compatibility // * Legacy reflection-based implementation for backward compatibility
*/ // */
private fun establishNoiseSessionIfNeededLegacy(peerID: String, meshService: Any) { // private fun establishNoiseSessionIfNeededLegacy(peerID: String, meshService: Any) {
try { // try {
// Check if we already have an established Noise session with this peer // // Check if we already have an established Noise session with this peer
val hasSessionMethod = meshService::class.java.getDeclaredMethod("hasEstablishedSession", String::class.java) // val hasSessionMethod = meshService::class.java.getDeclaredMethod("hasEstablishedSession", String::class.java)
val hasSession = hasSessionMethod.invoke(meshService, peerID) as Boolean // val hasSession = hasSessionMethod.invoke(meshService, peerID) as Boolean
//
if (hasSession) { // if (hasSession) {
Log.d(TAG, "Noise session already established with $peerID") // Log.d(TAG, "Noise session already established with $peerID")
return // return
} // }
//
Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake") // Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake")
//
// Get our peer ID from mesh service for lexicographical comparison // // Get our peer ID from mesh service for lexicographical comparison
val myPeerIDField = meshService::class.java.getField("myPeerID") // val myPeerIDField = meshService::class.java.getField("myPeerID")
val myPeerID = myPeerIDField.get(meshService) as String // val myPeerID = myPeerIDField.get(meshService) as String
//
// Use lexicographical comparison to decide who initiates (same logic as MessageHandler) // // Use lexicographical comparison to decide who initiates (same logic as MessageHandler)
if (myPeerID < peerID) { // if (myPeerID < peerID) {
// We should initiate the handshake // // We should initiate the handshake
Log.d(TAG, "Our peer ID lexicographically < target peer ID, initiating Noise handshake with $peerID") // Log.d(TAG, "Our peer ID lexicographically < target peer ID, initiating Noise handshake with $peerID")
initiateHandshakeWithPeer(peerID, meshService) // initiateHandshakeWithPeer(peerID, meshService)
} else { // } else {
// They should initiate, we send a Noise identity announcement // // 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") // Log.d(TAG, "Our peer ID lexicographically >= target peer ID, sending Noise identity announcement to prompt handshake from $peerID")
sendNoiseIdentityAnnouncement(meshService) // sendNoiseIdentityAnnouncement(meshService)
} // }
//
} catch (e: Exception) { // } catch (e: Exception) {
Log.e(TAG, "Failed to establish Noise session with $peerID: ${e.message}") // Log.e(TAG, "Failed to establish Noise session with $peerID: ${e.message}")
} // }
} // }
/** /**
* Initiate handshake with specific peer using the existing delegate pattern * Initiate handshake with specific peer using the existing delegate pattern
*/ */
@@ -398,20 +398,21 @@ class PrivateChatManager(
val messageHandler = meshService::class.java.getDeclaredField("messageHandler") val messageHandler = meshService::class.java.getDeclaredField("messageHandler")
messageHandler.isAccessible = true messageHandler.isAccessible = true
val handler = messageHandler.get(meshService) val handler = messageHandler.get(meshService)
val delegate = handler::class.java.getDeclaredField("delegate") val delegate = handler::class.java.getDeclaredField("delegate")
delegate.isAccessible = true delegate.isAccessible = true
val handlerDelegate = delegate.get(handler) val handlerDelegate = delegate.get(handler)
val method = handlerDelegate::class.java.getMethod("initiateNoiseHandshake", String::class.java) val method =
handlerDelegate::class.java.getMethod("initiateNoiseHandshake", String::class.java)
method.invoke(handlerDelegate, peerID) method.invoke(handlerDelegate, peerID)
Log.d(TAG, "Successfully initiated Noise handshake with $peerID using delegate pattern") Log.d(TAG, "Successfully initiated Noise handshake with $peerID using delegate pattern")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${e.message}") Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${e.message}")
} }
} }
/** /**
* Send Noise identity announcement to prompt other peer to initiate handshake * Send Noise identity announcement to prompt other peer to initiate handshake
* This follows the same pattern as broadcastNoiseIdentityAnnouncement() in BluetoothMeshService * This follows the same pattern as broadcastNoiseIdentityAnnouncement() in BluetoothMeshService
@@ -419,53 +420,40 @@ class PrivateChatManager(
private fun sendNoiseIdentityAnnouncement(meshService: Any) { private fun sendNoiseIdentityAnnouncement(meshService: Any) {
try { try {
// Call broadcastNoiseIdentityAnnouncement which sends a NoiseIdentityAnnouncement // Call broadcastNoiseIdentityAnnouncement which sends a NoiseIdentityAnnouncement
val method = meshService::class.java.getDeclaredMethod("broadcastNoiseIdentityAnnouncement") val method =
meshService::class.java.getDeclaredMethod("broadcastNoiseIdentityAnnouncement")
method.invoke(meshService) method.invoke(meshService)
Log.d(TAG, "Successfully sent Noise identity announcement") Log.d(TAG, "Successfully sent Noise identity announcement")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to send Noise identity announcement: ${e.message}") Log.e(TAG, "Failed to send Noise identity announcement: ${e.message}")
} }
} }
// MARK: - Utility Functions // MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: Any): String? { private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
// This would need to access the mesh service to get peer nicknames return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
// For now, we'll assume the mesh service provides a way to get this mapping
return try {
val method = meshService::class.java.getDeclaredMethod("getPeerNicknames")
val peerNicknames = method.invoke(meshService) as? Map<String, String>
peerNicknames?.entries?.find { it.value == nickname }?.key
} catch (e: Exception) {
null
}
} }
private fun getPeerNickname(peerID: String, meshService: Any): String { private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String {
return try { return meshService.getPeerNicknames()[peerID] ?: peerID
val method = meshService::class.java.getDeclaredMethod("getPeerNicknames")
val peerNicknames = method.invoke(meshService) as? Map<String, String>
peerNicknames?.get(peerID) ?: peerID
} catch (e: Exception) {
peerID
}
} }
// MARK: - Emergency Clear // MARK: - Emergency Clear
fun clearAllPrivateChats() { fun clearAllPrivateChats() {
state.setSelectedPrivateChatPeer(null) state.setSelectedPrivateChatPeer(null)
state.setUnreadPrivateMessages(emptySet()) state.setUnreadPrivateMessages(emptySet())
// Clear unread messages tracking // Clear unread messages tracking
unreadReceivedMessages.clear() unreadReceivedMessages.clear()
// Clear fingerprints via centralized manager (only if needed for emergency clear) // Clear fingerprints via centralized manager (only if needed for emergency clear)
// Note: This will be handled by the parent PeerManager.clearAllPeers() // Note: This will be handled by the parent PeerManager.clearAllPeers()
} }
// MARK: - Public Getters // MARK: - Public Getters
fun getAllPeerFingerprints(): Map<String, String> { fun getAllPeerFingerprints(): Map<String, String> {
return fingerprintManager.getAllPeerFingerprints() return fingerprintManager.getAllPeerFingerprints()
} }
+2 -4
View File
@@ -34,8 +34,7 @@ kotlinx-coroutines = "1.10.2"
# Bluetooth # Bluetooth
nordic-ble = "2.6.1" nordic-ble = "2.6.1"
# Compression
lz4-java = "1.8.0"
# Security # Security
security-crypto = "1.1.0-beta01" security-crypto = "1.1.0-beta01"
@@ -85,8 +84,7 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine
# Bluetooth # Bluetooth
nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble" } nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble" }
# Compression
lz4-java = { module = "org.lz4:lz4-java", version.ref = "lz4-java" }
# Security # Security
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }