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
+9
View File
@@ -5,6 +5,7 @@
<!-- Internet permissions for Nostr relay connections -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Bluetooth permissions -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
@@ -20,6 +21,12 @@
<!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WiFi / WiFi Aware permissions -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Android 13+ runtime permission for WiFi operations (including Aware) -->
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<!-- Signature permission for internal UI shutdown broadcasts -->
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
<!-- Foreground service and boot permissions for long-running background mesh -->
@@ -52,6 +59,8 @@
<!-- Hardware features -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<!-- Device support hint for WiFi Aware (optional) -->
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<permission
@@ -41,6 +41,12 @@ class BitchatApplication : Application() {
// Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
// Initialize WiFi Aware controller with persisted default
try {
val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true)
com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled)
} catch (_: Exception) { }
// Initialize Geohash Registries for persistence
try {
com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this)
@@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.onboarding.BluetoothCheckScreen
import com.bitchat.android.onboarding.BluetoothStatus
import com.bitchat.android.onboarding.BluetoothStatusManager
@@ -40,6 +41,7 @@ import com.bitchat.android.ui.ChatScreen
import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.wifiaware.WifiAwareController
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.delay
@@ -55,12 +57,13 @@ class MainActivity : OrientationAwareActivity() {
// Core mesh service - provided by the foreground service holder
private lateinit var meshService: BluetoothMeshService
private lateinit var unifiedMeshService: MeshService
private val mainViewModel: MainViewModel by viewModels()
private val chatViewModel: ChatViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ChatViewModel(application, meshService) as T
return ChatViewModel(application, meshService, unifiedMeshService) as T
}
}
}
@@ -114,6 +117,10 @@ class MainActivity : OrientationAwareActivity() {
// Ensure foreground service is running and get mesh instance from holder
try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext)
// Expose BLE mesh to WiFi Aware controller for cross-transport relays - DEPRECATED
// Bridging is now handled by TransportBridgeService automatically
bluetoothStatusManager = BluetoothStatusManager(
activity = this,
context = this,
@@ -165,6 +172,17 @@ class MainActivity : OrientationAwareActivity() {
}
}
// Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
WifiAwareController.running.collect { running ->
if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
unifiedMeshService.delegate = chatViewModel
}
}
}
}
// Only start onboarding process if we're in the initial CHECKING state
// This prevents restarting onboarding on configuration changes
if (mainViewModel.onboardingState.value == OnboardingState.CHECKING) {
@@ -222,6 +240,10 @@ class MainActivity : OrientationAwareActivity() {
onRetry = {
checkBluetoothAndProceed()
},
onSkip = {
mainViewModel.skipBluetoothCheck()
checkLocationAndProceed()
},
isLoading = isBluetoothLoading
)
}
@@ -355,6 +377,13 @@ class MainActivity : OrientationAwareActivity() {
private fun checkBluetoothAndProceed() {
// Log.d("MainActivity", "Checking Bluetooth status")
// Check if user has skipped Bluetooth check for this session
if (mainViewModel.isBluetoothCheckSkipped.value) {
Log.d("MainActivity", "Bluetooth check skipped by user, proceeding to location check")
checkLocationAndProceed()
return
}
// For first-time users, skip Bluetooth check and go straight to permissions
// We'll check Bluetooth after permissions are granted
if (permissionManager.isFirstTimeLaunch()) {
@@ -367,6 +396,12 @@ class MainActivity : OrientationAwareActivity() {
bluetoothStatusManager.logBluetoothStatus()
mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus())
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
if (!bleRequired) {
// Skip BLE checks entirely when BLE is disabled in debug settings
checkLocationAndProceed()
return
}
when (mainViewModel.bluetoothStatus.value) {
BluetoothStatus.ENABLED -> {
// Bluetooth is enabled, check location services next
@@ -472,6 +507,8 @@ class MainActivity : OrientationAwareActivity() {
Log.d("MainActivity", "Location services enabled by user")
mainViewModel.updateLocationLoading(false)
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
// Ensure Wi-Fi Aware starts now that location is enabled
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
checkBatteryOptimizationAndProceed()
}
@@ -540,8 +577,9 @@ class MainActivity : OrientationAwareActivity() {
else -> BatteryOptimizationStatus.ENABLED
}
val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
when {
currentBluetoothStatus != BluetoothStatus.ENABLED -> {
bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> {
// Bluetooth still disabled, but now we have permissions to enable it
Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
@@ -674,9 +712,9 @@ class MainActivity : OrientationAwareActivity() {
return@launch
}
// Set up mesh service delegate and start services
meshService.delegate = chatViewModel
meshService.startServices()
// Set up unified mesh delegate and start enabled transports
unifiedMeshService.delegate = chatViewModel
unifiedMeshService.startServices()
Log.d("MainActivity", "Mesh service started successfully")
@@ -720,11 +758,12 @@ class MainActivity : OrientationAwareActivity() {
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
try { meshService.delegate = chatViewModel } catch (_: Exception) { }
try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { }
// Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
if (currentBluetoothStatus != BluetoothStatus.ENABLED) {
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) {
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
@@ -739,6 +778,9 @@ class MainActivity : OrientationAwareActivity() {
mainViewModel.updateLocationStatus(currentLocationStatus)
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false)
} else {
// If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
}
}
}
@@ -748,7 +790,7 @@ class MainActivity : OrientationAwareActivity() {
// Only set background state if app is fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
try { meshService.delegate = null } catch (_: Exception) { }
try { unifiedMeshService.delegate = null } catch (_: Exception) { }
}
}
@@ -35,6 +35,9 @@ class MainViewModel : ViewModel() {
private val _isBatteryOptimizationLoading = MutableStateFlow(false)
val isBatteryOptimizationLoading: StateFlow<Boolean> = _isBatteryOptimizationLoading.asStateFlow()
private val _isBluetoothCheckSkipped = MutableStateFlow(false)
val isBluetoothCheckSkipped: StateFlow<Boolean> = _isBluetoothCheckSkipped.asStateFlow()
// Public update functions for MainActivity
fun updateOnboardingState(state: OnboardingState) {
_onboardingState.value = state
@@ -67,4 +70,8 @@ class MainViewModel : ViewModel() {
fun updateBatteryOptimizationLoading(loading: Boolean) {
_isBatteryOptimizationLoading.value = loading
}
fun skipBluetoothCheck() {
_isBluetoothCheckSkipped.value = true
}
}
@@ -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()
}
}
@@ -164,6 +191,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")
return false
@@ -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()) {
@@ -235,6 +267,19 @@ class BluetoothConnectionManager(
}
}
/**
* 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,45 +45,38 @@ 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
*/
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")
fun noteAnnounceReceived(deviceAddress: String) {
firstAnnounceSeen[deviceAddress] = true
}
// 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}")
}
}
}
/**
* 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
@@ -40,10 +45,27 @@ class BluetoothGattClientManager(
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,9 +83,17 @@ 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")
if (!isClientRoleEnabled()) {
Log.i(TAG, "Client start skipped: BLE/GATT Client disabled in debug settings")
return false
}
} catch (_: Exception) { }
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,23 +286,40 @@ 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")
}
}
}
@@ -305,6 +357,76 @@ class BluetoothGattClientManager(
}
}
/**
* 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")
if (!isServerRoleEnabled()) {
Log.i(TAG, "Server start skipped: BLE/GATT Server disabled in debug settings")
return false
}
} catch (_: Exception) { }
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
@@ -121,6 +117,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,18 +667,43 @@ 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
@@ -111,9 +111,20 @@ class PeerManager {
): 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)")
}
return false
if (shouldNotify) {
notifyPeerListUpdate()
}
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)
/**
@@ -415,6 +427,10 @@ class PeerManager {
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 }
}
}
@@ -70,7 +70,8 @@ class NoiseEncryptionService(private val context: Context) {
private fun initializeSessionManager() {
// Create new session manager with current keys
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey)
val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16)
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID)
// Set up session callbacks
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
@@ -153,6 +153,9 @@ class NoiseSession(
// Session state
private var state: NoiseSessionState = NoiseSessionState.Uninitialized
private val creationTime = System.currentTimeMillis()
private var handshakeStartMs: Long? = null
private var lastHandshakeActivityMs: Long? = null
private var handshakeMessage1: ByteArray? = null
// Session counters
private var currentPattern = 0;
@@ -195,6 +198,15 @@ class NoiseSession(
fun isEstablished(): Boolean = state is NoiseSessionState.Established
fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking
fun getCreationTime(): Long = creationTime
fun isInitiatorRole(): Boolean = isInitiator
fun getHandshakeStartMs(): Long? = handshakeStartMs
fun getLastHandshakeActivityMs(): Long? = lastHandshakeActivityMs
internal fun getHandshakeMessage1(): ByteArray? = handshakeMessage1?.clone()
internal fun setLastHandshakeActivityForTest(timestampMs: Long) {
lastHandshakeActivityMs = timestampMs
}
init {
try {
@@ -317,19 +329,25 @@ class NoiseSession(
// Initialize handshake as initiator
initializeNoiseHandshake(HandshakeState.INITIATOR)
state = NoiseSessionState.Handshaking
if (handshakeStartMs == null) {
handshakeStartMs = System.currentTimeMillis()
}
lastHandshakeActivityMs = System.currentTimeMillis()
val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE)
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0)
currentPattern++
val firstMessage = messageBuffer.copyOf(messageLength)
handshakeMessage1 = firstMessage
// Validate message size matches XX pattern expectations
if (firstMessage.size != XX_MESSAGE_1_SIZE) {
Log.w(TAG, "Warning: XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE")
}
Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) currentPattern: $currentPattern")
val ePrefix = firstMessage.take(4).toByteArray().toHexString()
Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) e_prefix=$ePrefix currentPattern: $currentPattern")
return firstMessage
} catch (e: Exception) {
state = NoiseSessionState.Failed(e)
@@ -344,19 +362,24 @@ class NoiseSession(
*/
@Synchronized
fun processHandshakeMessage(message: ByteArray): ByteArray? {
Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes)")
val inputPrefix = message.take(4).toByteArray().toHexString()
Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes) prefix=$inputPrefix")
try {
// Initialize as responder if receiving first message
if (state == NoiseSessionState.Uninitialized && !isInitiator) {
initializeNoiseHandshake(HandshakeState.RESPONDER)
state = NoiseSessionState.Handshaking
if (handshakeStartMs == null) {
handshakeStartMs = System.currentTimeMillis()
}
Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID")
}
if (state != NoiseSessionState.Handshaking) {
throw IllegalStateException("Invalid state for handshake: $state")
}
lastHandshakeActivityMs = System.currentTimeMillis()
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
@@ -366,7 +389,8 @@ class NoiseSession(
// Read the incoming message - the Noise library will handle validation
val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0)
currentPattern++
Log.d(TAG, "Read handshake message, payload length: $payloadLength currentPattern: $currentPattern")
val readPrefix = message.take(4).toByteArray().toHexString()
Log.d(TAG, "Read handshake message, payload length: $payloadLength prefix=$readPrefix currentPattern: $currentPattern")
// Check what action the handshake state wants us to take next
val action = handshakeStateLocal.getAction()
@@ -8,11 +8,14 @@ import java.util.concurrent.ConcurrentHashMap
*/
class NoiseSessionManager(
private val localStaticPrivateKey: ByteArray,
private val localStaticPublicKey: ByteArray
private val localStaticPublicKey: ByteArray,
private val localPeerID: String
) {
companion object {
private const val TAG = "NoiseSessionManager"
private const val HANDSHAKE_TIMEOUT_MS = 20_000L
private const val HANDSHAKE_MESSAGE_1_SIZE = 32
}
private val sessions = ConcurrentHashMap<String, NoiseSession>()
@@ -51,11 +54,30 @@ class NoiseSessionManager(
/**
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
*/
fun initiateHandshake(peerID: String): ByteArray {
fun initiateHandshake(peerID: String): ByteArray? {
Log.d(TAG, "initiateHandshake($peerID)")
// Remove any existing session first
val now = System.currentTimeMillis()
val existing = getSession(peerID)
if (existing != null) {
when {
existing.isEstablished() -> {
Log.d(TAG, "Handshake already established with $peerID, skipping initiate")
return null
}
existing.isHandshaking() -> {
if (!isHandshakeStale(existing, now)) {
Log.d(TAG, "Handshake already in progress with $peerID, not restarting")
return null
}
Log.d(TAG, "Handshake with $peerID is stale; restarting")
removeSession(peerID)
}
else -> {
removeSession(peerID)
}
}
}
// Create new session as initiator
val session = NoiseSession(
@@ -86,6 +108,23 @@ class NoiseSessionManager(
try {
var session = getSession(peerID)
// Collision handling: both sides initiated and we received message 1
if (session != null &&
session.isHandshaking() &&
session.isInitiatorRole() &&
message.size == HANDSHAKE_MESSAGE_1_SIZE
) {
val shouldYield = localPeerID > peerID
if (shouldYield) {
Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
removeSession(peerID)
session = null
} else {
Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
return null
}
}
// If no session exists, create one as responder
if (session == null) {
Log.d(TAG, "Creating new RESPONDER session for $peerID")
@@ -120,6 +159,12 @@ class NoiseSessionManager(
}
}
private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean {
val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs()
if (lastActivity == null) return false
return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS
}
/**
* SIMPLIFIED: Encrypt data
*/
@@ -26,6 +26,7 @@ fun BluetoothCheckScreen(
status: BluetoothStatus,
onEnableBluetooth: () -> Unit,
onRetry: () -> Unit,
onSkip: () -> Unit,
isLoading: Boolean = false
) {
val colorScheme = MaterialTheme.colorScheme
@@ -39,13 +40,15 @@ fun BluetoothCheckScreen(
BluetoothDisabledContent(
onEnableBluetooth = onEnableBluetooth,
onRetry = onRetry,
onSkip = onSkip,
colorScheme = colorScheme,
isLoading = isLoading
)
}
BluetoothStatus.NOT_SUPPORTED -> {
BluetoothNotSupportedContent(
colorScheme = colorScheme
colorScheme = colorScheme,
onSkip = onSkip
)
}
BluetoothStatus.ENABLED -> {
@@ -61,6 +64,7 @@ fun BluetoothCheckScreen(
private fun BluetoothDisabledContent(
onEnableBluetooth: () -> Unit,
onRetry: () -> Unit,
onSkip: () -> Unit,
colorScheme: ColorScheme,
isLoading: Boolean
) {
@@ -77,7 +81,7 @@ private fun BluetoothDisabledContent(
)
Text(
text = stringResource(R.string.bluetooth_required),
text = stringResource(R.string.bluetooth_recommended),
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -141,20 +145,17 @@ private fun BluetoothDisabledContent(
)
}
//Since we are automatically checking bluetooth state -- commented
// OutlinedButton(
// onClick = onRetry,
// modifier = Modifier.fillMaxWidth()
// ) {
// Text(
// text = "Check Again",
// style = MaterialTheme.typography.bodyMedium.copy(
// fontFamily = FontFamily.Monospace
// ),
// modifier = Modifier.padding(vertical = 4.dp)
// )
// }
TextButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.skip),
style = MaterialTheme.typography.labelLarge.copy(
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
)
}
}
}
}
@@ -162,7 +163,8 @@ private fun BluetoothDisabledContent(
@Composable
private fun BluetoothNotSupportedContent(
colorScheme: ColorScheme
colorScheme: ColorScheme,
onSkip: () -> Unit
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
@@ -209,6 +211,16 @@ private fun BluetoothNotSupportedContent(
textAlign = TextAlign.Center
)
}
Button(
onClick = onSkip,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = colorScheme.secondary
)
) {
Text(text = stringResource(R.string.continue_btn))
}
}
}
@@ -271,6 +271,7 @@ class OnboardingCoordinator(
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
permission.contains("BACKGROUND") -> "Background Location"
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
permission.contains("NEARBY_WIFI") -> "Nearby WiFi Devices (for WiFi Aware)"
permission.contains("NOTIFICATION") -> "Notifications"
else -> permission.substringAfterLast(".")
}
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -218,6 +219,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
PermissionType.MICROPHONE -> Icons.Filled.Mic
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
PermissionType.WIFI_AWARE -> Icons.Filled.Wifi
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
PermissionType.OTHER -> Icons.Filled.Settings
}
@@ -23,6 +23,22 @@ class PermissionManager(private val context: Context) {
private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private fun shouldRequireWifiAwarePermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
val enabled = try {
com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true)
} catch (_: Exception) {
true
}
if (!enabled) return false
return try {
com.bitchat.android.wifiaware.WifiAwareSupport.isSupported(context)
} catch (_: Exception) {
false
}
}
/**
* Check if this is the first time the user is launching the app
*/
@@ -69,6 +85,11 @@ class PermissionManager(private val context: Context) {
Manifest.permission.ACCESS_FINE_LOCATION
))
// WiFi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission
if (shouldRequireWifiAwarePermission()) {
permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
}
// Notification permission intentionally excluded to keep it optional
return permissions
@@ -209,6 +230,20 @@ class PermissionManager(private val context: Context) {
)
)
// WiFi Aware category (Android 13+)
if (shouldRequireWifiAwarePermission()) {
val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES)
categories.add(
PermissionCategory(
type = PermissionType.WIFI_AWARE,
description = "Enable WiFi Aware to discover and connect to nearby bitchat users over WiFi.",
permissions = wifiAwarePermissions,
isGranted = wifiAwarePermissions.all { isPermissionGranted(it) },
systemDescription = "Allow bitchat to discover nearby WiFi devices"
)
)
}
if (needsBackgroundLocationPermission()) {
val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
categories.add(
@@ -308,6 +343,7 @@ enum class PermissionType(val nameValue: String) {
BACKGROUND_LOCATION("Background Location"),
MICROPHONE("Microphone"),
NOTIFICATIONS("Notifications"),
WIFI_AWARE("WiFi Aware"),
BATTERY_OPTIMIZATION("Battery Optimization"),
OTHER("Other")
}
@@ -255,6 +255,10 @@ object BinaryProtocol {
if (packet.version >= 2u.toUByte()) {
buffer.putInt(payloadDataSize) // 4 bytes for v2+
} else {
if (payloadDataSize > 0xFFFF || (originalPayloadSize ?: 0) > 0xFFFF) {
Log.w("BinaryProtocol", "Cannot encode oversized v1 packet payload: $payloadDataSize bytes")
return null
}
buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1
}
@@ -3,7 +3,7 @@ package com.bitchat.android.service
import android.app.Application
import android.os.Process
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import kotlinx.coroutines.CoroutineScope
@@ -39,7 +39,7 @@ object AppShutdownCoordinator {
fun requestFullShutdownAndKill(
app: Application,
mesh: BluetoothMeshService?,
mesh: MeshService?,
notificationManager: NotificationManagerCompat,
stopForeground: () -> Unit,
stopService: () -> Unit
@@ -115,6 +115,8 @@ class MeshForegroundService : Service() {
private var updateJob: Job? = null
private val meshService: BluetoothMeshService?
get() = MeshServiceHolder.meshService
private val unifiedMeshService: com.bitchat.android.mesh.MeshService?
get() = MeshServiceHolder.unifiedMeshService
private val serviceJob = Job()
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
private var isInForeground: Boolean = false
@@ -134,6 +136,7 @@ class MeshForegroundService : Service() {
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
MeshServiceHolder.attach(created)
}
MeshServiceHolder.getUnifiedOrCreate(applicationContext)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -147,7 +150,7 @@ class MeshForegroundService : Service() {
when (intent?.action) {
ACTION_STOP -> {
// Stop FGS and mesh cleanly
try { meshService?.stopServices() } catch (_: Exception) { }
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
try { MeshServiceHolder.clear() } catch (_: Exception) { }
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
@@ -165,7 +168,7 @@ class MeshForegroundService : Service() {
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
AppShutdownCoordinator.requestFullShutdownAndKill(
app = application,
mesh = meshService,
mesh = unifiedMeshService,
notificationManager = notificationManager,
stopForeground = {
try { stopForeground(true) } catch (_: Exception) { }
@@ -178,7 +181,7 @@ class MeshForegroundService : Service() {
ACTION_UPDATE_NOTIFICATION -> {
// If we became eligible and are not in foreground yet, promote once
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
val n = buildNotification(getUnifiedActivePeerCount())
startForegroundCompat(n)
isInForeground = true
} else {
@@ -193,7 +196,7 @@ class MeshForegroundService : Service() {
// Promote exactly once when eligible, otherwise stay background (or stop)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
val notification = buildNotification(getUnifiedActivePeerCount())
startForegroundCompat(notification)
isInForeground = true
}
@@ -226,6 +229,21 @@ class MeshForegroundService : Service() {
private fun ensureMeshStarted() {
if (isShuttingDown) return
try {
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
} catch (e: Exception) {
android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}")
}
val bleEnabled = try {
com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true)
} catch (_: Exception) {
true
}
if (!bleEnabled) {
try { meshService?.setBleTransportEnabled(false) } catch (_: Exception) { }
return
}
if (!hasBluetoothPermissions()) return
try {
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
@@ -241,7 +259,7 @@ class MeshForegroundService : Service() {
notificationManager.cancel(NOTIFICATION_ID)
return
}
val count = meshService?.getActivePeerCount() ?: 0
val count = getUnifiedActivePeerCount()
val notification = buildNotification(count)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
notificationManager.notify(NOTIFICATION_ID, notification)
@@ -261,6 +279,14 @@ class MeshForegroundService : Service() {
return hasBluetoothPermissions() && hasNotificationPermission()
}
private fun getUnifiedActivePeerCount(): Int {
return try {
unifiedMeshService?.getActivePeerCount() ?: meshService?.getActivePeerCount() ?: 0
} catch (_: Exception) {
0
}
}
private fun hasBluetoothPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
@@ -2,6 +2,10 @@ package com.bitchat.android.service
import android.content.Context
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.UnifiedMeshService
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.sync.GossipSyncManager
/**
* Process-wide holder to share a single BluetoothMeshService instance
@@ -9,10 +13,69 @@ import com.bitchat.android.mesh.BluetoothMeshService
*/
object MeshServiceHolder {
private const val TAG = "MeshServiceHolder"
@Volatile
var sharedGossipSyncManager: GossipSyncManager? = null
private set
private val activeGossipOwners = mutableSetOf<String>()
@Synchronized
fun setGossipManager(
mgr: GossipSyncManager,
signer: (BitchatPacket) -> BitchatPacket
) {
val previous = sharedGossipSyncManager
if (previous !== mgr) {
try { previous?.stop() } catch (_: Exception) { }
}
sharedGossipSyncManager = mgr
mgr.delegate = TransportGossipDelegate(signer)
if (activeGossipOwners.isNotEmpty()) {
mgr.start()
}
}
@Synchronized
fun startSharedGossip(owner: String) {
val wasIdle = activeGossipOwners.isEmpty()
activeGossipOwners.add(owner)
if (wasIdle) {
sharedGossipSyncManager?.start()
}
}
@Synchronized
fun stopSharedGossip(owner: String) {
activeGossipOwners.remove(owner)
if (activeGossipOwners.isEmpty()) {
sharedGossipSyncManager?.stop()
}
}
private class TransportGossipDelegate(
private val signer: (BitchatPacket) -> BitchatPacket
) : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) {
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
}
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
TransportBridgeService.sendToPeerFromLocal(peerID, packet)
}
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
return signer(packet)
}
}
@Volatile
var meshService: BluetoothMeshService? = null
private set
@Volatile
var unifiedMeshService: UnifiedMeshService? = null
private set
@Synchronized
fun getOrCreate(context: Context): BluetoothMeshService {
val existing = meshService
@@ -31,18 +94,35 @@ object MeshServiceHolder {
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
meshService = created
unifiedMeshService = null
created
}
} catch (e: Exception) {
android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
val created = BluetoothMeshService(context.applicationContext)
meshService = created
unifiedMeshService = null
created
}
}
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
meshService = created
unifiedMeshService = null
return created
}
@Synchronized
fun getUnifiedOrCreate(context: Context): UnifiedMeshService {
val bluetooth = getOrCreate(context)
val existing = unifiedMeshService
if (existing != null) {
existing.refreshDelegates()
return existing
}
val created = UnifiedMeshService(context.applicationContext, bluetooth)
unifiedMeshService = created
android.util.Log.i(TAG, "Created new UnifiedMeshService")
return created
}
@@ -50,11 +130,16 @@ object MeshServiceHolder {
fun attach(service: BluetoothMeshService) {
android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
meshService = service
unifiedMeshService = null
}
@Synchronized
fun clear() {
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
try { sharedGossipSyncManager?.stop() } catch (_: Exception) { }
sharedGossipSyncManager = null
activeGossipOwners.clear()
meshService = null
unifiedMeshService = null
}
}
@@ -0,0 +1,184 @@
package com.bitchat.android.service
import android.util.Log
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.toHexString
import java.security.MessageDigest
import java.util.Collections
import java.util.LinkedHashMap
import java.util.concurrent.ConcurrentHashMap
/**
* Central bridge for routing packets between different transport layers
* (e.g., Bluetooth LE <-> Wi-Fi Aware).
*
* Allows a packet received on one transport to be seamlessly relayed
* to all other active transports, effectively bridging separate meshes.
*/
object TransportBridgeService {
private const val TAG = "TransportBridgeService"
private const val MAX_SEEN_PACKETS = 4096
private const val SEEN_PACKET_TTL_MS = 5 * 60 * 1000L
/**
* Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement
* to receive bridged packets.
*/
interface TransportLayer {
/**
* Send a packet out via this transport.
*/
fun send(packet: RoutedPacket)
/**
* Send a packet to a specific peer via this transport (optional).
*/
fun sendToPeer(peerID: String, packet: BitchatPacket) { }
}
private val transports = ConcurrentHashMap<String, TransportLayer>()
private val seenPackets = Collections.synchronizedMap(
object : LinkedHashMap<String, Long>(MAX_SEEN_PACKETS, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Long>?): Boolean {
return size > MAX_SEEN_PACKETS
}
}
)
/**
* Register a transport layer to receive bridged packets.
* @param id Unique identifier (e.g., "BLE", "WIFI")
* @param layer The transport implementation
*/
fun register(id: String, layer: TransportLayer) {
Log.i(TAG, "Registering transport layer: $id")
transports[id] = layer
}
/**
* Unregister a transport layer.
*/
fun unregister(id: String) {
Log.i(TAG, "Unregistering transport layer: $id")
transports.remove(id)
}
/**
* Broadcast a packet from a specific source transport to ALL other registered transports.
*
* @param sourceId The ID of the transport initiating the broadcast (e.g., "BLE").
* The packet will NOT be sent back to this source.
* @param packet The packet to bridge.
*/
fun broadcast(sourceId: String, packet: RoutedPacket) {
val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return
val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return
val forwarded = packet.copy(packet = forwardedPacket)
// Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}")
targets.forEach { (id, layer) ->
try {
layer.send(forwarded)
} catch (e: Exception) {
Log.e(TAG, "Failed to bridge packet to $id: ${e.message}")
}
}
}
/**
* Send a packet to a specific peer across all other transports.
*/
fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) {
val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return
val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return
targets.forEach { (id, layer) ->
try {
layer.sendToPeer(peerID, forwardedPacket)
} catch (e: Exception) {
Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}")
}
}
}
/**
* Send a locally originated packet to every active transport without applying relay TTL
* handling. This is used for neighbor-only packets such as REQUEST_SYNC whose TTL is
* intentionally zero on the first radio hop.
*/
fun broadcastFromLocal(packet: RoutedPacket) {
val targets = transports.toMap()
if (targets.isEmpty()) return
targets.forEach { (id, layer) ->
try {
layer.send(packet)
} catch (e: Exception) {
Log.e(TAG, "Failed to send local packet to $id: ${e.message}")
}
}
}
/**
* Send a locally originated packet directly to a peer on every active transport.
*/
fun sendToPeerFromLocal(peerID: String, packet: BitchatPacket) {
val targets = transports.toMap()
if (targets.isEmpty()) return
targets.forEach { (id, layer) ->
try {
layer.sendToPeer(peerID, packet)
} catch (e: Exception) {
Log.e(TAG, "Failed to send local peer packet to $id: ${e.message}")
}
}
}
private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? {
if (packet.ttl == 0u.toUByte()) {
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
return null
}
val key = "$kind:${logicalPacketId(packet)}"
val now = System.currentTimeMillis()
synchronized(seenPackets) {
pruneSeen(now)
val previous = seenPackets[key]
if (previous != null && now - previous < SEEN_PACKET_TTL_MS) {
Log.d(TAG, "Dropping duplicate bridged packet type ${packet.type}")
return null
}
seenPackets[key] = now
}
return packet.copy(ttl = (packet.ttl - 1u).toUByte())
}
private fun pruneSeen(now: Long) {
val iterator = seenPackets.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (now - entry.value > SEEN_PACKET_TTL_MS) {
iterator.remove()
}
}
}
private fun logicalPacketId(packet: BitchatPacket): String {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(packet.type.toByte())
digest.update(packet.senderID)
packet.recipientID?.let { digest.update(it) }
digest.update(packet.timestamp.toString().toByteArray(Charsets.UTF_8))
digest.update(packet.payload)
packet.route?.forEach { digest.update(it) }
packet.signature?.let { digest.update(it) }
return digest.digest().toHexString()
}
}
@@ -14,6 +14,9 @@ object AppStateStore {
// Global de-dup set by message id to avoid duplicate keys in Compose lists
private val seenMessageIds = mutableSetOf<String>()
private val seenPublicMessageKeys = mutableSetOf<String>()
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
// Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
private val directPeerIdsByTransport = mutableMapOf<String, Set<String>>()
// Connected peer IDs (mesh ephemeral IDs)
private val _peers = MutableStateFlow<List<String>>(emptyList())
val peers: StateFlow<List<String>> = _peers.asStateFlow()
@@ -31,7 +34,55 @@ object AppStateStore {
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
fun setPeers(ids: List<String>) {
_peers.value = ids
synchronized(this) {
_peers.value = ids.distinct()
}
}
fun setTransportPeers(transportId: String, ids: List<String>) {
synchronized(this) {
peerIdsByTransport[transportId] = ids.toSet()
publishTransportPeersLocked()
}
}
fun clearTransportPeers(transportId: String) {
synchronized(this) {
peerIdsByTransport.remove(transportId)
publishTransportPeersLocked()
}
}
private fun publishTransportPeersLocked() {
_peers.value = peerIdsByTransport.values
.asSequence()
.flatten()
.distinct()
.toList()
}
/**
* Record the set of direct (single-hop) peers reachable over a given transport. Each transport
* (BLE, Wi-Fi Aware, ...) only knows its own direct peers; [getDirectPeers] unions them so every
* transport can gossip the same complete neighbor list under our shared node identity.
*/
fun setTransportDirectPeers(transportId: String, ids: Collection<String>) {
synchronized(this) {
directPeerIdsByTransport[transportId] = ids.toSet()
}
}
fun clearTransportDirectPeers(transportId: String) {
synchronized(this) {
directPeerIdsByTransport.remove(transportId)
}
}
/** Union of direct peers across all transports. */
fun getDirectPeers(): Set<String> {
synchronized(this) {
return directPeerIdsByTransport.values.flatten().toSet()
}
}
fun addPublicMessage(msg: BitchatMessage) {
@@ -104,6 +155,8 @@ object AppStateStore {
synchronized(this) {
seenMessageIds.clear()
seenPublicMessageKeys.clear()
peerIdsByTransport.clear()
directPeerIdsByTransport.clear()
_peers.value = emptyList()
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
@@ -2,23 +2,23 @@ package com.bitchat.android.services
import android.content.Context
import android.util.Log
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.nostr.NostrTransport
/**
* Routes messages between BLE mesh and Nostr transports, matching iOS behavior.
* Routes messages between local mesh transports and Nostr, matching iOS behavior.
*/
class MessageRouter private constructor(
private val context: Context,
private var mesh: BluetoothMeshService,
private var mesh: MeshService,
private val nostr: NostrTransport
) {
companion object {
private const val TAG = "MessageRouter"
@Volatile private var INSTANCE: MessageRouter? = null
fun tryGetInstance(): MessageRouter? = INSTANCE
fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter {
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
val instance = INSTANCE ?: synchronized(this) {
INSTANCE ?: run {
val nostr = NostrTransport.getInstance(context)
@@ -70,9 +70,8 @@ class MessageRouter private constructor(
}
}
val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true
val hasEstablished = mesh.hasEstablishedSession(toPeerID)
if (hasMesh && hasEstablished) {
val hasMesh = isConnected(mesh, toPeerID)
if (isReady(mesh, toPeerID)) {
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}")
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
} else if (canSendViaNostr(toPeerID)) {
@@ -83,12 +82,12 @@ class MessageRouter private constructor(
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
q.add(Triple(content, recipientNickname, messageID))
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}")
mesh.initiateNoiseHandshake(toPeerID)
if (hasMesh) mesh.initiateNoiseHandshake(toPeerID)
}
}
fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) {
if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)) {
if (isReady(mesh, toPeerID)) {
Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}")
mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID)
} else {
@@ -113,11 +112,11 @@ class MessageRouter private constructor(
}
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
if (mesh.getPeerInfo(toPeerID)?.isConnected == true) {
if (mesh.getPeerInfo(toPeerID)?.isConnected == true && mesh.hasEstablishedSession(toPeerID)) {
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 = mesh.getPeerNicknames()[toPeerID] ?: toPeerID
mesh.sendPrivateMessage(content, toPeerID, nickname)
mesh.sendPrivateMessage(content, toPeerID, nickname, null)
} else {
nostr.sendFavoriteNotification(toPeerID, isFavorite)
}
@@ -131,11 +130,11 @@ class MessageRouter private constructor(
val iterator = queued.iterator()
while (iterator.hasNext()) {
val (content, nickname, messageID) = iterator.next()
var hasMesh = mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID)
val hasMesh = isReady(mesh, peerID)
// If this is a noiseHex key, see if there is a connected mesh peer for this identity
if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val meshPeer = resolveMeshPeerForNoiseHex(peerID)
if (meshPeer != null && mesh.getPeerInfo(meshPeer)?.isConnected == true && mesh.hasEstablishedSession(meshPeer)) {
val meshPeer = resolvePeerForNoiseHex(peerID, mesh)
if (meshPeer != null && isReady(mesh, meshPeer)) {
mesh.sendPrivateMessage(content, meshPeer, nickname, messageID)
iterator.remove()
continue
@@ -182,10 +181,27 @@ class MessageRouter private constructor(
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun resolveMeshPeerForNoiseHex(noiseHex: String): String? {
private fun isConnected(service: MeshService, peerID: String): Boolean {
return try {
mesh.getPeerNicknames().keys.firstOrNull { pid ->
val info = mesh.getPeerInfo(pid)
service.getPeerInfo(peerID)?.isConnected == true
} catch (_: Exception) {
false
}
}
private fun isReady(service: MeshService, peerID: String): Boolean {
return try {
service.getPeerInfo(peerID)?.isConnected == true &&
service.hasEstablishedSession(peerID)
} catch (_: Exception) {
false
}
}
private fun resolvePeerForNoiseHex(noiseHex: String, service: MeshService): String? {
return try {
service.getPeerNicknames().keys.firstOrNull { pid ->
val info = service.getPeerInfo(pid)
val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
keyHex != null && keyHex.equals(noiseHex, ignoreCase = true)
}
@@ -444,7 +444,7 @@ private fun MainHeader(
)
Spacer(modifier = Modifier.width(2.dp))
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
joinedChannels = joinedChannels,
hasUnreadChannels = hasUnreadChannels,
isConnected = isConnected,
@@ -133,7 +133,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshService,
meshService = viewModel.meshServiceFacade,
modifier = Modifier.weight(1f),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
@@ -11,7 +11,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.ui.graphics.vector.ImageVector
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import androidx.compose.material3.ColorScheme
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import java.text.SimpleDateFormat
@@ -42,7 +42,7 @@ fun getRSSIColor(rssi: Int): Color {
fun formatMessageAsAnnotatedString(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
): AnnotatedString {
@@ -162,7 +162,7 @@ fun formatMessageAsAnnotatedString(
fun formatMessageHeaderAnnotatedString(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
): AnnotatedString {
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
@@ -38,12 +39,16 @@ import java.security.MessageDigest
*/
class ChatViewModel(
application: Application,
initialMeshService: BluetoothMeshService
initialMeshService: BluetoothMeshService,
initialUnifiedMeshService: MeshService
) : AndroidViewModel(application), BluetoothMeshDelegate {
// Made var to support mesh service replacement after panic clear
var meshService: BluetoothMeshService = initialMeshService
private set
private var unifiedMeshService: MeshService = initialUnifiedMeshService
private val mesh: MeshService
get() = unifiedMeshService
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
companion object {
@@ -93,9 +98,9 @@ class ChatViewModel(
// Create Noise session delegate for clean dependency injection
private val noiseSessionDelegate = object : NoiseSessionDelegate {
override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID)
override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID)
override fun getMyPeerID(): String = meshService.myPeerID
override fun hasEstablishedSession(peerID: String): Boolean = hasEstablishedSessionOnAnyLocalTransport(peerID)
override fun initiateHandshake(peerID: String) = initiateNoiseHandshakeOnBestLocalTransport(peerID)
override fun getMyPeerID(): String = mesh.myPeerID
}
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
@@ -109,7 +114,7 @@ class ChatViewModel(
private val verificationHandler = VerificationHandler(
context = application.applicationContext,
scope = viewModelScope,
getMeshService = { meshService },
getMeshService = { mesh },
identityManager = identityManager,
state = state,
notificationManager = notificationManager,
@@ -118,7 +123,7 @@ class ChatViewModel(
val verifiedFingerprints = verificationHandler.verifiedFingerprints
// Media file sending manager
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { meshService }
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { mesh }
// Delegate handler for mesh callbacks
private val meshDelegateHandler = MeshDelegateHandler(
@@ -129,8 +134,8 @@ class ChatViewModel(
notificationManager = notificationManager,
coroutineScope = viewModelScope,
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
getMyPeerID = { meshService.myPeerID },
getMeshService = { meshService }
getMyPeerID = { mesh.myPeerID },
getMeshService = { mesh }
)
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
@@ -184,6 +189,18 @@ class ChatViewModel(
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val meshServiceFacade: MeshService
get() = mesh
val myPeerID: String
get() = mesh.myPeerID
fun getMeshPeerFingerprint(peerID: String): String? = mesh.getPeerFingerprint(peerID)
fun getMeshPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? = mesh.getPeerInfo(peerID)
fun initiateMeshHandshake(peerID: String) {
mesh.initiateNoiseHandshake(peerID)
}
init {
// Note: Mesh service delegate is now set by MainActivity
@@ -208,7 +225,7 @@ class ChatViewModel(
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
try {
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
val myNick = state.getNicknameValue() ?: meshService.myPeerID
val myNick = state.getNicknameValue() ?: mesh.myPeerID
val unread = mutableSetOf<String>()
byPeer.forEach { (peer, list) ->
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
@@ -299,7 +316,7 @@ class ChatViewModel(
// Ensure NostrTransport knows our mesh peer ID for embedded packets
try {
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.senderPeerID = mesh.myPeerID
} catch (_: Exception) { }
// Note: Mesh service is now started by MainActivity
@@ -317,7 +334,7 @@ class ChatViewModel(
fun setNickname(newNickname: String) {
state.setNickname(newNickname)
dataManager.saveNickname(newNickname)
meshService.sendBroadcastAnnounce()
mesh.sendBroadcastAnnounce()
}
/**
@@ -363,7 +380,7 @@ class ChatViewModel(
// MARK: - Channel Management (delegated)
fun joinChannel(channel: String, password: String? = null): Boolean {
return channelManager.joinChannel(channel, password, meshService.myPeerID)
return channelManager.joinChannel(channel, password, mesh.myPeerID)
}
fun switchToChannel(channel: String?) {
@@ -372,7 +389,7 @@ class ChatViewModel(
fun leaveChannel(channel: String) {
channelManager.leaveChannel(channel)
meshService.sendMessage("left $channel")
mesh.sendMessage("left $channel", emptyList(), null)
}
// MARK: - Private Chat Management (delegated)
@@ -383,7 +400,7 @@ class ChatViewModel(
ensureGeohashDMSubscriptionIfNeeded(peerID)
}
val success = privateChatManager.startPrivateChat(peerID, meshService)
val success = privateChatManager.startPrivateChat(peerID, mesh)
if (success) {
// Notify notification manager about current private chat
setCurrentPrivateChatPeer(peerID)
@@ -420,7 +437,7 @@ class ChatViewModel(
val unreadKeys = state.getUnreadPrivateMessagesValue()
if (unreadKeys.isEmpty()) return
val me = state.getNicknameValue() ?: meshService.myPeerID
val me = state.getNicknameValue() ?: mesh.myPeerID
val chats = state.getPrivateChatsValue()
// Pick the latest incoming message among unread conversations
@@ -451,8 +468,8 @@ class ChatViewModel(
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = targetKey,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
)
@@ -476,24 +493,23 @@ class ChatViewModel(
// Check for commands
if (content.startsWith("/")) {
val selectedLocationForCommand = state.selectedLocationChannel.value
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
commandProcessor.processCommand(content, mesh, mesh.myPeerID, { messageContent, mentions, channel ->
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
// Route command-generated public messages via Nostr in geohash channels
geohashViewModel.sendGeohashMessage(
messageContent,
selectedLocationForCommand.channel,
meshService.myPeerID,
mesh.myPeerID,
state.getNicknameValue()
)
} else {
// Default: route via mesh
meshService.sendMessage(messageContent, mentions, channel)
mesh.sendMessage(messageContent, mentions, channel)
}
})
}, this)
return
}
val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue())
val mentions = messageManager.parseMentions(content, mesh.getPeerNicknames().values.toSet(), state.getNicknameValue())
// REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions
// This was causing messages like "test @jack#1234 test" to auto-join channel "#1234"
@@ -505,13 +521,13 @@ class ChatViewModel(
selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = selectedPeer,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
).also { canonical ->
if (canonical != state.getSelectedPrivateChatPeerValue()) {
privateChatManager.startPrivateChat(canonical, meshService)
privateChatManager.startPrivateChat(canonical, mesh)
// If we're in the private chat sheet, update its active peer too
if (state.getPrivateChatSheetPeerValue() != null) {
showPrivateChatSheet(canonical)
@@ -519,16 +535,16 @@ class ChatViewModel(
}
}
// Send private message
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
val recipientNickname = nicknameForPeer(selectedPeer)
privateChatManager.sendPrivateMessage(
content,
selectedPeer,
recipientNickname,
state.getNicknameValue(),
meshService.myPeerID
mesh.myPeerID
) { messageContent, peerID, recipientNicknameParam, messageId ->
// Route via MessageRouter (mesh when connected+established, else Nostr)
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService)
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh)
router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
}
} else {
@@ -536,21 +552,21 @@ class ChatViewModel(
val selectedLocationChannel = state.selectedLocationChannel.value
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
// Send to geohash channel via Nostr ephemeral event
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, mesh.myPeerID, state.getNicknameValue())
} else {
// Send public/channel message via mesh
val message = BitchatMessage(
sender = state.getNicknameValue() ?: meshService.myPeerID,
sender = state.getNicknameValue() ?: mesh.myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
senderPeerID = meshService.myPeerID,
senderPeerID = mesh.myPeerID,
mentions = if (mentions.isNotEmpty()) mentions else null,
channel = currentChannelValue
)
if (currentChannelValue != null) {
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID)
// Check if encrypted channel
if (channelManager.hasChannelKey(currentChannelValue)) {
@@ -559,21 +575,20 @@ class ChatViewModel(
mentions,
currentChannelValue,
state.getNicknameValue(),
meshService.myPeerID,
mesh.myPeerID,
onEncryptedPayload = { encryptedData ->
// This would need proper mesh service integration
meshService.sendMessage(content, mentions, currentChannelValue)
mesh.sendMessage(content, mentions, currentChannelValue)
},
onFallback = {
meshService.sendMessage(content, mentions, currentChannelValue)
mesh.sendMessage(content, mentions, currentChannelValue)
}
)
} else {
meshService.sendMessage(content, mentions, currentChannelValue)
mesh.sendMessage(content, mentions, currentChannelValue)
}
} else {
messageManager.addMessage(message)
meshService.sendMessage(content, mentions, null)
mesh.sendMessage(content, mentions, null)
}
}
}
@@ -582,7 +597,7 @@ class ChatViewModel(
// MARK: - Utility Functions
fun getPeerIDForNickname(nickname: String): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
return mesh.getPeerNicknames().entries.find { it.value == nickname }?.key
}
fun toggleFavorite(peerID: String) {
@@ -592,10 +607,10 @@ class ChatViewModel(
// Persist relationship in FavoritesPersistenceService
try {
var noiseKey: ByteArray? = null
var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID
var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID
// Case 1: Live mesh peer with known info
val peerInfo = meshService.getPeerInfo(peerID)
val peerInfo = mesh.getPeerInfo(peerID)
if (peerInfo?.noisePublicKey != null) {
noiseKey = peerInfo.noisePublicKey
nickname = peerInfo.nickname
@@ -625,22 +640,9 @@ class ChatViewModel(
// Send favorite notification via mesh or Nostr with our npub if available
try {
val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}"
// Prefer mesh if session established, else try Nostr
if (meshService.hasEstablishedSession(peerID)) {
// Reuse existing private message path for notifications
meshService.sendPrivateMessage(
announcementContent,
peerID,
nickname,
java.util.UUID.randomUUID().toString()
)
} else {
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendFavoriteNotification(peerID, isNowFavorite)
}
com.bitchat.android.services.MessageRouter
.getInstance(getApplication(), mesh)
.sendFavoriteNotification(peerID, isNowFavorite)
} catch (_: Exception) { }
}
} catch (_: Exception) { }
@@ -657,6 +659,40 @@ class ChatViewModel(
Log.i("ChatViewModel", "==============================")
}
private fun isConnectedOnMesh(peerID: String): Boolean {
return try {
mesh.getPeerInfo(peerID)?.isConnected == true
} catch (_: Exception) {
false
}
}
private fun hasEstablishedSessionOnMesh(peerID: String): Boolean {
return try {
mesh.getPeerInfo(peerID)?.isConnected == true &&
mesh.hasEstablishedSession(peerID)
} catch (_: Exception) {
false
}
}
private fun hasEstablishedSessionOnAnyLocalTransport(peerID: String): Boolean {
return hasEstablishedSessionOnMesh(peerID)
}
private fun initiateNoiseHandshakeOnBestLocalTransport(peerID: String) {
mesh.initiateNoiseHandshake(peerID)
}
private fun nicknameForPeer(peerID: String): String? {
return state.peerNicknames.value[peerID]
?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null }
}
private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState {
return try { mesh.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized }
}
/**
* Initialize session state monitoring for reactive UI updates
*/
@@ -680,7 +716,7 @@ class ChatViewModel(
// Update session states
val prevStates = state.getPeerSessionStatesValue()
val sessionStates = currentPeers.associateWith { peerID ->
meshService.getSessionState(peerID).toString()
sessionStateForPeer(peerID).toString()
}
state.setPeerSessionStates(sessionStates)
// Detect new established sessions and flush router outbox for them and their noiseHex aliases
@@ -688,7 +724,7 @@ class ChatViewModel(
val old = prevStates[peerID]
if (old != "established" && newState == "established") {
com.bitchat.android.services.MessageRouter
.getInstance(getApplication(), meshService)
.getInstance(getApplication(), mesh)
.onSessionEstablished(peerID)
}
}
@@ -697,7 +733,7 @@ class ChatViewModel(
state.setPeerFingerprints(fingerprints)
fingerprints.forEach { (peerID, fingerprint) ->
identityManager.cachePeerFingerprint(peerID, fingerprint)
val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null }
val info = try { mesh.getPeerInfo(peerID) } catch (_: Exception) { null }
val noiseKeyHex = info?.noisePublicKey?.hexEncodedString()
if (noiseKeyHex != null) {
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
@@ -708,23 +744,21 @@ class ChatViewModel(
}
}
val nicknames = meshService.getPeerNicknames()
state.setPeerNicknames(nicknames)
state.setPeerNicknames(mesh.getPeerNicknames())
val rssiValues = meshService.getPeerRSSI()
state.setPeerRSSI(rssiValues)
state.setPeerRSSI(mesh.getPeerRSSI())
// Update directness per peer (driven by PeerManager state)
try {
val directMap = state.getConnectedPeersValue().associateWith { pid ->
meshService.getPeerInfo(pid)?.isDirectConnection == true
mesh.getPeerInfo(pid)?.isDirectConnection == true
}
state.setPeerDirect(directMap)
} catch (_: Exception) { }
// Flush any pending QR verification once a Noise session is established
currentPeers.forEach { peerID ->
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
if (sessionStateForPeer(peerID) is NoiseSession.NoiseSessionState.Established) {
verificationHandler.sendPendingVerificationIfNeeded(peerID)
}
}
@@ -754,7 +788,7 @@ class ChatViewModel(
// MARK: - Debug and Troubleshooting
fun getDebugStatus(): String {
return meshService.getDebugStatus()
return mesh.getDebugStatus()
}
fun setCurrentPrivateChatPeer(peerID: String?) {
@@ -851,7 +885,7 @@ class ChatViewModel(
// MARK: - Mention Autocomplete
fun updateMentionSuggestions(input: String) {
commandProcessor.updateMentionSuggestions(input, meshService, this)
commandProcessor.updateMentionSuggestions(input, mesh, this)
}
fun selectMentionSuggestion(nickname: String, currentText: String): String {
@@ -956,7 +990,7 @@ class ChatViewModel(
// Recreate mesh service with fresh identity
recreateMeshServiceAfterPanic()
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${meshService.myPeerID}")
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")
}
/**
@@ -964,25 +998,27 @@ class ChatViewModel(
* This ensures the new cryptographic keys are used for a new peer ID.
*/
private fun recreateMeshServiceAfterPanic() {
val oldPeerID = meshService.myPeerID
val oldPeerID = mesh.myPeerID
// Clear the holder so getOrCreate() returns a fresh instance
MeshServiceHolder.clear()
// Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData)
val freshMeshService = MeshServiceHolder.getOrCreate(getApplication())
val freshUnifiedMeshService = MeshServiceHolder.getUnifiedOrCreate(getApplication())
// Replace our reference and set up the new service
meshService = freshMeshService
meshService.delegate = this
unifiedMeshService = freshUnifiedMeshService
mesh.delegate = this
// Restart mesh operations with new identity
meshService.startServices()
meshService.sendBroadcastAnnounce()
mesh.startServices()
mesh.sendBroadcastAnnounce()
Log.d(
TAG,
"✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${meshService.myPeerID}"
"✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${mesh.myPeerID}"
)
}
@@ -992,7 +1028,7 @@ class ChatViewModel(
private fun clearAllMeshServiceData() {
try {
// Request mesh service to clear all its internal data
meshService.clearAllInternalData()
mesh.clearAllInternalData()
Log.d(TAG, "✅ Cleared all mesh service data")
} catch (e: Exception) {
@@ -1006,7 +1042,7 @@ class ChatViewModel(
private fun clearAllCryptographicData() {
try {
// Clear encryption service persistent identity (Ed25519 signing keys)
meshService.clearAllEncryptionData()
mesh.clearAllEncryptionData()
// Clear secure identity state (if used)
try {
@@ -1,6 +1,6 @@
package com.bitchat.android.ui
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import java.util.Date
@@ -29,21 +29,21 @@ class CommandProcessor(
// MARK: - Command Processing
fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean {
fun processCommand(command: String, meshService: MeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean {
if (!command.startsWith("/")) return false
val parts = command.split(" ")
val cmd = parts.first().lowercase()
when (cmd) {
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
"/m", "/msg" -> handleMessageCommand(parts, meshService)
"/m", "/msg" -> handleMessageCommand(parts, meshService, viewModel)
"/w" -> handleWhoCommand(meshService, viewModel)
"/clear" -> handleClearCommand()
"/pass" -> handlePassCommand(parts, myPeerID)
"/block" -> handleBlockCommand(parts, meshService)
"/unblock" -> handleUnblockCommand(parts, meshService)
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage)
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage)
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel)
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel)
"/channels" -> handleChannelsCommand()
else -> handleUnknownCommand(cmd)
}
@@ -77,7 +77,7 @@ class CommandProcessor(
}
}
private fun handleMessageCommand(parts: List<String>, meshService: BluetoothMeshService) {
private fun handleMessageCommand(parts: List<String>, meshService: MeshService, viewModel: ChatViewModel?) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
val peerID = getPeerIDForNickname(targetName, meshService)
@@ -96,8 +96,7 @@ class CommandProcessor(
state.getNicknameValue(),
getMyPeerID(meshService)
) { content, peerIdParam, recipientNicknameParam, messageId ->
// This would trigger the actual mesh service send
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
}
} else {
val systemMessage = BitchatMessage(
@@ -129,7 +128,7 @@ class CommandProcessor(
}
}
private fun handleWhoCommand(meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
private fun handleWhoCommand(meshService: MeshService, viewModel: ChatViewModel? = null) {
// Channel-aware who command (matches iOS behavior)
val (peerList, contextDescription) = if (viewModel != null) {
when (val selectedChannel = viewModel.selectedLocationChannel.value) {
@@ -248,7 +247,7 @@ class CommandProcessor(
}
}
private fun handleBlockCommand(parts: List<String>, meshService: BluetoothMeshService) {
private fun handleBlockCommand(parts: List<String>, meshService: MeshService) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
privateChatManager.blockPeerByNickname(targetName, meshService)
@@ -265,7 +264,7 @@ class CommandProcessor(
}
}
private fun handleUnblockCommand(parts: List<String>, meshService: BluetoothMeshService) {
private fun handleUnblockCommand(parts: List<String>, meshService: MeshService) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
privateChatManager.unblockPeerByNickname(targetName, meshService)
@@ -284,9 +283,10 @@ class CommandProcessor(
parts: List<String>,
verb: String,
object_: String,
meshService: BluetoothMeshService,
meshService: MeshService,
myPeerID: String,
onSendMessage: (String, List<String>, String?) -> Unit
onSendMessage: (String, List<String>, String?) -> Unit,
viewModel: ChatViewModel?
) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
@@ -306,7 +306,7 @@ class CommandProcessor(
state.getNicknameValue(),
myPeerID
) { content, peerIdParam, recipientNicknameParam, messageId ->
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
}
} else if (isInLocationChannel) {
// Let the transport layer add the echo; just send it out
@@ -423,7 +423,7 @@ class CommandProcessor(
// MARK: - Mention Autocomplete
fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
fun updateMentionSuggestions(input: String, meshService: MeshService, viewModel: ChatViewModel? = null) {
// Check if input contains @ and we're at the end of a word or at the end of input
val atIndex = input.lastIndexOf('@')
if (atIndex == -1) {
@@ -503,19 +503,33 @@ class CommandProcessor(
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
}
private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String {
return meshService.getPeerNicknames()[peerID] ?: peerID
private fun getPeerNickname(peerID: String, meshService: MeshService): String {
return meshService.getPeerNicknames()[peerID]
?: peerID
}
private fun getMyPeerID(meshService: BluetoothMeshService): String {
private fun getMyPeerID(meshService: MeshService): String {
return meshService.myPeerID
}
private fun sendPrivateMessageVia(meshService: BluetoothMeshService, content: String, peerID: String, recipientNickname: String, messageId: String) {
private fun sendPrivateMessageVia(
meshService: MeshService,
content: String,
peerID: String,
recipientNickname: String,
messageId: String,
viewModel: ChatViewModel?
) {
if (viewModel != null) {
com.bitchat.android.services.MessageRouter
.getInstance(viewModel.getApplication(), meshService)
.sendPrivate(content, peerID, recipientNickname, messageId)
} else {
meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId)
}
}
}
@@ -656,7 +656,7 @@ private fun meshTitleWithCount(viewModel: ChatViewModel): String {
}
private fun meshCount(viewModel: ChatViewModel): Int {
val myID = viewModel.meshService.myPeerID
val myID = viewModel.myPeerID
return viewModel.connectedPeers.value?.count { peerID ->
peerID != myID
} ?: 0
@@ -77,7 +77,7 @@ fun MessageWithMatrixAnimation(
message: com.bitchat.android.model.BitchatMessage,
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
currentUserNickname: String,
meshService: com.bitchat.android.mesh.BluetoothMeshService,
meshService: com.bitchat.android.mesh.MeshService,
colorScheme: androidx.compose.material3.ColorScheme,
timeFormatter: java.text.SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
@@ -124,7 +124,7 @@ fun MessageWithMatrixAnimation(
private fun AnimatedMessageDisplay(
message: com.bitchat.android.model.BitchatMessage,
currentUserNickname: String,
meshService: com.bitchat.android.mesh.BluetoothMeshService,
meshService: com.bitchat.android.mesh.MeshService,
colorScheme: androidx.compose.material3.ColorScheme,
timeFormatter: java.text.SimpleDateFormat,
modifier: Modifier = Modifier
@@ -241,7 +241,7 @@ private fun AnimatedMessageDisplay(
private fun formatMessageAsAnnotatedStringWithoutTimestamp(
message: com.bitchat.android.model.BitchatMessage,
currentUserNickname: String,
meshService: com.bitchat.android.mesh.BluetoothMeshService,
meshService: com.bitchat.android.mesh.MeshService,
colorScheme: androidx.compose.material3.ColorScheme
): AnnotatedString {
// Get the full formatted text first
@@ -1,7 +1,7 @@
package com.bitchat.android.ui
import android.util.Log
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
@@ -16,10 +16,10 @@ class MediaSendingManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val getMeshService: () -> BluetoothMeshService
private val getMeshService: () -> MeshService
) {
// Helper to get current mesh service (may change after panic clear)
private val meshService: BluetoothMeshService
private val meshService: MeshService
get() = getMeshService()
companion object {
private const val TAG = "MediaSendingManager"
@@ -2,7 +2,7 @@ package com.bitchat.android.ui
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.ui.NotificationTextUtils
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import kotlinx.coroutines.CoroutineScope
@@ -21,7 +21,7 @@ class MeshDelegateHandler(
private val coroutineScope: CoroutineScope,
private val onHapticFeedback: () -> Unit,
private val getMyPeerID: () -> String,
private val getMeshService: () -> BluetoothMeshService
private val getMeshService: () -> MeshService
) : BluetoothMeshDelegate {
override fun didReceiveMessage(message: BitchatMessage) {
@@ -96,14 +96,20 @@ class MeshDelegateHandler(
override fun didUpdatePeerList(peers: List<String>) {
coroutineScope.launch {
state.setConnectedPeers(peers)
state.setIsConnected(peers.isNotEmpty())
notificationManager.showActiveUserNotification(peers)
processPeerUpdate(peers.distinct())
}
}
private suspend fun processPeerUpdate(mergedPeers: List<String>) {
state.setConnectedPeers(mergedPeers)
state.setIsConnected(mergedPeers.isNotEmpty())
notificationManager.showActiveUserNotification(mergedPeers)
// Flush router outbox for any peers that just connected (and their noiseHex aliases)
runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) }
runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(mergedPeers) }
// Clean up channel members who disconnected
channelManager.cleanupDisconnectedMembers(peers, getMyPeerID())
channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID())
// Handle chat view migration based on current selection and new peer list
state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
@@ -116,9 +122,9 @@ class MeshDelegateHandler(
// Resolve canonical target (prefer connected mesh peer if available)
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = currentPeer,
connectedPeers = peers,
connectedPeers = mergedPeers,
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> peers.contains(pid) },
meshHasPeer = { pid -> mergedPeers.contains(pid) },
nostrPubHexForAlias = { alias ->
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
@@ -143,7 +149,7 @@ class MeshDelegateHandler(
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer))
state.setSelectedPrivateChatPeer(canonical)
}
} else if (isMeshEphemeral && !peers.contains(currentPeer)) {
} else if (isMeshEphemeral && !mergedPeers.contains(currentPeer)) {
// Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex)
val favoriteRel = try {
val info = getPeerInfo(currentPeer)
@@ -171,7 +177,7 @@ class MeshDelegateHandler(
// Global unification: for each connected peer, merge any offline/stable conversations
// (noiseHex or nostr_<pub16>) into the connected peer's chat so there is only one chat per identity.
peers.forEach { pid ->
mergedPeers.forEach { pid ->
try {
val info = getPeerInfo(pid)
val noiseKey = info?.noisePublicKey ?: return@forEach
@@ -190,7 +196,6 @@ class MeshDelegateHandler(
} catch (_: Exception) { }
}
}
}
/**
* Merge any chats stored under the given keys into the connected peer's chat entry.
@@ -294,8 +299,19 @@ class MeshDelegateHandler(
if (shouldSendReadReceipt) {
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
val nickname = state.getNicknameValue() ?: "unknown"
// Send directly for this message to avoid relying on unread queues
getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname)
val mesh = getMeshService()
val sent = try {
val hasMesh = mesh.getPeerInfo(senderPeerID!!)?.isConnected == true && mesh.hasEstablishedSession(senderPeerID)
if (hasMesh) {
mesh.sendReadReceipt(message.id, senderPeerID, nickname)
true
} else {
false
}
} catch (_: Exception) {
false
}
if (sent) {
// Ensure unread badge is cleared for this peer immediately
try {
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
@@ -303,6 +319,7 @@ class MeshDelegateHandler(
state.setUnreadPrivateMessages(current)
}
} catch (_: Exception) { }
}
} else {
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
}
@@ -61,6 +61,8 @@ fun MeshPeerListSheet(
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
@@ -163,6 +165,7 @@ fun MeshPeerListSheet(
nickname = nickname,
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
wifiAwarePeerIDs = wifiAwarePeerIDs,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.showPrivateChatSheet(peerID)
@@ -279,6 +282,7 @@ fun PeopleSection(
nickname: String,
colorScheme: ColorScheme,
selectedPrivatePeer: String?,
wifiAwarePeerIDs: Set<String> = emptySet(),
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit
) {
@@ -333,7 +337,7 @@ fun PeopleSection(
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try {
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
} catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! }
@@ -411,11 +415,12 @@ fun PeopleSection(
val showHash = (baseNameCounts[bName] ?: 0) > 1
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem(
peerID = peerID,
displayName = displayName,
isDirect = isDirectLive,
isWifiAware = peerID in wifiAwarePeerIDs,
isSelected = peerID == selectedPrivatePeer,
isFavorite = isFavorite,
isVerified = isVerified,
@@ -544,6 +549,7 @@ private fun PeerItem(
peerID: String,
displayName: String,
isDirect: Boolean,
isWifiAware: Boolean = false,
isSelected: Boolean,
isFavorite: Boolean,
isVerified: Boolean,
@@ -619,8 +625,16 @@ private fun PeerItem(
)
} else {
Icon(
imageVector = if (isDirect) Icons.Outlined.Bluetooth else Icons.Filled.Route,
contentDescription = if (isDirect) "Direct Bluetooth" else "Routed",
imageVector = when {
isWifiAware -> Icons.Filled.Wifi
isDirect -> Icons.Outlined.Bluetooth
else -> Icons.Filled.Route
},
contentDescription = when {
isWifiAware -> "Direct Wi-Fi Aware"
isDirect -> "Direct Bluetooth"
else -> "Routed"
},
modifier = Modifier.size(16.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
@@ -652,6 +666,16 @@ private fun PeerItem(
color = baseColor.copy(alpha = 0.6f)
)
}
if (isWifiAware && hasUnreadDM) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Wifi,
contentDescription = "Direct Wi-Fi Aware",
modifier = Modifier.size(13.dp),
tint = colorScheme.onSurface.copy(alpha = 0.8f)
)
}
}
}
@@ -763,6 +787,8 @@ fun PrivateChatSheet(
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val isWifiAware = peerID in wifiAwareConnected.keys
// Start private chat when screen opens
LaunchedEffect(peerID) {
@@ -831,7 +857,7 @@ fun PrivateChatSheet(
MessagesList(
messages = messages,
currentUserNickname = nickname,
meshService = viewModel.meshService,
meshService = viewModel.meshServiceFacade,
modifier = Modifier.weight(1f),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
@@ -915,10 +941,26 @@ fun PrivateChatSheet(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
when {
isNostrPeer -> {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp),
tint = Color(0xFF9C27B0)
)
}
isWifiAware -> {
Icon(
imageVector = Icons.Filled.Wifi,
contentDescription = "Direct Wi-Fi Aware",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
isDirect -> {
Icon(
imageVector = Icons.Outlined.SettingsInputAntenna,
contentDescription = stringResource(R.string.cd_connected_peers),
imageVector = Icons.Outlined.Bluetooth,
contentDescription = "Direct Bluetooth",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
@@ -926,19 +968,11 @@ fun PrivateChatSheet(
isConnected -> {
Icon(
imageVector = Icons.Filled.Route,
contentDescription = stringResource(R.string.cd_ready_for_handshake),
contentDescription = "Routed",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
isNostrPeer -> {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp),
tint = Color(0xFF9C27B0)
)
}
}
Text(
@@ -31,7 +31,7 @@ import android.content.Intent
import android.net.Uri
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import java.text.SimpleDateFormat
import java.util.*
import com.bitchat.android.ui.media.VoiceNotePlayer
@@ -58,7 +58,7 @@ import androidx.compose.ui.res.stringResource
fun MessagesList(
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
modifier: Modifier = Modifier,
forceScrollToBottom: Boolean = false,
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
@@ -137,7 +137,7 @@ fun MessagesList(
fun MessageItem(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
messages: List<BitchatMessage> = emptyList(),
onNicknameClick: ((String) -> Unit)? = null,
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
@@ -201,7 +201,7 @@ fun MessageItem(
message: BitchatMessage,
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
@@ -3,9 +3,9 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.PeerFingerprintManager
import com.bitchat.android.mesh.MeshService
import java.security.MessageDigest
import com.bitchat.android.mesh.BluetoothMeshService
import java.util.*
import android.util.Log
@@ -42,7 +42,7 @@ class PrivateChatManager(
// MARK: - Private Chat Lifecycle
fun startPrivateChat(peerID: String, meshService: BluetoothMeshService): Boolean {
fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
if (isPeerBlocked(peerID)) {
val peerNickname = getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage(
@@ -188,7 +188,7 @@ class PrivateChatManager(
// MARK: - Block/Unblock Operations
fun blockPeer(peerID: String, meshService: BluetoothMeshService): Boolean {
fun blockPeer(peerID: String, meshService: MeshService): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
if (fingerprint != null) {
dataManager.addBlockedUser(fingerprint)
@@ -212,7 +212,7 @@ class PrivateChatManager(
return false
}
fun unblockPeer(peerID: String, meshService: BluetoothMeshService): Boolean {
fun unblockPeer(peerID: String, meshService: MeshService): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
dataManager.removeBlockedUser(fingerprint)
@@ -230,7 +230,7 @@ class PrivateChatManager(
return false
}
fun blockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean {
fun blockPeerByNickname(targetName: String, meshService: MeshService): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
@@ -247,7 +247,7 @@ class PrivateChatManager(
}
}
fun unblockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean {
fun unblockPeerByNickname(targetName: String, meshService: MeshService): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
@@ -333,7 +333,7 @@ class PrivateChatManager(
* Send read receipts for all unread messages from a specific peer
* Called when the user focuses on a private chat
*/
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
fun sendReadReceiptsForPeer(peerID: String, meshService: MeshService) {
// Collect candidate messages: all incoming messages from this peer in the conversation
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
val messages = chats[peerID].orEmpty()
@@ -343,13 +343,16 @@ class PrivateChatManager(
}
val myNickname = state.getNicknameValue() ?: "unknown"
val hasMesh = try { meshService.getPeerInfo(peerID)?.isConnected == true && meshService.hasEstablishedSession(peerID) } catch (_: Exception) { false }
var sentCount = 0
messages.forEach { msg ->
// Only for incoming messages from this peer
if (msg.senderPeerID == peerID) {
try {
if (hasMesh) {
meshService.sendReadReceipt(msg.id, peerID, myNickname)
sentCount += 1
}
} catch (e: Exception) {
Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}")
}
@@ -380,7 +383,7 @@ class PrivateChatManager(
* Establish Noise session if needed before starting private chat
* Uses same lexicographical logic as MessageHandler.handleNoiseIdentityAnnouncement
*/
private fun establishNoiseSessionIfNeeded(peerID: String, meshService: BluetoothMeshService) {
private fun establishNoiseSessionIfNeeded(peerID: String, meshService: MeshService) {
if (noiseSessionDelegate.hasEstablishedSession(peerID)) {
Log.d(TAG, "Noise session already established with $peerID")
return
@@ -413,11 +416,11 @@ class PrivateChatManager(
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
}
private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String {
private fun getPeerNickname(peerID: String, meshService: MeshService): String {
return meshService.getPeerNicknames()[peerID] ?: peerID
}
@@ -134,7 +134,7 @@ fun SecurityVerificationSheet(
displayName = displayName,
accent = accent,
canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex),
onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) },
onStartHandshake = { viewModel.initiateMeshHandshake(selectedPeerID) },
onVerify = { fp -> viewModel.verifyFingerprintValue(fp) },
onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) }
)
@@ -4,7 +4,7 @@ import android.content.Context
import com.bitchat.android.R
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
@@ -26,14 +26,14 @@ import java.util.concurrent.ConcurrentHashMap
class VerificationHandler(
private val context: Context,
private val scope: CoroutineScope,
private val getMeshService: () -> BluetoothMeshService,
private val getMeshService: () -> MeshService,
private val identityManager: SecureIdentityStateManager,
private val state: ChatState,
private val notificationManager: NotificationManager,
private val messageManager: MessageManager
) {
// Helper to get current mesh service (may change after panic clear)
private val meshService: BluetoothMeshService
private val meshService: MeshService
get() = getMeshService()
private val _verifiedFingerprints = MutableStateFlow<Set<String>>(emptySet())
@@ -193,7 +193,7 @@ fun VerificationSheet(
val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
if (peerID != null) {
val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!)
val fingerprint = viewModel.getMeshPeerFingerprint(peerID!!)
if (fingerprint != null && fingerprints.contains(fingerprint)) {
Spacer(modifier = Modifier.height(16.dp))
Button(
@@ -20,7 +20,10 @@ object DebugPreferenceManager {
// GCS keys (no migration/back-compat)
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent"
// Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled
// Transport master toggles
private const val KEY_BLE_ENABLED = "ble_enabled"
private const val KEY_WIFI_AWARE_ENABLED = "wifi_aware_enabled"
private const val KEY_WIFI_AWARE_VERBOSE = "wifi_aware_verbose"
private lateinit var prefs: SharedPreferences
@@ -102,5 +105,25 @@ object DebugPreferenceManager {
if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply()
}
// No longer storing persistent notification in debug prefs.
// Transport toggles
fun getBleEnabled(default: Boolean = true): Boolean =
if (ready()) prefs.getBoolean(KEY_BLE_ENABLED, default) else default
fun setBleEnabled(value: Boolean) {
if (ready()) prefs.edit().putBoolean(KEY_BLE_ENABLED, value).apply()
}
fun getWifiAwareEnabled(default: Boolean = true): Boolean =
if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_ENABLED, default) else default
fun setWifiAwareEnabled(value: Boolean) {
if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_ENABLED, value).apply()
}
fun getWifiAwareVerbose(default: Boolean = false): Boolean =
if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_VERBOSE, default) else default
fun setWifiAwareVerbose(value: Boolean) {
if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_VERBOSE, value).apply()
}
}
@@ -40,6 +40,17 @@ class DebugSettingsManager private constructor() {
private val _packetRelayEnabled = MutableStateFlow(true)
val packetRelayEnabled: StateFlow<Boolean> = _packetRelayEnabled.asStateFlow()
// Master transport toggles
private val _bleEnabled = MutableStateFlow(true)
val bleEnabled: StateFlow<Boolean> = _bleEnabled.asStateFlow()
private val _wifiAwareEnabled = MutableStateFlow(true)
val wifiAwareEnabled: StateFlow<Boolean> = _wifiAwareEnabled.asStateFlow()
// Master transport toggles
private val _wifiAwareVerbose = MutableStateFlow(false)
val wifiAwareVerbose: StateFlow<Boolean> = _wifiAwareVerbose.asStateFlow()
// Visibility of the debug sheet; gates heavy work
private val _debugSheetVisible = MutableStateFlow(false)
val debugSheetVisible: StateFlow<Boolean> = _debugSheetVisible.asStateFlow()
@@ -63,6 +74,10 @@ class DebugSettingsManager private constructor() {
_maxConnectionsOverall.value = DebugPreferenceManager.getMaxConnectionsOverall(8)
_maxServerConnections.value = DebugPreferenceManager.getMaxConnectionsServer(8)
_maxClientConnections.value = DebugPreferenceManager.getMaxConnectionsClient(8)
// Transport toggles
_bleEnabled.value = DebugPreferenceManager.getBleEnabled(true)
_wifiAwareEnabled.value = DebugPreferenceManager.getWifiAwareEnabled(true)
_wifiAwareVerbose.value = DebugPreferenceManager.getWifiAwareVerbose(false)
} catch (_: Exception) {
// Preferences not ready yet; keep defaults. They will be applied on first change.
}
@@ -266,6 +281,30 @@ class DebugSettingsManager private constructor() {
))
}
fun setBleEnabled(enabled: Boolean) {
DebugPreferenceManager.setBleEnabled(enabled)
_bleEnabled.value = enabled
addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 BLE enabled" else "🔴 BLE disabled"))
try {
com.bitchat.android.service.MeshServiceHolder.meshService?.setBleTransportEnabled(enabled)
} catch (_: Exception) { }
}
fun setWifiAwareEnabled(enabled: Boolean) {
DebugPreferenceManager.setWifiAwareEnabled(enabled)
_wifiAwareEnabled.value = enabled
addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 WiFi Aware enabled" else "🔴 WiFi Aware disabled"))
try {
com.bitchat.android.wifiaware.WifiAwareController.setEnabled(enabled)
} catch (_: Exception) { }
}
fun setWifiAwareVerbose(enabled: Boolean) {
DebugPreferenceManager.setWifiAwareVerbose(enabled)
_wifiAwareVerbose.value = enabled
addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🔊 WiFi Aware verbose logging enabled" else "🔇 WiFi Aware verbose logging disabled"))
}
fun setMaxConnectionsOverall(value: Int) {
val clamped = value.coerceIn(1, 32)
DebugPreferenceManager.setMaxConnectionsOverall(clamped)
@@ -324,6 +363,16 @@ class DebugSettingsManager private constructor() {
_connectedDevices.value = devices
}
// WiFi Aware debug collections
private val _wifiAwareDiscovered = MutableStateFlow<Map<String, String>>(emptyMap()) // peerID->nickname
val wifiAwareDiscovered: StateFlow<Map<String, String>> = _wifiAwareDiscovered.asStateFlow()
private val _wifiAwareConnected = MutableStateFlow<Map<String, String>>(emptyMap()) // peerID->ip
val wifiAwareConnected: StateFlow<Map<String, String>> = _wifiAwareConnected.asStateFlow()
fun updateWifiAwareDiscovered(map: Map<String, String>) { _wifiAwareDiscovered.value = map }
fun updateWifiAwareConnected(map: Map<String, String>) { _wifiAwareConnected.value = map }
fun updateRelayStats(stats: PacketRelayStats) {
_relayStats.value = stats
}
@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiTethering
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.PowerSettingsNew
@@ -40,10 +42,15 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
@Composable
fun MeshTopologySection() {
fun MeshTopologySection(
localPeerID: String? = null,
blePeerIDs: Set<String> = emptySet(),
) {
val colorScheme = MaterialTheme.colorScheme
val graphService = remember { MeshGraphService.getInstance() }
val snapshot by graphService.graphState.collectAsState()
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState()
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -60,6 +67,9 @@ fun MeshTopologySection() {
ForceDirectedMeshGraph(
nodes = nodes,
edges = edges,
wifiAwarePeerIDs = wifiAwarePeerIDs,
blePeerIDs = blePeerIDs,
localPeerID = localPeerID,
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
@@ -102,7 +112,7 @@ fun DebugSettingsSheet(
val verboseLogging by manager.verboseLoggingEnabled.collectAsState()
val gattServerEnabled by manager.gattServerEnabled.collectAsState()
val gattClientEnabled by manager.gattClientEnabled.collectAsState()
val packetRelayEnabled by manager.packetRelayEnabled.collectAsState()
val packetRelayed by manager.packetRelayEnabled.collectAsState()
val maxOverall by manager.maxConnectionsOverall.collectAsState()
val maxServer by manager.maxServerConnections.collectAsState()
val maxClient by manager.maxClientConnections.collectAsState()
@@ -114,6 +124,15 @@ fun DebugSettingsSheet(
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
val gcsFpr by manager.gcsFprPercent.collectAsState()
val context = LocalContext.current
val bleEnabled by manager.bleEnabled.collectAsState()
val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState()
val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState()
val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState()
val wifiAwareConnected by manager.wifiAwareConnected.collectAsState()
val wifiAwareSupported by com.bitchat.android.wifiaware.WifiAwareController.supported.collectAsState()
val wifiAwareAvailable by com.bitchat.android.wifiaware.WifiAwareController.available.collectAsState()
val wifiAwareSupportStatus by com.bitchat.android.wifiaware.WifiAwareController.supportStatus.collectAsState()
// Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled
val listState = rememberLazyListState()
val isScrolled by remember {
@@ -148,6 +167,15 @@ fun DebugSettingsSheet(
)
}
manager.updateConnectedDevices(devices)
// Also surface WiFi Aware status
try {
val ctrl = com.bitchat.android.wifiaware.WifiAwareController
val known = ctrl.knownPeers.value
val discovered = ctrl.discoveredPeers.value
val discoveredMap = discovered.associateWith { pid -> known[pid] ?: "" }
manager.updateWifiAwareDiscovered(discoveredMap)
manager.updateWifiAwareConnected(ctrl.connectedPeers.value)
} catch (_: Exception) { }
kotlinx.coroutines.delay(1000)
}
}
@@ -204,7 +232,13 @@ fun DebugSettingsSheet(
// Mesh topology visualization (moved below verbose logging)
item {
MeshTopologySection()
val blePeerIDs = remember(connectedDevices) {
connectedDevices.mapNotNull { it.peerID }.toSet()
}
MeshTopologySection(
localPeerID = meshService.myPeerID,
blePeerIDs = blePeerIDs,
)
}
// GATT controls
@@ -276,6 +310,53 @@ fun DebugSettingsSheet(
}
}
// Transport toggles (BLE + WiFi Aware)
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50))
Text("Transports", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
Spacer(Modifier.width(8.dp))
Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Switch(checked = bleEnabled, onCheckedChange = {
manager.setBleEnabled(it)
})
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0))
Spacer(Modifier.width(8.dp))
Text("WiFi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
val wifiSwitchEnabled = wifiAwareSupported
Text(
when {
!wifiAwareSupported -> "unsupported"
wifiAwareAvailable -> "available"
else -> "unavailable"
},
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(Modifier.width(8.dp))
Switch(
checked = wifiAwareEnabled && wifiAwareSupported,
enabled = wifiSwitchEnabled,
onCheckedChange = { manager.setWifiAwareEnabled(it) }
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(24.dp))
Text("WiFi Aware verbose", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) })
}
}
}
}
// Packet relay controls and stats
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
@@ -286,7 +367,7 @@ fun DebugSettingsSheet(
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) })
Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) })
}
// Removed aggregate labels; we will show per-direction compact labels below titles
// Toggle: overall vs per-connection vs per-peer
@@ -535,6 +616,65 @@ fun DebugSettingsSheet(
}
}
// WiFi Aware controls and status
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState()
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0))
Text("WiFi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
val wifiStatusText = when {
!wifiAwareSupported -> "unsupported"
running -> "running"
!wifiAwareAvailable -> "unavailable"
else -> "stopped"
}
Text(wifiStatusText, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
if (!wifiAwareSupported) {
Text(
wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device",
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
AssistChip(
onClick = { manager.setWifiAwareEnabled(true) },
enabled = wifiAwareSupported,
label = { Text("Start") }
)
AssistChip(onClick = { manager.setWifiAwareEnabled(false) }, label = { Text("Stop") })
AssistChip(
onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() },
enabled = running,
label = { Text("Announce") }
)
}
Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
if (wifiAwareDiscovered.isEmpty()) {
Text("No discoveries yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) ->
Text("${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
}
Divider()
Text("Connected: ${wifiAwareConnected.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
if (wifiAwareConnected.isEmpty()) {
Text("No active sockets", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
wifiAwareConnected.entries.take(50).forEach { (peer, ip) ->
Text("${peer.take(8)}… @ $ip", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
}
}
}
}
// Connected devices
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
@@ -4,6 +4,12 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.outlined.Bluetooth
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@@ -14,6 +20,7 @@ import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.services.meshgraph.MeshGraphService
@@ -31,6 +38,44 @@ private const val DAMPING = 0.85f
private const val MAX_VELOCITY = 30f
private const val PULSE_DECAY = 0.05f
private const val ROUTE_DECAY = 0.02f
private val EDGE_ICON_OFFSET = 14.dp
private enum class EdgeTransport { WIFI, BLE }
private data class EdgeMarker(val x: Float, val y: Float, val transport: EdgeTransport)
private fun shouldMarkDirectEdge(
edge: MeshGraphService.GraphEdge,
transportPeerIDs: Set<String>,
localID: String?
): Boolean {
if (transportPeerIDs.isEmpty()) return false
return if (localID != null) {
(edge.a == localID && edge.b in transportPeerIDs) ||
(edge.b == localID && edge.a in transportPeerIDs)
} else {
edge.a in transportPeerIDs || edge.b in transportPeerIDs
}
}
private fun edgeMarkerPosition(
x1: Float,
y1: Float,
x2: Float,
y2: Float,
offsetPx: Float,
sideSign: Float
): Pair<Float, Float> {
val midX = (x1 + x2) / 2f
val midY = (y1 + y2) / 2f
val dx = x2 - x1
val dy = y2 - y1
val len = sqrt(dx * dx + dy * dy)
if (len < 0.1f) return midX to midY
val px = -dy / len * offsetPx * sideSign
val py = dx / len * offsetPx * sideSign
return (midX + px) to (midY + py)
}
private class GraphNodeState(
val id: String,
@@ -212,6 +257,9 @@ private class Simulation {
fun ForceDirectedMeshGraph(
nodes: List<MeshGraphService.GraphNode>,
edges: List<MeshGraphService.GraphEdge>,
wifiAwarePeerIDs: Set<String> = emptySet(),
blePeerIDs: Set<String> = emptySet(),
localPeerID: String? = null,
modifier: Modifier = Modifier
) {
val density = LocalDensity.current
@@ -405,5 +453,54 @@ fun ForceDirectedMeshGraph(
)
}
}
val iconSize = 16.dp
val iconSizePx = with(density) { iconSize.toPx() }
val halfIconSizePx = iconSizePx / 2f
val iconOffsetPx = with(density) { EDGE_ICON_OFFSET.toPx() }
val localID = localPeerID
val edgeMarkers = tick.let {
simulation.edges.flatMap { edge ->
val n1 = simulation.nodes[edge.a]
val n2 = simulation.nodes[edge.b]
if (n1 == null || n2 == null) return@flatMap emptyList()
val isWifi = shouldMarkDirectEdge(edge, wifiAwarePeerIDs, localID)
val isBle = shouldMarkDirectEdge(edge, blePeerIDs, localID)
if (!isWifi && !isBle) return@flatMap emptyList()
val markers = mutableListOf<EdgeMarker>()
if (isWifi) {
val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = 1f)
markers.add(EdgeMarker(x, y, EdgeTransport.WIFI))
}
if (isBle) {
val side = if (isWifi) -1f else 1f
val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = side)
markers.add(EdgeMarker(x, y, EdgeTransport.BLE))
}
markers
}
}
edgeMarkers.forEach { marker ->
Icon(
imageVector = when (marker.transport) {
EdgeTransport.WIFI -> Icons.Filled.Wifi
EdgeTransport.BLE -> Icons.Outlined.Bluetooth
},
contentDescription = null,
tint = colorScheme.onSurface.copy(alpha = 0.82f),
modifier = Modifier
.offset {
IntOffset(
x = (marker.x - halfIconSizePx).roundToInt(),
y = (marker.y - halfIconSizePx).roundToInt()
)
}
.size(iconSize)
)
}
}
}
@@ -21,7 +21,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme
import java.text.SimpleDateFormat
@@ -30,7 +30,7 @@ import java.text.SimpleDateFormat
fun AudioMessageItem(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
@@ -27,7 +27,7 @@ import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.text.font.FontFamily
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import androidx.compose.material3.ColorScheme
@@ -39,7 +39,7 @@ fun ImageMessageItem(
message: BitchatMessage,
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
@@ -0,0 +1,99 @@
package com.bitchat.android.wifiaware
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException
import java.net.Socket
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import android.util.Log
/**
* A synchronized wrapper around a raw Socket that implements a framed protocol:
* [4 bytes length][N bytes payload]
*/
class SyncedSocket(
val rawSocket: Socket,
readTimeoutMs: Int = DEFAULT_READ_TIMEOUT_MS
) {
private val TAG = "SyncedSocket"
private val writeLock = ReentrantLock()
private val readLock = ReentrantLock()
private val inputStream: DataInputStream
private val outputStream: DataOutputStream
companion object {
// Both peers exchange keep-alive frames every ~2s while connected, so a read that
// stalls well beyond that means the link is dead (half-open). Time out so the read
// loop can detect it and trigger disconnection instead of blocking forever.
const val DEFAULT_READ_TIMEOUT_MS = 15_000
}
init {
// A read timeout converts dead/half-open connections into a SocketTimeoutException
// (an IOException) so read() returns null and the peer is cleaned up.
try { rawSocket.soTimeout = readTimeoutMs } catch (_: Exception) {}
// We wrap streams to create DataInput/Output helpers
inputStream = DataInputStream(rawSocket.getInputStream())
outputStream = DataOutputStream(rawSocket.getOutputStream())
}
/**
* Writes a framed message to the socket.
* Thread-safe.
*/
fun write(data: ByteArray) {
writeLock.withLock {
Log.v(TAG, "Writing frame of size: ${data.size}")
outputStream.writeInt(data.size)
if (data.isNotEmpty()) {
outputStream.write(data)
}
outputStream.flush()
}
}
/**
* Reads a framed message from the socket.
* Blocks until a full frame is available.
* Returns null if socket is closed or EOF.
* Returns empty byte array for keep-alive (0 length frame).
*/
fun read(): ByteArray? {
readLock.withLock {
try {
// Read length prefix
val length = try {
inputStream.readInt()
} catch (e: java.io.EOFException) {
return null
}
Log.v(TAG, "Reading frame of size: $length")
if (length < 0) throw IOException("Negative frame length: $length")
if (length > 64 * 1024) throw IOException("Frame length exceeds 64KB limit: $length")
if (length == 0) {
return ByteArray(0)
}
val buf = ByteArray(length)
inputStream.readFully(buf)
return buf
} catch (e: IOException) {
Log.e(TAG, "Socket read failed: ${e.message}")
// Socket closed or error
return null
}
}
}
fun close() {
try { rawSocket.close() } catch (_: Exception) {}
}
fun isClosed() = rawSocket.isClosed
fun isConnected() = rawSocket.isConnected
val inetAddress get() = rawSocket.inetAddress
}
@@ -0,0 +1,239 @@
package com.bitchat.android.wifiaware
import android.net.ConnectivityManager
import android.util.Log
import com.bitchat.android.mesh.MeshConnectionTracker
import kotlinx.coroutines.CoroutineScope
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.ConcurrentHashMap
/**
* Tracks Wi-Fi Aware connections and manages retry logic using the shared state machine.
*/
class WifiAwareConnectionTracker(
scope: CoroutineScope,
private val cm: ConnectivityManager
) : MeshConnectionTracker(scope, TAG) {
companion object {
private const val TAG = "WifiAwareConnectionTracker"
}
// Active resources per peer
val peerSockets = ConcurrentHashMap<String, SyncedSocket>()
private val socketAliases = ConcurrentHashMap<String, String>()
val serverSockets = ConcurrentHashMap<String, ServerSocket>()
val networkCallbacks = ConcurrentHashMap<String, ConnectivityManager.NetworkCallback>()
override fun isConnected(id: String): Boolean {
// We consider it connected if we have a client socket to them
return getSocketForPeer(id) != null
}
override fun disconnect(id: String) {
Log.d(TAG, "Disconnecting peer $id")
val canonicalId = resolveCanonicalPeerId(id)
// 1. Close client socket
peerSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") }
}
socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId }
// 2. Close server socket
serverSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") }
}
// Ensure any pending/active network request is explicitly released
releaseNetworkRequest(canonicalId)
removePendingConnection(id)
removePendingConnection(canonicalId)
}
fun releaseNetworkRequest(id: String) {
val canonicalId = resolveCanonicalPeerId(id)
if (!networkCallbacks.containsKey(canonicalId)) return
// 3. Unregister network callback properly from ConnectivityManager
networkCallbacks.remove(canonicalId)?.let {
try {
Log.d(TAG, "Unregistering network callback for $canonicalId")
cm.unregisterNetworkCallback(it)
} catch (e: Exception) { Log.w(TAG, "Error unregistering callback for $canonicalId: ${e.message}") }
}
}
override fun getConnectionCount(): Int = peerSockets.size
/**
* Successfully established a client connection
*/
fun onClientConnected(peerId: String, socket: SyncedSocket) {
// Close previous socket if one exists to prevent zombie readers
peerSockets[peerId]?.let {
try { it.close() } catch (_: Exception) {}
}
peerSockets[peerId] = socket
removePendingConnection(peerId) // Clear retry state on success
}
fun getSocketForPeer(peerId: String): SyncedSocket? {
val canonicalId = resolveCanonicalPeerId(peerId)
return peerSockets[canonicalId]
}
fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId)
fun rebindPeerId(previousPeerId: String, resolvedPeerId: String, socket: SyncedSocket): String {
if (previousPeerId == resolvedPeerId) {
peerSockets[resolvedPeerId] = socket
return resolvedPeerId
}
val previousCanonical = resolveCanonicalPeerId(previousPeerId)
val existing = peerSockets[previousCanonical]
if (existing === socket) {
peerSockets.remove(previousCanonical)
}
peerSockets[resolvedPeerId]?.let { current ->
if (current !== socket) {
try { current.close() } catch (_: Exception) { }
}
}
peerSockets[resolvedPeerId] = socket
serverSockets.remove(previousCanonical)?.let { serverSockets[resolvedPeerId] = it }
networkCallbacks.remove(previousCanonical)?.let { networkCallbacks[resolvedPeerId] = it }
socketAliases[previousPeerId] = resolvedPeerId
if (previousCanonical != previousPeerId) {
socketAliases[previousCanonical] = resolvedPeerId
}
removePendingConnection(previousPeerId)
removePendingConnection(resolvedPeerId)
Log.i(TAG, "Rebound Wi-Fi Aware socket ${previousPeerId.take(8)} -> ${resolvedPeerId.take(8)}")
return resolvedPeerId
}
private fun resolveCanonicalPeerId(peerId: String): String {
var current = peerId
val visited = mutableSetOf<String>()
while (visited.add(current)) {
val next = socketAliases[current] ?: return current
current = next
}
return current
}
fun addServerSocket(peerId: String, socket: ServerSocket) {
val canonicalId = resolveCanonicalPeerId(peerId)
serverSockets.put(canonicalId, socket)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing replaced server socket for $peerId: ${e.message}") }
}
}
fun hasOpenServerSocket(peerId: String): Boolean {
val canonicalId = resolveCanonicalPeerId(peerId)
val socket = serverSockets[canonicalId] ?: return false
if (!socket.isClosed) return true
serverSockets.remove(canonicalId)
return false
}
fun closeServerSocket(peerId: String) {
val canonicalId = resolveCanonicalPeerId(peerId)
serverSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $peerId: ${e.message}") }
}
}
fun hasPendingDataPathRequest(exceptPeerId: String? = null): Boolean {
val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) }
return pendingDataPathPeerIds(exceptCanonical).isNotEmpty()
}
fun pendingDataPathPeerIds(exceptPeerId: String? = null): Set<String> {
val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) }
val pendingIds = linkedSetOf<String>()
pendingConnections.keys.forEach { peerId ->
val canonicalId = resolveCanonicalPeerId(peerId)
if (canonicalId != exceptCanonical && !isConnected(canonicalId)) {
pendingIds.add(canonicalId)
}
}
networkCallbacks.keys.forEach { peerId ->
val canonicalId = resolveCanonicalPeerId(peerId)
if (canonicalId != exceptCanonical && !isConnected(canonicalId)) {
pendingIds.add(canonicalId)
}
}
return pendingIds
}
fun pendingServerDataPathPeerIds(exceptPeerId: String? = null): Set<String> {
val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) }
return serverSockets.keys.map { resolveCanonicalPeerId(it) }
.filter { peerId ->
peerId != exceptCanonical &&
!isConnected(peerId) &&
networkCallbacks.containsKey(peerId) &&
serverSockets[peerId]?.isClosed == false
}
.toSet()
}
fun cancelPendingServerDataPaths(exceptPeerId: String? = null): Set<String> {
val cancelled = pendingServerDataPathPeerIds(exceptPeerId)
cancelled.forEach { disconnect(it) }
return cancelled
}
fun addNetworkCallback(peerId: String, callback: ConnectivityManager.NetworkCallback) {
val canonicalId = resolveCanonicalPeerId(peerId)
networkCallbacks.put(canonicalId, callback)?.let {
try {
Log.d(TAG, "Replacing network callback for $canonicalId")
cm.unregisterNetworkCallback(it)
} catch (e: Exception) {
Log.w(TAG, "Error unregistering replaced callback for $canonicalId: ${e.message}")
}
}
}
/**
* Clean up all resources
*/
override fun stop() {
super.stop()
val allIds = peerSockets.keys + serverSockets.keys + networkCallbacks.keys
allIds.toSet().forEach { disconnect(it) }
}
fun getDebugInfo(): String {
return buildString {
appendLine("Aware Connections: ${getConnectionCount()}")
peerSockets.keys.forEach { pid ->
appendLine(" - $pid (Socket)")
}
if (socketAliases.isNotEmpty()) {
appendLine("Socket aliases:")
socketAliases.forEach { (alias, canonical) ->
appendLine(" - $alias -> $canonical")
}
}
appendLine("Server Sockets: ${serverSockets.size}")
serverSockets.keys.forEach { pid ->
appendLine(" - $pid (Listening)")
}
appendLine("Pending Attempts: ${pendingConnections.size}")
pendingConnections.forEach { (pid, attempt) ->
appendLine(" - $pid: ${attempt.attempts} attempts")
}
}
}
}
@@ -0,0 +1,305 @@
package com.bitchat.android.wifiaware
import android.content.Context
import android.os.Build
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/**
* WifiAwareController manages lifecycle and debug surfacing for the WifiAwareMeshService.
* It starts/stops the service based on debug preferences and exposes simple flows for UI.
*/
object WifiAwareController {
private const val TAG = "WifiAwareController"
private const val MAX_RESTART_ATTEMPTS = 15
private const val RESTART_RETRY_DELAY_MS = 2_000L
private var service: WifiAwareMeshService? = null
private var appContext: Context? = null
private val lifecycleLock = Any()
private var starting = false
private val restartInFlight = AtomicBoolean(false)
private var awareReceiverRegistered = false
private var lastBlockedReason: String? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _enabled = MutableStateFlow(false)
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
private val _supported = MutableStateFlow(false)
val supported: StateFlow<Boolean> = _supported.asStateFlow()
private val _available = MutableStateFlow(false)
val available: StateFlow<Boolean> = _available.asStateFlow()
private val _supportStatus = MutableStateFlow<WifiAwareSupport.Status?>(null)
val supportStatus: StateFlow<WifiAwareSupport.Status?> = _supportStatus.asStateFlow()
private val _running = MutableStateFlow(false)
val running: StateFlow<Boolean> = _running.asStateFlow()
// Simple debug surfacing
private val _connectedPeers = MutableStateFlow<Map<String, String>>(emptyMap()) // peerID -> ip
val connectedPeers: StateFlow<Map<String, String>> = _connectedPeers.asStateFlow()
private val _knownPeers = MutableStateFlow<Map<String, String>>(emptyMap()) // peerID -> nickname
val knownPeers: StateFlow<Map<String, String>> = _knownPeers.asStateFlow()
private val _discoveredPeers = MutableStateFlow<Set<String>>(emptySet())
val discoveredPeers: StateFlow<Set<String>> = _discoveredPeers.asStateFlow()
fun initialize(context: Context, enabledByDefault: Boolean) {
appContext = context.applicationContext
val status = refreshSupportStatus(appContext!!)
if (status.supported) {
registerAwareStateReceiver(appContext!!)
} else {
Log.i(TAG, "Wi-Fi Aware unsupported: ${status.reason}")
}
setEnabled(enabledByDefault)
// Start background poller for debug surfacing
scope.launch {
while (isActive) {
try {
val s = service
if (s != null) {
_connectedPeers.value = s.getDeviceAddressToPeerMapping() // peerID -> ip
_knownPeers.value = s.getPeerNicknames()
_discoveredPeers.value = s.getDiscoveredPeerIds()
} else {
_connectedPeers.value = emptyMap()
_knownPeers.value = emptyMap()
_discoveredPeers.value = emptySet()
}
} catch (_: Exception) { }
delay(1000)
}
}
}
fun setEnabled(value: Boolean) {
_enabled.value = value
if (value) startIfPossible() else stop()
}
fun startIfPossible() {
val reusableService = synchronized(lifecycleLock) {
if (!_enabled.value) return
val existing = service
if (existing?.isRunning() == true) {
_running.value = true
return
}
if (starting) return
starting = true
existing
}
val ctx = appContext ?: run {
synchronized(lifecycleLock) { starting = false }
return
}
val status = refreshSupportStatus(ctx)
if (!status.supported) {
val reason = status.reason ?: "not supported"
Log.w(TAG, "WiFi Aware unsupported; not starting ($reason)")
addBlockedDebugMessage("unsupported:$reason", "Wi-Fi Aware not supported on this device ($reason)")
synchronized(lifecycleLock) { starting = false }
return
}
val awareManager = WifiAwareSupport.getManager(ctx)
if (awareManager == null || !status.available) {
Log.w(TAG, "Wi-Fi Aware is not currently available; not starting")
addBlockedDebugMessage("unavailable", "Wi-Fi Aware is not available right now")
synchronized(lifecycleLock) { starting = false }
return
}
// Check system location setting: WifiAwareManager.attach() throws SecurityException if disabled
val lm = ctx.getSystemService(Context.LOCATION_SERVICE) as? android.location.LocationManager
val locationEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
lm?.isLocationEnabled == true
} else {
@Suppress("DEPRECATION")
lm?.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER) == true ||
lm?.isProviderEnabled(android.location.LocationManager.NETWORK_PROVIDER) == true
}
if (!locationEnabled) {
Log.w(TAG, "Location services are disabled; Wi-Fi Aware cannot start.")
addBlockedDebugMessage("location-disabled", "Enable Location Services to start Wi-Fi Aware")
synchronized(lifecycleLock) { starting = false }
return
}
// Android 13+: require NEARBY_WIFI_DEVICES runtime permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val granted = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.NEARBY_WIFI_DEVICES) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!granted) {
Log.w(TAG, "Missing NEARBY_WIFI_DEVICES permission; not starting WiFi Aware")
addBlockedDebugMessage("missing-nearby-wifi", "Grant Nearby Wi-Fi Devices to start Wi-Fi Aware")
synchronized(lifecycleLock) { starting = false }
return
}
}
if (!_enabled.value) {
synchronized(lifecycleLock) { starting = false }
return
}
try {
val startedService = reusableService ?: run {
Log.i(TAG, "Instantiating WifiAwareMeshService...")
WifiAwareMeshService(ctx)
}
startedService.startServices()
if (startedService.isRunning()) {
synchronized(lifecycleLock) {
service = startedService
_running.value = true
}
try { com.bitchat.android.service.MeshServiceHolder.unifiedMeshService?.refreshDelegates() } catch (_: Exception) { }
clearBlockedDebugMessage()
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware started")) } catch (_: Exception) {}
} else {
if (reusableService == null) {
try { startedService.stopServices() } catch (_: Exception) { }
}
synchronized(lifecycleLock) {
if (service === startedService) service = null
_running.value = false
}
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware did not start")) } catch (_: Exception) {}
}
} catch (e: Throwable) {
Log.e(TAG, "Failed to start WifiAwareMeshService", e)
_running.value = false
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware failed to start: ${e.message}")) } catch (_: Exception) {}
} finally {
synchronized(lifecycleLock) { starting = false }
}
}
fun stop() {
val stopped = synchronized(lifecycleLock) {
val current = service
service = null
starting = false
_running.value = false
current
}
try { stopped?.stopServices() } catch (_: Exception) { }
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { }
_connectedPeers.value = emptyMap()
_knownPeers.value = emptyMap()
_discoveredPeers.value = emptySet()
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware stopped")) } catch (_: Exception) {}
}
internal fun onServiceStopped(stoppedService: WifiAwareMeshService) {
synchronized(lifecycleLock) {
if (service !== stoppedService) return
service = null
_running.value = false
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { }
_connectedPeers.value = emptyMap()
_knownPeers.value = emptyMap()
_discoveredPeers.value = emptySet()
}
}
/**
* Schedules a restart of the Wi-Fi Aware transport. Concurrent requests are coalesced into
* a single in-flight loop that retries with backoff. This is important because a single fixed
* delay can land while the service is still tearing down (recoveryInProgress), in which case
* startServices() defers and we must try again rather than give up.
*/
internal fun restartIfStillEnabled(delayMs: Long = 0L) {
if (!restartInFlight.compareAndSet(false, true)) {
Log.d(TAG, "Restart already in flight; coalescing request")
return
}
scope.launch {
try {
if (delayMs > 0L) delay(delayMs)
var attempt = 0
while (_enabled.value && !_running.value && attempt < MAX_RESTART_ATTEMPTS) {
val ctx = appContext
if (ctx != null && !refreshSupportStatus(ctx).supported) break
startIfPossible()
if (_running.value) break
attempt++
delay(RESTART_RETRY_DELAY_MS)
}
} finally {
restartInFlight.set(false)
}
}
}
/**
* Listens for system Wi-Fi Aware availability changes. Aware can flip off/on at runtime
* (Wi-Fi toggling, hotspot/SoftAP, location changes); without this we would only recover on
* an unrelated trigger.
*/
private fun registerAwareStateReceiver(ctx: Context) {
if (awareReceiverRegistered) return
if (!refreshSupportStatus(ctx).supported) return
try {
val filter = android.content.IntentFilter(
android.net.wifi.aware.WifiAwareManager.ACTION_WIFI_AWARE_STATE_CHANGED
)
ctx.registerReceiver(object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, intent: android.content.Intent?) {
val status = refreshSupportStatus(ctx)
Log.i(TAG, "Wi-Fi Aware availability changed: supported=${status.supported} available=${status.available} enabled=${_enabled.value} running=${_running.value}")
if (status.available) {
if (_enabled.value) restartIfStillEnabled(500)
} else if (_running.value) {
// Aware went away; tear down cleanly so we can re-attach when it returns.
// Note: this does not change the enabled preference.
stop()
}
}
}, filter)
awareReceiverRegistered = true
} catch (e: Exception) {
Log.w(TAG, "Failed to register Wi-Fi Aware state receiver: ${e.message}")
}
}
private fun refreshSupportStatus(ctx: Context): WifiAwareSupport.Status {
val status = WifiAwareSupport.evaluate(ctx)
_supported.value = status.supported
_available.value = status.available
_supportStatus.value = status
return status
}
private fun addBlockedDebugMessage(key: String, message: String) {
if (lastBlockedReason == key) return
lastBlockedReason = key
try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
.addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage(message))
} catch (_: Exception) { }
}
private fun clearBlockedDebugMessage() {
lastBlockedReason = null
}
fun getService(): WifiAwareMeshService? = service
}
@@ -0,0 +1,3 @@
package com.bitchat.android.wifiaware
typealias WifiAwareMeshDelegate = com.bitchat.android.mesh.MeshDelegate
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
package com.bitchat.android.wifiaware
import android.content.Context
import android.content.pm.PackageManager
import android.net.wifi.aware.WifiAwareManager
import android.os.Build
/**
* Centralized Wi-Fi Aware capability checks.
*
* "Supported" is stable device/API capability. "Available" is runtime state and can change
* when Wi-Fi, location, airplane mode, or system radio state changes.
*/
object WifiAwareSupport {
data class Status(
val supported: Boolean,
val available: Boolean,
val reason: String? = null
)
fun evaluate(context: Context): Status {
val appContext = context.applicationContext
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return Status(
supported = false,
available = false,
reason = "requires Android 10+"
)
}
val hasFeature = try {
appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_AWARE)
} catch (_: Exception) {
false
}
if (!hasFeature) {
return Status(
supported = false,
available = false,
reason = "device does not advertise Wi-Fi Aware support"
)
}
val manager = getManager(appContext)
?: return Status(
supported = false,
available = false,
reason = "WifiAwareManager unavailable"
)
val available = try {
manager.isAvailable
} catch (_: Exception) {
false
}
return Status(
supported = true,
available = available,
reason = if (available) null else "Wi-Fi Aware temporarily unavailable"
)
}
fun isSupported(context: Context): Boolean = evaluate(context).supported
fun getManager(context: Context): WifiAwareManager? {
return try {
context.applicationContext.getSystemService(WifiAwareManager::class.java)
} catch (_: Exception) {
null
}
}
}
+1
View File
@@ -362,6 +362,7 @@
<string name="location_notes_empty_desc">كن أول من يضيف ملاحظة لهذا المكان.</string>
<string name="dismiss">إغلاق</string>
<string name="location_notes_input_placeholder">أضف ملاحظة لهذا المكان</string>
<string name="bluetooth_recommended">بلوتوث موصى به</string>
<string name="mesh_service_notification_content">الشبكة تعمل — %1$d أقران</string>
<string name="verify_title">verify</string>
+1
View File
@@ -349,6 +349,7 @@
<item quantity="other">%d জন</item>
</plurals>
<string name="bluetooth_recommended">ব্লুটুথ প্রস্তাবিত</string>
<string name="mesh_service_notification_content">মেশ চলছে — %1$d পিয়ার</string>
<string name="verify_title">verify</string>
+1
View File
@@ -363,6 +363,7 @@
<string name="location_notes_empty_desc">sei der Erste, der hier eine Notiz hinterlässt.</string>
<string name="dismiss">schließen</string>
<string name="location_notes_input_placeholder">füge eine Notiz zu diesem Ort hinzu</string>
<string name="bluetooth_recommended">Bluetooth empfohlen</string>
<string name="mesh_service_notification_content">Mesh läuft — %1$d Peers</string>
<string name="verify_title">verifizieren</string>
+1
View File
@@ -362,6 +362,7 @@
<string name="location_notes_empty_desc">sé el primero en añadir una para este sitio.</string>
<string name="dismiss">cerrar</string>
<string name="location_notes_input_placeholder">agrega una nota para este lugar</string>
<string name="bluetooth_recommended">Bluetooth recomendado</string>
<string name="mesh_service_notification_content">Mesh en ejecución — %1$d pares</string>
<string name="verify_title">verificar</string>
+1
View File
@@ -349,6 +349,7 @@
<item quantity="other">%d نفر</item>
</plurals>
<string name="bluetooth_recommended">بلوتوث توصیه می شود</string>
<string name="mesh_service_notification_content">مش در حال اجرا — %1$d همتا</string>
<string name="verify_title">verify</string>
+1
View File
@@ -362,6 +362,7 @@
<item quantity="other">%d tao</item>
</plurals>
<string name="bluetooth_recommended">Bluetooth Recommended</string>
<string name="mesh_service_notification_content">Tumatakbo ang Mesh — %1$d na peer</string>
<string name="verify_title">verify</string>
+1
View File
@@ -376,6 +376,7 @@
<string name="location_notes_empty_desc">soyez le premier à en ajouter pour cet endroit.</string>
<string name="dismiss">fermer</string>
<string name="location_notes_input_placeholder">ajoutez une note pour cet endroit</string>
<string name="bluetooth_recommended">Bluetooth recommandé</string>
<string name="mesh_service_notification_content">Mesh actif — %1$d pairs</string>
<string name="verify_title">vérifier</string>
+2
View File
@@ -14,6 +14,8 @@
<string name="location_notes_empty_desc">היה הראשון להוסיף הערה למקום זה.</string>
<string name="dismiss">סגור</string>
<string name="location_notes_input_placeholder">הוסף הערה למקום זה</string>
<string name="skip">Skip</string>
<string name="bluetooth_recommended">Bluetooth Recommended</string>
<string name="mesh_service_notification_content">רשת Mesh פועלת — %1$d עמיתים</string>
<string name="verify_title">verify</string>
+1
View File
@@ -362,6 +362,7 @@
<string name="location_notes_empty_desc">इस स्थान के लिए पहला नोट जोड़ें।</string>
<string name="dismiss">बंद करें</string>
<string name="location_notes_input_placeholder">इस स्थान के लिए एक नोट जोड़ें</string>
<string name="bluetooth_recommended">ब्लूटूथ अनुशंसित</string>
<string name="mesh_service_notification_content">मेश चल रहा है — %1$d पीयर्स</string>
<string name="verify_title">verify</string>
+1
View File
@@ -362,6 +362,7 @@
<string name="location_notes_empty_desc">jadilah yang pertama menambahkan catatan untuk tempat ini.</string>
<string name="dismiss">tutup</string>
<string name="location_notes_input_placeholder">tambahkan catatan untuk tempat ini</string>
<string name="bluetooth_recommended">Bluetooth Recommended</string>
<string name="mesh_service_notification_content">Mesh berjalan — %1$d peer</string>
<string name="verify_title">verify</string>
+1
View File
@@ -396,6 +396,7 @@
<string name="location_notes_empty_desc">sii il primo ad aggiungerne una per questo posto.</string>
<string name="dismiss">chiudi</string>
<string name="location_notes_input_placeholder">aggiungi una nota per questo luogo</string>
<string name="bluetooth_recommended">Bluetooth consigliato</string>
<string name="mesh_service_notification_content">Mesh in esecuzione — %1$d peer</string>
<string name="verify_title">verifica</string>
+1
View File
@@ -362,6 +362,7 @@
<string name="location_notes_empty_desc">この場所の最初のメモを追加しましょう。</string>
<string name="dismiss">閉じる</string>
<string name="location_notes_input_placeholder">この場所へのメモを追加</string>
<string name="bluetooth_recommended">Bluetooth推奨</string>
<string name="mesh_service_notification_content">メッシュ実行中 — %1$d ピア</string>
<string name="verify_title">検証</string>
+1
View File
@@ -349,6 +349,7 @@
<item quantity="other">%d ადამიანი</item>
</plurals>
<string name="bluetooth_recommended">Bluetooth Recommended</string>
<string name="mesh_service_notification_content">Mesh გაშვებულია — %1$d პირები</string>
<string name="verify_title">verify</string>
+1
View File
@@ -362,6 +362,7 @@
<string name="location_notes_empty_desc">이 장소에 첫 번째 노트를 추가해 보세요.</string>
<string name="dismiss">닫기</string>
<string name="location_notes_input_placeholder">이 장소에 노트 추가</string>
<string name="bluetooth_recommended">블루투스 권장</string>
<string name="mesh_service_notification_content">메시 실행 중 — %1$d 피어</string>
<string name="verify_title">verify</string>
+1
View File
@@ -376,6 +376,7 @@
<item quantity="other">Olona %d</item>
</plurals>
<string name="bluetooth_recommended">Bluetooth Recommended</string>
<string name="mesh_service_notification_content">Mandeha ny Mesh — %1$d peers</string>
<string name="verify_title">verify</string>
+2
View File
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- TODO: Malay translations -->
<string name="skip">Skip</string>
<string name="bluetooth_recommended">Bluetooth Recommended</string>
<string name="mesh_service_notification_content">Mesh sedang berjalan — %1$d rakan</string>
<string name="verify_title">verify</string>
+1
View File
@@ -362,6 +362,7 @@
<item quantity="other">%d जना</item>
</plurals>
<string name="bluetooth_recommended">ब्लुटुथ सिफारिस गरिएको</string>
<string name="mesh_service_notification_content">मेश चलिरहेको छ — %1$d पियर्स</string>
<string name="verify_title">verify</string>
+1
View File
@@ -394,6 +394,7 @@
<string name="location_notes_empty_desc">wees de eerste die een notitie voor deze plek toevoegt.</string>
<string name="dismiss">sluiten</string>
<string name="location_notes_input_placeholder">voeg een notitie toe voor deze plek</string>
<string name="bluetooth_recommended">Bluetooth aanbevolen</string>
<string name="mesh_service_notification_content">Mesh actief — %1$d peers</string>
<string name="verify_title">verify</string>
@@ -349,6 +349,7 @@
<item quantity="other">%d بندے</item>
</plurals>
<string name="bluetooth_recommended">Bluetooth Recommended</string>
<string name="mesh_service_notification_content">میش چل رہا ہے — %1$d ساتھی</string>
<string name="verify_title">verify</string>
+2
View File
@@ -14,6 +14,8 @@
<string name="location_notes_empty_desc">bądź pierwszy, który doda notatkę dla tego miejsca.</string>
<string name="dismiss">zamknij</string>
<string name="location_notes_input_placeholder">dodaj notatkę dla tego miejsca</string>
<string name="skip">Pomiń</string>
<string name="bluetooth_recommended">Bluetooth zalecany</string>
<string name="mesh_service_notification_content">Mesh działa — %1$d peerów</string>
<string name="verify_title">verify</string>
@@ -362,6 +362,7 @@
<string name="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string>
<string name="dismiss">fechar</string>
<string name="location_notes_input_placeholder">adicione uma nota para este local</string>
<string name="bluetooth_recommended">Bluetooth recomendado</string>
<string name="mesh_service_notification_content">Mesh rodando — %1$d pares</string>
<string name="verify_title">verificar</string>
+1
View File
@@ -362,6 +362,7 @@
<string name="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string>
<string name="dismiss">fechar</string>
<string name="location_notes_input_placeholder">adicione uma nota para este local</string>
<string name="bluetooth_recommended">Bluetooth recomendado</string>
<string name="mesh_service_notification_content">Mesh em execução — %1$d pares</string>
<string name="verify_title">verificar</string>
+1
View File
@@ -352,6 +352,7 @@
<string name="location_notes_empty_desc">станьте первым, кто добавит заметку для этого места.</string>
<string name="dismiss">закрыть</string>
<string name="location_notes_input_placeholder">добавьте заметку для этого места</string>
<string name="bluetooth_recommended">Рекомендуется Bluetooth</string>
<string name="mesh_service_notification_content">Mesh запущен — %1$d пиров</string>
<string name="verify_title">проверить</string>
+1
View File
@@ -350,6 +350,7 @@
<string name="location_notes_empty_desc">var först med att lägga till en anteckning för den här platsen.</string>
<string name="dismiss">stäng</string>
<string name="location_notes_input_placeholder">lägg till en anteckning för den här platsen</string>
<string name="bluetooth_recommended">Bluetooth rekommenderas</string>
<string name="mesh_service_notification_content">Mesh körs — %1$d peers</string>
<string name="verify_title">verify</string>
+2
View File
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- TODO: Tamil translations -->
<string name="skip">Skip</string>
<string name="bluetooth_recommended">Bluetooth Recommended</string>
<string name="mesh_service_notification_content">மெஷ் இயங்குகிறது — %1$d பியர்ஸ்</string>
<string name="verify_title">verify</string>
+1
View File
@@ -349,6 +349,7 @@
<item quantity="other">%d คน</item>
</plurals>
<string name="bluetooth_recommended">Bluetooth Recommended</string>
<string name="mesh_service_notification_content">Mesh กำลังทำงาน — %1$d เพื่อน</string>
<string name="verify_title">verify</string>
+1
View File
@@ -350,6 +350,7 @@
<string name="location_notes_empty_desc">bu yer için ilk notu ekleyen siz olun.</string>
<string name="dismiss">kapat</string>
<string name="location_notes_input_placeholder">bu yer için bir not ekleyin</string>
<string name="bluetooth_recommended">Bluetooth Önerilir</string>
<string name="mesh_service_notification_content">Mesh çalışıyor — %1$d eş</string>
<string name="verify_title">verify</string>
+2
View File
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- TODO: Ukrainian translations -->
<string name="skip">Пропустити</string>
<string name="bluetooth_recommended">Рекомендується Bluetooth</string>
<string name="mesh_service_notification_content">Mesh працює — %1$d пірів</string>
<string name="verify_title">verify</string>
+1
View File
@@ -362,6 +362,7 @@
<string name="location_notes_empty_desc">اس جگہ کے لیے پہلا نوٹ شامل کریں۔</string>
<string name="dismiss">بند کریں</string>
<string name="location_notes_input_placeholder">اس جگہ کے لیے ایک نوٹ شامل کریں</string>
<string name="bluetooth_recommended">Bluetooth Recommended</string>
<string name="mesh_service_notification_content">میش چل رہا ہے — %1$d ساتھی</string>
<string name="verify_title">verify</string>
+1
View File
@@ -349,6 +349,7 @@
<item quantity="other">%d người</item>
</plurals>
<string name="bluetooth_recommended">Khuyên dùng Bluetooth</string>
<string name="mesh_service_notification_content">Mesh đang chạy — %1$d ngang hàng</string>
<string name="verify_title">verify</string>
@@ -14,6 +14,8 @@
<string name="location_notes_empty_desc">成为第一个为此地点添加笔记的人。</string>
<string name="dismiss">关闭</string>
<string name="location_notes_input_placeholder">为此地点添加一条笔记</string>
<string name="skip">跳过</string>
<string name="bluetooth_recommended">建议开启蓝牙</string>
<string name="mesh_service_notification_content">Mesh 运行中 — %1$d 个节点</string>
<string name="verify_title">验证</string>
@@ -14,6 +14,8 @@
<string name="location_notes_empty_desc">成為第一個為此地點新增筆記的人。</string>
<string name="dismiss">關閉</string>
<string name="location_notes_input_placeholder">為此地點新增一則筆記</string>
<string name="skip">跳過</string>
<string name="bluetooth_recommended">建議開啟藍牙</string>
<string name="mesh_service_notification_content">Mesh 運行中 — %1$d 個節點</string>
<string name="verify_title">验证</string>
+1
View File
@@ -375,6 +375,7 @@
<string name="location_notes_empty_desc">成为第一个为此地点添加笔记的人。</string>
<string name="dismiss">关闭</string>
<string name="location_notes_input_placeholder">为此地点添加一条笔记</string>
<string name="bluetooth_recommended">建议开启蓝牙</string>
<string name="mesh_service_notification_content">Mesh 运行中 — %1$d 个节点</string>
<string name="verify_title">验证</string>
+1
View File
@@ -452,4 +452,5 @@
<item quantity="other">%d people</item>
</plurals>
<string name="bluetooth_recommended">Bluetooth Recommended</string>
</resources>
@@ -2,7 +2,7 @@ package com.bitchat.android.ui
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -34,7 +34,7 @@ class CommandProcessorTest() {
coroutineScope = testScope
)
private val meshService: BluetoothMeshService = mock()
private val meshService: MeshService = mock()
@Before
fun setup() {
@@ -0,0 +1,45 @@
package com.bitchat
import com.bitchat.android.mesh.MeshPacketUtils
import junit.framework.TestCase.assertEquals
import org.junit.Test
class MeshPacketUtilsTest {
@Test
fun hexStringToByteArray_parsesFullId() {
val bytes = MeshPacketUtils.hexStringToByteArray("0011223344556677")
assertEquals(8, bytes.size)
assertEquals(0x00.toByte(), bytes[0])
assertEquals(0x11.toByte(), bytes[1])
assertEquals(0x22.toByte(), bytes[2])
assertEquals(0x33.toByte(), bytes[3])
assertEquals(0x44.toByte(), bytes[4])
assertEquals(0x55.toByte(), bytes[5])
assertEquals(0x66.toByte(), bytes[6])
assertEquals(0x77.toByte(), bytes[7])
}
@Test
fun hexStringToByteArray_parsesShortId() {
val bytes = MeshPacketUtils.hexStringToByteArray("ab")
assertEquals(8, bytes.size)
assertEquals(0xab.toByte(), bytes[0])
assertEquals(0x00.toByte(), bytes[1])
}
@Test
fun hexStringToByteArray_handlesInvalidHex() {
val bytes = MeshPacketUtils.hexStringToByteArray("zz")
assertEquals(8, bytes.size)
assertEquals(0x00.toByte(), bytes[0])
}
@Test
fun sha256Hex_matchesKnownValue() {
val hash = MeshPacketUtils.sha256Hex("hello".toByteArray())
assertEquals(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
hash
)
}
}
@@ -0,0 +1,117 @@
package com.bitchat.android.mesh
import android.os.Build
import com.bitchat.android.model.IdentityAnnouncement
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.services.meshgraph.MeshGraphService
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.isNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class MessageHandlerTest {
private lateinit var handler: MessageHandler
private lateinit var delegate: MessageHandlerDelegate
private val myPeerID = "1111222233334444"
private val peerID = "aaaabbbbccccdddd"
private val nickname = "peer"
private val noiseKey = ByteArray(32) { 0x0B }
private val signingKey = ByteArray(32) { 0x0A }
private val signature = ByteArray(64) { 1 }
private val announceClockSkewToleranceMs = 10 * 60 * 1000L
@Before
fun setup() {
MeshGraphService.resetForTesting()
handler = MessageHandler(myPeerID, RuntimeEnvironment.getApplication())
delegate = mock()
handler.delegate = delegate
whenever(delegate.getPeerInfo(peerID)).thenReturn(null)
whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(true)
whenever(delegate.updatePeerInfo(any(), any(), any(), any(), any())).thenReturn(true)
}
@After
fun tearDown() {
MeshGraphService.resetForTesting()
}
@Test
fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() = runBlocking {
val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertTrue("Announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true))
verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull())
}
@Test
fun `handleAnnounce accepts future announce within clock skew tolerance`() = runBlocking {
val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000))
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertTrue("Future announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true))
Unit
}
@Test
fun `handleAnnounce rejects announce older than clock skew tolerance`() = runBlocking {
val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link"))
assertFalse("Announce older than clock skew tolerance should not store peer identity", result)
verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any())
verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any())
}
private fun announcePacket(
ageMs: Long,
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte()
): BitchatPacket {
val announcement = IdentityAnnouncement(
nickname = nickname,
noisePublicKey = noiseKey,
signingPublicKey = signingKey
)
return BitchatPacket(
version = 1u,
type = MessageType.ANNOUNCE.value,
senderID = peerID.hexToBytes(),
recipientID = SpecialRecipients.BROADCAST,
timestamp = (System.currentTimeMillis() - ageMs).toULong(),
payload = announcement.encode()!!,
signature = signature,
ttl = ttl
)
}
private fun String.hexToBytes(): ByteArray {
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
}
@@ -55,4 +55,45 @@ class AppStateStoreTest {
assertEquals(listOf(first, second), AppStateStore.publicMessages.value)
}
@Test
fun `peer list merges transport updates instead of overwriting`() {
AppStateStore.setTransportPeers("WIFI", listOf("wifi-peer"))
AppStateStore.setTransportPeers("BLE", emptyList())
assertEquals(listOf("wifi-peer"), AppStateStore.peers.value)
AppStateStore.setTransportPeers("BLE", listOf("ble-peer"))
assertEquals(listOf("wifi-peer", "ble-peer"), AppStateStore.peers.value)
}
@Test
fun `direct peers union across transports`() {
AppStateStore.setTransportDirectPeers("BLE", listOf("ble-1", "shared"))
AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1", "shared"))
assertEquals(
setOf("ble-1", "wifi-1", "shared"),
AppStateStore.getDirectPeers()
)
}
@Test
fun `clearing one transport keeps the other transport direct peers`() {
AppStateStore.setTransportDirectPeers("BLE", listOf("ble-1"))
AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1"))
AppStateStore.clearTransportDirectPeers("WIFI")
assertEquals(setOf("ble-1"), AppStateStore.getDirectPeers())
}
@Test
fun `latest direct peer set replaces previous set for same transport`() {
AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1", "wifi-2"))
AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-3"))
assertEquals(setOf("wifi-3"), AppStateStore.getDirectPeers())
}
}