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
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)
@@ -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)
}
@@ -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 {
@@ -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
@@ -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<String>) {
// 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()
@@ -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<String>) {
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<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() {
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
@@ -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
@@ -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
@@ -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) {
))
// 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)
}
@@ -215,7 +232,7 @@ class PermissionManager(private val context: Context) {
)
// WiFi 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(
@@ -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")
@@ -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<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.
@@ -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()
}
}
@@ -14,6 +14,7 @@ object AppStateStore {
// Global de-dup set by message id to avoid duplicate keys in Compose lists
private val seenMessageIds = mutableSetOf<String>()
private val seenPublicMessageKeys = mutableSetOf<String>()
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
// Connected peer IDs (mesh ephemeral IDs)
private val _peers = MutableStateFlow<List<String>>(emptyList())
val peers: StateFlow<List<String>> = _peers.asStateFlow()
@@ -31,7 +32,31 @@ object AppStateStore {
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
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) {
@@ -104,6 +129,7 @@ object AppStateStore {
synchronized(this) {
seenMessageIds.clear()
seenPublicMessageKeys.clear()
peerIdsByTransport.clear()
_peers.value = emptyList()
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
@@ -100,6 +100,7 @@ class MeshDelegateHandler(
override fun didUpdatePeerList(peers: List<String>) {
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<String>) {
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())
@@ -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<String> = 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)
)
}
}
}
@@ -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) {
@@ -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)
@@ -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<MeshGraphService.GraphNode>,
edges: List<MeshGraphService.GraphEdge>,
wifiAwarePeerIDs: Set<String> = 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)
)
}
}
}
@@ -22,29 +22,32 @@ class WifiAwareConnectionTracker(
// Active resources per peer
val peerSockets = ConcurrentHashMap<String, SyncedSocket>()
private val socketAliases = ConcurrentHashMap<String, String>()
val serverSockets = ConcurrentHashMap<String, ServerSocket>()
val networkCallbacks = ConcurrentHashMap<String, ConnectivityManager.NetworkCallback>()
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<String>()
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)")
@@ -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) {}
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
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("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) {
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("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
}
@@ -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<String, ULong>()
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<String, String> {
@@ -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)
}
}