diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f4ca2b898..e79fa8eb2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
@@ -19,6 +20,12 @@
+
+
+
+
+
+
@@ -47,6 +54,8 @@
+
+
+ val svc = com.bitchat.android.wifiaware.WifiAwareController.getService()
+ if (running && svc != null) {
+ svc.delegate = object : com.bitchat.android.wifiaware.WifiAwareMeshDelegate {
+ override fun didReceiveMessage(message: com.bitchat.android.model.BitchatMessage) {
+ if (message.isPrivate) {
+ message.senderPeerID?.let { pid -> com.bitchat.android.services.AppStateStore.addPrivateMessage(pid, message) }
+ } else if (message.channel != null) {
+ com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel, message)
+ } else {
+ com.bitchat.android.services.AppStateStore.addPublicMessage(message)
+ }
+ chatViewModel.didReceiveMessage(message)
+ }
+ override fun didUpdatePeerList(peers: List) {
+ chatViewModel.onWifiPeersUpdated(peers)
+ }
+ override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
+ chatViewModel.didReceiveChannelLeave(channel, fromPeer)
+ }
+ override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
+ chatViewModel.didReceiveDeliveryAck(messageID, recipientPeerID)
+ }
+ override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
+ chatViewModel.didReceiveReadReceipt(messageID, recipientPeerID)
+ }
+ override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
+ return chatViewModel.decryptChannelMessage(encryptedContent, channel)
+ }
+ override fun getNickname(): String? {
+ return chatViewModel.getNickname()
+ }
+ override fun isFavorite(peerID: String): Boolean {
+ return try {
+ com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true
+ } catch (_: Exception) { false }
+ }
+ }
+ }
+ }
+ }
+ }
// Only start onboarding process if we're in the initial CHECKING state
// This prevents restarting onboarding on configuration changes
@@ -217,6 +266,10 @@ class MainActivity : OrientationAwareActivity() {
onRetry = {
checkBluetoothAndProceed()
},
+ onSkip = {
+ mainViewModel.skipBluetoothCheck()
+ checkLocationAndProceed()
+ },
isLoading = isBluetoothLoading
)
}
@@ -335,6 +388,13 @@ class MainActivity : OrientationAwareActivity() {
private fun checkBluetoothAndProceed() {
// Log.d("MainActivity", "Checking Bluetooth status")
+ // Check if user has skipped Bluetooth check for this session
+ if (mainViewModel.isBluetoothCheckSkipped.value) {
+ Log.d("MainActivity", "Bluetooth check skipped by user, proceeding to location check")
+ checkLocationAndProceed()
+ return
+ }
+
// For first-time users, skip Bluetooth check and go straight to permissions
// We'll check Bluetooth after permissions are granted
if (permissionManager.isFirstTimeLaunch()) {
@@ -347,6 +407,12 @@ class MainActivity : OrientationAwareActivity() {
bluetoothStatusManager.logBluetoothStatus()
mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus())
+ val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
+ if (!bleRequired) {
+ // Skip BLE checks entirely when BLE is disabled in debug settings
+ checkLocationAndProceed()
+ return
+ }
when (mainViewModel.bluetoothStatus.value) {
BluetoothStatus.ENABLED -> {
// Bluetooth is enabled, check location services next
@@ -513,8 +579,9 @@ class MainActivity : OrientationAwareActivity() {
else -> BatteryOptimizationStatus.ENABLED
}
+ val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
when {
- currentBluetoothStatus != BluetoothStatus.ENABLED -> {
+ bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> {
// Bluetooth still disabled, but now we have permissions to enable it
Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
@@ -698,7 +765,7 @@ class MainActivity : OrientationAwareActivity() {
// Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
- if (currentBluetoothStatus != BluetoothStatus.ENABLED) {
+ if (currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) {
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
diff --git a/app/src/main/java/com/bitchat/android/MainViewModel.kt b/app/src/main/java/com/bitchat/android/MainViewModel.kt
index 35125d855..15ec6fdac 100644
--- a/app/src/main/java/com/bitchat/android/MainViewModel.kt
+++ b/app/src/main/java/com/bitchat/android/MainViewModel.kt
@@ -35,6 +35,9 @@ class MainViewModel : ViewModel() {
private val _isBatteryOptimizationLoading = MutableStateFlow(false)
val isBatteryOptimizationLoading: StateFlow = _isBatteryOptimizationLoading.asStateFlow()
+ private val _isBluetoothCheckSkipped = MutableStateFlow(false)
+ val isBluetoothCheckSkipped: StateFlow = _isBluetoothCheckSkipped.asStateFlow()
+
// Public update functions for MainActivity
fun updateOnboardingState(state: OnboardingState) {
_onboardingState.value = state
@@ -67,4 +70,8 @@ class MainViewModel : ViewModel() {
fun updateBatteryOptimizationLoading(loading: Boolean) {
_isBatteryOptimizationLoading.value = loading
}
+
+ fun skipBluetoothCheck() {
+ _isBluetoothCheckSkipped.value = true
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
index 7029185f5..1412732d8 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
@@ -16,14 +16,11 @@ import java.util.concurrent.CopyOnWriteArrayList
class BluetoothConnectionTracker(
private val connectionScope: CoroutineScope,
private val powerManager: PowerManager
-) {
+) : MeshConnectionTracker(connectionScope, TAG) {
companion object {
private const val TAG = "BluetoothConnectionTracker"
- private const val CONNECTION_RETRY_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_RETRY_DELAY_MS
- private const val MAX_CONNECTION_ATTEMPTS = com.bitchat.android.util.AppConstants.Mesh.MAX_CONNECTION_ATTEMPTS
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_DELAY_MS
- private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_INTERVAL_MS // 30 seconds
}
// Connection tracking - reduced memory footprint
@@ -36,12 +33,6 @@ class BluetoothConnectionTracker(
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap()
- // Connection attempt tracking with automatic cleanup
- private val pendingConnections = ConcurrentHashMap()
-
- // State management
- private var isActive = false
-
/**
* Consolidated device connection information
*/
@@ -54,37 +45,28 @@ class BluetoothConnectionTracker(
val connectedAt: Long = System.currentTimeMillis()
)
- /**
- * Connection attempt tracking with automatic expiry
- */
- data class ConnectionAttempt(
- val attempts: Int,
- val lastAttempt: Long = System.currentTimeMillis()
- ) {
- fun isExpired(): Boolean =
- System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2
-
- fun shouldRetry(): Boolean =
- attempts < MAX_CONNECTION_ATTEMPTS &&
- System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY
- }
-
- /**
- * Start the connection tracker
- */
- fun start() {
- isActive = true
- startPeriodicCleanup()
+ override fun start() {
+ super.start()
}
- /**
- * Stop the connection tracker
- */
- fun stop() {
- isActive = false
+ override fun stop() {
+ super.stop()
cleanupAllConnections()
clearAllConnections()
}
+
+ // Abstract implementations
+ override fun isConnected(id: String): Boolean = connectedDevices.containsKey(id)
+
+ override fun disconnect(id: String) {
+ connectedDevices[id]?.gatt?.let {
+ try { it.disconnect() } catch (_: Exception) { }
+ }
+ cleanupDeviceConnection(id)
+ Log.d(TAG, "Requested disconnect for $id")
+ }
+
+ override fun getConnectionCount(): Int = connectedDevices.size
/**
* Add a device connection
@@ -92,7 +74,7 @@ class BluetoothConnectionTracker(
fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
connectedDevices[deviceAddress] = deviceConn
- pendingConnections.remove(deviceAddress)
+ removePendingConnection(deviceAddress)
// Mark as awaiting first ANNOUNCE on this connection
firstAnnounceSeen[deviceAddress] = false
}
@@ -167,67 +149,17 @@ class BluetoothConnectionTracker(
/**
* Check if device is already connected
*/
- fun isDeviceConnected(deviceAddress: String): Boolean {
- return connectedDevices.containsKey(deviceAddress)
- }
-
- /**
- * Check if connection attempt is allowed
- */
- fun isConnectionAttemptAllowed(deviceAddress: String): Boolean {
- val existingAttempt = pendingConnections[deviceAddress]
- return existingAttempt?.let {
- it.isExpired() || it.shouldRetry()
- } ?: true
- }
-
- /**
- * Add a pending connection attempt
- */
- fun addPendingConnection(deviceAddress: String): Boolean {
- Log.d(TAG, "Tracker: Adding pending connection for $deviceAddress")
- synchronized(pendingConnections) {
- // Double-check inside synchronized block
- val currentAttempt = pendingConnections[deviceAddress]
- if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) {
- Log.d(TAG, "Tracker: Connection attempt already in progress for $deviceAddress")
- return false
- }
- if (currentAttempt != null) {
- Log.d(TAG, "Tracker: current attempt: $currentAttempt")
- }
-
- // Update connection attempt atomically
- // If the previous attempt window expired, reset backoff to 1; otherwise increment
- val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1
- pendingConnections[deviceAddress] = ConnectionAttempt(attempts)
- Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)")
- return true
- }
- }
+ fun isDeviceConnected(deviceAddress: String): Boolean = isConnected(deviceAddress)
/**
* Disconnect a specific device (by MAC address)
*/
- fun disconnectDevice(deviceAddress: String) {
- connectedDevices[deviceAddress]?.gatt?.let {
- try { it.disconnect() } catch (_: Exception) { }
- }
- cleanupDeviceConnection(deviceAddress)
- Log.d(TAG, "Requested disconnect for $deviceAddress")
- }
-
- /**
- * Remove a pending connection
- */
- fun removePendingConnection(deviceAddress: String) {
- pendingConnections.remove(deviceAddress)
- }
+ fun disconnectDevice(deviceAddress: String) = disconnect(deviceAddress)
/**
* Get connected device count
*/
- fun getConnectedDeviceCount(): Int = connectedDevices.size
+ fun getConnectedDeviceCount(): Int = getConnectionCount()
/**
* Check if connection limit is reached
@@ -335,36 +267,6 @@ class BluetoothConnectionTracker(
return firstAnnounceSeen[deviceAddress] == true
}
- /**
- * Start periodic cleanup of expired connections
- */
- private fun startPeriodicCleanup() {
- connectionScope.launch {
- while (isActive) {
- delay(CLEANUP_INTERVAL)
-
- if (!isActive) break
-
- try {
- // Clean up expired pending connections
- val expiredConnections = pendingConnections.filter { it.value.isExpired() }
- expiredConnections.keys.forEach { pendingConnections.remove(it) }
-
- // Log cleanup if any
- if (expiredConnections.isNotEmpty()) {
- Log.d(TAG, "Cleaned up ${expiredConnections.size} expired connection attempts")
- }
-
- // Log current state
- Log.d(TAG, "Periodic cleanup: ${connectedDevices.size} connections, ${pendingConnections.size} pending")
-
- } catch (e: Exception) {
- Log.w(TAG, "Error in periodic cleanup: ${e.message}")
- }
- }
- }
- }
-
/**
* Get debug information
*/
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
index 3de484072..bc920f9bd 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
@@ -13,6 +13,7 @@ import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
+import com.bitchat.android.service.TransportBridgeService
import kotlinx.coroutines.*
import java.util.*
import kotlin.math.sign
@@ -31,7 +32,7 @@ import kotlin.random.Random
* - BluetoothConnectionManager: BLE connections and GATT operations
* - PacketProcessor: Incoming packet routing
*/
-class BluetoothMeshService(private val context: Context) {
+class BluetoothMeshService(private val context: Context) : TransportBridgeService.TransportLayer {
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
companion object {
@@ -94,13 +95,22 @@ class BluetoothMeshService(private val context: Context) {
} catch (_: Exception) { 0.01 }
}
)
+
+ // Register as shared instance for Wi-Fi Aware transport
+ com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager)
// Wire sync manager delegate
gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) {
- connectionManager.broadcastPacket(RoutedPacket(packet))
+ dispatchGlobal(RoutedPacket(packet))
}
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
+ // Point-to-point optimization if possible, but for bridge safety
+ // we might want to consider dispatchGlobal if peer is on another transport.
+ // However, sendPacketToPeer in connectionManager is BLE-specific unicast.
+ // If peer is on Wi-Fi, this won't reach.
+ // For now, let's keep unicast as-is (it's mostly for sync)
+ // and assume routing handles the rest via broadcasts if needed.
connectionManager.sendPacketToPeer(peerID, packet)
}
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
@@ -108,6 +118,26 @@ class BluetoothMeshService(private val context: Context) {
}
}
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
+
+ // Register with cross-layer transport bridge
+ TransportBridgeService.register("BLE", this)
+ }
+
+ // TransportLayer implementation
+ override fun send(packet: RoutedPacket) {
+ // Received from bridge (e.g. Wi-Fi) -> Send via BLE
+ // Direct injection prevents routing loops (bridge handles source check)
+ connectionManager.broadcastPacket(packet)
+ }
+
+ /**
+ * unified dispatch: Send to local BLE and bridge to other transports
+ */
+ private fun dispatchGlobal(routed: RoutedPacket) {
+ // 1. Send to local BLE transport
+ connectionManager.broadcastPacket(routed)
+ // 2. Bridge to other transports (e.g. Wi-Fi)
+ TransportBridgeService.broadcast("BLE", routed)
}
/**
@@ -161,8 +191,6 @@ class BluetoothMeshService(private val context: Context) {
// PeerManager delegates to main mesh service delegate
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List) {
- // Update process-wide state first
- try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { }
// Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs)
}
@@ -205,7 +233,7 @@ class BluetoothMeshService(private val context: Context) {
)
// Sign the handshake response
val signedPacket = signPacketBeforeBroadcast(responsePacket)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ dispatchGlobal(RoutedPacket(signedPacket))
Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)")
}
@@ -225,7 +253,7 @@ class BluetoothMeshService(private val context: Context) {
}
override fun sendPacket(packet: BitchatPacket) {
- connectionManager.broadcastPacket(RoutedPacket(packet))
+ dispatchGlobal(RoutedPacket(packet))
}
}
@@ -268,11 +296,12 @@ class BluetoothMeshService(private val context: Context) {
override fun sendPacket(packet: BitchatPacket) {
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ val routed = RoutedPacket(signedPacket)
+ dispatchGlobal(routed)
}
override fun relayPacket(routed: RoutedPacket) {
- connectionManager.broadcastPacket(routed)
+ dispatchGlobal(routed)
}
override fun getBroadcastRecipient(): ByteArray {
@@ -319,7 +348,7 @@ class BluetoothMeshService(private val context: Context) {
// Sign the handshake packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ dispatchGlobal(RoutedPacket(signedPacket))
Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)")
} else {
Log.w(TAG, "Failed to generate Noise handshake data for $peerID")
@@ -511,7 +540,7 @@ class BluetoothMeshService(private val context: Context) {
}
override fun relayPacket(routed: RoutedPacket) {
- connectionManager.broadcastPacket(routed)
+ dispatchGlobal(routed)
}
override fun handleRequestSync(routed: RoutedPacket) {
@@ -628,6 +657,9 @@ class BluetoothMeshService(private val context: Context) {
Log.i(TAG, "Stopping Bluetooth mesh service")
isActive = false
+
+ // Unregister from bridge
+ TransportBridgeService.unregister("BLE")
// Send leave announcement
sendLeaveAnnouncement()
@@ -687,7 +719,7 @@ class BluetoothMeshService(private val context: Context) {
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ dispatchGlobal(RoutedPacket(signedPacket))
// Track our own broadcast message for sync
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
}
@@ -719,7 +751,7 @@ class BluetoothMeshService(private val context: Context) {
val signed = signPacketBeforeBroadcast(packet)
// Use a stable transferId based on the file TLV payload for progress tracking
val transferId = sha256Hex(payload)
- connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
+ dispatchGlobal(RoutedPacket(signed, transferId = transferId))
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
}
} catch (e: Exception) {
@@ -777,7 +809,7 @@ class BluetoothMeshService(private val context: Context) {
val signed = signPacketBeforeBroadcast(packet)
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
val transferId = sha256Hex(filePayload)
- connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
+ dispatchGlobal(RoutedPacket(signed, transferId = transferId))
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
} catch (e: Exception) {
@@ -857,7 +889,7 @@ class BluetoothMeshService(private val context: Context) {
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ dispatchGlobal(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
// FIXED: Don't send didReceiveMessage for our own sent messages
@@ -928,7 +960,7 @@ class BluetoothMeshService(private val context: Context) {
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ dispatchGlobal(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
// Persist as read after successful send
@@ -982,7 +1014,7 @@ class BluetoothMeshService(private val context: Context) {
announcePacket.copy(signature = signature)
} ?: announcePacket
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ dispatchGlobal(RoutedPacket(signedPacket))
Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)")
// Track announce for sync
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
@@ -1031,7 +1063,7 @@ class BluetoothMeshService(private val context: Context) {
packet.copy(signature = signature)
} ?: packet
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ dispatchGlobal(RoutedPacket(signedPacket))
peerManager.markPeerAsAnnouncedTo(peerID)
Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)")
@@ -1052,7 +1084,7 @@ class BluetoothMeshService(private val context: Context) {
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ dispatchGlobal(RoutedPacket(signedPacket))
}
/**
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt
new file mode 100644
index 000000000..01df23c80
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt
@@ -0,0 +1,140 @@
+package com.bitchat.android.mesh
+
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Abstract base tracker for mesh connections (BLE, Wi-Fi Aware, etc.)
+ * Encapsulates common state machine logic:
+ * - Connection attempt tracking (retries, backoff)
+ * - Pending connection management
+ * - Automatic cleanup of expired attempts
+ */
+abstract class MeshConnectionTracker(
+ private val scope: CoroutineScope,
+ protected val tag: String
+) {
+ companion object {
+ const val CONNECTION_RETRY_DELAY = 5_000L
+ const val MAX_CONNECTION_ATTEMPTS = 3
+ const val CLEANUP_INTERVAL = 30_000L
+ }
+
+ /**
+ * Connection attempt tracking with automatic expiry
+ */
+ protected data class ConnectionAttempt(
+ val attempts: Int,
+ val lastAttempt: Long = System.currentTimeMillis()
+ ) {
+ fun isExpired(): Boolean =
+ System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2
+
+ fun shouldRetry(): Boolean =
+ attempts < MAX_CONNECTION_ATTEMPTS &&
+ System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY
+ }
+
+ // Tracks in-progress or failed attempts
+ protected val pendingConnections = ConcurrentHashMap()
+
+ private var isActive = false
+
+ /**
+ * Start the tracker and its cleanup loop
+ */
+ open fun start() {
+ isActive = true
+ startPeriodicCleanup()
+ }
+
+ /**
+ * Stop the tracker
+ */
+ open fun stop() {
+ isActive = false
+ pendingConnections.clear()
+ }
+
+ /**
+ * Check if a connection attempt is allowed for this peer/address
+ */
+ fun isConnectionAttemptAllowed(id: String): Boolean {
+ // If already connected, usually no need to retry (subclasses can override logic if needed,
+ // but typically the caller checks isConnected() first).
+
+ val existingAttempt = pendingConnections[id]
+ return existingAttempt?.let {
+ it.isExpired() || it.shouldRetry()
+ } ?: true
+ }
+
+ /**
+ * Record a new connection attempt.
+ * Returns true if the attempt was recorded (allowed), false if skipped.
+ */
+ fun addPendingConnection(id: String): Boolean {
+ synchronized(pendingConnections) {
+ val currentAttempt = pendingConnections[id]
+
+ // If strictly not allowed right now, reject
+ if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) {
+ Log.d(tag, "Connection attempt already in progress for $id")
+ return false
+ }
+
+ // Update attempt count
+ // Reset to 1 if expired, otherwise increment
+ val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1
+ pendingConnections[id] = ConnectionAttempt(attempts)
+ Log.d(tag, "Added pending connection for $id (attempts: $attempts)")
+ return true
+ }
+ }
+
+ /**
+ * Remove a pending attempt (e.g., on success or fatal error)
+ */
+ fun removePendingConnection(id: String) {
+ pendingConnections.remove(id)
+ }
+
+ /**
+ * Abstract: Subclasses must define what "connected" means
+ */
+ abstract fun isConnected(id: String): Boolean
+
+ /**
+ * Abstract: Subclasses must implement disconnect logic
+ */
+ abstract fun disconnect(id: String)
+
+ /**
+ * Abstract: Subclasses report their active connection count
+ */
+ abstract fun getConnectionCount(): Int
+
+ private fun startPeriodicCleanup() {
+ scope.launch {
+ while (isActive) {
+ try {
+ delay(CLEANUP_INTERVAL)
+ if (!isActive) break
+
+ // Clean up expired pending connections
+ val expired = pendingConnections.filter { it.value.isExpired() }
+ expired.keys.forEach { pendingConnections.remove(it) }
+
+ if (expired.isNotEmpty()) {
+ Log.d(tag, "Cleaned up ${expired.size} expired connection attempts")
+ }
+ } catch (e: Exception) {
+ Log.w(tag, "Error in periodic cleanup: ${e.message}")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt
index bdfc9733d..60c6e5e47 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt
@@ -26,6 +26,7 @@ fun BluetoothCheckScreen(
status: BluetoothStatus,
onEnableBluetooth: () -> Unit,
onRetry: () -> Unit,
+ onSkip: () -> Unit,
isLoading: Boolean = false
) {
val colorScheme = MaterialTheme.colorScheme
@@ -39,13 +40,15 @@ fun BluetoothCheckScreen(
BluetoothDisabledContent(
onEnableBluetooth = onEnableBluetooth,
onRetry = onRetry,
+ onSkip = onSkip,
colorScheme = colorScheme,
isLoading = isLoading
)
}
BluetoothStatus.NOT_SUPPORTED -> {
BluetoothNotSupportedContent(
- colorScheme = colorScheme
+ colorScheme = colorScheme,
+ onSkip = onSkip
)
}
BluetoothStatus.ENABLED -> {
@@ -61,6 +64,7 @@ fun BluetoothCheckScreen(
private fun BluetoothDisabledContent(
onEnableBluetooth: () -> Unit,
onRetry: () -> Unit,
+ onSkip: () -> Unit,
colorScheme: ColorScheme,
isLoading: Boolean
) {
@@ -77,7 +81,7 @@ private fun BluetoothDisabledContent(
)
Text(
- text = stringResource(R.string.bluetooth_required),
+ text = stringResource(R.string.bluetooth_recommended),
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -141,20 +145,17 @@ private fun BluetoothDisabledContent(
)
}
- //Since we are automatically checking bluetooth state -- commented
-
-// OutlinedButton(
-// onClick = onRetry,
-// modifier = Modifier.fillMaxWidth()
-// ) {
-// Text(
-// text = "Check Again",
-// style = MaterialTheme.typography.bodyMedium.copy(
-// fontFamily = FontFamily.Monospace
-// ),
-// modifier = Modifier.padding(vertical = 4.dp)
-// )
-// }
+ TextButton(
+ onClick = onSkip,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(R.string.skip),
+ style = MaterialTheme.typography.labelLarge.copy(
+ color = colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+ )
+ }
}
}
}
@@ -162,7 +163,8 @@ private fun BluetoothDisabledContent(
@Composable
private fun BluetoothNotSupportedContent(
- colorScheme: ColorScheme
+ colorScheme: ColorScheme,
+ onSkip: () -> Unit
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
@@ -209,6 +211,16 @@ private fun BluetoothNotSupportedContent(
textAlign = TextAlign.Center
)
}
+
+ Button(
+ onClick = onSkip,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = colorScheme.secondary
+ )
+ ) {
+ Text(text = stringResource(R.string.continue_btn))
+ }
}
}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt
index 871cc892d..ba4da701f 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt
@@ -209,6 +209,7 @@ class OnboardingCoordinator(
return when {
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
+ permission.contains("NEARBY_WIFI") -> "Nearby Wi‑Fi Devices (for Wi‑Fi Aware)"
permission.contains("NOTIFICATION") -> "Notifications"
else -> permission.substringAfterLast(".")
}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
index 2b84aefa1..c00f35f30 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
@@ -242,6 +243,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
PermissionType.MICROPHONE -> Icons.Filled.Mic
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
+ PermissionType.WIFI_AWARE -> Icons.Filled.Wifi
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
PermissionType.OTHER -> Icons.Filled.Settings
}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
index ff0a160fd..aedf85f8e 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
@@ -67,6 +67,11 @@ class PermissionManager(private val context: Context) {
Manifest.permission.ACCESS_FINE_LOCATION
))
+ // Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
+ }
+
// Notification permission intentionally excluded to keep it optional
return permissions
@@ -177,6 +182,20 @@ class PermissionManager(private val context: Context) {
)
)
+ // Wi‑Fi Aware category (Android 13+)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES)
+ categories.add(
+ PermissionCategory(
+ type = PermissionType.WIFI_AWARE,
+ description = "Enable Wi‑Fi Aware to discover and connect to nearby bitchat users over Wi‑Fi.",
+ permissions = wifiAwarePermissions,
+ isGranted = wifiAwarePermissions.all { isPermissionGranted(it) },
+ systemDescription = "Allow bitchat to discover nearby Wi‑Fi devices"
+ )
+ )
+ }
+
// Notifications category (if applicable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
categories.add(
@@ -262,6 +281,7 @@ enum class PermissionType(val nameValue: String) {
PRECISE_LOCATION("Precise Location"),
MICROPHONE("Microphone"),
NOTIFICATIONS("Notifications"),
+ WIFI_AWARE("Wi‑Fi Aware"),
BATTERY_OPTIMIZATION("Battery Optimization"),
OTHER("Other")
}
diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
index d271ab295..71dddb664 100644
--- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
+++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
@@ -9,6 +9,12 @@ import com.bitchat.android.mesh.BluetoothMeshService
*/
object MeshServiceHolder {
private const val TAG = "MeshServiceHolder"
+ @Volatile
+ var sharedGossipSyncManager: com.bitchat.android.sync.GossipSyncManager? = null
+ private set
+
+ fun setGossipManager(mgr: com.bitchat.android.sync.GossipSyncManager) { sharedGossipSyncManager = mgr }
+
@Volatile
var meshService: BluetoothMeshService? = null
private set
diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
new file mode 100644
index 000000000..61c73c822
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
@@ -0,0 +1,69 @@
+package com.bitchat.android.service
+
+import android.util.Log
+import com.bitchat.android.model.RoutedPacket
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Central bridge for routing packets between different transport layers
+ * (e.g., Bluetooth LE <-> Wi-Fi Aware).
+ *
+ * Allows a packet received on one transport to be seamlessly relayed
+ * to all other active transports, effectively bridging separate meshes.
+ */
+object TransportBridgeService {
+ private const val TAG = "TransportBridgeService"
+
+ /**
+ * Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement
+ * to receive bridged packets.
+ */
+ interface TransportLayer {
+ /**
+ * Send a packet out via this transport.
+ */
+ fun send(packet: RoutedPacket)
+ }
+
+ private val transports = ConcurrentHashMap()
+
+ /**
+ * Register a transport layer to receive bridged packets.
+ * @param id Unique identifier (e.g., "BLE", "WIFI")
+ * @param layer The transport implementation
+ */
+ fun register(id: String, layer: TransportLayer) {
+ Log.i(TAG, "Registering transport layer: $id")
+ transports[id] = layer
+ }
+
+ /**
+ * Unregister a transport layer.
+ */
+ fun unregister(id: String) {
+ Log.i(TAG, "Unregistering transport layer: $id")
+ transports.remove(id)
+ }
+
+ /**
+ * Broadcast a packet from a specific source transport to ALL other registered transports.
+ *
+ * @param sourceId The ID of the transport initiating the broadcast (e.g., "BLE").
+ * The packet will NOT be sent back to this source.
+ * @param packet The packet to bridge.
+ */
+ fun broadcast(sourceId: String, packet: RoutedPacket) {
+ val targets = transports.filterKeys { it != sourceId }
+ if (targets.isEmpty()) return
+
+ // Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}")
+
+ targets.forEach { (id, layer) ->
+ try {
+ layer.send(packet)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to bridge packet to $id: ${e.message}")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt
index 8166487e4..53db2fa61 100644
--- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt
+++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt
@@ -72,9 +72,15 @@ class MessageRouter private constructor(
val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true
val hasEstablished = mesh.hasEstablishedSession(toPeerID)
+ // Check Wi‑Fi Aware availability as a secondary transport
+ val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null }
+ val hasAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false }
if (hasMesh && hasEstablished) {
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…")
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
+ } else if (hasAware) {
+ Log.d(TAG, "Routing PM via Wi‑Fi Aware to ${toPeerID} msg_id=${messageID.take(8)}…")
+ aware?.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
} else if (canSendViaNostr(toPeerID)) {
Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}… msg_id=${messageID.take(8)}…")
nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
@@ -83,14 +89,21 @@ class MessageRouter private constructor(
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
q.add(Triple(content, recipientNickname, messageID))
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…")
- mesh.initiateNoiseHandshake(toPeerID)
+ if (hasMesh) mesh.initiateNoiseHandshake(toPeerID) else aware?.initiateNoiseHandshake(toPeerID)
}
}
fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) {
- if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)) {
+ val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null }
+ val viaMesh = (mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)
+ val viaAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false }
+ if (viaMesh) {
Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…")
mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID)
+ } else if (viaAware) {
+ Log.d(TAG, "Routing READ via Wi‑Fi Aware to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…")
+ val me = try { aware?.myPeerID } catch (_: Exception) { null }
+ aware?.sendReadReceipt(receipt.originalMessageID, toPeerID, me ?: "")
} else {
Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…")
nostr.sendReadReceipt(receipt, toPeerID)
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
index 1196396d5..ee31ef74e 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
@@ -441,8 +441,9 @@ class ChatViewModel(
state.getNicknameValue()
)
} else {
- // Default: route via mesh
+ // Default: route via mesh + Wi‑Fi Aware
meshService.sendMessage(messageContent, mentions, channel)
+ try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(messageContent, mentions, channel) } catch (_: Exception) {}
}
})
return
@@ -512,19 +513,23 @@ class ChatViewModel(
state.getNicknameValue(),
meshService.myPeerID,
onEncryptedPayload = { encryptedData ->
- // This would need proper mesh service integration
+ // Send encrypted payload announcement over both transports for reachability
meshService.sendMessage(content, mentions, currentChannelValue)
+ try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {}
},
onFallback = {
meshService.sendMessage(content, mentions, currentChannelValue)
+ try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {}
}
)
} else {
meshService.sendMessage(content, mentions, currentChannelValue)
+ try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {}
}
} else {
messageManager.addMessage(message)
meshService.sendMessage(content, mentions, null)
+ try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, null) } catch (_: Exception) {}
}
}
}
@@ -647,16 +652,23 @@ class ChatViewModel(
val fingerprints = privateChatManager.getAllPeerFingerprints()
state.setPeerFingerprints(fingerprints)
- val nicknames = meshService.getPeerNicknames()
- state.setPeerNicknames(nicknames)
+ // Merge nicknames from BLE and Wi‑Fi Aware to display names for all peers
+ val bleNick = meshService.getPeerNicknames()
+ val awareNickRaw = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknamesMap() } catch (_: Exception) { null }
+ val mergedNick = if (awareNickRaw != null) bleNick + awareNickRaw.filter { it.value != null }.mapValues { it.value!! }.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick
+ state.setPeerNicknames(mergedNick)
val rssiValues = meshService.getPeerRSSI()
- state.setPeerRSSI(rssiValues)
+ val awareRssi = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerRSSI() } catch (_: Exception) { null }
+ val mergedRssi = if (awareRssi != null) rssiValues + awareRssi.filterKeys { it !in rssiValues } else rssiValues
+ state.setPeerRSSI(mergedRssi)
// Update directness per peer (driven by PeerManager state)
try {
val directMap = state.getConnectedPeersValue().associateWith { pid ->
- meshService.getPeerInfo(pid)?.isDirectConnection == true
+ val ble = meshService.getPeerInfo(pid)?.isDirectConnection == true
+ val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerInfo(pid)?.isDirectConnection == true } catch (_: Exception) { false }
+ ble || aware
}
state.setPeerDirect(directMap)
} catch (_: Exception) { }
@@ -732,6 +744,10 @@ class ChatViewModel(
override fun didUpdatePeerList(peers: List) {
meshDelegateHandler.didUpdatePeerList(peers)
}
+
+ fun onWifiPeersUpdated(peers: List) {
+ meshDelegateHandler.onWifiPeersUpdated(peers)
+ }
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer)
diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt
index a3def5235..fc134a1ba 100644
--- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt
+++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt
@@ -210,6 +210,7 @@ class MediaSendingManager(
Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID")
meshService.sendFilePrivate(toPeerID, filePacket)
+ try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFilePrivate(toPeerID, filePacket) } catch (_: Exception) {}
Log.d(TAG, "✅ File send completed successfully")
}
@@ -264,6 +265,7 @@ class MediaSendingManager(
Log.d(TAG, "📤 Calling meshService.sendFileBroadcast")
meshService.sendFileBroadcast(filePacket)
+ try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFileBroadcast(filePacket) } catch (_: Exception) {}
Log.d(TAG, "✅ File broadcast completed successfully")
}
diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt
index a452616ec..559ef11a2 100644
--- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt
+++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt
@@ -94,101 +94,123 @@ class MeshDelegateHandler(
}
}
+ private var blePeers: Set = emptySet()
+ private var wifiPeers: Set = emptySet()
+
override fun didUpdatePeerList(peers: List) {
coroutineScope.launch {
- state.setConnectedPeers(peers)
- state.setIsConnected(peers.isNotEmpty())
- notificationManager.showActiveUserNotification(peers)
- // Flush router outbox for any peers that just connected (and their noiseHex aliases)
- runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) }
+ blePeers = peers.toSet()
+ processPeerUpdate()
+ }
+ }
- // Clean up channel members who disconnected
- channelManager.cleanupDisconnectedMembers(peers, getMyPeerID())
+ fun onWifiPeersUpdated(peers: List) {
+ coroutineScope.launch {
+ wifiPeers = peers.toSet()
+ processPeerUpdate()
+ }
+ }
- // Handle chat view migration based on current selection and new peer list
- state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
- val isNostrAlias = currentPeer.startsWith("nostr_")
- val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
- val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
+ private suspend fun processPeerUpdate() {
+ // Merge peers from multiple transports
+ val mergedPeers = (blePeers + wifiPeers).toList()
+
+ // Update process-wide state as source of truth
+ try { com.bitchat.android.services.AppStateStore.setPeers(mergedPeers) } catch (_: Exception) { }
- if (isNostrAlias || isNoiseHex) {
- // Reverse case: Nostr/offline chat is open, and peer may have come online on mesh.
- // Resolve canonical target (prefer connected mesh peer if available)
- val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
- selectedPeerID = currentPeer,
- connectedPeers = peers,
- meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
- meshHasPeer = { pid -> peers.contains(pid) },
- nostrPubHexForAlias = { alias ->
- // Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
- if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
- com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
- } else {
- // Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
- val prefix = alias.removePrefix("nostr_")
- val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
- favs.firstNotNullOfOrNull { rel ->
- rel.peerNostrPublicKey?.let { s ->
- runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
- if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
- }
- }
- }?.takeIf { it.startsWith(prefix, ignoreCase = true) }
- }
- },
- findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
- )
- if (canonical != currentPeer) {
- // Merge conversations and switch selection to the live mesh peer (or noiseHex)
- com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer))
- state.setSelectedPrivateChatPeer(canonical)
- }
- } else if (isMeshEphemeral && !peers.contains(currentPeer)) {
- // Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex)
- val favoriteRel = try {
- val info = getPeerInfo(currentPeer)
- val noiseKey = info?.noisePublicKey
- if (noiseKey != null) {
- com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
- } else null
- } catch (_: Exception) { null }
+ state.setConnectedPeers(mergedPeers)
+ state.setIsConnected(mergedPeers.isNotEmpty())
+ notificationManager.showActiveUserNotification(mergedPeers)
+
+ // Flush router outbox for any peers that just connected (and their noiseHex aliases)
+ runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(mergedPeers) }
- if (favoriteRel?.isMutual == true) {
- val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
- if (noiseHex != currentPeer) {
- com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
- state = state,
- targetPeerID = noiseHex,
- keysToMerge = listOf(currentPeer)
- )
- state.setSelectedPrivateChatPeer(noiseHex)
+ // Clean up channel members who disconnected
+ channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID())
+
+ // Handle chat view migration based on current selection and new peer list
+ state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
+ val isNostrAlias = currentPeer.startsWith("nostr_")
+ val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
+ val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
+
+ if (isNostrAlias || isNoiseHex) {
+ // Reverse case: Nostr/offline chat is open, and peer may have come online on mesh.
+ // Resolve canonical target (prefer connected mesh peer if available)
+ val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
+ selectedPeerID = currentPeer,
+ connectedPeers = mergedPeers,
+ meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
+ meshHasPeer = { pid -> mergedPeers.contains(pid) },
+ nostrPubHexForAlias = { alias ->
+ // Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
+ if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
+ com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
+ } else {
+ // Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
+ val prefix = alias.removePrefix("nostr_")
+ val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
+ favs.firstNotNullOfOrNull { rel ->
+ rel.peerNostrPublicKey?.let { s ->
+ runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
+ if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
+ }
+ }
+ }?.takeIf { it.startsWith(prefix, ignoreCase = true) }
}
- } else {
- privateChatManager.cleanupDisconnectedPeer(currentPeer)
+ },
+ findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
+ )
+ if (canonical != currentPeer) {
+ // Merge conversations and switch selection to the live mesh peer (or noiseHex)
+ com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer))
+ state.setSelectedPrivateChatPeer(canonical)
+ }
+ } else if (isMeshEphemeral && !mergedPeers.contains(currentPeer)) {
+ // Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex)
+ val favoriteRel = try {
+ val info = getPeerInfo(currentPeer)
+ val noiseKey = info?.noisePublicKey
+ if (noiseKey != null) {
+ com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
+ } else null
+ } catch (_: Exception) { null }
+
+ if (favoriteRel?.isMutual == true) {
+ val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
+ if (noiseHex != currentPeer) {
+ com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
+ state = state,
+ targetPeerID = noiseHex,
+ keysToMerge = listOf(currentPeer)
+ )
+ state.setSelectedPrivateChatPeer(noiseHex)
}
+ } else {
+ privateChatManager.cleanupDisconnectedPeer(currentPeer)
}
}
+ }
- // Global unification: for each connected peer, merge any offline/stable conversations
- // (noiseHex or nostr_) into the connected peer's chat so there is only one chat per identity.
- peers.forEach { pid ->
- try {
- val info = getPeerInfo(pid)
- val noiseKey = info?.noisePublicKey ?: return@forEach
- val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
+ // Global unification: for each connected peer, merge any offline/stable conversations
+ // (noiseHex or nostr_) into the connected peer's chat so there is only one chat per identity.
+ mergedPeers.forEach { pid ->
+ try {
+ val info = getPeerInfo(pid)
+ val noiseKey = info?.noisePublicKey ?: return@forEach
+ val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
- // Derive temp nostr key from favorites npub
- val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
- val tempNostrKey: String? = try {
- if (npub != null) {
- val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
- if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null
- } else null
- } catch (_: Exception) { null }
+ // Derive temp nostr key from favorites npub
+ val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
+ val tempNostrKey: String? = try {
+ if (npub != null) {
+ val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
+ if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null
+ } else null
+ } catch (_: Exception) { null }
- unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey))
- } catch (_: Exception) { }
- }
+ unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey))
+ } catch (_: Exception) { }
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt
index 4ab4360af..c504b9f93 100644
--- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt
+++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt
@@ -576,9 +576,26 @@ private fun PeerItem(
tint = Color.Gray
)
} else {
+ val awareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState()
+ val awareDiscovered by com.bitchat.android.wifiaware.WifiAwareController.discoveredPeers.collectAsState()
+ val isWifiDirect = awareConnected.containsKey(peerID)
+ val isBleDirect = isDirect
+ val icon = when {
+ isWifiDirect -> Icons.Filled.Wifi
+ isBleDirect -> Icons.Outlined.SettingsInputAntenna
+ // Routed: show Route icon; optionally prefer Wi‑Fi Aware if discovered there
+ awareDiscovered.contains(peerID) -> Icons.Filled.WifiTethering
+ else -> Icons.Filled.Route
+ }
+ val cd = when {
+ isWifiDirect -> "Direct Wi‑Fi Aware"
+ isBleDirect -> "Direct Bluetooth"
+ awareDiscovered.contains(peerID) -> "Routed over Wi‑Fi"
+ else -> "Routed"
+ }
Icon(
- imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route,
- contentDescription = if (isDirect) "Direct Bluetooth" else "Routed",
+ imageVector = icon,
+ contentDescription = cd,
modifier = Modifier.size(16.dp),
tint = colorScheme.onSurface.copy(alpha = 0.8f)
)
diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt
index 04ad48a2e..2d734c14e 100644
--- a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt
+++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt
@@ -20,7 +20,10 @@ object DebugPreferenceManager {
// GCS keys (no migration/back-compat)
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent"
- // Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled
+ // Transport master toggles
+ private const val KEY_BLE_ENABLED = "ble_enabled"
+ private const val KEY_WIFI_AWARE_ENABLED = "wifi_aware_enabled"
+ private const val KEY_WIFI_AWARE_VERBOSE = "wifi_aware_verbose"
private lateinit var prefs: SharedPreferences
@@ -102,5 +105,25 @@ object DebugPreferenceManager {
if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply()
}
- // No longer storing persistent notification in debug prefs.
+ // Transport toggles
+ fun getBleEnabled(default: Boolean = true): Boolean =
+ if (ready()) prefs.getBoolean(KEY_BLE_ENABLED, default) else default
+
+ fun setBleEnabled(value: Boolean) {
+ if (ready()) prefs.edit().putBoolean(KEY_BLE_ENABLED, value).apply()
+ }
+
+ fun getWifiAwareEnabled(default: Boolean = false): Boolean =
+ if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_ENABLED, default) else default
+
+ fun setWifiAwareEnabled(value: Boolean) {
+ if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_ENABLED, value).apply()
+ }
+
+ fun getWifiAwareVerbose(default: Boolean = false): Boolean =
+ if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_VERBOSE, default) else default
+
+ fun setWifiAwareVerbose(value: Boolean) {
+ if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_VERBOSE, value).apply()
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt
index 77f6ce12a..669331e60 100644
--- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt
+++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt
@@ -36,6 +36,17 @@ class DebugSettingsManager private constructor() {
private val _packetRelayEnabled = MutableStateFlow(true)
val packetRelayEnabled: StateFlow = _packetRelayEnabled.asStateFlow()
+ // Master transport toggles
+ private val _bleEnabled = MutableStateFlow(true)
+ val bleEnabled: StateFlow = _bleEnabled.asStateFlow()
+
+ private val _wifiAwareEnabled = MutableStateFlow(false)
+ val wifiAwareEnabled: StateFlow = _wifiAwareEnabled.asStateFlow()
+
+ // Master transport toggles
+ private val _wifiAwareVerbose = MutableStateFlow(false)
+ val wifiAwareVerbose: StateFlow = _wifiAwareVerbose.asStateFlow()
+
// Visibility of the debug sheet; gates heavy work
private val _debugSheetVisible = MutableStateFlow(false)
val debugSheetVisible: StateFlow = _debugSheetVisible.asStateFlow()
@@ -59,6 +70,10 @@ class DebugSettingsManager private constructor() {
_maxConnectionsOverall.value = DebugPreferenceManager.getMaxConnectionsOverall(8)
_maxServerConnections.value = DebugPreferenceManager.getMaxConnectionsServer(8)
_maxClientConnections.value = DebugPreferenceManager.getMaxConnectionsClient(8)
+ // Transport toggles
+ _bleEnabled.value = DebugPreferenceManager.getBleEnabled(true)
+ _wifiAwareEnabled.value = DebugPreferenceManager.getWifiAwareEnabled(false)
+ _wifiAwareVerbose.value = DebugPreferenceManager.getWifiAwareVerbose(false)
} catch (_: Exception) {
// Preferences not ready yet; keep defaults. They will be applied on first change.
}
@@ -262,6 +277,27 @@ class DebugSettingsManager private constructor() {
))
}
+ fun setBleEnabled(enabled: Boolean) {
+ DebugPreferenceManager.setBleEnabled(enabled)
+ _bleEnabled.value = enabled
+ addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 BLE enabled" else "🔴 BLE disabled"))
+ }
+
+ fun setWifiAwareEnabled(enabled: Boolean) {
+ DebugPreferenceManager.setWifiAwareEnabled(enabled)
+ _wifiAwareEnabled.value = enabled
+ addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 Wi‑Fi Aware enabled" else "🔴 Wi‑Fi Aware disabled"))
+ try {
+ com.bitchat.android.wifiaware.WifiAwareController.setEnabled(enabled)
+ } catch (_: Exception) { }
+ }
+
+ fun setWifiAwareVerbose(enabled: Boolean) {
+ DebugPreferenceManager.setWifiAwareVerbose(enabled)
+ _wifiAwareVerbose.value = enabled
+ addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🔊 Wi‑Fi Aware verbose logging enabled" else "🔇 Wi‑Fi Aware verbose logging disabled"))
+ }
+
fun setMaxConnectionsOverall(value: Int) {
val clamped = value.coerceIn(1, 32)
DebugPreferenceManager.setMaxConnectionsOverall(clamped)
@@ -319,6 +355,16 @@ class DebugSettingsManager private constructor() {
fun updateConnectedDevices(devices: List) {
_connectedDevices.value = devices
}
+
+ // Wi‑Fi Aware debug collections
+ private val _wifiAwareDiscovered = MutableStateFlow