Wifi aware refactor mesh core (#711)

* wifi aware wip

* wifi aware wip

* starting to work

* werk

* dms work

* wip

* fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork

* Merge branch 'upstream/main' into fix/wifi-aware-socket-binding

* Fix Wi-Fi Aware connectivity and UI integration post-merge

- Replace bindProcessToNetwork with bindSocket for VPN compatibility.
- Implement Scoped IPv6 address resolution (aware0) for mesh routing.
- Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility.
- Fix syntax errors and variable name conflicts in Debug UI.

* Enhance Wi-Fi Aware robustness and debug UI display

- Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection.
- Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI.
- Fix Map type mismatch warning in ChatViewModel bridge.
- Filter self-ID from peer cleanup tables to prevent recursive self-removal.

* Share GossipSyncManager across transports to prevent redundant message synchronization

- Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder.
- Modified WifiAwareMeshService to use the shared GossipSyncManager if available.
- Added background cleanup for peer mappings on socket disconnection.
- Fixed Kotlin type mismatch during nickname map merging.

* Restore VPN acquisition logic and improve peer cleanup

- Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active.
- Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs.
- Switch cleanup logging to debug level to reduce log noise.

* Fix wifi aware socket binding 2 (#536)

* Background persistence (#505)

* persistence step 1

* fix build

* messages in the background work, notifications not yet

* app state store

* DM icon shows up

* notification launches when app is closed!

* keep ui updated

* lifecycle fixes

* extensive logging, maybe revert later

* send nickname in announcement

* quit in notification

* setting in about sheet

* fix quit bitchat

* lifecycle fixes

* power mode based on background state

* stats for both direciotns

* fix graph persistence

* better counting

* count per device

* only compute when debug sheet is open? untested

* fix read receipts

* fix read receipts fully

* fix unread badge if messages have been read in focus

* foreground promotion fix

* fix app kill in notification

* adjust to new tor

* nice

* about sheet design

* bump version 1.6.0 (#524)

* Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025

* bump targetSdk (#526)

* Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025

* Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025

* Prevent quit notification from reappearing (#530)

* shutdown sequence

* Prevent quit notification from reappearing

* Restrict force-finish broadcast

* Cancel quit shutdown on relaunch

* fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork

* Merge branch 'upstream/main' into fix/wifi-aware-socket-binding

* Fix Wi-Fi Aware connectivity and UI integration post-merge

- Replace bindProcessToNetwork with bindSocket for VPN compatibility.
- Implement Scoped IPv6 address resolution (aware0) for mesh routing.
- Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility.
- Fix syntax errors and variable name conflicts in Debug UI.

* Enhance Wi-Fi Aware robustness and debug UI display

- Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection.
- Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI.
- Fix Map type mismatch warning in ChatViewModel bridge.
- Filter self-ID from peer cleanup tables to prevent recursive self-removal.

* Share GossipSyncManager across transports to prevent redundant message synchronization

- Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder.
- Modified WifiAwareMeshService to use the shared GossipSyncManager if available.
- Added background cleanup for peer mappings on socket disconnection.
- Fixed Kotlin type mismatch during nickname map merging.

* Restore VPN acquisition logic and improve peer cleanup

- Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active.
- Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs.
- Switch cleanup logging to debug level to reduce log noise.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: aidenvalue <>

* Fix wifi aware routing (#534)

* Background persistence (#505)

* persistence step 1

* fix build

* messages in the background work, notifications not yet

* app state store

* DM icon shows up

* notification launches when app is closed!

* keep ui updated

* lifecycle fixes

* extensive logging, maybe revert later

* send nickname in announcement

* quit in notification

* setting in about sheet

* fix quit bitchat

* lifecycle fixes

* power mode based on background state

* stats for both direciotns

* fix graph persistence

* better counting

* count per device

* only compute when debug sheet is open? untested

* fix read receipts

* fix read receipts fully

* fix unread badge if messages have been read in focus

* foreground promotion fix

* fix app kill in notification

* adjust to new tor

* nice

* about sheet design

* bump version 1.6.0 (#524)

* Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025

* bump targetSdk (#526)

* Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025

* Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025

* Prevent quit notification from reappearing (#530)

* shutdown sequence

* Prevent quit notification from reappearing

* Restrict force-finish broadcast

* Cancel quit shutdown on relaunch

* fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork

* Merge branch 'upstream/main' into fix/wifi-aware-socket-binding

* Fix Wi-Fi Aware connectivity and UI integration post-merge

- Replace bindProcessToNetwork with bindSocket for VPN compatibility.
- Implement Scoped IPv6 address resolution (aware0) for mesh routing.
- Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility.
- Fix syntax errors and variable name conflicts in Debug UI.

* Enhance Wi-Fi Aware robustness and debug UI display

- Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection.
- Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI.
- Fix Map type mismatch warning in ChatViewModel bridge.
- Filter self-ID from peer cleanup tables to prevent recursive self-removal.

* Share GossipSyncManager across transports to prevent redundant message synchronization

- Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder.
- Modified WifiAwareMeshService to use the shared GossipSyncManager if available.
- Added background cleanup for peer mappings on socket disconnection.
- Fixed Kotlin type mismatch during nickname map merging.

* Restore VPN acquisition logic and improve peer cleanup

- Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active.
- Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs.
- Switch cleanup logging to debug level to reduce log noise.

* fix wifi aware routing

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: aidenvalue <>

* unify connection tracker for ble and wifi (#537)

* unify connection tracker for ble and wifi

* cleanup nicely

* Wifi aware skip bluetooth (#538)

* unify connection tracker for ble and wifi

* bluetooth optional

* refactor mesh core

* tests

* share gossip

* refresh peer list more often

* patches: wifi aware mesh refactor core (#549)

* Refactor WifiAware memory management and lifecycle handling

* Fix Wi-Fi Aware reconnection by ensuring callback unregistration and session watchdog

* fix(wifiaware): allow WifiAwareController to restart service if session drops

* Enhance WiFi Aware logging with network request timeouts and detailed callback status

* Ensure explicit release of WiFi Aware network callbacks on failure or disconnection

---------

Co-authored-by: aidenvalue <>

* fix: wifi aware mesh private dms (#561)

* fix(wifi-aware): restore peer check and remove aggressive session restarts

* synchronized access to sockets.

* clear old peer socket `onClientConnected` if one was found.

* address second round of review

* fix: Restart publish session on termination in WifiAwareMeshService

* fix: cleanup connection tracker resources when handling peer disconnection without active socket

---------

Co-authored-by: aidenvalu3 <>

* fix(mesh): address memory leaks and background persistence issues in Wi-Fi Aware

- Move message persistence and background notifications for Wi-Fi Aware into the service layer via MeshCore hooks.
- Fix memory leak in MainActivity by properly detaching WifiAwareMeshDelegate on pause.
- Ensure BluetoothMeshService is initialized before WifiAwareMeshService to guarantee shared GossipSyncManager instance.
- Deduplicate persistence logic from MainActivity delegate.

* wifi aware working

* more robust

* fragments work

* new files

* wifi improvements

* handshake works

* improve

* wifi mesh more robust

* fix review

* check for support

* wifi aware fixes

---------

Co-authored-by: aidenvalue <>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: aidenvalu3 <erendentman@gmail.com>
Co-authored-by: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com>
Co-authored-by: CC <cc@ggg.local>
This commit is contained in:
callebtc
2026-06-16 12:11:33 -05:00
committed by GitHub
co-authored by aidenvalue <> GitHub Action aidenvalu3 a1denvalu3 CC
parent 6189f0cb84
commit 3a983c5767
99 changed files with 5982 additions and 649 deletions
@@ -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)
}
@@ -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<String, DeviceConnection>()
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
val addressPeerMap = ConcurrentHashMap<String, String>()
// Track whether we have seen the first ANNOUNCE on a given device connection
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
// Connection attempt tracking with automatic cleanup
private val pendingConnections = ConcurrentHashMap<String, ConnectionAttempt>()
// 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
}
/**
@@ -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 {
@@ -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
@@ -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<String>) {
// Update process-wide state first
try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { }
try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { }
// Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs)
}
@@ -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<String>)
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)
}
@@ -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<String, Job>()
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 }
}
/**
@@ -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<String, Job>()
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<BitchatPacket>? {
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)
}
}
@@ -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<String, ConnectionAttempt>()
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}")
}
}
}
}
}
@@ -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<String>()
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<String>) {
try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { }
delegate?.didUpdatePeerList(peerIDs)
}
override fun onPeerRemoved(peerID: String) {
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
try { peerManager.refreshPeerList() } catch (_: Exception) { }
}
}
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<String> = 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<String> {
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<String, String> = peerManager.getAllPeerNicknames()
fun getPeerRSSI(): Map<String, Int> = 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, String>): 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<String> = 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<String, String> = transport.getDeviceAddressToPeerMapping()
fun getDebugStatus(
transportInfo: String,
deviceMap: Map<String, String>,
extraLines: List<String> = 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
}
}
}
@@ -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<String>)
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
}
@@ -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)
}
}
@@ -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<String> = 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<String, String>
fun getPeerRSSI(): Map<String, Int>
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<String>
fun getDeviceAddressForPeer(peerID: String): String?
fun getDeviceAddressToPeerMapping(): Map<String, String>
fun printDeviceAddressesForPeers(): String
fun getDebugStatus(): String
fun clearAllInternalData()
fun clearAllEncryptionData()
}
@@ -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<String, String> = emptyMap()
fun getTransportDebugInfo(): String = ""
}
@@ -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
@@ -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
@@ -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<String>, 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<String, String> {
val merged = linkedMapOf<String, String>()
try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { }
try { merged.putAll(bluetooth.getPeerNicknames()) } catch (_: Exception) { }
return merged
}
override fun getPeerRSSI(): Map<String, Int> {
val merged = linkedMapOf<String, Int>()
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<String> {
val encrypted = linkedSetOf<String>()
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<String, String> {
val merged = linkedMapOf<String, String>()
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<String>) {
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<String> {
val ids = linkedSetOf<String>()
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 }
}
}