diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8c957240..f83ecefb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
@@ -20,6 +21,12 @@
+
+
+
+
+
+
@@ -52,6 +59,8 @@
+
+
create(modelClass: Class): T {
@Suppress("UNCHECKED_CAST")
- return ChatViewModel(application, meshService) as T
+ return ChatViewModel(application, meshService, unifiedMeshService) as T
}
}
}
@@ -114,6 +117,10 @@ class MainActivity : OrientationAwareActivity() {
// Ensure foreground service is running and get mesh instance from holder
try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
+ unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext)
+ // Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays - DEPRECATED
+ // Bridging is now handled by TransportBridgeService automatically
+
bluetoothStatusManager = BluetoothStatusManager(
activity = this,
context = this,
@@ -164,6 +171,17 @@ class MainActivity : OrientationAwareActivity() {
}
}
}
+
+ // Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI.
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ WifiAwareController.running.collect { running ->
+ if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
+ unifiedMeshService.delegate = chatViewModel
+ }
+ }
+ }
+ }
// Only start onboarding process if we're in the initial CHECKING state
// This prevents restarting onboarding on configuration changes
@@ -222,6 +240,10 @@ class MainActivity : OrientationAwareActivity() {
onRetry = {
checkBluetoothAndProceed()
},
+ onSkip = {
+ mainViewModel.skipBluetoothCheck()
+ checkLocationAndProceed()
+ },
isLoading = isBluetoothLoading
)
}
@@ -355,6 +377,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()) {
@@ -367,6 +396,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
@@ -472,6 +507,8 @@ class MainActivity : OrientationAwareActivity() {
Log.d("MainActivity", "Location services enabled by user")
mainViewModel.updateLocationLoading(false)
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
+ // Ensure Wi-Fi Aware starts now that location is enabled
+ com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
checkBatteryOptimizationAndProceed()
}
@@ -540,8 +577,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)
@@ -674,9 +712,9 @@ class MainActivity : OrientationAwareActivity() {
return@launch
}
- // Set up mesh service delegate and start services
- meshService.delegate = chatViewModel
- meshService.startServices()
+ // Set up unified mesh delegate and start enabled transports
+ unifiedMeshService.delegate = chatViewModel
+ unifiedMeshService.startServices()
Log.d("MainActivity", "Mesh service started successfully")
@@ -720,11 +758,12 @@ class MainActivity : OrientationAwareActivity() {
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
- try { meshService.delegate = chatViewModel } catch (_: Exception) { }
+ try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { }
// Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
- if (currentBluetoothStatus != BluetoothStatus.ENABLED) {
+ 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)
@@ -739,6 +778,9 @@ class MainActivity : OrientationAwareActivity() {
mainViewModel.updateLocationStatus(currentLocationStatus)
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false)
+ } else {
+ // If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier
+ com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
}
}
}
@@ -748,7 +790,7 @@ class MainActivity : OrientationAwareActivity() {
// Only set background state if app is fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
- try { meshService.delegate = null } catch (_: Exception) { }
+ try { unifiedMeshService.delegate = null } catch (_: Exception) { }
}
}
diff --git a/app/src/main/java/com/bitchat/android/MainViewModel.kt b/app/src/main/java/com/bitchat/android/MainViewModel.kt
index 35125d85..15ec6fda 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/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
index dce58031..328d361f 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
@@ -88,22 +88,49 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap
+ private fun isBleTransportEnabled(): Boolean {
+ return try {
+ com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
+ } catch (_: Exception) {
+ try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
+ }
+ }
+
+ private fun isGattServerEnabled(): Boolean {
+ return isBleTransportEnabled() &&
+ (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true })
+ }
+
+ private fun isGattClientEnabled(): Boolean {
+ return isBleTransportEnabled() &&
+ (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true })
+ }
+
init {
powerManager.delegate = this
// Observe debug settings to enforce role state while active
try {
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
+ // Master transport enable/disable
+ connectionScope.launch {
+ dbg.bleEnabled.collect { enabled ->
+ if (enabled) return@collect
+ if (isActive) {
+ disableTransport()
+ }
+ }
+ }
// Role enable/disable
connectionScope.launch {
dbg.gattServerEnabled.collect { enabled ->
if (!isActive) return@collect
- if (enabled) startServer() else stopServer()
+ if (enabled && isBleTransportEnabled()) startServer() else stopServer()
}
}
connectionScope.launch {
dbg.gattClientEnabled.collect { enabled ->
if (!isActive) return@collect
- if (enabled) startClient() else stopClient()
+ if (enabled && isBleTransportEnabled()) startClient() else stopClient()
}
}
@@ -163,6 +190,12 @@ class BluetoothConnectionManager(
*/
fun startServices(): Boolean {
Log.i(TAG, "Starting power-optimized Bluetooth services...")
+
+ if (!isBleTransportEnabled()) {
+ Log.i(TAG, "BLE transport disabled by debug settings; not starting Bluetooth services")
+ disableTransport()
+ return false
+ }
if (!permissionManager.hasBluetoothPermissions()) {
Log.e(TAG, "Missing Bluetooth permissions")
@@ -197,9 +230,8 @@ class BluetoothConnectionManager(
powerManager.start()
// Start server/client based on debug settings
- val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
- val startServer = dbg?.gattServerEnabled?.value != false
- val startClient = dbg?.gattClientEnabled?.value != false
+ val startServer = isGattServerEnabled()
+ val startClient = isGattClientEnabled()
if (startServer) {
if (!serverManager.start()) {
@@ -234,6 +266,19 @@ class BluetoothConnectionManager(
return false
}
}
+
+ /**
+ * Disable BLE without cancelling this manager's coroutine scope, so it can be re-enabled.
+ */
+ fun disableTransport() {
+ Log.i(TAG, "Disabling BLE transport")
+ isActive = false
+ connectionScope.launch {
+ clientManager.stop()
+ serverManager.stop()
+ connectionTracker.stop()
+ }
+ }
/**
* Stop all Bluetooth services with proper cleanup
@@ -279,7 +324,7 @@ class BluetoothConnectionManager(
* Automatically fragments large packets to fit within BLE MTU limits
*/
fun broadcastPacket(routed: RoutedPacket) {
- if (!isActive) return
+ if (!isActive || !isBleTransportEnabled()) return
packetBroadcaster.broadcastPacket(
routed,
@@ -289,7 +334,7 @@ class BluetoothConnectionManager(
}
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
- if (!isActive) return false
+ if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendToPeer(
peerID,
routed,
@@ -306,7 +351,7 @@ class BluetoothConnectionManager(
* Send a packet directly to a specific peer, without broadcasting to others.
*/
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
- if (!isActive) return false
+ if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendPacketToPeer(
RoutedPacket(packet),
peerID,
@@ -317,9 +362,15 @@ class BluetoothConnectionManager(
// Expose role controls for debug UI
- fun startServer() { connectionScope.launch { serverManager.start() } }
+ fun startServer() {
+ if (!isActive || !isBleTransportEnabled()) return
+ connectionScope.launch { if (isGattServerEnabled()) serverManager.start() }
+ }
fun stopServer() { connectionScope.launch { serverManager.stop() } }
- fun startClient() { connectionScope.launch { clientManager.start() } }
+ fun startClient() {
+ if (!isActive || !isBleTransportEnabled()) return
+ connectionScope.launch { if (isGattClientEnabled()) clientManager.start() }
+ }
fun stopClient() { connectionScope.launch { clientManager.stop() } }
// Inject nickname resolver for broadcaster logs
@@ -347,7 +398,10 @@ class BluetoothConnectionManager(
/**
* Public: connect/disconnect helpers for debug UI
*/
- fun connectToAddress(address: String): Boolean = clientManager.connectToAddress(address)
+ fun connectToAddress(address: String): Boolean {
+ if (!isActive || !isBleTransportEnabled()) return false
+ return clientManager.connectToAddress(address)
+ }
fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) }
@@ -358,10 +412,10 @@ class BluetoothConnectionManager(
clientManager.stop()
serverManager.stop()
delay(200)
- if (isActive) {
+ if (isActive && isBleTransportEnabled()) {
// Restart managers if service is active
- serverManager.start()
- clientManager.start()
+ if (isGattServerEnabled()) serverManager.start()
+ if (isGattClientEnabled()) clientManager.start()
}
}
}
@@ -396,11 +450,17 @@ class BluetoothConnectionManager(
Log.i(TAG, "Power mode changed to: $newMode")
connectionScope.launch {
+ if (!isActive || !isBleTransportEnabled()) {
+ serverManager.stop()
+ clientManager.stop()
+ return@launch
+ }
+
// Avoid rapid scan restarts by checking if we need to change scan behavior
val wasUsingDutyCycle = powerManager.shouldUseDutyCycle()
// Update advertising with new power settings if server enabled
- val serverEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
+ val serverEnabled = isGattServerEnabled()
if (serverEnabled) {
serverManager.restartAdvertising()
} else {
@@ -411,7 +471,7 @@ class BluetoothConnectionManager(
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
if (wasUsingDutyCycle != nowUsingDutyCycle) {
Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan")
- val clientEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
+ val clientEnabled = isGattClientEnabled()
if (clientEnabled) {
clientManager.restartScanning()
} else {
@@ -427,6 +487,10 @@ class BluetoothConnectionManager(
}
override fun onScanStateChanged(shouldScan: Boolean) {
+ if (!isActive || !isBleTransportEnabled()) {
+ clientManager.onScanStateChanged(false)
+ return
+ }
clientManager.onScanStateChanged(shouldScan)
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
index 681185d5..f139b9d3 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
@@ -16,29 +16,22 @@ 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
private val connectedDevices = ConcurrentHashMap()
private val subscribedDevices = CopyOnWriteArrayList()
val addressPeerMap = ConcurrentHashMap()
+ // Track whether we have seen the first ANNOUNCE on a given device connection
+ private val firstAnnounceSeen = ConcurrentHashMap()
// 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
*/
@@ -52,37 +45,28 @@ class BluetoothConnectionTracker(
val peerID: String? = null
)
- /**
- * 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
+ override fun start() {
+ super.start()
}
- /**
- * Start the connection tracker
- */
- fun start() {
- isActive = true
- startPeriodicCleanup()
- }
-
- /**
- * 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
@@ -90,7 +74,9 @@ 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
}
/**
@@ -163,9 +149,7 @@ class BluetoothConnectionTracker(
/**
* Check if device is already connected
*/
- fun isDeviceConnected(deviceAddress: String): Boolean {
- return connectedDevices.containsKey(deviceAddress)
- }
+ fun isDeviceConnected(deviceAddress: String): Boolean = isConnected(deviceAddress)
/**
* Check if a peer is already connected (by PeerID)
@@ -175,63 +159,15 @@ class BluetoothConnectionTracker(
return connectedDevices.values.any { it.peerID == peerID }
}
- /**
- * 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
- }
- }
-
/**
* 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
@@ -301,6 +237,7 @@ class BluetoothConnectionTracker(
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
}
+ firstAnnounceSeen.remove(deviceAddress)
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
}
@@ -334,36 +271,21 @@ class BluetoothConnectionTracker(
addressPeerMap.clear()
pendingConnections.clear()
scanRSSI.clear()
+ firstAnnounceSeen.clear()
}
/**
- * Start periodic cleanup of expired connections
+ * Mark that we have received the first ANNOUNCE over this device connection.
*/
- 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}")
- }
- }
- }
+ fun noteAnnounceReceived(deviceAddress: String) {
+ firstAnnounceSeen[deviceAddress] = true
+ }
+
+ /**
+ * Check whether the first ANNOUNCE has been seen for a device connection.
+ */
+ fun hasSeenFirstAnnounce(deviceAddress: String): Boolean {
+ return firstAnnounceSeen[deviceAddress] == true
}
/**
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
index 2bb22fce..3885a523 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
@@ -32,6 +32,11 @@ class BluetoothGattClientManager(
companion object {
private const val TAG = "BluetoothGattClientManager"
+ // Self-healing scan recovery tuning
+ private const val SCAN_RETRY_BASE_MS = 3_000L // base backoff for transient scan failures
+ private const val SCAN_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
+ private const val SCAN_WATCHDOG_INTERVAL_MS = 30_000L // how often to verify the scanner is alive
+ private const val SCAN_STALE_RESULT_MS = 120_000L // force a scan restart if no results for this long
}
// Core Bluetooth components
@@ -39,11 +44,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
@@ -61,8 +83,16 @@ class BluetoothGattClientManager(
// Scan rate limiting to prevent "scanning too frequently" errors
private var lastScanStartTime = 0L
private var lastScanStopTime = 0L
- private var isCurrentlyScanning = false
+ @Volatile private var isCurrentlyScanning = false
private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts
+
+ // Self-healing scan state.
+ // scanningDesired distinguishes "we want to be scanning but it isn't running" (a fault to recover
+ // from) from "scanning is intentionally off" (e.g. duty-cycle OFF window or client disabled).
+ @Volatile private var scanningDesired = false
+ @Volatile private var lastScanResultTime = 0L
+ private var scanRetryCount = 0
+ private var scanWatchdogJob: Job? = null
// RSSI monitoring state
private var rssiMonitoringJob: Job? = null
@@ -75,12 +105,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")
@@ -106,12 +134,16 @@ class BluetoothGattClientManager(
connectionScope.launch {
if (powerManager.shouldUseDutyCycle()) {
Log.i(TAG, "Using power-aware duty cycling")
+ // Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that.
} else {
+ scanningDesired = true
startScanning()
}
// Start RSSI monitoring
startRSSIMonitoring()
+ // Start the scan watchdog so a silently-dead or wedged scanner self-heals.
+ startScanWatchdog()
}
return true
@@ -121,6 +153,8 @@ class BluetoothGattClientManager(
* Stop client manager
*/
fun stop() {
+ scanningDesired = false
+ stopScanWatchdog()
if (!isActive) {
// Idempotent stop
stopScanning()
@@ -150,7 +184,8 @@ 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()
+ scanningDesired = shouldScan && enabled
if (shouldScan && enabled) {
startScanning()
} else {
@@ -199,7 +234,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 +252,7 @@ class BluetoothGattClientManager(
// Schedule delayed scan start
connectionScope.launch {
delay(remainingWait)
- if (isActive && !isCurrentlyScanning) {
+ if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) {
startScanning()
}
}
@@ -251,22 +286,39 @@ class BluetoothGattClientManager(
lastScanStopTime = System.currentTimeMillis()
when (errorCode) {
- 1 -> Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
- 2 -> Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
- 3 -> Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
- 4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED")
- 5 -> Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
+ 1 -> {
+ // Already started: the stack thinks a scan is running. Re-arm from a clean
+ // state so we don't stay wedged (stop then restart with backoff).
+ Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
+ stopScanning()
+ scheduleScanRestart("already-started", SCAN_RETRY_BASE_MS)
+ }
+ 2 -> {
+ // App registration failed: common transient stack fault. Previously had NO
+ // retry, which left discovery dead until a manual BLE toggle.
+ Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
+ scheduleScanRestart("registration-failed", SCAN_RETRY_BASE_MS)
+ }
+ 3 -> {
+ Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
+ scheduleScanRestart("internal-error", SCAN_RETRY_BASE_MS)
+ }
+ 4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") // permanent: don't retry
+ 5 -> {
+ // Out of hardware resources: back off longer so other scanners/connections
+ // can free up before we try again.
+ Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
+ scheduleScanRestart("out-of-resources", SCAN_RETRY_BASE_MS * 3)
+ }
6 -> {
Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY")
Log.w(TAG, "Scan failed due to rate limiting - will retry after delay")
- connectionScope.launch {
- delay(10000) // Wait 10 seconds before retrying
- if (isActive) {
- startScanning()
- }
- }
+ scheduleScanRestart("too-frequently", 10_000L)
+ }
+ else -> {
+ Log.e(TAG, "Unknown scan failure code: $errorCode")
+ scheduleScanRestart("unknown-$errorCode", SCAN_RETRY_BASE_MS)
}
- else -> Log.e(TAG, "Unknown scan failure code: $errorCode")
}
}
}
@@ -304,6 +356,76 @@ class BluetoothGattClientManager(
lastScanStopTime = System.currentTimeMillis()
}
}
+
+ /**
+ * Schedule a scan restart with incremental backoff. Used to recover from transient scan
+ * failures that previously had no retry path (codes 2/3/5), leaving discovery dead until a
+ * manual BLE toggle.
+ */
+ private fun scheduleScanRestart(reason: String, baseDelayMs: Long) {
+ scanRetryCount++
+ val delayMs = (baseDelayMs * scanRetryCount).coerceAtMost(SCAN_MAX_RETRY_DELAY_MS)
+ Log.w(TAG, "Scheduling scan restart in ${delayMs}ms (attempt $scanRetryCount, reason=$reason)")
+ connectionScope.launch {
+ delay(delayMs)
+ if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
+ startScanning()
+ }
+ }
+ }
+
+ /**
+ * Periodic watchdog that self-heals the scanner. Android can stop a scan without ever invoking
+ * onScanFailed (internal stack reset, Doze, background throttling), which leaves the app
+ * believing it is scanning while it is not. This re-arms the scanner in those cases.
+ */
+ private fun startScanWatchdog() {
+ scanWatchdogJob?.cancel()
+ scanWatchdogJob = connectionScope.launch {
+ while (isActive) {
+ delay(SCAN_WATCHDOG_INTERVAL_MS)
+ try {
+ // Only act when we are supposed to be scanning. Honors duty-cycle OFF windows
+ // and the client-disabled state via scanningDesired.
+ if (!isActive || !scanningDesired || !isClientRoleEnabled()) continue
+ if (!permissionManager.hasBluetoothPermissions() || bluetoothAdapter?.isEnabled != true) continue
+
+ val now = System.currentTimeMillis()
+ if (!isCurrentlyScanning) {
+ Log.w(TAG, "Watchdog: scan desired but not running -> restarting scan")
+ startScanning()
+ } else if (lastScanResultTime > 0L &&
+ now - lastScanResultTime > SCAN_STALE_RESULT_MS &&
+ now - lastScanStartTime > SCAN_STALE_RESULT_MS) {
+ // We think we're scanning but haven't seen anything for a long time. The scan
+ // may have silently died (flag wedged true). Force a clean re-arm.
+ Log.w(TAG, "Watchdog: no scan results for ${(now - lastScanResultTime) / 1000}s -> forcing scan restart")
+ forceRestartScan()
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Scan watchdog error: ${e.message}")
+ }
+ }
+ }
+ }
+
+ private fun stopScanWatchdog() {
+ scanWatchdogJob?.cancel()
+ scanWatchdogJob = null
+ }
+
+ /**
+ * Force a clean scan restart, clearing a possibly-wedged isCurrentlyScanning flag.
+ */
+ private fun forceRestartScan() {
+ stopScanning()
+ connectionScope.launch {
+ delay(500)
+ if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
+ startScanning()
+ }
+ }
+ }
/**
* Handle scan result and initiate connection if appropriate
@@ -320,6 +442,10 @@ class BluetoothGattClientManager(
return
}
+ // Proof the scanner is alive and finding our network: refresh liveness and clear backoff.
+ lastScanResultTime = System.currentTimeMillis()
+ scanRetryCount = 0
+
// Try to extract peerID from Service Data (if available) for stable identity
val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
val peerID = if (serviceData != null && serviceData.size >= 8) {
@@ -402,6 +528,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 +689,7 @@ class BluetoothGattClientManager(
*/
fun restartScanning() {
// Respect debug setting
- val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
+ val enabled = isClientRoleEnabled()
if (!isActive || !enabled) return
connectionScope.launch {
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
index ad7c9cf1..0c7aabc3 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
@@ -30,6 +30,9 @@ class BluetoothGattServerManager(
companion object {
private const val TAG = "BluetoothGattServerManager"
+ // Self-healing advertising recovery tuning
+ private const val ADVERTISE_RETRY_BASE_MS = 3_000L // base backoff for transient advertise failures
+ private const val ADVERTISE_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
}
// Core Bluetooth components
@@ -42,10 +45,24 @@ class BluetoothGattServerManager(
private var gattServer: BluetoothGattServer? = null
private var characteristic: BluetoothGattCharacteristic? = null
private var advertiseCallback: AdvertiseCallback? = null
+ private var advertiseRetryCount = 0
// 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 +79,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 +337,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()) {
@@ -374,6 +389,7 @@ class BluetoothGattServerManager(
advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
+ advertiseRetryCount = 0
val mode = try {
powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]
} catch (_: Exception) { "unknown" }
@@ -382,6 +398,28 @@ class BluetoothGattServerManager(
override fun onStartFailure(errorCode: Int) {
Log.e(TAG, "Advertising failed: $errorCode")
+ // Previously this only logged, so if advertising failed this device became
+ // undiscoverable until a manual BLE toggle. Retry transient failures with backoff.
+ when (errorCode) {
+ ADVERTISE_FAILED_ALREADY_STARTED ->
+ Log.w(TAG, "ADVERTISE_FAILED_ALREADY_STARTED - already advertising, no retry")
+ ADVERTISE_FAILED_DATA_TOO_LARGE ->
+ Log.e(TAG, "ADVERTISE_FAILED_DATA_TOO_LARGE - config issue, not retrying")
+ ADVERTISE_FAILED_FEATURE_UNSUPPORTED ->
+ Log.e(TAG, "ADVERTISE_FAILED_FEATURE_UNSUPPORTED - unsupported, not retrying")
+ ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> {
+ Log.w(TAG, "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS - will retry after backoff")
+ scheduleAdvertiseRestart("too-many-advertisers")
+ }
+ ADVERTISE_FAILED_INTERNAL_ERROR -> {
+ Log.w(TAG, "ADVERTISE_FAILED_INTERNAL_ERROR - will retry after backoff")
+ scheduleAdvertiseRestart("internal-error")
+ }
+ else -> {
+ Log.w(TAG, "Unknown advertise failure $errorCode - will retry after backoff")
+ scheduleAdvertiseRestart("unknown-$errorCode")
+ }
+ }
}
}
@@ -407,12 +445,29 @@ class BluetoothGattServerManager(
}
}
+ /**
+ * Schedule an advertising restart with incremental backoff after a transient failure.
+ */
+ private fun scheduleAdvertiseRestart(reason: String) {
+ advertiseRetryCount++
+ val delayMs = (ADVERTISE_RETRY_BASE_MS * advertiseRetryCount).coerceAtMost(ADVERTISE_MAX_RETRY_DELAY_MS)
+ Log.w(TAG, "Scheduling advertising restart in ${delayMs}ms (attempt $advertiseRetryCount, reason=$reason)")
+ connectionScope.launch {
+ delay(delayMs)
+ if (isActive && isServerRoleEnabled()) {
+ stopAdvertising()
+ delay(100)
+ startAdvertising()
+ }
+ }
+ }
+
/**
* Restart advertising (for power mode changes)
*/
fun restartAdvertising() {
// Respect debug setting
- val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
+ val enabled = isServerRoleEnabled()
if (!isActive || !enabled) {
stopAdvertising()
return
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
index 474c04a5..c6c10473 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
@@ -16,6 +16,7 @@ import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
import com.bitchat.android.services.VerificationService
+import com.bitchat.android.service.TransportBridgeService
import kotlinx.coroutines.*
import java.util.*
import kotlin.math.sign
@@ -34,7 +35,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 {
@@ -70,6 +71,7 @@ class BluetoothMeshService(private val context: Context) {
// 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
@@ -99,17 +101,11 @@ class BluetoothMeshService(private val context: Context) {
}
)
- // Wire sync manager delegate
- gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
- override fun sendPacket(packet: BitchatPacket) {
- connectionManager.broadcastPacket(RoutedPacket(packet))
- }
- override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
- connectionManager.sendPacketToPeer(peerID, packet)
- }
- override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
- return signPacketBeforeBroadcast(packet)
- }
+ com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet ->
+ signPacketBeforeBroadcast(packet)
+ }
+ if (isBleTransportEnabled()) {
+ TransportBridgeService.register("BLE", this)
}
// Inject dynamic direct connection check into PeerManager
@@ -120,6 +116,30 @@ class BluetoothMeshService(private val context: Context) {
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
}
+
+ 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 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
@@ -146,7 +166,8 @@ class BluetoothMeshService(private val context: Context) {
* 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 {
@@ -175,7 +196,7 @@ class BluetoothMeshService(private val context: Context) {
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List) {
// Update process-wide state first
- try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { }
+ try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { }
// Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs)
}
@@ -221,7 +242,7 @@ class BluetoothMeshService(private val context: Context) {
)
// Sign the handshake response
val signedPacket = signPacketBeforeBroadcast(responsePacket)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)")
}
@@ -241,7 +262,7 @@ class BluetoothMeshService(private val context: Context) {
}
override fun sendPacket(packet: BitchatPacket) {
- connectionManager.broadcastPacket(RoutedPacket(packet))
+ broadcastRoutedPacket(RoutedPacket(packet))
}
}
@@ -284,11 +305,11 @@ class BluetoothMeshService(private val context: Context) {
override fun sendPacket(packet: BitchatPacket) {
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
}
override fun relayPacket(routed: RoutedPacket) {
- connectionManager.broadcastPacket(routed)
+ broadcastRoutedPacket(routed)
}
override fun getBroadcastRecipient(): ByteArray {
@@ -335,7 +356,7 @@ class BluetoothMeshService(private val context: Context) {
// Sign the handshake packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(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")
@@ -538,11 +559,13 @@ class BluetoothMeshService(private val context: Context) {
}
override fun relayPacket(routed: RoutedPacket) {
- connectionManager.broadcastPacket(routed)
+ broadcastRoutedPacket(routed)
}
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
- return connectionManager.sendToPeer(peerID, routed)
+ val sentOverBle = connectionManager.sendToPeer(peerID, routed)
+ TransportBridgeService.sendToPeer("BLE", peerID, routed.packet)
+ return sentOverBle
}
override fun handleRequestSync(routed: RoutedPacket) {
@@ -625,6 +648,15 @@ class BluetoothMeshService(private val context: Context) {
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")
+ com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
+ try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
+ try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("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")
@@ -635,17 +667,42 @@ class BluetoothMeshService(private val context: Context) {
if (connectionManager.startServices()) {
isActive = true
+ TransportBridgeService.register("BLE", this)
// Start periodic announcements for peer discovery and connectivity
sendPeriodicBroadcastAnnounce()
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
// Start periodic syncs
- gossipSyncManager.start()
+ com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE")
Log.d(TAG, "GossipSyncManager started")
} else {
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
+ com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
+ TransportBridgeService.unregister("BLE")
+ try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
+ try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { }
+ connectionManager.disableTransport()
+ try { peerManager.refreshPeerList() } catch (_: Exception) { }
+ }
/**
* Stop all mesh services
@@ -658,6 +715,11 @@ class BluetoothMeshService(private val context: Context) {
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) { }
+ try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { }
// Send leave announcement
sendLeaveAnnouncement()
@@ -667,7 +729,7 @@ class BluetoothMeshService(private val context: Context) {
delay(200) // Give leave message time to send
// Stop all components
- gossipSyncManager.stop()
+ com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
Log.d(TAG, "GossipSyncManager stopped")
connectionManager.stopServices()
Log.d(TAG, "BluetoothConnectionManager stop requested")
@@ -717,7 +779,7 @@ class BluetoothMeshService(private val context: Context) {
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
// Track our own broadcast message for sync
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
}
@@ -749,7 +811,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))
+ broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
}
} catch (e: Exception) {
@@ -793,7 +855,7 @@ class BluetoothMeshService(private val context: Context) {
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
val packet = BitchatPacket(
- version = 1u,
+ version = if (encrypted.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
@@ -807,7 +869,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))
+ broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
} catch (e: Exception) {
@@ -887,7 +949,7 @@ class BluetoothMeshService(private val context: Context) {
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
// FIXED: Don't send didReceiveMessage for our own sent messages
@@ -958,7 +1020,7 @@ class BluetoothMeshService(private val context: Context) {
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
// Persist as read after successful send
@@ -1006,7 +1068,7 @@ class BluetoothMeshService(private val context: Context) {
)
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)")
} catch (e: Exception) {
Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}")
@@ -1070,7 +1132,7 @@ class BluetoothMeshService(private val context: Context) {
announcePacket.copy(signature = signature)
} ?: announcePacket
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)")
// Track announce for sync
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
@@ -1133,7 +1195,7 @@ class BluetoothMeshService(private val context: Context) {
packet.copy(signature = signature)
} ?: packet
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
peerManager.markPeerAsAnnouncedTo(peerID)
Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)")
@@ -1148,8 +1210,14 @@ class BluetoothMeshService(private val context: Context) {
return try {
// Prefer verified peers that are currently marked as direct
val verified = peerManager.getVerifiedPeers()
- val direct = verified.filter { it.value.isDirectConnection }.keys.toList()
- direct.take(10)
+ val direct = verified.filter { it.value.isDirectConnection }.keys.toSet()
+ // Publish this transport's direct peers and gossip the cross-transport union so a
+ // node connected via multiple transports advertises a complete neighbor list.
+ try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers("BLE", direct) } catch (_: Exception) { }
+ val union = try {
+ com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { direct }
+ } catch (_: Exception) { direct }
+ union.distinct().take(10)
} catch (_: Exception) {
emptyList()
}
@@ -1168,7 +1236,7 @@ class BluetoothMeshService(private val context: Context) {
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
- connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ broadcastRoutedPacket(RoutedPacket(signedPacket))
}
/**
@@ -1415,18 +1483,10 @@ class BluetoothMeshService(private val context: Context) {
}
/**
- * Delegate interface for mesh service callbacks (maintains exact same interface)
+ * Delegate interface for BLE mesh callbacks. Extends the shared mesh delegate so
+ * transport-agnostic facades can receive the same callback stream.
*/
-interface BluetoothMeshDelegate {
- fun didReceiveMessage(message: BitchatMessage)
- fun didUpdatePeerList(peers: List)
- fun didReceiveChannelLeave(channel: String, fromPeer: String)
- fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
- fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
- fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
- fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
- fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
- fun getNickname(): String?
- fun isFavorite(peerID: String): Boolean
- // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
+interface BluetoothMeshDelegate : MeshDelegate {
+ override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
+ override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
index 37af030d..efb3c4df 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
@@ -18,8 +18,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.Job
-import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.channels.actor
/**
@@ -117,7 +115,7 @@ class BluetoothPacketBroadcaster(
// Actor scope for the broadcaster
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- private val transferJobs = ConcurrentHashMap()
+ private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG)
// SERIALIZATION: Actor to serialize all broadcast operations
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
@@ -139,71 +137,14 @@ class BluetoothPacketBroadcaster(
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
) {
- val packet = routed.packet
- val isFile = packet.type == MessageType.FILE_TRANSFER.value
- if (isFile) {
- Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
- }
- // Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
- val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
- // Check if we need to fragment
- if (fragmentManager != null) {
- val fragments = try {
- fragmentManager.createFragments(packet)
- } catch (e: Exception) {
- Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
- if (isFile) {
- Log.e(TAG, "❌ File fragmentation failed for ${packet.payload.size} byte file")
- }
- return
- }
- if (fragments.size > 1) {
- if (isFile) {
- Log.d(TAG, "🔀 File needs ${fragments.size} fragments")
- }
- Log.d(TAG, "Fragmenting packet into ${fragments.size} fragments")
- if (transferId != null) {
- TransferProgressManager.start(transferId, fragments.size)
- }
- val job = connectionScope.launch {
- var sent = 0
- fragments.forEach { fragment ->
- if (!isActive) return@launch
- // If cancelled, stop sending remaining fragments
- if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
- broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic)
- // 20ms delay between fragments
- delay(20)
- if (transferId != null) {
- sent += 1
- TransferProgressManager.progress(transferId, sent, fragments.size)
- if (sent == fragments.size) TransferProgressManager.complete(transferId, fragments.size)
- }
- }
- }
- if (transferId != null) {
- transferJobs[transferId] = job
- job.invokeOnCompletion { transferJobs.remove(transferId) }
- }
- return
- }
- }
-
- // Send single packet if no fragmentation needed
- if (transferId != null) {
- TransferProgressManager.start(transferId, 1)
- }
- broadcastSinglePacket(routed, gattServer, characteristic)
- if (transferId != null) {
- TransferProgressManager.progress(transferId, 1, 1)
- TransferProgressManager.complete(transferId, 1)
+ fragmentingSender.send(routed, "BLE broadcast") { packet ->
+ broadcastSinglePacket(packet, gattServer, characteristic)
+ true
}
}
fun cancelTransfer(transferId: String): Boolean {
- val job = transferJobs.remove(transferId) ?: return false
- job.cancel()
- return true
+ return fragmentingSender.cancelTransfer(transferId)
}
/**
@@ -215,6 +156,18 @@ class BluetoothPacketBroadcaster(
targetPeerID: String,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
+ ): Boolean {
+ if (!hasPeerConnection(targetPeerID)) return false
+ return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
+ sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
+ }
+ }
+
+ private fun sendSinglePacketToPeer(
+ routed: RoutedPacket,
+ targetPeerID: String,
+ gattServer: BluetoothGattServer?,
+ characteristic: BluetoothGattCharacteristic?
): Boolean {
val packet = routed.packet
val data = packet.toBinaryData() ?: return false
@@ -222,11 +175,6 @@ class BluetoothPacketBroadcaster(
if (isFile) {
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
}
- // Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
- val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
- if (transferId != null) {
- TransferProgressManager.start(transferId, 1)
- }
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
val incomingAddr = routed.relayAddress
@@ -241,10 +189,6 @@ class BluetoothPacketBroadcaster(
if (serverTarget != null) {
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo)
- if (transferId != null) {
- TransferProgressManager.progress(transferId, 1, 1)
- TransferProgressManager.complete(transferId, 1)
- }
return true
}
}
@@ -255,10 +199,6 @@ class BluetoothPacketBroadcaster(
if (clientTarget != null) {
if (writeToDeviceConn(clientTarget, data)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
- if (transferId != null) {
- TransferProgressManager.progress(transferId, 1, 1)
- TransferProgressManager.complete(transferId, 1)
- }
return true
}
}
@@ -266,12 +206,6 @@ class BluetoothPacketBroadcaster(
return false
}
- private fun sha256Hex(bytes: ByteArray): String = try {
- val md = java.security.MessageDigest.getInstance("SHA-256")
- md.update(bytes)
- md.digest().joinToString("") { "%02x".format(it) }
- } catch (_: Exception) { bytes.size.toString(16) }
-
/**
* Public entry point for broadcasting - submits request to actor for serialization
@@ -303,34 +237,19 @@ class BluetoothPacketBroadcaster(
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
- val packet = routed.packet
- val data = packet.toBinaryData() ?: return false
- val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
- val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
- val incomingAddr = routed.relayAddress
- val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
- val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
-
- // Try server-side connections first
- val targetDevice = connectionTracker.getSubscribedDevices()
- .firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
- if (targetDevice != null) {
- if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
- logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetDevice.address, packet.ttl)
- return true
- }
+ if (!hasPeerConnection(targetPeerID)) return false
+ return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
+ sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
}
+ }
- // Try client-side connections next
- val targetConn = connectionTracker.getConnectedDevices().values
- .firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
- if (targetConn != null) {
- if (writeToDeviceConn(targetConn, data)) {
- logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetConn.device.address, packet.ttl)
- return true
- }
- }
- return false
+ private fun hasPeerConnection(targetPeerID: String): Boolean {
+ val hasServerTarget = connectionTracker.getSubscribedDevices()
+ .any { connectionTracker.addressPeerMap[it.address] == targetPeerID }
+ if (hasServerTarget) return true
+
+ return connectionTracker.getConnectedDevices().values
+ .any { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
}
/**
diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt
new file mode 100644
index 00000000..115254dc
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt
@@ -0,0 +1,138 @@
+package com.bitchat.android.mesh
+
+import android.util.Log
+import com.bitchat.android.model.RoutedPacket
+import com.bitchat.android.protocol.BitchatPacket
+import com.bitchat.android.protocol.MessageType
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.security.MessageDigest
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Shared transport send wrapper that applies bitchat packet fragmentation and
+ * transfer progress before a transport writes packets to its concrete medium.
+ */
+class FragmentingPacketSender(
+ private val scope: CoroutineScope,
+ private val fragmentManager: FragmentManager?,
+ private val logTag: String,
+ private val interFragmentDelayMs: Long = 20L
+) {
+ private val transferJobs = ConcurrentHashMap()
+
+ fun send(
+ routed: RoutedPacket,
+ description: String,
+ sendSingle: (RoutedPacket) -> Boolean
+ ): Boolean {
+ val transferId = transferIdFor(routed)
+ val packets = packetsForTransport(routed.packet) ?: return false
+ val total = packets.size
+
+ if (total <= 1) {
+ if (transferId != null) {
+ TransferProgressManager.start(transferId, 1)
+ }
+ val sent = sendSingle(routed.copy(packet = packets.first(), transferId = transferId))
+ if (sent && transferId != null) {
+ TransferProgressManager.progress(transferId, 1, 1)
+ TransferProgressManager.complete(transferId, 1)
+ }
+ return sent
+ }
+
+ Log.d(logTag, "Fragmenting packet type ${routed.packet.type} into $total fragments for $description")
+ if (transferId != null) {
+ TransferProgressManager.start(transferId, total)
+ }
+
+ val job = scope.launch(start = CoroutineStart.LAZY) {
+ var sent = 0
+ for (packet in packets) {
+ if (!isActive) return@launch
+ if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
+
+ val fragment = routed.copy(packet = packet, transferId = transferId)
+ val delivered = try {
+ sendSingle(fragment)
+ } catch (e: Exception) {
+ Log.e(logTag, "Fragment send failed for $description: ${e.message}", e)
+ false
+ }
+
+ if (!delivered) {
+ Log.w(logTag, "Stopping fragmented send for $description after $sent/$total fragments")
+ return@launch
+ }
+
+ sent += 1
+ if (transferId != null) {
+ TransferProgressManager.progress(transferId, sent, total)
+ }
+ if (sent < total) {
+ delay(interFragmentDelayMs)
+ }
+ }
+
+ if (transferId != null) {
+ TransferProgressManager.complete(transferId, total)
+ }
+ }
+
+ if (transferId != null) {
+ transferJobs[transferId] = job
+ job.invokeOnCompletion { transferJobs.remove(transferId, job) }
+ }
+ job.start()
+ return true
+ }
+
+ fun cancelTransfer(transferId: String): Boolean {
+ val job = transferJobs.remove(transferId) ?: return false
+ job.cancel()
+ return true
+ }
+
+ private fun packetsForTransport(packet: BitchatPacket): List? {
+ if (packet.type == MessageType.FRAGMENT.value) {
+ return listOf(packet)
+ }
+
+ val manager = fragmentManager ?: return listOf(packet)
+ return try {
+ val fragments = manager.createFragments(packet)
+ if (fragments.isEmpty()) {
+ Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}")
+ null
+ } else {
+ fragments
+ }
+ } catch (e: Exception) {
+ Log.e(logTag, "Fragment creation failed for packet type ${packet.type}: ${e.message}", e)
+ null
+ }
+ }
+
+ private fun transferIdFor(routed: RoutedPacket): String? {
+ routed.transferId?.let { return it }
+ val packet = routed.packet
+ return if (packet.type == MessageType.FILE_TRANSFER.value) {
+ sha256Hex(packet.payload)
+ } else {
+ null
+ }
+ }
+
+ private fun sha256Hex(bytes: ByteArray): String = try {
+ val md = MessageDigest.getInstance("SHA-256")
+ md.update(bytes)
+ md.digest().joinToString("") { "%02x".format(it) }
+ } catch (_: Exception) {
+ bytes.size.toString(16)
+ }
+}
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 00000000..0dd6a552
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt
@@ -0,0 +1,143 @@
+package com.bitchat.android.mesh
+
+import android.util.Log
+import kotlinx.coroutines.CancellationException
+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: CancellationException) {
+ break
+ } catch (e: Exception) {
+ Log.w(tag, "Error in periodic cleanup: ${e.message}")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
new file mode 100644
index 00000000..46518428
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
@@ -0,0 +1,843 @@
+package com.bitchat.android.mesh
+
+import android.content.Context
+import android.util.Log
+import com.bitchat.android.crypto.EncryptionService
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.model.BitchatFilePacket
+import com.bitchat.android.model.IdentityAnnouncement
+import com.bitchat.android.model.NoisePayload
+import com.bitchat.android.model.NoisePayloadType
+import com.bitchat.android.model.PrivateMessagePacket
+import com.bitchat.android.model.RequestSyncPacket
+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
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Shared mesh coordinator that wires all mesh-layer components and provides common APIs
+ * for send/receive operations across transports.
+ */
+class MeshCore(
+ private val context: Context,
+ private val scope: CoroutineScope,
+ private val transport: MeshTransport,
+ private val encryptionService: EncryptionService,
+ val myPeerID: String,
+ private val maxTtl: UByte,
+ sharedGossipManager: GossipSyncManager?,
+ gossipConfigProvider: GossipSyncManager.ConfigProvider,
+ private val hooks: Hooks = Hooks()
+) {
+ data class Hooks(
+ val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
+ val onPeerIdBindingUpdated: ((String, String, ByteArray, String?) -> Unit)? = null,
+ val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
+ val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
+ val onReadReceiptSent: ((String) -> Unit)? = null,
+ val announcementNicknameProvider: (() -> String?)? = null,
+ val leavePayloadProvider: (() -> ByteArray)? = null
+ )
+
+ private val peerManager = PeerManager()
+ val fragmentManager = FragmentManager()
+ private val securityManager = SecurityManager(encryptionService, myPeerID)
+ private val storeForwardManager = StoreForwardManager()
+ private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
+ private val packetProcessor = PacketProcessor(myPeerID)
+ private val directPeers = ConcurrentHashMap.newKeySet()
+
+ val gossipSyncManager: GossipSyncManager =
+ sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider)
+ private val ownsGossipManager: Boolean = sharedGossipManager == null
+
+ var delegate: MeshDelegate? = null
+
+ private var announceJob: Job? = null
+ private var isActive = false
+
+ init {
+ messageHandler.packetProcessor = packetProcessor
+ peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
+ setupDelegates()
+
+ if (sharedGossipManager == null) {
+ gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
+ override fun sendPacket(packet: BitchatPacket) {
+ dispatchGlobal(RoutedPacket(packet))
+ }
+
+ override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
+ transport.sendPacketToPeer(peerID, packet)
+ TransportBridgeService.sendToPeer(transport.id, peerID, packet)
+ }
+
+ override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
+ return signPacketBeforeBroadcast(packet)
+ }
+ }
+ }
+ }
+
+ fun startCore() {
+ if (isActive) return
+ isActive = true
+ startPeriodicBroadcastAnnounce()
+ if (ownsGossipManager) {
+ gossipSyncManager.start()
+ }
+ }
+
+ fun stopCore() {
+ if (!isActive) return
+ isActive = false
+ announceJob?.cancel()
+ announceJob = null
+ if (ownsGossipManager) {
+ gossipSyncManager.stop()
+ }
+ }
+
+ fun shutdown() {
+ peerManager.shutdown()
+ fragmentManager.shutdown()
+ securityManager.shutdown()
+ storeForwardManager.shutdown()
+ messageHandler.shutdown()
+ packetProcessor.shutdown()
+ }
+
+ fun processIncoming(packet: BitchatPacket, peerID: String?, relayAddress: String?) {
+ packetProcessor.processPacket(RoutedPacket(packet, peerID, relayAddress))
+ }
+
+ fun sendFromBridge(packet: RoutedPacket) {
+ transport.broadcastPacket(packet)
+ }
+
+ private fun dispatchGlobal(routed: RoutedPacket) {
+ transport.broadcastPacket(routed)
+ TransportBridgeService.broadcast(transport.id, routed)
+ }
+
+ private fun startPeriodicBroadcastAnnounce() {
+ announceJob?.cancel()
+ announceJob = scope.launch {
+ while (isActive) {
+ try {
+ delay(30_000)
+ sendBroadcastAnnounce()
+ } catch (_: Exception) { }
+ }
+ }
+ }
+
+ private fun setupDelegates() {
+ peerManager.delegate = object : PeerManagerDelegate {
+ override fun onPeerListUpdated(peerIDs: List) {
+ try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { }
+ delegate?.didUpdatePeerList(peerIDs)
+ }
+
+ override fun onPeerRemoved(peerID: String) {
+ try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
+ try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
+ try { peerManager.refreshPeerList() } catch (_: Exception) { }
+ }
+ }
+
+ securityManager.delegate = object : SecurityManagerDelegate {
+ override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
+ scope.launch {
+ delay(100)
+ sendAnnouncementToPeer(peerID)
+ delay(1000)
+ storeForwardManager.sendCachedMessages(peerID)
+ }
+ }
+
+ override fun sendHandshakeResponse(peerID: String, response: ByteArray) {
+ val responsePacket = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_HANDSHAKE.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = response,
+ ttl = maxTtl
+ )
+ dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(responsePacket)))
+ }
+
+ override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
+ }
+
+ storeForwardManager.delegate = object : StoreForwardManagerDelegate {
+ override fun isFavorite(peerID: String): Boolean {
+ return delegate?.isFavorite(peerID) ?: false
+ }
+
+ override fun isPeerOnline(peerID: String): Boolean {
+ return peerManager.isPeerActive(peerID)
+ }
+
+ override fun sendPacket(packet: BitchatPacket) {
+ dispatchGlobal(RoutedPacket(packet))
+ }
+ }
+
+ messageHandler.delegate = object : MessageHandlerDelegate {
+ override fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
+ return peerManager.addOrUpdatePeer(peerID, nickname)
+ }
+
+ override fun removePeer(peerID: String) {
+ peerManager.removePeer(peerID)
+ }
+
+ override fun updatePeerNickname(peerID: String, nickname: String) {
+ peerManager.addOrUpdatePeer(peerID, nickname)
+ }
+
+ override fun getPeerNickname(peerID: String): String? {
+ return peerManager.getPeerNickname(peerID)
+ }
+
+ override fun getNetworkSize(): Int {
+ return peerManager.getActivePeerCount()
+ }
+
+ override fun getMyNickname(): String? {
+ return delegate?.getNickname()
+ }
+
+ override fun getPeerInfo(peerID: String): PeerInfo? {
+ return peerManager.getPeerInfo(peerID)
+ }
+
+ override fun updatePeerInfo(
+ peerID: String,
+ nickname: String,
+ noisePublicKey: ByteArray,
+ signingPublicKey: ByteArray,
+ isVerified: Boolean
+ ): Boolean {
+ return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
+ }
+
+ override fun sendPacket(packet: BitchatPacket) {
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ }
+
+ override fun relayPacket(routed: RoutedPacket) {
+ dispatchGlobal(routed)
+ }
+
+ override fun getBroadcastRecipient(): ByteArray {
+ return SpecialRecipients.BROADCAST
+ }
+
+ override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
+ return securityManager.verifySignature(packet, peerID)
+ }
+
+ override fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? {
+ return securityManager.encryptForPeer(data, recipientPeerID)
+ }
+
+ override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
+ return securityManager.decryptFromPeer(encryptedData, senderPeerID)
+ }
+
+ override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
+ return encryptionService.verifyEd25519Signature(signature, data, publicKey)
+ }
+
+ override fun hasNoiseSession(peerID: String): Boolean {
+ return encryptionService.hasEstablishedSession(peerID)
+ }
+
+ override fun initiateNoiseHandshake(peerID: String) {
+ this@MeshCore.initiateNoiseHandshake(peerID)
+ }
+
+ override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? {
+ return try {
+ encryptionService.processHandshakeMessage(payload, peerID)
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ override fun updatePeerIDBinding(
+ newPeerID: String,
+ nickname: String,
+ publicKey: ByteArray,
+ previousPeerID: String?
+ ) {
+ peerManager.addOrUpdatePeer(newPeerID, nickname)
+ val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
+ previousPeerID?.let { peerManager.removePeer(it) }
+ Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}")
+ hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID)
+ }
+
+ override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
+ return delegate?.decryptChannelMessage(encryptedContent, channel)
+ }
+
+ override fun onMessageReceived(message: BitchatMessage) {
+ hooks.onMessageReceived?.invoke(message)
+ delegate?.didReceiveMessage(message)
+ }
+
+ override fun onChannelLeave(channel: String, fromPeer: String) {
+ delegate?.didReceiveChannelLeave(channel, fromPeer)
+ }
+
+ override fun onDeliveryAckReceived(messageID: String, peerID: String) {
+ delegate?.didReceiveDeliveryAck(messageID, peerID)
+ }
+
+ override fun onReadReceiptReceived(messageID: String, peerID: String) {
+ delegate?.didReceiveReadReceipt(messageID, peerID)
+ }
+
+ override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
+ }
+
+ override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
+ }
+ }
+
+ packetProcessor.delegate = object : PacketProcessorDelegate {
+ override fun validatePacketSecurity(packet: BitchatPacket, peerID: String): Boolean {
+ return securityManager.validatePacket(packet, peerID)
+ }
+
+ override fun updatePeerLastSeen(peerID: String) {
+ peerManager.updatePeerLastSeen(peerID)
+ }
+
+ override fun getPeerNickname(peerID: String): String? {
+ return peerManager.getPeerNickname(peerID)
+ }
+
+ override fun getNetworkSize(): Int {
+ return peerManager.getActivePeerCount()
+ }
+
+ override fun getBroadcastRecipient(): ByteArray {
+ return SpecialRecipients.BROADCAST
+ }
+
+ override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
+ return runBlocking { securityManager.handleNoiseHandshake(routed) }
+ }
+
+ override fun handleNoiseEncrypted(routed: RoutedPacket) {
+ scope.launch { messageHandler.handleNoiseEncrypted(routed) }
+ }
+
+ override fun handleAnnounce(routed: RoutedPacket) {
+ scope.launch {
+ val isFirst = messageHandler.handleAnnounce(routed)
+ hooks.onAnnounceProcessed?.invoke(routed, isFirst)
+ try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
+ }
+ }
+
+ override fun handleMessage(routed: RoutedPacket) {
+ scope.launch { messageHandler.handleMessage(routed) }
+ try {
+ val pkt = routed.packet
+ val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST))
+ if (isBroadcast && pkt.type == MessageType.MESSAGE.value) {
+ gossipSyncManager.onPublicPacketSeen(pkt)
+ }
+ } catch (_: Exception) { }
+ }
+
+ override fun handleLeave(routed: RoutedPacket) {
+ scope.launch { messageHandler.handleLeave(routed) }
+ }
+
+ override fun handleFragment(packet: BitchatPacket): BitchatPacket? {
+ try {
+ val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
+ if (isBroadcast && packet.type == MessageType.FRAGMENT.value) {
+ gossipSyncManager.onPublicPacketSeen(packet)
+ }
+ } catch (_: Exception) { }
+ return fragmentManager.handleFragment(packet)
+ }
+
+ override fun sendAnnouncementToPeer(peerID: String) {
+ this@MeshCore.sendAnnouncementToPeer(peerID)
+ }
+
+ override fun sendCachedMessages(peerID: String) {
+ storeForwardManager.sendCachedMessages(peerID)
+ }
+
+ override fun relayPacket(routed: RoutedPacket) {
+ dispatchGlobal(routed)
+ }
+
+ override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
+ val sent = transport.sendPacketToPeer(peerID, routed.packet)
+ TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet)
+ return sent
+ }
+
+ override fun handleRequestSync(routed: RoutedPacket) {
+ val fromPeer = routed.peerID ?: return
+ val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
+ gossipSyncManager.handleRequestSync(fromPeer, req)
+ }
+ }
+ }
+
+ fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) {
+ if (content.isEmpty()) return
+ scope.launch {
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.MESSAGE.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = SpecialRecipients.BROADCAST,
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = content.toByteArray(Charsets.UTF_8),
+ signature = null,
+ ttl = maxTtl
+ )
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
+ }
+ }
+
+ fun sendFileBroadcast(file: BitchatFilePacket) {
+ try {
+ val payload = file.encode() ?: return
+ scope.launch {
+ val packet = BitchatPacket(
+ version = 2u,
+ type = MessageType.FILE_TRANSFER.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = SpecialRecipients.BROADCAST,
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = payload,
+ signature = null,
+ ttl = maxTtl
+ )
+ val signed = signPacketBeforeBroadcast(packet)
+ val transferId = MeshPacketUtils.sha256Hex(payload)
+ dispatchGlobal(RoutedPacket(signed, transferId = transferId))
+ try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
+ }
+ } catch (e: Exception) {
+ Log.e("MeshCore", "sendFileBroadcast failed: ${e.message}", e)
+ }
+ }
+
+ fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
+ try {
+ scope.launch {
+ if (!encryptionService.hasEstablishedSession(recipientPeerID)) {
+ initiateNoiseHandshake(recipientPeerID)
+ return@launch
+ }
+ val tlv = file.encode() ?: return@launch
+ val np = NoisePayload(type = NoisePayloadType.FILE_TRANSFER, data = tlv).encode()
+ val enc = encryptionService.encrypt(np, recipientPeerID)
+ val packet = BitchatPacket(
+ version = if (enc.size > 0xFFFF) 2u else 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = enc,
+ signature = null,
+ ttl = maxTtl
+ )
+ val signed = signPacketBeforeBroadcast(packet)
+ val transferId = MeshPacketUtils.sha256Hex(tlv)
+ dispatchGlobal(RoutedPacket(signed, transferId = transferId))
+ }
+ } catch (e: Exception) {
+ Log.e("MeshCore", "sendFilePrivate failed: ${e.message}", e)
+ }
+ }
+
+ fun cancelFileTransfer(transferId: String): Boolean {
+ return transport.cancelTransfer(transferId)
+ }
+
+ fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
+ if (content.isEmpty() || recipientPeerID.isEmpty()) return
+ scope.launch {
+ val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
+
+ if (encryptionService.hasEstablishedSession(recipientPeerID)) {
+ try {
+ val privateMessage = PrivateMessagePacket(messageID = finalMessageID, content = content)
+ val tlvData = privateMessage.encode() ?: return@launch
+ val messagePayload = NoisePayload(
+ type = NoisePayloadType.PRIVATE_MESSAGE,
+ data = tlvData
+ )
+ val encrypted = encryptionService.encrypt(messagePayload.encode(), recipientPeerID)
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = encrypted,
+ signature = null,
+ ttl = maxTtl
+ )
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ } catch (e: Exception) {
+ Log.e("MeshCore", "Failed to encrypt private message: ${e.message}")
+ }
+ } else {
+ initiateNoiseHandshake(recipientPeerID)
+ }
+ }
+ }
+
+ fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
+ scope.launch {
+ if (hooks.readReceiptInterceptor?.invoke(messageID, recipientPeerID) == true) return@launch
+ try {
+ val payload = NoisePayload(
+ type = NoisePayloadType.READ_RECEIPT,
+ data = messageID.toByteArray(Charsets.UTF_8)
+ ).encode()
+ val enc = encryptionService.encrypt(payload, recipientPeerID)
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = enc,
+ signature = null,
+ ttl = maxTtl
+ )
+ dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
+ hooks.onReadReceiptSent?.invoke(messageID)
+ } catch (e: Exception) {
+ Log.e("MeshCore", "Failed to send read receipt: ${e.message}")
+ }
+ }
+ }
+
+ fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ val payload = NoisePayload(
+ type = NoisePayloadType.VERIFY_CHALLENGE,
+ data = com.bitchat.android.services.VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA)
+ )
+ sendNoisePayloadToPeer(payload, peerID)
+ }
+
+ fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ val tlv = com.bitchat.android.services.VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return
+ val payload = NoisePayload(
+ type = NoisePayloadType.VERIFY_RESPONSE,
+ data = tlv
+ )
+ sendNoisePayloadToPeer(payload, peerID)
+ }
+
+ private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) {
+ scope.launch {
+ try {
+ val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID)
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = encrypted,
+ signature = null,
+ ttl = maxTtl
+ )
+ dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
+ } catch (e: Exception) {
+ Log.e("MeshCore", "Failed to send Noise payload to $recipientPeerID: ${e.message}")
+ }
+ }
+ }
+
+ fun sendBroadcastAnnounce() {
+ scope.launch {
+ val nickname = hooks.announcementNicknameProvider?.invoke()
+ ?: delegate?.getNickname()
+ ?: myPeerID
+ val staticKey = encryptionService.getStaticPublicKey() ?: run {
+ Log.e("MeshCore", "No static public key available for announcement")
+ return@launch
+ }
+ val signingKey = encryptionService.getSigningPublicKey() ?: run {
+ Log.e("MeshCore", "No signing public key available for announcement")
+ return@launch
+ }
+ val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
+ val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
+ val announcePacket = BitchatPacket(
+ type = MessageType.ANNOUNCE.value,
+ ttl = maxTtl,
+ senderID = myPeerID,
+ payload = tlvPayload
+ )
+ val signedPacket = signPacketBeforeBroadcast(announcePacket)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
+ }
+ }
+
+ fun sendAnnouncementToPeer(peerID: String) {
+ if (peerManager.hasAnnouncedToPeer(peerID)) return
+ val nickname = hooks.announcementNicknameProvider?.invoke()
+ ?: delegate?.getNickname()
+ ?: myPeerID
+ val staticKey = encryptionService.getStaticPublicKey() ?: return
+ val signingKey = encryptionService.getSigningPublicKey() ?: return
+ val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
+ val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
+ val packet = BitchatPacket(
+ type = MessageType.ANNOUNCE.value,
+ ttl = maxTtl,
+ senderID = myPeerID,
+ payload = tlvPayload
+ )
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ peerManager.markPeerAsAnnouncedTo(peerID)
+ try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
+ }
+
+ private fun buildAnnouncementPayload(announcement: IdentityAnnouncement, nickname: String): ByteArray? {
+ var tlvPayload = announcement.encode() ?: return null
+ val directPeersForGossip = getDirectPeerIDsForGossip()
+ try {
+ if (directPeersForGossip.isNotEmpty()) {
+ tlvPayload += com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeersForGossip)
+ }
+ com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
+ .updateFromAnnouncement(myPeerID, nickname, directPeersForGossip, System.currentTimeMillis().toULong())
+ } catch (_: Exception) { }
+ return tlvPayload
+ }
+
+ private fun getDirectPeerIDsForGossip(): List {
+ return try {
+ val verifiedDirect = peerManager.getVerifiedPeers()
+ .filter { it.value.isDirectConnection }
+ .keys
+ val localDirect = (verifiedDirect + directPeers).toSet()
+ // Publish this transport's direct peers and gossip the cross-transport union so a
+ // node connected via multiple transports advertises a complete neighbor list.
+ try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers(transport.id, localDirect) } catch (_: Exception) { }
+ val union = try {
+ com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { localDirect }
+ } catch (_: Exception) { localDirect }
+ union.distinct().take(10)
+ } catch (_: Exception) {
+ directPeers.toList().take(10)
+ }
+ }
+
+ fun sendLeaveAnnouncement() {
+ val payload = hooks.leavePayloadProvider?.invoke() ?: byteArrayOf()
+ val packet = BitchatPacket(
+ type = MessageType.LEAVE.value,
+ ttl = maxTtl,
+ senderID = myPeerID,
+ payload = payload
+ )
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ }
+
+ fun getPeerNicknames(): Map = peerManager.getAllPeerNicknames()
+
+ fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI()
+
+ fun getPeerNickname(peerID: String): String? = peerManager.getPeerNickname(peerID)
+
+ fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
+ return peerManager.addOrUpdatePeer(peerID, nickname)
+ }
+
+ fun removePeer(peerID: String) {
+ peerManager.removePeer(peerID)
+ }
+
+ fun setDirectConnection(peerID: String, isDirect: Boolean) {
+ if (isDirect) {
+ directPeers.add(peerID)
+ } else {
+ directPeers.remove(peerID)
+ }
+ peerManager.refreshPeerList()
+ }
+
+ fun updatePeerRSSI(peerID: String, rssi: Int) {
+ peerManager.updatePeerRSSI(peerID, rssi)
+ }
+
+ fun getDebugInfoWithDeviceAddresses(deviceMap: Map): String {
+ return peerManager.getDebugInfoWithDeviceAddresses(deviceMap)
+ }
+
+ fun getFingerprintDebugInfo(): String {
+ return peerManager.getFingerprintDebugInfo()
+ }
+
+ fun hasEstablishedSession(peerID: String): Boolean {
+ return encryptionService.hasEstablishedSession(peerID)
+ }
+
+ fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState {
+ return encryptionService.getSessionState(peerID)
+ }
+
+ fun initiateNoiseHandshake(peerID: String) {
+ scope.launch {
+ try {
+ val handshakeData = encryptionService.initiateHandshake(peerID) ?: return@launch
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_HANDSHAKE.value,
+ senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
+ recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = handshakeData,
+ ttl = maxTtl
+ )
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ dispatchGlobal(RoutedPacket(signedPacket))
+ } catch (e: Exception) {
+ Log.e("MeshCore", "Failed to initiate Noise handshake with $peerID: ${e.message}")
+ }
+ }
+ }
+
+ fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID)
+
+ fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
+
+ fun updatePeerInfo(
+ peerID: String,
+ nickname: String,
+ noisePublicKey: ByteArray,
+ signingPublicKey: ByteArray,
+ isVerified: Boolean
+ ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
+
+ fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint()
+
+ fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
+
+ fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID)
+
+ fun getEncryptedPeers(): List = emptyList()
+
+ fun getActivePeerCount(): Int = try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
+
+ fun refreshPeerList() {
+ try { peerManager.refreshPeerList() } catch (_: Exception) { }
+ }
+
+ fun getDeviceAddressForPeer(peerID: String): String? = transport.getDeviceAddressForPeer(peerID)
+
+ fun getDeviceAddressToPeerMapping(): Map = transport.getDeviceAddressToPeerMapping()
+
+ fun getDebugStatus(
+ transportInfo: String,
+ deviceMap: Map,
+ extraLines: List = emptyList(),
+ title: String? = null
+ ): String {
+ return buildString {
+ appendLine("=== ${title ?: "${transport.id} Mesh Debug Status"} ===")
+ appendLine("My Peer ID: $myPeerID")
+ if (extraLines.isNotEmpty()) {
+ extraLines.forEach { appendLine(it) }
+ }
+ appendLine(transportInfo)
+ appendLine(peerManager.getDebugInfo(deviceMap))
+ appendLine(fragmentManager.getDebugInfo())
+ appendLine(securityManager.getDebugInfo())
+ appendLine(storeForwardManager.getDebugInfo())
+ appendLine(messageHandler.getDebugInfo())
+ appendLine(packetProcessor.getDebugInfo())
+ }
+ }
+
+ fun clearAllInternalData() {
+ fragmentManager.clearAllFragments()
+ storeForwardManager.clearAllCache()
+ securityManager.clearAllData()
+ peerManager.clearAllPeers()
+ peerManager.clearAllFingerprints()
+ }
+
+ fun clearAllEncryptionData() {
+ encryptionService.clearPersistentIdentity()
+ }
+
+ private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
+ return try {
+ 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) {
+ withRoute.copy(signature = signature)
+ } else {
+ withRoute
+ }
+ } catch (_: Exception) {
+ packet
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt
new file mode 100644
index 00000000..de662384
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt
@@ -0,0 +1,19 @@
+package com.bitchat.android.mesh
+
+import com.bitchat.android.model.BitchatMessage
+
+/**
+ * Shared mesh delegate interface for transport-agnostic callbacks.
+ */
+interface MeshDelegate {
+ fun didReceiveMessage(message: BitchatMessage)
+ fun didUpdatePeerList(peers: List)
+ fun didReceiveChannelLeave(channel: String, fromPeer: String)
+ fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
+ fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
+ fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
+ fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {}
+ fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
+ fun getNickname(): String?
+ fun isFavorite(peerID: String): Boolean
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt b/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt
new file mode 100644
index 00000000..514e7a99
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt
@@ -0,0 +1,37 @@
+package com.bitchat.android.mesh
+
+/**
+ * Shared helpers for mesh packet handling.
+ */
+object MeshPacketUtils {
+ /**
+ * Convert hex string peer ID to binary data (8 bytes), matching iOS behavior.
+ */
+ fun hexStringToByteArray(hexString: String): ByteArray {
+ val result = ByteArray(8) { 0 }
+ var tempID = hexString
+ var index = 0
+
+ while (tempID.length >= 2 && index < 8) {
+ val hexByte = tempID.substring(0, 2)
+ val byte = hexByte.toIntOrNull(16)?.toByte()
+ if (byte != null) {
+ result[index] = byte
+ }
+ tempID = tempID.substring(2)
+ index++
+ }
+ return result
+ }
+
+ /**
+ * Hash payloads to a stable hex ID for transfer tracking.
+ */
+ fun sha256Hex(bytes: ByteArray): String = try {
+ val md = java.security.MessageDigest.getInstance("SHA-256")
+ md.update(bytes)
+ md.digest().joinToString("") { "%02x".format(it) }
+ } catch (_: Exception) {
+ bytes.size.toString(16)
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt
new file mode 100644
index 00000000..c612e8ed
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt
@@ -0,0 +1,56 @@
+package com.bitchat.android.mesh
+
+import com.bitchat.android.model.BitchatFilePacket
+
+/**
+ * Transport-agnostic mesh service API for UI and routing layers.
+ */
+interface MeshService {
+ val myPeerID: String
+ var delegate: MeshDelegate?
+
+ fun startServices()
+ fun stopServices()
+
+ fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null)
+ fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null)
+ fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String)
+ fun sendDeliveryAck(messageID: String, recipientPeerID: String) {}
+ fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {}
+ fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
+ fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
+ fun sendFileBroadcast(file: BitchatFilePacket)
+ fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
+ fun cancelFileTransfer(transferId: String): Boolean
+
+ fun sendBroadcastAnnounce()
+ fun sendAnnouncementToPeer(peerID: String)
+
+ fun getPeerNicknames(): Map
+ fun getPeerRSSI(): Map
+ fun getActivePeerCount(): Int
+ fun hasEstablishedSession(peerID: String): Boolean
+ fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState
+ fun initiateNoiseHandshake(peerID: String)
+ fun getPeerFingerprint(peerID: String): String?
+ fun getPeerInfo(peerID: String): PeerInfo?
+ fun updatePeerInfo(
+ peerID: String,
+ nickname: String,
+ noisePublicKey: ByteArray,
+ signingPublicKey: ByteArray,
+ isVerified: Boolean
+ ): Boolean
+ fun getIdentityFingerprint(): String
+ fun getStaticNoisePublicKey(): ByteArray?
+ fun shouldShowEncryptionIcon(peerID: String): Boolean
+ fun getEncryptedPeers(): List
+
+ fun getDeviceAddressForPeer(peerID: String): String?
+ fun getDeviceAddressToPeerMapping(): Map
+ fun printDeviceAddressesForPeers(): String
+ fun getDebugStatus(): String
+
+ fun clearAllInternalData()
+ fun clearAllEncryptionData()
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
new file mode 100644
index 00000000..26a63848
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
@@ -0,0 +1,23 @@
+package com.bitchat.android.mesh
+
+import com.bitchat.android.model.RoutedPacket
+import com.bitchat.android.protocol.BitchatPacket
+
+/**
+ * Transport abstraction used by MeshCore to send packets via a specific medium.
+ */
+interface MeshTransport {
+ val id: String
+
+ fun broadcastPacket(routed: RoutedPacket)
+
+ fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
+
+ fun cancelTransfer(transferId: String): Boolean = false
+
+ fun getDeviceAddressForPeer(peerID: String): String? = null
+
+ fun getDeviceAddressToPeerMapping(): Map = emptyMap()
+
+ fun getTransportDebugInfo(): String = ""
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
index ecac572b..d6daa547 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
@@ -21,6 +21,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
companion object {
private const val TAG = "MessageHandler"
+ private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L
}
// Delegate for callbacks
@@ -220,12 +221,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
if (peerID == myPeerID) return false
- // Ignore stale announcements older than STALE_PEER_TIMEOUT
+ // Peers use wall-clock packet timestamps; tolerate moderate device clock skew
+ // during identity learning, or later signed messages cannot be verified.
val now = System.currentTimeMillis()
- val age = now - packet.timestamp.toLong()
- if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
- Log.w(TAG, "Ignoring stale ANNOUNCE from ${peerID.take(8)} (age=${age}ms > ${com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS}ms)")
+ val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong())
+ if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) {
+ Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)")
return false
+ } else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
+ Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)")
}
// Try to decode as iOS-compatible IdentityAnnouncement with TLV format
diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt
index 536a9b2a..01132ae7 100644
--- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt
@@ -110,10 +110,21 @@ class PeerManager {
isVerified: Boolean
): Boolean {
if (peerID == "unknown") return false
+
+ fun keysMatch(a: ByteArray?, b: ByteArray?): Boolean {
+ if (a == null && b == null) return true
+ if (a == null || b == null) return false
+ return a.contentEquals(b)
+ }
val now = System.currentTimeMillis()
val existingPeer = peers[peerID]
val isNewPeer = existingPeer == null
+ val wasVerified = existingPeer?.isVerifiedNickname == true
+ val nicknameChanged = existingPeer != null && existingPeer.nickname != nickname
+ val noiseKeyChanged = existingPeer != null && !keysMatch(existingPeer.noisePublicKey, noisePublicKey)
+ val signingKeyChanged = existingPeer != null && !keysMatch(existingPeer.signingPublicKey, signingPublicKey)
+ val connectedChanged = existingPeer != null && existingPeer.isConnected != true
// Update or create peer info
val peerInfo = PeerInfo(
@@ -133,18 +144,27 @@ class PeerManager {
// No legacy maps; peers map is the single source of truth
// Maintain announcedPeers for first-time announce semantics
+ val shouldNotify = when {
+ isNewPeer && isVerified -> true
+ wasVerified != isVerified -> true
+ nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged -> true
+ else -> false
+ }
+
if (isNewPeer && isVerified) {
announcedPeers.add(peerID)
- notifyPeerListUpdate()
Log.d(TAG, "🆕 New verified peer: $nickname ($peerID)")
- return true
} else if (isVerified) {
Log.d(TAG, "🔄 Updated verified peer: $nickname ($peerID)")
} else {
Log.d(TAG, "⚠️ Unverified peer announcement from: $nickname ($peerID)")
}
+
+ if (shouldNotify) {
+ notifyPeerListUpdate()
+ }
- return false
+ return isNewPeer && isVerified
}
/**
@@ -179,14 +199,6 @@ class PeerManager {
}
}
- /**
- * Force a peer list update notification.
- * Call this when connection state changes to refresh UI badges.
- */
- fun refreshPeerList() {
- notifyPeerListUpdate()
- }
-
// MARK: - Legacy Methods (maintained for compatibility)
/**
@@ -414,6 +426,10 @@ class PeerManager {
val peerList = getActivePeerIDs()
delegate?.onPeerListUpdated(peerList)
}
+
+ fun refreshPeerList() {
+ notifyPeerListUpdate()
+ }
/**
* Start periodic cleanup of stale peers
diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
new file mode 100644
index 00000000..46d36a8f
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
@@ -0,0 +1,386 @@
+package com.bitchat.android.mesh
+
+import android.content.Context
+import android.util.Log
+import com.bitchat.android.model.BitchatFilePacket
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.noise.NoiseSession
+import com.bitchat.android.wifiaware.WifiAwareController
+
+/**
+ * Feature-facing mesh service that hides local transport selection from the rest of the app.
+ *
+ * BLE remains the canonical origin for broadcast packets when it is enabled so existing BLE mesh
+ * behavior and bridge semantics stay intact. Addressed Noise traffic is routed over whichever
+ * local transport already has the peer/session, falling back to a connected transport handshake.
+ */
+class UnifiedMeshService(
+ private val context: Context,
+ private val bluetooth: BluetoothMeshService
+) : MeshService, BluetoothMeshDelegate {
+
+ companion object {
+ private const val TAG = "UnifiedMeshService"
+ }
+
+ override val myPeerID: String
+ get() = bluetooth.myPeerID
+
+ override var delegate: MeshDelegate? = null
+ set(value) {
+ field = value
+ refreshDelegates()
+ }
+
+ fun refreshDelegates() {
+ try { bluetooth.delegate = if (delegate != null) this else null } catch (_: Exception) { }
+ try { wifiService()?.delegate = if (delegate != null) this else null } catch (_: Exception) { }
+ }
+
+ override fun startServices() {
+ if (isBleEnabled()) {
+ try { bluetooth.startServices() } catch (e: Exception) {
+ Log.w(TAG, "Failed to start BLE transport: ${e.message}")
+ }
+ } else {
+ try { bluetooth.setBleTransportEnabled(false) } catch (_: Exception) { }
+ }
+ try { WifiAwareController.startIfPossible() } catch (e: Exception) {
+ Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}")
+ }
+ refreshDelegates()
+ }
+
+ override fun stopServices() {
+ try { bluetooth.stopServices() } catch (_: Exception) { }
+ try { WifiAwareController.stop() } catch (_: Exception) { }
+ }
+
+ override fun sendMessage(content: String, mentions: List, channel: String?) {
+ when {
+ isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel)
+ else -> wifiService()?.sendMessage(content, mentions, channel)
+ }
+ }
+
+ override fun sendPrivateMessage(
+ content: String,
+ recipientPeerID: String,
+ recipientNickname: String,
+ messageID: String?
+ ) {
+ when {
+ isBleReady(recipientPeerID) -> bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
+ isWifiReady(recipientPeerID) -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
+ isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
+ bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
+ else -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
+ }
+ }
+
+ override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
+ when {
+ isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname)
+ isWifiReady(recipientPeerID) -> wifiService()?.sendReadReceipt(messageID, recipientPeerID, readerNickname)
+ }
+ }
+
+ override fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {
+ val myNpub = try {
+ com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub
+ } catch (_: Exception) {
+ null
+ }
+ val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
+ val nickname = getPeerNicknames()[peerID] ?: peerID
+ if (hasEstablishedSession(peerID)) {
+ sendPrivateMessage(content, peerID, nickname, java.util.UUID.randomUUID().toString())
+ }
+ }
+
+ override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ when {
+ isBleReady(peerID) -> bluetooth.sendVerifyChallenge(peerID, noiseKeyHex, nonceA)
+ isWifiReady(peerID) -> wifiService()?.sendVerifyChallenge(peerID, noiseKeyHex, nonceA)
+ }
+ }
+
+ override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ when {
+ isBleReady(peerID) -> bluetooth.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
+ isWifiReady(peerID) -> wifiService()?.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
+ }
+ }
+
+ override fun sendFileBroadcast(file: BitchatFilePacket) {
+ when {
+ isBleEnabled() -> bluetooth.sendFileBroadcast(file)
+ else -> wifiService()?.sendFileBroadcast(file)
+ }
+ }
+
+ override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
+ when {
+ isBleReady(recipientPeerID) -> bluetooth.sendFilePrivate(recipientPeerID, file)
+ isWifiReady(recipientPeerID) -> wifiService()?.sendFilePrivate(recipientPeerID, file)
+ isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
+ bluetooth.sendFilePrivate(recipientPeerID, file)
+ else -> wifiService()?.sendFilePrivate(recipientPeerID, file)
+ }
+ }
+
+ override fun cancelFileTransfer(transferId: String): Boolean {
+ val bleCancelled = try { bluetooth.cancelFileTransfer(transferId) } catch (_: Exception) { false }
+ val wifiCancelled = try { wifiService()?.cancelFileTransfer(transferId) == true } catch (_: Exception) { false }
+ return bleCancelled || wifiCancelled
+ }
+
+ override fun sendBroadcastAnnounce() {
+ if (isBleEnabled()) {
+ try { bluetooth.sendBroadcastAnnounce() } catch (_: Exception) { }
+ }
+ try { wifiService()?.sendBroadcastAnnounce() } catch (_: Exception) { }
+ }
+
+ override fun sendAnnouncementToPeer(peerID: String) {
+ when {
+ isBleConnected(peerID) || (isBleEnabled() && !isWifiConnected(peerID)) -> bluetooth.sendAnnouncementToPeer(peerID)
+ else -> wifiService()?.sendAnnouncementToPeer(peerID)
+ }
+ }
+
+ override fun getPeerNicknames(): Map {
+ val merged = linkedMapOf()
+ try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { }
+ try { merged.putAll(bluetooth.getPeerNicknames()) } catch (_: Exception) { }
+ return merged
+ }
+
+ override fun getPeerRSSI(): Map {
+ val merged = linkedMapOf()
+ try { merged.putAll(wifiService()?.getPeerRSSI().orEmpty()) } catch (_: Exception) { }
+ try { merged.putAll(bluetooth.getPeerRSSI()) } catch (_: Exception) { }
+ return merged
+ }
+
+ override fun getActivePeerCount(): Int {
+ return mergedPeerIDs().filter { it != myPeerID }.distinct().size
+ }
+
+ override fun hasEstablishedSession(peerID: String): Boolean {
+ return isBleReady(peerID) || isWifiReady(peerID)
+ }
+
+ override fun getSessionState(peerID: String): NoiseSession.NoiseSessionState {
+ val bleState = try { bluetooth.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized }
+ val wifiState = try { wifiService()?.getSessionState(peerID) } catch (_: Exception) { null }
+ return when {
+ bleState is NoiseSession.NoiseSessionState.Established -> bleState
+ wifiState is NoiseSession.NoiseSessionState.Established -> wifiState
+ bleState is NoiseSession.NoiseSessionState.Handshaking -> bleState
+ wifiState is NoiseSession.NoiseSessionState.Handshaking -> wifiState
+ bleState !is NoiseSession.NoiseSessionState.Uninitialized -> bleState
+ wifiState != null -> wifiState
+ else -> bleState
+ }
+ }
+
+ override fun initiateNoiseHandshake(peerID: String) {
+ when {
+ isBleConnected(peerID) -> bluetooth.initiateNoiseHandshake(peerID)
+ isWifiConnected(peerID) -> wifiService()?.initiateNoiseHandshake(peerID)
+ isBleEnabled() -> bluetooth.initiateNoiseHandshake(peerID)
+ else -> wifiService()?.initiateNoiseHandshake(peerID)
+ }
+ }
+
+ override fun getPeerFingerprint(peerID: String): String? {
+ return try { bluetooth.getPeerFingerprint(peerID) } catch (_: Exception) { null }
+ ?: try { wifiService()?.getPeerFingerprint(peerID) } catch (_: Exception) { null }
+ }
+
+ override fun getPeerInfo(peerID: String): PeerInfo? {
+ val ble = try { bluetooth.getPeerInfo(peerID) } catch (_: Exception) { null }
+ val wifi = try { wifiService()?.getPeerInfo(peerID) } catch (_: Exception) { null }
+ return when {
+ ble?.isConnected == true && hasEstablishedSessionOnBluetooth(peerID) -> ble
+ wifi?.isConnected == true && wifiService()?.hasEstablishedSession(peerID) == true -> wifi
+ ble?.isConnected == true -> ble
+ wifi?.isConnected == true -> wifi
+ else -> ble ?: wifi
+ }
+ }
+
+ override fun updatePeerInfo(
+ peerID: String,
+ nickname: String,
+ noisePublicKey: ByteArray,
+ signingPublicKey: ByteArray,
+ isVerified: Boolean
+ ): Boolean {
+ val bleUpdated = try {
+ bluetooth.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
+ } catch (_: Exception) {
+ false
+ }
+ val wifiUpdated = try {
+ wifiService()?.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) == true
+ } catch (_: Exception) {
+ false
+ }
+ return bleUpdated || wifiUpdated
+ }
+
+ override fun getIdentityFingerprint(): String = bluetooth.getIdentityFingerprint()
+
+ override fun getStaticNoisePublicKey(): ByteArray? {
+ return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey()
+ }
+
+ override fun shouldShowEncryptionIcon(peerID: String): Boolean {
+ return hasEstablishedSession(peerID)
+ }
+
+ override fun getEncryptedPeers(): List {
+ val encrypted = linkedSetOf()
+ try { encrypted.addAll(bluetooth.getEncryptedPeers()) } catch (_: Exception) { }
+ try { encrypted.addAll(wifiService()?.getEncryptedPeers().orEmpty()) } catch (_: Exception) { }
+ mergedPeerIDs().filterTo(encrypted) { hasEstablishedSession(it) }
+ return encrypted.toList()
+ }
+
+ override fun getDeviceAddressForPeer(peerID: String): String? {
+ return try { bluetooth.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null }
+ ?: try { wifiService()?.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null }
+ }
+
+ override fun getDeviceAddressToPeerMapping(): Map {
+ val merged = linkedMapOf()
+ try { merged.putAll(wifiService()?.getDeviceAddressToPeerMapping().orEmpty()) } catch (_: Exception) { }
+ try { merged.putAll(bluetooth.getDeviceAddressToPeerMapping()) } catch (_: Exception) { }
+ return merged
+ }
+
+ override fun printDeviceAddressesForPeers(): String {
+ return buildString {
+ appendLine(bluetooth.printDeviceAddressesForPeers())
+ wifiService()?.let {
+ appendLine()
+ appendLine(it.printDeviceAddressesForPeers())
+ }
+ }
+ }
+
+ override fun getDebugStatus(): String {
+ return buildString {
+ appendLine("=== Unified Mesh Service Debug Status ===")
+ appendLine("My Peer ID: $myPeerID")
+ appendLine("Merged Peers: ${mergedPeerIDs().joinToString(", ")}")
+ appendLine()
+ appendLine(bluetooth.getDebugStatus())
+ wifiService()?.let {
+ appendLine()
+ appendLine(it.getDebugStatus())
+ }
+ }
+ }
+
+ override fun clearAllInternalData() {
+ try { bluetooth.clearAllInternalData() } catch (_: Exception) { }
+ try { wifiService()?.clearAllInternalData() } catch (_: Exception) { }
+ }
+
+ override fun clearAllEncryptionData() {
+ try { bluetooth.clearAllEncryptionData() } catch (_: Exception) { }
+ try { wifiService()?.clearAllEncryptionData() } catch (_: Exception) { }
+ }
+
+ override fun didReceiveMessage(message: BitchatMessage) {
+ delegate?.didReceiveMessage(message)
+ }
+
+ override fun didUpdatePeerList(peers: List) {
+ delegate?.didUpdatePeerList(mergedPeerIDs().ifEmpty { peers.distinct() })
+ }
+
+ override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
+ delegate?.didReceiveChannelLeave(channel, fromPeer)
+ }
+
+ override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
+ delegate?.didReceiveDeliveryAck(messageID, recipientPeerID)
+ }
+
+ override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
+ delegate?.didReceiveReadReceipt(messageID, recipientPeerID)
+ }
+
+ override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
+ }
+
+ override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
+ }
+
+ override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
+ return delegate?.decryptChannelMessage(encryptedContent, channel)
+ }
+
+ override fun getNickname(): String? = delegate?.getNickname()
+
+ override fun isFavorite(peerID: String): Boolean = delegate?.isFavorite(peerID) ?: false
+
+ private fun mergedPeerIDs(): List {
+ val ids = linkedSetOf()
+ try { ids.addAll(com.bitchat.android.services.AppStateStore.peers.value) } catch (_: Exception) { }
+ try { ids.addAll(bluetooth.getPeerNicknames().keys) } catch (_: Exception) { }
+ try { ids.addAll(wifiService()?.getPeerNicknames()?.keys.orEmpty()) } catch (_: Exception) { }
+ return ids.toList()
+ }
+
+ private fun wifiService(): MeshService? {
+ return try {
+ WifiAwareController.getService()?.also { service ->
+ if (delegate != null && service.delegate !== this) {
+ service.delegate = this
+ }
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun isBleEnabled(): 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 isBleConnected(peerID: String): Boolean {
+ return try { bluetooth.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false }
+ }
+
+ private fun isWifiConnected(peerID: String): Boolean {
+ return try { wifiService()?.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false }
+ }
+
+ private fun isBleReady(peerID: String): Boolean {
+ return isBleConnected(peerID) && hasEstablishedSessionOnBluetooth(peerID)
+ }
+
+ private fun isWifiReady(peerID: String): Boolean {
+ return try {
+ val wifi = wifiService()
+ wifi?.getPeerInfo(peerID)?.isConnected == true && wifi.hasEstablishedSession(peerID)
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ private fun hasEstablishedSessionOnBluetooth(peerID: String): Boolean {
+ return try { bluetooth.hasEstablishedSession(peerID) } catch (_: Exception) { false }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt
index 58dc810e..5ca8df28 100644
--- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt
+++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt
@@ -70,7 +70,8 @@ class NoiseEncryptionService(private val context: Context) {
private fun initializeSessionManager() {
// Create new session manager with current keys
- sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey)
+ val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16)
+ sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID)
// Set up session callbacks
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt
index 332dc78a..4eab42bb 100644
--- a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt
+++ b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt
@@ -153,6 +153,9 @@ class NoiseSession(
// Session state
private var state: NoiseSessionState = NoiseSessionState.Uninitialized
private val creationTime = System.currentTimeMillis()
+ private var handshakeStartMs: Long? = null
+ private var lastHandshakeActivityMs: Long? = null
+ private var handshakeMessage1: ByteArray? = null
// Session counters
private var currentPattern = 0;
@@ -195,6 +198,15 @@ class NoiseSession(
fun isEstablished(): Boolean = state is NoiseSessionState.Established
fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking
fun getCreationTime(): Long = creationTime
+ fun isInitiatorRole(): Boolean = isInitiator
+ fun getHandshakeStartMs(): Long? = handshakeStartMs
+ fun getLastHandshakeActivityMs(): Long? = lastHandshakeActivityMs
+
+ internal fun getHandshakeMessage1(): ByteArray? = handshakeMessage1?.clone()
+
+ internal fun setLastHandshakeActivityForTest(timestampMs: Long) {
+ lastHandshakeActivityMs = timestampMs
+ }
init {
try {
@@ -317,19 +329,25 @@ class NoiseSession(
// Initialize handshake as initiator
initializeNoiseHandshake(HandshakeState.INITIATOR)
state = NoiseSessionState.Handshaking
+ if (handshakeStartMs == null) {
+ handshakeStartMs = System.currentTimeMillis()
+ }
+ lastHandshakeActivityMs = System.currentTimeMillis()
val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE)
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0)
currentPattern++
val firstMessage = messageBuffer.copyOf(messageLength)
+ handshakeMessage1 = firstMessage
// Validate message size matches XX pattern expectations
if (firstMessage.size != XX_MESSAGE_1_SIZE) {
Log.w(TAG, "Warning: XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE")
}
- Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) currentPattern: $currentPattern")
+ val ePrefix = firstMessage.take(4).toByteArray().toHexString()
+ Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) e_prefix=$ePrefix currentPattern: $currentPattern")
return firstMessage
} catch (e: Exception) {
state = NoiseSessionState.Failed(e)
@@ -344,19 +362,24 @@ class NoiseSession(
*/
@Synchronized
fun processHandshakeMessage(message: ByteArray): ByteArray? {
- Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes)")
+ val inputPrefix = message.take(4).toByteArray().toHexString()
+ Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes) prefix=$inputPrefix")
try {
// Initialize as responder if receiving first message
if (state == NoiseSessionState.Uninitialized && !isInitiator) {
initializeNoiseHandshake(HandshakeState.RESPONDER)
state = NoiseSessionState.Handshaking
+ if (handshakeStartMs == null) {
+ handshakeStartMs = System.currentTimeMillis()
+ }
Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID")
}
if (state != NoiseSessionState.Handshaking) {
throw IllegalStateException("Invalid state for handshake: $state")
}
+ lastHandshakeActivityMs = System.currentTimeMillis()
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
@@ -366,7 +389,8 @@ class NoiseSession(
// Read the incoming message - the Noise library will handle validation
val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0)
currentPattern++
- Log.d(TAG, "Read handshake message, payload length: $payloadLength currentPattern: $currentPattern")
+ val readPrefix = message.take(4).toByteArray().toHexString()
+ Log.d(TAG, "Read handshake message, payload length: $payloadLength prefix=$readPrefix currentPattern: $currentPattern")
// Check what action the handshake state wants us to take next
val action = handshakeStateLocal.getAction()
diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
index 2d9e06d8..618f26be 100644
--- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
+++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
@@ -8,11 +8,14 @@ import java.util.concurrent.ConcurrentHashMap
*/
class NoiseSessionManager(
private val localStaticPrivateKey: ByteArray,
- private val localStaticPublicKey: ByteArray
+ private val localStaticPublicKey: ByteArray,
+ private val localPeerID: String
) {
companion object {
private const val TAG = "NoiseSessionManager"
+ private const val HANDSHAKE_TIMEOUT_MS = 20_000L
+ private const val HANDSHAKE_MESSAGE_1_SIZE = 32
}
private val sessions = ConcurrentHashMap()
@@ -51,11 +54,30 @@ class NoiseSessionManager(
/**
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
*/
- fun initiateHandshake(peerID: String): ByteArray {
+ fun initiateHandshake(peerID: String): ByteArray? {
Log.d(TAG, "initiateHandshake($peerID)")
- // Remove any existing session first
- removeSession(peerID)
+ val now = System.currentTimeMillis()
+ val existing = getSession(peerID)
+ if (existing != null) {
+ when {
+ existing.isEstablished() -> {
+ Log.d(TAG, "Handshake already established with $peerID, skipping initiate")
+ return null
+ }
+ existing.isHandshaking() -> {
+ if (!isHandshakeStale(existing, now)) {
+ Log.d(TAG, "Handshake already in progress with $peerID, not restarting")
+ return null
+ }
+ Log.d(TAG, "Handshake with $peerID is stale; restarting")
+ removeSession(peerID)
+ }
+ else -> {
+ removeSession(peerID)
+ }
+ }
+ }
// Create new session as initiator
val session = NoiseSession(
@@ -85,6 +107,23 @@ class NoiseSessionManager(
try {
var session = getSession(peerID)
+
+ // Collision handling: both sides initiated and we received message 1
+ if (session != null &&
+ session.isHandshaking() &&
+ session.isInitiatorRole() &&
+ message.size == HANDSHAKE_MESSAGE_1_SIZE
+ ) {
+ val shouldYield = localPeerID > peerID
+ if (shouldYield) {
+ Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
+ removeSession(peerID)
+ session = null
+ } else {
+ Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
+ return null
+ }
+ }
// If no session exists, create one as responder
if (session == null) {
@@ -119,6 +158,12 @@ class NoiseSessionManager(
throw e
}
}
+
+ private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean {
+ val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs()
+ if (lastActivity == null) return false
+ return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS
+ }
/**
* SIMPLIFIED: Encrypt data
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 bdfc9733..60c6e5e4 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 701e9d19..674deb7d 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt
@@ -271,6 +271,7 @@ class OnboardingCoordinator(
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
permission.contains("BACKGROUND") -> "Background Location"
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 e982d2e1..48138d66 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.material3.*
import androidx.compose.runtime.*
@@ -218,6 +219,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
PermissionType.BACKGROUND_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 c32f855b..8fcf9c31 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
@@ -23,6 +23,22 @@ 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(true)
+ } catch (_: Exception) {
+ true
+ }
+ if (!enabled) return false
+
+ return try {
+ com.bitchat.android.wifiaware.WifiAwareSupport.isSupported(context)
+ } catch (_: Exception) {
+ false
+ }
+ }
+
/**
* Check if this is the first time the user is launching the app
*/
@@ -69,6 +85,11 @@ class PermissionManager(private val context: Context) {
Manifest.permission.ACCESS_FINE_LOCATION
))
+ // Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission
+ if (shouldRequireWifiAwarePermission()) {
+ permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
+ }
+
// Notification permission intentionally excluded to keep it optional
return permissions
@@ -209,6 +230,20 @@ class PermissionManager(private val context: Context) {
)
)
+ // Wi‑Fi Aware category (Android 13+)
+ if (shouldRequireWifiAwarePermission()) {
+ 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"
+ )
+ )
+ }
+
if (needsBackgroundLocationPermission()) {
val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
categories.add(
@@ -308,6 +343,7 @@ enum class PermissionType(val nameValue: String) {
BACKGROUND_LOCATION("Background 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/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
index db17ba1c..88e5f783 100644
--- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
+++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
@@ -255,6 +255,10 @@ object BinaryProtocol {
if (packet.version >= 2u.toUByte()) {
buffer.putInt(payloadDataSize) // 4 bytes for v2+
} else {
+ if (payloadDataSize > 0xFFFF || (originalPayloadSize ?: 0) > 0xFFFF) {
+ Log.w("BinaryProtocol", "Cannot encode oversized v1 packet payload: $payloadDataSize bytes")
+ return null
+ }
buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1
}
diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
index 1c3abaf9..f4ca9f74 100644
--- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
+++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
@@ -3,7 +3,7 @@ package com.bitchat.android.service
import android.app.Application
import android.os.Process
import androidx.core.app.NotificationManagerCompat
-import com.bitchat.android.mesh.BluetoothMeshService
+import com.bitchat.android.mesh.MeshService
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import kotlinx.coroutines.CoroutineScope
@@ -39,7 +39,7 @@ object AppShutdownCoordinator {
fun requestFullShutdownAndKill(
app: Application,
- mesh: BluetoothMeshService?,
+ mesh: MeshService?,
notificationManager: NotificationManagerCompat,
stopForeground: () -> Unit,
stopService: () -> Unit
diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
index aa9e6fc3..218631ea 100644
--- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
+++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
@@ -115,6 +115,8 @@ class MeshForegroundService : Service() {
private var updateJob: Job? = null
private val meshService: BluetoothMeshService?
get() = MeshServiceHolder.meshService
+ private val unifiedMeshService: com.bitchat.android.mesh.MeshService?
+ get() = MeshServiceHolder.unifiedMeshService
private val serviceJob = Job()
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
private var isInForeground: Boolean = false
@@ -134,6 +136,7 @@ class MeshForegroundService : Service() {
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
MeshServiceHolder.attach(created)
}
+ MeshServiceHolder.getUnifiedOrCreate(applicationContext)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -147,7 +150,7 @@ class MeshForegroundService : Service() {
when (intent?.action) {
ACTION_STOP -> {
// Stop FGS and mesh cleanly
- try { meshService?.stopServices() } catch (_: Exception) { }
+ try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
try { MeshServiceHolder.clear() } catch (_: Exception) { }
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
@@ -165,7 +168,7 @@ class MeshForegroundService : Service() {
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
AppShutdownCoordinator.requestFullShutdownAndKill(
app = application,
- mesh = meshService,
+ mesh = unifiedMeshService,
notificationManager = notificationManager,
stopForeground = {
try { stopForeground(true) } catch (_: Exception) { }
@@ -178,7 +181,7 @@ class MeshForegroundService : Service() {
ACTION_UPDATE_NOTIFICATION -> {
// If we became eligible and are not in foreground yet, promote once
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
- val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
+ val n = buildNotification(getUnifiedActivePeerCount())
startForegroundCompat(n)
isInForeground = true
} else {
@@ -193,7 +196,7 @@ class MeshForegroundService : Service() {
// Promote exactly once when eligible, otherwise stay background (or stop)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
- val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
+ val notification = buildNotification(getUnifiedActivePeerCount())
startForegroundCompat(notification)
isInForeground = true
}
@@ -226,6 +229,21 @@ class MeshForegroundService : Service() {
private fun ensureMeshStarted() {
if (isShuttingDown) return
+ try {
+ com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
+ } catch (e: Exception) {
+ android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}")
+ }
+
+ 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")
@@ -241,7 +259,7 @@ class MeshForegroundService : Service() {
notificationManager.cancel(NOTIFICATION_ID)
return
}
- val count = meshService?.getActivePeerCount() ?: 0
+ val count = getUnifiedActivePeerCount()
val notification = buildNotification(count)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
notificationManager.notify(NOTIFICATION_ID, notification)
@@ -261,6 +279,14 @@ class MeshForegroundService : Service() {
return hasBluetoothPermissions() && hasNotificationPermission()
}
+ private fun getUnifiedActivePeerCount(): Int {
+ return try {
+ unifiedMeshService?.getActivePeerCount() ?: meshService?.getActivePeerCount() ?: 0
+ } catch (_: Exception) {
+ 0
+ }
+ }
+
private fun hasBluetoothPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
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 d271ab29..1ff4ec29 100644
--- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
+++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
@@ -2,6 +2,10 @@ package com.bitchat.android.service
import android.content.Context
import com.bitchat.android.mesh.BluetoothMeshService
+import com.bitchat.android.mesh.UnifiedMeshService
+import com.bitchat.android.model.RoutedPacket
+import com.bitchat.android.protocol.BitchatPacket
+import com.bitchat.android.sync.GossipSyncManager
/**
* Process-wide holder to share a single BluetoothMeshService instance
@@ -9,10 +13,69 @@ import com.bitchat.android.mesh.BluetoothMeshService
*/
object MeshServiceHolder {
private const val TAG = "MeshServiceHolder"
+ @Volatile
+ var sharedGossipSyncManager: GossipSyncManager? = null
+ private set
+
+ private val activeGossipOwners = mutableSetOf()
+
+ @Synchronized
+ fun setGossipManager(
+ mgr: GossipSyncManager,
+ signer: (BitchatPacket) -> BitchatPacket
+ ) {
+ val previous = sharedGossipSyncManager
+ if (previous !== mgr) {
+ try { previous?.stop() } catch (_: Exception) { }
+ }
+ sharedGossipSyncManager = mgr
+ mgr.delegate = TransportGossipDelegate(signer)
+ if (activeGossipOwners.isNotEmpty()) {
+ mgr.start()
+ }
+ }
+
+ @Synchronized
+ fun startSharedGossip(owner: String) {
+ val wasIdle = activeGossipOwners.isEmpty()
+ activeGossipOwners.add(owner)
+ if (wasIdle) {
+ sharedGossipSyncManager?.start()
+ }
+ }
+
+ @Synchronized
+ fun stopSharedGossip(owner: String) {
+ activeGossipOwners.remove(owner)
+ if (activeGossipOwners.isEmpty()) {
+ sharedGossipSyncManager?.stop()
+ }
+ }
+
+ private class TransportGossipDelegate(
+ private val signer: (BitchatPacket) -> BitchatPacket
+ ) : GossipSyncManager.Delegate {
+ override fun sendPacket(packet: BitchatPacket) {
+ TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
+ }
+
+ override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
+ TransportBridgeService.sendToPeerFromLocal(peerID, packet)
+ }
+
+ override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
+ return signer(packet)
+ }
+ }
+
@Volatile
var meshService: BluetoothMeshService? = null
private set
+ @Volatile
+ var unifiedMeshService: UnifiedMeshService? = null
+ private set
+
@Synchronized
fun getOrCreate(context: Context): BluetoothMeshService {
val existing = meshService
@@ -31,18 +94,35 @@ object MeshServiceHolder {
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
meshService = created
+ unifiedMeshService = null
created
}
} catch (e: Exception) {
android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
val created = BluetoothMeshService(context.applicationContext)
meshService = created
+ unifiedMeshService = null
created
}
}
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
meshService = created
+ unifiedMeshService = null
+ return created
+ }
+
+ @Synchronized
+ fun getUnifiedOrCreate(context: Context): UnifiedMeshService {
+ val bluetooth = getOrCreate(context)
+ val existing = unifiedMeshService
+ if (existing != null) {
+ existing.refreshDelegates()
+ return existing
+ }
+ val created = UnifiedMeshService(context.applicationContext, bluetooth)
+ unifiedMeshService = created
+ android.util.Log.i(TAG, "Created new UnifiedMeshService")
return created
}
@@ -50,11 +130,16 @@ object MeshServiceHolder {
fun attach(service: BluetoothMeshService) {
android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
meshService = service
+ unifiedMeshService = null
}
@Synchronized
fun clear() {
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
+ try { sharedGossipSyncManager?.stop() } catch (_: Exception) { }
+ sharedGossipSyncManager = null
+ activeGossipOwners.clear()
meshService = null
+ unifiedMeshService = null
}
}
diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
new file mode 100644
index 00000000..9422be0e
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
@@ -0,0 +1,184 @@
+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
+
+/**
+ * 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"
+ 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
+ * to receive bridged packets.
+ */
+ interface TransportLayer {
+ /**
+ * Send a packet out via this transport.
+ */
+ fun send(packet: RoutedPacket)
+
+ /**
+ * Send a packet to a specific peer via this transport (optional).
+ */
+ fun sendToPeer(peerID: String, packet: BitchatPacket) { }
+ }
+
+ private val transports = ConcurrentHashMap()
+ private val seenPackets = Collections.synchronizedMap(
+ object : LinkedHashMap(MAX_SEEN_PACKETS, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean {
+ return size > MAX_SEEN_PACKETS
+ }
+ }
+ )
+
+ /**
+ * Register a transport layer to receive bridged packets.
+ * @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
+ 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(forwarded)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to bridge packet to $id: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Send a packet to a specific peer across all other transports.
+ */
+ 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, forwardedPacket)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Send a locally originated packet to every active transport without applying relay TTL
+ * handling. This is used for neighbor-only packets such as REQUEST_SYNC whose TTL is
+ * intentionally zero on the first radio hop.
+ */
+ fun broadcastFromLocal(packet: RoutedPacket) {
+ val targets = transports.toMap()
+ if (targets.isEmpty()) return
+
+ targets.forEach { (id, layer) ->
+ try {
+ layer.send(packet)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send local packet to $id: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Send a locally originated packet directly to a peer on every active transport.
+ */
+ fun sendToPeerFromLocal(peerID: String, packet: BitchatPacket) {
+ val targets = transports.toMap()
+ if (targets.isEmpty()) return
+
+ targets.forEach { (id, layer) ->
+ try {
+ layer.sendToPeer(peerID, packet)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send local peer packet to $id: ${e.message}")
+ }
+ }
+ }
+
+ private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? {
+ if (packet.ttl == 0u.toUByte()) {
+ Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
+ return null
+ }
+
+ val key = "$kind:${logicalPacketId(packet)}"
+ val now = System.currentTimeMillis()
+ synchronized(seenPackets) {
+ pruneSeen(now)
+ val previous = seenPackets[key]
+ if (previous != null && now - previous < SEEN_PACKET_TTL_MS) {
+ Log.d(TAG, "Dropping duplicate bridged packet type ${packet.type}")
+ return null
+ }
+ seenPackets[key] = now
+ }
+
+ return packet.copy(ttl = (packet.ttl - 1u).toUByte())
+ }
+
+ private fun pruneSeen(now: Long) {
+ val iterator = seenPackets.entries.iterator()
+ while (iterator.hasNext()) {
+ val entry = iterator.next()
+ if (now - entry.value > SEEN_PACKET_TTL_MS) {
+ iterator.remove()
+ }
+ }
+ }
+
+ private fun logicalPacketId(packet: BitchatPacket): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.update(packet.type.toByte())
+ digest.update(packet.senderID)
+ packet.recipientID?.let { digest.update(it) }
+ digest.update(packet.timestamp.toString().toByteArray(Charsets.UTF_8))
+ digest.update(packet.payload)
+ packet.route?.forEach { digest.update(it) }
+ packet.signature?.let { digest.update(it) }
+ return digest.digest().toHexString()
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
index 0997beff..7a935396 100644
--- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
+++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
@@ -14,6 +14,9 @@ object AppStateStore {
// Global de-dup set by message id to avoid duplicate keys in Compose lists
private val seenMessageIds = mutableSetOf()
private val seenPublicMessageKeys = mutableSetOf()
+ private val peerIdsByTransport = mutableMapOf>()
+ // Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
+ private val directPeerIdsByTransport = mutableMapOf>()
// Connected peer IDs (mesh ephemeral IDs)
private val _peers = MutableStateFlow>(emptyList())
val peers: StateFlow> = _peers.asStateFlow()
@@ -31,7 +34,55 @@ object AppStateStore {
val channelMessages: StateFlow