diff --git a/.env b/.env new file mode 100644 index 00000000..9fad86dd --- /dev/null +++ b/.env @@ -0,0 +1 @@ +JAVA_HOME=/Users/cc/Library/Java/JavaVirtualMachines/openjdk-22.0.1/Contents/Home diff --git a/.gradle-local/wrapper/dists/gradle-8.13-bin/5xuhj0ry160q40clulazy9h7d/gradle-8.13-bin.zip.lck b/.gradle-local/wrapper/dists/gradle-8.13-bin/5xuhj0ry160q40clulazy9h7d/gradle-8.13-bin.zip.lck new file mode 100644 index 00000000..e69de29b diff --git a/.gradle-local/wrapper/dists/gradle-8.13-bin/5xuhj0ry160q40clulazy9h7d/gradle-8.13-bin.zip.part b/.gradle-local/wrapper/dists/gradle-8.13-bin/5xuhj0ry160q40clulazy9h7d/gradle-8.13-bin.zip.part new file mode 100644 index 00000000..e69de29b diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index 12cd3e9f..d4fcd1f7 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -3,6 +3,7 @@ package com.bitchat.android.favorites import android.content.Context import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.util.toHexString import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.util.* @@ -95,7 +96,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte /** Get favorite status for Noise public key */ fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? { - val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } + val keyHex = noisePublicKey.toHexString() return favorites[keyHex] } @@ -103,7 +104,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte fun getFavoriteStatus(peerID: String): FavoriteRelationship? { val pid = peerID.lowercase() for ((_, relationship) in favorites) { - val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) } + val noiseKeyHex = relationship.peerNoisePublicKey.toHexString() if (noiseKeyHex.startsWith(pid)) return relationship } return null @@ -111,7 +112,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte /** Update Nostr public key for a peer (indexed by Noise key) */ fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) { - val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } + val keyHex = noisePublicKey.toHexString() val existing = favorites[keyHex] if (existing != null) { @@ -168,7 +169,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte // Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix val rel = favorites.values.firstOrNull { it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex } if (rel != null) { - val noiseHex = rel.peerNoisePublicKey.joinToString("") { "%02x".format(it) } + val noiseHex = rel.peerNoisePublicKey.toHexString() // Return 16-hex prefix as best-effort if no explicit mapping exists return noiseHex.take(16) } @@ -178,7 +179,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte /** Update favorite status */ fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) { - val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } + val keyHex = noisePublicKey.toHexString() val existing = favorites[keyHex] @@ -210,7 +211,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte /** Update peer favorited-us flag */ fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) { - val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } + val keyHex = noisePublicKey.toHexString() val existing = favorites[keyHex] if (existing != null) { @@ -248,7 +249,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte /** Find Nostr pubkey by Noise key */ fun findNostrPubkey(forNoiseKey: ByteArray): String? { - val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) } + val keyHex = forNoiseKey.toHexString() return favorites[keyHex]?.peerNostrPublicKey } @@ -330,7 +331,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte private fun normalizeNostrKeyToHex(value: String): String? = try { if (value.startsWith("npub1")) { val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value) - if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) } + if (hrp != "npub") null else data.toHexString() } else value.lowercase() } catch (_: Exception) { null } } @@ -348,7 +349,7 @@ private data class FavoriteRelationshipData( companion object { fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData { return FavoriteRelationshipData( - peerNoisePublicKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }, + peerNoisePublicKeyHex = relationship.peerNoisePublicKey.toHexString(), peerNostrPublicKey = relationship.peerNostrPublicKey, peerNickname = relationship.peerNickname, isFavorite = relationship.isFavorite, diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 012ea3c4..7da60534 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -4,10 +4,9 @@ import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey -import java.security.MessageDigest import android.util.Base64 import android.util.Log -import com.bitchat.android.util.hexEncodedString +import com.bitchat.android.util.Hashing import androidx.core.content.edit /** @@ -175,9 +174,7 @@ class SecureIdentityStateManager(private val context: Context) { * Generate fingerprint from public key (SHA-256 hash) */ fun generateFingerprint(publicKeyData: ByteArray): String { - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(publicKeyData) - return hash.hexEncodedString() + return Hashing.sha256Hex(publicKeyData) } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 2bb22fce..806fec30 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -10,6 +10,7 @@ import android.os.ParcelUuid import android.util.Log import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.util.AppConstants +import com.bitchat.android.util.PeerId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -323,7 +324,7 @@ class BluetoothGattClientManager( // Try to extract peerID from Service Data (if available) for stable identity val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID)) val peerID = if (serviceData != null && serviceData.size >= 8) { - serviceData.joinToString("") { "%02x".format(it) } + PeerId.fromBytes(serviceData) } else { null } @@ -515,7 +516,7 @@ class BluetoothGattClientManager( Log.i(TAG, "Client: Received packet from ${gatt.device.address}, size: ${value.size} bytes") val packet = BitchatPacket.fromBinaryData(value) if (packet != null) { - val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) } + val peerID = PeerId.fromBytes(packet.senderID) Log.d(TAG, "Client: Parsed packet type ${packet.type} from $peerID") delegate?.onPacketReceived(packet, peerID, gatt.device) } else { diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index ad7c9cf1..9fe8c584 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -10,6 +10,7 @@ import android.os.ParcelUuid import android.util.Log import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.util.AppConstants +import com.bitchat.android.util.PeerId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -223,7 +224,7 @@ class BluetoothGattServerManager( Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes") val packet = BitchatPacket.fromBinaryData(value) if (packet != null) { - val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) } + val peerID = PeerId.fromBytes(packet.senderID) Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID") delegate?.onPacketReceived(packet, peerID, device) } else { @@ -377,7 +378,7 @@ class BluetoothGattServerManager( val mode = try { powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0] } catch (_: Exception) { "unknown" } - Log.i(TAG, "Advertising started (power mode: $mode) with stable ID: ${peerIDBytes.joinToString("") { "%02x".format(it) }}") + Log.i(TAG, "Advertising started (power mode: $mode) with stable ID: ${PeerId.fromBytes(peerIDBytes)}") } override fun onStartFailure(errorCode: Int) { diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 629af1c3..4c02a0cd 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -14,6 +14,8 @@ import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.Hashing +import com.bitchat.android.util.PeerId import com.bitchat.android.util.toHexString import com.bitchat.android.services.VerificationService import kotlinx.coroutines.* @@ -184,8 +186,8 @@ class BluetoothMeshService(private val context: Context) { val responsePacket = BitchatPacket( version = 1u, type = MessageType.NOISE_HANDSHAKE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(peerID), + senderID = PeerId.toBytes(myPeerID), + recipientID = PeerId.toBytes(peerID), timestamp = System.currentTimeMillis().toULong(), payload = response, ttl = MAX_TTL @@ -296,8 +298,8 @@ class BluetoothMeshService(private val context: Context) { val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_HANDSHAKE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(peerID), + senderID = PeerId.toBytes(myPeerID), + recipientID = PeerId.toBytes(peerID), timestamp = System.currentTimeMillis().toULong(), payload = handshakeData, ttl = MAX_TTL @@ -662,7 +664,7 @@ class BluetoothMeshService(private val context: Context) { val packet = BitchatPacket( version = 1u, type = MessageType.MESSAGE.value, - senderID = hexStringToByteArray(myPeerID), + senderID = PeerId.toBytes(myPeerID), recipientID = SpecialRecipients.BROADCAST, timestamp = System.currentTimeMillis().toULong(), payload = content.toByteArray(Charsets.UTF_8), @@ -692,7 +694,7 @@ class BluetoothMeshService(private val context: Context) { val packet = BitchatPacket( version = 2u, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files type = MessageType.FILE_TRANSFER.value, - senderID = hexStringToByteArray(myPeerID), + senderID = PeerId.toBytes(myPeerID), recipientID = SpecialRecipients.BROADCAST, timestamp = System.currentTimeMillis().toULong(), payload = payload, @@ -701,7 +703,7 @@ class BluetoothMeshService(private val context: Context) { ) val signed = signPacketBeforeBroadcast(packet) // Use a stable transferId based on the file TLV payload for progress tracking - val transferId = sha256Hex(payload) + val transferId = Hashing.sha256Hex(payload) connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } } @@ -744,8 +746,8 @@ class BluetoothMeshService(private val context: Context) { val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(recipientPeerID), + senderID = PeerId.toBytes(myPeerID), + recipientID = PeerId.toBytes(recipientPeerID), timestamp = System.currentTimeMillis().toULong(), payload = encrypted, signature = null, @@ -755,7 +757,7 @@ class BluetoothMeshService(private val context: Context) { // Sign and send the encrypted packet val signed = signPacketBeforeBroadcast(packet) // Use a stable transferId based on the unencrypted file TLV payload for progress tracking - val transferId = sha256Hex(filePayload) + val transferId = Hashing.sha256Hex(filePayload) connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) } catch (e: Exception) { @@ -777,13 +779,6 @@ class BluetoothMeshService(private val context: Context) { return connectionManager.cancelTransfer(transferId) } - // Local helper to hash payloads to a stable hex ID for progress mapping - private fun sha256Hex(bytes: ByteArray): String = try { - val md = java.security.MessageDigest.getInstance("SHA-256") - md.update(bytes) - md.digest().joinToString("") { "%02x".format(it) } - } catch (_: Exception) { bytes.size.toString(16) } - /** * Send private message - SIMPLIFIED iOS-compatible version * Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService @@ -823,8 +818,8 @@ class BluetoothMeshService(private val context: Context) { val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(recipientPeerID), + senderID = PeerId.toBytes(myPeerID), + recipientID = PeerId.toBytes(recipientPeerID), timestamp = System.currentTimeMillis().toULong(), payload = encrypted, signature = null, @@ -889,8 +884,8 @@ class BluetoothMeshService(private val context: Context) { val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(recipientPeerID), + senderID = PeerId.toBytes(myPeerID), + recipientID = PeerId.toBytes(recipientPeerID), timestamp = System.currentTimeMillis().toULong(), payload = encrypted, signature = null, @@ -937,8 +932,8 @@ class BluetoothMeshService(private val context: Context) { val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(recipientPeerID), + senderID = PeerId.toBytes(myPeerID), + recipientID = PeerId.toBytes(recipientPeerID), timestamp = System.currentTimeMillis().toULong(), payload = encrypted, signature = null, @@ -1247,27 +1242,6 @@ class BluetoothMeshService(private val context: Context) { } } - /** - * 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 - } - /** * Sign packet before broadcasting using our signing private key */ @@ -1277,12 +1251,12 @@ class BluetoothMeshService(private val context: Context) { val withRoute = try { val rec = packet.recipientID if (rec != null && !rec.contentEquals(SpecialRecipients.BROADCAST)) { - val dest = rec.joinToString("") { b -> "%02x".format(b) } + val dest = rec.toHexString() val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, dest) if (path != null && path.size >= 3) { // Exclude first (sender) and last (recipient); only intermediates val intermediates = path.subList(1, path.size - 1) - val hopsBytes = intermediates.map { hexStringToByteArray(it) } + val hopsBytes = intermediates.map { PeerId.toBytes(it) } // Attach route and upgrade to v2 (required for HAS_ROUTE flag) packet.copy(route = hopsBytes, version = 2u) } else packet.copy(route = null) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 59b6ef45..ba4ac058 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -9,6 +9,7 @@ import android.util.Log import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.Hashing import com.bitchat.android.util.toHexString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -122,7 +123,7 @@ class BluetoothPacketBroadcaster( val packet = routed.packet val isFile = packet.type == MessageType.FILE_TRANSFER.value // Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER - val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null) + val transferId = routed.transferId ?: (if (isFile) Hashing.sha256Hex(packet.payload) else null) // Check if we need to fragment if (fragmentManager != null) { val fragments = try { @@ -193,7 +194,7 @@ class BluetoothPacketBroadcaster( val data = packet.toBinaryData() ?: return false val isFile = packet.type == MessageType.FILE_TRANSFER.value // Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER - val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null) + val transferId = routed.transferId ?: (if (isFile) Hashing.sha256Hex(packet.payload) else null) if (transferId != null) { TransferProgressManager.start(transferId, 1) } @@ -236,13 +237,6 @@ class BluetoothPacketBroadcaster( return false } - private fun sha256Hex(bytes: ByteArray): String = try { - val md = java.security.MessageDigest.getInstance("SHA-256") - md.update(bytes) - md.digest().joinToString("") { "%02x".format(it) } - } catch (_: Exception) { bytes.size.toString(16) } - - /** * Public entry point for broadcasting - submits request to actor for serialization */ diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index d46cba7a..d3e122c0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -8,6 +8,7 @@ import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.sync.PacketIdUtil +import com.bitchat.android.util.PeerId import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import java.util.* @@ -181,8 +182,8 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(senderPeerID), + senderID = PeerId.toBytes(myPeerID), + recipientID = PeerId.toBytes(senderPeerID), timestamp = System.currentTimeMillis().toULong(), payload = encryptedPayload, signature = null, @@ -302,8 +303,8 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro val responsePacket = BitchatPacket( version = 1u, type = MessageType.NOISE_HANDSHAKE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(peerID), + senderID = PeerId.toBytes(myPeerID), + recipientID = PeerId.toBytes(peerID), timestamp = System.currentTimeMillis().toULong(), payload = response, signature = null, @@ -468,27 +469,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } } - /** - * Convert hex string peer ID to binary data (8 bytes) - same as iOS implementation - */ - private fun hexStringToByteArray(hexString: String): ByteArray { - val result = ByteArray(8) { 0 } // Initialize with zeros, exactly 8 bytes - var tempID = hexString - var index = 0 - - while (tempID.length >= 2 && index < 8) { - val hexByte = tempID.substring(0, 2) - val byte = hexByte.toIntOrNull(16)?.toByte() - if (byte != null) { - result[index] = byte - } - tempID = tempID.substring(2) - index++ - } - - return result - } - /** * Shutdown the handler */ diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt index cee60a53..6c7f655a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt @@ -4,6 +4,7 @@ import com.bitchat.android.protocol.MessageType import android.util.Log import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.util.PeerId import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import kotlin.random.Random @@ -66,7 +67,7 @@ class PacketRelayManager(private val myPeerID: String) { Log.w(TAG, "Packet with duplicate hops dropped") return } - val myIdBytes = hexStringToPeerBytes(myPeerID) + val myIdBytes = PeerId.toBytes(myPeerID) val index = route.indexOfFirst { it.contentEquals(myIdBytes) } if (index >= 0) { val nextHopIdHex: String? = run { @@ -186,15 +187,3 @@ interface PacketRelayManagerDelegate { fun broadcastPacket(routed: RoutedPacket) fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean } - -private fun hexStringToPeerBytes(hex: String): ByteArray { - val result = ByteArray(8) - var idx = 0 - var out = 0 - while (idx + 1 < hex.length && out < 8) { - val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0 - result[out++] = b - idx += 2 - } - return result -} diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerFingerprintManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerFingerprintManager.kt index c48b5bd9..acdfa132 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerFingerprintManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerFingerprintManager.kt @@ -1,7 +1,7 @@ package com.bitchat.android.mesh import android.util.Log -import java.security.MessageDigest +import com.bitchat.android.util.Hashing import java.util.concurrent.ConcurrentHashMap /** @@ -204,9 +204,7 @@ class PeerFingerprintManager private constructor() { * @return The hex-encoded SHA-256 hash */ private fun calculateFingerprint(publicKey: ByteArray): String { - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(publicKey) - return hash.joinToString("") { "%02x".format(it) } + return Hashing.sha256Hex(publicKey) } /** diff --git a/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt b/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt index 5e47742f..880c9954 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt @@ -1,5 +1,9 @@ package com.bitchat.android.model +import com.bitchat.android.protocol.TlvLengthSize +import com.bitchat.android.protocol.TlvReader +import com.bitchat.android.protocol.TlvWriter +import com.bitchat.android.protocol.UnknownTlvPolicy import java.nio.ByteBuffer import java.nio.ByteOrder @@ -7,16 +11,13 @@ import java.nio.ByteOrder * BitchatFilePacket: TLV-encoded file transfer payload for BLE mesh. * TLVs: * - 0x01: filename (UTF-8) - * - 0x02: file size (8 bytes, UInt64) + * - 0x02: file size (4 bytes, UInt32) * - 0x03: mime type (UTF-8) - * - 0x04: content (bytes) — may appear multiple times for large files + * - 0x04: content (bytes) * - * Length field for TLV is 2 bytes (UInt16, big-endian) for all TLVs. - * For large files, CONTENT is chunked into multiple TLVs of up to 65535 bytes each. - * - * Note: The outer BitchatPacket uses version 2 (4-byte payload length), so this - * TLV payload can exceed 64 KiB even though each TLV value is limited to 65535 bytes. - * Transport-level fragmentation then splits the final packet for BLE MTU. + * FILE_NAME, FILE_SIZE, and MIME_TYPE use 2-byte big-endian lengths. + * CONTENT uses a 4-byte big-endian length so the outer v2 packet can carry + * payloads larger than 64 KiB before transport fragmentation. */ data class BitchatFilePacket( val fileName: String, @@ -30,87 +31,53 @@ data class BitchatFilePacket( } fun encode(): ByteArray? { - try { - android.util.Log.d("BitchatFilePacket", "🔄 Encoding: name=$fileName, size=$fileSize, mime=$mimeType") - val nameBytes = fileName.toByteArray(Charsets.UTF_8) - val mimeBytes = mimeType.toByteArray(Charsets.UTF_8) - // Validate bounds for 2-byte TLV lengths (per-TLV). CONTENT may exceed 65535 and will be chunked. - if (nameBytes.size > 0xFFFF || mimeBytes.size > 0xFFFF) { - android.util.Log.e("BitchatFilePacket", "❌ TLV field too large: name=${nameBytes.size}, mime=${mimeBytes.size} (max: 65535)") + return try { + val nameBytes = fileName.toByteArray(Charsets.UTF_8) + val mimeBytes = mimeType.toByteArray(Charsets.UTF_8) + if (nameBytes.size > 0xFFFF || mimeBytes.size > 0xFFFF) { return null } - if (content.size > 0xFFFF) { - android.util.Log.d("BitchatFilePacket", "📦 Content exceeds 65535 bytes (${content.size}); will be split into multiple CONTENT TLVs") - } else { - android.util.Log.d("BitchatFilePacket", "📏 TLV sizes OK: name=${nameBytes.size}, mime=${mimeBytes.size}, content=${content.size}") - } - val sizeFieldLen = 4 // UInt32 for FILE_SIZE (changed from 8 bytes) - val contentLenFieldLen = 4 // UInt32 for CONTENT TLV as requested - // Compute capacity: header TLVs + single CONTENT TLV with 4-byte length - val contentTLVBytes = 1 + contentLenFieldLen + content.size - val capacity = (1 + 2 + nameBytes.size) + (1 + 2 + sizeFieldLen) + (1 + 2 + mimeBytes.size) + contentTLVBytes - val buf = ByteBuffer.allocate(capacity).order(ByteOrder.BIG_ENDIAN) + val sizeBytes = ByteBuffer.allocate(4) + .order(ByteOrder.BIG_ENDIAN) + .putInt(fileSize.toInt()) + .array() - // FILE_NAME - buf.put(TLVType.FILE_NAME.v.toByte()) - buf.putShort(nameBytes.size.toShort()) - buf.put(nameBytes) - - // FILE_SIZE (4 bytes) - buf.put(TLVType.FILE_SIZE.v.toByte()) - buf.putShort(sizeFieldLen.toShort()) - buf.putInt(fileSize.toInt()) - - // MIME_TYPE - buf.put(TLVType.MIME_TYPE.v.toByte()) - buf.putShort(mimeBytes.size.toShort()) - buf.put(mimeBytes) - - // CONTENT (single TLV with 4-byte length) - buf.put(TLVType.CONTENT.v.toByte()) - buf.putInt(content.size) - buf.put(content) - - val result = buf.array() - android.util.Log.d("BitchatFilePacket", "✅ Encoded successfully: ${result.size} bytes total") - return result + TlvWriter() + .put(TLVType.FILE_NAME.v.toInt(), nameBytes, TlvLengthSize.TWO_BYTES) + .put(TLVType.FILE_SIZE.v.toInt(), sizeBytes, TlvLengthSize.TWO_BYTES) + .put(TLVType.MIME_TYPE.v.toInt(), mimeBytes, TlvLengthSize.TWO_BYTES) + .put(TLVType.CONTENT.v.toInt(), content, TlvLengthSize.FOUR_BYTES) + .toByteArray() } catch (e: Exception) { - android.util.Log.e("BitchatFilePacket", "❌ Encoding failed: ${e.message}", e) - return null + null } } companion object { fun decode(data: ByteArray): BitchatFilePacket? { - android.util.Log.d("BitchatFilePacket", "🔄 Decoding ${data.size} bytes") try { - var off = 0 + val knownTypes = TLVType.values().map { it.v.toInt() }.toSet() + val fields = TlvReader.decode( + data = data, + defaultLengthSize = TlvLengthSize.TWO_BYTES, + unknownPolicy = UnknownTlvPolicy.FAIL, + knownTypes = knownTypes + ) { type -> + if (type == TLVType.CONTENT.v.toInt()) TlvLengthSize.FOUR_BYTES else TlvLengthSize.TWO_BYTES + } ?: return null + var name: String? = null var size: Long? = null var mime: String? = null var contentBytes: ByteArray? = null - while (off + 3 <= data.size) { // minimum TLV header size (type + 2 bytes length) - val t = TLVType.from(data[off].toUByte()) ?: return null - off += 1 - // CONTENT uses 4-byte length; others use 2-byte length - val len: Int - if (t == TLVType.CONTENT) { - if (off + 4 > data.size) return null - len = ((data[off].toInt() and 0xFF) shl 24) or ((data[off + 1].toInt() and 0xFF) shl 16) or ((data[off + 2].toInt() and 0xFF) shl 8) or (data[off + 3].toInt() and 0xFF) - off += 4 - } else { - if (off + 2 > data.size) return null - len = ((data[off].toInt() and 0xFF) shl 8) or (data[off + 1].toInt() and 0xFF) - off += 2 - } - if (len < 0 || off + len > data.size) return null - val value = data.copyOfRange(off, off + len) - off += len + for (field in fields) { + val t = TLVType.from(field.type.toUByte()) ?: return null + val value = field.value when (t) { TLVType.FILE_NAME -> name = String(value, Charsets.UTF_8) TLVType.FILE_SIZE -> { - if (len != 4) return null + if (value.size != 4) return null val bb = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN) size = bb.int.toLong() } @@ -128,14 +95,10 @@ data class BitchatFilePacket( val c = contentBytes ?: return null val s = size ?: c.size.toLong() val m = mime ?: "application/octet-stream" - val result = BitchatFilePacket(n, s, m, c) - android.util.Log.d("BitchatFilePacket", "✅ Decoded: name=$n, size=$s, mime=$m, content=${c.size} bytes") - return result + return BitchatFilePacket(n, s, m, c) } catch (e: Exception) { - android.util.Log.e("BitchatFilePacket", "❌ Decoding failed: ${e.message}", e) return null } } } } - diff --git a/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt b/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt index b902222b..e5000934 100644 --- a/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt +++ b/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt @@ -1,6 +1,7 @@ package com.bitchat.android.model import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.toHexString /** * FragmentPayload - 100% iOS-compatible fragment payload structure @@ -111,7 +112,7 @@ data class FragmentPayload( * Get fragment ID as hex string for logging/debugging */ fun getFragmentIDString(): String { - return fragmentID.joinToString("") { "%02x".format(it) } + return fragmentID.toHexString() } /** diff --git a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt index 2dfbe9c2..f3caae01 100644 --- a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt +++ b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt @@ -1,8 +1,12 @@ package com.bitchat.android.model import android.os.Parcelable +import com.bitchat.android.protocol.TlvLengthSize +import com.bitchat.android.protocol.TlvReader +import com.bitchat.android.protocol.TlvWriter +import com.bitchat.android.protocol.UnknownTlvPolicy +import com.bitchat.android.util.toHexString import kotlinx.parcelize.Parcelize -import com.bitchat.android.util.* /** * Identity announcement structure with TLV encoding @@ -36,29 +40,15 @@ data class IdentityAnnouncement( fun encode(): ByteArray? { val nicknameData = nickname.toByteArray(Charsets.UTF_8) - // Check size limits - if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255) { - return null + return try { + TlvWriter() + .put(TLVType.NICKNAME.value.toInt(), nicknameData, TlvLengthSize.ONE_BYTE) + .put(TLVType.NOISE_PUBLIC_KEY.value.toInt(), noisePublicKey, TlvLengthSize.ONE_BYTE) + .put(TLVType.SIGNING_PUBLIC_KEY.value.toInt(), signingPublicKey, TlvLengthSize.ONE_BYTE) + .toByteArray() + } catch (_: IllegalArgumentException) { + null } - - val result = mutableListOf() - - // TLV for nickname - result.add(TLVType.NICKNAME.value.toByte()) - result.add(nicknameData.size.toByte()) - result.addAll(nicknameData.toList()) - - // TLV for noise public key - result.add(TLVType.NOISE_PUBLIC_KEY.value.toByte()) - result.add(noisePublicKey.size.toByte()) - result.addAll(noisePublicKey.toList()) - - // TLV for signing public key - result.add(TLVType.SIGNING_PUBLIC_KEY.value.toByte()) - result.add(signingPublicKey.size.toByte()) - result.addAll(signingPublicKey.toList()) - - return result.toByteArray() } companion object { @@ -66,45 +56,29 @@ data class IdentityAnnouncement( * 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 noisePublicKey: ByteArray? = null var signingPublicKey: ByteArray? = null - - while (offset + 2 <= dataCopy.size) { - // Read TLV type - val typeValue = dataCopy[offset].toUByte() - val type = TLVType.fromValue(typeValue) - 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 - - // Process known TLV types, skip unknown ones for forward compatibility + + val knownTypes = TLVType.values().map { it.value.toInt() }.toSet() + val fields = TlvReader.decode( + data = data, + defaultLengthSize = TlvLengthSize.ONE_BYTE, + unknownPolicy = UnknownTlvPolicy.SKIP, + knownTypes = knownTypes + ) ?: return null + + for (field in fields) { + val type = TLVType.fromValue(field.type.toUByte()) ?: continue when (type) { TLVType.NICKNAME -> { - nickname = String(value, Charsets.UTF_8) + nickname = String(field.value, Charsets.UTF_8) } TLVType.NOISE_PUBLIC_KEY -> { - noisePublicKey = value + noisePublicKey = field.value } TLVType.SIGNING_PUBLIC_KEY -> { - signingPublicKey = value - } - null -> { - // Unknown TLV; skip (tolerant decoder for forward compatibility) - continue + signingPublicKey = field.value } } } @@ -140,6 +114,6 @@ data class IdentityAnnouncement( } override fun toString(): String { - return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}...)" + return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.toHexString().take(16)}..., signingPublicKey=${signingPublicKey.toHexString().take(16)}...)" } } diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index c46a114f..150eb064 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -1,6 +1,10 @@ package com.bitchat.android.model import android.os.Parcelable +import com.bitchat.android.protocol.TlvLengthSize +import com.bitchat.android.protocol.TlvReader +import com.bitchat.android.protocol.TlvWriter +import com.bitchat.android.protocol.UnknownTlvPolicy import kotlinx.parcelize.Parcelize /** @@ -127,24 +131,14 @@ data class PrivateMessagePacket( 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 + return try { + TlvWriter() + .put(TLVType.MESSAGE_ID.value.toInt(), messageIDData, TlvLengthSize.ONE_BYTE) + .put(TLVType.CONTENT.value.toInt(), contentData, TlvLengthSize.ONE_BYTE) + .toByteArray() + } catch (_: IllegalArgumentException) { + null } - - val result = mutableListOf() - - // 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 { @@ -152,33 +146,25 @@ data class PrivateMessagePacket( * 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 - + + val knownTypes = TLVType.values().map { it.value.toInt() }.toSet() + val fields = TlvReader.decode( + data = data, + defaultLengthSize = TlvLengthSize.ONE_BYTE, + unknownPolicy = UnknownTlvPolicy.FAIL, + knownTypes = knownTypes + ) ?: return null + + for (field in fields) { + val type = TLVType.fromValue(field.type.toUByte()) ?: return null when (type) { TLVType.MESSAGE_ID -> { - messageID = String(value, Charsets.UTF_8) + messageID = String(field.value, Charsets.UTF_8) } TLVType.CONTENT -> { - content = String(value, Charsets.UTF_8) + content = String(field.value, Charsets.UTF_8) } } } diff --git a/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt index ab52f071..73fc0497 100644 --- a/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt +++ b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt @@ -1,5 +1,9 @@ package com.bitchat.android.model +import com.bitchat.android.protocol.TlvLengthSize +import com.bitchat.android.protocol.TlvReader +import com.bitchat.android.protocol.TlvWriter +import com.bitchat.android.protocol.UnknownTlvPolicy import com.bitchat.android.sync.SyncDefaults /** @@ -15,30 +19,21 @@ data class RequestSyncPacket( val data: ByteArray ) { fun encode(): ByteArray { - val out = ArrayList() - fun putTLV(t: Int, v: ByteArray) { - out.add(t.toByte()) - val len = v.size - out.add(((len ushr 8) and 0xFF).toByte()) - out.add((len and 0xFF).toByte()) - out.addAll(v.toList()) - } - // P - putTLV(0x01, byteArrayOf(p.toByte())) - // M (uint32) val m32 = m.coerceAtMost(0xffff_ffffL) - putTLV( - 0x02, - byteArrayOf( - ((m32 ushr 24) and 0xFF).toByte(), - ((m32 ushr 16) and 0xFF).toByte(), - ((m32 ushr 8) and 0xFF).toByte(), - (m32 and 0xFF).toByte() + return TlvWriter() + .put(0x01, byteArrayOf(p.toByte()), TlvLengthSize.TWO_BYTES) + .put( + 0x02, + byteArrayOf( + ((m32 ushr 24) and 0xFF).toByte(), + ((m32 ushr 16) and 0xFF).toByte(), + ((m32 ushr 8) and 0xFF).toByte(), + (m32 and 0xFF).toByte() + ), + TlvLengthSize.TWO_BYTES ) - ) - // data - putTLV(0x03, data) - return out.toByteArray() + .put(0x03, data, TlvLengthSize.TWO_BYTES) + .toByteArray() } companion object { @@ -46,28 +41,30 @@ data class RequestSyncPacket( const val MAX_ACCEPT_FILTER_BYTES: Int = SyncDefaults.MAX_ACCEPT_FILTER_BYTES fun decode(data: ByteArray): RequestSyncPacket? { - var off = 0 var p: Int? = null var m: Long? = null var payload: ByteArray? = null - while (off + 3 <= data.size) { - val t = (data[off].toInt() and 0xFF); off += 1 - val len = ((data[off].toInt() and 0xFF) shl 8) or (data[off+1].toInt() and 0xFF); off += 2 - if (off + len > data.size) return null - val v = data.copyOfRange(off, off + len); off += len - when (t) { - 0x01 -> if (len == 1) p = (v[0].toInt() and 0xFF) - 0x02 -> if (len == 4) { - val mm = ((v[0].toLong() and 0xFF) shl 24) or - ((v[1].toLong() and 0xFF) shl 16) or - ((v[2].toLong() and 0xFF) shl 8) or - (v[3].toLong() and 0xFF) + val fields = TlvReader.decode( + data = data, + defaultLengthSize = TlvLengthSize.TWO_BYTES, + unknownPolicy = UnknownTlvPolicy.SKIP, + knownTypes = setOf(0x01, 0x02, 0x03) + ) ?: return null + + for (field in fields) { + when (field.type) { + 0x01 -> if (field.value.size == 1) p = (field.value[0].toInt() and 0xFF) + 0x02 -> if (field.value.size == 4) { + val mm = ((field.value[0].toLong() and 0xFF) shl 24) or + ((field.value[1].toLong() and 0xFF) shl 16) or + ((field.value[2].toLong() and 0xFF) shl 8) or + (field.value[3].toLong() and 0xFF) m = mm } 0x03 -> { - if (v.size > MAX_ACCEPT_FILTER_BYTES) return null - payload = v + if (field.value.size > MAX_ACCEPT_FILTER_BYTES) return null + payload = field.value } } } diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt b/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt index cf8c3f80..68209b68 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt @@ -1,7 +1,7 @@ package com.bitchat.android.noise import android.util.Log -import java.security.MessageDigest +import com.bitchat.android.util.Hashing import java.util.concurrent.ConcurrentHashMap import javax.crypto.Cipher import javax.crypto.SecretKeyFactory @@ -174,9 +174,7 @@ class NoiseChannelEncryption { val key = channelKeys[channel] ?: return null return try { - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(key.encoded) - hash.joinToString("") { "%02x".format(it) } + Hashing.sha256Hex(key.encoded) } catch (e: Exception) { Log.e(TAG, "Failed to calculate key commitment: ${e.message}") null diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index 8b8bf0cc..96be8571 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -5,7 +5,7 @@ import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.mesh.PeerFingerprintManager import com.bitchat.android.noise.southernstorm.protocol.Noise -import java.security.MessageDigest +import com.bitchat.android.util.Hashing import java.security.SecureRandom import java.util.concurrent.ConcurrentHashMap @@ -133,9 +133,7 @@ class NoiseEncryptionService(private val context: Context) { * Get our identity fingerprint (SHA-256 hash of static public key) */ fun getIdentityFingerprint(): String { - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(staticIdentityPublicKey) - return hash.joinToString("") { "%02x".format(it) } + return Hashing.sha256Hex(staticIdentityPublicKey) } /** @@ -387,9 +385,7 @@ class NoiseEncryptionService(private val context: Context) { * Calculate fingerprint from public key (SHA-256 hash) */ private fun calculateFingerprint(publicKey: ByteArray): String { - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(publicKey) - return hash.joinToString("") { "%02x".format(it) } + return Hashing.sha256Hex(publicKey) } // MARK: - Packet Signing/Verification diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index a4803157..442da361 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -2,6 +2,8 @@ package com.bitchat.android.nostr import android.content.Context import android.util.Log +import com.bitchat.android.util.hexToByteArray +import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt index b25499dd..6521a702 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt @@ -1,5 +1,7 @@ package com.bitchat.android.nostr +import com.bitchat.android.util.hexToByteArray +import com.bitchat.android.util.toHexString import org.bouncycastle.crypto.ec.CustomNamedCurves import org.bouncycastle.crypto.params.ECDomainParameters import org.bouncycastle.crypto.params.ECPrivateKeyParameters diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt index 3f70bbb7..4aea24e1 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt @@ -6,6 +6,9 @@ import com.bitchat.android.model.PrivateMessagePacket import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.Hex +import com.bitchat.android.util.PeerId +import com.bitchat.android.util.toHexString import java.util.* /** @@ -41,8 +44,8 @@ object NostrEmbeddedBitChat { val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(senderPeerID), - recipientID = hexStringToByteArray(recipientIDHex), + senderID = PeerId.toBytes(senderPeerID), + recipientID = PeerId.toBytes(recipientIDHex), timestamp = System.currentTimeMillis().toULong(), payload = payload, signature = null, @@ -81,8 +84,8 @@ object NostrEmbeddedBitChat { val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(senderPeerID), - recipientID = hexStringToByteArray(recipientIDHex), + senderID = PeerId.toBytes(senderPeerID), + recipientID = PeerId.toBytes(recipientIDHex), timestamp = System.currentTimeMillis().toULong(), payload = payload, signature = null, @@ -118,7 +121,7 @@ object NostrEmbeddedBitChat { val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(senderPeerID), + senderID = PeerId.toBytes(senderPeerID), recipientID = null, // No recipient for geohash DMs timestamp = System.currentTimeMillis().toULong(), payload = payload, @@ -153,7 +156,7 @@ object NostrEmbeddedBitChat { val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(senderPeerID), + senderID = PeerId.toBytes(senderPeerID), recipientID = null, // No recipient for geohash DMs timestamp = System.currentTimeMillis().toULong(), payload = payload, @@ -174,16 +177,16 @@ object NostrEmbeddedBitChat { */ private fun normalizeRecipientPeerID(recipientPeerID: String): String { try { - val maybeData = hexStringToByteArray(recipientPeerID) + val maybeData = Hex.decode(recipientPeerID) ?: return recipientPeerID return when (maybeData.size) { 32 -> { // Treat as Noise static public key; derive peerID from fingerprint // For now, return first 8 bytes as hex (simplified) - maybeData.take(8).joinToString("") { "%02x".format(it) } + maybeData.copyOfRange(0, 8).toHexString() } 8 -> { // Already an 8-byte peer ID - recipientPeerID + PeerId.fromBytes(maybeData) } else -> { // Fallback: return as-is (expecting 16 hex chars) @@ -207,28 +210,4 @@ object NostrEmbeddedBitChat { .replace("=", "") } - /** - * Convert hex string to byte array - */ - private fun hexStringToByteArray(hexString: String): ByteArray { - if (hexString.length % 2 != 0) { - return ByteArray(8) // Return 8-byte array filled with zeros - } - - val result = ByteArray(8) { 0 } // Exactly 8 bytes like iOS - 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 - } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 92752b17..97c3f59f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -3,7 +3,8 @@ package com.bitchat.android.nostr import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.annotations.SerializedName -import java.security.MessageDigest +import com.bitchat.android.util.Hashing +import com.bitchat.android.util.toHexString /** * Nostr Event structure following NIP-01 @@ -134,13 +135,11 @@ data class NostrEvent( val gson = GsonBuilder().disableHtmlEscaping().create() val jsonString = gson.toJson(serialized) - // SHA256 hash of the JSON string - val digest = MessageDigest.getInstance("SHA-256") val jsonBytes = jsonString.toByteArray(Charsets.UTF_8) - val hash = digest.digest(jsonBytes) + val hash = Hashing.sha256(jsonBytes) // Convert to hex - val hexId = hash.joinToString("") { "%02x".format(it) } + val hexId = hash.toHexString() return Pair(hexId, hash) } @@ -217,15 +216,3 @@ object NostrKind { const val EPHEMERAL_EVENT = 20000 // For geohash channels const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat } - -/** - * Extension functions for hex encoding/decoding - */ -fun String.hexToByteArray(): ByteArray { - check(length % 2 == 0) { "Must have an even length" } - return chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray() -} - -fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt index 01583dde..b61e613c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt @@ -3,7 +3,9 @@ package com.bitchat.android.nostr import android.content.Context import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager -import java.security.MessageDigest +import com.bitchat.android.util.Hashing +import com.bitchat.android.util.hexToByteArray +import com.bitchat.android.util.toHexString import java.security.SecureRandom /** @@ -25,7 +27,7 @@ data class NostrIdentity( */ fun generate(): NostrIdentity { val (privateKeyHex, publicKeyHex) = NostrCrypto.generateKeyPair() - val npub = Bech32.encode("npub", publicKeyHex.hexToByteArrayLocal()) + val npub = Bech32.encode("npub", publicKeyHex.hexToByteArray()) Log.d(TAG, "Generated new Nostr identity: npub=$npub") @@ -46,7 +48,7 @@ data class NostrIdentity( } val publicKeyHex = NostrCrypto.derivePublicKey(privateKeyHex) - val npub = Bech32.encode("npub", publicKeyHex.hexToByteArrayLocal()) + val npub = Bech32.encode("npub", publicKeyHex.hexToByteArray()) return NostrIdentity( privateKeyHex = privateKeyHex, @@ -61,10 +63,8 @@ data class NostrIdentity( */ fun fromSeed(seed: String): NostrIdentity { // Hash the seed to create a private key - val digest = MessageDigest.getInstance("SHA-256") val seedBytes = seed.toByteArray(Charsets.UTF_8) - val privateKeyBytes = digest.digest(seedBytes) - val privateKeyHex = privateKeyBytes.joinToString("") { "%02x".format(it) } + val privateKeyHex = Hashing.sha256Hex(seedBytes) return fromPrivateKey(privateKeyHex) } @@ -149,7 +149,7 @@ object NostrIdentityBridge { // Try a few iterations to ensure a valid key can be formed (exactly like iOS) for (i in 0 until 10) { val candidateKey = candidateKey(seed, geohashBytes, i.toUInt()) - val candidateKeyHex = candidateKey.toHexStringLocal() + val candidateKeyHex = candidateKey.toHexString() if (NostrCrypto.isValidPrivateKey(candidateKeyHex)) { val identity = NostrIdentity.fromPrivateKey(candidateKeyHex) @@ -164,10 +164,9 @@ object NostrIdentityBridge { // As a final fallback, hash the seed+msg and try again (exactly like iOS) val combined = seed + geohashBytes - val digest = MessageDigest.getInstance("SHA-256") - val fallbackKey = digest.digest(combined) + val fallbackKey = Hashing.sha256(combined) - val fallbackIdentity = NostrIdentity.fromPrivateKey(fallbackKey.toHexStringLocal()) + val fallbackIdentity = NostrIdentity.fromPrivateKey(fallbackKey.toHexString()) // Cache the fallback result too geohashIdentityCache[forGeohash] = fallbackIdentity @@ -280,15 +279,6 @@ object NostrIdentityBridge { } } -// Extension functions for data conversion -private fun String.hexToByteArrayLocal(): ByteArray { - return chunked(2).map { it.toInt(16).toByte() }.toByteArray() -} - -private fun ByteArray.toHexStringLocal(): String { - return joinToString("") { "%02x".format(it) } -} - private fun UInt.toLittleEndianBytes(): ByteArray { val bytes = ByteArray(4) bytes[0] = (this and 0xFFu).toByte() diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProofOfWork.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProofOfWork.kt index ffbe6607..6c5a3f83 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProofOfWork.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProofOfWork.kt @@ -3,7 +3,6 @@ package com.bitchat.android.nostr import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.security.MessageDigest import kotlin.random.Random /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt index 66f3b095..3cef6d7a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt @@ -2,6 +2,8 @@ package com.bitchat.android.nostr import android.content.Context import android.util.Log +import com.bitchat.android.util.hexToByteArray +import com.bitchat.android.util.toHexString import kotlinx.coroutines.* /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index 26ba8369..446d0368 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -4,6 +4,8 @@ import android.content.Context import android.util.Log import com.bitchat.android.model.ReadReceipt import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.util.PeerId +import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import java.util.* import java.util.concurrent.ConcurrentLinkedQueue @@ -79,7 +81,7 @@ class NostrTransport( Log.e(TAG, "NostrTransport: recipient key not npub (hrp=$hrp)") return@launch } - data.joinToString("") { "%02x".format(it) } + data.toHexString() } catch (e: Exception) { Log.e(TAG, "NostrTransport: failed to decode npub -> hex: $e") return@launch @@ -174,7 +176,7 @@ class NostrTransport( scheduleNextReadAck() return@launch } - data.joinToString("") { "%02x".format(it) } + data.toHexString() } catch (e: Exception) { scheduleNextReadAck() return@launch @@ -248,7 +250,7 @@ class NostrTransport( val recipientHex = try { val (hrp, data) = Bech32.decode(recipientNostrPubkey) if (hrp != "npub") return@launch - data.joinToString("") { "%02x".format(it) } + data.toHexString() } catch (e: Exception) { return@launch } @@ -306,7 +308,7 @@ class NostrTransport( val recipientHex = try { val (hrp, data) = Bech32.decode(recipientNostrPubkey) if (hrp != "npub") return@launch - data.joinToString("") { "%02x".format(it) } + data.toHexString() } catch (e: Exception) { return@launch } @@ -486,7 +488,7 @@ class NostrTransport( com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it } // 2) Legacy path: resolve by noise public key association - val noiseKey = hexStringToByteArray(peerID) + val noiseKey = PeerId.toBytes(peerID) val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey @@ -503,14 +505,6 @@ class NostrTransport( } } - /** - * Convert full hex string to byte array - */ - private fun hexStringToByteArray(hexString: String): ByteArray { - val clean = if (hexString.length % 2 == 0) hexString else "0$hexString" - return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - } - fun cleanup() { transportScope.cancel() } diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index f591b2b8..05a0f483 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -3,13 +3,13 @@ package com.bitchat.android.nostr import android.app.Application import android.content.SharedPreferences import android.util.Log +import com.bitchat.android.util.Hashing import java.io.BufferedReader import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStream import java.io.InputStreamReader -import java.security.MessageDigest import java.util.concurrent.TimeUnit import kotlin.math.* import kotlinx.coroutines.CoroutineScope @@ -284,20 +284,6 @@ object RelayDirectory { } catch (_: Exception) { "error" } private fun streamSha256Hex(input: InputStream): String { - val digest = MessageDigest.getInstance("SHA-256") - val buf = ByteArray(8192) - var read: Int - while (true) { - read = input.read(buf) - if (read <= 0) break - digest.update(buf, 0, read) - } - val bytes = digest.digest() - return bytes.joinToString("") { b -> - val v = b.toInt() and 0xff - val s = Integer.toHexString(v) - if (s.length == 1) "0$s" else s - } + return Hashing.sha256Hex(input) } } - diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index db17ba1c..70566361 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -1,6 +1,7 @@ package com.bitchat.android.protocol import android.os.Parcelable +import com.bitchat.android.util.PeerId import kotlinx.parcelize.Parcelize import java.nio.ByteBuffer import java.nio.ByteOrder @@ -71,7 +72,7 @@ data class BitchatPacket( ) : this( version = 1u, type = type, - senderID = hexStringToByteArray(senderID), + senderID = PeerId.toBytes(senderID), recipientID = null, timestamp = (System.currentTimeMillis()).toULong(), payload = payload, @@ -108,27 +109,6 @@ data class BitchatPacket( fun fromBinaryData(data: ByteArray): BitchatPacket? { return BinaryProtocol.decode(data) } - - /** - * 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 - } } override fun equals(other: Any?): Boolean { diff --git a/app/src/main/java/com/bitchat/android/protocol/Tlv.kt b/app/src/main/java/com/bitchat/android/protocol/Tlv.kt new file mode 100644 index 00000000..60ea0383 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/protocol/Tlv.kt @@ -0,0 +1,110 @@ +package com.bitchat.android.protocol + +enum class TlvLengthSize(val byteCount: Int, val maxValue: Long) { + ONE_BYTE(1, 0xFFL), + TWO_BYTES(2, 0xFFFFL), + FOUR_BYTES(4, 0xFFFF_FFFFL) +} + +enum class UnknownTlvPolicy { + SKIP, + FAIL +} + +data class TlvField( + val type: Int, + val value: ByteArray +) + +class TlvWriter { + private val output = ArrayList() + + fun put(type: Int, value: ByteArray, lengthSize: TlvLengthSize): TlvWriter { + require(type in 0..0xFF) { "TLV type must fit in one byte" } + require(value.size.toLong() <= lengthSize.maxValue) { + "TLV value length ${value.size} exceeds ${lengthSize.maxValue}" + } + + output.add(type.toByte()) + writeLength(value.size.toLong(), lengthSize) + output.addAll(value.toList()) + return this + } + + fun toByteArray(): ByteArray { + return output.toByteArray() + } + + private fun writeLength(length: Long, lengthSize: TlvLengthSize) { + when (lengthSize) { + TlvLengthSize.ONE_BYTE -> output.add((length and 0xFF).toByte()) + TlvLengthSize.TWO_BYTES -> { + output.add(((length ushr 8) and 0xFF).toByte()) + output.add((length and 0xFF).toByte()) + } + TlvLengthSize.FOUR_BYTES -> { + output.add(((length ushr 24) and 0xFF).toByte()) + output.add(((length ushr 16) and 0xFF).toByte()) + output.add(((length ushr 8) and 0xFF).toByte()) + output.add((length and 0xFF).toByte()) + } + } + } +} + +object TlvReader { + fun decode( + data: ByteArray, + defaultLengthSize: TlvLengthSize, + unknownPolicy: UnknownTlvPolicy = UnknownTlvPolicy.SKIP, + knownTypes: Set? = null, + lengthSizeForType: (Int) -> TlvLengthSize = { defaultLengthSize } + ): List? { + val fields = ArrayList() + var offset = 0 + + while (offset < data.size) { + if (data.size - offset < 1) return null + val type = data[offset].toInt() and 0xFF + offset += 1 + + val lengthSize = lengthSizeForType(type) + if (data.size - offset < lengthSize.byteCount) return null + + val length = readLength(data, offset, lengthSize) ?: return null + offset += lengthSize.byteCount + + if (length > Int.MAX_VALUE || data.size - offset < length.toInt()) return null + val valueLength = length.toInt() + val value = data.copyOfRange(offset, offset + valueLength) + offset += valueLength + + if (knownTypes != null && type !in knownTypes) { + when (unknownPolicy) { + UnknownTlvPolicy.SKIP -> continue + UnknownTlvPolicy.FAIL -> return null + } + } + + fields.add(TlvField(type, value)) + } + + return fields + } + + private fun readLength(data: ByteArray, offset: Int, lengthSize: TlvLengthSize): Long? { + return when (lengthSize) { + TlvLengthSize.ONE_BYTE -> data[offset].toLong() and 0xFFL + TlvLengthSize.TWO_BYTES -> { + ((data[offset].toLong() and 0xFFL) shl 8) or + (data[offset + 1].toLong() and 0xFFL) + } + TlvLengthSize.FOUR_BYTES -> { + ((data[offset].toLong() and 0xFFL) shl 24) or + ((data[offset + 1].toLong() and 0xFFL) shl 16) or + ((data[offset + 2].toLong() and 0xFFL) shl 8) or + (data[offset + 3].toLong() and 0xFFL) + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt index d67ff432..93652757 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt @@ -1,6 +1,8 @@ package com.bitchat.android.services import com.bitchat.android.ui.ChatState +import com.bitchat.android.util.hexToByteArray +import com.bitchat.android.util.toHexString object ConversationAliasResolver { @@ -19,7 +21,7 @@ object ConversationAliasResolver { if (pubHex != null) { val noiseKey = findNoiseKeyForNostr(pubHex) if (noiseKey != null) { - val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } + val noiseHex = noiseKey.toHexString() // Prefer a connected mesh peer that matches this noise key val meshPeer = connectedPeers.firstOrNull { pid -> meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true @@ -29,7 +31,7 @@ object ConversationAliasResolver { } } else if (peer.length == 64 && peer.matches(Regex("^[0-9a-fA-F]+$"))) { // Peer is full noise key hex: upgrade to active mesh peer if available - val noiseKey = peer.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val noiseKey = peer.hexToByteArray() val meshPeer = connectedPeers.firstOrNull { pid -> meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true } diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index a6bac09a..1592d189 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -5,6 +5,8 @@ import android.util.Log import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.ReadReceipt import com.bitchat.android.nostr.NostrTransport +import com.bitchat.android.util.hexToByteArrayOrNull +import com.bitchat.android.util.toHexString /** * Routes messages between BLE mesh and Nostr transports, matching iOS behavior. @@ -164,7 +166,7 @@ class MessageRouter private constructor( return try { // Full Noise key hex if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - val noiseKey = hexToBytes(peerID) + val noiseKey = peerID.hexToByteArrayOrNull() ?: return false val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) fav?.isMutual == true && fav.peerNostrPublicKey != null } else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { @@ -177,16 +179,11 @@ class MessageRouter private constructor( } catch (_: Exception) { false } } - private fun hexToBytes(hex: String): ByteArray { - val clean = if (hex.length % 2 == 0) hex else "0$hex" - return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - } - private fun resolveMeshPeerForNoiseHex(noiseHex: String): String? { return try { mesh.getPeerNicknames().keys.firstOrNull { pid -> val info = mesh.getPeerInfo(pid) - val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + val keyHex = info?.noisePublicKey?.toHexString() keyHex != null && keyHex.equals(noiseHex, ignoreCase = true) } } catch (_: Exception) { null } @@ -197,7 +194,7 @@ class MessageRouter private constructor( peers.forEach { pid -> flushOutboxFor(pid) val noiseHex = try { - mesh.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + mesh.getPeerInfo(pid)?.noisePublicKey?.toHexString() } catch (_: Exception) { null } noiseHex?.let { flushOutboxFor(it) } } @@ -207,7 +204,7 @@ class MessageRouter private constructor( fun onSessionEstablished(peerID: String) { flushOutboxFor(peerID) val noiseHex = try { - mesh.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + mesh.getPeerInfo(peerID)?.noisePublicKey?.toHexString() } catch (_: Exception) { null } noiseHex?.let { flushOutboxFor(it) } } diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt index 85ea6c9c..ea45d07c 100644 --- a/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt @@ -1,6 +1,11 @@ package com.bitchat.android.services.meshgraph import android.util.Log +import com.bitchat.android.protocol.TlvLengthSize +import com.bitchat.android.protocol.TlvReader +import com.bitchat.android.protocol.TlvWriter +import com.bitchat.android.protocol.UnknownTlvPolicy +import com.bitchat.android.util.PeerId /** * Gossip TLV helpers for embedding direct neighbor peer IDs in ANNOUNCE payloads. @@ -15,12 +20,14 @@ object GossipTLV { */ fun encodeNeighbors(peerIDs: List): ByteArray { val unique = peerIDs.distinct().take(10) - val valueBytes = unique.flatMap { id -> hexStringPeerIdTo8Bytes(id).toList() }.toByteArray() + val valueBytes = unique.flatMap { id -> PeerId.toBytes(id).toList() }.toByteArray() if (valueBytes.size > 255) { // Safety check, though 10*8 = 80 bytes, so well under 255 Log.w("GossipTLV", "Neighbors value exceeds 255, truncating") } - return byteArrayOf(DIRECT_NEIGHBORS_TYPE.toByte(), valueBytes.size.toByte()) + valueBytes + return TlvWriter() + .put(DIRECT_NEIGHBORS_TYPE.toInt(), valueBytes, TlvLengthSize.ONE_BYTE) + .toByteArray() } /** @@ -29,22 +36,21 @@ object GossipTLV { */ fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List? { val result = mutableListOf() - var offset = 0 - while (offset + 2 <= payload.size) { - val type = payload[offset].toUByte() - val len = payload[offset + 1].toUByte().toInt() - offset += 2 - if (offset + len > payload.size) break - val value = payload.sliceArray(offset until offset + len) - offset += len + val fields = TlvReader.decode( + data = payload, + defaultLengthSize = TlvLengthSize.ONE_BYTE, + unknownPolicy = UnknownTlvPolicy.SKIP, + knownTypes = setOf(DIRECT_NEIGHBORS_TYPE.toInt()) + ) ?: return null - if (type == DIRECT_NEIGHBORS_TYPE) { + for (field in fields) { + if (field.type == DIRECT_NEIGHBORS_TYPE.toInt()) { // Value is N*8 bytes of peer IDs var pos = 0 - while (pos + 8 <= value.size) { - val idBytes = value.sliceArray(pos until pos + 8) - result.add(bytesToPeerIdHex(idBytes)) - pos += 8 + while (pos + PeerId.BYTE_LENGTH <= field.value.size) { + val idBytes = field.value.sliceArray(pos until pos + PeerId.BYTE_LENGTH) + result.add(PeerId.fromBytes(idBytes)) + pos += PeerId.BYTE_LENGTH } return result // present (possibly empty) } @@ -52,26 +58,4 @@ object GossipTLV { // Not present return null } - - private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray { - val clean = hexString.lowercase().take(16) - val result = ByteArray(8) { 0 } - var idx = 0 - var out = 0 - while (idx + 1 < clean.length && out < 8) { - val byteStr = clean.substring(idx, idx + 2) - val b = byteStr.toIntOrNull(16)?.toByte() ?: 0 - result[out++] = b - idx += 2 - } - return result - } - - private fun bytesToPeerIdHex(bytes: ByteArray): String { - val sb = StringBuilder() - for (b in bytes.take(8)) { - sb.append(String.format("%02x", b)) - } - return sb.toString() - } } diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 5c786365..f65876e6 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -6,6 +6,9 @@ import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.util.PeerId +import com.bitchat.android.util.hexToByteArray +import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import java.util.concurrent.ConcurrentHashMap @@ -101,7 +104,7 @@ class GossipSyncManager( if (!isBroadcastMessage && !isAnnouncement) return val idBytes = PacketIdUtil.computeIdBytes(packet) - val id = idBytes.joinToString("") { b -> "%02x".format(b) } + val id = idBytes.toHexString() if (isBroadcastMessage) { synchronized(messages) { @@ -122,7 +125,7 @@ class GossipSyncManager( return } // senderID is fixed-size 8 bytes; map to hex string for key - val sender = packet.senderID.joinToString("") { b -> "%02x".format(b) } + val sender = packet.senderID.toHexString() latestAnnouncementByPeer[sender] = id to packet // Enforce capacity (remove oldest when exceeded) val cap = configProvider.seenCapacity().coerceAtLeast(1) @@ -138,7 +141,7 @@ class GossipSyncManager( val packet = BitchatPacket( type = MessageType.REQUEST_SYNC.value, - senderID = hexStringToByteArray(myPeerID), + senderID = PeerId.toBytes(myPeerID), timestamp = System.currentTimeMillis().toULong(), payload = payload, ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only @@ -153,8 +156,8 @@ class GossipSyncManager( val packet = BitchatPacket( type = MessageType.REQUEST_SYNC.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(peerID), + senderID = PeerId.toBytes(myPeerID), + recipientID = PeerId.toBytes(peerID), timestamp = System.currentTimeMillis().toULong(), payload = payload, ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbor only @@ -177,7 +180,7 @@ class GossipSyncManager( // 1) Announcements: send latest per peerID if remote doesn't have them for ((_, pair) in latestAnnouncementByPeer.entries) { val (id, pkt) = pair - val idBytes = hexToBytes(id) + val idBytes = id.hexToByteArray() if (!mightContain(idBytes)) { // Send original packet unchanged to requester only (keep local TTL) val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) @@ -198,31 +201,6 @@ class GossipSyncManager( } } - private fun hexStringToByteArray(hexString: String): ByteArray { - val result = ByteArray(8) { 0 } - var tempID = hexString - var index = 0 - while (tempID.length >= 2 && index < 8) { - val hexByte = tempID.substring(0, 2) - val byte = hexByte.toIntOrNull(16)?.toByte() - if (byte != null) result[index] = byte - tempID = tempID.substring(2) - index++ - } - return result - } - - private fun hexToBytes(hex: String): ByteArray { - val clean = if (hex.length % 2 == 0) hex else "0$hex" - val out = ByteArray(clean.length / 2) - var i = 0 - while (i < clean.length) { - out[i/2] = clean.substring(i, i+2).toInt(16).toByte() - i += 2 - } - return out - } - private fun buildGcsPayload(): ByteArray { // Collect candidates: latest announcement per peer + recent broadcast messages val list = ArrayList() @@ -276,7 +254,7 @@ class GossipSyncManager( val toRemove = mutableListOf() synchronized(messages) { for ((id, message) in messages) { - val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } + val sender = message.senderID.toHexString() if (sender == peerID) toRemove.add(id) } } @@ -300,7 +278,7 @@ class GossipSyncManager( val idsToRemove = mutableListOf() synchronized(messages) { for ((id, message) in messages) { - val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } + val sender = message.senderID.toHexString() if (sender == key) { idsToRemove.add(id) } diff --git a/app/src/main/java/com/bitchat/android/sync/PacketIdUtil.kt b/app/src/main/java/com/bitchat/android/sync/PacketIdUtil.kt index 4396ff5a..d08956fe 100644 --- a/app/src/main/java/com/bitchat/android/sync/PacketIdUtil.kt +++ b/app/src/main/java/com/bitchat/android/sync/PacketIdUtil.kt @@ -1,7 +1,8 @@ package com.bitchat.android.sync import com.bitchat.android.protocol.BitchatPacket -import java.security.MessageDigest +import com.bitchat.android.util.Hashing +import com.bitchat.android.util.toHexString /** * Deterministic packet ID helper for sync purposes. @@ -11,21 +12,22 @@ import java.security.MessageDigest */ object PacketIdUtil { fun computeIdBytes(packet: BitchatPacket): ByteArray { - val md = MessageDigest.getInstance("SHA-256") - md.update(packet.type.toByte()) - md.update(packet.senderID) - // Timestamp as 8 bytes big-endian + val data = ByteArray(1 + packet.senderID.size + 8 + packet.payload.size) + var offset = 0 + data[offset++] = packet.type.toByte() + packet.senderID.copyInto(data, destinationOffset = offset) + offset += packet.senderID.size + val ts = packet.timestamp.toLong() for (i in 7 downTo 0) { - md.update(((ts ushr (i * 8)) and 0xFF).toByte()) + data[offset++] = ((ts ushr (i * 8)) and 0xFF).toByte() } - md.update(packet.payload) - val digest = md.digest() - return digest.copyOf(16) // 128-bit ID + packet.payload.copyInto(data, destinationOffset = offset) + + return Hashing.sha256(data).copyOf(16) // 128-bit ID } fun computeIdHex(packet: BitchatPacket): String { - return computeIdBytes(packet).joinToString("") { b -> "%02x".format(b) } + return computeIdBytes(packet).toHexString() } } - diff --git a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt index 5cc1375c..6e082b20 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt @@ -2,7 +2,6 @@ package com.bitchat.android.ui import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import java.security.MessageDigest import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 786e7e91..fc47828d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -30,7 +30,6 @@ import com.bitchat.android.noise.NoiseSession import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.util.dataFromHexString import com.bitchat.android.util.hexEncodedString -import java.security.MessageDigest /** * Refactored ChatViewModel - Main coordinator for bitchat functionality diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index 8de53239..4703c3b4 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -5,8 +5,8 @@ import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.util.Hashing import java.util.Date -import java.security.MessageDigest /** * Handles media file sending operations (voice notes, images, generic files) @@ -181,8 +181,8 @@ class MediaSendingManager( } Log.d(TAG, "🔒 Encoded private packet: ${payload.size} bytes") - val transferId = sha256Hex(payload) - val contentHash = sha256Hex(filePacket.content) + val transferId = Hashing.sha256Hex(payload) + val contentHash = Hashing.sha256Hex(filePacket.content) Log.d(TAG, "📤 FILE_TRANSFER send (private): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, to=${toPeerID.take(8)} transferId=${transferId.take(16)}…") @@ -232,8 +232,8 @@ class MediaSendingManager( } Log.d(TAG, "🔓 Encoded broadcast packet: ${payload.size} bytes") - val transferId = sha256Hex(payload) - val contentHash = sha256Hex(filePacket.content) + val transferId = Hashing.sha256Hex(payload) + val contentHash = Hashing.sha256Hex(filePacket.content) Log.d(TAG, "📤 FILE_TRANSFER send (broadcast): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, transferId=${transferId.take(16)}…") @@ -339,11 +339,4 @@ class MediaSendingManager( } } - private fun sha256Hex(bytes: ByteArray): String = try { - val md = MessageDigest.getInstance("SHA-256") - md.update(bytes) - md.digest().joinToString("") { "%02x".format(it) } - } catch (_: Exception) { - bytes.size.toString(16) - } } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index d27719c7..8111cb28 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -5,6 +5,7 @@ import com.bitchat.android.ui.NotificationTextUtils import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.util.toHexString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.util.Date @@ -130,7 +131,7 @@ class MeshDelegateHandler( favs.firstNotNullOfOrNull { rel -> rel.peerNostrPublicKey?.let { s -> runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec -> - if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null + if (dec.first == "npub") dec.second.toHexString() else null } } }?.takeIf { it.startsWith(prefix, ignoreCase = true) } @@ -154,7 +155,7 @@ class MeshDelegateHandler( } catch (_: Exception) { null } if (favoriteRel?.isMutual == true) { - val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + val noiseHex = favoriteRel.peerNoisePublicKey.toHexString() if (noiseHex != currentPeer) { com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer( state = state, @@ -175,14 +176,14 @@ class MeshDelegateHandler( try { val info = getPeerInfo(pid) val noiseKey = info?.noisePublicKey ?: return@forEach - val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } + val noiseHex = noiseKey.toHexString() // Derive temp nostr key from favorites npub val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey) val tempNostrKey: String? = try { if (npub != null) { val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub) - if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null + if (hrp == "npub") "nostr_${data.toHexString().take(16)}" else null } else null } catch (_: Exception) { null } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index e5688a4e..3087ece6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -2,6 +2,7 @@ package com.bitchat.android.ui import com.bitchat.android.R import android.util.Log +import com.bitchat.android.util.toHexString import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -333,7 +334,7 @@ fun PeopleSection( // Build mapping of connected peerID -> noise key hex to unify with offline favorites val noiseHexByPeerID: Map = connectedPeers.associateWith { pid -> try { - viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.toHexString() } catch (_: Exception) { null } }.filterValues { it != null }.mapValues { it.value!! } @@ -367,7 +368,7 @@ fun PeopleSection( // Offline favorites (exclude ones mapped to connected) val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + val favPeerID = fav.peerNoisePublicKey.toHexString() val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } if (!isMappedToConnected) { val dn = peerNicknames[favPeerID] ?: fav.peerNickname @@ -435,7 +436,7 @@ fun PeopleSection( // Append offline favorites we actively favorite (and not currently connected) offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + val favPeerID = fav.peerNoisePublicKey.toHexString() // If any connected peer maps to this noise key, skip showing the offline entry val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } if (isMappedToConnected) return@forEach @@ -446,7 +447,7 @@ fun PeopleSection( if (npubOrHex != null) { val hex = if (npubOrHex.startsWith("npub")) { val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex) - if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null + if (hrp == "npub") data.toHexString() else null } else { npubOrHex.lowercase() } diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index af2882bb..f359fed3 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -3,7 +3,9 @@ package com.bitchat.android.ui import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.mesh.PeerFingerprintManager -import java.security.MessageDigest +import com.bitchat.android.util.Hashing +import com.bitchat.android.util.hexToByteArray +import com.bitchat.android.util.toHexString import com.bitchat.android.mesh.BluetoothMeshService import java.util.* @@ -131,10 +133,8 @@ class PrivateChatManager( // compute a synthetic fingerprint (SHA-256 of public key) to allow unfollowing offline peers if (fingerprint == null && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { try { - val pubBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - val digest = java.security.MessageDigest.getInstance("SHA-256") - val fpBytes = digest.digest(pubBytes) - fingerprint = fpBytes.joinToString("") { "%02x".format(it) } + val pubBytes = peerID.hexToByteArray() + fingerprint = Hashing.sha256Hex(pubBytes) Log.d(TAG, "Computed fingerprint from noise key hex for offline toggle: $fingerprint") } catch (e: Exception) { Log.w(TAG, "Failed to compute fingerprint from noise key hex: ${e.message}") @@ -432,13 +432,13 @@ class PrivateChatManager( // If we know the sender's Nostr pubkey for this peer via favorites, derive temp key try { - val noiseKeyBytes = targetPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val noiseKeyBytes = targetPeerID.hexToByteArray() val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKeyBytes) if (npub != null) { // Normalize to hex to match how we formed temp keys (nostr_) val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub) if (hrp == "npub") { - val pubHex = data.joinToString("") { "%02x".format(it) } + val pubHex = data.toHexString() tryMergeKeys.add("nostr_${pubHex.take(16)}") } } diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt index 2b0d503f..cde3eefe 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -9,6 +9,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.noise.NoiseSession import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.services.VerificationService +import com.bitchat.android.util.Hashing import com.bitchat.android.util.dataFromHexString import com.bitchat.android.util.hexEncodedString import kotlinx.coroutines.CoroutineScope @@ -16,7 +17,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.security.MessageDigest import java.util.Date import java.util.concurrent.ConcurrentHashMap @@ -333,8 +333,7 @@ class VerificationHandler( } fun fingerprintFromNoiseBytes(bytes: ByteArray): String { - val hash = MessageDigest.getInstance("SHA-256").digest(bytes) - return hash.hexEncodedString() + return Hashing.sha256Hex(bytes) } private data class PendingVerification( diff --git a/app/src/main/java/com/bitchat/android/util/BinaryEncodingUtils.kt b/app/src/main/java/com/bitchat/android/util/BinaryEncodingUtils.kt index 549f5971..ce564eca 100644 --- a/app/src/main/java/com/bitchat/android/util/BinaryEncodingUtils.kt +++ b/app/src/main/java/com/bitchat/android/util/BinaryEncodingUtils.kt @@ -12,24 +12,11 @@ import java.util.* // MARK: - Hex Encoding/Decoding Extensions fun ByteArray.hexEncodedString(): String { - if (this.isEmpty()) { - return "" - } - return this.joinToString("") { "%02x".format(it) } + return Hex.encode(this) } fun String.dataFromHexString(): ByteArray? { - val len = this.length / 2 - val data = ByteArray(len) - var index = 0 - - for (i in 0 until len) { - val hexByte = this.substring(i * 2, i * 2 + 2) - val byte = hexByte.toIntOrNull(16)?.toByte() ?: return null - data[index++] = byte - } - - return data + return Hex.decode(this) } // MARK: - Binary Encoding Utilities @@ -201,7 +188,7 @@ class BinaryDataReader(private val data: ByteArray) { offset += 16 // Convert 16 bytes to UUID string format - val uuid = uuidData.joinToString("") { "%02x".format(it) } + val uuid = uuidData.toHexString() // Insert hyphens at proper positions: 8-4-4-4-12 val result = StringBuilder() @@ -340,7 +327,7 @@ fun ByteArray.readUUID(at: IntArray): String? { at[0] += 16 // Convert 16 bytes to UUID string format - val uuid = uuidData.joinToString("") { "%02x".format(it) } + val uuid = uuidData.toHexString() // Insert hyphens at proper positions: 8-4-4-4-12 val result = StringBuilder() diff --git a/app/src/main/java/com/bitchat/android/util/ByteArrayExtensions.kt b/app/src/main/java/com/bitchat/android/util/ByteArrayExtensions.kt deleted file mode 100644 index b2acf112..00000000 --- a/app/src/main/java/com/bitchat/android/util/ByteArrayExtensions.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.bitchat.android.util - -/** - * Extension function to convert a ByteArray to a hexadecimal string. - */ -fun ByteArray.toHexString(): String { - return this.joinToString("") { "%02x".format(it) } -} diff --git a/app/src/main/java/com/bitchat/android/util/ByteArrayWrapper.kt b/app/src/main/java/com/bitchat/android/util/ByteArrayWrapper.kt index da078d3d..db63b67e 100644 --- a/app/src/main/java/com/bitchat/android/util/ByteArrayWrapper.kt +++ b/app/src/main/java/com/bitchat/android/util/ByteArrayWrapper.kt @@ -21,6 +21,6 @@ data class ByteArrayWrapper(val bytes: ByteArray) { } fun toHexString(): String { - return bytes.joinToString("") { "%02x".format(it) } + return Hex.encode(bytes) } } diff --git a/app/src/main/java/com/bitchat/android/util/Hashing.kt b/app/src/main/java/com/bitchat/android/util/Hashing.kt new file mode 100644 index 00000000..06e675d3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/Hashing.kt @@ -0,0 +1,31 @@ +package com.bitchat.android.util + +import java.io.InputStream +import java.security.MessageDigest + +object Hashing { + fun sha256(bytes: ByteArray): ByteArray { + return MessageDigest.getInstance("SHA-256").digest(bytes) + } + + fun sha256Hex(bytes: ByteArray): String { + return Hex.encode(sha256(bytes)) + } + + fun sha256Hex(text: String): String { + return sha256Hex(text.toByteArray(Charsets.UTF_8)) + } + + fun sha256Hex(input: InputStream): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + while (true) { + val read = input.read(buffer) + if (read <= 0) break + digest.update(buffer, 0, read) + } + + return Hex.encode(digest.digest()) + } +} diff --git a/app/src/main/java/com/bitchat/android/util/Hex.kt b/app/src/main/java/com/bitchat/android/util/Hex.kt new file mode 100644 index 00000000..6bf57345 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/Hex.kt @@ -0,0 +1,49 @@ +package com.bitchat.android.util + +object Hex { + private val digits = "0123456789abcdef".toCharArray() + + fun encode(bytes: ByteArray): String { + val chars = CharArray(bytes.size * 2) + var index = 0 + for (byte in bytes) { + val value = byte.toInt() and 0xFF + chars[index++] = digits[value ushr 4] + chars[index++] = digits[value and 0x0F] + } + return String(chars) + } + + fun decode(hex: String, allowOddLength: Boolean = false): ByteArray? { + val clean = hex.trim() + val normalized = when { + clean.length % 2 == 0 -> clean + allowOddLength -> "0$clean" + else -> return null + } + + val output = ByteArray(normalized.length / 2) + var inputIndex = 0 + var outputIndex = 0 + while (inputIndex < normalized.length) { + val high = normalized[inputIndex].digitToIntOrNull(16) ?: return null + val low = normalized[inputIndex + 1].digitToIntOrNull(16) ?: return null + output[outputIndex++] = ((high shl 4) or low).toByte() + inputIndex += 2 + } + return output + } + + fun decodeOrThrow(hex: String, allowOddLength: Boolean = false): ByteArray { + return decode(hex, allowOddLength) + ?: throw IllegalArgumentException("Invalid hex string") + } +} + +fun ByteArray.toHexString(): String = Hex.encode(this) + +fun String.hexToByteArray(): ByteArray = Hex.decodeOrThrow(this) + +fun String.hexToByteArrayOrNull(allowOddLength: Boolean = false): ByteArray? { + return Hex.decode(this, allowOddLength) +} diff --git a/app/src/main/java/com/bitchat/android/util/PeerId.kt b/app/src/main/java/com/bitchat/android/util/PeerId.kt new file mode 100644 index 00000000..2f7ccdfb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/PeerId.kt @@ -0,0 +1,38 @@ +package com.bitchat.android.util + +object PeerId { + const val BYTE_LENGTH = 8 + const val HEX_LENGTH = BYTE_LENGTH * 2 + + fun fromBytes(bytes: ByteArray): String { + return Hex.encode(bytes.copyOf(BYTE_LENGTH)) + } + + fun parse(peerIdHex: String): ByteArray? { + val clean = peerIdHex.trim() + if (clean.length != HEX_LENGTH) return null + return Hex.decode(clean)?.takeIf { it.size == BYTE_LENGTH } + } + + fun toBytes(peerIdHex: String): ByteArray { + val result = ByteArray(BYTE_LENGTH) + val clean = peerIdHex.trim() + var inputIndex = 0 + var outputIndex = 0 + + while (inputIndex + 1 < clean.length && outputIndex < BYTE_LENGTH) { + val value = clean.substring(inputIndex, inputIndex + 2).toIntOrNull(16) + if (value != null) { + result[outputIndex] = value.toByte() + } + inputIndex += 2 + outputIndex += 1 + } + + return result + } + + fun normalize(peerIdHex: String): String { + return fromBytes(toBytes(peerIdHex)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt index ae896e53..481ad464 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt @@ -4,6 +4,7 @@ package com.bitchat.android.mesh import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.PeerId import com.bitchat.android.util.toHexString import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -37,8 +38,8 @@ class PacketRelayManagerTest { private fun createPacket(route: List?, recipient: String? = null): BitchatPacket { return BitchatPacket( type = MessageType.MESSAGE.value, - senderID = hexStringToPeerBytes(otherPeerID), - recipientID = recipient?.let { hexStringToPeerBytes(it) }, + senderID = PeerId.toBytes(otherPeerID), + recipientID = recipient?.let { PeerId.toBytes(it) }, timestamp = System.currentTimeMillis().toULong(), payload = "hello".toByteArray(), ttl = 5u, @@ -49,8 +50,8 @@ class PacketRelayManagerTest { @Test fun `packet with duplicate hops is dropped`() = runTest { val route = listOf( - hexStringToPeerBytes(nextHopPeerID), - hexStringToPeerBytes(nextHopPeerID) + PeerId.toBytes(nextHopPeerID), + PeerId.toBytes(nextHopPeerID) ) val packet = createPacket(route) val routedPacket = RoutedPacket(packet, otherPeerID) @@ -64,8 +65,8 @@ class PacketRelayManagerTest { @Test fun `valid source-routed packet is relayed to next hop`() = runTest { val route = listOf( - hexStringToPeerBytes(myPeerID), - hexStringToPeerBytes(nextHopPeerID) + PeerId.toBytes(myPeerID), + PeerId.toBytes(nextHopPeerID) ) val packet = createPacket(route, finalRecipientID) val routedPacket = RoutedPacket(packet, otherPeerID) @@ -80,7 +81,7 @@ class PacketRelayManagerTest { @Test fun `last hop does not relay further`() = runTest { val route = listOf( - hexStringToPeerBytes(myPeerID) + PeerId.toBytes(myPeerID) ) val packet = createPacket(route, finalRecipientID) val routedPacket = RoutedPacket(packet, otherPeerID) @@ -103,15 +104,4 @@ class PacketRelayManagerTest { verify(delegate).broadcastPacket(any()) } - private fun hexStringToPeerBytes(hex: String): ByteArray { - val result = ByteArray(8) - var idx = 0 - var out = 0 - while (idx + 1 < hex.length && out < 8) { - val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0 - result[out++] = b - idx += 2 - } - return result - } } diff --git a/app/src/test/kotlin/com/bitchat/android/model/TlvPacketRegressionTest.kt b/app/src/test/kotlin/com/bitchat/android/model/TlvPacketRegressionTest.kt new file mode 100644 index 00000000..8cb208f7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/TlvPacketRegressionTest.kt @@ -0,0 +1,110 @@ +package com.bitchat.android.model + +import com.bitchat.android.protocol.TlvLengthSize +import com.bitchat.android.protocol.TlvWriter +import com.bitchat.android.services.meshgraph.GossipTLV +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class TlvPacketRegressionTest { + + @Test + fun `identity announcement skips unknown tlv fields`() { + val noiseKey = ByteArray(32) { 0x11 } + val signingKey = ByteArray(32) { 0x22 } + val encoded = TlvWriter() + .put(0x7f, byteArrayOf(0x55), TlvLengthSize.ONE_BYTE) + .put(0x01, "alice".toByteArray(), TlvLengthSize.ONE_BYTE) + .put(0x02, noiseKey, TlvLengthSize.ONE_BYTE) + .put(0x03, signingKey, TlvLengthSize.ONE_BYTE) + .toByteArray() + + val decoded = IdentityAnnouncement.decode(encoded) + + assertNotNull(decoded) + assertEquals("alice", decoded!!.nickname) + assertArrayEquals(noiseKey, decoded.noisePublicKey) + assertArrayEquals(signingKey, decoded.signingPublicKey) + } + + @Test + fun `private message rejects unknown tlv fields`() { + val encoded = TlvWriter() + .put(0x00, "msg-1".toByteArray(), TlvLengthSize.ONE_BYTE) + .put(0x7f, byteArrayOf(0x55), TlvLengthSize.ONE_BYTE) + .put(0x01, "hello".toByteArray(), TlvLengthSize.ONE_BYTE) + .toByteArray() + + assertNull(PrivateMessagePacket.decode(encoded)) + } + + @Test + fun `request sync skips unknown tlv fields and enforces payload cap`() { + val encoded = TlvWriter() + .put(0x7f, byteArrayOf(0x55), TlvLengthSize.TWO_BYTES) + .put(0x01, byteArrayOf(4), TlvLengthSize.TWO_BYTES) + .put(0x02, byteArrayOf(0, 0, 0, 16), TlvLengthSize.TWO_BYTES) + .put(0x03, byteArrayOf(1, 2, 3), TlvLengthSize.TWO_BYTES) + .toByteArray() + val tooLarge = TlvWriter() + .put(0x01, byteArrayOf(4), TlvLengthSize.TWO_BYTES) + .put(0x02, byteArrayOf(0, 0, 0, 16), TlvLengthSize.TWO_BYTES) + .put(0x03, ByteArray(RequestSyncPacket.MAX_ACCEPT_FILTER_BYTES + 1), TlvLengthSize.TWO_BYTES) + .toByteArray() + + val decoded = RequestSyncPacket.decode(encoded) + + assertNotNull(decoded) + assertEquals(4, decoded!!.p) + assertEquals(16L, decoded.m) + assertArrayEquals(byteArrayOf(1, 2, 3), decoded.data) + assertNull(RequestSyncPacket.decode(tooLarge)) + } + + @Test + fun `file packet uses four byte content length for large payloads`() { + val content = ByteArray(70_000) { (it % 251).toByte() } + val packet = BitchatFilePacket( + fileName = "large.bin", + fileSize = content.size.toLong(), + mimeType = "application/octet-stream", + content = content + ) + + val encoded = packet.encode() + val decoded = BitchatFilePacket.decode(encoded!!) + val contentTypeOffset = encoded.indexOf(0x04.toByte()) + val contentLength = + ((encoded[contentTypeOffset + 1].toInt() and 0xFF) shl 24) or + ((encoded[contentTypeOffset + 2].toInt() and 0xFF) shl 16) or + ((encoded[contentTypeOffset + 3].toInt() and 0xFF) shl 8) or + (encoded[contentTypeOffset + 4].toInt() and 0xFF) + + assertEquals(content.size, contentLength) + assertNotNull(decoded) + assertArrayEquals(content, decoded!!.content) + } + + @Test + fun `file packet rejects unknown tlv fields`() { + val encoded = TlvWriter() + .put(0x7f, byteArrayOf(0x55), TlvLengthSize.TWO_BYTES) + .put(0x01, "x.bin".toByteArray(), TlvLengthSize.TWO_BYTES) + .put(0x02, byteArrayOf(0, 0, 0, 1), TlvLengthSize.TWO_BYTES) + .put(0x03, "application/octet-stream".toByteArray(), TlvLengthSize.TWO_BYTES) + .put(0x04, byteArrayOf(1), TlvLengthSize.FOUR_BYTES) + .toByteArray() + + assertNull(BitchatFilePacket.decode(encoded)) + } + + @Test + fun `gossip tlv uses shared peer id and tlv helpers`() { + val encoded = GossipTLV.encodeNeighbors(listOf("0102030405060708", "aabbcc")) + + assertEquals(listOf("0102030405060708", "aabbcc0000000000"), GossipTLV.decodeNeighborsFromAnnouncementPayload(encoded)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/protocol/TlvTest.kt b/app/src/test/kotlin/com/bitchat/android/protocol/TlvTest.kt new file mode 100644 index 00000000..eb98b965 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/protocol/TlvTest.kt @@ -0,0 +1,74 @@ +package com.bitchat.android.protocol + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TlvTest { + + @Test + fun `writer and reader support one two and four byte lengths`() { + val oneByteValue = byteArrayOf(0x7f) + val twoByteValue = ByteArray(256) { it.toByte() } + val fourByteValue = ByteArray(70_000) { (it % 251).toByte() } + + val encoded = TlvWriter() + .put(0x01, oneByteValue, TlvLengthSize.ONE_BYTE) + .put(0x02, twoByteValue, TlvLengthSize.TWO_BYTES) + .put(0x03, fourByteValue, TlvLengthSize.FOUR_BYTES) + .toByteArray() + + val fields = TlvReader.decode(encoded, TlvLengthSize.ONE_BYTE) { type -> + when (type) { + 0x02 -> TlvLengthSize.TWO_BYTES + 0x03 -> TlvLengthSize.FOUR_BYTES + else -> TlvLengthSize.ONE_BYTE + } + } + + assertEquals(3, fields!!.size) + assertArrayEquals(oneByteValue, fields[0].value) + assertArrayEquals(twoByteValue, fields[1].value) + assertArrayEquals(fourByteValue, fields[2].value) + } + + @Test + fun `unknown tlv policy skips or fails consistently`() { + val encoded = TlvWriter() + .put(0x01, byteArrayOf(1), TlvLengthSize.ONE_BYTE) + .put(0x7f, byteArrayOf(9), TlvLengthSize.ONE_BYTE) + .put(0x02, byteArrayOf(2), TlvLengthSize.ONE_BYTE) + .toByteArray() + + val skipped = TlvReader.decode( + data = encoded, + defaultLengthSize = TlvLengthSize.ONE_BYTE, + unknownPolicy = UnknownTlvPolicy.SKIP, + knownTypes = setOf(0x01, 0x02) + ) + val failed = TlvReader.decode( + data = encoded, + defaultLengthSize = TlvLengthSize.ONE_BYTE, + unknownPolicy = UnknownTlvPolicy.FAIL, + knownTypes = setOf(0x01, 0x02) + ) + + assertEquals(listOf(0x01, 0x02), skipped!!.map { it.type }) + assertNull(failed) + } + + @Test + fun `reader rejects truncated values and writer rejects oversized values`() { + val truncated = byteArrayOf(0x01, 0x03, 0x41) + + assertNull(TlvReader.decode(truncated, TlvLengthSize.ONE_BYTE)) + + try { + TlvWriter().put(0x01, ByteArray(256), TlvLengthSize.ONE_BYTE) + throw AssertionError("Expected oversized one-byte TLV value to fail") + } catch (_: IllegalArgumentException) { + // Expected. + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/debug/DebugSettingsManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/debug/DebugSettingsManagerTest.kt new file mode 100644 index 00000000..9ae7f1fe --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/debug/DebugSettingsManagerTest.kt @@ -0,0 +1,94 @@ +package com.bitchat.android.ui.debug + +import android.os.Build +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class DebugSettingsManagerTest { + + private lateinit var manager: DebugSettingsManager + + @Before + fun setup() { + manager = DebugSettingsManager.getInstance() + manager.resetForTesting() + manager.setVerboseLoggingEnabled(true) + } + + @After + fun tearDown() { + manager.resetForTesting() + } + + @Test + fun `logIncoming emits one typed packet trace`() { + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 7u, + senderID = "aaaabbbbccccdddd", + payload = byteArrayOf(1, 2, 3) + ) + + manager.logIncoming( + packet = packet, + fromPeerID = "aaaabbbbccccdddd", + fromNickname = "alice", + fromDeviceAddress = "AA:BB:CC:DD:EE:FF", + myPeerID = "1111222233334444" + ) + + val packetMessages = manager.debugMessages.value.filterIsInstance() + + assertEquals("Incoming packet should create one packet event", 1, packetMessages.size) + assertTrue(packetMessages.single().content.contains("Incoming")) + assertTrue(packetMessages.single().content.contains("MESSAGE")) + } + + @Test + fun `logOutgoing relay emits one relay event without packet duplicate`() { + manager.logOutgoing( + packetType = "MESSAGE", + toPeerID = "bbbbccccddddeeee", + toNickname = "bob", + toDeviceAddress = "11:22:33:44:55:66", + previousHopPeerID = "aaaabbbbccccdddd", + packetVersion = 1u, + routeInfo = "routed: 2 hops", + ttl = 6u + ) + + val packetMessages = manager.debugMessages.value.filterIsInstance() + val relayMessages = manager.debugMessages.value.filterIsInstance() + + assertEquals("Relay send should not also create a packet event", 0, packetMessages.size) + assertEquals("Relay send should create one relay event", 1, relayMessages.size) + assertTrue(relayMessages.single().content.contains("Relay")) + assertTrue(relayMessages.single().content.contains("MESSAGE")) + } + + @Test + fun `validation failures are hidden when verbose logging is disabled`() { + manager.setVerboseLoggingEnabled(false) + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 7u, + senderID = "aaaabbbbccccdddd", + payload = byteArrayOf(1) + ) + + manager.logPacketValidationFailure(packet, "aaaabbbbccccdddd", "invalid signature") + + val validationMessages = manager.debugMessages.value.filterIsInstance() + assertEquals(0, validationMessages.size) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/HexPeerHashingTest.kt b/app/src/test/kotlin/com/bitchat/android/util/HexPeerHashingTest.kt new file mode 100644 index 00000000..1caf0a4f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/HexPeerHashingTest.kt @@ -0,0 +1,58 @@ +package com.bitchat.android.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HexPeerHashingTest { + + @Test + fun `hex encodes lowercase and decodes mixed case`() { + val bytes = byteArrayOf(0x00, 0x0f, 0x10, 0x7f, 0xff.toByte()) + + assertEquals("000f107fff", Hex.encode(bytes)) + assertArrayEquals(bytes, Hex.decode("000F107fFF")) + assertEquals("000f107fff", bytes.toHexString()) + assertArrayEquals(bytes, "000f107fff".hexToByteArray()) + } + + @Test + fun `hex decode rejects malformed input unless odd length is explicitly allowed`() { + assertNull(Hex.decode("abc")) + assertArrayEquals(byteArrayOf(0x0a, 0xbc.toByte()), Hex.decode("abc", allowOddLength = true)) + assertNull(Hex.decode("00xx")) + assertNull("00xx".hexToByteArrayOrNull()) + } + + @Test + fun `peer id parsing is fixed width with truncation and zero fill`() { + assertArrayEquals( + byteArrayOf(0x11, 0x22, 0x00, 0x44, 0, 0, 0, 0), + PeerId.toBytes("1122zz44") + ) + assertArrayEquals( + byteArrayOf(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77), + PeerId.toBytes("00112233445566778899") + ) + assertEquals("1122004400000000", PeerId.normalize("1122zz44")) + } + + @Test + fun `peer id strict parse requires exactly eight valid bytes`() { + val bytes = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7) + + assertArrayEquals(bytes, PeerId.parse("0001020304050607")) + assertEquals("0001020304050607", PeerId.fromBytes(bytes)) + assertNull(PeerId.parse("000102")) + assertNull(PeerId.parse("00010203040506xx")) + } + + @Test + fun `hashing sha256 hex matches known vector`() { + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + Hashing.sha256Hex("abc") + ) + } +} diff --git a/docs/BCH-01-report.pdf b/docs/BCH-01-report.pdf new file mode 100644 index 00000000..53f17efe Binary files /dev/null and b/docs/BCH-01-report.pdf differ diff --git a/docs/CODEX_MESH_CORE_REFACTOR_PLAN.md b/docs/CODEX_MESH_CORE_REFACTOR_PLAN.md new file mode 100644 index 00000000..f8846a93 --- /dev/null +++ b/docs/CODEX_MESH_CORE_REFACTOR_PLAN.md @@ -0,0 +1,69 @@ +# Mesh Core Refactor Plan + +Goal: unify BLE + Wi-Fi Aware mesh services behind a shared core to reduce duplication, improve readability, and make auditing easier without changing runtime behavior. + +## Phase 0: Inventory + Guardrails +- [x] Capture current public API surface used by UI/services (identify required methods/properties). +- [x] Identify transport-specific responsibilities vs shared mesh-core logic. +- [x] Decide how to share GossipSyncManager (keep existing holder behavior unless explicitly improved). + +## Phase 1: Shared Types + Utilities +- [x] Introduce `MeshDelegate` in `com.bitchat.android.mesh` and make both BLE/Wi‑Fi delegates type-alias it. +- [x] Add `MeshTransport` interface to abstract local transport send/unicast and optional info hooks. +- [x] Extract `MeshPacketUtils` (hex id parsing, sha256 helper) used by both services. +- [x] Add unit tests for `MeshPacketUtils` (hex parsing + sha256 stability). + +## Phase 2: MeshCore (Shared Coordinator) +- [x] Create `MeshCore` with shared managers (Peer/Fragment/Security/StoreForward/Message/Packet). +- [x] Move shared delegate wiring into `MeshCore` with hooks for transport-specific behavior: + - onMessageReceived (BLE notification / AppStateStore) + - onPeerIdBinding (BLE favorites mapping) + - onAnnounceProcessed (BLE first-announce address map) +- [x] Centralize shared packet signing + announce/leave creation. +- [x] Centralize message/file/read-receipt senders (public + private). +- [x] Expose helper methods for peer info, session state, debug info, and panic-mode clearing. + +## Phase 3: BluetoothMeshService Integration +- [x] Replace duplicated core fields with a single `MeshCore` instance. +- [x] Wire `BluetoothConnectionManager` callbacks into `MeshCore` packet ingestion. +- [x] Move periodic announce + gossip start/stop into `MeshCore` usage. +- [x] Preserve BLE-only behaviors (connection manager control, background notifications). +- [x] Keep `MeshServiceHolder` semantics intact (reusability + shared gossip manager). + +## Phase 4: WifiAwareMeshService Integration +- [x] Replace duplicated core fields with a `MeshCore` instance. +- [x] Route Wi‑Fi socket RX into `MeshCore` packet ingestion. +- [x] Keep Wi‑Fi aware discovery/connection logic transport-specific. +- [x] Ensure gossip sync usage matches current shared behavior. + +## Phase 5: Cleanup + Consistency +- [x] Remove legacy duplicate helpers + delegates from both services. +- [x] Ensure debug status output stays consistent (core + transport sections). +- [x] Confirm behavior parity for announce/leave + handshake flows. + +## Phase 6: Tests + Verification +- [x] Run unit tests: `./gradlew test` (or targeted if full suite is too slow). +- [x] If new tests fail, iterate until green. + +## Status Tracking +- [x] Phase 0 complete +- [x] Phase 1 complete +- [x] Phase 2 complete +- [x] Phase 3 complete +- [x] Phase 4 complete +- [x] Phase 5 complete +- [x] Phase 6 complete + +## Post-Refactor Review Findings (2025-02-XX) + +Findings (confirmed issues to fix): +- Shared `GossipSyncManager` is stopped when Wi‑Fi Aware stops, even if BLE is still active. +- Shared `GossipSyncManager` unicast (`sendPacketToPeer`) only reaches BLE peers; Wi‑Fi Aware peers miss initial sync. +- Read receipts are sent only via BLE in UI paths; Wi‑Fi Aware-only sessions never send receipts. + +TODO (fix plan): +- [x] MeshCore: guard `gossipSyncManager.start/stop` so only the owner transport controls lifecycle. +- [x] TransportBridgeService: add unicast forwarding across transports; update BLE/Wi‑Fi transport layers to support it. +- [x] MeshCore: when owning gossip manager, forward unicast sync packets to other transports via bridge. +- [x] UI: route read receipts via BLE if session established, else via Wi‑Fi Aware when available. +- [x] Tests: add unit tests for `TransportBridgeService` unicast forwarding and run them. diff --git a/docs/CODEX_WIFI_FIX_PLAN.md b/docs/CODEX_WIFI_FIX_PLAN.md new file mode 100644 index 00000000..3ccf954d --- /dev/null +++ b/docs/CODEX_WIFI_FIX_PLAN.md @@ -0,0 +1,18 @@ +# Wi‑Fi Aware + BLE Mesh Fix Plan + +## Goals +- One canonical, merged peer list across BLE + Wi‑Fi Aware. +- Packets relay seamlessly across transports without flapping or missing peers. +- ANNOUNCE and peer mapping remain consistent, regardless of transport. + +## TODO Tracker +- [x] Add a centralized mesh peer registry and merge BLE + Wi‑Fi Aware peer lists. +- [x] Route peer list updates through the registry (BLE + Wi‑Fi Aware). +- [x] Remove UI state overwrites of connected peers; use merged list for cleanup/notifications/outbox. +- [x] Bridge relay across transports (BLE → Wi‑Fi Aware and Wi‑Fi Aware → BLE). +- [ ] Verify peer list stability and cross‑transport routing with mixed peers. + +## Implementation Notes +- Registry should own the merged list and update `AppStateStore` only from the merged result. +- Relay bridging should occur at `PacketProcessorDelegate.relayPacket(...)` to keep relay logic centralized. +- Avoid new loops/duplication: reuse existing TTL/relay rules and do not mutate the routed packet. diff --git a/docs/REVIEW_FOREGROUND_SERVICE.md b/docs/REVIEW_FOREGROUND_SERVICE.md new file mode 100644 index 00000000..d559fe81 --- /dev/null +++ b/docs/REVIEW_FOREGROUND_SERVICE.md @@ -0,0 +1,189 @@ +**Summary** + +I reviewed the background/foreground service branch against `main` focusing on mesh, relay, DMs, handshake and lifecycle. Overall the architecture moves the mesh into a persistent Foreground Service with a notification and centralizes a single `BluetoothMeshService` via a process‑wide holder. The BLE layer and power manager refactors look solid. + +I found several lifecycle and policy gaps that can break behavior when the app is in the background or is killed, and a couple of leaks/over‑engineering hotspots. Below are concrete issues and recommendations with file references. + +**Key Risks and Issues** + +- Foreground toggle doesn’t control mesh start/stop + - `MeshForegroundService.ensureMeshStarted()` starts the mesh regardless of the “background enabled” preference or notification permission. + - Impact: Mesh can run without a visible foreground notification (policy risk) or continue running after the user disables background, causing hidden BLE activity. + - Where: + - `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:195` (ensureMeshStarted only checks BT perms) + - `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:176` (loop updates notif but never stops mesh when disabled/perms missing) + +- ACTION_STOP doesn’t stop the mesh + - The STOP action stops only the foreground service and not the BLE mesh. + - Impact: BLE continues to scan/advertise without a notification, violating FGS expectations and confusing users. + - Where: `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:133` (STOP path does not call `meshService.stopServices()`) + +- Service destroy doesn’t clean up the mesh + - `onDestroy()` cancels a job but does not stop the mesh or clear the holder. + - Impact: Potentially leaves the mesh running if the FGS is torn down (e.g., the system stops the service), and can lead to a stale singleton. + - Where: `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:234` + +- Possible foreground service policy violation window + - In the periodic loop, if background is disabled or POST_NOTIFICATIONS is missing, the code stops the foreground state and cancels the notification, but it does not stop the mesh. + - Impact: Mesh continues running in background with no notification. This is high risk for Play policy and Android OS behavior (more aggressive kills). + - Where: `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:182-191` + +- BluetoothPacketBroadcaster actor scope leak + - `BluetoothPacketBroadcaster` creates its own `broadcasterScope` with a fresh `SupervisorJob()` instead of using the connection scope passed into the manager. It’s never cancelled in `stopServices()`. + - Impact: Actor and jobs can outlive the connection manager/service, causing leaks and unexpected broadcast attempts. + - Where: + - `app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt:55` (ctor receives a scope) + - `app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt:98` (creates new `broadcasterScope` ignoring the injected scope) + - No corresponding cancellation on stop in `BluetoothConnectionManager.stopServices()`. + +- Boot completed auto-start edge cases + - Auto-start runs on BOOT_COMPLETED and LOCKED_BOOT_COMPLETED. + - On Android 13+, if POST_NOTIFICATIONS isn’t granted, `MeshForegroundService.start()` won’t start (correct), but there’s no fallback scheduling (e.g., WorkManager) to retry foreground promotion once the user unlocks or grants permission later. + - Impact: Users expecting auto-start may never get the mesh until they open the app once or grant notifications via a separate action. + - Where: + - `app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt:10-19` + - `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:37-63` (strict start gating on notif permission) + +- Multiple starters and race potential + - Both `BitchatApplication` and `MainActivity` attempt to start the foreground service and/or get/create the mesh. + - Impact: It works because the holder dedupes instances and `startServices()` is idempotent, but the responsibility boundaries are muddled and harder to reason about; risk of start during missing perms or unwanted background. + - Where: + - `app/src/main/java/com/bitchat/android/BitchatApplication.kt:42-46` + - `app/src/main/java/com/bitchat/android/MainActivity.kt:47-67` + +- Mesh can run without an attached UI delegate + - Intentional, and DM notifications are handled via `serviceNotificationManager` if no UI delegate is present. + - Risk: Good for background DMs, but be sure delivery acks/read receipts don’t depend on UI-only state. + - Where: + - `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt:391-411` (use of `serviceNotificationManager` when delegate is null) + +- Android 14 FGS restrictions timing window + - `startForegroundService()` requires calling `startForeground()` within ~5 seconds. Primary start path builds the notification right away, but the retry/promotion logic in the update loop could call `startForeground()` multiple times or after a delay. + - Impact: While caught in try/catch, repeated `startForeground()` attempts aren’t ideal; more deterministic promotion improves reliability. + - Where: + - `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:168-174` (initial call OK) + - `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:178-187` (repeated attempts in loop) + +- Over-permissive defaults + - Defaults enable background and auto-start. Without an explicit UX flow, users may unexpectedly run BLE activity until they disable background. + - Recommendation: Default background OFF on fresh installs, and require explicit opt‑in. + - Where: + - `app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt:20-32` + +- Minor: Notification permission “grant” handling depends on an in‑app broadcast + - `NotificationPermissionChangedReceiver` listens for `ACTION_NOTIFICATION_PERMISSION_GRANTED` but depends on some component actually broadcasting it. + - Impact: If no component sends the broadcast after permission grant, the FGS won’t auto‑promote. + - Where: + - `app/src/main/java/com/bitchat/android/service/NotificationPermissionChangedReceiver.kt:10-18` + +**What Might Not Work Reliably In Background** + +- Packet relay and scanning without a persistent FGS + - If background preference is disabled or notification permission is missing, the mesh can still be started by `ensureMeshStarted()` and keep running without a foreground notification. Android may restrict scan/advertise in Doze, leading to reduced connectivity, delayed DMs, and poor relay propagation. + +- “Stop service” paths + - Using the STOP notification action does not stop the mesh; users think they stopped the app, but BLE stays active. This can cause unexpected background behavior and policy problems. + +- Restart after process kill or reboot + - Reboot auto-start requires notification permission; otherwise it silently does nothing. There’s no queued retry to promote the service after the user unlocks/changes permission without opening the app. + +- Actor/broadcaster lifecycle + - The broadcaster actor may outlive the connection manager/service and could keep jobs alive, causing unexpected logs or work after shutdown. + +- Foreground service promotion timing/retries + - Promotion attempts in the periodic loop could miss the 5s window or re‑invoke `startForeground()` unnecessarily. + +**Recommendations** + +- Enforce mesh lifecycle to foreground state + - Start/stop mesh strictly based on foreground eligibility and user preference: + - Only start mesh when both background is enabled and all required permissions are present. + - Stop mesh immediately when background is disabled or notification permission is revoked. + - Where/How: + - Update `ensureMeshStarted()` to require `MeshServicePreferences.isBackgroundEnabled(true)` and `hasNotificationPermission()` in addition to BT perms before starting the mesh. Also add a symmetric `ensureMeshStopped()` when not eligible. + - `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:192` + - In the periodic loop, if background is disabled or permissions are missing, also stop the mesh, not just the foreground/notification. + - `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:182-191` + +- Fix STOP and onDestroy behavior + - STOP action should stop the mesh and clear the holder. + - `onDestroy()` should best‑effort stop mesh if this service is the owner. + - Where/How: + - `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:133` — call `meshService?.stopServices()` and `MeshServiceHolder.clear()` before `stopSelf()`. + - `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:234` — on destroy, if foreground eligibility is off, call `meshService?.stopServices()`; consider a guard not to kill if another visible Activity is using it (see next). + +- Introduce simple ownership/reference accounting for `MeshServiceHolder` + - Avoid ambiguous ownership between Activity and FGS: + - Track “UI attached” vs “FGS attached”; only stop underlying mesh when both detach, or when policy requires it (user disabled background). + - Where/How: + - Extend `MeshServiceHolder` with simple ref counts or flags to indicate active owners; add `attach(type)`/`detach(type)`. + - `app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt` + - In `MainActivity.onStart/onStop` attach/detach the UI owner; in `MeshForegroundService.onCreate/onDestroy` attach/detach the service owner. + +- Use a single, parent scope to avoid leaks + - Make `BluetoothPacketBroadcaster` use the injected connection scope instead of creating its own `SupervisorJob()`. + - Where/How: + - `app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt:98` — replace `broadcasterScope` with the passed `connectionScope` and remove the extra job. Also ensure any actors/jobs are cancelled when `BluetoothConnectionManager.stopServices()` cancels its scope. + +- Tighten foreground promotion logic + - Avoid repeated `startForeground()` calls in the update loop. Promote once on start when eligible; afterwards only call `NotificationManagerCompat.notify()`. + - Where/How: + - `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:176-187` — keep a local `isInForeground` flag; only call `startForeground()` on the transition from not‑in‑FG to in‑FG. Use `notify()` for updates. + +- Improve boot/permission retry behavior + - When auto-start is enabled but notification permission is missing: + - Consider scheduling a WorkManager task that re‑evaluates eligibility on unlock or app launch and then promotes the FGS, or rely on an in‑app signal sent by permission request flows. + - Where/How: + - `app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt:10-19` + - Ensure the onboarding/permission flow emits `ACTION_NOTIFICATION_PERMISSION_GRANTED` broadcast upon grant so `NotificationPermissionChangedReceiver` can promote immediately. + +- Default background OFF on fresh installs + - Reduce surprises and align with Play policy expectations by requiring explicit opt‑in to run in background. + - Where/How: + - Change defaults in `MeshServicePreferences`: + - `app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt:20-32` — set default `false` for `BACKGROUND_ENABLED` and possibly `AUTO_START`. + +- Optional: Consolidate service/mesh creation path + - To reduce races and complexity: + - Let the foreground service own `BluetoothMeshService` creation, and have the UI bind to it via the holder. + - Remove the eager start from `BitchatApplication`, and let `MainActivity` only request start of the FGS (which in turn creates/adopts the mesh). + - Where/How: + - `app/src/main/java/com/bitchat/android/BitchatApplication.kt:42-46` — consider removing the proactive start or guard behind explicit background enabled + permission checks. + +- Validate read receipt/DM flows without UI + - Current design shows DM notifications via the service when `delegate == null`; that’s good. + - Ensure delivery acks and handshake retries don’t rely on UI-only paths. + - Where/How: + - Review and, if needed, mirror “UI-triggered” handshake initiation logic inside `BluetoothMeshService` for background-only contexts. + - `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt:789, 873, 1086-1090` + +**Minor Polish** + +- Consider adding a user-visible state in the persistent notification when background is disabled or permissions are missing, so the user can tap to enable. + - Where: `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:208-240` + +- Consider scoping `updateJob` to `serviceJob` and guarding multiple starters. + - Where: `app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt:27, 158-196` + +- Revisit scan/advertise settings in Doze with FGS active + - PowerManager duty cycle is good; validate real‑world scan rates under Doze while FGS is active and adjust thresholds if relay connectivity drops. + - Where: `app/src/main/java/com/bitchat/android/mesh/PowerManager.kt` + +**Suggested Acceptance Criteria After Fixes** + +- When background is ON and notifications are granted: + - Mesh starts, persistent notification is visible, scanning/advertising work after swiping the app away. +- When background is OFF or notifications are revoked: + - Foreground service and mesh both stop; no BLE activity remains. +- STOP action fully stops mesh and closes the foreground service. +- After reboot, if background is ON and notification permission is granted: + - Foreground service starts and mesh runs. If not granted, it defers cleanly until the user grants. +- No coroutine/actor leaks after stop/restart cycles: + - Logs cleanly shut down and no lingering “broadcaster actor” messages after stopping. + +If you want, I can implement the specific code changes for: +- stopping mesh on STOP/onDestroy, +- gating `ensureMeshStarted()` and adding `ensureMeshStopped()`, +- fixing `BluetoothPacketBroadcaster` to use the injected scope, +- tightening `startForeground()` promotion logic. +