wifi aware working

This commit is contained in:
CC
2026-06-08 23:31:05 +02:00
parent 3ac6976a54
commit 0a8f77961a
21 changed files with 694 additions and 120 deletions
@@ -793,7 +793,8 @@ class MainActivity : OrientationAwareActivity() {
// Check if Bluetooth was disabled while app was backgrounded // Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() 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") Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus) mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
@@ -88,22 +88,49 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping // Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap 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 { init {
powerManager.delegate = this powerManager.delegate = this
// Observe debug settings to enforce role state while active // Observe debug settings to enforce role state while active
try { try {
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() 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 // Role enable/disable
connectionScope.launch { connectionScope.launch {
dbg.gattServerEnabled.collect { enabled -> dbg.gattServerEnabled.collect { enabled ->
if (!isActive) return@collect if (!isActive) return@collect
if (enabled) startServer() else stopServer() if (enabled && isBleTransportEnabled()) startServer() else stopServer()
} }
} }
connectionScope.launch { connectionScope.launch {
dbg.gattClientEnabled.collect { enabled -> dbg.gattClientEnabled.collect { enabled ->
if (!isActive) return@collect if (!isActive) return@collect
if (enabled) startClient() else stopClient() if (enabled && isBleTransportEnabled()) startClient() else stopClient()
} }
} }
@@ -163,6 +190,12 @@ class BluetoothConnectionManager(
*/ */
fun startServices(): Boolean { fun startServices(): Boolean {
Log.i(TAG, "Starting power-optimized Bluetooth services...") 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()) { if (!permissionManager.hasBluetoothPermissions()) {
Log.e(TAG, "Missing Bluetooth permissions") Log.e(TAG, "Missing Bluetooth permissions")
@@ -197,9 +230,8 @@ class BluetoothConnectionManager(
powerManager.start() powerManager.start()
// Start server/client based on debug settings // Start server/client based on debug settings
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null } val startServer = isGattServerEnabled()
val startServer = dbg?.gattServerEnabled?.value != false val startClient = isGattClientEnabled()
val startClient = dbg?.gattClientEnabled?.value != false
if (startServer) { if (startServer) {
if (!serverManager.start()) { if (!serverManager.start()) {
@@ -234,6 +266,19 @@ class BluetoothConnectionManager(
return false 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 * Stop all Bluetooth services with proper cleanup
@@ -279,7 +324,7 @@ class BluetoothConnectionManager(
* Automatically fragments large packets to fit within BLE MTU limits * Automatically fragments large packets to fit within BLE MTU limits
*/ */
fun broadcastPacket(routed: RoutedPacket) { fun broadcastPacket(routed: RoutedPacket) {
if (!isActive) return if (!isActive || !isBleTransportEnabled()) return
packetBroadcaster.broadcastPacket( packetBroadcaster.broadcastPacket(
routed, routed,
@@ -289,7 +334,7 @@ class BluetoothConnectionManager(
} }
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
if (!isActive) return false if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendToPeer( return packetBroadcaster.sendToPeer(
peerID, peerID,
routed, routed,
@@ -306,7 +351,7 @@ class BluetoothConnectionManager(
* Send a packet directly to a specific peer, without broadcasting to others. * Send a packet directly to a specific peer, without broadcasting to others.
*/ */
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
if (!isActive) return false if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendPacketToPeer( return packetBroadcaster.sendPacketToPeer(
RoutedPacket(packet), RoutedPacket(packet),
peerID, peerID,
@@ -317,9 +362,15 @@ class BluetoothConnectionManager(
// Expose role controls for debug UI // 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 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() } } fun stopClient() { connectionScope.launch { clientManager.stop() } }
// Inject nickname resolver for broadcaster logs // Inject nickname resolver for broadcaster logs
@@ -347,7 +398,10 @@ class BluetoothConnectionManager(
/** /**
* Public: connect/disconnect helpers for debug UI * 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) } fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) }
@@ -358,10 +412,10 @@ class BluetoothConnectionManager(
clientManager.stop() clientManager.stop()
serverManager.stop() serverManager.stop()
delay(200) delay(200)
if (isActive) { if (isActive && isBleTransportEnabled()) {
// Restart managers if service is active // Restart managers if service is active
serverManager.start() if (isGattServerEnabled()) serverManager.start()
clientManager.start() if (isGattClientEnabled()) clientManager.start()
} }
} }
} }
@@ -396,11 +450,17 @@ class BluetoothConnectionManager(
Log.i(TAG, "Power mode changed to: $newMode") Log.i(TAG, "Power mode changed to: $newMode")
connectionScope.launch { connectionScope.launch {
if (!isActive || !isBleTransportEnabled()) {
serverManager.stop()
clientManager.stop()
return@launch
}
// Avoid rapid scan restarts by checking if we need to change scan behavior // Avoid rapid scan restarts by checking if we need to change scan behavior
val wasUsingDutyCycle = powerManager.shouldUseDutyCycle() val wasUsingDutyCycle = powerManager.shouldUseDutyCycle()
// Update advertising with new power settings if server enabled // 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) { if (serverEnabled) {
serverManager.restartAdvertising() serverManager.restartAdvertising()
} else { } else {
@@ -411,7 +471,7 @@ class BluetoothConnectionManager(
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle() val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
if (wasUsingDutyCycle != nowUsingDutyCycle) { if (wasUsingDutyCycle != nowUsingDutyCycle) {
Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan") 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) { if (clientEnabled) {
clientManager.restartScanning() clientManager.restartScanning()
} else { } else {
@@ -427,6 +487,10 @@ class BluetoothConnectionManager(
} }
override fun onScanStateChanged(shouldScan: Boolean) { override fun onScanStateChanged(shouldScan: Boolean) {
if (!isActive || !isBleTransportEnabled()) {
clientManager.onScanStateChanged(false)
return
}
clientManager.onScanStateChanged(shouldScan) clientManager.onScanStateChanged(shouldScan)
} }
@@ -39,11 +39,28 @@ class BluetoothGattClientManager(
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner 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) * Public: Connect to a device by MAC address (for debug UI)
*/ */
fun connectToAddress(deviceAddress: String): Boolean { fun connectToAddress(deviceAddress: String): Boolean {
if (!isClientRoleEnabled()) {
Log.i(TAG, "connectToAddress skipped: BLE client disabled")
return false
}
val device = bluetoothAdapter?.getRemoteDevice(deviceAddress) val device = bluetoothAdapter?.getRemoteDevice(deviceAddress)
return if (device != null) { return if (device != null) {
val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50 val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50
@@ -75,12 +92,10 @@ class BluetoothGattClientManager(
*/ */
fun start(): Boolean { fun start(): Boolean {
// Respect debug setting // Respect debug setting
try { if (!isClientRoleEnabled()) {
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value) { Log.i(TAG, "Client start skipped: BLE/GATT Client disabled in debug settings")
Log.i(TAG, "Client start skipped: GATT Client disabled in debug settings") return false
return false }
}
} catch (_: Exception) { }
if (isActive) { if (isActive) {
Log.d(TAG, "GATT client already active; start is a no-op") 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 * Handle scan state changes from power manager
*/ */
fun onScanStateChanged(shouldScan: Boolean) { 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) { if (shouldScan && enabled) {
startScanning() startScanning()
} else { } else {
@@ -199,7 +214,7 @@ class BluetoothGattClientManager(
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private fun startScanning() { private fun startScanning() {
// Respect debug setting // 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 if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return
// Rate limit scan starts to prevent "scanning too frequently" errors // Rate limit scan starts to prevent "scanning too frequently" errors
@@ -217,7 +232,7 @@ class BluetoothGattClientManager(
// Schedule delayed scan start // Schedule delayed scan start
connectionScope.launch { connectionScope.launch {
delay(remainingWait) delay(remainingWait)
if (isActive && !isCurrentlyScanning) { if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) {
startScanning() startScanning()
} }
} }
@@ -402,6 +417,7 @@ class BluetoothGattClientManager(
*/ */
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) { private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) {
if (!isClientRoleEnabled()) return
if (!permissionManager.hasBluetoothPermissions()) return if (!permissionManager.hasBluetoothPermissions()) return
val deviceAddress = device.address val deviceAddress = device.address
@@ -562,7 +578,7 @@ class BluetoothGattClientManager(
*/ */
fun restartScanning() { fun restartScanning() {
// Respect debug setting // 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 if (!isActive || !enabled) return
connectionScope.launch { connectionScope.launch {
@@ -46,6 +46,19 @@ class BluetoothGattServerManager(
// State management // State management
private var isActive = false 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) * Disconnect a specific device (used by ConnectionManager to enforce overall limits)
*/ */
@@ -62,12 +75,10 @@ class BluetoothGattServerManager(
*/ */
fun start(): Boolean { fun start(): Boolean {
// Respect debug setting // Respect debug setting
try { if (!isServerRoleEnabled()) {
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value) { Log.i(TAG, "Server start skipped: BLE/GATT Server disabled in debug settings")
Log.i(TAG, "Server start skipped: GATT Server disabled in debug settings") return false
return false }
}
} catch (_: Exception) { }
if (isActive) { if (isActive) {
Log.d(TAG, "GATT server already active; start is a no-op") Log.d(TAG, "GATT server already active; start is a no-op")
@@ -322,7 +333,7 @@ class BluetoothGattServerManager(
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private fun startAdvertising() { private fun startAdvertising() {
// Respect debug setting // 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 // Guard conditions never throw here to avoid crashing the app from a background coroutine
if (!permissionManager.hasBluetoothPermissions()) { if (!permissionManager.hasBluetoothPermissions()) {
@@ -412,7 +423,7 @@ class BluetoothGattServerManager(
*/ */
fun restartAdvertising() { fun restartAdvertising() {
// Respect debug setting // Respect debug setting
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } val enabled = isServerRoleEnabled()
if (!isActive || !enabled) { if (!isActive || !enabled) {
stopAdvertising() stopAdvertising()
return return
@@ -71,6 +71,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Coroutines // Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var announceJob: Job? = null
// Tracks whether this instance has been terminated via stopServices() // Tracks whether this instance has been terminated via stopServices()
private var terminated = false private var terminated = false
@@ -114,7 +115,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
} }
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager)
TransportBridgeService.register("BLE", this) if (isBleTransportEnabled()) {
TransportBridgeService.register("BLE", this)
}
// Inject dynamic direct connection check into PeerManager // Inject dynamic direct connection check into PeerManager
// Matches iOS logic: checks if we have an active hardware mapping for this peer // 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) { override fun send(packet: RoutedPacket) {
if (!isBleTransportEnabled()) return
connectionManager.broadcastPacket(packet) connectionManager.broadcastPacket(packet)
} }
override fun sendToPeer(peerID: String, packet: BitchatPacket) { override fun sendToPeer(peerID: String, packet: BitchatPacket) {
if (!isBleTransportEnabled()) return
connectionManager.sendPacketToPeer(peerID, packet) connectionManager.sendPacketToPeer(peerID, packet)
} }
private fun broadcastRoutedPacket(routed: RoutedPacket) { private fun broadcastRoutedPacket(routed: RoutedPacket) {
if (!isBleTransportEnabled()) return
connectionManager.broadcastPacket(routed) connectionManager.broadcastPacket(routed)
TransportBridgeService.broadcast("BLE", routed) TransportBridgeService.broadcast("BLE", routed)
} }
private fun sendPacketToPeerAcrossTransports(peerID: String, packet: BitchatPacket): Boolean { private fun sendPacketToPeerAcrossTransports(peerID: String, packet: BitchatPacket): Boolean {
if (!isBleTransportEnabled()) return false
val sentOverBle = connectionManager.sendPacketToPeer(peerID, packet) val sentOverBle = connectionManager.sendPacketToPeer(peerID, packet)
TransportBridgeService.sendToPeer("BLE", peerID, packet) TransportBridgeService.sendToPeer("BLE", peerID, packet)
return sentOverBle 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 * Start periodic debug logging every 10 seconds
@@ -169,7 +184,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
* Send broadcast announcement every 30 seconds * Send broadcast announcement every 30 seconds
*/ */
private fun sendPeriodicBroadcastAnnounce() { private fun sendPeriodicBroadcastAnnounce() {
serviceScope.launch { announceJob?.cancel()
announceJob = serviceScope.launch {
Log.d(TAG, "Starting periodic announce loop") Log.d(TAG, "Starting periodic announce loop")
while (isActive) { while (isActive) {
try { try {
@@ -198,7 +214,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
peerManager.delegate = object : PeerManagerDelegate { peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List<String>) { override fun onPeerListUpdated(peerIDs: List<String>) {
// Update process-wide state first // 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 // Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs) 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") Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
return 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) { if (terminated) {
// This instance's scope was cancelled previously; refuse to start to avoid using dead scopes. // 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") 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()) { if (connectionManager.startServices()) {
isActive = true isActive = true
TransportBridgeService.register("BLE", this)
// Start periodic announcements for peer discovery and connectivity // Start periodic announcements for peer discovery and connectivity
sendPeriodicBroadcastAnnounce() sendPeriodicBroadcastAnnounce()
@@ -671,6 +695,29 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
Log.e(TAG, "Failed to start Bluetooth services") 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 * Stop all mesh services
@@ -683,7 +730,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
Log.i(TAG, "Stopping Bluetooth mesh service") Log.i(TAG, "Stopping Bluetooth mesh service")
isActive = false isActive = false
announceJob?.cancel()
announceJob = null
TransportBridgeService.unregister("BLE") TransportBridgeService.unregister("BLE")
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
// Send leave announcement // Send leave announcement
sendLeaveAnnouncement() sendLeaveAnnouncement()
@@ -16,6 +16,7 @@ import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -144,12 +145,14 @@ class MeshCore(
private fun setupDelegates() { private fun setupDelegates() {
peerManager.delegate = object : PeerManagerDelegate { peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List<String>) { override fun onPeerListUpdated(peerIDs: List<String>) {
try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { }
delegate?.didUpdatePeerList(peerIDs) delegate?.didUpdatePeerList(peerIDs)
} }
override fun onPeerRemoved(peerID: String) { override fun onPeerRemoved(peerID: String) {
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
try { encryptionService.removePeer(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 { 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) TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet)
return true return sent
} }
override fun handleRequestSync(routed: RoutedPacket) { override fun handleRequestSync(routed: RoutedPacket) {
@@ -560,7 +563,7 @@ class MeshCore(
return@launch return@launch
} }
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
val tlvPayload = announcement.encode() ?: return@launch val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
val announcePacket = BitchatPacket( val announcePacket = BitchatPacket(
type = MessageType.ANNOUNCE.value, type = MessageType.ANNOUNCE.value,
ttl = maxTtl, ttl = maxTtl,
@@ -581,7 +584,7 @@ class MeshCore(
val staticKey = encryptionService.getStaticPublicKey() ?: return val staticKey = encryptionService.getStaticPublicKey() ?: return
val signingKey = encryptionService.getSigningPublicKey() ?: return val signingKey = encryptionService.getSigningPublicKey() ?: return
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
val tlvPayload = announcement.encode() ?: return val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
val packet = BitchatPacket( val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value, type = MessageType.ANNOUNCE.value,
ttl = maxTtl, ttl = maxTtl,
@@ -594,6 +597,30 @@ class MeshCore(
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } 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<String> {
return try {
val verifiedDirect = peerManager.getVerifiedPeers()
.filter { it.value.isDirectConnection }
.keys
(verifiedDirect + directPeers).distinct().take(10)
} catch (_: Exception) {
directPeers.toList().take(10)
}
}
fun sendLeaveAnnouncement() { fun sendLeaveAnnouncement() {
val payload = hooks.leavePayloadProvider?.invoke() ?: byteArrayOf() val payload = hooks.leavePayloadProvider?.invoke() ?: byteArrayOf()
val packet = BitchatPacket( val packet = BitchatPacket(
@@ -734,12 +761,33 @@ class MeshCore(
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
return try { 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) val signature = encryptionService.signData(packetDataForSigning)
if (signature != null) { if (signature != null) {
packet.copy(signature = signature) withRoute.copy(signature = signature)
} else { } else {
packet withRoute
} }
} catch (_: Exception) { } catch (_: Exception) {
packet packet
@@ -11,7 +11,7 @@ interface MeshTransport {
fun broadcastPacket(routed: RoutedPacket) fun broadcastPacket(routed: RoutedPacket)
fun sendPacketToPeer(peerID: String, packet: BitchatPacket) fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
fun cancelTransfer(transferId: String): Boolean = false fun cancelTransfer(transferId: String): Boolean = false
@@ -21,6 +21,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
companion object { companion object {
private const val TAG = "MessageHandler" private const val TAG = "MessageHandler"
private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L
} }
// Delegate for callbacks // Delegate for callbacks
@@ -220,12 +221,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
if (peerID == myPeerID) return false 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 now = System.currentTimeMillis()
val age = now - packet.timestamp.toLong() val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong())
if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) { if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_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)") Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)")
return false 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 // Try to decode as iOS-compatible IdentityAnnouncement with TLV format
@@ -23,6 +23,23 @@ class PermissionManager(private val context: Context) {
private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) 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 * Check if this is the first time the user is launching the app
*/ */
@@ -70,7 +87,7 @@ class PermissionManager(private val context: Context) {
)) ))
// WiFi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission // WiFi 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) permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
} }
@@ -215,7 +232,7 @@ class PermissionManager(private val context: Context) {
) )
// WiFi Aware category (Android 13+) // WiFi Aware category (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (shouldRequireWifiAwarePermission()) {
val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES) val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES)
categories.add( categories.add(
PermissionCategory( PermissionCategory(
@@ -226,6 +226,15 @@ class MeshForegroundService : Service() {
private fun ensureMeshStarted() { private fun ensureMeshStarted() {
if (isShuttingDown) return 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 if (!hasBluetoothPermissions()) return
try { try {
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
@@ -3,6 +3,10 @@ package com.bitchat.android.service
import android.util.Log import android.util.Log
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.toHexString
import java.security.MessageDigest
import java.util.Collections
import java.util.LinkedHashMap
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
@@ -14,6 +18,8 @@ import java.util.concurrent.ConcurrentHashMap
*/ */
object TransportBridgeService { object TransportBridgeService {
private const val TAG = "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 * Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement
@@ -32,6 +38,13 @@ object TransportBridgeService {
} }
private val transports = ConcurrentHashMap<String, TransportLayer>() private val transports = ConcurrentHashMap<String, TransportLayer>()
private val seenPackets = Collections.synchronizedMap(
object : LinkedHashMap<String, Long>(MAX_SEEN_PACKETS, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Long>?): Boolean {
return size > MAX_SEEN_PACKETS
}
}
)
/** /**
* Register a transport layer to receive bridged packets. * Register a transport layer to receive bridged packets.
@@ -61,12 +74,14 @@ object TransportBridgeService {
fun broadcast(sourceId: String, packet: RoutedPacket) { fun broadcast(sourceId: String, packet: RoutedPacket) {
val targets = transports.filterKeys { it != sourceId } val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return 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}") // Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}")
targets.forEach { (id, layer) -> targets.forEach { (id, layer) ->
try { try {
layer.send(packet) layer.send(forwarded)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to bridge packet to $id: ${e.message}") 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) { fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) {
val targets = transports.filterKeys { it != sourceId } val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return if (targets.isEmpty()) return
val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return
targets.forEach { (id, layer) -> targets.forEach { (id, layer) ->
try { try {
layer.sendToPeer(peerID, packet) layer.sendToPeer(peerID, forwardedPacket)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}") 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()
}
} }
@@ -14,6 +14,7 @@ object AppStateStore {
// Global de-dup set by message id to avoid duplicate keys in Compose lists // Global de-dup set by message id to avoid duplicate keys in Compose lists
private val seenMessageIds = mutableSetOf<String>() private val seenMessageIds = mutableSetOf<String>()
private val seenPublicMessageKeys = mutableSetOf<String>() private val seenPublicMessageKeys = mutableSetOf<String>()
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
// Connected peer IDs (mesh ephemeral IDs) // Connected peer IDs (mesh ephemeral IDs)
private val _peers = MutableStateFlow<List<String>>(emptyList()) private val _peers = MutableStateFlow<List<String>>(emptyList())
val peers: StateFlow<List<String>> = _peers.asStateFlow() val peers: StateFlow<List<String>> = _peers.asStateFlow()
@@ -31,7 +32,31 @@ object AppStateStore {
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow() val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
fun setPeers(ids: List<String>) { fun setPeers(ids: List<String>) {
_peers.value = ids synchronized(this) {
_peers.value = ids.distinct()
}
}
fun setTransportPeers(transportId: String, ids: List<String>) {
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) { fun addPublicMessage(msg: BitchatMessage) {
@@ -104,6 +129,7 @@ object AppStateStore {
synchronized(this) { synchronized(this) {
seenMessageIds.clear() seenMessageIds.clear()
seenPublicMessageKeys.clear() seenPublicMessageKeys.clear()
peerIdsByTransport.clear()
_peers.value = emptyList() _peers.value = emptyList()
_publicMessages.value = emptyList() _publicMessages.value = emptyList()
_privateMessages.value = emptyMap() _privateMessages.value = emptyMap()
@@ -100,6 +100,7 @@ class MeshDelegateHandler(
override fun didUpdatePeerList(peers: List<String>) { override fun didUpdatePeerList(peers: List<String>) {
coroutineScope.launch { coroutineScope.launch {
blePeers = peers.toSet() blePeers = peers.toSet()
try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peers) } catch (_: Exception) { }
processPeerUpdate() processPeerUpdate()
} }
} }
@@ -107,16 +108,15 @@ class MeshDelegateHandler(
fun onWifiPeersUpdated(peers: List<String>) { fun onWifiPeersUpdated(peers: List<String>) {
coroutineScope.launch { coroutineScope.launch {
wifiPeers = peers.toSet() wifiPeers = peers.toSet()
try { com.bitchat.android.services.AppStateStore.setTransportPeers("WIFI", peers) } catch (_: Exception) { }
processPeerUpdate() processPeerUpdate()
} }
} }
private suspend fun processPeerUpdate() { private suspend fun processPeerUpdate() {
// Merge peers from multiple transports // Merge peers from multiple transports
val mergedPeers = (blePeers + wifiPeers).toList() val mergedPeers = com.bitchat.android.services.AppStateStore.peers.value
.ifEmpty { (blePeers + wifiPeers).toList() }
// Update process-wide state as source of truth
try { com.bitchat.android.services.AppStateStore.setPeers(mergedPeers) } catch (_: Exception) { }
state.setConnectedPeers(mergedPeers) state.setConnectedPeers(mergedPeers)
state.setIsConnected(mergedPeers.isNotEmpty()) state.setIsConnected(mergedPeers.isNotEmpty())
@@ -61,6 +61,8 @@ fun MeshPeerListSheet(
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.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 // Bottom sheet state
val sheetState = rememberModalBottomSheetState( val sheetState = rememberModalBottomSheetState(
@@ -163,11 +165,12 @@ fun MeshPeerListSheet(
nickname = nickname, nickname = nickname,
colorScheme = colorScheme, colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer, selectedPrivatePeer = selectedPrivatePeer,
wifiAwarePeerIDs = wifiAwarePeerIDs,
viewModel = viewModel, viewModel = viewModel,
onPrivateChatStart = { peerID -> onPrivateChatStart = { peerID ->
viewModel.showPrivateChatSheet(peerID) viewModel.showPrivateChatSheet(peerID)
onDismiss() onDismiss()
} }
) )
} }
} }
@@ -279,6 +282,7 @@ fun PeopleSection(
nickname: String, nickname: String,
colorScheme: ColorScheme, colorScheme: ColorScheme,
selectedPrivatePeer: String?, selectedPrivatePeer: String?,
wifiAwarePeerIDs: Set<String> = emptySet(),
viewModel: ChatViewModel, viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit onPrivateChatStart: (String) -> Unit
) { ) {
@@ -416,6 +420,7 @@ fun PeopleSection(
peerID = peerID, peerID = peerID,
displayName = displayName, displayName = displayName,
isDirect = isDirectLive, isDirect = isDirectLive,
isWifiAware = peerID in wifiAwarePeerIDs,
isSelected = peerID == selectedPrivatePeer, isSelected = peerID == selectedPrivatePeer,
isFavorite = isFavorite, isFavorite = isFavorite,
isVerified = isVerified, isVerified = isVerified,
@@ -544,6 +549,7 @@ private fun PeerItem(
peerID: String, peerID: String,
displayName: String, displayName: String,
isDirect: Boolean, isDirect: Boolean,
isWifiAware: Boolean = false,
isSelected: Boolean, isSelected: Boolean,
isFavorite: Boolean, isFavorite: Boolean,
isVerified: Boolean, isVerified: Boolean,
@@ -619,10 +625,18 @@ private fun PeerItem(
) )
} else { } else {
Icon( Icon(
imageVector = if (isDirect) Icons.Outlined.Bluetooth else Icons.Filled.Route, imageVector = when {
contentDescription = if (isDirect) "Direct Bluetooth" else "Routed", 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), 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) 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)
)
}
} }
} }
@@ -285,6 +285,9 @@ class DebugSettingsManager private constructor() {
DebugPreferenceManager.setBleEnabled(enabled) DebugPreferenceManager.setBleEnabled(enabled)
_bleEnabled.value = enabled _bleEnabled.value = enabled
addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 BLE enabled" else "🔴 BLE disabled")) 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) { fun setWifiAwareEnabled(enabled: Boolean) {
@@ -42,10 +42,12 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
@Composable @Composable
fun MeshTopologySection() { fun MeshTopologySection(localPeerID: String? = null) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val graphService = remember { MeshGraphService.getInstance() } val graphService = remember { MeshGraphService.getInstance() }
val snapshot by graphService.graphState.collectAsState() 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)) { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -62,6 +64,8 @@ fun MeshTopologySection() {
ForceDirectedMeshGraph( ForceDirectedMeshGraph(
nodes = nodes, nodes = nodes,
edges = edges, edges = edges,
wifiAwarePeerIDs = wifiAwarePeerIDs,
localPeerID = localPeerID,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(300.dp) .height(300.dp)
@@ -221,7 +225,7 @@ fun DebugSettingsSheet(
// Mesh topology visualization (moved below verbose logging) // Mesh topology visualization (moved below verbose logging)
item { item {
MeshTopologySection() MeshTopologySection(localPeerID = meshService.myPeerID)
} }
// GATT controls // GATT controls
@@ -307,15 +311,6 @@ fun DebugSettingsSheet(
Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Switch(checked = bleEnabled, onCheckedChange = { Switch(checked = bleEnabled, onCheckedChange = {
manager.setBleEnabled(it) 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) { 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)) 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)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() }, label = { Text("Start") }) AssistChip(onClick = { manager.setWifiAwareEnabled(true) }, label = { Text("Start") })
AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.stop() }, label = { Text("Stop") }) AssistChip(onClick = { manager.setWifiAwareEnabled(false) }, label = { Text("Stop") })
AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() }, label = { Text("Announce") }) AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() }, label = { Text("Announce") })
} }
Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
@@ -4,6 +4,11 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize 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.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset 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.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.bitchat.android.services.meshgraph.MeshGraphService import com.bitchat.android.services.meshgraph.MeshGraphService
@@ -212,6 +218,8 @@ private class Simulation {
fun ForceDirectedMeshGraph( fun ForceDirectedMeshGraph(
nodes: List<MeshGraphService.GraphNode>, nodes: List<MeshGraphService.GraphNode>,
edges: List<MeshGraphService.GraphEdge>, edges: List<MeshGraphService.GraphEdge>,
wifiAwarePeerIDs: Set<String> = emptySet(),
localPeerID: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val density = LocalDensity.current 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)
)
}
} }
} }
@@ -22,29 +22,32 @@ class WifiAwareConnectionTracker(
// Active resources per peer // Active resources per peer
val peerSockets = ConcurrentHashMap<String, SyncedSocket>() val peerSockets = ConcurrentHashMap<String, SyncedSocket>()
private val socketAliases = ConcurrentHashMap<String, String>()
val serverSockets = ConcurrentHashMap<String, ServerSocket>() val serverSockets = ConcurrentHashMap<String, ServerSocket>()
val networkCallbacks = ConcurrentHashMap<String, ConnectivityManager.NetworkCallback>() val networkCallbacks = ConcurrentHashMap<String, ConnectivityManager.NetworkCallback>()
override fun isConnected(id: String): Boolean { override fun isConnected(id: String): Boolean {
// We consider it connected if we have a client socket to them // 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) { override fun disconnect(id: String) {
Log.d(TAG, "Disconnecting peer $id") Log.d(TAG, "Disconnecting peer $id")
val canonicalId = resolveCanonicalPeerId(id)
// 1. Close client socket // 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}") } 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 // 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}") } 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 // Ensure any pending/active network request is explicitly released
releaseNetworkRequest(id) releaseNetworkRequest(canonicalId)
} }
fun releaseNetworkRequest(id: String) { fun releaseNetworkRequest(id: String) {
@@ -73,6 +76,54 @@ class WifiAwareConnectionTracker(
removePendingConnection(peerId) // Clear retry state on success 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<String>()
while (visited.add(current)) {
val next = socketAliases[current] ?: return current
current = next
}
return current
}
fun addServerSocket(peerId: String, socket: ServerSocket) { fun addServerSocket(peerId: String, socket: ServerSocket) {
serverSockets[peerId] = socket serverSockets[peerId] = socket
} }
@@ -96,6 +147,12 @@ class WifiAwareConnectionTracker(
peerSockets.keys.forEach { pid -> peerSockets.keys.forEach { pid ->
appendLine(" - $pid (Socket)") appendLine(" - $pid (Socket)")
} }
if (socketAliases.isNotEmpty()) {
appendLine("Socket aliases:")
socketAliases.forEach { (alias, canonical) ->
appendLine(" - $alias -> $canonical")
}
}
appendLine("Server Sockets: ${serverSockets.size}") appendLine("Server Sockets: ${serverSockets.size}")
serverSockets.keys.forEach { pid -> serverSockets.keys.forEach { pid ->
appendLine(" - $pid (Listening)") appendLine(" - $pid (Listening)")
@@ -84,6 +84,12 @@ object WifiAwareController {
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware not supported on this device (requires Android 10+)")) } catch (_: Exception) {} try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware not supported on this device (requires Android 10+)")) } catch (_: Exception) {}
return 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("WiFi Aware is not available on this device right now")) } catch (_: Exception) {}
return
}
// Check system location setting: WifiAwareManager.attach() throws SecurityException if disabled // Check system location setting: WifiAwareManager.attach() throws SecurityException if disabled
val lm = ctx.getSystemService(Context.LOCATION_SERVICE) as? android.location.LocationManager val lm = ctx.getSystemService(Context.LOCATION_SERVICE) as? android.location.LocationManager
@@ -111,11 +117,18 @@ object WifiAwareController {
} }
} }
try { try {
service = WifiAwareMeshService(ctx).also { Log.i(TAG, "Instantiating WifiAwareMeshService...")
Log.i(TAG, "Instantiating WifiAwareMeshService...") val startedService = WifiAwareMeshService(ctx)
it.startServices() startedService.startServices()
if (startedService.isRunning()) {
service = startedService
_running.value = true _running.value = true
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware started")) } catch (_: Exception) {} try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi 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("WiFi Aware did not start")) } catch (_: Exception) {}
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "Failed to start WifiAwareMeshService", e) Log.e(TAG, "Failed to start WifiAwareMeshService", e)
@@ -128,8 +141,30 @@ object WifiAwareController {
try { service?.stopServices() } catch (_: Exception) { } try { service?.stopServices() } catch (_: Exception) { }
service = null service = null
_running.value = false _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("WiFi Aware stopped")) } catch (_: Exception) {} try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi 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 fun getService(): WifiAwareMeshService? = service
} }
@@ -20,6 +20,8 @@ import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString import com.bitchat.android.util.toHexString
@@ -102,6 +104,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// Timestamp dedupe // Timestamp dedupe
private val lastTimestamps = ConcurrentHashMap<String, ULong>() private val lastTimestamps = ConcurrentHashMap<String, ULong>()
fun isRunning(): Boolean = isActive
init { init {
// Ensure BluetoothMeshService is initialized so we share its GossipSyncManager // Ensure BluetoothMeshService is initialized so we share its GossipSyncManager
// This avoids race conditions and ensures a single gossip source/delegate // 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) { private fun broadcastPacket(routed: RoutedPacket) {
Log.d(TAG, "TX: packet type=${routed.packet.type} broadcast (ttl=${routed.packet.ttl})") 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 // Wi-Fi Aware uses full packets; no fragmentation
val data = routed.packet.toBinaryData() ?: return val data = packet.toBinaryData() ?: return
serviceScope.launch { broadcastRaw(data) } serviceScope.launch { broadcastRaw(data) }
} }
@@ -211,15 +234,15 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
/** /**
* Send packet to connected peer. * 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 // 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 { serviceScope.launch {
val sock = connectionTracker.peerSockets[peerID]
if (sock == null) {
Log.w(TAG, "TX: no socket for ${peerID.take(8)}")
return@launch
}
try { try {
sock.write(data) sock.write(data)
Log.d(TAG, "TX: packet type=${packet.type} to ${peerID.take(8)} (bytes=${data.size})") 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}") 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() { override fun startServices() {
if (isActive) return 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 isActive = true
Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID") Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID")
awareManager?.attach(object : AttachCallback() { manager.attach(object : AttachCallback() {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@RequiresPermission(allOf = [ @RequiresPermission(allOf = [
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION,
@@ -302,10 +335,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
override fun onSessionTerminated() { override fun onSessionTerminated() {
Log.e(TAG, "PUBLISH: onSessionTerminated()") Log.e(TAG, "PUBLISH: onSessionTerminated()")
publishSession = null publishSession = null
if (isActive) { val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value
Log.i(TAG, "PUBLISH: Attempting to restart publish session...") handleUnexpectedStop()
// Delay and check if we need to restart services entirely if (shouldRestart) {
serviceScope.launch { delay(2000); if (isActive) startServices() } 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() { override fun onSessionTerminated() {
Log.e(TAG, "SUBSCRIBE: onSessionTerminated()") Log.e(TAG, "SUBSCRIBE: onSessionTerminated()")
subscribeSession = null subscribeSession = null
if (isActive) { val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value
Log.i(TAG, "SUBSCRIBE: Attempting to restart subscribe session...") handleUnexpectedStop()
serviceScope.launch { delay(2000); if (isActive) startServices() } 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() { override fun onAttachFailed() {
Log.e(TAG, "Wi-Fi Aware attach failed") Log.e(TAG, "Wi-Fi Aware attach failed")
handleUnexpectedStop()
} }
override fun onAwareSessionTerminated() { override fun onAwareSessionTerminated() {
Log.e(TAG, "Aware Session Terminated unexpectedly") Log.e(TAG, "Aware Session Terminated unexpectedly")
wifiAwareSession = null wifiAwareSession = null
isActive = false val shouldRestart = com.bitchat.android.wifiaware.WifiAwareController.enabled.value
if (com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { serviceScope.launch { delay(3000); com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() } } handleUnexpectedStop()
if (shouldRestart) {
com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(3000)
}
} }
}, Handler(Looper.getMainLooper())) }, 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. * Stops the Wi-Fi Aware mesh services and cleans up sockets and sessions.
*/ */
override fun stopServices() { override fun stopServices() {
if (!isActive) return val wasActive = isActive
isActive = false isActive = false
Log.i(TAG, "Stopping Wi-Fi Aware mesh") Log.i(TAG, "Stopping Wi-Fi Aware mesh")
// Unregister from bridge // Unregister from bridge
TransportBridgeService.unregister("WIFI") TransportBridgeService.unregister("WIFI")
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { }
meshCore.sendLeaveAnnouncement() if (wasActive) {
meshCore.sendLeaveAnnouncement()
}
serviceScope.launch { serviceScope.launch {
delay(200) delay(200)
@@ -408,6 +451,31 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.shutdown() 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() serviceScope.cancel()
} }
} }
@@ -748,6 +816,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
* @param initialLogicalPeerId Temporary identifier before peer ID resolution * @param initialLogicalPeerId Temporary identifier before peer ID resolution
*/ */
private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) { private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) {
var logicalPeerId = initialLogicalPeerId
while (isActive) { while (isActive) {
val raw = socket.read() ?: break val raw = socket.read() ?: break
@@ -759,6 +828,22 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
val pkt = BitchatPacket.fromBinaryData(raw) ?: continue val pkt = BitchatPacket.fromBinaryData(raw) ?: continue
val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: 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) // Deduplicate based on timestamp + sender (standard flood fill protection)
val ts = pkt.timestamp val ts = pkt.timestamp
@@ -769,15 +854,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// Route the packet: // Route the packet:
// - peerID = Originator (who signed it) // - peerID = Originator (who signed it)
// - relayAddress = Neighbor (who sent it to us over this socket) // - relayAddress = Neighbor (who sent it to us over this socket)
// Note: We do NOT update peerSockets mapping based on senderPeerHex. Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${logicalPeerId.take(8)} (bytes=${raw.size})")
// The socket belongs to initialLogicalPeerId effectively serving as the "MAC address" layer. meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId)
Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${initialLogicalPeerId.take(8)} (bytes=${raw.size})")
meshCore.processIncoming(pkt, senderPeerHex, initialLogicalPeerId)
} }
// Breaking out of the loop means the socket is dead or service is stopping. // 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.") Log.i(TAG, "Socket loop terminated for ${logicalPeerId.take(8)} removing peer.")
handlePeerDisconnection(initialLogicalPeerId, socket) handlePeerDisconnection(logicalPeerId, socket)
socket.close() socket.close()
} }
@@ -788,7 +871,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
connectionTracker.releaseNetworkRequest(peerId) connectionTracker.releaseNetworkRequest(peerId)
if (!connectionTracker.isConnected(peerId)) { if (!connectionTracker.isConnected(peerId)) {
meshCore.removePeer(peerId) val canonicalPeerId = connectionTracker.canonicalPeerId(peerId)
meshCore.removePeer(canonicalPeerId)
if (canonicalPeerId != peerId) {
meshCore.removePeer(peerId)
}
} else { } else {
Log.d(TAG, "Network failure ignored for $peerId - another socket is active") 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) { private fun handlePeerDisconnection(initialId: String, socket: SyncedSocket? = null) {
serviceScope.launch { serviceScope.launch {
// Check if this socket is the current active one before nuking the session // 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) { if (currentSocket === socket) {
Log.d(TAG, "Cleaning up peer: $initialId (active socket)") Log.d(TAG, "Cleaning up peer: $canonicalPeerId (active socket)")
connectionTracker.disconnect(initialId) connectionTracker.disconnect(initialId)
meshCore.removePeer(initialId) meshCore.removePeer(canonicalPeerId)
if (canonicalPeerId != initialId) {
meshCore.removePeer(initialId)
}
} else if (socket == null && currentSocket == null) { } else if (socket == null && currentSocket == null) {
// Fallback: If we don't have a specific socket context but we are already disconnected, ensure cleanup // 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)") Log.d(TAG, "Cleaning up peer: $initialId (no active socket)")
connectionTracker.disconnect(initialId) connectionTracker.disconnect(initialId)
meshCore.removePeer(initialId) meshCore.removePeer(canonicalPeerId)
if (canonicalPeerId != initialId) {
meshCore.removePeer(initialId)
}
} else { } else {
Log.d(TAG, "Ignored disconnection for $initialId - socket replaced or inactive") Log.d(TAG, "Ignored disconnection for $initialId - socket replaced or inactive")
// Do not remove peer/session, as a new socket has likely taken over // 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) { override fun broadcastPacket(routed: RoutedPacket) {
this@WifiAwareMeshService.broadcastPacket(routed) this@WifiAwareMeshService.broadcastPacket(routed)
} }
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
} }
override fun getDeviceAddressForPeer(peerID: String): String? { 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<String, String> { override fun getDeviceAddressToPeerMapping(): Map<String, String> {
@@ -55,4 +55,16 @@ class AppStateStoreTest {
assertEquals(listOf(first, second), AppStateStore.publicMessages.value) 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)
}
} }