From 0a8f77961ab4855b2e27d598674e69e5fc994893 Mon Sep 17 00:00:00 2001 From: CC Date: Mon, 8 Jun 2026 23:31:05 +0200 Subject: [PATCH] wifi aware working --- .../java/com/bitchat/android/MainActivity.kt | 3 +- .../mesh/BluetoothConnectionManager.kt | 96 +++++++++-- .../mesh/BluetoothGattClientManager.kt | 36 ++-- .../mesh/BluetoothGattServerManager.kt | 27 ++- .../android/mesh/BluetoothMeshService.kt | 56 +++++- .../java/com/bitchat/android/mesh/MeshCore.kt | 62 ++++++- .../com/bitchat/android/mesh/MeshTransport.kt | 2 +- .../bitchat/android/mesh/MessageHandler.kt | 12 +- .../android/onboarding/PermissionManager.kt | 21 ++- .../android/service/MeshForegroundService.kt | 9 + .../android/service/TransportBridgeService.kt | 63 ++++++- .../bitchat/android/services/AppStateStore.kt | 28 ++- .../bitchat/android/ui/MeshDelegateHandler.kt | 8 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 38 +++- .../android/ui/debug/DebugSettingsManager.kt | 3 + .../android/ui/debug/DebugSettingsSheet.kt | 21 +-- .../com/bitchat/android/ui/debug/MeshGraph.kt | 49 ++++++ .../wifi-aware/WifiAwareConnectionTracker.kt | 65 ++++++- .../android/wifi-aware/WifiAwareController.kt | 41 ++++- .../wifi-aware/WifiAwareMeshService.kt | 162 ++++++++++++++---- .../android/services/AppStateStoreTest.kt | 12 ++ 21 files changed, 694 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 7b1ee53d..9d86fadb 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -793,7 +793,8 @@ class MainActivity : OrientationAwareActivity() { // Check if Bluetooth was disabled while app was backgrounded val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() - if (currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) { + val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) { Log.w("MainActivity", "Bluetooth disabled while app was backgrounded") mainViewModel.updateBluetoothStatus(currentBluetoothStatus) mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index dce58031..328d361f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -88,22 +88,49 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isGattServerEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }) + } + + private fun isGattClientEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }) + } + init { powerManager.delegate = this // Observe debug settings to enforce role state while active try { val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + // Master transport enable/disable + connectionScope.launch { + dbg.bleEnabled.collect { enabled -> + if (enabled) return@collect + if (isActive) { + disableTransport() + } + } + } // Role enable/disable connectionScope.launch { dbg.gattServerEnabled.collect { enabled -> if (!isActive) return@collect - if (enabled) startServer() else stopServer() + if (enabled && isBleTransportEnabled()) startServer() else stopServer() } } connectionScope.launch { dbg.gattClientEnabled.collect { enabled -> if (!isActive) return@collect - if (enabled) startClient() else stopClient() + if (enabled && isBleTransportEnabled()) startClient() else stopClient() } } @@ -163,6 +190,12 @@ class BluetoothConnectionManager( */ fun startServices(): Boolean { Log.i(TAG, "Starting power-optimized Bluetooth services...") + + if (!isBleTransportEnabled()) { + Log.i(TAG, "BLE transport disabled by debug settings; not starting Bluetooth services") + disableTransport() + return false + } if (!permissionManager.hasBluetoothPermissions()) { Log.e(TAG, "Missing Bluetooth permissions") @@ -197,9 +230,8 @@ class BluetoothConnectionManager( powerManager.start() // Start server/client based on debug settings - val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null } - val startServer = dbg?.gattServerEnabled?.value != false - val startClient = dbg?.gattClientEnabled?.value != false + val startServer = isGattServerEnabled() + val startClient = isGattClientEnabled() if (startServer) { if (!serverManager.start()) { @@ -234,6 +266,19 @@ class BluetoothConnectionManager( return false } } + + /** + * Disable BLE without cancelling this manager's coroutine scope, so it can be re-enabled. + */ + fun disableTransport() { + Log.i(TAG, "Disabling BLE transport") + isActive = false + connectionScope.launch { + clientManager.stop() + serverManager.stop() + connectionTracker.stop() + } + } /** * Stop all Bluetooth services with proper cleanup @@ -279,7 +324,7 @@ class BluetoothConnectionManager( * Automatically fragments large packets to fit within BLE MTU limits */ fun broadcastPacket(routed: RoutedPacket) { - if (!isActive) return + if (!isActive || !isBleTransportEnabled()) return packetBroadcaster.broadcastPacket( routed, @@ -289,7 +334,7 @@ class BluetoothConnectionManager( } fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { - if (!isActive) return false + if (!isActive || !isBleTransportEnabled()) return false return packetBroadcaster.sendToPeer( peerID, routed, @@ -306,7 +351,7 @@ class BluetoothConnectionManager( * Send a packet directly to a specific peer, without broadcasting to others. */ fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { - if (!isActive) return false + if (!isActive || !isBleTransportEnabled()) return false return packetBroadcaster.sendPacketToPeer( RoutedPacket(packet), peerID, @@ -317,9 +362,15 @@ class BluetoothConnectionManager( // Expose role controls for debug UI - fun startServer() { connectionScope.launch { serverManager.start() } } + fun startServer() { + if (!isActive || !isBleTransportEnabled()) return + connectionScope.launch { if (isGattServerEnabled()) serverManager.start() } + } fun stopServer() { connectionScope.launch { serverManager.stop() } } - fun startClient() { connectionScope.launch { clientManager.start() } } + fun startClient() { + if (!isActive || !isBleTransportEnabled()) return + connectionScope.launch { if (isGattClientEnabled()) clientManager.start() } + } fun stopClient() { connectionScope.launch { clientManager.stop() } } // Inject nickname resolver for broadcaster logs @@ -347,7 +398,10 @@ class BluetoothConnectionManager( /** * Public: connect/disconnect helpers for debug UI */ - fun connectToAddress(address: String): Boolean = clientManager.connectToAddress(address) + fun connectToAddress(address: String): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return clientManager.connectToAddress(address) + } fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) } @@ -358,10 +412,10 @@ class BluetoothConnectionManager( clientManager.stop() serverManager.stop() delay(200) - if (isActive) { + if (isActive && isBleTransportEnabled()) { // Restart managers if service is active - serverManager.start() - clientManager.start() + if (isGattServerEnabled()) serverManager.start() + if (isGattClientEnabled()) clientManager.start() } } } @@ -396,11 +450,17 @@ class BluetoothConnectionManager( Log.i(TAG, "Power mode changed to: $newMode") connectionScope.launch { + if (!isActive || !isBleTransportEnabled()) { + serverManager.stop() + clientManager.stop() + return@launch + } + // Avoid rapid scan restarts by checking if we need to change scan behavior val wasUsingDutyCycle = powerManager.shouldUseDutyCycle() // Update advertising with new power settings if server enabled - val serverEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val serverEnabled = isGattServerEnabled() if (serverEnabled) { serverManager.restartAdvertising() } else { @@ -411,7 +471,7 @@ class BluetoothConnectionManager( val nowUsingDutyCycle = powerManager.shouldUseDutyCycle() if (wasUsingDutyCycle != nowUsingDutyCycle) { Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan") - val clientEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val clientEnabled = isGattClientEnabled() if (clientEnabled) { clientManager.restartScanning() } else { @@ -427,6 +487,10 @@ class BluetoothConnectionManager( } override fun onScanStateChanged(shouldScan: Boolean) { + if (!isActive || !isBleTransportEnabled()) { + clientManager.onScanStateChanged(false) + return + } clientManager.onScanStateChanged(shouldScan) } 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..429711c5 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -39,11 +39,28 @@ class BluetoothGattClientManager( context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner + + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isClientRoleEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }) + } /** * Public: Connect to a device by MAC address (for debug UI) */ fun connectToAddress(deviceAddress: String): Boolean { + if (!isClientRoleEnabled()) { + Log.i(TAG, "connectToAddress skipped: BLE client disabled") + return false + } val device = bluetoothAdapter?.getRemoteDevice(deviceAddress) return if (device != null) { val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50 @@ -75,12 +92,10 @@ class BluetoothGattClientManager( */ fun start(): Boolean { // Respect debug setting - try { - if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value) { - Log.i(TAG, "Client start skipped: GATT Client disabled in debug settings") - return false - } - } catch (_: Exception) { } + if (!isClientRoleEnabled()) { + Log.i(TAG, "Client start skipped: BLE/GATT Client disabled in debug settings") + return false + } if (isActive) { Log.d(TAG, "GATT client already active; start is a no-op") @@ -150,7 +165,7 @@ class BluetoothGattClientManager( * Handle scan state changes from power manager */ fun onScanStateChanged(shouldScan: Boolean) { - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = isClientRoleEnabled() if (shouldScan && enabled) { startScanning() } else { @@ -199,7 +214,7 @@ class BluetoothGattClientManager( @Suppress("DEPRECATION") private fun startScanning() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = isClientRoleEnabled() if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return // Rate limit scan starts to prevent "scanning too frequently" errors @@ -217,7 +232,7 @@ class BluetoothGattClientManager( // Schedule delayed scan start connectionScope.launch { delay(remainingWait) - if (isActive && !isCurrentlyScanning) { + if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) { startScanning() } } @@ -402,6 +417,7 @@ class BluetoothGattClientManager( */ @Suppress("DEPRECATION") private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) { + if (!isClientRoleEnabled()) return if (!permissionManager.hasBluetoothPermissions()) return val deviceAddress = device.address @@ -562,7 +578,7 @@ class BluetoothGattClientManager( */ fun restartScanning() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = isClientRoleEnabled() if (!isActive || !enabled) return connectionScope.launch { 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..14ba0bf6 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -46,6 +46,19 @@ class BluetoothGattServerManager( // State management private var isActive = false + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isServerRoleEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }) + } + /** * Disconnect a specific device (used by ConnectionManager to enforce overall limits) */ @@ -62,12 +75,10 @@ class BluetoothGattServerManager( */ fun start(): Boolean { // Respect debug setting - try { - if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value) { - Log.i(TAG, "Server start skipped: GATT Server disabled in debug settings") - return false - } - } catch (_: Exception) { } + if (!isServerRoleEnabled()) { + Log.i(TAG, "Server start skipped: BLE/GATT Server disabled in debug settings") + return false + } if (isActive) { Log.d(TAG, "GATT server already active; start is a no-op") @@ -322,7 +333,7 @@ class BluetoothGattServerManager( @Suppress("DEPRECATION") private fun startAdvertising() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val enabled = isServerRoleEnabled() // Guard conditions – never throw here to avoid crashing the app from a background coroutine if (!permissionManager.hasBluetoothPermissions()) { @@ -412,7 +423,7 @@ class BluetoothGattServerManager( */ fun restartAdvertising() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val enabled = isServerRoleEnabled() if (!isActive || !enabled) { stopAdvertising() return 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 5d069ac7..eb14201d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -71,6 +71,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // Coroutines private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var announceJob: Job? = null // Tracks whether this instance has been terminated via stopServices() private var terminated = false @@ -114,7 +115,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) - TransportBridgeService.register("BLE", this) + if (isBleTransportEnabled()) { + TransportBridgeService.register("BLE", this) + } // Inject dynamic direct connection check into PeerManager // Matches iOS logic: checks if we have an active hardware mapping for this peer @@ -126,23 +129,35 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } override fun send(packet: RoutedPacket) { + if (!isBleTransportEnabled()) return connectionManager.broadcastPacket(packet) } override fun sendToPeer(peerID: String, packet: BitchatPacket) { + if (!isBleTransportEnabled()) return connectionManager.sendPacketToPeer(peerID, packet) } private fun broadcastRoutedPacket(routed: RoutedPacket) { + if (!isBleTransportEnabled()) return connectionManager.broadcastPacket(routed) TransportBridgeService.broadcast("BLE", routed) } private fun sendPacketToPeerAcrossTransports(peerID: String, packet: BitchatPacket): Boolean { + if (!isBleTransportEnabled()) return false val sentOverBle = connectionManager.sendPacketToPeer(peerID, packet) TransportBridgeService.sendToPeer("BLE", peerID, packet) return sentOverBle } + + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } /** * Start periodic debug logging every 10 seconds @@ -169,7 +184,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic * Send broadcast announcement every 30 seconds */ private fun sendPeriodicBroadcastAnnounce() { - serviceScope.launch { + announceJob?.cancel() + announceJob = serviceScope.launch { Log.d(TAG, "Starting periodic announce loop") while (isActive) { try { @@ -198,7 +214,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { // Update process-wide state first - try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { } // Then notify UI delegate if attached delegate?.didUpdatePeerList(peerIDs) } @@ -650,6 +666,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic Log.w(TAG, "Mesh service already active, ignoring duplicate start request") return } + if (!isBleTransportEnabled()) { + Log.i(TAG, "BLE transport disabled by debug settings; not starting mesh service") + connectionManager.disableTransport() + TransportBridgeService.unregister("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } + return + } if (terminated) { // This instance's scope was cancelled previously; refuse to start to avoid using dead scopes. Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting") @@ -660,6 +683,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic if (connectionManager.startServices()) { isActive = true + TransportBridgeService.register("BLE", this) // Start periodic announcements for peer discovery and connectivity sendPeriodicBroadcastAnnounce() @@ -671,6 +695,29 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic Log.e(TAG, "Failed to start Bluetooth services") } } + + /** + * Apply the debug master transport toggle without destroying this mesh instance. + */ + fun setBleTransportEnabled(enabled: Boolean) { + if (enabled) { + startServices() + } else { + pauseServicesForTransportDisable() + } + } + + private fun pauseServicesForTransportDisable() { + Log.i(TAG, "Disabling BLE mesh transport") + isActive = false + announceJob?.cancel() + announceJob = null + try { gossipSyncManager.stop() } catch (_: Exception) { } + TransportBridgeService.unregister("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } + connectionManager.disableTransport() + try { peerManager.refreshPeerList() } catch (_: Exception) { } + } /** * Stop all mesh services @@ -683,7 +730,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic Log.i(TAG, "Stopping Bluetooth mesh service") isActive = false + announceJob?.cancel() + announceJob = null TransportBridgeService.unregister("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } // Send leave announcement sendLeaveAnnouncement() diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 9f72752d..fbd64876 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -16,6 +16,7 @@ import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.toHexString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -144,12 +145,14 @@ class MeshCore( private fun setupDelegates() { peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { + try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { } delegate?.didUpdatePeerList(peerIDs) } override fun onPeerRemoved(peerID: String) { try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } try { encryptionService.removePeer(peerID) } catch (_: Exception) { } + try { peerManager.refreshPeerList() } catch (_: Exception) { } } } @@ -395,9 +398,9 @@ class MeshCore( } override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { - transport.sendPacketToPeer(peerID, routed.packet) + val sent = transport.sendPacketToPeer(peerID, routed.packet) TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet) - return true + return sent } override fun handleRequestSync(routed: RoutedPacket) { @@ -560,7 +563,7 @@ class MeshCore( return@launch } val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) - val tlvPayload = announcement.encode() ?: return@launch + val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch val announcePacket = BitchatPacket( type = MessageType.ANNOUNCE.value, ttl = maxTtl, @@ -581,7 +584,7 @@ class MeshCore( val staticKey = encryptionService.getStaticPublicKey() ?: return val signingKey = encryptionService.getSigningPublicKey() ?: return val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) - val tlvPayload = announcement.encode() ?: return + val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return val packet = BitchatPacket( type = MessageType.ANNOUNCE.value, ttl = maxTtl, @@ -594,6 +597,30 @@ class MeshCore( try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } } + private fun buildAnnouncementPayload(announcement: IdentityAnnouncement, nickname: String): ByteArray? { + var tlvPayload = announcement.encode() ?: return null + val directPeersForGossip = getDirectPeerIDsForGossip() + try { + if (directPeersForGossip.isNotEmpty()) { + tlvPayload += com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeersForGossip) + } + com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() + .updateFromAnnouncement(myPeerID, nickname, directPeersForGossip, System.currentTimeMillis().toULong()) + } catch (_: Exception) { } + return tlvPayload + } + + private fun getDirectPeerIDsForGossip(): List { + return try { + val verifiedDirect = peerManager.getVerifiedPeers() + .filter { it.value.isDirectConnection } + .keys + (verifiedDirect + directPeers).distinct().take(10) + } catch (_: Exception) { + directPeers.toList().take(10) + } + } + fun sendLeaveAnnouncement() { val payload = hooks.leavePayloadProvider?.invoke() ?: byteArrayOf() val packet = BitchatPacket( @@ -734,12 +761,33 @@ class MeshCore( private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { return try { - val packetDataForSigning = packet.toBinaryDataForSigning() ?: return packet + val withRoute = try { + val recipient = packet.recipientID + if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) { + val destination = recipient.toHexString() + val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, destination) + if (path != null && path.size >= 3) { + val intermediates = path.subList(1, path.size - 1) + packet.copy( + route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) }, + version = 2u + ) + } else { + packet.copy(route = null) + } + } else { + packet + } + } catch (_: Exception) { + packet + } + + val packetDataForSigning = withRoute.toBinaryDataForSigning() ?: return withRoute val signature = encryptionService.signData(packetDataForSigning) if (signature != null) { - packet.copy(signature = signature) + withRoute.copy(signature = signature) } else { - packet + withRoute } } catch (_: Exception) { packet diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt index 259d696f..26a63848 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt @@ -11,7 +11,7 @@ interface MeshTransport { fun broadcastPacket(routed: RoutedPacket) - fun sendPacketToPeer(peerID: String, packet: BitchatPacket) + fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean fun cancelTransfer(transferId: String): Boolean = false 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 ecac572b..d6daa547 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -21,6 +21,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro companion object { private const val TAG = "MessageHandler" + private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L } // Delegate for callbacks @@ -220,12 +221,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro if (peerID == myPeerID) return false - // Ignore stale announcements older than STALE_PEER_TIMEOUT + // Peers use wall-clock packet timestamps; tolerate moderate device clock skew + // during identity learning, or later signed messages cannot be verified. val now = System.currentTimeMillis() - val age = now - packet.timestamp.toLong() - if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) { - Log.w(TAG, "Ignoring stale ANNOUNCE from ${peerID.take(8)} (age=${age}ms > ${com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS}ms)") + val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong()) + if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) { + Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)") return false + } else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) { + Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)") } // Try to decode as iOS-compatible IdentityAnnouncement with TLV format diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index 36a1343d..b8b92048 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -23,6 +23,23 @@ class PermissionManager(private val context: Context) { private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private fun shouldRequireWifiAwarePermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false + val enabled = try { + com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false) + } catch (_: Exception) { + false + } + if (!enabled) return false + + val hasFeature = try { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_AWARE) + } catch (_: Exception) { + false + } + return hasFeature + } + /** * Check if this is the first time the user is launching the app */ @@ -70,7 +87,7 @@ class PermissionManager(private val context: Context) { )) // Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (shouldRequireWifiAwarePermission()) { permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES) } @@ -215,7 +232,7 @@ class PermissionManager(private val context: Context) { ) // Wi‑Fi Aware category (Android 13+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (shouldRequireWifiAwarePermission()) { val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES) categories.add( PermissionCategory( diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index aa9e6fc3..2d1a4d9c 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -226,6 +226,15 @@ class MeshForegroundService : Service() { private fun ensureMeshStarted() { if (isShuttingDown) return + val bleEnabled = try { + com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) + } catch (_: Exception) { + true + } + if (!bleEnabled) { + try { meshService?.setBleTransportEnabled(false) } catch (_: Exception) { } + return + } if (!hasBluetoothPermissions()) return try { android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt index d8a65aa6..c6a83fd0 100644 --- a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -3,6 +3,10 @@ package com.bitchat.android.service import android.util.Log import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.util.toHexString +import java.security.MessageDigest +import java.util.Collections +import java.util.LinkedHashMap import java.util.concurrent.ConcurrentHashMap /** @@ -14,6 +18,8 @@ import java.util.concurrent.ConcurrentHashMap */ object TransportBridgeService { private const val TAG = "TransportBridgeService" + private const val MAX_SEEN_PACKETS = 4096 + private const val SEEN_PACKET_TTL_MS = 5 * 60 * 1000L /** * Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement @@ -32,6 +38,13 @@ object TransportBridgeService { } private val transports = ConcurrentHashMap() + private val seenPackets = Collections.synchronizedMap( + object : LinkedHashMap(MAX_SEEN_PACKETS, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > MAX_SEEN_PACKETS + } + } + ) /** * Register a transport layer to receive bridged packets. @@ -61,12 +74,14 @@ object TransportBridgeService { fun broadcast(sourceId: String, packet: RoutedPacket) { val targets = transports.filterKeys { it != sourceId } if (targets.isEmpty()) return + val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return + val forwarded = packet.copy(packet = forwardedPacket) // Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}") targets.forEach { (id, layer) -> try { - layer.send(packet) + layer.send(forwarded) } catch (e: Exception) { Log.e(TAG, "Failed to bridge packet to $id: ${e.message}") } @@ -79,13 +94,57 @@ object TransportBridgeService { fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) { val targets = transports.filterKeys { it != sourceId } if (targets.isEmpty()) return + val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return targets.forEach { (id, layer) -> try { - layer.sendToPeer(peerID, packet) + layer.sendToPeer(peerID, forwardedPacket) } catch (e: Exception) { Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}") } } } + + private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? { + if (packet.ttl == 0u.toUByte()) { + Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired") + return null + } + + val key = "$kind:${logicalPacketId(packet)}" + val now = System.currentTimeMillis() + synchronized(seenPackets) { + pruneSeen(now) + val previous = seenPackets[key] + if (previous != null && now - previous < SEEN_PACKET_TTL_MS) { + Log.d(TAG, "Dropping duplicate bridged packet type ${packet.type}") + return null + } + seenPackets[key] = now + } + + return packet.copy(ttl = (packet.ttl - 1u).toUByte()) + } + + private fun pruneSeen(now: Long) { + val iterator = seenPackets.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (now - entry.value > SEEN_PACKET_TTL_MS) { + iterator.remove() + } + } + } + + private fun logicalPacketId(packet: BitchatPacket): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(packet.type.toByte()) + digest.update(packet.senderID) + packet.recipientID?.let { digest.update(it) } + digest.update(packet.timestamp.toString().toByteArray(Charsets.UTF_8)) + digest.update(packet.payload) + packet.route?.forEach { digest.update(it) } + packet.signature?.let { digest.update(it) } + return digest.digest().toHexString() + } } diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index 0997beff..4829e3a4 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -14,6 +14,7 @@ object AppStateStore { // Global de-dup set by message id to avoid duplicate keys in Compose lists private val seenMessageIds = mutableSetOf() private val seenPublicMessageKeys = mutableSetOf() + private val peerIdsByTransport = mutableMapOf>() // Connected peer IDs (mesh ephemeral IDs) private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers.asStateFlow() @@ -31,7 +32,31 @@ object AppStateStore { val channelMessages: StateFlow>> = _channelMessages.asStateFlow() fun setPeers(ids: List) { - _peers.value = ids + synchronized(this) { + _peers.value = ids.distinct() + } + } + + fun setTransportPeers(transportId: String, ids: List) { + synchronized(this) { + peerIdsByTransport[transportId] = ids.toSet() + publishTransportPeersLocked() + } + } + + fun clearTransportPeers(transportId: String) { + synchronized(this) { + peerIdsByTransport.remove(transportId) + publishTransportPeersLocked() + } + } + + private fun publishTransportPeersLocked() { + _peers.value = peerIdsByTransport.values + .asSequence() + .flatten() + .distinct() + .toList() } fun addPublicMessage(msg: BitchatMessage) { @@ -104,6 +129,7 @@ object AppStateStore { synchronized(this) { seenMessageIds.clear() seenPublicMessageKeys.clear() + peerIdsByTransport.clear() _peers.value = emptyList() _publicMessages.value = emptyList() _privateMessages.value = emptyMap() 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 c3794cec..294f4ec9 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -100,6 +100,7 @@ class MeshDelegateHandler( override fun didUpdatePeerList(peers: List) { coroutineScope.launch { blePeers = peers.toSet() + try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peers) } catch (_: Exception) { } processPeerUpdate() } } @@ -107,16 +108,15 @@ class MeshDelegateHandler( fun onWifiPeersUpdated(peers: List) { coroutineScope.launch { wifiPeers = peers.toSet() + try { com.bitchat.android.services.AppStateStore.setTransportPeers("WIFI", peers) } catch (_: Exception) { } processPeerUpdate() } } 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) { } + val mergedPeers = com.bitchat.android.services.AppStateStore.peers.value + .ifEmpty { (blePeers + wifiPeers).toList() } state.setConnectedPeers(mergedPeers) state.setIsConnected(mergedPeers.isNotEmpty()) 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..a8e1a5cf 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -61,6 +61,8 @@ fun MeshPeerListSheet( val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() + val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } // Bottom sheet state val sheetState = rememberModalBottomSheetState( @@ -163,11 +165,12 @@ fun MeshPeerListSheet( nickname = nickname, colorScheme = colorScheme, selectedPrivatePeer = selectedPrivatePeer, + wifiAwarePeerIDs = wifiAwarePeerIDs, viewModel = viewModel, - onPrivateChatStart = { peerID -> - viewModel.showPrivateChatSheet(peerID) - onDismiss() - } + onPrivateChatStart = { peerID -> + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } ) } } @@ -279,6 +282,7 @@ fun PeopleSection( nickname: String, colorScheme: ColorScheme, selectedPrivatePeer: String?, + wifiAwarePeerIDs: Set = emptySet(), viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit ) { @@ -416,6 +420,7 @@ fun PeopleSection( peerID = peerID, displayName = displayName, isDirect = isDirectLive, + isWifiAware = peerID in wifiAwarePeerIDs, isSelected = peerID == selectedPrivatePeer, isFavorite = isFavorite, isVerified = isVerified, @@ -544,6 +549,7 @@ private fun PeerItem( peerID: String, displayName: String, isDirect: Boolean, + isWifiAware: Boolean = false, isSelected: Boolean, isFavorite: Boolean, isVerified: Boolean, @@ -619,10 +625,18 @@ private fun PeerItem( ) } else { Icon( - imageVector = if (isDirect) Icons.Outlined.Bluetooth else Icons.Filled.Route, - contentDescription = if (isDirect) "Direct Bluetooth" else "Routed", + imageVector = when { + isWifiAware -> Icons.Filled.Wifi + isDirect -> Icons.Outlined.Bluetooth + else -> Icons.Filled.Route + }, + contentDescription = when { + isWifiAware -> "Direct Wi-Fi Aware" + isDirect -> "Direct Bluetooth" + else -> "Routed" + }, modifier = Modifier.size(16.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) + tint = if (isWifiAware) Color(0xFF9C27B0) else colorScheme.onSurface.copy(alpha = 0.6f) ) } @@ -652,6 +666,16 @@ private fun PeerItem( color = baseColor.copy(alpha = 0.6f) ) } + + if (isWifiAware && hasUnreadDM) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Wifi, + contentDescription = "Direct Wi-Fi Aware", + modifier = Modifier.size(13.dp), + tint = Color(0xFF9C27B0).copy(alpha = 0.8f) + ) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt index 0ae205a9..80910b15 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt @@ -285,6 +285,9 @@ class DebugSettingsManager private constructor() { DebugPreferenceManager.setBleEnabled(enabled) _bleEnabled.value = enabled addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 BLE enabled" else "🔴 BLE disabled")) + try { + com.bitchat.android.service.MeshServiceHolder.meshService?.setBleTransportEnabled(enabled) + } catch (_: Exception) { } } fun setWifiAwareEnabled(enabled: Boolean) { diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index e9018e70..967830f4 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -42,10 +42,12 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle @Composable -fun MeshTopologySection() { +fun MeshTopologySection(localPeerID: String? = null) { val colorScheme = MaterialTheme.colorScheme val graphService = remember { MeshGraphService.getInstance() } val snapshot by graphService.graphState.collectAsState() + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState() + val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -62,6 +64,8 @@ fun MeshTopologySection() { ForceDirectedMeshGraph( nodes = nodes, edges = edges, + wifiAwarePeerIDs = wifiAwarePeerIDs, + localPeerID = localPeerID, modifier = Modifier .fillMaxWidth() .height(300.dp) @@ -221,7 +225,7 @@ fun DebugSettingsSheet( // Mesh topology visualization (moved below verbose logging) item { - MeshTopologySection() + MeshTopologySection(localPeerID = meshService.myPeerID) } // GATT controls @@ -307,15 +311,6 @@ fun DebugSettingsSheet( Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) Switch(checked = bleEnabled, onCheckedChange = { manager.setBleEnabled(it) - scope.launch { - if (it) { - if (gattServerEnabled) meshService.connectionManager.startServer() - if (gattClientEnabled) meshService.connectionManager.startClient() - } else { - meshService.connectionManager.stopServer() - meshService.connectionManager.stopClient() - } - } }) } Row(verticalAlignment = Alignment.CenterVertically) { @@ -604,8 +599,8 @@ fun DebugSettingsSheet( Text(if (running) "running" else "stopped", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() }, label = { Text("Start") }) - AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.stop() }, label = { Text("Stop") }) + AssistChip(onClick = { manager.setWifiAwareEnabled(true) }, label = { Text("Start") }) + AssistChip(onClick = { manager.setWifiAwareEnabled(false) }, label = { Text("Stop") }) AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() }, label = { Text("Announce") }) } Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt index 2df56b5d..3a4d6754 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -4,6 +4,11 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -14,6 +19,7 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.services.meshgraph.MeshGraphService @@ -212,6 +218,8 @@ private class Simulation { fun ForceDirectedMeshGraph( nodes: List, edges: List, + wifiAwarePeerIDs: Set = emptySet(), + localPeerID: String? = null, modifier: Modifier = Modifier ) { val density = LocalDensity.current @@ -405,5 +413,46 @@ fun ForceDirectedMeshGraph( ) } } + + val iconSize = 16.dp + val iconSizePx = with(density) { iconSize.toPx() } + val halfIconSizePx = iconSizePx / 2f + val localID = localPeerID + val shouldMarkWifiEdge: (MeshGraphService.GraphEdge) -> Boolean = { edge -> + if (localID != null) { + (edge.a == localID && edge.b in wifiAwarePeerIDs) || + (edge.b == localID && edge.a in wifiAwarePeerIDs) + } else { + edge.a in wifiAwarePeerIDs || edge.b in wifiAwarePeerIDs + } + } + + val wifiEdgeMidpoints = tick.let { + simulation.edges.mapNotNull { edge -> + val n1 = simulation.nodes[edge.a] + val n2 = simulation.nodes[edge.b] + if (n1 != null && n2 != null && shouldMarkWifiEdge(edge)) { + ((n1.x + n2.x) / 2f) to ((n1.y + n2.y) / 2f) + } else { + null + } + } + } + + wifiEdgeMidpoints.forEach { (midX, midY) -> + Icon( + imageVector = Icons.Filled.Wifi, + contentDescription = null, + tint = Color(0xFF9C27B0).copy(alpha = 0.82f), + modifier = Modifier + .offset { + IntOffset( + x = (midX - halfIconSizePx).roundToInt(), + y = (midY - halfIconSizePx).roundToInt() + ) + } + .size(iconSize) + ) + } } } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt index a5071bb6..c07feb34 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt @@ -22,29 +22,32 @@ class WifiAwareConnectionTracker( // Active resources per peer val peerSockets = ConcurrentHashMap() + private val socketAliases = ConcurrentHashMap() val serverSockets = ConcurrentHashMap() val networkCallbacks = ConcurrentHashMap() override fun isConnected(id: String): Boolean { // We consider it connected if we have a client socket to them - return peerSockets.containsKey(id) + return getSocketForPeer(id) != null } override fun disconnect(id: String) { Log.d(TAG, "Disconnecting peer $id") + val canonicalId = resolveCanonicalPeerId(id) // 1. Close client socket - peerSockets.remove(id)?.let { + peerSockets.remove(canonicalId)?.let { try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") } } + socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId } // 2. Close server socket - serverSockets.remove(id)?.let { + serverSockets.remove(canonicalId)?.let { try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") } } // Ensure any pending/active network request is explicitly released - releaseNetworkRequest(id) + releaseNetworkRequest(canonicalId) } fun releaseNetworkRequest(id: String) { @@ -73,6 +76,54 @@ class WifiAwareConnectionTracker( removePendingConnection(peerId) // Clear retry state on success } + fun getSocketForPeer(peerId: String): SyncedSocket? { + val canonicalId = resolveCanonicalPeerId(peerId) + return peerSockets[canonicalId] + } + + fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId) + + fun rebindPeerId(previousPeerId: String, resolvedPeerId: String, socket: SyncedSocket): String { + if (previousPeerId == resolvedPeerId) { + peerSockets[resolvedPeerId] = socket + return resolvedPeerId + } + + val previousCanonical = resolveCanonicalPeerId(previousPeerId) + val existing = peerSockets[previousCanonical] + if (existing === socket) { + peerSockets.remove(previousCanonical) + } + + peerSockets[resolvedPeerId]?.let { current -> + if (current !== socket) { + try { current.close() } catch (_: Exception) { } + } + } + peerSockets[resolvedPeerId] = socket + serverSockets.remove(previousCanonical)?.let { serverSockets[resolvedPeerId] = it } + networkCallbacks.remove(previousCanonical)?.let { networkCallbacks[resolvedPeerId] = it } + socketAliases[previousPeerId] = resolvedPeerId + if (previousCanonical != previousPeerId) { + socketAliases[previousCanonical] = resolvedPeerId + } + removePendingConnection(previousPeerId) + removePendingConnection(resolvedPeerId) + + Log.i(TAG, "Rebound Wi-Fi Aware socket ${previousPeerId.take(8)} -> ${resolvedPeerId.take(8)}") + return resolvedPeerId + } + + private fun resolveCanonicalPeerId(peerId: String): String { + var current = peerId + val visited = mutableSetOf() + while (visited.add(current)) { + val next = socketAliases[current] ?: return current + current = next + } + return current + } + fun addServerSocket(peerId: String, socket: ServerSocket) { serverSockets[peerId] = socket } @@ -96,6 +147,12 @@ class WifiAwareConnectionTracker( peerSockets.keys.forEach { pid -> appendLine(" - $pid (Socket)") } + if (socketAliases.isNotEmpty()) { + appendLine("Socket aliases:") + socketAliases.forEach { (alias, canonical) -> + appendLine(" - $alias -> $canonical") + } + } appendLine("Server Sockets: ${serverSockets.size}") serverSockets.keys.forEach { pid -> appendLine(" - $pid (Listening)") 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 e70ed670..58641f66 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 @@ -84,6 +84,12 @@ object WifiAwareController { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware not supported on this device (requires Android 10+)")) } catch (_: Exception) {} return } + val awareManager = ctx.getSystemService(android.net.wifi.aware.WifiAwareManager::class.java) + if (awareManager == null || !awareManager.isAvailable) { + Log.w(TAG, "Wi-Fi Aware is not currently available; not starting") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware is not available on this device right now")) } catch (_: Exception) {} + return + } // Check system location setting: WifiAwareManager.attach() throws SecurityException if disabled val lm = ctx.getSystemService(Context.LOCATION_SERVICE) as? android.location.LocationManager @@ -111,11 +117,18 @@ object WifiAwareController { } } try { - service = WifiAwareMeshService(ctx).also { - Log.i(TAG, "Instantiating WifiAwareMeshService...") - it.startServices() + Log.i(TAG, "Instantiating WifiAwareMeshService...") + val startedService = WifiAwareMeshService(ctx) + startedService.startServices() + if (startedService.isRunning()) { + service = startedService _running.value = true try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware started")) } catch (_: Exception) {} + } else { + try { startedService.stopServices() } catch (_: Exception) { } + service = null + _running.value = false + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware did not start")) } catch (_: Exception) {} } } catch (e: Throwable) { Log.e(TAG, "Failed to start WifiAwareMeshService", e) @@ -128,8 +141,30 @@ object WifiAwareController { try { service?.stopServices() } catch (_: Exception) { } service = null _running.value = false + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + _connectedPeers.value = emptyMap() + _knownPeers.value = emptyMap() + _discoveredPeers.value = emptySet() try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {} } + internal fun onServiceStopped(stoppedService: WifiAwareMeshService) { + if (service === stoppedService) { + service = null + _running.value = false + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + _connectedPeers.value = emptyMap() + _knownPeers.value = emptyMap() + _discoveredPeers.value = emptySet() + } + } + + internal fun restartIfStillEnabled(delayMs: Long = 0L) { + scope.launch { + if (delayMs > 0L) delay(delayMs) + if (_enabled.value) startIfPossible() + } + } + fun getService(): WifiAwareMeshService? = service } 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 ae9724dd..3bb4e470 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 @@ -20,6 +20,8 @@ import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString @@ -102,6 +104,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // Timestamp dedupe private val lastTimestamps = ConcurrentHashMap() + fun isRunning(): Boolean = isActive + init { // Ensure BluetoothMeshService is initialized so we share its GossipSyncManager // This avoids race conditions and ensures a single gossip source/delegate @@ -198,8 +202,27 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor */ private fun broadcastPacket(routed: RoutedPacket) { Log.d(TAG, "TX: packet type=${routed.packet.type} broadcast (ttl=${routed.packet.ttl})") + + val packet = routed.packet + if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) { + val firstHop = packet.route!![0].toHexString() + if (sendPacketToPeer(firstHop, packet)) { + Log.d(TAG, "TX: source-routed packet sent only to first Wi-Fi hop ${firstHop.take(8)}") + return + } + Log.w(TAG, "TX: first Wi-Fi source-route hop ${firstHop.take(8)} unavailable; falling back to broadcast") + } + + val recipientId = packet.recipientID?.toHexString() + if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) { + if (sendPacketToPeer(recipientId, packet)) { + Log.d(TAG, "TX: addressed packet sent directly to Wi-Fi peer ${recipientId.take(8)}") + return + } + } + // Wi-Fi Aware uses full packets; no fragmentation - val data = routed.packet.toBinaryData() ?: return + val data = packet.toBinaryData() ?: return serviceScope.launch { broadcastRaw(data) } } @@ -211,15 +234,15 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor /** * Send packet to connected peer. */ - private fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + private fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { // Wi-Fi Aware uses full packets; no fragmentation - val data = packet.toBinaryData() ?: return + val data = packet.toBinaryData() ?: return false + val sock = connectionTracker.getSocketForPeer(peerID) + if (sock == null) { + Log.w(TAG, "TX: no socket for ${peerID.take(8)}") + return false + } serviceScope.launch { - val sock = connectionTracker.peerSockets[peerID] - if (sock == null) { - Log.w(TAG, "TX: no socket for ${peerID.take(8)}") - return@launch - } try { sock.write(data) Log.d(TAG, "TX: packet type=${packet.type} to ${peerID.take(8)} (bytes=${data.size})") @@ -227,6 +250,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}") } } + return true } @@ -245,10 +269,19 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor ]) override fun startServices() { if (isActive) return + if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + Log.i(TAG, "Wi-Fi Aware transport disabled by debug settings; not starting") + return + } + val manager = awareManager + if (manager == null || !manager.isAvailable) { + Log.w(TAG, "Wi-Fi Aware manager unavailable; not starting") + return + } isActive = true Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID") - awareManager?.attach(object : AttachCallback() { + manager.attach(object : AttachCallback() { @SuppressLint("MissingPermission") @RequiresPermission(allOf = [ Manifest.permission.ACCESS_FINE_LOCATION, @@ -302,10 +335,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor override fun onSessionTerminated() { Log.e(TAG, "PUBLISH: onSessionTerminated()") publishSession = null - if (isActive) { - Log.i(TAG, "PUBLISH: Attempting to restart publish session...") - // Delay and check if we need to restart services entirely - serviceScope.launch { delay(2000); if (isActive) startServices() } + val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop() + if (shouldRestart) { + Log.i(TAG, "PUBLISH: Scheduling Wi-Fi Aware restart") + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000) } } }, @@ -352,9 +386,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor override fun onSessionTerminated() { Log.e(TAG, "SUBSCRIBE: onSessionTerminated()") subscribeSession = null - if (isActive) { - Log.i(TAG, "SUBSCRIBE: Attempting to restart subscribe session...") - serviceScope.launch { delay(2000); if (isActive) startServices() } + val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop() + if (shouldRestart) { + Log.i(TAG, "SUBSCRIBE: Scheduling Wi-Fi Aware restart") + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000) } } }, @@ -363,13 +399,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor } override fun onAttachFailed() { Log.e(TAG, "Wi-Fi Aware attach failed") + handleUnexpectedStop() } override fun onAwareSessionTerminated() { Log.e(TAG, "Aware Session Terminated unexpectedly") wifiAwareSession = null - isActive = false - if (com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { serviceScope.launch { delay(3000); com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() } } + val shouldRestart = com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop() + if (shouldRestart) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(3000) + } } }, Handler(Looper.getMainLooper())) @@ -385,14 +425,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor * Stops the Wi-Fi Aware mesh services and cleans up sockets and sessions. */ override fun stopServices() { - if (!isActive) return + val wasActive = isActive isActive = false Log.i(TAG, "Stopping Wi-Fi Aware mesh") // Unregister from bridge TransportBridgeService.unregister("WIFI") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } - meshCore.sendLeaveAnnouncement() + if (wasActive) { + meshCore.sendLeaveAnnouncement() + } serviceScope.launch { delay(200) @@ -408,6 +451,31 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.shutdown() + com.bitchat.android.wifiaware.WifiAwareController.onServiceStopped(this@WifiAwareMeshService) + serviceScope.cancel() + } + } + + private fun handleUnexpectedStop() { + if (!isActive) { + com.bitchat.android.wifiaware.WifiAwareController.onServiceStopped(this) + return + } + isActive = false + TransportBridgeService.unregister("WIFI") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + serviceScope.launch { + try { meshCore.stopCore() } catch (_: Exception) { } + try { connectionTracker.stop() } catch (_: Exception) { } + try { publishSession?.close() } catch (_: Exception) { } + try { subscribeSession?.close() } catch (_: Exception) { } + try { wifiAwareSession?.close() } catch (_: Exception) { } + publishSession = null + subscribeSession = null + wifiAwareSession = null + handleToPeerId.clear() + try { meshCore.shutdown() } catch (_: Exception) { } + com.bitchat.android.wifiaware.WifiAwareController.onServiceStopped(this@WifiAwareMeshService) serviceScope.cancel() } } @@ -748,6 +816,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor * @param initialLogicalPeerId Temporary identifier before peer ID resolution */ private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) { + var logicalPeerId = initialLogicalPeerId while (isActive) { val raw = socket.read() ?: break @@ -759,6 +828,22 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val pkt = BitchatPacket.fromBinaryData(raw) ?: continue val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue + + if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) { + val previousPeerId = logicalPeerId + logicalPeerId = connectionTracker.rebindPeerId(previousPeerId, senderPeerHex, socket) + handleToPeerId.forEach { (handle, peerId) -> + if (peerId == previousPeerId) { + handleToPeerId[handle] = senderPeerHex + } + } + discoveredTimestamps.remove(previousPeerId) + discoveredTimestamps[senderPeerHex] = System.currentTimeMillis() + try { meshCore.setDirectConnection(previousPeerId, false) } catch (_: Exception) { } + try { meshCore.removePeer(previousPeerId) } catch (_: Exception) { } + try { meshCore.setDirectConnection(senderPeerHex, true) } catch (_: Exception) { } + Log.i(TAG, "RX: rebound Wi-Fi direct peer ${previousPeerId.take(8)} -> ${senderPeerHex.take(8)}") + } // Deduplicate based on timestamp + sender (standard flood fill protection) val ts = pkt.timestamp @@ -769,15 +854,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // 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})") - meshCore.processIncoming(pkt, senderPeerHex, initialLogicalPeerId) + Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${logicalPeerId.take(8)} (bytes=${raw.size})") + meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId) } // Breaking out of the loop means the socket is dead or service is stopping. - Log.i(TAG, "Socket loop terminated for ${initialLogicalPeerId.take(8)} removing peer.") - handlePeerDisconnection(initialLogicalPeerId, socket) + Log.i(TAG, "Socket loop terminated for ${logicalPeerId.take(8)} removing peer.") + handlePeerDisconnection(logicalPeerId, socket) socket.close() } @@ -788,7 +871,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor connectionTracker.releaseNetworkRequest(peerId) if (!connectionTracker.isConnected(peerId)) { - meshCore.removePeer(peerId) + val canonicalPeerId = connectionTracker.canonicalPeerId(peerId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != peerId) { + meshCore.removePeer(peerId) + } } else { Log.d(TAG, "Network failure ignored for $peerId - another socket is active") } @@ -798,16 +885,23 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private fun handlePeerDisconnection(initialId: String, socket: SyncedSocket? = null) { serviceScope.launch { // Check if this socket is the current active one before nuking the session - val currentSocket = connectionTracker.peerSockets[initialId] + val currentSocket = connectionTracker.getSocketForPeer(initialId) + val canonicalPeerId = connectionTracker.canonicalPeerId(initialId) if (currentSocket === socket) { - Log.d(TAG, "Cleaning up peer: $initialId (active socket)") + Log.d(TAG, "Cleaning up peer: $canonicalPeerId (active socket)") connectionTracker.disconnect(initialId) - meshCore.removePeer(initialId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != initialId) { + meshCore.removePeer(initialId) + } } else if (socket == null && currentSocket == null) { // Fallback: If we don't have a specific socket context but we are already disconnected, ensure cleanup Log.d(TAG, "Cleaning up peer: $initialId (no active socket)") connectionTracker.disconnect(initialId) - meshCore.removePeer(initialId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != initialId) { + meshCore.removePeer(initialId) + } } else { Log.d(TAG, "Ignored disconnection for $initialId - socket replaced or inactive") // Do not remove peer/session, as a new socket has likely taken over @@ -1036,11 +1130,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor override fun broadcastPacket(routed: RoutedPacket) { this@WifiAwareMeshService.broadcastPacket(routed) } - override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { - this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) } override fun getDeviceAddressForPeer(peerID: String): String? { - return connectionTracker.peerSockets[peerID]?.let { resolveScopedAddress(it.rawSocket) } + return connectionTracker.getSocketForPeer(peerID)?.let { resolveScopedAddress(it.rawSocket) } } override fun getDeviceAddressToPeerMapping(): Map { diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 3c680777..2ce77f07 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -55,4 +55,16 @@ class AppStateStoreTest { assertEquals(listOf(first, second), AppStateStore.publicMessages.value) } + + @Test + fun `peer list merges transport updates instead of overwriting`() { + AppStateStore.setTransportPeers("WIFI", listOf("wifi-peer")) + AppStateStore.setTransportPeers("BLE", emptyList()) + + assertEquals(listOf("wifi-peer"), AppStateStore.peers.value) + + AppStateStore.setTransportPeers("BLE", listOf("ble-peer")) + + assertEquals(listOf("wifi-peer", "ble-peer"), AppStateStore.peers.value) + } }