diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 1f0781ae..d43578b5 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -112,8 +112,9 @@ class MainActivity : OrientationAwareActivity() { // Ensure foreground service is running and get mesh instance from holder try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { } meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext) - // Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays - try { com.bitchat.android.wifiaware.WifiAwareController.setBleMeshService(meshService) } catch (_: Exception) { } + // Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays - DEPRECATED + // Bridging is now handled by TransportBridgeService automatically + bluetoothStatusManager = BluetoothStatusManager( activity = this, context = this, @@ -180,7 +181,7 @@ class MainActivity : OrientationAwareActivity() { chatViewModel.didReceiveMessage(message) } override fun didUpdatePeerList(peers: List) { - chatViewModel.didUpdatePeerList(peers) + chatViewModel.onWifiPeersUpdated(peers) } override fun didReceiveChannelLeave(channel: String, fromPeer: String) { chatViewModel.didReceiveChannelLeave(channel, fromPeer) 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 c48ce087..bc920f9b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -13,6 +13,7 @@ import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString +import com.bitchat.android.service.TransportBridgeService import kotlinx.coroutines.* import java.util.* import kotlin.math.sign @@ -31,7 +32,7 @@ import kotlin.random.Random * - BluetoothConnectionManager: BLE connections and GATT operations * - PacketProcessor: Incoming packet routing */ -class BluetoothMeshService(private val context: Context) { +class BluetoothMeshService(private val context: Context) : TransportBridgeService.TransportLayer { private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { @@ -101,9 +102,15 @@ class BluetoothMeshService(private val context: Context) { // Wire sync manager delegate gossipSyncManager.delegate = object : GossipSyncManager.Delegate { override fun sendPacket(packet: BitchatPacket) { - connectionManager.broadcastPacket(RoutedPacket(packet)) + dispatchGlobal(RoutedPacket(packet)) } override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + // Point-to-point optimization if possible, but for bridge safety + // we might want to consider dispatchGlobal if peer is on another transport. + // However, sendPacketToPeer in connectionManager is BLE-specific unicast. + // If peer is on Wi-Fi, this won't reach. + // For now, let's keep unicast as-is (it's mostly for sync) + // and assume routing handles the rest via broadcasts if needed. connectionManager.sendPacketToPeer(peerID, packet) } override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { @@ -111,6 +118,26 @@ class BluetoothMeshService(private val context: Context) { } } Log.d(TAG, "Delegates set up; GossipSyncManager initialized") + + // Register with cross-layer transport bridge + TransportBridgeService.register("BLE", this) + } + + // TransportLayer implementation + override fun send(packet: RoutedPacket) { + // Received from bridge (e.g. Wi-Fi) -> Send via BLE + // Direct injection prevents routing loops (bridge handles source check) + connectionManager.broadcastPacket(packet) + } + + /** + * unified dispatch: Send to local BLE and bridge to other transports + */ + private fun dispatchGlobal(routed: RoutedPacket) { + // 1. Send to local BLE transport + connectionManager.broadcastPacket(routed) + // 2. Bridge to other transports (e.g. Wi-Fi) + TransportBridgeService.broadcast("BLE", routed) } /** @@ -164,8 +191,6 @@ class BluetoothMeshService(private val context: Context) { // PeerManager delegates to main mesh service delegate peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { - // Update process-wide state first - try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { } // Then notify UI delegate if attached delegate?.didUpdatePeerList(peerIDs) } @@ -208,7 +233,7 @@ class BluetoothMeshService(private val context: Context) { ) // Sign the handshake response val signedPacket = signPacketBeforeBroadcast(responsePacket) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)") } @@ -228,7 +253,7 @@ class BluetoothMeshService(private val context: Context) { } override fun sendPacket(packet: BitchatPacket) { - connectionManager.broadcastPacket(RoutedPacket(packet)) + dispatchGlobal(RoutedPacket(packet)) } } @@ -272,11 +297,11 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) val routed = RoutedPacket(signedPacket) - connectionManager.broadcastPacket(routed) + dispatchGlobal(routed) } override fun relayPacket(routed: RoutedPacket) { - connectionManager.broadcastPacket(routed) + dispatchGlobal(routed) } override fun getBroadcastRecipient(): ByteArray { @@ -323,7 +348,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the handshake packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)") } else { Log.w(TAG, "Failed to generate Noise handshake data for $peerID") @@ -515,7 +540,7 @@ class BluetoothMeshService(private val context: Context) { } override fun relayPacket(routed: RoutedPacket) { - connectionManager.broadcastPacket(routed) + dispatchGlobal(routed) } override fun handleRequestSync(routed: RoutedPacket) { @@ -632,6 +657,9 @@ class BluetoothMeshService(private val context: Context) { Log.i(TAG, "Stopping Bluetooth mesh service") isActive = false + + // Unregister from bridge + TransportBridgeService.unregister("BLE") // Send leave announcement sendLeaveAnnouncement() @@ -691,7 +719,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) // Track our own broadcast message for sync try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } } @@ -723,7 +751,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) - connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } } } catch (e: Exception) { @@ -781,7 +809,7 @@ class BluetoothMeshService(private val context: Context) { val signed = signPacketBeforeBroadcast(packet) // Use a stable transferId based on the unencrypted file TLV payload for progress tracking val transferId = sha256Hex(filePayload) - connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID") } catch (e: Exception) { @@ -861,7 +889,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)") // FIXED: Don't send didReceiveMessage for our own sent messages @@ -932,7 +960,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") // Persist as read after successful send @@ -986,7 +1014,7 @@ class BluetoothMeshService(private val context: Context) { announcePacket.copy(signature = signature) } ?: announcePacket - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)") // Track announce for sync try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } @@ -1035,7 +1063,7 @@ class BluetoothMeshService(private val context: Context) { packet.copy(signature = signature) } ?: packet - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) peerManager.markPeerAsAnnouncedTo(peerID) Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)") @@ -1056,7 +1084,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) } /** diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt new file mode 100644 index 00000000..61c73c82 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -0,0 +1,69 @@ +package com.bitchat.android.service + +import android.util.Log +import com.bitchat.android.model.RoutedPacket +import java.util.concurrent.ConcurrentHashMap + +/** + * Central bridge for routing packets between different transport layers + * (e.g., Bluetooth LE <-> Wi-Fi Aware). + * + * Allows a packet received on one transport to be seamlessly relayed + * to all other active transports, effectively bridging separate meshes. + */ +object TransportBridgeService { + private const val TAG = "TransportBridgeService" + + /** + * Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement + * to receive bridged packets. + */ + interface TransportLayer { + /** + * Send a packet out via this transport. + */ + fun send(packet: RoutedPacket) + } + + private val transports = ConcurrentHashMap() + + /** + * Register a transport layer to receive bridged packets. + * @param id Unique identifier (e.g., "BLE", "WIFI") + * @param layer The transport implementation + */ + fun register(id: String, layer: TransportLayer) { + Log.i(TAG, "Registering transport layer: $id") + transports[id] = layer + } + + /** + * Unregister a transport layer. + */ + fun unregister(id: String) { + Log.i(TAG, "Unregistering transport layer: $id") + transports.remove(id) + } + + /** + * Broadcast a packet from a specific source transport to ALL other registered transports. + * + * @param sourceId The ID of the transport initiating the broadcast (e.g., "BLE"). + * The packet will NOT be sent back to this source. + * @param packet The packet to bridge. + */ + fun broadcast(sourceId: String, packet: RoutedPacket) { + val targets = transports.filterKeys { it != sourceId } + if (targets.isEmpty()) return + + // Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}") + + targets.forEach { (id, layer) -> + try { + layer.send(packet) + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge packet to $id: ${e.message}") + } + } + } +} 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 22345923..ee31ef74 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -744,6 +744,10 @@ class ChatViewModel( override fun didUpdatePeerList(peers: List) { meshDelegateHandler.didUpdatePeerList(peers) } + + fun onWifiPeersUpdated(peers: List) { + meshDelegateHandler.onWifiPeersUpdated(peers) + } override fun didReceiveChannelLeave(channel: String, fromPeer: String) { meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) 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 6e7d5d22..559ef11a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -94,104 +94,123 @@ class MeshDelegateHandler( } } + private var blePeers: Set = emptySet() + private var wifiPeers: Set = emptySet() + override fun didUpdatePeerList(peers: List) { coroutineScope.launch { - // Merge peers from multiple transports to avoid flapping - val current = state.getConnectedPeersValue().toMutableSet() - current.addAll(peers) - state.setConnectedPeers(current.toList()) - state.setIsConnected(peers.isNotEmpty()) - notificationManager.showActiveUserNotification(peers) - // Flush router outbox for any peers that just connected (and their noiseHex aliases) - runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) } + blePeers = peers.toSet() + processPeerUpdate() + } + } - // Clean up channel members who disconnected - channelManager.cleanupDisconnectedMembers(peers, getMyPeerID()) + fun onWifiPeersUpdated(peers: List) { + coroutineScope.launch { + wifiPeers = peers.toSet() + processPeerUpdate() + } + } - // Handle chat view migration based on current selection and new peer list - state.getSelectedPrivateChatPeerValue()?.let { currentPeer -> - val isNostrAlias = currentPeer.startsWith("nostr_") - val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) - val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) + private suspend fun processPeerUpdate() { + // Merge peers from multiple transports + val mergedPeers = (blePeers + wifiPeers).toList() + + // Update process-wide state as source of truth + try { com.bitchat.android.services.AppStateStore.setPeers(mergedPeers) } catch (_: Exception) { } - if (isNostrAlias || isNoiseHex) { - // Reverse case: Nostr/offline chat is open, and peer may have come online on mesh. - // Resolve canonical target (prefer connected mesh peer if available) - val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( - selectedPeerID = currentPeer, - connectedPeers = peers, - meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> peers.contains(pid) }, - nostrPubHexForAlias = { alias -> - // Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping - if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) { - com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) - } else { - // Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases - val prefix = alias.removePrefix("nostr_") - val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } - 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 - } + state.setConnectedPeers(mergedPeers) + state.setIsConnected(mergedPeers.isNotEmpty()) + notificationManager.showActiveUserNotification(mergedPeers) + + // Flush router outbox for any peers that just connected (and their noiseHex aliases) + runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(mergedPeers) } + + // Clean up channel members who disconnected + channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID()) + + // Handle chat view migration based on current selection and new peer list + state.getSelectedPrivateChatPeerValue()?.let { currentPeer -> + val isNostrAlias = currentPeer.startsWith("nostr_") + val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) + val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) + + if (isNostrAlias || isNoiseHex) { + // Reverse case: Nostr/offline chat is open, and peer may have come online on mesh. + // Resolve canonical target (prefer connected mesh peer if available) + val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( + selectedPeerID = currentPeer, + connectedPeers = mergedPeers, + meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey }, + meshHasPeer = { pid -> mergedPeers.contains(pid) }, + nostrPubHexForAlias = { alias -> + // Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping + if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) { + com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) + } else { + // Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases + val prefix = alias.removePrefix("nostr_") + val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } + 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 } - }?.takeIf { it.startsWith(prefix, ignoreCase = true) } - } - }, - findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } - ) - if (canonical != currentPeer) { - // Merge conversations and switch selection to the live mesh peer (or noiseHex) - com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer)) - state.setSelectedPrivateChatPeer(canonical) - } - } else if (isMeshEphemeral && !peers.contains(currentPeer)) { - // Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex) - val favoriteRel = try { - val info = getPeerInfo(currentPeer) - val noiseKey = info?.noisePublicKey - if (noiseKey != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) - } else null - } catch (_: Exception) { null } - - if (favoriteRel?.isMutual == true) { - val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } - if (noiseHex != currentPeer) { - com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer( - state = state, - targetPeerID = noiseHex, - keysToMerge = listOf(currentPeer) - ) - state.setSelectedPrivateChatPeer(noiseHex) + } + }?.takeIf { it.startsWith(prefix, ignoreCase = true) } } - } else { - privateChatManager.cleanupDisconnectedPeer(currentPeer) + }, + findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } + ) + if (canonical != currentPeer) { + // Merge conversations and switch selection to the live mesh peer (or noiseHex) + com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer)) + state.setSelectedPrivateChatPeer(canonical) + } + } else if (isMeshEphemeral && !mergedPeers.contains(currentPeer)) { + // Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex) + val favoriteRel = try { + val info = getPeerInfo(currentPeer) + val noiseKey = info?.noisePublicKey + if (noiseKey != null) { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + } else null + } catch (_: Exception) { null } + + if (favoriteRel?.isMutual == true) { + val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + if (noiseHex != currentPeer) { + com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer( + state = state, + targetPeerID = noiseHex, + keysToMerge = listOf(currentPeer) + ) + state.setSelectedPrivateChatPeer(noiseHex) } + } else { + privateChatManager.cleanupDisconnectedPeer(currentPeer) } } + } - // Global unification: for each connected peer, merge any offline/stable conversations - // (noiseHex or nostr_) into the connected peer's chat so there is only one chat per identity. - peers.forEach { pid -> - try { - val info = getPeerInfo(pid) - val noiseKey = info?.noisePublicKey ?: return@forEach - val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } + // Global unification: for each connected peer, merge any offline/stable conversations + // (noiseHex or nostr_) into the connected peer's chat so there is only one chat per identity. + mergedPeers.forEach { pid -> + try { + val info = getPeerInfo(pid) + val noiseKey = info?.noisePublicKey ?: return@forEach + val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } - // 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 - } else null - } catch (_: Exception) { null } + // 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 + } else null + } catch (_: Exception) { null } - unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey)) - } catch (_: Exception) { } - } + unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey)) + } catch (_: Exception) { } } } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt index d10c6741..acdb3c91 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -108,9 +108,4 @@ object WifiAwareController { } fun getService(): WifiAwareMeshService? = service - - // Optional bridge to BLE mesh for cross-transport relaying - @Volatile private var bleMesh: com.bitchat.android.mesh.BluetoothMeshService? = null - fun setBleMeshService(svc: com.bitchat.android.mesh.BluetoothMeshService) { bleMesh = svc } - fun getBleMeshService(): com.bitchat.android.mesh.BluetoothMeshService? = bleMesh } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index f895a7cb..59a45bc3 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -14,6 +14,7 @@ import androidx.annotation.RequiresPermission import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.* import com.bitchat.android.protocol.* +import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString // Mesh-layer components are reused from the existing Bluetooth stack @@ -51,7 +52,7 @@ import java.util.concurrent.Executors * - MessageHandler: Message type processing and relay logic * - PacketProcessor: Incoming packet routing */ -class WifiAwareMeshService(private val context: Context) { +class WifiAwareMeshService(private val context: Context) : TransportBridgeService.TransportLayer { companion object { private const val TAG = "WifiAwareMeshService" @@ -95,6 +96,7 @@ class WifiAwareMeshService(private val context: Context) { private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager private val handleToPeerId = ConcurrentHashMap() // discovery mapping private val discoveredTimestamps = ConcurrentHashMap() // peerID -> last seen time + private val connectionAttempts = ConcurrentHashMap() // peerID -> last connection attempt time // Timestamp dedupe private val lastTimestamps = ConcurrentHashMap() @@ -125,7 +127,7 @@ class WifiAwareMeshService(private val context: Context) { this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) } override fun sendPacket(packet: BitchatPacket) { - broadcastPacket(RoutedPacket(packet)) + dispatchGlobal(RoutedPacket(packet)) } override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { return signPacketBeforeBroadcast(packet) @@ -174,6 +176,23 @@ class WifiAwareMeshService(private val context: Context) { Log.i(TAG, "TX: broadcast via Wi-Fi Aware to $sent peers (bytes=${bytes.size})") } + // TransportLayer implementation + override fun send(packet: RoutedPacket) { + // Received from bridge (e.g. BLE) -> Send via Wi-Fi + // Direct injection prevents routing loops (bridge handles source check) + broadcastPacket(packet) + } + + /** + * unified dispatch: Send to local Wi-Fi and bridge to other transports + */ + private fun dispatchGlobal(routed: RoutedPacket) { + // 1. Send to local Wi-Fi transport + broadcastPacket(routed) + // 2. Bridge to other transports (e.g. BLE) + TransportBridgeService.broadcast("WIFI", routed) + } + /** * Broadcasts routed packet to currently connected peers. */ @@ -244,7 +263,7 @@ class WifiAwareMeshService(private val context: Context) { payload = response, ttl = MAX_TTL ) - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) } override fun getPeerInfo(peerID: String): PeerInfo? { return peerManager.getPeerInfo(peerID) @@ -255,7 +274,7 @@ class WifiAwareMeshService(private val context: Context) { override fun isFavorite(peerID: String) = delegate?.isFavorite(peerID) ?: false override fun isPeerOnline(peerID: String) = peerManager.isPeerActive(peerID) override fun sendPacket(packet: BitchatPacket) { - broadcastPacket(RoutedPacket(packet)) + dispatchGlobal(RoutedPacket(packet)) } } @@ -280,9 +299,9 @@ class WifiAwareMeshService(private val context: Context) { ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) override fun sendPacket(packet: BitchatPacket) { - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) } - override fun relayPacket(routed: RoutedPacket) { broadcastPacket(routed) } + override fun relayPacket(routed: RoutedPacket) { dispatchGlobal(routed) } override fun getBroadcastRecipient() = SpecialRecipients.BROADCAST override fun verifySignature(packet: BitchatPacket, peerID: String) = @@ -308,7 +327,7 @@ class WifiAwareMeshService(private val context: Context) { payload = hs, ttl = MAX_TTL ) - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) } } override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? = @@ -390,7 +409,7 @@ class WifiAwareMeshService(private val context: Context) { override fun sendAnnouncementToPeer(peerID: String) = this@WifiAwareMeshService.sendAnnouncementToPeer(peerID) override fun sendCachedMessages(peerID: String) = storeForwardManager.sendCachedMessages(peerID) - override fun relayPacket(routed: RoutedPacket) = broadcastPacket(routed) + override fun relayPacket(routed: RoutedPacket) = dispatchGlobal(routed) override fun handleRequestSync(routed: RoutedPacket) { val fromPeer = routed.peerID ?: return @@ -512,7 +531,11 @@ class WifiAwareMeshService(private val context: Context) { }, Handler(Looper.getMainLooper())) sendPeriodicBroadcastAnnounce() + startPeriodicConnectionMaintenance() gossipSyncManager.start() + + // Register with cross-layer transport bridge + TransportBridgeService.register("WIFI", this) } /** @@ -523,6 +546,9 @@ class WifiAwareMeshService(private val context: Context) { isActive = false Log.i(TAG, "Stopping Wi-Fi Aware mesh") + // Unregister from bridge + TransportBridgeService.unregister("WIFI") + sendLeaveAnnouncement() serviceScope.launch { @@ -565,6 +591,55 @@ class WifiAwareMeshService(private val context: Context) { } } + /** + * Periodic active maintenance: retries connections to discovered but unconnected peers. + */ + private fun startPeriodicConnectionMaintenance() { + serviceScope.launch { + Log.d(TAG, "Starting periodic connection maintenance loop") + while (isActive) { + try { + delay(15_000) // Check every 15 seconds + if (!isActive) break + + val now = System.currentTimeMillis() + // 1. Identify peers that are discovered (recently seen) but not currently connected + val recentDiscovered = discoveredTimestamps.filter { (id, ts) -> + (now - ts) < 5 * 60 * 1000 // Seen in last 5 minutes + }.keys + + // 2. Filter out those who are already connected (either as client or server) + val disconnectedPeers = recentDiscovered.filter { peerId -> + !peerSockets.containsKey(peerId) && !serverSockets.containsKey(peerId) + } + + // 3. Attempt reconnection + for (peerId in disconnectedPeers) { + // Find the PeerHandle for this peerId + val handle = handleToPeerId.entries.find { it.value == peerId }?.key ?: continue + + // Rate limit attempts: max 1 per minute per peer + val lastAttempt = connectionAttempts[peerId] ?: 0L + if (now - lastAttempt < 60_000) continue + + Log.i(TAG, "🔄 Maintenance: attempting reconnect to ${peerId.take(8)}") + connectionAttempts[peerId] = now + + // Resend ping to trigger handshake + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + try { + subscribeSession?.sendMessage(handle, msgId, myPeerID.toByteArray()) + } catch (e: Exception) { + Log.w(TAG, "Failed to send maintenance ping to ${peerId.take(8)}: ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error in connection maintenance: ${e.message}") + } + } + } + } + /** * Handles subscriber ping: spawns a server socket and responds with connection info. * @@ -819,7 +894,6 @@ class WifiAwareMeshService(private val context: Context) { private fun listenToPeer(socket: Socket, initialLogicalPeerId: String) { val inStream = socket.getInputStream() val buf = ByteArray(64 * 1024) - var routedPeerId: String? = null while (isActive) { val len = try { inStream.read(buf) } catch (_: IOException) { break } @@ -829,46 +903,36 @@ class WifiAwareMeshService(private val context: Context) { val pkt = BitchatPacket.fromBinaryData(raw) ?: continue val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue - if (senderPeerHex == myPeerID) continue - + + // Deduplicate based on timestamp + sender (standard flood fill protection) val ts = pkt.timestamp if (lastTimestamps.put(senderPeerHex, ts) == ts) { continue } - if (routedPeerId == null) { - routedPeerId = senderPeerHex - peerSockets[routedPeerId] = socket - } - - Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} (bytes=${raw.size})") - packetProcessor.processPacket(RoutedPacket(pkt, routedPeerId)) + // Route the packet: + // - peerID = Originator (who signed it) + // - relayAddress = Neighbor (who sent it to us over this socket) + // Note: We do NOT update peerSockets mapping based on senderPeerHex. + // The socket belongs to initialLogicalPeerId effectively serving as the "MAC address" layer. + Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${initialLogicalPeerId.take(8)} (bytes=${raw.size})") + packetProcessor.processPacket(RoutedPacket(pkt, senderPeerHex, initialLogicalPeerId)) } // Breaking out of the loop means the socket is dead or service is stopping. - // We MUST notify the mesh layer so it removes the logical peer immediately to allow reconnection. Log.i(TAG, "Socket loop terminated for ${initialLogicalPeerId.take(8)} removing peer.") - handlePeerDisconnection(initialLogicalPeerId, routedPeerId) + handlePeerDisconnection(initialLogicalPeerId) socket.closeQuietly() } - private fun handlePeerDisconnection(initialId: String, routedId: String?) { + private fun handlePeerDisconnection(initialId: String) { serviceScope.launch { - Log.d(TAG, "Cleaning up peer: $initialId / $routedId") + Log.d(TAG, "Cleaning up peer: $initialId") peerSockets.remove(initialId)?.closeQuietly() serverSockets.remove(initialId)?.closeQuietly() networkCallbacks.remove(initialId)?.let { runCatching { cm.unregisterNetworkCallback(it) } } peerManager.removePeer(initialId) - - routedId?.let { id -> - if (id != initialId) { - peerSockets.remove(id)?.closeQuietly() - serverSockets.remove(id)?.closeQuietly() - networkCallbacks.remove(id)?.let { runCatching { cm.unregisterNetworkCallback(it) } } - peerManager.removePeer(id) - } - } } } @@ -893,7 +957,7 @@ class WifiAwareMeshService(private val context: Context) { ttl = MAX_TTL ) val signed = signPacketBeforeBroadcast(packet) - broadcastPacket(RoutedPacket(signed)) + dispatchGlobal(RoutedPacket(signed)) try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } } } @@ -929,7 +993,7 @@ class WifiAwareMeshService(private val context: Context) { signature = null, ttl = MAX_TTL ) - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(pkt))) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(pkt))) } catch (e: Exception) { Log.e(TAG, "Failed to encrypt private message: ${e.message}") } @@ -965,7 +1029,7 @@ class WifiAwareMeshService(private val context: Context) { signature = null, ttl = MAX_TTL ) - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(pkt))) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(pkt))) } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt: ${e.message}") } @@ -994,7 +1058,7 @@ class WifiAwareMeshService(private val context: Context) { ) val signed = signPacketBeforeBroadcast(pkt) val transferId = sha256Hex(payload) - broadcastPacket(RoutedPacket(signed, transferId = transferId)) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } } } catch (e: Exception) { @@ -1031,7 +1095,7 @@ class WifiAwareMeshService(private val context: Context) { ) val signed = signPacketBeforeBroadcast(pkt) val transferId = sha256Hex(tlv) - broadcastPacket(RoutedPacket(signed, transferId = transferId)) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) } } catch (e: Exception) { Log.e(TAG, "sendFilePrivate failed: ${e.message}", e) @@ -1080,7 +1144,7 @@ class WifiAwareMeshService(private val context: Context) { ) val signed = signPacketBeforeBroadcast(announcePacket) - broadcastPacket(RoutedPacket(signed)) + dispatchGlobal(RoutedPacket(signed)) try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } } } @@ -1105,7 +1169,7 @@ class WifiAwareMeshService(private val context: Context) { ) val signed = signPacketBeforeBroadcast(packet) - broadcastPacket(RoutedPacket(signed)) + dispatchGlobal(RoutedPacket(signed)) peerManager.markPeerAsAnnouncedTo(peerID) try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } } @@ -1121,7 +1185,7 @@ class WifiAwareMeshService(private val context: Context) { senderID = myPeerID, payload = nickname.toByteArray() ) - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) } /** @return Mapping of peer IDs to nicknames. */