From 3a983c57671eacf1ff9c5bcaba26f5c756ed6402 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:11:33 +0200 Subject: [PATCH] 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 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 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 Co-authored-by: aidenvalu3 Co-authored-by: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com> Co-authored-by: CC --- app/src/main/AndroidManifest.xml | 9 + .../com/bitchat/android/BitchatApplication.kt | 6 + .../java/com/bitchat/android/MainActivity.kt | 58 +- .../java/com/bitchat/android/MainViewModel.kt | 7 + .../mesh/BluetoothConnectionManager.kt | 96 +- .../mesh/BluetoothConnectionTracker.kt | 154 +- .../mesh/BluetoothGattClientManager.kt | 173 +- .../mesh/BluetoothGattServerManager.kt | 71 +- .../android/mesh/BluetoothMeshService.kt | 156 +- .../mesh/BluetoothPacketBroadcaster.kt | 137 +- .../android/mesh/FragmentingPacketSender.kt | 138 ++ .../android/mesh/MeshConnectionTracker.kt | 143 ++ .../java/com/bitchat/android/mesh/MeshCore.kt | 843 +++++++++ .../com/bitchat/android/mesh/MeshDelegate.kt | 19 + .../bitchat/android/mesh/MeshPacketUtils.kt | 37 + .../com/bitchat/android/mesh/MeshService.kt | 56 + .../com/bitchat/android/mesh/MeshTransport.kt | 23 + .../bitchat/android/mesh/MessageHandler.kt | 12 +- .../com/bitchat/android/mesh/PeerManager.kt | 38 +- .../android/mesh/UnifiedMeshService.kt | 386 ++++ .../android/noise/NoiseEncryptionService.kt | 3 +- .../com/bitchat/android/noise/NoiseSession.kt | 30 +- .../android/noise/NoiseSessionManager.kt | 53 +- .../onboarding/BluetoothCheckScreen.kt | 46 +- .../onboarding/OnboardingCoordinator.kt | 1 + .../onboarding/PermissionExplanationScreen.kt | 2 + .../android/onboarding/PermissionManager.kt | 36 + .../android/protocol/BinaryProtocol.kt | 4 + .../android/service/AppShutdownCoordinator.kt | 4 +- .../android/service/MeshForegroundService.kt | 36 +- .../android/service/MeshServiceHolder.kt | 85 + .../android/service/TransportBridgeService.kt | 184 ++ .../bitchat/android/services/AppStateStore.kt | 55 +- .../bitchat/android/services/MessageRouter.kt | 50 +- .../java/com/bitchat/android/ui/ChatHeader.kt | 2 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 2 +- .../com/bitchat/android/ui/ChatUIUtils.kt | 6 +- .../com/bitchat/android/ui/ChatViewModel.kt | 192 +- .../bitchat/android/ui/CommandProcessor.kt | 56 +- .../android/ui/LocationChannelsSheet.kt | 2 +- .../android/ui/MatrixEncryptionAnimation.kt | 6 +- .../bitchat/android/ui/MediaSendingManager.kt | 6 +- .../bitchat/android/ui/MeshDelegateHandler.kt | 201 ++- .../bitchat/android/ui/MeshPeerListSheet.kt | 74 +- .../bitchat/android/ui/MessageComponents.kt | 8 +- .../bitchat/android/ui/PrivateChatManager.kt | 27 +- .../android/ui/SecurityVerificationSheet.kt | 2 +- .../bitchat/android/ui/VerificationHandler.kt | 6 +- .../bitchat/android/ui/VerificationSheet.kt | 2 +- .../ui/debug/DebugPreferenceManager.kt | 27 +- .../android/ui/debug/DebugSettingsManager.kt | 49 + .../android/ui/debug/DebugSettingsSheet.kt | 148 +- .../com/bitchat/android/ui/debug/MeshGraph.kt | 97 + .../android/ui/media/AudioMessageItem.kt | 4 +- .../android/ui/media/ImageMessageItem.kt | 4 +- .../android/wifi-aware/SyncedSocket.kt | 99 + .../wifi-aware/WifiAwareConnectionTracker.kt | 239 +++ .../android/wifi-aware/WifiAwareController.kt | 305 ++++ .../wifi-aware/WifiAwareMeshDelegate.kt | 3 + .../wifi-aware/WifiAwareMeshService.kt | 1591 +++++++++++++++++ .../android/wifi-aware/WifiAwareSupport.kt | 74 + app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-bn/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fa/strings.xml | 1 + app/src/main/res/values-fil/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-he/strings.xml | 2 + app/src/main/res/values-hi/strings.xml | 1 + app/src/main/res/values-id/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ka/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-mg/strings.xml | 1 + app/src/main/res/values-ms/strings.xml | 2 + app/src/main/res/values-ne/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pa-rPK/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-sv/strings.xml | 1 + app/src/main/res/values-ta/strings.xml | 2 + app/src/main/res/values-th/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 2 + app/src/main/res/values-ur/strings.xml | 1 + app/src/main/res/values-vi/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values-zh-rTW/strings.xml | 2 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../android/ui/CommandProcessorTest.kt | 4 +- .../kotlin/com/bitchat/MeshPacketUtilsTest.kt | 45 + .../android/mesh/MessageHandlerTest.kt | 117 ++ .../android/services/AppStateStoreTest.kt | 41 + 99 files changed, 5982 insertions(+), 649 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshCore.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshService.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt create mode 100644 app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt create mode 100644 app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c957240..f83ecefb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -20,6 +21,12 @@ + + + + + + @@ -52,6 +59,8 @@ + + create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") - return ChatViewModel(application, meshService) as T + return ChatViewModel(application, meshService, unifiedMeshService) as T } } } @@ -114,6 +117,10 @@ class MainActivity : OrientationAwareActivity() { // Ensure foreground service is running and get mesh instance from holder try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { } meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext) + unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext) + // Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays - DEPRECATED + // Bridging is now handled by TransportBridgeService automatically + bluetoothStatusManager = BluetoothStatusManager( activity = this, context = this, @@ -164,6 +171,17 @@ class MainActivity : OrientationAwareActivity() { } } } + + // Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + WifiAwareController.running.collect { running -> + if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + unifiedMeshService.delegate = chatViewModel + } + } + } + } // Only start onboarding process if we're in the initial CHECKING state // This prevents restarting onboarding on configuration changes @@ -222,6 +240,10 @@ class MainActivity : OrientationAwareActivity() { onRetry = { checkBluetoothAndProceed() }, + onSkip = { + mainViewModel.skipBluetoothCheck() + checkLocationAndProceed() + }, isLoading = isBluetoothLoading ) } @@ -355,6 +377,13 @@ class MainActivity : OrientationAwareActivity() { private fun checkBluetoothAndProceed() { // Log.d("MainActivity", "Checking Bluetooth status") + // Check if user has skipped Bluetooth check for this session + if (mainViewModel.isBluetoothCheckSkipped.value) { + Log.d("MainActivity", "Bluetooth check skipped by user, proceeding to location check") + checkLocationAndProceed() + return + } + // For first-time users, skip Bluetooth check and go straight to permissions // We'll check Bluetooth after permissions are granted if (permissionManager.isFirstTimeLaunch()) { @@ -367,6 +396,12 @@ class MainActivity : OrientationAwareActivity() { bluetoothStatusManager.logBluetoothStatus() mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) + val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + if (!bleRequired) { + // Skip BLE checks entirely when BLE is disabled in debug settings + checkLocationAndProceed() + return + } when (mainViewModel.bluetoothStatus.value) { BluetoothStatus.ENABLED -> { // Bluetooth is enabled, check location services next @@ -472,6 +507,8 @@ class MainActivity : OrientationAwareActivity() { Log.d("MainActivity", "Location services enabled by user") mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationStatus(LocationStatus.ENABLED) + // Ensure Wi-Fi Aware starts now that location is enabled + com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() checkBatteryOptimizationAndProceed() } @@ -540,8 +577,9 @@ class MainActivity : OrientationAwareActivity() { else -> BatteryOptimizationStatus.ENABLED } + val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } when { - currentBluetoothStatus != BluetoothStatus.ENABLED -> { + bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> { // Bluetooth still disabled, but now we have permissions to enable it Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.") mainViewModel.updateBluetoothStatus(currentBluetoothStatus) @@ -674,9 +712,9 @@ class MainActivity : OrientationAwareActivity() { return@launch } - // Set up mesh service delegate and start services - meshService.delegate = chatViewModel - meshService.startServices() + // Set up unified mesh delegate and start enabled transports + unifiedMeshService.delegate = chatViewModel + unifiedMeshService.startServices() Log.d("MainActivity", "Mesh service started successfully") @@ -720,11 +758,12 @@ class MainActivity : OrientationAwareActivity() { // Check Bluetooth and Location status on resume and handle accordingly if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { // Reattach mesh delegate to new ChatViewModel instance after Activity recreation - try { meshService.delegate = chatViewModel } catch (_: Exception) { } + try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { } // Check if Bluetooth was disabled while app was backgrounded val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() - if (currentBluetoothStatus != BluetoothStatus.ENABLED) { + val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) { Log.w("MainActivity", "Bluetooth disabled while app was backgrounded") mainViewModel.updateBluetoothStatus(currentBluetoothStatus) mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) @@ -739,6 +778,9 @@ class MainActivity : OrientationAwareActivity() { mainViewModel.updateLocationStatus(currentLocationStatus) mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateLocationLoading(false) + } else { + // If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier + com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() } } } @@ -748,7 +790,7 @@ class MainActivity : OrientationAwareActivity() { // Only set background state if app is fully initialized if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { // Detach UI delegate so the foreground service can own DM notifications while UI is closed - try { meshService.delegate = null } catch (_: Exception) { } + try { unifiedMeshService.delegate = null } catch (_: Exception) { } } } diff --git a/app/src/main/java/com/bitchat/android/MainViewModel.kt b/app/src/main/java/com/bitchat/android/MainViewModel.kt index 35125d85..15ec6fda 100644 --- a/app/src/main/java/com/bitchat/android/MainViewModel.kt +++ b/app/src/main/java/com/bitchat/android/MainViewModel.kt @@ -35,6 +35,9 @@ class MainViewModel : ViewModel() { private val _isBatteryOptimizationLoading = MutableStateFlow(false) val isBatteryOptimizationLoading: StateFlow = _isBatteryOptimizationLoading.asStateFlow() + private val _isBluetoothCheckSkipped = MutableStateFlow(false) + val isBluetoothCheckSkipped: StateFlow = _isBluetoothCheckSkipped.asStateFlow() + // Public update functions for MainActivity fun updateOnboardingState(state: OnboardingState) { _onboardingState.value = state @@ -67,4 +70,8 @@ class MainViewModel : ViewModel() { fun updateBatteryOptimizationLoading(loading: Boolean) { _isBatteryOptimizationLoading.value = loading } + + fun skipBluetoothCheck() { + _isBluetoothCheckSkipped.value = true + } } \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index dce58031..328d361f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -88,22 +88,49 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isGattServerEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }) + } + + private fun isGattClientEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }) + } + init { powerManager.delegate = this // Observe debug settings to enforce role state while active try { val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + // Master transport enable/disable + connectionScope.launch { + dbg.bleEnabled.collect { enabled -> + if (enabled) return@collect + if (isActive) { + disableTransport() + } + } + } // Role enable/disable connectionScope.launch { dbg.gattServerEnabled.collect { enabled -> if (!isActive) return@collect - if (enabled) startServer() else stopServer() + if (enabled && isBleTransportEnabled()) startServer() else stopServer() } } connectionScope.launch { dbg.gattClientEnabled.collect { enabled -> if (!isActive) return@collect - if (enabled) startClient() else stopClient() + if (enabled && isBleTransportEnabled()) startClient() else stopClient() } } @@ -163,6 +190,12 @@ class BluetoothConnectionManager( */ fun startServices(): Boolean { Log.i(TAG, "Starting power-optimized Bluetooth services...") + + if (!isBleTransportEnabled()) { + Log.i(TAG, "BLE transport disabled by debug settings; not starting Bluetooth services") + disableTransport() + return false + } if (!permissionManager.hasBluetoothPermissions()) { Log.e(TAG, "Missing Bluetooth permissions") @@ -197,9 +230,8 @@ class BluetoothConnectionManager( powerManager.start() // Start server/client based on debug settings - val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null } - val startServer = dbg?.gattServerEnabled?.value != false - val startClient = dbg?.gattClientEnabled?.value != false + val startServer = isGattServerEnabled() + val startClient = isGattClientEnabled() if (startServer) { if (!serverManager.start()) { @@ -234,6 +266,19 @@ class BluetoothConnectionManager( return false } } + + /** + * Disable BLE without cancelling this manager's coroutine scope, so it can be re-enabled. + */ + fun disableTransport() { + Log.i(TAG, "Disabling BLE transport") + isActive = false + connectionScope.launch { + clientManager.stop() + serverManager.stop() + connectionTracker.stop() + } + } /** * Stop all Bluetooth services with proper cleanup @@ -279,7 +324,7 @@ class BluetoothConnectionManager( * Automatically fragments large packets to fit within BLE MTU limits */ fun broadcastPacket(routed: RoutedPacket) { - if (!isActive) return + if (!isActive || !isBleTransportEnabled()) return packetBroadcaster.broadcastPacket( routed, @@ -289,7 +334,7 @@ class BluetoothConnectionManager( } fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { - if (!isActive) return false + if (!isActive || !isBleTransportEnabled()) return false return packetBroadcaster.sendToPeer( peerID, routed, @@ -306,7 +351,7 @@ class BluetoothConnectionManager( * Send a packet directly to a specific peer, without broadcasting to others. */ fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { - if (!isActive) return false + if (!isActive || !isBleTransportEnabled()) return false return packetBroadcaster.sendPacketToPeer( RoutedPacket(packet), peerID, @@ -317,9 +362,15 @@ class BluetoothConnectionManager( // Expose role controls for debug UI - fun startServer() { connectionScope.launch { serverManager.start() } } + fun startServer() { + if (!isActive || !isBleTransportEnabled()) return + connectionScope.launch { if (isGattServerEnabled()) serverManager.start() } + } fun stopServer() { connectionScope.launch { serverManager.stop() } } - fun startClient() { connectionScope.launch { clientManager.start() } } + fun startClient() { + if (!isActive || !isBleTransportEnabled()) return + connectionScope.launch { if (isGattClientEnabled()) clientManager.start() } + } fun stopClient() { connectionScope.launch { clientManager.stop() } } // Inject nickname resolver for broadcaster logs @@ -347,7 +398,10 @@ class BluetoothConnectionManager( /** * Public: connect/disconnect helpers for debug UI */ - fun connectToAddress(address: String): Boolean = clientManager.connectToAddress(address) + fun connectToAddress(address: String): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return clientManager.connectToAddress(address) + } fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) } @@ -358,10 +412,10 @@ class BluetoothConnectionManager( clientManager.stop() serverManager.stop() delay(200) - if (isActive) { + if (isActive && isBleTransportEnabled()) { // Restart managers if service is active - serverManager.start() - clientManager.start() + if (isGattServerEnabled()) serverManager.start() + if (isGattClientEnabled()) clientManager.start() } } } @@ -396,11 +450,17 @@ class BluetoothConnectionManager( Log.i(TAG, "Power mode changed to: $newMode") connectionScope.launch { + if (!isActive || !isBleTransportEnabled()) { + serverManager.stop() + clientManager.stop() + return@launch + } + // Avoid rapid scan restarts by checking if we need to change scan behavior val wasUsingDutyCycle = powerManager.shouldUseDutyCycle() // Update advertising with new power settings if server enabled - val serverEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val serverEnabled = isGattServerEnabled() if (serverEnabled) { serverManager.restartAdvertising() } else { @@ -411,7 +471,7 @@ class BluetoothConnectionManager( val nowUsingDutyCycle = powerManager.shouldUseDutyCycle() if (wasUsingDutyCycle != nowUsingDutyCycle) { Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan") - val clientEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val clientEnabled = isGattClientEnabled() if (clientEnabled) { clientManager.restartScanning() } else { @@ -427,6 +487,10 @@ class BluetoothConnectionManager( } override fun onScanStateChanged(shouldScan: Boolean) { + if (!isActive || !isBleTransportEnabled()) { + clientManager.onScanStateChanged(false) + return + } clientManager.onScanStateChanged(shouldScan) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index 681185d5..f139b9d3 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -16,29 +16,22 @@ import java.util.concurrent.CopyOnWriteArrayList class BluetoothConnectionTracker( private val connectionScope: CoroutineScope, private val powerManager: PowerManager -) { +) : MeshConnectionTracker(connectionScope, TAG) { companion object { private const val TAG = "BluetoothConnectionTracker" - private const val CONNECTION_RETRY_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_RETRY_DELAY_MS - private const val MAX_CONNECTION_ATTEMPTS = com.bitchat.android.util.AppConstants.Mesh.MAX_CONNECTION_ATTEMPTS private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_DELAY_MS - private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_INTERVAL_MS // 30 seconds } // Connection tracking - reduced memory footprint private val connectedDevices = ConcurrentHashMap() private val subscribedDevices = CopyOnWriteArrayList() val addressPeerMap = ConcurrentHashMap() + // Track whether we have seen the first ANNOUNCE on a given device connection + private val firstAnnounceSeen = ConcurrentHashMap() // RSSI tracking from scan results (for devices we discover but may connect as servers) private val scanRSSI = ConcurrentHashMap() - // Connection attempt tracking with automatic cleanup - private val pendingConnections = ConcurrentHashMap() - - // State management - private var isActive = false - /** * Consolidated device connection information */ @@ -52,37 +45,28 @@ class BluetoothConnectionTracker( val peerID: String? = null ) - /** - * Connection attempt tracking with automatic expiry - */ - data class ConnectionAttempt( - val attempts: Int, - val lastAttempt: Long = System.currentTimeMillis() - ) { - fun isExpired(): Boolean = - System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2 - - fun shouldRetry(): Boolean = - attempts < MAX_CONNECTION_ATTEMPTS && - System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY + override fun start() { + super.start() } - /** - * Start the connection tracker - */ - fun start() { - isActive = true - startPeriodicCleanup() - } - - /** - * Stop the connection tracker - */ - fun stop() { - isActive = false + override fun stop() { + super.stop() cleanupAllConnections() clearAllConnections() } + + // Abstract implementations + override fun isConnected(id: String): Boolean = connectedDevices.containsKey(id) + + override fun disconnect(id: String) { + connectedDevices[id]?.gatt?.let { + try { it.disconnect() } catch (_: Exception) { } + } + cleanupDeviceConnection(id) + Log.d(TAG, "Requested disconnect for $id") + } + + override fun getConnectionCount(): Int = connectedDevices.size /** * Add a device connection @@ -90,7 +74,9 @@ class BluetoothConnectionTracker( fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") connectedDevices[deviceAddress] = deviceConn - pendingConnections.remove(deviceAddress) + removePendingConnection(deviceAddress) + // Mark as awaiting first ANNOUNCE on this connection + firstAnnounceSeen[deviceAddress] = false } /** @@ -163,9 +149,7 @@ class BluetoothConnectionTracker( /** * Check if device is already connected */ - fun isDeviceConnected(deviceAddress: String): Boolean { - return connectedDevices.containsKey(deviceAddress) - } + fun isDeviceConnected(deviceAddress: String): Boolean = isConnected(deviceAddress) /** * Check if a peer is already connected (by PeerID) @@ -175,63 +159,15 @@ class BluetoothConnectionTracker( return connectedDevices.values.any { it.peerID == peerID } } - /** - * Check if connection attempt is allowed - */ - fun isConnectionAttemptAllowed(deviceAddress: String): Boolean { - val existingAttempt = pendingConnections[deviceAddress] - return existingAttempt?.let { - it.isExpired() || it.shouldRetry() - } ?: true - } - - /** - * Add a pending connection attempt - */ - fun addPendingConnection(deviceAddress: String): Boolean { - Log.d(TAG, "Tracker: Adding pending connection for $deviceAddress") - synchronized(pendingConnections) { - // Double-check inside synchronized block - val currentAttempt = pendingConnections[deviceAddress] - if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) { - Log.d(TAG, "Tracker: Connection attempt already in progress for $deviceAddress") - return false - } - if (currentAttempt != null) { - Log.d(TAG, "Tracker: current attempt: $currentAttempt") - } - - // Update connection attempt atomically - // If the previous attempt window expired, reset backoff to 1; otherwise increment - val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1 - pendingConnections[deviceAddress] = ConnectionAttempt(attempts) - Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)") - return true - } - } - /** * Disconnect a specific device (by MAC address) */ - fun disconnectDevice(deviceAddress: String) { - connectedDevices[deviceAddress]?.gatt?.let { - try { it.disconnect() } catch (_: Exception) { } - } - cleanupDeviceConnection(deviceAddress) - Log.d(TAG, "Requested disconnect for $deviceAddress") - } - - /** - * Remove a pending connection - */ - fun removePendingConnection(deviceAddress: String) { - pendingConnections.remove(deviceAddress) - } + fun disconnectDevice(deviceAddress: String) = disconnect(deviceAddress) /** * Get connected device count */ - fun getConnectedDeviceCount(): Int = connectedDevices.size + fun getConnectedDeviceCount(): Int = getConnectionCount() /** * Check if connection limit is reached @@ -301,6 +237,7 @@ class BluetoothConnectionTracker( subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) } + firstAnnounceSeen.remove(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") } @@ -334,36 +271,21 @@ class BluetoothConnectionTracker( addressPeerMap.clear() pendingConnections.clear() scanRSSI.clear() + firstAnnounceSeen.clear() } /** - * Start periodic cleanup of expired connections + * Mark that we have received the first ANNOUNCE over this device connection. */ - private fun startPeriodicCleanup() { - connectionScope.launch { - while (isActive) { - delay(CLEANUP_INTERVAL) - - if (!isActive) break - - try { - // Clean up expired pending connections - val expiredConnections = pendingConnections.filter { it.value.isExpired() } - expiredConnections.keys.forEach { pendingConnections.remove(it) } - - // Log cleanup if any - if (expiredConnections.isNotEmpty()) { - Log.d(TAG, "Cleaned up ${expiredConnections.size} expired connection attempts") - } - - // Log current state - Log.d(TAG, "Periodic cleanup: ${connectedDevices.size} connections, ${pendingConnections.size} pending") - - } catch (e: Exception) { - Log.w(TAG, "Error in periodic cleanup: ${e.message}") - } - } - } + fun noteAnnounceReceived(deviceAddress: String) { + firstAnnounceSeen[deviceAddress] = true + } + + /** + * Check whether the first ANNOUNCE has been seen for a device connection. + */ + fun hasSeenFirstAnnounce(deviceAddress: String): Boolean { + return firstAnnounceSeen[deviceAddress] == true } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 2bb22fce..3885a523 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -32,6 +32,11 @@ class BluetoothGattClientManager( companion object { private const val TAG = "BluetoothGattClientManager" + // Self-healing scan recovery tuning + private const val SCAN_RETRY_BASE_MS = 3_000L // base backoff for transient scan failures + private const val SCAN_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay + private const val SCAN_WATCHDOG_INTERVAL_MS = 30_000L // how often to verify the scanner is alive + private const val SCAN_STALE_RESULT_MS = 120_000L // force a scan restart if no results for this long } // Core Bluetooth components @@ -39,11 +44,28 @@ class BluetoothGattClientManager( context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner + + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isClientRoleEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }) + } /** * Public: Connect to a device by MAC address (for debug UI) */ fun connectToAddress(deviceAddress: String): Boolean { + if (!isClientRoleEnabled()) { + Log.i(TAG, "connectToAddress skipped: BLE client disabled") + return false + } val device = bluetoothAdapter?.getRemoteDevice(deviceAddress) return if (device != null) { val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50 @@ -61,8 +83,16 @@ class BluetoothGattClientManager( // Scan rate limiting to prevent "scanning too frequently" errors private var lastScanStartTime = 0L private var lastScanStopTime = 0L - private var isCurrentlyScanning = false + @Volatile private var isCurrentlyScanning = false private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts + + // Self-healing scan state. + // scanningDesired distinguishes "we want to be scanning but it isn't running" (a fault to recover + // from) from "scanning is intentionally off" (e.g. duty-cycle OFF window or client disabled). + @Volatile private var scanningDesired = false + @Volatile private var lastScanResultTime = 0L + private var scanRetryCount = 0 + private var scanWatchdogJob: Job? = null // RSSI monitoring state private var rssiMonitoringJob: Job? = null @@ -75,12 +105,10 @@ class BluetoothGattClientManager( */ fun start(): Boolean { // Respect debug setting - try { - if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value) { - Log.i(TAG, "Client start skipped: GATT Client disabled in debug settings") - return false - } - } catch (_: Exception) { } + if (!isClientRoleEnabled()) { + Log.i(TAG, "Client start skipped: BLE/GATT Client disabled in debug settings") + return false + } if (isActive) { Log.d(TAG, "GATT client already active; start is a no-op") @@ -106,12 +134,16 @@ class BluetoothGattClientManager( connectionScope.launch { if (powerManager.shouldUseDutyCycle()) { Log.i(TAG, "Using power-aware duty cycling") + // Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that. } else { + scanningDesired = true startScanning() } // Start RSSI monitoring startRSSIMonitoring() + // Start the scan watchdog so a silently-dead or wedged scanner self-heals. + startScanWatchdog() } return true @@ -121,6 +153,8 @@ class BluetoothGattClientManager( * Stop client manager */ fun stop() { + scanningDesired = false + stopScanWatchdog() if (!isActive) { // Idempotent stop stopScanning() @@ -150,7 +184,8 @@ class BluetoothGattClientManager( * Handle scan state changes from power manager */ fun onScanStateChanged(shouldScan: Boolean) { - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = isClientRoleEnabled() + scanningDesired = shouldScan && enabled if (shouldScan && enabled) { startScanning() } else { @@ -199,7 +234,7 @@ class BluetoothGattClientManager( @Suppress("DEPRECATION") private fun startScanning() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = isClientRoleEnabled() if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return // Rate limit scan starts to prevent "scanning too frequently" errors @@ -217,7 +252,7 @@ class BluetoothGattClientManager( // Schedule delayed scan start connectionScope.launch { delay(remainingWait) - if (isActive && !isCurrentlyScanning) { + if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) { startScanning() } } @@ -251,22 +286,39 @@ class BluetoothGattClientManager( lastScanStopTime = System.currentTimeMillis() when (errorCode) { - 1 -> Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED") - 2 -> Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED") - 3 -> Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR") - 4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") - 5 -> Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES") + 1 -> { + // Already started: the stack thinks a scan is running. Re-arm from a clean + // state so we don't stay wedged (stop then restart with backoff). + Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED") + stopScanning() + scheduleScanRestart("already-started", SCAN_RETRY_BASE_MS) + } + 2 -> { + // App registration failed: common transient stack fault. Previously had NO + // retry, which left discovery dead until a manual BLE toggle. + Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED") + scheduleScanRestart("registration-failed", SCAN_RETRY_BASE_MS) + } + 3 -> { + Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR") + scheduleScanRestart("internal-error", SCAN_RETRY_BASE_MS) + } + 4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") // permanent: don't retry + 5 -> { + // Out of hardware resources: back off longer so other scanners/connections + // can free up before we try again. + Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES") + scheduleScanRestart("out-of-resources", SCAN_RETRY_BASE_MS * 3) + } 6 -> { Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY") Log.w(TAG, "Scan failed due to rate limiting - will retry after delay") - connectionScope.launch { - delay(10000) // Wait 10 seconds before retrying - if (isActive) { - startScanning() - } - } + scheduleScanRestart("too-frequently", 10_000L) + } + else -> { + Log.e(TAG, "Unknown scan failure code: $errorCode") + scheduleScanRestart("unknown-$errorCode", SCAN_RETRY_BASE_MS) } - else -> Log.e(TAG, "Unknown scan failure code: $errorCode") } } } @@ -304,6 +356,76 @@ class BluetoothGattClientManager( lastScanStopTime = System.currentTimeMillis() } } + + /** + * Schedule a scan restart with incremental backoff. Used to recover from transient scan + * failures that previously had no retry path (codes 2/3/5), leaving discovery dead until a + * manual BLE toggle. + */ + private fun scheduleScanRestart(reason: String, baseDelayMs: Long) { + scanRetryCount++ + val delayMs = (baseDelayMs * scanRetryCount).coerceAtMost(SCAN_MAX_RETRY_DELAY_MS) + Log.w(TAG, "Scheduling scan restart in ${delayMs}ms (attempt $scanRetryCount, reason=$reason)") + connectionScope.launch { + delay(delayMs) + if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) { + startScanning() + } + } + } + + /** + * Periodic watchdog that self-heals the scanner. Android can stop a scan without ever invoking + * onScanFailed (internal stack reset, Doze, background throttling), which leaves the app + * believing it is scanning while it is not. This re-arms the scanner in those cases. + */ + private fun startScanWatchdog() { + scanWatchdogJob?.cancel() + scanWatchdogJob = connectionScope.launch { + while (isActive) { + delay(SCAN_WATCHDOG_INTERVAL_MS) + try { + // Only act when we are supposed to be scanning. Honors duty-cycle OFF windows + // and the client-disabled state via scanningDesired. + if (!isActive || !scanningDesired || !isClientRoleEnabled()) continue + if (!permissionManager.hasBluetoothPermissions() || bluetoothAdapter?.isEnabled != true) continue + + val now = System.currentTimeMillis() + if (!isCurrentlyScanning) { + Log.w(TAG, "Watchdog: scan desired but not running -> restarting scan") + startScanning() + } else if (lastScanResultTime > 0L && + now - lastScanResultTime > SCAN_STALE_RESULT_MS && + now - lastScanStartTime > SCAN_STALE_RESULT_MS) { + // We think we're scanning but haven't seen anything for a long time. The scan + // may have silently died (flag wedged true). Force a clean re-arm. + Log.w(TAG, "Watchdog: no scan results for ${(now - lastScanResultTime) / 1000}s -> forcing scan restart") + forceRestartScan() + } + } catch (e: Exception) { + Log.w(TAG, "Scan watchdog error: ${e.message}") + } + } + } + } + + private fun stopScanWatchdog() { + scanWatchdogJob?.cancel() + scanWatchdogJob = null + } + + /** + * Force a clean scan restart, clearing a possibly-wedged isCurrentlyScanning flag. + */ + private fun forceRestartScan() { + stopScanning() + connectionScope.launch { + delay(500) + if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) { + startScanning() + } + } + } /** * Handle scan result and initiate connection if appropriate @@ -320,6 +442,10 @@ class BluetoothGattClientManager( return } + // Proof the scanner is alive and finding our network: refresh liveness and clear backoff. + lastScanResultTime = System.currentTimeMillis() + scanRetryCount = 0 + // Try to extract peerID from Service Data (if available) for stable identity val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID)) val peerID = if (serviceData != null && serviceData.size >= 8) { @@ -402,6 +528,7 @@ class BluetoothGattClientManager( */ @Suppress("DEPRECATION") private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) { + if (!isClientRoleEnabled()) return if (!permissionManager.hasBluetoothPermissions()) return val deviceAddress = device.address @@ -562,7 +689,7 @@ class BluetoothGattClientManager( */ fun restartScanning() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = isClientRoleEnabled() if (!isActive || !enabled) return connectionScope.launch { diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index ad7c9cf1..0c7aabc3 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -30,6 +30,9 @@ class BluetoothGattServerManager( companion object { private const val TAG = "BluetoothGattServerManager" + // Self-healing advertising recovery tuning + private const val ADVERTISE_RETRY_BASE_MS = 3_000L // base backoff for transient advertise failures + private const val ADVERTISE_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay } // Core Bluetooth components @@ -42,10 +45,24 @@ class BluetoothGattServerManager( private var gattServer: BluetoothGattServer? = null private var characteristic: BluetoothGattCharacteristic? = null private var advertiseCallback: AdvertiseCallback? = null + private var advertiseRetryCount = 0 // State management private var isActive = false + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isServerRoleEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }) + } + /** * Disconnect a specific device (used by ConnectionManager to enforce overall limits) */ @@ -62,12 +79,10 @@ class BluetoothGattServerManager( */ fun start(): Boolean { // Respect debug setting - try { - if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value) { - Log.i(TAG, "Server start skipped: GATT Server disabled in debug settings") - return false - } - } catch (_: Exception) { } + if (!isServerRoleEnabled()) { + Log.i(TAG, "Server start skipped: BLE/GATT Server disabled in debug settings") + return false + } if (isActive) { Log.d(TAG, "GATT server already active; start is a no-op") @@ -322,7 +337,7 @@ class BluetoothGattServerManager( @Suppress("DEPRECATION") private fun startAdvertising() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val enabled = isServerRoleEnabled() // Guard conditions – never throw here to avoid crashing the app from a background coroutine if (!permissionManager.hasBluetoothPermissions()) { @@ -374,6 +389,7 @@ class BluetoothGattServerManager( advertiseCallback = object : AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { + advertiseRetryCount = 0 val mode = try { powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0] } catch (_: Exception) { "unknown" } @@ -382,6 +398,28 @@ class BluetoothGattServerManager( override fun onStartFailure(errorCode: Int) { Log.e(TAG, "Advertising failed: $errorCode") + // Previously this only logged, so if advertising failed this device became + // undiscoverable until a manual BLE toggle. Retry transient failures with backoff. + when (errorCode) { + ADVERTISE_FAILED_ALREADY_STARTED -> + Log.w(TAG, "ADVERTISE_FAILED_ALREADY_STARTED - already advertising, no retry") + ADVERTISE_FAILED_DATA_TOO_LARGE -> + Log.e(TAG, "ADVERTISE_FAILED_DATA_TOO_LARGE - config issue, not retrying") + ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> + Log.e(TAG, "ADVERTISE_FAILED_FEATURE_UNSUPPORTED - unsupported, not retrying") + ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> { + Log.w(TAG, "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS - will retry after backoff") + scheduleAdvertiseRestart("too-many-advertisers") + } + ADVERTISE_FAILED_INTERNAL_ERROR -> { + Log.w(TAG, "ADVERTISE_FAILED_INTERNAL_ERROR - will retry after backoff") + scheduleAdvertiseRestart("internal-error") + } + else -> { + Log.w(TAG, "Unknown advertise failure $errorCode - will retry after backoff") + scheduleAdvertiseRestart("unknown-$errorCode") + } + } } } @@ -407,12 +445,29 @@ class BluetoothGattServerManager( } } + /** + * Schedule an advertising restart with incremental backoff after a transient failure. + */ + private fun scheduleAdvertiseRestart(reason: String) { + advertiseRetryCount++ + val delayMs = (ADVERTISE_RETRY_BASE_MS * advertiseRetryCount).coerceAtMost(ADVERTISE_MAX_RETRY_DELAY_MS) + Log.w(TAG, "Scheduling advertising restart in ${delayMs}ms (attempt $advertiseRetryCount, reason=$reason)") + connectionScope.launch { + delay(delayMs) + if (isActive && isServerRoleEnabled()) { + stopAdvertising() + delay(100) + startAdvertising() + } + } + } + /** * Restart advertising (for power mode changes) */ fun restartAdvertising() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val enabled = isServerRoleEnabled() if (!isActive || !enabled) { stopAdvertising() return diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 474c04a5..c6c10473 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -16,6 +16,7 @@ import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString import com.bitchat.android.services.VerificationService +import com.bitchat.android.service.TransportBridgeService import kotlinx.coroutines.* import java.util.* import kotlin.math.sign @@ -34,7 +35,7 @@ import kotlin.random.Random * - BluetoothConnectionManager: BLE connections and GATT operations * - PacketProcessor: Incoming packet routing */ -class BluetoothMeshService(private val context: Context) { +class BluetoothMeshService(private val context: Context) : TransportBridgeService.TransportLayer { private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { @@ -70,6 +71,7 @@ class BluetoothMeshService(private val context: Context) { // Coroutines private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var announceJob: Job? = null // Tracks whether this instance has been terminated via stopServices() private var terminated = false @@ -99,17 +101,11 @@ class BluetoothMeshService(private val context: Context) { } ) - // Wire sync manager delegate - gossipSyncManager.delegate = object : GossipSyncManager.Delegate { - override fun sendPacket(packet: BitchatPacket) { - connectionManager.broadcastPacket(RoutedPacket(packet)) - } - override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { - connectionManager.sendPacketToPeer(peerID, packet) - } - override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { - return signPacketBeforeBroadcast(packet) - } + com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet -> + signPacketBeforeBroadcast(packet) + } + if (isBleTransportEnabled()) { + TransportBridgeService.register("BLE", this) } // Inject dynamic direct connection check into PeerManager @@ -120,6 +116,30 @@ class BluetoothMeshService(private val context: Context) { Log.d(TAG, "Delegates set up; GossipSyncManager initialized") } + + override fun send(packet: RoutedPacket) { + if (!isBleTransportEnabled()) return + connectionManager.broadcastPacket(packet) + } + + override fun sendToPeer(peerID: String, packet: BitchatPacket) { + if (!isBleTransportEnabled()) return + connectionManager.sendPacketToPeer(peerID, packet) + } + + private fun broadcastRoutedPacket(routed: RoutedPacket) { + if (!isBleTransportEnabled()) return + connectionManager.broadcastPacket(routed) + TransportBridgeService.broadcast("BLE", routed) + } + + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } /** * Start periodic debug logging every 10 seconds @@ -146,7 +166,8 @@ class BluetoothMeshService(private val context: Context) { * Send broadcast announcement every 30 seconds */ private fun sendPeriodicBroadcastAnnounce() { - serviceScope.launch { + announceJob?.cancel() + announceJob = serviceScope.launch { Log.d(TAG, "Starting periodic announce loop") while (isActive) { try { @@ -175,7 +196,7 @@ class BluetoothMeshService(private val context: Context) { peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { // Update process-wide state first - try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { } // Then notify UI delegate if attached delegate?.didUpdatePeerList(peerIDs) } @@ -221,7 +242,7 @@ class BluetoothMeshService(private val context: Context) { ) // Sign the handshake response val signedPacket = signPacketBeforeBroadcast(responsePacket) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)") } @@ -241,7 +262,7 @@ class BluetoothMeshService(private val context: Context) { } override fun sendPacket(packet: BitchatPacket) { - connectionManager.broadcastPacket(RoutedPacket(packet)) + broadcastRoutedPacket(RoutedPacket(packet)) } } @@ -284,11 +305,11 @@ class BluetoothMeshService(private val context: Context) { override fun sendPacket(packet: BitchatPacket) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) } override fun relayPacket(routed: RoutedPacket) { - connectionManager.broadcastPacket(routed) + broadcastRoutedPacket(routed) } override fun getBroadcastRecipient(): ByteArray { @@ -335,7 +356,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the handshake packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)") } else { Log.w(TAG, "Failed to generate Noise handshake data for $peerID") @@ -538,11 +559,13 @@ class BluetoothMeshService(private val context: Context) { } override fun relayPacket(routed: RoutedPacket) { - connectionManager.broadcastPacket(routed) + broadcastRoutedPacket(routed) } override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { - return connectionManager.sendToPeer(peerID, routed) + val sentOverBle = connectionManager.sendToPeer(peerID, routed) + TransportBridgeService.sendToPeer("BLE", peerID, routed.packet) + return sentOverBle } override fun handleRequestSync(routed: RoutedPacket) { @@ -625,6 +648,15 @@ class BluetoothMeshService(private val context: Context) { Log.w(TAG, "Mesh service already active, ignoring duplicate start request") return } + if (!isBleTransportEnabled()) { + Log.i(TAG, "BLE transport disabled by debug settings; not starting mesh service") + connectionManager.disableTransport() + TransportBridgeService.unregister("BLE") + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } + return + } if (terminated) { // This instance's scope was cancelled previously; refuse to start to avoid using dead scopes. Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting") @@ -635,17 +667,42 @@ class BluetoothMeshService(private val context: Context) { if (connectionManager.startServices()) { isActive = true + TransportBridgeService.register("BLE", this) // Start periodic announcements for peer discovery and connectivity sendPeriodicBroadcastAnnounce() Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)") // Start periodic syncs - gossipSyncManager.start() + com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE") Log.d(TAG, "GossipSyncManager started") } else { Log.e(TAG, "Failed to start Bluetooth services") } } + + /** + * Apply the debug master transport toggle without destroying this mesh instance. + */ + fun setBleTransportEnabled(enabled: Boolean) { + if (enabled) { + startServices() + } else { + pauseServicesForTransportDisable() + } + } + + private fun pauseServicesForTransportDisable() { + Log.i(TAG, "Disabling BLE mesh transport") + isActive = false + announceJob?.cancel() + announceJob = null + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") + TransportBridgeService.unregister("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } + connectionManager.disableTransport() + try { peerManager.refreshPeerList() } catch (_: Exception) { } + } /** * Stop all mesh services @@ -658,6 +715,11 @@ class BluetoothMeshService(private val context: Context) { Log.i(TAG, "Stopping Bluetooth mesh service") isActive = false + announceJob?.cancel() + announceJob = null + TransportBridgeService.unregister("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } // Send leave announcement sendLeaveAnnouncement() @@ -667,7 +729,7 @@ class BluetoothMeshService(private val context: Context) { delay(200) // Give leave message time to send // Stop all components - gossipSyncManager.stop() + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") Log.d(TAG, "GossipSyncManager stopped") connectionManager.stopServices() Log.d(TAG, "BluetoothConnectionManager stop requested") @@ -717,7 +779,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) // Track our own broadcast message for sync try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } } @@ -749,7 +811,7 @@ class BluetoothMeshService(private val context: Context) { val signed = signPacketBeforeBroadcast(packet) // Use a stable transferId based on the file TLV payload for progress tracking val transferId = sha256Hex(payload) - connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) + broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId)) try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } } } catch (e: Exception) { @@ -793,7 +855,7 @@ class BluetoothMeshService(private val context: Context) { // Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!) val packet = BitchatPacket( - version = 1u, + version = if (encrypted.size > 0xFFFF) 2u else 1u, type = MessageType.NOISE_ENCRYPTED.value, senderID = hexStringToByteArray(myPeerID), recipientID = hexStringToByteArray(recipientPeerID), @@ -807,7 +869,7 @@ class BluetoothMeshService(private val context: Context) { val signed = signPacketBeforeBroadcast(packet) // Use a stable transferId based on the unencrypted file TLV payload for progress tracking val transferId = sha256Hex(filePayload) - connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) + broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId)) Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID") } catch (e: Exception) { @@ -887,7 +949,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)") // FIXED: Don't send didReceiveMessage for our own sent messages @@ -958,7 +1020,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") // Persist as read after successful send @@ -1006,7 +1068,7 @@ class BluetoothMeshService(private val context: Context) { ) val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)") } catch (e: Exception) { Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}") @@ -1070,7 +1132,7 @@ class BluetoothMeshService(private val context: Context) { announcePacket.copy(signature = signature) } ?: announcePacket - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)") // Track announce for sync try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } @@ -1133,7 +1195,7 @@ class BluetoothMeshService(private val context: Context) { packet.copy(signature = signature) } ?: packet - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) peerManager.markPeerAsAnnouncedTo(peerID) Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)") @@ -1148,8 +1210,14 @@ class BluetoothMeshService(private val context: Context) { return try { // Prefer verified peers that are currently marked as direct val verified = peerManager.getVerifiedPeers() - val direct = verified.filter { it.value.isDirectConnection }.keys.toList() - direct.take(10) + val direct = verified.filter { it.value.isDirectConnection }.keys.toSet() + // Publish this transport's direct peers and gossip the cross-transport union so a + // node connected via multiple transports advertises a complete neighbor list. + try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers("BLE", direct) } catch (_: Exception) { } + val union = try { + com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { direct } + } catch (_: Exception) { direct } + union.distinct().take(10) } catch (_: Exception) { emptyList() } @@ -1168,7 +1236,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) } /** @@ -1415,18 +1483,10 @@ class BluetoothMeshService(private val context: Context) { } /** - * Delegate interface for mesh service callbacks (maintains exact same interface) + * Delegate interface for BLE mesh callbacks. Extends the shared mesh delegate so + * transport-agnostic facades can receive the same callback stream. */ -interface BluetoothMeshDelegate { - fun didReceiveMessage(message: BitchatMessage) - fun didUpdatePeerList(peers: List) - fun didReceiveChannelLeave(channel: String, fromPeer: String) - fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) - fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) - fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) - fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) - fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? - fun getNickname(): String? - fun isFavorite(peerID: String): Boolean - // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager +interface BluetoothMeshDelegate : MeshDelegate { + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 37af030d..efb3c4df 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -18,8 +18,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.Job -import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.channels.actor /** @@ -117,7 +115,7 @@ class BluetoothPacketBroadcaster( // Actor scope for the broadcaster private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val transferJobs = ConcurrentHashMap() + private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG) // SERIALIZATION: Actor to serialize all broadcast operations @OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class) @@ -139,71 +137,14 @@ class BluetoothPacketBroadcaster( gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? ) { - val packet = routed.packet - val isFile = packet.type == MessageType.FILE_TRANSFER.value - if (isFile) { - Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes") - } - // Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER - val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null) - // Check if we need to fragment - if (fragmentManager != null) { - val fragments = try { - fragmentManager.createFragments(packet) - } catch (e: Exception) { - Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e) - if (isFile) { - Log.e(TAG, "❌ File fragmentation failed for ${packet.payload.size} byte file") - } - return - } - if (fragments.size > 1) { - if (isFile) { - Log.d(TAG, "🔀 File needs ${fragments.size} fragments") - } - Log.d(TAG, "Fragmenting packet into ${fragments.size} fragments") - if (transferId != null) { - TransferProgressManager.start(transferId, fragments.size) - } - val job = connectionScope.launch { - var sent = 0 - fragments.forEach { fragment -> - if (!isActive) return@launch - // If cancelled, stop sending remaining fragments - if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch - broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic) - // 20ms delay between fragments - delay(20) - if (transferId != null) { - sent += 1 - TransferProgressManager.progress(transferId, sent, fragments.size) - if (sent == fragments.size) TransferProgressManager.complete(transferId, fragments.size) - } - } - } - if (transferId != null) { - transferJobs[transferId] = job - job.invokeOnCompletion { transferJobs.remove(transferId) } - } - return - } - } - - // Send single packet if no fragmentation needed - if (transferId != null) { - TransferProgressManager.start(transferId, 1) - } - broadcastSinglePacket(routed, gattServer, characteristic) - if (transferId != null) { - TransferProgressManager.progress(transferId, 1, 1) - TransferProgressManager.complete(transferId, 1) + fragmentingSender.send(routed, "BLE broadcast") { packet -> + broadcastSinglePacket(packet, gattServer, characteristic) + true } } fun cancelTransfer(transferId: String): Boolean { - val job = transferJobs.remove(transferId) ?: return false - job.cancel() - return true + return fragmentingSender.cancelTransfer(transferId) } /** @@ -215,6 +156,18 @@ class BluetoothPacketBroadcaster( targetPeerID: String, gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? + ): Boolean { + if (!hasPeerConnection(targetPeerID)) return false + return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet -> + sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic) + } + } + + private fun sendSinglePacketToPeer( + routed: RoutedPacket, + targetPeerID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? ): Boolean { val packet = routed.packet val data = packet.toBinaryData() ?: return false @@ -222,11 +175,6 @@ class BluetoothPacketBroadcaster( if (isFile) { Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes") } - // Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER - val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null) - if (transferId != null) { - TransferProgressManager.start(transferId, 1) - } val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() val senderPeerID = routed.peerID ?: packet.senderID.toHexString() val incomingAddr = routed.relayAddress @@ -241,10 +189,6 @@ class BluetoothPacketBroadcaster( if (serverTarget != null) { if (notifyDevice(serverTarget, data, gattServer, characteristic)) { logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo) - if (transferId != null) { - TransferProgressManager.progress(transferId, 1, 1) - TransferProgressManager.complete(transferId, 1) - } return true } } @@ -255,10 +199,6 @@ class BluetoothPacketBroadcaster( if (clientTarget != null) { if (writeToDeviceConn(clientTarget, data)) { logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo) - if (transferId != null) { - TransferProgressManager.progress(transferId, 1, 1) - TransferProgressManager.complete(transferId, 1) - } return true } } @@ -266,12 +206,6 @@ class BluetoothPacketBroadcaster( return false } - private fun sha256Hex(bytes: ByteArray): String = try { - val md = java.security.MessageDigest.getInstance("SHA-256") - md.update(bytes) - md.digest().joinToString("") { "%02x".format(it) } - } catch (_: Exception) { bytes.size.toString(16) } - /** * Public entry point for broadcasting - submits request to actor for serialization @@ -303,34 +237,19 @@ class BluetoothPacketBroadcaster( gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? ): Boolean { - val packet = routed.packet - val data = packet.toBinaryData() ?: return false - val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() - val senderPeerID = routed.peerID ?: packet.senderID.toHexString() - val incomingAddr = routed.relayAddress - val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] } - val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) } - - // Try server-side connections first - val targetDevice = connectionTracker.getSubscribedDevices() - .firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID } - if (targetDevice != null) { - if (notifyDevice(targetDevice, data, gattServer, characteristic)) { - logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetDevice.address, packet.ttl) - return true - } + if (!hasPeerConnection(targetPeerID)) return false + return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet -> + sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic) } + } - // Try client-side connections next - val targetConn = connectionTracker.getConnectedDevices().values - .firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID } - if (targetConn != null) { - if (writeToDeviceConn(targetConn, data)) { - logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetConn.device.address, packet.ttl) - return true - } - } - return false + private fun hasPeerConnection(targetPeerID: String): Boolean { + val hasServerTarget = connectionTracker.getSubscribedDevices() + .any { connectionTracker.addressPeerMap[it.address] == targetPeerID } + if (hasServerTarget) return true + + return connectionTracker.getConnectedDevices().values + .any { connectionTracker.addressPeerMap[it.device.address] == targetPeerID } } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt new file mode 100644 index 00000000..115254dc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt @@ -0,0 +1,138 @@ +package com.bitchat.android.mesh + +import android.util.Log +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +/** + * Shared transport send wrapper that applies bitchat packet fragmentation and + * transfer progress before a transport writes packets to its concrete medium. + */ +class FragmentingPacketSender( + private val scope: CoroutineScope, + private val fragmentManager: FragmentManager?, + private val logTag: String, + private val interFragmentDelayMs: Long = 20L +) { + private val transferJobs = ConcurrentHashMap() + + fun send( + routed: RoutedPacket, + description: String, + sendSingle: (RoutedPacket) -> Boolean + ): Boolean { + val transferId = transferIdFor(routed) + val packets = packetsForTransport(routed.packet) ?: return false + val total = packets.size + + if (total <= 1) { + if (transferId != null) { + TransferProgressManager.start(transferId, 1) + } + val sent = sendSingle(routed.copy(packet = packets.first(), transferId = transferId)) + if (sent && transferId != null) { + TransferProgressManager.progress(transferId, 1, 1) + TransferProgressManager.complete(transferId, 1) + } + return sent + } + + Log.d(logTag, "Fragmenting packet type ${routed.packet.type} into $total fragments for $description") + if (transferId != null) { + TransferProgressManager.start(transferId, total) + } + + val job = scope.launch(start = CoroutineStart.LAZY) { + var sent = 0 + for (packet in packets) { + if (!isActive) return@launch + if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch + + val fragment = routed.copy(packet = packet, transferId = transferId) + val delivered = try { + sendSingle(fragment) + } catch (e: Exception) { + Log.e(logTag, "Fragment send failed for $description: ${e.message}", e) + false + } + + if (!delivered) { + Log.w(logTag, "Stopping fragmented send for $description after $sent/$total fragments") + return@launch + } + + sent += 1 + if (transferId != null) { + TransferProgressManager.progress(transferId, sent, total) + } + if (sent < total) { + delay(interFragmentDelayMs) + } + } + + if (transferId != null) { + TransferProgressManager.complete(transferId, total) + } + } + + if (transferId != null) { + transferJobs[transferId] = job + job.invokeOnCompletion { transferJobs.remove(transferId, job) } + } + job.start() + return true + } + + fun cancelTransfer(transferId: String): Boolean { + val job = transferJobs.remove(transferId) ?: return false + job.cancel() + return true + } + + private fun packetsForTransport(packet: BitchatPacket): List? { + if (packet.type == MessageType.FRAGMENT.value) { + return listOf(packet) + } + + val manager = fragmentManager ?: return listOf(packet) + return try { + val fragments = manager.createFragments(packet) + if (fragments.isEmpty()) { + Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}") + null + } else { + fragments + } + } catch (e: Exception) { + Log.e(logTag, "Fragment creation failed for packet type ${packet.type}: ${e.message}", e) + null + } + } + + private fun transferIdFor(routed: RoutedPacket): String? { + routed.transferId?.let { return it } + val packet = routed.packet + return if (packet.type == MessageType.FILE_TRANSFER.value) { + sha256Hex(packet.payload) + } else { + null + } + } + + private fun sha256Hex(bytes: ByteArray): String = try { + val md = MessageDigest.getInstance("SHA-256") + md.update(bytes) + md.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { + bytes.size.toString(16) + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt new file mode 100644 index 00000000..0dd6a552 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt @@ -0,0 +1,143 @@ +package com.bitchat.android.mesh + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Abstract base tracker for mesh connections (BLE, Wi-Fi Aware, etc.) + * Encapsulates common state machine logic: + * - Connection attempt tracking (retries, backoff) + * - Pending connection management + * - Automatic cleanup of expired attempts + */ +abstract class MeshConnectionTracker( + private val scope: CoroutineScope, + protected val tag: String +) { + companion object { + const val CONNECTION_RETRY_DELAY = 5_000L + const val MAX_CONNECTION_ATTEMPTS = 3 + const val CLEANUP_INTERVAL = 30_000L + } + + /** + * Connection attempt tracking with automatic expiry + */ + protected data class ConnectionAttempt( + val attempts: Int, + val lastAttempt: Long = System.currentTimeMillis() + ) { + fun isExpired(): Boolean = + System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2 + + fun shouldRetry(): Boolean = + attempts < MAX_CONNECTION_ATTEMPTS && + System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY + } + + // Tracks in-progress or failed attempts + protected val pendingConnections = ConcurrentHashMap() + + private var isActive = false + + /** + * Start the tracker and its cleanup loop + */ + open fun start() { + isActive = true + startPeriodicCleanup() + } + + /** + * Stop the tracker + */ + open fun stop() { + isActive = false + pendingConnections.clear() + } + + /** + * Check if a connection attempt is allowed for this peer/address + */ + fun isConnectionAttemptAllowed(id: String): Boolean { + // If already connected, usually no need to retry (subclasses can override logic if needed, + // but typically the caller checks isConnected() first). + + val existingAttempt = pendingConnections[id] + return existingAttempt?.let { + it.isExpired() || it.shouldRetry() + } ?: true + } + + /** + * Record a new connection attempt. + * Returns true if the attempt was recorded (allowed), false if skipped. + */ + fun addPendingConnection(id: String): Boolean { + synchronized(pendingConnections) { + val currentAttempt = pendingConnections[id] + + // If strictly not allowed right now, reject + if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) { + Log.d(tag, "Connection attempt already in progress for $id") + return false + } + + // Update attempt count + // Reset to 1 if expired, otherwise increment + val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1 + pendingConnections[id] = ConnectionAttempt(attempts) + Log.d(tag, "Added pending connection for $id (attempts: $attempts)") + return true + } + } + + /** + * Remove a pending attempt (e.g., on success or fatal error) + */ + fun removePendingConnection(id: String) { + pendingConnections.remove(id) + } + + /** + * Abstract: Subclasses must define what "connected" means + */ + abstract fun isConnected(id: String): Boolean + + /** + * Abstract: Subclasses must implement disconnect logic + */ + abstract fun disconnect(id: String) + + /** + * Abstract: Subclasses report their active connection count + */ + abstract fun getConnectionCount(): Int + + private fun startPeriodicCleanup() { + scope.launch { + while (isActive) { + try { + delay(CLEANUP_INTERVAL) + if (!isActive) break + + // Clean up expired pending connections + val expired = pendingConnections.filter { it.value.isExpired() } + expired.keys.forEach { pendingConnections.remove(it) } + + if (expired.isNotEmpty()) { + Log.d(tag, "Cleaned up ${expired.size} expired connection attempts") + } + } catch (e: CancellationException) { + break + } catch (e: Exception) { + Log.w(tag, "Error in periodic cleanup: ${e.message}") + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt new file mode 100644 index 00000000..46518428 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -0,0 +1,843 @@ +package com.bitchat.android.mesh + +import android.content.Context +import android.util.Log +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PrivateMessagePacket +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.toHexString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap + +/** + * Shared mesh coordinator that wires all mesh-layer components and provides common APIs + * for send/receive operations across transports. + */ +class MeshCore( + private val context: Context, + private val scope: CoroutineScope, + private val transport: MeshTransport, + private val encryptionService: EncryptionService, + val myPeerID: String, + private val maxTtl: UByte, + sharedGossipManager: GossipSyncManager?, + gossipConfigProvider: GossipSyncManager.ConfigProvider, + private val hooks: Hooks = Hooks() +) { + data class Hooks( + val onMessageReceived: ((BitchatMessage) -> Unit)? = null, + val onPeerIdBindingUpdated: ((String, String, ByteArray, String?) -> Unit)? = null, + val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null, + val readReceiptInterceptor: ((String, String) -> Boolean)? = null, + val onReadReceiptSent: ((String) -> Unit)? = null, + val announcementNicknameProvider: (() -> String?)? = null, + val leavePayloadProvider: (() -> ByteArray)? = null + ) + + private val peerManager = PeerManager() + val fragmentManager = FragmentManager() + private val securityManager = SecurityManager(encryptionService, myPeerID) + private val storeForwardManager = StoreForwardManager() + private val messageHandler = MessageHandler(myPeerID, context.applicationContext) + private val packetProcessor = PacketProcessor(myPeerID) + private val directPeers = ConcurrentHashMap.newKeySet() + + val gossipSyncManager: GossipSyncManager = + sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider) + private val ownsGossipManager: Boolean = sharedGossipManager == null + + var delegate: MeshDelegate? = null + + private var announceJob: Job? = null + private var isActive = false + + init { + messageHandler.packetProcessor = packetProcessor + peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) } + setupDelegates() + + if (sharedGossipManager == null) { + gossipSyncManager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) { + dispatchGlobal(RoutedPacket(packet)) + } + + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + transport.sendPacketToPeer(peerID, packet) + TransportBridgeService.sendToPeer(transport.id, peerID, packet) + } + + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { + return signPacketBeforeBroadcast(packet) + } + } + } + } + + fun startCore() { + if (isActive) return + isActive = true + startPeriodicBroadcastAnnounce() + if (ownsGossipManager) { + gossipSyncManager.start() + } + } + + fun stopCore() { + if (!isActive) return + isActive = false + announceJob?.cancel() + announceJob = null + if (ownsGossipManager) { + gossipSyncManager.stop() + } + } + + fun shutdown() { + peerManager.shutdown() + fragmentManager.shutdown() + securityManager.shutdown() + storeForwardManager.shutdown() + messageHandler.shutdown() + packetProcessor.shutdown() + } + + fun processIncoming(packet: BitchatPacket, peerID: String?, relayAddress: String?) { + packetProcessor.processPacket(RoutedPacket(packet, peerID, relayAddress)) + } + + fun sendFromBridge(packet: RoutedPacket) { + transport.broadcastPacket(packet) + } + + private fun dispatchGlobal(routed: RoutedPacket) { + transport.broadcastPacket(routed) + TransportBridgeService.broadcast(transport.id, routed) + } + + private fun startPeriodicBroadcastAnnounce() { + announceJob?.cancel() + announceJob = scope.launch { + while (isActive) { + try { + delay(30_000) + sendBroadcastAnnounce() + } catch (_: Exception) { } + } + } + } + + private fun setupDelegates() { + peerManager.delegate = object : PeerManagerDelegate { + override fun onPeerListUpdated(peerIDs: List) { + try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { } + delegate?.didUpdatePeerList(peerIDs) + } + + override fun onPeerRemoved(peerID: String) { + try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } + try { encryptionService.removePeer(peerID) } catch (_: Exception) { } + try { peerManager.refreshPeerList() } catch (_: Exception) { } + } + } + + securityManager.delegate = object : SecurityManagerDelegate { + override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { + scope.launch { + delay(100) + sendAnnouncementToPeer(peerID) + delay(1000) + storeForwardManager.sendCachedMessages(peerID) + } + } + + override fun sendHandshakeResponse(peerID: String, response: ByteArray) { + val responsePacket = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = response, + ttl = maxTtl + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(responsePacket))) + } + + override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + } + + storeForwardManager.delegate = object : StoreForwardManagerDelegate { + override fun isFavorite(peerID: String): Boolean { + return delegate?.isFavorite(peerID) ?: false + } + + override fun isPeerOnline(peerID: String): Boolean { + return peerManager.isPeerActive(peerID) + } + + override fun sendPacket(packet: BitchatPacket) { + dispatchGlobal(RoutedPacket(packet)) + } + } + + messageHandler.delegate = object : MessageHandlerDelegate { + override fun addOrUpdatePeer(peerID: String, nickname: String): Boolean { + return peerManager.addOrUpdatePeer(peerID, nickname) + } + + override fun removePeer(peerID: String) { + peerManager.removePeer(peerID) + } + + override fun updatePeerNickname(peerID: String, nickname: String) { + peerManager.addOrUpdatePeer(peerID, nickname) + } + + override fun getPeerNickname(peerID: String): String? { + return peerManager.getPeerNickname(peerID) + } + + override fun getNetworkSize(): Int { + return peerManager.getActivePeerCount() + } + + override fun getMyNickname(): String? { + return delegate?.getNickname() + } + + override fun getPeerInfo(peerID: String): PeerInfo? { + return peerManager.getPeerInfo(peerID) + } + + override fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean { + return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + } + + override fun sendPacket(packet: BitchatPacket) { + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } + + override fun relayPacket(routed: RoutedPacket) { + dispatchGlobal(routed) + } + + override fun getBroadcastRecipient(): ByteArray { + return SpecialRecipients.BROADCAST + } + + override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean { + return securityManager.verifySignature(packet, peerID) + } + + override fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? { + return securityManager.encryptForPeer(data, recipientPeerID) + } + + override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? { + return securityManager.decryptFromPeer(encryptedData, senderPeerID) + } + + override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean { + return encryptionService.verifyEd25519Signature(signature, data, publicKey) + } + + override fun hasNoiseSession(peerID: String): Boolean { + return encryptionService.hasEstablishedSession(peerID) + } + + override fun initiateNoiseHandshake(peerID: String) { + this@MeshCore.initiateNoiseHandshake(peerID) + } + + override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? { + return try { + encryptionService.processHandshakeMessage(payload, peerID) + } catch (_: Exception) { + null + } + } + + override fun updatePeerIDBinding( + newPeerID: String, + nickname: String, + publicKey: ByteArray, + previousPeerID: String? + ) { + peerManager.addOrUpdatePeer(newPeerID, nickname) + val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) + previousPeerID?.let { peerManager.removePeer(it) } + Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}") + hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID) + } + + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { + return delegate?.decryptChannelMessage(encryptedContent, channel) + } + + override fun onMessageReceived(message: BitchatMessage) { + hooks.onMessageReceived?.invoke(message) + delegate?.didReceiveMessage(message) + } + + override fun onChannelLeave(channel: String, fromPeer: String) { + delegate?.didReceiveChannelLeave(channel, fromPeer) + } + + override fun onDeliveryAckReceived(messageID: String, peerID: String) { + delegate?.didReceiveDeliveryAck(messageID, peerID) + } + + override fun onReadReceiptReceived(messageID: String, peerID: String) { + delegate?.didReceiveReadReceipt(messageID, peerID) + } + + override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs) + } + + override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) + } + } + + packetProcessor.delegate = object : PacketProcessorDelegate { + override fun validatePacketSecurity(packet: BitchatPacket, peerID: String): Boolean { + return securityManager.validatePacket(packet, peerID) + } + + override fun updatePeerLastSeen(peerID: String) { + peerManager.updatePeerLastSeen(peerID) + } + + override fun getPeerNickname(peerID: String): String? { + return peerManager.getPeerNickname(peerID) + } + + override fun getNetworkSize(): Int { + return peerManager.getActivePeerCount() + } + + override fun getBroadcastRecipient(): ByteArray { + return SpecialRecipients.BROADCAST + } + + override fun handleNoiseHandshake(routed: RoutedPacket): Boolean { + return runBlocking { securityManager.handleNoiseHandshake(routed) } + } + + override fun handleNoiseEncrypted(routed: RoutedPacket) { + scope.launch { messageHandler.handleNoiseEncrypted(routed) } + } + + override fun handleAnnounce(routed: RoutedPacket) { + scope.launch { + val isFirst = messageHandler.handleAnnounce(routed) + hooks.onAnnounceProcessed?.invoke(routed, isFirst) + try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + } + } + + override fun handleMessage(routed: RoutedPacket) { + scope.launch { messageHandler.handleMessage(routed) } + try { + val pkt = routed.packet + val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (isBroadcast && pkt.type == MessageType.MESSAGE.value) { + gossipSyncManager.onPublicPacketSeen(pkt) + } + } catch (_: Exception) { } + } + + override fun handleLeave(routed: RoutedPacket) { + scope.launch { messageHandler.handleLeave(routed) } + } + + override fun handleFragment(packet: BitchatPacket): BitchatPacket? { + try { + val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (isBroadcast && packet.type == MessageType.FRAGMENT.value) { + gossipSyncManager.onPublicPacketSeen(packet) + } + } catch (_: Exception) { } + return fragmentManager.handleFragment(packet) + } + + override fun sendAnnouncementToPeer(peerID: String) { + this@MeshCore.sendAnnouncementToPeer(peerID) + } + + override fun sendCachedMessages(peerID: String) { + storeForwardManager.sendCachedMessages(peerID) + } + + override fun relayPacket(routed: RoutedPacket) { + dispatchGlobal(routed) + } + + override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + val sent = transport.sendPacketToPeer(peerID, routed.packet) + TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet) + return sent + } + + override fun handleRequestSync(routed: RoutedPacket) { + val fromPeer = routed.peerID ?: return + val req = RequestSyncPacket.decode(routed.packet.payload) ?: return + gossipSyncManager.handleRequestSync(fromPeer, req) + } + } + } + + fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { + if (content.isEmpty()) return + scope.launch { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = content.toByteArray(Charsets.UTF_8), + signature = null, + ttl = maxTtl + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + } + + fun sendFileBroadcast(file: BitchatFilePacket) { + try { + val payload = file.encode() ?: return + scope.launch { + val packet = BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = maxTtl + ) + val signed = signPacketBeforeBroadcast(packet) + val transferId = MeshPacketUtils.sha256Hex(payload) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + } catch (e: Exception) { + Log.e("MeshCore", "sendFileBroadcast failed: ${e.message}", e) + } + } + + fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + try { + scope.launch { + if (!encryptionService.hasEstablishedSession(recipientPeerID)) { + initiateNoiseHandshake(recipientPeerID) + return@launch + } + val tlv = file.encode() ?: return@launch + val np = NoisePayload(type = NoisePayloadType.FILE_TRANSFER, data = tlv).encode() + val enc = encryptionService.encrypt(np, recipientPeerID) + val packet = BitchatPacket( + version = if (enc.size > 0xFFFF) 2u else 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = enc, + signature = null, + ttl = maxTtl + ) + val signed = signPacketBeforeBroadcast(packet) + val transferId = MeshPacketUtils.sha256Hex(tlv) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) + } + } catch (e: Exception) { + Log.e("MeshCore", "sendFilePrivate failed: ${e.message}", e) + } + } + + fun cancelFileTransfer(transferId: String): Boolean { + return transport.cancelTransfer(transferId) + } + + fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { + if (content.isEmpty() || recipientPeerID.isEmpty()) return + scope.launch { + val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() + + if (encryptionService.hasEstablishedSession(recipientPeerID)) { + try { + val privateMessage = PrivateMessagePacket(messageID = finalMessageID, content = content) + val tlvData = privateMessage.encode() ?: return@launch + val messagePayload = NoisePayload( + type = NoisePayloadType.PRIVATE_MESSAGE, + data = tlvData + ) + val encrypted = encryptionService.encrypt(messagePayload.encode(), recipientPeerID) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + signature = null, + ttl = maxTtl + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to encrypt private message: ${e.message}") + } + } else { + initiateNoiseHandshake(recipientPeerID) + } + } + } + + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + scope.launch { + if (hooks.readReceiptInterceptor?.invoke(messageID, recipientPeerID) == true) return@launch + try { + val payload = NoisePayload( + type = NoisePayloadType.READ_RECEIPT, + data = messageID.toByteArray(Charsets.UTF_8) + ).encode() + val enc = encryptionService.encrypt(payload, recipientPeerID) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = enc, + signature = null, + ttl = maxTtl + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + hooks.onReadReceiptSent?.invoke(messageID) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to send read receipt: ${e.message}") + } + } + } + + fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_CHALLENGE, + data = com.bitchat.android.services.VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA) + ) + sendNoisePayloadToPeer(payload, peerID) + } + + fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val tlv = com.bitchat.android.services.VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_RESPONSE, + data = tlv + ) + sendNoisePayloadToPeer(payload, peerID) + } + + private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) { + scope.launch { + try { + val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + signature = null, + ttl = maxTtl + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to send Noise payload to $recipientPeerID: ${e.message}") + } + } + } + + fun sendBroadcastAnnounce() { + scope.launch { + val nickname = hooks.announcementNicknameProvider?.invoke() + ?: delegate?.getNickname() + ?: myPeerID + val staticKey = encryptionService.getStaticPublicKey() ?: run { + Log.e("MeshCore", "No static public key available for announcement") + return@launch + } + val signingKey = encryptionService.getSigningPublicKey() ?: run { + Log.e("MeshCore", "No signing public key available for announcement") + return@launch + } + val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch + val announcePacket = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = maxTtl, + senderID = myPeerID, + payload = tlvPayload + ) + val signedPacket = signPacketBeforeBroadcast(announcePacket) + dispatchGlobal(RoutedPacket(signedPacket)) + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + } + + fun sendAnnouncementToPeer(peerID: String) { + if (peerManager.hasAnnouncedToPeer(peerID)) return + val nickname = hooks.announcementNicknameProvider?.invoke() + ?: delegate?.getNickname() + ?: myPeerID + val staticKey = encryptionService.getStaticPublicKey() ?: return + val signingKey = encryptionService.getSigningPublicKey() ?: return + val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = maxTtl, + senderID = myPeerID, + payload = tlvPayload + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + peerManager.markPeerAsAnnouncedTo(peerID) + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + + private fun buildAnnouncementPayload(announcement: IdentityAnnouncement, nickname: String): ByteArray? { + var tlvPayload = announcement.encode() ?: return null + val directPeersForGossip = getDirectPeerIDsForGossip() + try { + if (directPeersForGossip.isNotEmpty()) { + tlvPayload += com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeersForGossip) + } + com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() + .updateFromAnnouncement(myPeerID, nickname, directPeersForGossip, System.currentTimeMillis().toULong()) + } catch (_: Exception) { } + return tlvPayload + } + + private fun getDirectPeerIDsForGossip(): List { + return try { + val verifiedDirect = peerManager.getVerifiedPeers() + .filter { it.value.isDirectConnection } + .keys + val localDirect = (verifiedDirect + directPeers).toSet() + // Publish this transport's direct peers and gossip the cross-transport union so a + // node connected via multiple transports advertises a complete neighbor list. + try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers(transport.id, localDirect) } catch (_: Exception) { } + val union = try { + com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { localDirect } + } catch (_: Exception) { localDirect } + union.distinct().take(10) + } catch (_: Exception) { + directPeers.toList().take(10) + } + } + + fun sendLeaveAnnouncement() { + val payload = hooks.leavePayloadProvider?.invoke() ?: byteArrayOf() + val packet = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = maxTtl, + senderID = myPeerID, + payload = payload + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } + + fun getPeerNicknames(): Map = peerManager.getAllPeerNicknames() + + fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI() + + fun getPeerNickname(peerID: String): String? = peerManager.getPeerNickname(peerID) + + fun addOrUpdatePeer(peerID: String, nickname: String): Boolean { + return peerManager.addOrUpdatePeer(peerID, nickname) + } + + fun removePeer(peerID: String) { + peerManager.removePeer(peerID) + } + + fun setDirectConnection(peerID: String, isDirect: Boolean) { + if (isDirect) { + directPeers.add(peerID) + } else { + directPeers.remove(peerID) + } + peerManager.refreshPeerList() + } + + fun updatePeerRSSI(peerID: String, rssi: Int) { + peerManager.updatePeerRSSI(peerID, rssi) + } + + fun getDebugInfoWithDeviceAddresses(deviceMap: Map): String { + return peerManager.getDebugInfoWithDeviceAddresses(deviceMap) + } + + fun getFingerprintDebugInfo(): String { + return peerManager.getFingerprintDebugInfo() + } + + fun hasEstablishedSession(peerID: String): Boolean { + return encryptionService.hasEstablishedSession(peerID) + } + + fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState { + return encryptionService.getSessionState(peerID) + } + + fun initiateNoiseHandshake(peerID: String) { + scope.launch { + try { + val handshakeData = encryptionService.initiateHandshake(peerID) ?: return@launch + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = handshakeData, + ttl = maxTtl + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to initiate Noise handshake with $peerID: ${e.message}") + } + } + } + + fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID) + + fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + + fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + + fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint() + + fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey() + + fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID) + + fun getEncryptedPeers(): List = emptyList() + + fun getActivePeerCount(): Int = try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 } + + fun refreshPeerList() { + try { peerManager.refreshPeerList() } catch (_: Exception) { } + } + + fun getDeviceAddressForPeer(peerID: String): String? = transport.getDeviceAddressForPeer(peerID) + + fun getDeviceAddressToPeerMapping(): Map = transport.getDeviceAddressToPeerMapping() + + fun getDebugStatus( + transportInfo: String, + deviceMap: Map, + extraLines: List = emptyList(), + title: String? = null + ): String { + return buildString { + appendLine("=== ${title ?: "${transport.id} Mesh Debug Status"} ===") + appendLine("My Peer ID: $myPeerID") + if (extraLines.isNotEmpty()) { + extraLines.forEach { appendLine(it) } + } + appendLine(transportInfo) + appendLine(peerManager.getDebugInfo(deviceMap)) + appendLine(fragmentManager.getDebugInfo()) + appendLine(securityManager.getDebugInfo()) + appendLine(storeForwardManager.getDebugInfo()) + appendLine(messageHandler.getDebugInfo()) + appendLine(packetProcessor.getDebugInfo()) + } + } + + fun clearAllInternalData() { + fragmentManager.clearAllFragments() + storeForwardManager.clearAllCache() + securityManager.clearAllData() + peerManager.clearAllPeers() + peerManager.clearAllFingerprints() + } + + fun clearAllEncryptionData() { + encryptionService.clearPersistentIdentity() + } + + private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { + return try { + val withRoute = try { + val recipient = packet.recipientID + if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) { + val destination = recipient.toHexString() + val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, destination) + if (path != null && path.size >= 3) { + val intermediates = path.subList(1, path.size - 1) + packet.copy( + route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) }, + version = 2u + ) + } else { + packet.copy(route = null) + } + } else { + packet + } + } catch (_: Exception) { + packet + } + + val packetDataForSigning = withRoute.toBinaryDataForSigning() ?: return withRoute + val signature = encryptionService.signData(packetDataForSigning) + if (signature != null) { + withRoute.copy(signature = signature) + } else { + withRoute + } + } catch (_: Exception) { + packet + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt new file mode 100644 index 00000000..de662384 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt @@ -0,0 +1,19 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.BitchatMessage + +/** + * Shared mesh delegate interface for transport-agnostic callbacks. + */ +interface MeshDelegate { + fun didReceiveMessage(message: BitchatMessage) + fun didUpdatePeerList(peers: List) + fun didReceiveChannelLeave(channel: String, fromPeer: String) + fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) + fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) + fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {} + fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {} + fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? + fun getNickname(): String? + fun isFavorite(peerID: String): Boolean +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt b/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt new file mode 100644 index 00000000..514e7a99 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt @@ -0,0 +1,37 @@ +package com.bitchat.android.mesh + +/** + * Shared helpers for mesh packet handling. + */ +object MeshPacketUtils { + /** + * Convert hex string peer ID to binary data (8 bytes), matching iOS behavior. + */ + fun hexStringToByteArray(hexString: String): ByteArray { + val result = ByteArray(8) { 0 } + var tempID = hexString + var index = 0 + + while (tempID.length >= 2 && index < 8) { + val hexByte = tempID.substring(0, 2) + val byte = hexByte.toIntOrNull(16)?.toByte() + if (byte != null) { + result[index] = byte + } + tempID = tempID.substring(2) + index++ + } + return result + } + + /** + * Hash payloads to a stable hex ID for transfer tracking. + */ + fun sha256Hex(bytes: ByteArray): String = try { + val md = java.security.MessageDigest.getInstance("SHA-256") + md.update(bytes) + md.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { + bytes.size.toString(16) + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt new file mode 100644 index 00000000..c612e8ed --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -0,0 +1,56 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.BitchatFilePacket + +/** + * Transport-agnostic mesh service API for UI and routing layers. + */ +interface MeshService { + val myPeerID: String + var delegate: MeshDelegate? + + fun startServices() + fun stopServices() + + fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) + fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) + fun sendDeliveryAck(messageID: String, recipientPeerID: String) {} + fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {} + fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) + fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) + fun sendFileBroadcast(file: BitchatFilePacket) + fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) + fun cancelFileTransfer(transferId: String): Boolean + + fun sendBroadcastAnnounce() + fun sendAnnouncementToPeer(peerID: String) + + fun getPeerNicknames(): Map + fun getPeerRSSI(): Map + fun getActivePeerCount(): Int + fun hasEstablishedSession(peerID: String): Boolean + fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState + fun initiateNoiseHandshake(peerID: String) + fun getPeerFingerprint(peerID: String): String? + fun getPeerInfo(peerID: String): PeerInfo? + fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean + fun getIdentityFingerprint(): String + fun getStaticNoisePublicKey(): ByteArray? + fun shouldShowEncryptionIcon(peerID: String): Boolean + fun getEncryptedPeers(): List + + fun getDeviceAddressForPeer(peerID: String): String? + fun getDeviceAddressToPeerMapping(): Map + fun printDeviceAddressesForPeers(): String + fun getDebugStatus(): String + + fun clearAllInternalData() + fun clearAllEncryptionData() +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt new file mode 100644 index 00000000..26a63848 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt @@ -0,0 +1,23 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket + +/** + * Transport abstraction used by MeshCore to send packets via a specific medium. + */ +interface MeshTransport { + val id: String + + fun broadcastPacket(routed: RoutedPacket) + + fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean + + fun cancelTransfer(transferId: String): Boolean = false + + fun getDeviceAddressForPeer(peerID: String): String? = null + + fun getDeviceAddressToPeerMapping(): Map = emptyMap() + + fun getTransportDebugInfo(): String = "" +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index ecac572b..d6daa547 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -21,6 +21,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro companion object { private const val TAG = "MessageHandler" + private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L } // Delegate for callbacks @@ -220,12 +221,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro if (peerID == myPeerID) return false - // Ignore stale announcements older than STALE_PEER_TIMEOUT + // Peers use wall-clock packet timestamps; tolerate moderate device clock skew + // during identity learning, or later signed messages cannot be verified. val now = System.currentTimeMillis() - val age = now - packet.timestamp.toLong() - if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) { - Log.w(TAG, "Ignoring stale ANNOUNCE from ${peerID.take(8)} (age=${age}ms > ${com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS}ms)") + val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong()) + if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) { + Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)") return false + } else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) { + Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)") } // Try to decode as iOS-compatible IdentityAnnouncement with TLV format diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt index 536a9b2a..01132ae7 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt @@ -110,10 +110,21 @@ class PeerManager { isVerified: Boolean ): Boolean { if (peerID == "unknown") return false + + fun keysMatch(a: ByteArray?, b: ByteArray?): Boolean { + if (a == null && b == null) return true + if (a == null || b == null) return false + return a.contentEquals(b) + } val now = System.currentTimeMillis() val existingPeer = peers[peerID] val isNewPeer = existingPeer == null + val wasVerified = existingPeer?.isVerifiedNickname == true + val nicknameChanged = existingPeer != null && existingPeer.nickname != nickname + val noiseKeyChanged = existingPeer != null && !keysMatch(existingPeer.noisePublicKey, noisePublicKey) + val signingKeyChanged = existingPeer != null && !keysMatch(existingPeer.signingPublicKey, signingPublicKey) + val connectedChanged = existingPeer != null && existingPeer.isConnected != true // Update or create peer info val peerInfo = PeerInfo( @@ -133,18 +144,27 @@ class PeerManager { // No legacy maps; peers map is the single source of truth // Maintain announcedPeers for first-time announce semantics + val shouldNotify = when { + isNewPeer && isVerified -> true + wasVerified != isVerified -> true + nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged -> true + else -> false + } + if (isNewPeer && isVerified) { announcedPeers.add(peerID) - notifyPeerListUpdate() Log.d(TAG, "🆕 New verified peer: $nickname ($peerID)") - return true } else if (isVerified) { Log.d(TAG, "🔄 Updated verified peer: $nickname ($peerID)") } else { Log.d(TAG, "⚠️ Unverified peer announcement from: $nickname ($peerID)") } + + if (shouldNotify) { + notifyPeerListUpdate() + } - return false + return isNewPeer && isVerified } /** @@ -179,14 +199,6 @@ class PeerManager { } } - /** - * Force a peer list update notification. - * Call this when connection state changes to refresh UI badges. - */ - fun refreshPeerList() { - notifyPeerListUpdate() - } - // MARK: - Legacy Methods (maintained for compatibility) /** @@ -414,6 +426,10 @@ class PeerManager { val peerList = getActivePeerIDs() delegate?.onPeerListUpdated(peerList) } + + fun refreshPeerList() { + notifyPeerListUpdate() + } /** * Start periodic cleanup of stale peers diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt new file mode 100644 index 00000000..46d36a8f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -0,0 +1,386 @@ +package com.bitchat.android.mesh + +import android.content.Context +import android.util.Log +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.wifiaware.WifiAwareController + +/** + * Feature-facing mesh service that hides local transport selection from the rest of the app. + * + * BLE remains the canonical origin for broadcast packets when it is enabled so existing BLE mesh + * behavior and bridge semantics stay intact. Addressed Noise traffic is routed over whichever + * local transport already has the peer/session, falling back to a connected transport handshake. + */ +class UnifiedMeshService( + private val context: Context, + private val bluetooth: BluetoothMeshService +) : MeshService, BluetoothMeshDelegate { + + companion object { + private const val TAG = "UnifiedMeshService" + } + + override val myPeerID: String + get() = bluetooth.myPeerID + + override var delegate: MeshDelegate? = null + set(value) { + field = value + refreshDelegates() + } + + fun refreshDelegates() { + try { bluetooth.delegate = if (delegate != null) this else null } catch (_: Exception) { } + try { wifiService()?.delegate = if (delegate != null) this else null } catch (_: Exception) { } + } + + override fun startServices() { + if (isBleEnabled()) { + try { bluetooth.startServices() } catch (e: Exception) { + Log.w(TAG, "Failed to start BLE transport: ${e.message}") + } + } else { + try { bluetooth.setBleTransportEnabled(false) } catch (_: Exception) { } + } + try { WifiAwareController.startIfPossible() } catch (e: Exception) { + Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}") + } + refreshDelegates() + } + + override fun stopServices() { + try { bluetooth.stopServices() } catch (_: Exception) { } + try { WifiAwareController.stop() } catch (_: Exception) { } + } + + override fun sendMessage(content: String, mentions: List, channel: String?) { + when { + isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel) + else -> wifiService()?.sendMessage(content, mentions, channel) + } + } + + override fun sendPrivateMessage( + content: String, + recipientPeerID: String, + recipientNickname: String, + messageID: String? + ) { + when { + isBleReady(recipientPeerID) -> bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + isWifiReady(recipientPeerID) -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) -> + bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + else -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + } + } + + override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + when { + isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname) + isWifiReady(recipientPeerID) -> wifiService()?.sendReadReceipt(messageID, recipientPeerID, readerNickname) + } + } + + override fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) { + val myNpub = try { + com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub + } catch (_: Exception) { + null + } + val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}" + val nickname = getPeerNicknames()[peerID] ?: peerID + if (hasEstablishedSession(peerID)) { + sendPrivateMessage(content, peerID, nickname, java.util.UUID.randomUUID().toString()) + } + } + + override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + when { + isBleReady(peerID) -> bluetooth.sendVerifyChallenge(peerID, noiseKeyHex, nonceA) + isWifiReady(peerID) -> wifiService()?.sendVerifyChallenge(peerID, noiseKeyHex, nonceA) + } + } + + override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + when { + isBleReady(peerID) -> bluetooth.sendVerifyResponse(peerID, noiseKeyHex, nonceA) + isWifiReady(peerID) -> wifiService()?.sendVerifyResponse(peerID, noiseKeyHex, nonceA) + } + } + + override fun sendFileBroadcast(file: BitchatFilePacket) { + when { + isBleEnabled() -> bluetooth.sendFileBroadcast(file) + else -> wifiService()?.sendFileBroadcast(file) + } + } + + override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + when { + isBleReady(recipientPeerID) -> bluetooth.sendFilePrivate(recipientPeerID, file) + isWifiReady(recipientPeerID) -> wifiService()?.sendFilePrivate(recipientPeerID, file) + isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) -> + bluetooth.sendFilePrivate(recipientPeerID, file) + else -> wifiService()?.sendFilePrivate(recipientPeerID, file) + } + } + + override fun cancelFileTransfer(transferId: String): Boolean { + val bleCancelled = try { bluetooth.cancelFileTransfer(transferId) } catch (_: Exception) { false } + val wifiCancelled = try { wifiService()?.cancelFileTransfer(transferId) == true } catch (_: Exception) { false } + return bleCancelled || wifiCancelled + } + + override fun sendBroadcastAnnounce() { + if (isBleEnabled()) { + try { bluetooth.sendBroadcastAnnounce() } catch (_: Exception) { } + } + try { wifiService()?.sendBroadcastAnnounce() } catch (_: Exception) { } + } + + override fun sendAnnouncementToPeer(peerID: String) { + when { + isBleConnected(peerID) || (isBleEnabled() && !isWifiConnected(peerID)) -> bluetooth.sendAnnouncementToPeer(peerID) + else -> wifiService()?.sendAnnouncementToPeer(peerID) + } + } + + override fun getPeerNicknames(): Map { + val merged = linkedMapOf() + try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { } + try { merged.putAll(bluetooth.getPeerNicknames()) } catch (_: Exception) { } + return merged + } + + override fun getPeerRSSI(): Map { + val merged = linkedMapOf() + try { merged.putAll(wifiService()?.getPeerRSSI().orEmpty()) } catch (_: Exception) { } + try { merged.putAll(bluetooth.getPeerRSSI()) } catch (_: Exception) { } + return merged + } + + override fun getActivePeerCount(): Int { + return mergedPeerIDs().filter { it != myPeerID }.distinct().size + } + + override fun hasEstablishedSession(peerID: String): Boolean { + return isBleReady(peerID) || isWifiReady(peerID) + } + + override fun getSessionState(peerID: String): NoiseSession.NoiseSessionState { + val bleState = try { bluetooth.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized } + val wifiState = try { wifiService()?.getSessionState(peerID) } catch (_: Exception) { null } + return when { + bleState is NoiseSession.NoiseSessionState.Established -> bleState + wifiState is NoiseSession.NoiseSessionState.Established -> wifiState + bleState is NoiseSession.NoiseSessionState.Handshaking -> bleState + wifiState is NoiseSession.NoiseSessionState.Handshaking -> wifiState + bleState !is NoiseSession.NoiseSessionState.Uninitialized -> bleState + wifiState != null -> wifiState + else -> bleState + } + } + + override fun initiateNoiseHandshake(peerID: String) { + when { + isBleConnected(peerID) -> bluetooth.initiateNoiseHandshake(peerID) + isWifiConnected(peerID) -> wifiService()?.initiateNoiseHandshake(peerID) + isBleEnabled() -> bluetooth.initiateNoiseHandshake(peerID) + else -> wifiService()?.initiateNoiseHandshake(peerID) + } + } + + override fun getPeerFingerprint(peerID: String): String? { + return try { bluetooth.getPeerFingerprint(peerID) } catch (_: Exception) { null } + ?: try { wifiService()?.getPeerFingerprint(peerID) } catch (_: Exception) { null } + } + + override fun getPeerInfo(peerID: String): PeerInfo? { + val ble = try { bluetooth.getPeerInfo(peerID) } catch (_: Exception) { null } + val wifi = try { wifiService()?.getPeerInfo(peerID) } catch (_: Exception) { null } + return when { + ble?.isConnected == true && hasEstablishedSessionOnBluetooth(peerID) -> ble + wifi?.isConnected == true && wifiService()?.hasEstablishedSession(peerID) == true -> wifi + ble?.isConnected == true -> ble + wifi?.isConnected == true -> wifi + else -> ble ?: wifi + } + } + + override fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean { + val bleUpdated = try { + bluetooth.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + } catch (_: Exception) { + false + } + val wifiUpdated = try { + wifiService()?.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) == true + } catch (_: Exception) { + false + } + return bleUpdated || wifiUpdated + } + + override fun getIdentityFingerprint(): String = bluetooth.getIdentityFingerprint() + + override fun getStaticNoisePublicKey(): ByteArray? { + return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey() + } + + override fun shouldShowEncryptionIcon(peerID: String): Boolean { + return hasEstablishedSession(peerID) + } + + override fun getEncryptedPeers(): List { + val encrypted = linkedSetOf() + try { encrypted.addAll(bluetooth.getEncryptedPeers()) } catch (_: Exception) { } + try { encrypted.addAll(wifiService()?.getEncryptedPeers().orEmpty()) } catch (_: Exception) { } + mergedPeerIDs().filterTo(encrypted) { hasEstablishedSession(it) } + return encrypted.toList() + } + + override fun getDeviceAddressForPeer(peerID: String): String? { + return try { bluetooth.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null } + ?: try { wifiService()?.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null } + } + + override fun getDeviceAddressToPeerMapping(): Map { + val merged = linkedMapOf() + try { merged.putAll(wifiService()?.getDeviceAddressToPeerMapping().orEmpty()) } catch (_: Exception) { } + try { merged.putAll(bluetooth.getDeviceAddressToPeerMapping()) } catch (_: Exception) { } + return merged + } + + override fun printDeviceAddressesForPeers(): String { + return buildString { + appendLine(bluetooth.printDeviceAddressesForPeers()) + wifiService()?.let { + appendLine() + appendLine(it.printDeviceAddressesForPeers()) + } + } + } + + override fun getDebugStatus(): String { + return buildString { + appendLine("=== Unified Mesh Service Debug Status ===") + appendLine("My Peer ID: $myPeerID") + appendLine("Merged Peers: ${mergedPeerIDs().joinToString(", ")}") + appendLine() + appendLine(bluetooth.getDebugStatus()) + wifiService()?.let { + appendLine() + appendLine(it.getDebugStatus()) + } + } + } + + override fun clearAllInternalData() { + try { bluetooth.clearAllInternalData() } catch (_: Exception) { } + try { wifiService()?.clearAllInternalData() } catch (_: Exception) { } + } + + override fun clearAllEncryptionData() { + try { bluetooth.clearAllEncryptionData() } catch (_: Exception) { } + try { wifiService()?.clearAllEncryptionData() } catch (_: Exception) { } + } + + override fun didReceiveMessage(message: BitchatMessage) { + delegate?.didReceiveMessage(message) + } + + override fun didUpdatePeerList(peers: List) { + delegate?.didUpdatePeerList(mergedPeerIDs().ifEmpty { peers.distinct() }) + } + + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { + delegate?.didReceiveChannelLeave(channel, fromPeer) + } + + override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { + delegate?.didReceiveDeliveryAck(messageID, recipientPeerID) + } + + override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { + delegate?.didReceiveReadReceipt(messageID, recipientPeerID) + } + + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs) + } + + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) + } + + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { + return delegate?.decryptChannelMessage(encryptedContent, channel) + } + + override fun getNickname(): String? = delegate?.getNickname() + + override fun isFavorite(peerID: String): Boolean = delegate?.isFavorite(peerID) ?: false + + private fun mergedPeerIDs(): List { + val ids = linkedSetOf() + try { ids.addAll(com.bitchat.android.services.AppStateStore.peers.value) } catch (_: Exception) { } + try { ids.addAll(bluetooth.getPeerNicknames().keys) } catch (_: Exception) { } + try { ids.addAll(wifiService()?.getPeerNicknames()?.keys.orEmpty()) } catch (_: Exception) { } + return ids.toList() + } + + private fun wifiService(): MeshService? { + return try { + WifiAwareController.getService()?.also { service -> + if (delegate != null && service.delegate !== this) { + service.delegate = this + } + } + } catch (_: Exception) { + null + } + } + + private fun isBleEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isBleConnected(peerID: String): Boolean { + return try { bluetooth.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false } + } + + private fun isWifiConnected(peerID: String): Boolean { + return try { wifiService()?.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false } + } + + private fun isBleReady(peerID: String): Boolean { + return isBleConnected(peerID) && hasEstablishedSessionOnBluetooth(peerID) + } + + private fun isWifiReady(peerID: String): Boolean { + return try { + val wifi = wifiService() + wifi?.getPeerInfo(peerID)?.isConnected == true && wifi.hasEstablishedSession(peerID) + } catch (_: Exception) { + false + } + } + + private fun hasEstablishedSessionOnBluetooth(peerID: String): Boolean { + return try { bluetooth.hasEstablishedSession(peerID) } catch (_: Exception) { false } + } +} diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index 58dc810e..5ca8df28 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -70,7 +70,8 @@ class NoiseEncryptionService(private val context: Context) { private fun initializeSessionManager() { // Create new session manager with current keys - sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey) + val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16) + sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID) // Set up session callbacks sessionManager.onSessionEstablished = { peerID, remoteStaticKey -> diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt index 332dc78a..4eab42bb 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt @@ -153,6 +153,9 @@ class NoiseSession( // Session state private var state: NoiseSessionState = NoiseSessionState.Uninitialized private val creationTime = System.currentTimeMillis() + private var handshakeStartMs: Long? = null + private var lastHandshakeActivityMs: Long? = null + private var handshakeMessage1: ByteArray? = null // Session counters private var currentPattern = 0; @@ -195,6 +198,15 @@ class NoiseSession( fun isEstablished(): Boolean = state is NoiseSessionState.Established fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking fun getCreationTime(): Long = creationTime + fun isInitiatorRole(): Boolean = isInitiator + fun getHandshakeStartMs(): Long? = handshakeStartMs + fun getLastHandshakeActivityMs(): Long? = lastHandshakeActivityMs + + internal fun getHandshakeMessage1(): ByteArray? = handshakeMessage1?.clone() + + internal fun setLastHandshakeActivityForTest(timestampMs: Long) { + lastHandshakeActivityMs = timestampMs + } init { try { @@ -317,19 +329,25 @@ class NoiseSession( // Initialize handshake as initiator initializeNoiseHandshake(HandshakeState.INITIATOR) state = NoiseSessionState.Handshaking + if (handshakeStartMs == null) { + handshakeStartMs = System.currentTimeMillis() + } + lastHandshakeActivityMs = System.currentTimeMillis() val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE) val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null") val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0) currentPattern++ val firstMessage = messageBuffer.copyOf(messageLength) + handshakeMessage1 = firstMessage // Validate message size matches XX pattern expectations if (firstMessage.size != XX_MESSAGE_1_SIZE) { Log.w(TAG, "Warning: XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE") } - Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) currentPattern: $currentPattern") + val ePrefix = firstMessage.take(4).toByteArray().toHexString() + Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) e_prefix=$ePrefix currentPattern: $currentPattern") return firstMessage } catch (e: Exception) { state = NoiseSessionState.Failed(e) @@ -344,19 +362,24 @@ class NoiseSession( */ @Synchronized fun processHandshakeMessage(message: ByteArray): ByteArray? { - Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes)") + val inputPrefix = message.take(4).toByteArray().toHexString() + Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes) prefix=$inputPrefix") try { // Initialize as responder if receiving first message if (state == NoiseSessionState.Uninitialized && !isInitiator) { initializeNoiseHandshake(HandshakeState.RESPONDER) state = NoiseSessionState.Handshaking + if (handshakeStartMs == null) { + handshakeStartMs = System.currentTimeMillis() + } Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID") } if (state != NoiseSessionState.Handshaking) { throw IllegalStateException("Invalid state for handshake: $state") } + lastHandshakeActivityMs = System.currentTimeMillis() val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null") @@ -366,7 +389,8 @@ class NoiseSession( // Read the incoming message - the Noise library will handle validation val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0) currentPattern++ - Log.d(TAG, "Read handshake message, payload length: $payloadLength currentPattern: $currentPattern") + val readPrefix = message.take(4).toByteArray().toHexString() + Log.d(TAG, "Read handshake message, payload length: $payloadLength prefix=$readPrefix currentPattern: $currentPattern") // Check what action the handshake state wants us to take next val action = handshakeStateLocal.getAction() diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt index 2d9e06d8..618f26be 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt @@ -8,11 +8,14 @@ import java.util.concurrent.ConcurrentHashMap */ class NoiseSessionManager( private val localStaticPrivateKey: ByteArray, - private val localStaticPublicKey: ByteArray + private val localStaticPublicKey: ByteArray, + private val localPeerID: String ) { companion object { private const val TAG = "NoiseSessionManager" + private const val HANDSHAKE_TIMEOUT_MS = 20_000L + private const val HANDSHAKE_MESSAGE_1_SIZE = 32 } private val sessions = ConcurrentHashMap() @@ -51,11 +54,30 @@ class NoiseSessionManager( /** * SIMPLIFIED: Initiate handshake - no tie breaker, just start */ - fun initiateHandshake(peerID: String): ByteArray { + fun initiateHandshake(peerID: String): ByteArray? { Log.d(TAG, "initiateHandshake($peerID)") - // Remove any existing session first - removeSession(peerID) + val now = System.currentTimeMillis() + val existing = getSession(peerID) + if (existing != null) { + when { + existing.isEstablished() -> { + Log.d(TAG, "Handshake already established with $peerID, skipping initiate") + return null + } + existing.isHandshaking() -> { + if (!isHandshakeStale(existing, now)) { + Log.d(TAG, "Handshake already in progress with $peerID, not restarting") + return null + } + Log.d(TAG, "Handshake with $peerID is stale; restarting") + removeSession(peerID) + } + else -> { + removeSession(peerID) + } + } + } // Create new session as initiator val session = NoiseSession( @@ -85,6 +107,23 @@ class NoiseSessionManager( try { var session = getSession(peerID) + + // Collision handling: both sides initiated and we received message 1 + if (session != null && + session.isHandshaking() && + session.isInitiatorRole() && + message.size == HANDSHAKE_MESSAGE_1_SIZE + ) { + val shouldYield = localPeerID > peerID + if (shouldYield) { + Log.d(TAG, "Handshake collision with $peerID; yielding to responder role") + removeSession(peerID) + session = null + } else { + Log.d(TAG, "Handshake collision with $peerID; keeping initiator role") + return null + } + } // If no session exists, create one as responder if (session == null) { @@ -119,6 +158,12 @@ class NoiseSessionManager( throw e } } + + private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean { + val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs() + if (lastActivity == null) return false + return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS + } /** * SIMPLIFIED: Encrypt data diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt index bdfc9733..60c6e5e4 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt @@ -26,6 +26,7 @@ fun BluetoothCheckScreen( status: BluetoothStatus, onEnableBluetooth: () -> Unit, onRetry: () -> Unit, + onSkip: () -> Unit, isLoading: Boolean = false ) { val colorScheme = MaterialTheme.colorScheme @@ -39,13 +40,15 @@ fun BluetoothCheckScreen( BluetoothDisabledContent( onEnableBluetooth = onEnableBluetooth, onRetry = onRetry, + onSkip = onSkip, colorScheme = colorScheme, isLoading = isLoading ) } BluetoothStatus.NOT_SUPPORTED -> { BluetoothNotSupportedContent( - colorScheme = colorScheme + colorScheme = colorScheme, + onSkip = onSkip ) } BluetoothStatus.ENABLED -> { @@ -61,6 +64,7 @@ fun BluetoothCheckScreen( private fun BluetoothDisabledContent( onEnableBluetooth: () -> Unit, onRetry: () -> Unit, + onSkip: () -> Unit, colorScheme: ColorScheme, isLoading: Boolean ) { @@ -77,7 +81,7 @@ private fun BluetoothDisabledContent( ) Text( - text = stringResource(R.string.bluetooth_required), + text = stringResource(R.string.bluetooth_recommended), style = MaterialTheme.typography.headlineSmall.copy( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, @@ -141,20 +145,17 @@ private fun BluetoothDisabledContent( ) } - //Since we are automatically checking bluetooth state -- commented - -// OutlinedButton( -// onClick = onRetry, -// modifier = Modifier.fillMaxWidth() -// ) { -// Text( -// text = "Check Again", -// style = MaterialTheme.typography.bodyMedium.copy( -// fontFamily = FontFamily.Monospace -// ), -// modifier = Modifier.padding(vertical = 4.dp) -// ) -// } + TextButton( + onClick = onSkip, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.skip), + style = MaterialTheme.typography.labelLarge.copy( + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + ) + } } } } @@ -162,7 +163,8 @@ private fun BluetoothDisabledContent( @Composable private fun BluetoothNotSupportedContent( - colorScheme: ColorScheme + colorScheme: ColorScheme, + onSkip: () -> Unit ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp), @@ -209,6 +211,16 @@ private fun BluetoothNotSupportedContent( textAlign = TextAlign.Center ) } + + Button( + onClick = onSkip, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.secondary + ) + ) { + Text(text = stringResource(R.string.continue_btn)) + } } } diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt index 701e9d19..674deb7d 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt @@ -271,6 +271,7 @@ class OnboardingCoordinator( permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices" permission.contains("BACKGROUND") -> "Background Location" permission.contains("LOCATION") -> "Location (for Bluetooth scanning)" + permission.contains("NEARBY_WIFI") -> "Nearby Wi‑Fi Devices (for Wi‑Fi Aware)" permission.contains("NOTIFICATION") -> "Notifications" else -> permission.substringAfterLast(".") } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index e982d2e1..48138d66 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* @@ -218,6 +219,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector { PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn PermissionType.MICROPHONE -> Icons.Filled.Mic PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications + PermissionType.WIFI_AWARE -> Icons.Filled.Wifi PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power PermissionType.OTHER -> Icons.Filled.Settings } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index c32f855b..8fcf9c31 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -23,6 +23,22 @@ class PermissionManager(private val context: Context) { private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private fun shouldRequireWifiAwarePermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false + val enabled = try { + com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true) + } catch (_: Exception) { + true + } + if (!enabled) return false + + return try { + com.bitchat.android.wifiaware.WifiAwareSupport.isSupported(context) + } catch (_: Exception) { + false + } + } + /** * Check if this is the first time the user is launching the app */ @@ -69,6 +85,11 @@ class PermissionManager(private val context: Context) { Manifest.permission.ACCESS_FINE_LOCATION )) + // Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission + if (shouldRequireWifiAwarePermission()) { + permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES) + } + // Notification permission intentionally excluded to keep it optional return permissions @@ -209,6 +230,20 @@ class PermissionManager(private val context: Context) { ) ) + // Wi‑Fi Aware category (Android 13+) + if (shouldRequireWifiAwarePermission()) { + val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES) + categories.add( + PermissionCategory( + type = PermissionType.WIFI_AWARE, + description = "Enable Wi‑Fi Aware to discover and connect to nearby bitchat users over Wi‑Fi.", + permissions = wifiAwarePermissions, + isGranted = wifiAwarePermissions.all { isPermissionGranted(it) }, + systemDescription = "Allow bitchat to discover nearby Wi‑Fi devices" + ) + ) + } + if (needsBackgroundLocationPermission()) { val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION) categories.add( @@ -308,6 +343,7 @@ enum class PermissionType(val nameValue: String) { BACKGROUND_LOCATION("Background Location"), MICROPHONE("Microphone"), NOTIFICATIONS("Notifications"), + WIFI_AWARE("Wi‑Fi Aware"), BATTERY_OPTIMIZATION("Battery Optimization"), OTHER("Other") } diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index db17ba1c..88e5f783 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -255,6 +255,10 @@ object BinaryProtocol { if (packet.version >= 2u.toUByte()) { buffer.putInt(payloadDataSize) // 4 bytes for v2+ } else { + if (payloadDataSize > 0xFFFF || (originalPayloadSize ?: 0) > 0xFFFF) { + Log.w("BinaryProtocol", "Cannot encode oversized v1 packet payload: $payloadDataSize bytes") + return null + } buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1 } diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt index 1c3abaf9..f4ca9f74 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -3,7 +3,7 @@ package com.bitchat.android.service import android.app.Application import android.os.Process import androidx.core.app.NotificationManagerCompat -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorMode import kotlinx.coroutines.CoroutineScope @@ -39,7 +39,7 @@ object AppShutdownCoordinator { fun requestFullShutdownAndKill( app: Application, - mesh: BluetoothMeshService?, + mesh: MeshService?, notificationManager: NotificationManagerCompat, stopForeground: () -> Unit, stopService: () -> Unit diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index aa9e6fc3..218631ea 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -115,6 +115,8 @@ class MeshForegroundService : Service() { private var updateJob: Job? = null private val meshService: BluetoothMeshService? get() = MeshServiceHolder.meshService + private val unifiedMeshService: com.bitchat.android.mesh.MeshService? + get() = MeshServiceHolder.unifiedMeshService private val serviceJob = Job() private val scope = CoroutineScope(Dispatchers.Default + serviceJob) private var isInForeground: Boolean = false @@ -134,6 +136,7 @@ class MeshForegroundService : Service() { Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder") MeshServiceHolder.attach(created) } + MeshServiceHolder.getUnifiedOrCreate(applicationContext) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -147,7 +150,7 @@ class MeshForegroundService : Service() { when (intent?.action) { ACTION_STOP -> { // Stop FGS and mesh cleanly - try { meshService?.stopServices() } catch (_: Exception) { } + try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { } try { MeshServiceHolder.clear() } catch (_: Exception) { } try { stopForeground(true) } catch (_: Exception) { } notificationManager.cancel(NOTIFICATION_ID) @@ -165,7 +168,7 @@ class MeshForegroundService : Service() { // Fully stop all background activity, stop Tor (without changing setting), then kill the app AppShutdownCoordinator.requestFullShutdownAndKill( app = application, - mesh = meshService, + mesh = unifiedMeshService, notificationManager = notificationManager, stopForeground = { try { stopForeground(true) } catch (_: Exception) { } @@ -178,7 +181,7 @@ class MeshForegroundService : Service() { ACTION_UPDATE_NOTIFICATION -> { // If we became eligible and are not in foreground yet, promote once if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { - val n = buildNotification(meshService?.getActivePeerCount() ?: 0) + val n = buildNotification(getUnifiedActivePeerCount()) startForegroundCompat(n) isInForeground = true } else { @@ -193,7 +196,7 @@ class MeshForegroundService : Service() { // Promote exactly once when eligible, otherwise stay background (or stop) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { - val notification = buildNotification(meshService?.getActivePeerCount() ?: 0) + val notification = buildNotification(getUnifiedActivePeerCount()) startForegroundCompat(notification) isInForeground = true } @@ -226,6 +229,21 @@ class MeshForegroundService : Service() { private fun ensureMeshStarted() { if (isShuttingDown) return + try { + com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() + } catch (e: Exception) { + android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}") + } + + val bleEnabled = try { + com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) + } catch (_: Exception) { + true + } + if (!bleEnabled) { + try { meshService?.setBleTransportEnabled(false) } catch (_: Exception) { } + return + } if (!hasBluetoothPermissions()) return try { android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") @@ -241,7 +259,7 @@ class MeshForegroundService : Service() { notificationManager.cancel(NOTIFICATION_ID) return } - val count = meshService?.getActivePeerCount() ?: 0 + val count = getUnifiedActivePeerCount() val notification = buildNotification(count) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { notificationManager.notify(NOTIFICATION_ID, notification) @@ -261,6 +279,14 @@ class MeshForegroundService : Service() { return hasBluetoothPermissions() && hasNotificationPermission() } + private fun getUnifiedActivePeerCount(): Int { + return try { + unifiedMeshService?.getActivePeerCount() ?: meshService?.getActivePeerCount() ?: 0 + } catch (_: Exception) { + 0 + } + } + private fun hasBluetoothPermissions(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED && diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt index d271ab29..1ff4ec29 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -2,6 +2,10 @@ package com.bitchat.android.service import android.content.Context import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.UnifiedMeshService +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.sync.GossipSyncManager /** * Process-wide holder to share a single BluetoothMeshService instance @@ -9,10 +13,69 @@ import com.bitchat.android.mesh.BluetoothMeshService */ object MeshServiceHolder { private const val TAG = "MeshServiceHolder" + @Volatile + var sharedGossipSyncManager: GossipSyncManager? = null + private set + + private val activeGossipOwners = mutableSetOf() + + @Synchronized + fun setGossipManager( + mgr: GossipSyncManager, + signer: (BitchatPacket) -> BitchatPacket + ) { + val previous = sharedGossipSyncManager + if (previous !== mgr) { + try { previous?.stop() } catch (_: Exception) { } + } + sharedGossipSyncManager = mgr + mgr.delegate = TransportGossipDelegate(signer) + if (activeGossipOwners.isNotEmpty()) { + mgr.start() + } + } + + @Synchronized + fun startSharedGossip(owner: String) { + val wasIdle = activeGossipOwners.isEmpty() + activeGossipOwners.add(owner) + if (wasIdle) { + sharedGossipSyncManager?.start() + } + } + + @Synchronized + fun stopSharedGossip(owner: String) { + activeGossipOwners.remove(owner) + if (activeGossipOwners.isEmpty()) { + sharedGossipSyncManager?.stop() + } + } + + private class TransportGossipDelegate( + private val signer: (BitchatPacket) -> BitchatPacket + ) : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) { + TransportBridgeService.broadcastFromLocal(RoutedPacket(packet)) + } + + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + TransportBridgeService.sendToPeerFromLocal(peerID, packet) + } + + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { + return signer(packet) + } + } + @Volatile var meshService: BluetoothMeshService? = null private set + @Volatile + var unifiedMeshService: UnifiedMeshService? = null + private set + @Synchronized fun getOrCreate(context: Context): BluetoothMeshService { val existing = meshService @@ -31,18 +94,35 @@ object MeshServiceHolder { val created = BluetoothMeshService(context.applicationContext) android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)") meshService = created + unifiedMeshService = null created } } catch (e: Exception) { android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}") val created = BluetoothMeshService(context.applicationContext) meshService = created + unifiedMeshService = null created } } val created = BluetoothMeshService(context.applicationContext) android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)") meshService = created + unifiedMeshService = null + return created + } + + @Synchronized + fun getUnifiedOrCreate(context: Context): UnifiedMeshService { + val bluetooth = getOrCreate(context) + val existing = unifiedMeshService + if (existing != null) { + existing.refreshDelegates() + return existing + } + val created = UnifiedMeshService(context.applicationContext, bluetooth) + unifiedMeshService = created + android.util.Log.i(TAG, "Created new UnifiedMeshService") return created } @@ -50,11 +130,16 @@ object MeshServiceHolder { fun attach(service: BluetoothMeshService) { android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder") meshService = service + unifiedMeshService = null } @Synchronized fun clear() { android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder") + try { sharedGossipSyncManager?.stop() } catch (_: Exception) { } + sharedGossipSyncManager = null + activeGossipOwners.clear() meshService = null + unifiedMeshService = null } } diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt new file mode 100644 index 00000000..9422be0e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -0,0 +1,184 @@ +package com.bitchat.android.service + +import android.util.Log +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.util.toHexString +import java.security.MessageDigest +import java.util.Collections +import java.util.LinkedHashMap +import java.util.concurrent.ConcurrentHashMap + +/** + * Central bridge for routing packets between different transport layers + * (e.g., Bluetooth LE <-> Wi-Fi Aware). + * + * Allows a packet received on one transport to be seamlessly relayed + * to all other active transports, effectively bridging separate meshes. + */ +object TransportBridgeService { + private const val TAG = "TransportBridgeService" + private const val MAX_SEEN_PACKETS = 4096 + private const val SEEN_PACKET_TTL_MS = 5 * 60 * 1000L + + /** + * Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement + * to receive bridged packets. + */ + interface TransportLayer { + /** + * Send a packet out via this transport. + */ + fun send(packet: RoutedPacket) + + /** + * Send a packet to a specific peer via this transport (optional). + */ + fun sendToPeer(peerID: String, packet: BitchatPacket) { } + } + + private val transports = ConcurrentHashMap() + private val seenPackets = Collections.synchronizedMap( + object : LinkedHashMap(MAX_SEEN_PACKETS, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > MAX_SEEN_PACKETS + } + } + ) + + /** + * Register a transport layer to receive bridged packets. + * @param id Unique identifier (e.g., "BLE", "WIFI") + * @param layer The transport implementation + */ + fun register(id: String, layer: TransportLayer) { + Log.i(TAG, "Registering transport layer: $id") + transports[id] = layer + } + + /** + * Unregister a transport layer. + */ + fun unregister(id: String) { + Log.i(TAG, "Unregistering transport layer: $id") + transports.remove(id) + } + + /** + * Broadcast a packet from a specific source transport to ALL other registered transports. + * + * @param sourceId The ID of the transport initiating the broadcast (e.g., "BLE"). + * The packet will NOT be sent back to this source. + * @param packet The packet to bridge. + */ + fun broadcast(sourceId: String, packet: RoutedPacket) { + val targets = transports.filterKeys { it != sourceId } + if (targets.isEmpty()) return + val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return + val forwarded = packet.copy(packet = forwardedPacket) + + // Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}") + + targets.forEach { (id, layer) -> + try { + layer.send(forwarded) + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge packet to $id: ${e.message}") + } + } + } + + /** + * Send a packet to a specific peer across all other transports. + */ + fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) { + val targets = transports.filterKeys { it != sourceId } + if (targets.isEmpty()) return + val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return + + targets.forEach { (id, layer) -> + try { + layer.sendToPeer(peerID, forwardedPacket) + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}") + } + } + } + + /** + * Send a locally originated packet to every active transport without applying relay TTL + * handling. This is used for neighbor-only packets such as REQUEST_SYNC whose TTL is + * intentionally zero on the first radio hop. + */ + fun broadcastFromLocal(packet: RoutedPacket) { + val targets = transports.toMap() + if (targets.isEmpty()) return + + targets.forEach { (id, layer) -> + try { + layer.send(packet) + } catch (e: Exception) { + Log.e(TAG, "Failed to send local packet to $id: ${e.message}") + } + } + } + + /** + * Send a locally originated packet directly to a peer on every active transport. + */ + fun sendToPeerFromLocal(peerID: String, packet: BitchatPacket) { + val targets = transports.toMap() + if (targets.isEmpty()) return + + targets.forEach { (id, layer) -> + try { + layer.sendToPeer(peerID, packet) + } catch (e: Exception) { + Log.e(TAG, "Failed to send local peer packet to $id: ${e.message}") + } + } + } + + private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? { + if (packet.ttl == 0u.toUByte()) { + Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired") + return null + } + + val key = "$kind:${logicalPacketId(packet)}" + val now = System.currentTimeMillis() + synchronized(seenPackets) { + pruneSeen(now) + val previous = seenPackets[key] + if (previous != null && now - previous < SEEN_PACKET_TTL_MS) { + Log.d(TAG, "Dropping duplicate bridged packet type ${packet.type}") + return null + } + seenPackets[key] = now + } + + return packet.copy(ttl = (packet.ttl - 1u).toUByte()) + } + + private fun pruneSeen(now: Long) { + val iterator = seenPackets.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (now - entry.value > SEEN_PACKET_TTL_MS) { + iterator.remove() + } + } + } + + private fun logicalPacketId(packet: BitchatPacket): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(packet.type.toByte()) + digest.update(packet.senderID) + packet.recipientID?.let { digest.update(it) } + digest.update(packet.timestamp.toString().toByteArray(Charsets.UTF_8)) + digest.update(packet.payload) + packet.route?.forEach { digest.update(it) } + packet.signature?.let { digest.update(it) } + return digest.digest().toHexString() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index 0997beff..7a935396 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -14,6 +14,9 @@ object AppStateStore { // Global de-dup set by message id to avoid duplicate keys in Compose lists private val seenMessageIds = mutableSetOf() private val seenPublicMessageKeys = mutableSetOf() + private val peerIdsByTransport = mutableMapOf>() + // Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set. + private val directPeerIdsByTransport = mutableMapOf>() // Connected peer IDs (mesh ephemeral IDs) private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers.asStateFlow() @@ -31,7 +34,55 @@ object AppStateStore { val channelMessages: StateFlow>> = _channelMessages.asStateFlow() fun setPeers(ids: List) { - _peers.value = ids + synchronized(this) { + _peers.value = ids.distinct() + } + } + + fun setTransportPeers(transportId: String, ids: List) { + 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) { + 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 { + 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() diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index a6bac09a..77949697 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -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) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 99244290..7273813b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 0d22a0d5..bc83a9f9 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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 }, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 82db6b64..e3097eed 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -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 { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index a1f13a7a..2dd82cdb 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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> = state.geohashPeople val teleportedGeo: StateFlow> = state.teleportedGeo val geohashParticipantCounts: StateFlow> = 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() 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) { } @@ -656,6 +658,40 @@ class ChatViewModel( Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") 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 { @@ -867,7 +901,7 @@ class ChatViewModel( override fun didUpdatePeerList(peers: List) { meshDelegateHandler.didUpdatePeerList(peers) } - + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) } @@ -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 { diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index 499b3926..a92d2d60 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -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?) -> Unit, viewModel: ChatViewModel? = null): Boolean { + fun processCommand(command: String, meshService: MeshService, myPeerID: String, onSendMessage: (String, List, 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, meshService: BluetoothMeshService) { + private fun handleMessageCommand(parts: List, 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, meshService: BluetoothMeshService) { + private fun handleBlockCommand(parts: List, 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, meshService: BluetoothMeshService) { + private fun handleUnblockCommand(parts: List, meshService: MeshService) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") privateChatManager.unblockPeerByNickname(targetName, meshService) @@ -284,9 +283,10 @@ class CommandProcessor( parts: List, verb: String, object_: String, - meshService: BluetoothMeshService, + meshService: MeshService, myPeerID: String, - onSendMessage: (String, List, String?) -> Unit + onSendMessage: (String, List, 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) { - meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId) + 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) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 5bf7003b..99ed10cc 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 9c7d776f..045ea127 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -77,7 +77,7 @@ fun MessageWithMatrixAnimation( message: com.bitchat.android.model.BitchatMessage, messages: List = 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 diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index 8de53239..b7e87f91 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -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" diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index d27719c7..f77e146d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -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,99 +96,104 @@ class MeshDelegateHandler( override fun didUpdatePeerList(peers: List) { coroutineScope.launch { - state.setConnectedPeers(peers) - state.setIsConnected(peers.isNotEmpty()) - notificationManager.showActiveUserNotification(peers) - // Flush router outbox for any peers that just connected (and their noiseHex aliases) - runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) } + processPeerUpdate(peers.distinct()) + } + } - // Clean up channel members who disconnected - channelManager.cleanupDisconnectedMembers(peers, getMyPeerID()) + private suspend fun processPeerUpdate(mergedPeers: List) { + state.setConnectedPeers(mergedPeers) + state.setIsConnected(mergedPeers.isNotEmpty()) + notificationManager.showActiveUserNotification(mergedPeers) + + // Flush router outbox for any peers that just connected (and their noiseHex aliases) + runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(mergedPeers) } - // Handle chat view migration based on current selection and new peer list - state.getSelectedPrivateChatPeerValue()?.let { currentPeer -> - val isNostrAlias = currentPeer.startsWith("nostr_") - val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) - val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) + // Clean up channel members who disconnected + channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID()) - if (isNostrAlias || isNoiseHex) { - // Reverse case: Nostr/offline chat is open, and peer may have come online on mesh. - // Resolve canonical target (prefer connected mesh peer if available) - val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( - selectedPeerID = currentPeer, - connectedPeers = peers, - meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> peers.contains(pid) }, - nostrPubHexForAlias = { alias -> - // Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping - if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) { - com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) - } else { - // Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases - val prefix = alias.removePrefix("nostr_") - val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } - favs.firstNotNullOfOrNull { rel -> - rel.peerNostrPublicKey?.let { s -> - runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec -> - if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null - } + // Handle chat view migration based on current selection and new peer list + state.getSelectedPrivateChatPeerValue()?.let { currentPeer -> + val isNostrAlias = currentPeer.startsWith("nostr_") + val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) + val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) + + if (isNostrAlias || isNoiseHex) { + // Reverse case: Nostr/offline chat is open, and peer may have come online on mesh. + // Resolve canonical target (prefer connected mesh peer if available) + val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( + selectedPeerID = currentPeer, + connectedPeers = mergedPeers, + meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey }, + meshHasPeer = { pid -> mergedPeers.contains(pid) }, + nostrPubHexForAlias = { alias -> + // Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping + if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) { + com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) + } else { + // Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases + val prefix = alias.removePrefix("nostr_") + val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } + favs.firstNotNullOfOrNull { rel -> + rel.peerNostrPublicKey?.let { s -> + runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec -> + if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null } - }?.takeIf { it.startsWith(prefix, ignoreCase = true) } - } - }, - findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } - ) - if (canonical != currentPeer) { - // Merge conversations and switch selection to the live mesh peer (or noiseHex) - com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer)) - state.setSelectedPrivateChatPeer(canonical) - } - } else if (isMeshEphemeral && !peers.contains(currentPeer)) { - // Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex) - val favoriteRel = try { - val info = getPeerInfo(currentPeer) - val noiseKey = info?.noisePublicKey - if (noiseKey != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) - } else null - } catch (_: Exception) { null } - - if (favoriteRel?.isMutual == true) { - val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } - if (noiseHex != currentPeer) { - com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer( - state = state, - targetPeerID = noiseHex, - keysToMerge = listOf(currentPeer) - ) - state.setSelectedPrivateChatPeer(noiseHex) + } + }?.takeIf { it.startsWith(prefix, ignoreCase = true) } } - } else { - privateChatManager.cleanupDisconnectedPeer(currentPeer) + }, + findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } + ) + if (canonical != currentPeer) { + // Merge conversations and switch selection to the live mesh peer (or noiseHex) + com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer)) + state.setSelectedPrivateChatPeer(canonical) + } + } else if (isMeshEphemeral && !mergedPeers.contains(currentPeer)) { + // Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex) + val favoriteRel = try { + val info = getPeerInfo(currentPeer) + val noiseKey = info?.noisePublicKey + if (noiseKey != null) { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + } else null + } catch (_: Exception) { null } + + if (favoriteRel?.isMutual == true) { + val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + if (noiseHex != currentPeer) { + com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer( + state = state, + targetPeerID = noiseHex, + keysToMerge = listOf(currentPeer) + ) + state.setSelectedPrivateChatPeer(noiseHex) } + } else { + privateChatManager.cleanupDisconnectedPeer(currentPeer) } } + } - // Global unification: for each connected peer, merge any offline/stable conversations - // (noiseHex or nostr_) into the connected peer's chat so there is only one chat per identity. - peers.forEach { pid -> - try { - val info = getPeerInfo(pid) - val noiseKey = info?.noisePublicKey ?: return@forEach - val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } + // Global unification: for each connected peer, merge any offline/stable conversations + // (noiseHex or nostr_) into the connected peer's chat so there is only one chat per identity. + mergedPeers.forEach { pid -> + try { + val info = getPeerInfo(pid) + val noiseKey = info?.noisePublicKey ?: return@forEach + val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } - // Derive temp nostr key from favorites npub - val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey) - val tempNostrKey: String? = try { - if (npub != null) { - val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub) - if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null - } else null - } catch (_: Exception) { null } + // Derive temp nostr key from favorites npub + val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey) + val tempNostrKey: String? = try { + if (npub != null) { + val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub) + if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null + } else null + } catch (_: Exception) { null } - unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey)) - } catch (_: Exception) { } - } + unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey)) + } catch (_: Exception) { } } } @@ -294,15 +299,27 @@ 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) - // Ensure unread badge is cleared for this peer immediately - try { - val current = state.getUnreadPrivateMessagesValue().toMutableSet() - if (current.remove(senderPeerID)) { - state.setUnreadPrivateMessages(current) + 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) { } + } catch (_: Exception) { + false + } + if (sent) { + // Ensure unread badge is cleared for this peer immediately + try { + val current = state.getUnreadPrivateMessagesValue().toMutableSet() + if (current.remove(senderPeerID)) { + state.setUnreadPrivateMessages(current) + } + } catch (_: Exception) { } + } } else { android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index e5688a4e..f2bccdb6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -61,6 +61,8 @@ fun MeshPeerListSheet( val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() + val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } // Bottom sheet state val sheetState = rememberModalBottomSheetState( @@ -163,11 +165,12 @@ fun MeshPeerListSheet( nickname = nickname, colorScheme = colorScheme, selectedPrivatePeer = selectedPrivatePeer, + wifiAwarePeerIDs = wifiAwarePeerIDs, viewModel = viewModel, - onPrivateChatStart = { peerID -> - viewModel.showPrivateChatSheet(peerID) - onDismiss() - } + onPrivateChatStart = { peerID -> + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } ) } } @@ -279,6 +282,7 @@ fun PeopleSection( nickname: String, colorScheme: ColorScheme, selectedPrivatePeer: String?, + wifiAwarePeerIDs: Set = 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 = 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( diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 986c8c63..88b24015 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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, 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 = emptyList(), onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, @@ -201,7 +201,7 @@ fun MessageItem( message: BitchatMessage, messages: List, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index af2882bb..1ddf8cbf 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -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>() } 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 { - meshService.sendReadReceipt(msg.id, peerID, myNickname) - sentCount += 1 + 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 } diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 9a4a1592..2633f888 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -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) } ) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt index 2b0d503f..28c051bc 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -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>(emptySet()) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 04d93e96..97392eae 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -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( diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt index 04ad48a2..f03df3da 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt @@ -20,7 +20,10 @@ object DebugPreferenceManager { // GCS keys (no migration/back-compat) private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes" private const val KEY_GCS_FPR = "gcs_filter_fpr_percent" - // Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled + // Transport master toggles + private const val KEY_BLE_ENABLED = "ble_enabled" + private const val KEY_WIFI_AWARE_ENABLED = "wifi_aware_enabled" + private const val KEY_WIFI_AWARE_VERBOSE = "wifi_aware_verbose" private lateinit var prefs: SharedPreferences @@ -102,5 +105,25 @@ object DebugPreferenceManager { if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() } - // No longer storing persistent notification in debug prefs. + // Transport toggles + fun getBleEnabled(default: Boolean = true): Boolean = + if (ready()) prefs.getBoolean(KEY_BLE_ENABLED, default) else default + + fun setBleEnabled(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_BLE_ENABLED, value).apply() + } + + fun getWifiAwareEnabled(default: Boolean = 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() + } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt index 04341fa5..82bc7fc2 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt @@ -40,6 +40,17 @@ class DebugSettingsManager private constructor() { private val _packetRelayEnabled = MutableStateFlow(true) val packetRelayEnabled: StateFlow = _packetRelayEnabled.asStateFlow() + // Master transport toggles + private val _bleEnabled = MutableStateFlow(true) + val bleEnabled: StateFlow = _bleEnabled.asStateFlow() + + private val _wifiAwareEnabled = MutableStateFlow(true) + val wifiAwareEnabled: StateFlow = _wifiAwareEnabled.asStateFlow() + + // Master transport toggles + private val _wifiAwareVerbose = MutableStateFlow(false) + val wifiAwareVerbose: StateFlow = _wifiAwareVerbose.asStateFlow() + // Visibility of the debug sheet; gates heavy work private val _debugSheetVisible = MutableStateFlow(false) val debugSheetVisible: StateFlow = _debugSheetVisible.asStateFlow() @@ -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) "🟢 Wi‑Fi Aware enabled" else "🔴 Wi‑Fi Aware disabled")) + try { + com.bitchat.android.wifiaware.WifiAwareController.setEnabled(enabled) + } catch (_: Exception) { } + } + + fun setWifiAwareVerbose(enabled: Boolean) { + DebugPreferenceManager.setWifiAwareVerbose(enabled) + _wifiAwareVerbose.value = enabled + addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🔊 Wi‑Fi Aware verbose logging enabled" else "🔇 Wi‑Fi Aware verbose logging disabled")) + } + fun setMaxConnectionsOverall(value: Int) { val clamped = value.coerceIn(1, 32) DebugPreferenceManager.setMaxConnectionsOverall(clamped) @@ -323,6 +362,16 @@ class DebugSettingsManager private constructor() { fun updateConnectedDevices(devices: List) { _connectedDevices.value = devices } + + // Wi‑Fi Aware debug collections + private val _wifiAwareDiscovered = MutableStateFlow>(emptyMap()) // peerID->nickname + val wifiAwareDiscovered: StateFlow> = _wifiAwareDiscovered.asStateFlow() + + private val _wifiAwareConnected = MutableStateFlow>(emptyMap()) // peerID->ip + val wifiAwareConnected: StateFlow> = _wifiAwareConnected.asStateFlow() + + fun updateWifiAwareDiscovered(map: Map) { _wifiAwareDiscovered.value = map } + fun updateWifiAwareConnected(map: Map) { _wifiAwareConnected.value = map } fun updateRelayStats(stats: PacketRelayStats) { _relayStats.value = stats diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 672d352a..65b19182 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -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 = 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 Wi‑Fi 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 + Wi‑Fi 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("Wi‑Fi 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("Wi‑Fi 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( } } + // Wi‑Fi 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("Wi‑Fi 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)) { diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt index 2df56b5d..44c7ef55 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -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, + 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 { + 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, edges: List, + wifiAwarePeerIDs: Set = emptySet(), + blePeerIDs: Set = 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() + 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) + ) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 264d9d6c..6af2cc51 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -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)?, diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 2310cd5b..43ae84f9 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -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, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt new file mode 100644 index 00000000..c3039327 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt @@ -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 +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt new file mode 100644 index 00000000..115dbb05 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt @@ -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() + private val socketAliases = ConcurrentHashMap() + val serverSockets = ConcurrentHashMap() + val networkCallbacks = ConcurrentHashMap() + + 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() + 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 { + val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) } + val pendingIds = linkedSetOf() + + 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 { + 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 { + 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") + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt new file mode 100644 index 00000000..f2ec4cfa --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -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 = _enabled.asStateFlow() + + private val _supported = MutableStateFlow(false) + val supported: StateFlow = _supported.asStateFlow() + + private val _available = MutableStateFlow(false) + val available: StateFlow = _available.asStateFlow() + + private val _supportStatus = MutableStateFlow(null) + val supportStatus: StateFlow = _supportStatus.asStateFlow() + + private val _running = MutableStateFlow(false) + val running: StateFlow = _running.asStateFlow() + + // Simple debug surfacing + private val _connectedPeers = MutableStateFlow>(emptyMap()) // peerID -> ip + val connectedPeers: StateFlow> = _connectedPeers.asStateFlow() + + private val _knownPeers = MutableStateFlow>(emptyMap()) // peerID -> nickname + val knownPeers: StateFlow> = _knownPeers.asStateFlow() + + private val _discoveredPeers = MutableStateFlow>(emptySet()) + val discoveredPeers: StateFlow> = _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, "Wi‑Fi 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 Wi‑Fi 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("Wi‑Fi 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("Wi‑Fi 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("Wi‑Fi 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("Wi‑Fi 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 +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt new file mode 100644 index 00000000..066a8b82 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt @@ -0,0 +1,3 @@ +package com.bitchat.android.wifiaware + +typealias WifiAwareMeshDelegate = com.bitchat.android.mesh.MeshDelegate diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt new file mode 100644 index 00000000..36eaea1c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -0,0 +1,1591 @@ +package com.bitchat.android.wifiaware + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.net.* +import android.net.wifi.aware.* +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.system.OsConstants +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.mesh.FragmentingPacketSender +import com.bitchat.android.mesh.MeshCore +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.MeshTransport +import com.bitchat.android.mesh.PeerInfo +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.toHexString +import java.io.InterruptedIOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.IOException +import java.net.Inet6Address +import java.net.ServerSocket +import java.net.Socket +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +/** + * WifiAware mesh service - LATEST + * + * This is now a coordinator that orchestrates the following components: + * - PeerManager: Peer lifecycle management + * - FragmentManager: Message fragmentation and reassembly + * - SecurityManager: Security, duplicate detection, encryption + * - StoreForwardManager: Offline message caching + * - MessageHandler: Message type processing and relay logic + * - PacketProcessor: Incoming packet routing + */ +class WifiAwareMeshService(private val context: Context) : MeshService, TransportBridgeService.TransportLayer { + + companion object { + private const val TAG = "WifiAwareMeshService" + private const val MAX_TTL: UByte = 7u + private const val SERVICE_NAME = "bitchat" + private const val PSK = "bitchat_secret" + // Network request / socket timeouts + private const val NETWORK_REQUEST_TIMEOUT_MS = 30_000 + private const val ACCEPT_TIMEOUT_MS = 30_000 + private const val CLIENT_CONNECT_TIMEOUT_MS = 7_000 + private const val CLIENT_SOCKET_READY_DELAY_MS = 750L + private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L + private const val CLIENT_SOCKET_ATTEMPTS = 3 + private const val CLIENT_ROLE_REVERSAL_FAILURES = 3 + // Discovery freshness window for reconnection maintenance + private const val DISCOVERY_STALE_MS = 5L * 60 * 1000 + private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000 + private const val DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS = 90L * 1000 + private const val ROLE_REVERSAL_PREFIX = "ROLE_SERVER:" + } + + // Core crypto/services + private val encryptionService = EncryptionService(context) + + // Peer ID must match BluetoothMeshService: first 16 hex chars of identity fingerprint (8 bytes) + override val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val wifiTransport = WifiAwareTransport() + private lateinit var meshCore: MeshCore + private lateinit var fragmentingSender: FragmentingPacketSender + + // Service-level notification manager for background (no-UI) DMs + private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( + context.applicationContext, + androidx.core.app.NotificationManagerCompat.from(context.applicationContext), + com.bitchat.android.util.NotificationIntervalManager() + ) + + // Wi-Fi Aware transport + private val awareManager = context.getSystemService(WifiAwareManager::class.java) + @Volatile private var wifiAwareSession: WifiAwareSession? = null + @Volatile private var publishSession: PublishDiscoverySession? = null + @Volatile private var subscribeSession: SubscribeDiscoverySession? = null + private val listenerExec = Executors.newCachedThreadPool() + @Volatile private var isActive = false + @Volatile private var recoveryInProgress = false + private val sessionGeneration = AtomicInteger(0) + + // Delegate + override var delegate: WifiAwareMeshDelegate? = null + set(value) { + field = value + if (::meshCore.isInitialized) { + meshCore.delegate = value + meshCore.refreshPeerList() + } + } + private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + // Transport state + private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm) + private val handleToPeerId = ConcurrentHashMap() // discovery mapping + private val discoveredTimestamps = ConcurrentHashMap() // peerID -> last seen time + // Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained + // from the publish session is NOT valid for subscribeSession.sendMessage(). Maintenance re-pings + // (subscriber -> publisher) must use a handle that originated from the subscribe session. + private val subscribeHandles = ConcurrentHashMap() // peerID -> latest subscribe handle + private val publishHandles = ConcurrentHashMap() // peerID -> latest publish handle + private val forcedServerPeers = ConcurrentHashMap.newKeySet() + private val forcedClientPeers = ConcurrentHashMap.newKeySet() + private val clientSocketFailures = ConcurrentHashMap() + private val lastDiscoveryActivityAt = AtomicLong(0L) + private val lastDiscoveryRefreshAt = AtomicLong(0L) + + fun isRunning(): Boolean = isActive + + init { + // Ensure BluetoothMeshService is initialized so we share its GossipSyncManager + // This avoids race conditions and ensures a single gossip source/delegate + com.bitchat.android.service.MeshServiceHolder.getOrCreate(context) + val shared = com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager + encryptionService.onSessionEstablished = { peerID -> + Log.d(TAG, "Wi-Fi Aware Noise session established with ${peerID.take(8)}") + try { + com.bitchat.android.services.MessageRouter + .tryGetInstance() + ?.onSessionEstablished(peerID) + } catch (_: Exception) { } + } + meshCore = MeshCore( + context = context.applicationContext, + scope = serviceScope, + transport = wifiTransport, + encryptionService = encryptionService, + myPeerID = myPeerID, + maxTtl = MAX_TTL, + sharedGossipManager = shared, + gossipConfigProvider = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = 500 + override fun gcsMaxBytes(): Int = 400 + override fun gcsTargetFpr(): Double = 0.01 + }, + hooks = MeshCore.Hooks( + onMessageReceived = { message -> handleMessageReceived(message) }, + onAnnounceProcessed = { routed, _ -> + routed.peerID?.let { pid -> + try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } + } + }, + announcementNicknameProvider = { + try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null } + }, + leavePayloadProvider = { + (delegate?.getNickname() ?: myPeerID).toByteArray(Charsets.UTF_8) + } + ) + ) + fragmentingSender = FragmentingPacketSender(serviceScope, meshCore.fragmentManager, TAG) + } + + private fun handleMessageReceived(message: BitchatMessage) { + try { + when { + message.isPrivate -> { + val peer = message.senderPeerID ?: "" + if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message) + } + message.channel != null -> { + com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message) + } + else -> { + com.bitchat.android.services.AppStateStore.addPublicMessage(message) + } + } + } catch (_: Exception) { } + + if (delegate == null && message.isPrivate) { + try { + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + val nick = try { meshCore.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID + val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message) + serviceNotificationManager.setAppBackgroundState(true) + serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview) + } + } catch (_: Exception) { } + } + } + + /** + * Broadcasts raw bytes to currently connected peer. + */ + private fun broadcastRaw(bytes: ByteArray) { + var sent = 0 + connectionTracker.peerSockets.forEach { (pid, sock) -> + try { + sock.write(bytes) + sent++ + } catch (e: IOException) { + Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}") + } + } + Log.i(TAG, "TX: broadcast via Wi-Fi Aware to $sent peers (bytes=${bytes.size})") + } + + // TransportLayer implementation + override fun send(packet: RoutedPacket) { + // Received from bridge (e.g. BLE) -> Send via Wi-Fi + // Direct injection prevents routing loops (bridge handles source check) + meshCore.sendFromBridge(packet) + } + + override fun sendToPeer(peerID: String, packet: BitchatPacket) { + sendPacketToPeer(peerID, packet) + } + + /** + * Broadcasts routed packet to currently connected peers. + */ + private fun broadcastPacket(routed: RoutedPacket) { + Log.d(TAG, "TX: packet type=${routed.packet.type} broadcast (ttl=${routed.packet.ttl})") + + val packet = routed.packet + if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) { + val firstHop = packet.route!![0].toHexString() + if (sendRoutedPacketToPeer(firstHop, routed)) { + Log.d(TAG, "TX: source-routed packet sent only to first Wi-Fi hop ${firstHop.take(8)}") + return + } + Log.w(TAG, "TX: first Wi-Fi source-route hop ${firstHop.take(8)} unavailable; falling back to broadcast") + } + + val recipientId = packet.recipientID?.toHexString() + if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) { + if (sendRoutedPacketToPeer(recipientId, routed)) { + Log.d(TAG, "TX: addressed packet sent directly to Wi-Fi peer ${recipientId.take(8)}") + return + } + } + + fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single -> + broadcastSinglePacket(single) + } + } + + // Expose a public method so BLE can forward relays to Wi-Fi Aware + fun broadcastRoutedPacket(routed: RoutedPacket) { + broadcastPacket(routed) + } + + /** + * Send packet to connected peer. + */ + private fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + return sendRoutedPacketToPeer(peerID, RoutedPacket(packet)) + } + + private fun sendRoutedPacketToPeer(peerID: String, routed: RoutedPacket): Boolean { + if (connectionTracker.getSocketForPeer(peerID) == null) { + Log.w(TAG, "TX: no socket for ${peerID.take(8)}") + return false + } + return fragmentingSender.send(routed, "Wi-Fi Aware peer ${peerID.take(8)}") { single -> + sendSinglePacketToPeer(peerID, single.packet) + } + } + + private fun broadcastSinglePacket(routed: RoutedPacket): Boolean { + val data = routed.packet.toBinaryData() ?: return false + broadcastRaw(data) + return true + } + + private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + val data = packet.toBinaryData() ?: return false + val sock = connectionTracker.getSocketForPeer(peerID) + if (sock == null) { + Log.w(TAG, "TX: no socket for ${peerID.take(8)}") + return false + } + try { + sock.write(data) + Log.d(TAG, "TX: packet type=${packet.type} to ${peerID.take(8)} (bytes=${data.size})") + return true + } catch (e: IOException) { + Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}") + return false + } + } + + + + /** + * Starts Wi-Fi Aware services (publish + subscribe). + * + * Requires Wi-Fi state and location permissions. This method attaches to the + * Aware session and initializes both the publisher (server role) and subscriber + * (client role). + */ + @SuppressLint("MissingPermission") + @RequiresPermission(allOf = [ + Manifest.permission.ACCESS_WIFI_STATE, + Manifest.permission.CHANGE_WIFI_STATE + ]) + override fun startServices() { + if (isActive) return + if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + Log.i(TAG, "Wi-Fi Aware transport disabled by debug settings; not starting") + return + } + val supportStatus = com.bitchat.android.wifiaware.WifiAwareSupport.evaluate(context) + if (!supportStatus.supported) { + Log.i(TAG, "Wi-Fi Aware unsupported on this device; not starting (${supportStatus.reason})") + return + } + if (!supportStatus.available) { + Log.i(TAG, "Wi-Fi Aware unavailable right now; not starting (${supportStatus.reason})") + return + } + if (recoveryInProgress) { + Log.i(TAG, "Wi-Fi Aware recovery cleanup still in progress; deferring start") + return + } + val manager = awareManager + if (manager == null || !manager.isAvailable) { + Log.w(TAG, "Wi-Fi Aware manager unavailable; not starting") + return + } + isActive = true + val startTime = System.currentTimeMillis() + lastDiscoveryActivityAt.set(startTime) + lastDiscoveryRefreshAt.set(startTime) + val generation = sessionGeneration.incrementAndGet() + Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID") + + manager.attach(object : AttachCallback() { + @SuppressLint("MissingPermission") + @RequiresPermission(allOf = [ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.NEARBY_WIFI_DEVICES + ]) + override fun onAttached(session: WifiAwareSession) { + if (!isCurrentSession(generation)) { + session.close() + return + } + wifiAwareSession = session + Log.i(TAG, "Wi-Fi Aware attached; starting publish & subscribe (peerID=$myPeerID)") + + // PUBLISH (server role) + session.publish( + PublishConfig.Builder() + .setServiceName(SERVICE_NAME) + .setServiceSpecificInfo(myPeerID.toByteArray()) + .build(), + object : DiscoverySessionCallback() { + override fun onPublishStarted(pub: PublishDiscoverySession) { + if (!isCurrentSession(generation)) { + pub.close() + return + } + publishSession = pub + Log.d(TAG, "PUBLISH: onPublishStarted()") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi-Fi Aware Publish Started")) } catch (_: Exception) {} + } + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray, + matchFilter: List + ) { + if (!isCurrentSession(generation)) return + val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } + handleToPeerId[peerHandle] = peerId + if (peerId.isNotBlank()) { + rememberDiscoveredPeer(peerId) + publishHandles[peerId] = peerHandle + Log.i(TAG, "PUBLISH: Discovered subscriber '$peerId' via Aware") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + offerServerPathIfAppropriate(peerId, peerHandle, "publish discovery") + } + } + Log.d(TAG, "PUBLISH: onServiceDiscovered ssi='${peerId.take(16)}' len=${serviceSpecificInfo.size}") + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun onMessageReceived( + peerHandle: PeerHandle, + message: ByteArray + ) { + if (!isCurrentSession(generation)) return + if (message.isEmpty()) return + val subscriberId = try { String(message) } catch (_: Exception) { "" } + if (subscriberId.startsWith(ROLE_REVERSAL_PREFIX)) { + val requesterId = subscriberId.removePrefix(ROLE_REVERSAL_PREFIX) + handleRoleReversalRequest(peerHandle, requesterId) + return + } + if (subscriberId == myPeerID) return + + handleToPeerId[peerHandle] = subscriberId + if (subscriberId.isNotBlank()) { + rememberDiscoveredPeer(subscriberId) + publishHandles[subscriberId] = peerHandle + } + Log.i(TAG, "PUBLISH: Received discovery ping from subscriber '$subscriberId'") + handleSubscriberPing(publishSession!!, peerHandle) + } + + override fun onSessionTerminated() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "PUBLISH: onSessionTerminated()") + publishSession = null + val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop(generation) + if (shouldRestart) { + Log.i(TAG, "PUBLISH: Scheduling Wi-Fi Aware restart") + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000) + } + } + }, + Handler(Looper.getMainLooper()) + ) + + // SUBSCRIBE (client role) + session.subscribe( + SubscribeConfig.Builder() + .setServiceName(SERVICE_NAME) + .setServiceSpecificInfo(myPeerID.toByteArray(Charsets.UTF_8)) + .build(), + object : DiscoverySessionCallback() { + override fun onSubscribeStarted(sub: SubscribeDiscoverySession) { + if (!isCurrentSession(generation)) { + sub.close() + return + } + subscribeSession = sub + Log.d(TAG, "SUBSCRIBE: onSubscribeStarted()") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi-Fi Aware Subscribe Started")) } catch (_: Exception) {} + } + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray, + matchFilter: List + ) { + if (!isCurrentSession(generation)) return + val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } + handleToPeerId[peerHandle] = peerId + // This handle came from the subscribe session, so it is valid for + // subscribeSession.sendMessage() (used by maintenance reconnection). + if (peerId.isNotBlank()) subscribeHandles[peerId] = peerHandle + sendSubscribePing(peerId, peerHandle, "discovery") + if (peerId.isNotBlank()) rememberDiscoveredPeer(peerId) + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun onMessageReceived( + peerHandle: PeerHandle, + message: ByteArray + ) { + if (!isCurrentSession(generation)) return + if (message.isEmpty()) return + handleServerReady(peerHandle, message) + } + + override fun onSessionTerminated() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "SUBSCRIBE: onSessionTerminated()") + subscribeSession = null + val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop(generation) + if (shouldRestart) { + Log.i(TAG, "SUBSCRIBE: Scheduling Wi-Fi Aware restart") + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000) + } + } + }, + Handler(Looper.getMainLooper()) + ) + } + override fun onAttachFailed() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "Wi-Fi Aware attach failed") + handleUnexpectedStop(generation) + if (com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(3000) + } + } + + override fun onAwareSessionTerminated() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "Aware Session Terminated unexpectedly") + wifiAwareSession = null + val shouldRestart = com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop(generation) + if (shouldRestart) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(3000) + } + } + }, Handler(Looper.getMainLooper())) + + // Register with cross-layer transport bridge + TransportBridgeService.register("WIFI", this) + + meshCore.startCore() + com.bitchat.android.service.MeshServiceHolder.startSharedGossip("WIFI") + startPeriodicConnectionMaintenance() + connectionTracker.start() + } + + /** + * Stops the Wi-Fi Aware mesh services and cleans up sockets and sessions. + */ + override fun stopServices() { + val wasActive = isActive + isActive = false + sessionGeneration.incrementAndGet() + Log.i(TAG, "Stopping Wi-Fi Aware mesh") + + // Unregister from bridge + TransportBridgeService.unregister("WIFI") + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { } + + if (wasActive) { + meshCore.sendLeaveAnnouncement() + } + + serviceScope.launch { + delay(200) + + meshCore.stopCore() + connectionTracker.stop() // Handles socket closing and callback unregistration + + publishSession?.close(); publishSession = null + subscribeSession?.close(); subscribeSession = null + wifiAwareSession?.close(); wifiAwareSession = null + + handleToPeerId.clear() + subscribeHandles.clear() + publishHandles.clear() + discoveredTimestamps.clear() + + meshCore.shutdown() + + // Tear down listener threads; this instance is discarded after a full stop. + try { listenerExec.shutdownNow() } catch (_: Exception) { } + + com.bitchat.android.wifiaware.WifiAwareController.onServiceStopped(this@WifiAwareMeshService) + serviceScope.cancel() + } + } + + private fun isCurrentSession(generation: Int): Boolean { + return generation == sessionGeneration.get() && isActive + } + + private fun handleUnexpectedStop(generation: Int) { + if (generation != sessionGeneration.get()) return + if (!isActive) { + return + } + recoveryInProgress = true + isActive = false + TransportBridgeService.unregister("WIFI") + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { } + val oldPublishSession = publishSession + val oldSubscribeSession = subscribeSession + val oldWifiAwareSession = wifiAwareSession + serviceScope.launch { + try { + try { meshCore.stopCore() } catch (_: Exception) { } + try { connectionTracker.stop() } catch (_: Exception) { } + try { oldPublishSession?.close() } catch (_: Exception) { } + try { oldSubscribeSession?.close() } catch (_: Exception) { } + try { oldWifiAwareSession?.close() } catch (_: Exception) { } + if (generation == sessionGeneration.get() && !isActive) { + if (publishSession === oldPublishSession) publishSession = null + if (subscribeSession === oldSubscribeSession) subscribeSession = null + if (wifiAwareSession === oldWifiAwareSession) wifiAwareSession = null + handleToPeerId.clear() + subscribeHandles.clear() + publishHandles.clear() + discoveredTimestamps.clear() + } + } finally { + recoveryInProgress = false + // Recovery cleanup is done; nudge a restart now that startServices() will no + // longer be deferred by recoveryInProgress. The controller coalesces requests. + if (com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(500) + } + } + } + } + + private fun rememberDiscoveredPeer(peerId: String) { + if (peerId.isBlank() || peerId == myPeerID) return + val now = System.currentTimeMillis() + discoveredTimestamps[peerId] = now + lastDiscoveryActivityAt.set(now) + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun offerServerPathIfAppropriate(peerId: String, peerHandle: PeerHandle, reason: String) { + val pubSession = publishSession ?: return + if (peerId.isBlank() || peerId == myPeerID || !amIServerFor(peerId)) return + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) return + + Log.d(TAG, "PUBLISH: offering server path to ${peerId.take(8)} after $reason") + handleSubscriberPing(pubSession, peerHandle) + } + + private fun refreshDiscoverySessions(reason: String, now: Long = System.currentTimeMillis()): Boolean { + if (!isActive || recoveryInProgress) return false + if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) return false + + val lastRefresh = lastDiscoveryRefreshAt.get() + if ((now - lastRefresh) < DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS) return false + if (!lastDiscoveryRefreshAt.compareAndSet(lastRefresh, now)) return false + + Log.i(TAG, "Maintenance: refreshing Wi-Fi Aware discovery sessions ($reason)") + handleUnexpectedStop(sessionGeneration.get()) + return true + } + + /** + * Periodic active maintenance: retries connections to discovered but unconnected peers. + */ + private fun startPeriodicConnectionMaintenance() { + serviceScope.launch { + Log.d(TAG, "Starting periodic connection maintenance loop") + while (isActive) { + try { + delay(15_000) // Check every 15 seconds + if (!isActive) break + + val now = System.currentTimeMillis() + + // 0. Prune stale discovery entries. PeerHandles become invalid when the + // discovery sessions restart, so we must not keep pinging old handles forever. + val staleIds = discoveredTimestamps.filter { (id, ts) -> + (now - ts) >= DISCOVERY_STALE_MS && !connectionTracker.isConnected(id) + }.keys.toSet() + if (staleIds.isNotEmpty()) { + staleIds.forEach { discoveredTimestamps.remove(it) } + handleToPeerId.entries.removeIf { it.value in staleIds } + staleIds.forEach { subscribeHandles.remove(it) } + staleIds.forEach { publishHandles.remove(it) } + Log.d(TAG, "Maintenance: pruned ${staleIds.size} stale discovery entries") + } + + // 1. Identify peers that are discovered (recently seen) but not currently connected + val recentDiscovered = discoveredTimestamps.filter { (id, ts) -> + (now - ts) < DISCOVERY_STALE_MS // Seen in last 5 minutes + }.keys + + // 2. Filter out those who are already connected + val disconnectedPeers = recentDiscovered.filter { peerId -> + !connectionTracker.isConnected(peerId) + } + + // 3. Attempt reconnection. Aware discovery is not always symmetrical: + // subscribe handles can disappear while publish handles still see the peer. + var attemptedReconnect = false + var missingUsableHandle = false + for (peerId in disconnectedPeers) { + if (amIServerFor(peerId)) { + val handle = publishHandles[peerId] + if (handle == null) { + missingUsableHandle = true + continue + } + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) continue + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Log.i(TAG, "Maintenance: offering Wi-Fi Aware server path to ${peerId.take(8)}") + offerServerPathIfAppropriate(peerId, handle, "maintenance") + attemptedReconnect = true + } + continue + } + + // Use a subscribe-session-scoped handle. A publish-scoped handle would be + // invalid for subscribeSession.sendMessage() and silently fail. + val handle = subscribeHandles[peerId] + if (handle == null) { + missingUsableHandle = true + continue + } + + // Check tracker policy + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) continue + + Log.i(TAG, "Maintenance: attempting Wi-Fi Aware reconnect to ${peerId.take(8)}") + sendSubscribePing(peerId, handle, "maintenance") + attemptedReconnect = true + } + + val noActiveDataPath = connectionTracker.getConnectionCount() == 0 && + !connectionTracker.hasPendingDataPathRequest() + if (noActiveDataPath) { + val idleFor = now - lastDiscoveryActivityAt.get() + when { + disconnectedPeers.isNotEmpty() && missingUsableHandle && !attemptedReconnect -> { + refreshDiscoverySessions("missing peer handle", now) + } + recentDiscovered.isEmpty() && idleFor >= DISCOVERY_IDLE_REFRESH_MS -> { + refreshDiscoverySessions("idle discovery", now) + } + } + } + } catch (e: CancellationException) { + break + } catch (e: Exception) { + Log.e(TAG, "Error in connection maintenance: ${e.message}") + } + } + } + } + + private fun sendSubscribePing(peerId: String, peerHandle: PeerHandle, reason: String) { + if (peerId.isBlank()) return + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + try { + subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray()) + Log.d(TAG, "SUBSCRIBE: sent $reason ping to '${peerId.take(16)}' (msgId=$msgId)") + } catch (e: Exception) { + Log.w(TAG, "Failed to send $reason ping to ${peerId.take(8)}: ${e.message}") + } + } + + private fun requestRoleReversal(peerId: String, allowForcedClientOverride: Boolean = false) { + if (peerId.isBlank()) return + if (forcedClientPeers.contains(peerId) && !allowForcedClientOverride) return + forcedServerPeers.add(peerId) + forcedClientPeers.remove(peerId) + + val handle = subscribeHandles[peerId] + if (handle == null) { + Log.i(TAG, "CLIENT: role reversal queued for ${peerId.take(8)} until subscribe handle is available") + return + } + + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + val payload = "$ROLE_REVERSAL_PREFIX$myPeerID".toByteArray() + try { + subscribeSession?.sendMessage(handle, msgId, payload) + Log.i(TAG, "CLIENT: requested Wi-Fi Aware role reversal with ${peerId.take(8)} (msgId=$msgId)") + } catch (e: Exception) { + Log.w(TAG, "CLIENT: failed to request role reversal with ${peerId.take(8)}: ${e.message}") + } + } + + private fun shouldRequestRoleReversalAfterClientFailure(peerId: String): Boolean { + val failures = clientSocketFailures + .computeIfAbsent(peerId) { AtomicInteger(0) } + .incrementAndGet() + val shouldReverse = failures >= CLIENT_ROLE_REVERSAL_FAILURES + if (shouldReverse) { + clientSocketFailures.remove(peerId) + Log.i(TAG, "CLIENT: ${peerId.take(8)} failed $failures client socket attempts; requesting role reversal") + } else { + Log.d(TAG, "CLIENT: ${peerId.take(8)} failed client socket attempt $failures/$CLIENT_ROLE_REVERSAL_FAILURES; retrying same role") + } + return shouldReverse + } + + private fun handleRoleReversalRequest(peerHandle: PeerHandle, requesterId: String) { + if (requesterId.isBlank() || requesterId == myPeerID) return + handleToPeerId[peerHandle] = requesterId + discoveredTimestamps[requesterId] = System.currentTimeMillis() + forcedClientPeers.add(requesterId) + forcedServerPeers.remove(requesterId) + Log.i(TAG, "PUBLISH: role reversal requested by ${requesterId.take(8)}; switching to client role") + + subscribeHandles[requesterId]?.let { handle -> + sendSubscribePing(requesterId, handle, "role-reversal") + } + } + + /** + * Handles subscriber ping: spawns a server socket and responds with connection info. + * + * @param pubSession The current publish discovery session + * @param peerHandle The handle for the peer that pinged us + */ + @RequiresApi(Build.VERSION_CODES.Q) + private fun handleSubscriberPing( + pubSession: PublishDiscoverySession, + peerHandle: PeerHandle + ) { + val peerId = handleToPeerId[peerHandle] ?: return + if (!amIServerFor(peerId)) return + + if (connectionTracker.isConnected(peerId)) { + Log.v(TAG, "↪ already connected to $peerId, skipping serve") + return + } + if (connectionTracker.hasOpenServerSocket(peerId)) { + Log.v(TAG, "↪ already serving $peerId, skipping") + return + } + if (connectionTracker.hasPendingDataPathRequest(peerId)) { + val pending = connectionTracker.pendingDataPathPeerIds(peerId).joinToString(", ") { it.take(8) } + Log.d(TAG, "SERVER: deferring serve for ${peerId.take(8)}; pending Aware data path(s): $pending") + return + } + if (!connectionTracker.addPendingConnection(peerId)) { + return + } + + val ss = ServerSocket() + try { + ss.reuseAddress = true + val anyIpv6 = Inet6Address.getByAddress(ByteArray(16)) + ss.bind(java.net.InetSocketAddress(anyIpv6, 0)) + } catch (e: Exception) { + Log.e(TAG, "Failed to bind server socket", e) + handleNetworkFailure(peerId) + return + } + + connectionTracker.addServerSocket(peerId, ss) + val port = ss.localPort + + Log.d(TAG, "SERVER: listening for ${peerId.take(8)} on ${ss.localSocketAddress}") + + val spec = WifiAwareNetworkSpecifier.Builder(pubSession, peerHandle) + .setPskPassphrase(PSK) + .setPort(port) + .setTransportProtocol(OsConstants.IPPROTO_TCP) + .build() + // Default capabilities include NET_CAPABILITY_NOT_VPN. + // Keeping defaults for hardware interface handle acquisition compatibility with global VPNs. + val req = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(spec) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + @Volatile private var activeSocket: SyncedSocket? = null + private val acceptStarted = AtomicBoolean(false) + + override fun onAvailable(network: Network) { + Log.i(TAG, "SERVER: onAvailable() - Aware network is ready for ${peerId.take(8)}") + // Only accept once per network request + if (!acceptStarted.compareAndSet(false, true)) return + // Offload the blocking accept() off the callback thread so we never stall + // the (main-thread) ConnectivityManager callback dispatcher. + listenerExec.execute { + try { + try { ss.soTimeout = ACCEPT_TIMEOUT_MS } catch (_: Exception) {} + val client = ss.accept() + Log.i(TAG, "SERVER: Accepted raw TCP connection from ${peerId.take(8)}") + try { network.bindSocket(client) } catch (e: Exception) { Log.w(TAG, "Server bindSocket EPERM: ${e.message}") } + client.keepAlive = true + Log.i(TAG, "SERVER: Bound and established TCP with ${peerId.take(8)} addr=${client.inetAddress?.hostAddress}") + val synced = SyncedSocket(client) + activeSocket = synced + connectionTracker.onClientConnected(peerId, synced) + // We only ever accept a single data socket per server request. Close the + // listening ServerSocket now so it can't block a future re-serve (its + // presence makes hasOpenServerSocket() true for the life of the process) + // and so we free the fd/port promptly. + connectionTracker.closeServerSocket(peerId) + try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} + try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(synced, peerId) } + handleSubscriberKeepAlive(synced, peerId, pubSession, peerHandle) + + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + meshCore.initiateNoiseHandshake(peerId) + Log.i(TAG, "SERVER: Initiating Noise handshake to ${peerId.take(8)}") + } + // Ensure fast presence even before handshake settles + serviceScope.launch { delay(150); sendBroadcastAnnounce() } + } catch (ioe: IOException) { + if (ss.isClosed || !isActive) { + Log.d(TAG, "SERVER: accept stopped for ${peerId.take(8)} after socket cleanup") + } else { + Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}", ioe) + handleNetworkFailure(peerId) + } + } + } + } + + override fun onUnavailable() { + Log.e(TAG, "SERVER: onUnavailable() - Failed to acquire Aware network for ${peerId.take(8)} (timeout or refused)") + handleNetworkFailure(peerId) + } + + override fun onLost(network: Network) { + handlePeerDisconnection(peerId, activeSocket) + Log.i(TAG, "SERVER: WiFi Aware network lost for ${peerId.take(8)}") + } + } + + connectionTracker.addNetworkCallback(peerId, cb) + Log.i(TAG, "SERVER: [Calling requestNetwork] for ${peerId.take(8)} with port $port") + try { + // use requestNetwork with a timeout to trigger onUnavailable if it fails + cm.requestNetwork(req, cb, NETWORK_REQUEST_TIMEOUT_MS) + } catch (e: Exception) { + Log.e(TAG, "SERVER: ConnectivityManager.requestNetwork threw exception", e) + connectionTracker.disconnect(peerId) + } + + val readyId = (System.nanoTime() and 0x7fffffff).toInt() + val readyPayload = buildServerReadyPayload(port) + Handler(Looper.getMainLooper()).post { + try { + val sent = pubSession.sendMessage(peerHandle, readyId, readyPayload) + Log.d(TAG, "PUBLISH: server-ready sent=$sent (msgId=$readyId, port=$port)") + } catch (e: Exception) { + Log.e(TAG, "PUBLISH: Exception sending server-ready to $peerHandle", e) + } + } + } + + /** + * Sends periodic TCP and discovery keep-alive messages to maintain a subscriber connection. + * + * @param client Connected client socket + * @param peerId ID of the connected peer + */ + private fun handleSubscriberKeepAlive( + client: SyncedSocket, + peerId: String, + pubSession: PublishDiscoverySession, + peerHandle: PeerHandle + ) { + // TCP keep-alive pings + serviceScope.launch { + try { + while (connectionTracker.isConnected(peerId)) { + // write empty byte array effectively sends [4 bytes length=0] which is our ping + try { + client.write(ByteArray(0)) + } catch (_: IOException) { + // The write side is dead. Don't just stop pinging: actively tear down so the + // half-open socket stops counting as "connected" and maintenance can retry. + handlePeerDisconnection(peerId, client) + break + } + delay(2_000) + } + } catch (_: Exception) {} + } + // Discovery keep-alive + serviceScope.launch { + var msgId = 0 + while (connectionTracker.isConnected(peerId)) { + try { pubSession.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } + delay(20_000) + } + } + } + + private fun connectAwareClientSocket( + network: Network, + scopedAddr: Inet6Address, + port: Int, + peerId: String + ): Socket { + var lastFailure: IOException? = null + for (attempt in 1..CLIENT_SOCKET_ATTEMPTS) { + val delayMs = if (attempt == 1) CLIENT_SOCKET_READY_DELAY_MS else CLIENT_SOCKET_RETRY_DELAY_MS + if (delayMs > 0) { + try { + Thread.sleep(delayMs) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw InterruptedIOException("Interrupted before Wi-Fi Aware socket connect") + } + } + + var sock: Socket? = null + try { + sock = network.socketFactory.createSocket() + sock.tcpNoDelay = true + sock.keepAlive = true + sock.connect(java.net.InetSocketAddress(scopedAddr, port), CLIENT_CONNECT_TIMEOUT_MS) + if (attempt > 1) { + Log.i(TAG, "CLIENT: socket connect succeeded for ${peerId.take(8)} on attempt $attempt") + } + return sock + } catch (e: IOException) { + lastFailure = e + try { sock?.close() } catch (_: Exception) { } + if (attempt < CLIENT_SOCKET_ATTEMPTS) { + Log.w(TAG, "CLIENT: socket attempt $attempt/$CLIENT_SOCKET_ATTEMPTS failed for ${peerId.take(8)}: ${e.message}; retrying") + } + } + } + + throw lastFailure ?: IOException("Wi-Fi Aware socket connect failed without an exception") + } + + private fun buildServerReadyPayload(port: Int): ByteArray { + val peerIdBytes = myPeerID.toByteArray(Charsets.UTF_8) + return ByteBuffer.allocate(Int.SIZE_BYTES + peerIdBytes.size) + .order(ByteOrder.BIG_ENDIAN) + .putInt(port) + .put(peerIdBytes) + .array() + } + + private fun peerIdFromServerReadyPayload(payload: ByteArray): String? { + if (payload.size <= Int.SIZE_BYTES) return null + val peerId = try { + String(payload.copyOfRange(Int.SIZE_BYTES, payload.size), Charsets.UTF_8).trim() + } catch (_: Exception) { + return null + } + return peerId.takeIf { id -> + id.length == 16 && id.all { ch -> ch in '0'..'9' || ch in 'a'..'f' || ch in 'A'..'F' } + }?.lowercase() + } + + private fun resolveServerReadyPeerId(peerHandle: PeerHandle, payload: ByteArray): String? { + val advertisedPeerId = peerIdFromServerReadyPayload(payload) + val mappedPeerId = handleToPeerId[peerHandle]?.takeIf { it.isNotBlank() } + val peerId = advertisedPeerId ?: mappedPeerId + if (peerId == null) { + Log.w(TAG, "SUBSCRIBE: dropped server-ready with no peer mapping and no peer ID payload (payload=${payload.size}B)") + return null + } + + handleToPeerId[peerHandle] = peerId + subscribeHandles[peerId] = peerHandle + rememberDiscoveredPeer(peerId) + if (advertisedPeerId != null && mappedPeerId != null && advertisedPeerId != mappedPeerId) { + Log.d(TAG, "SUBSCRIBE: server-ready remapped handle ${mappedPeerId.take(8)} -> ${advertisedPeerId.take(8)}") + } + return peerId + } + + /** + * Handles a "server ready" message from a publishing peer and initiates a client connection. + */ + @RequiresApi(Build.VERSION_CODES.Q) + private fun handleServerReady( + peerHandle: PeerHandle, + payload: ByteArray + ) { + if (payload.size < Int.SIZE_BYTES) { + Log.w(TAG, "handleServerReady called with invalid payload size=${payload.size}, dropping") + return + } + + val peerId = resolveServerReadyPeerId(peerHandle, payload) ?: return + if (peerId == myPeerID) return + if (amIServerFor(peerId)) return + if (connectionTracker.peerSockets.containsKey(peerId)) { + Log.v(TAG, "↪ already client-connected to $peerId, skipping") + return + } + val cancelledServerOffers = connectionTracker.cancelPendingServerDataPaths(peerId) + if (cancelledServerOffers.isNotEmpty()) { + val cancelled = cancelledServerOffers.joinToString(", ") { it.take(8) } + Log.i(TAG, "CLIENT: preempted pending server offer(s) for $cancelled to connect ${peerId.take(8)}") + } + if (connectionTracker.hasPendingDataPathRequest(peerId)) { + val pending = connectionTracker.pendingDataPathPeerIds(peerId).joinToString(", ") { it.take(8) } + Log.d(TAG, "CLIENT: deferring server-ready for ${peerId.take(8)}; pending Aware data path(s): $pending") + return + } + if (!connectionTracker.addPendingConnection(peerId)) { + return + } + + val port = ByteBuffer.wrap(payload, 0, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int + Log.i(TAG, "CLIENT: Received server-ready from ${peerId.take(8)} on port $port (payload=${payload.size}B). Requesting network...") + + val subSession = subscribeSession ?: run { + Log.w(TAG, "CLIENT: subscribe session missing for server-ready from ${peerId.take(8)}") + connectionTracker.removePendingConnection(peerId) + return + } + val spec = WifiAwareNetworkSpecifier.Builder(subSession, peerHandle) + .setPskPassphrase(PSK) + .build() + val req = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(spec) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + @Volatile private var activeSocket: SyncedSocket? = null + private val connectStarted = AtomicBoolean(false) + + override fun onAvailable(network: Network) { + Log.i(TAG, "CLIENT: onAvailable() - Aware network is ready for ${peerId.take(8)}") + // Do not bind process for Aware; use per-socket binding instead + } + + override fun onUnavailable() { + Log.e(TAG, "CLIENT: onUnavailable() - Failed to acquire Aware network for ${peerId.take(8)}") + if (shouldRequestRoleReversalAfterClientFailure(peerId)) { + requestRoleReversal(peerId, allowForcedClientOverride = true) + } + handleNetworkFailure(peerId) + } + + override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) { + if (connectionTracker.peerSockets.containsKey(peerId)) return + val info = (nc.transportInfo as? WifiAwareNetworkInfo) ?: return + val addr = info.peerIpv6Addr as? Inet6Address ?: return + val connectPort = if (info.port > 0) info.port else port + // onCapabilitiesChanged can fire multiple times; only connect once + if (!connectStarted.compareAndSet(false, true)) return + Log.i(TAG, "CLIENT: onCapabilitiesChanged() - Peer IPv6 discovered: $addr port=$connectPort") + + val lp = cm.getLinkProperties(network) + val iface = lp?.interfaceName + + // Offload the blocking connect() off the callback thread. + listenerExec.execute { + try { + // Use scoped IPv6 if interface name is available + val scopedAddr = if (iface != null && addr.scopeId == 0) { + try { + Inet6Address.getByAddress(null, addr.address, java.net.NetworkInterface.getByName(iface)) + } catch (e: Exception) { + addr + } + } else { + addr + } + + val sock = connectAwareClientSocket(network, scopedAddr, connectPort, peerId) + Log.i(TAG, "CLIENT: TCP connected to ${peerId.take(8)} at $scopedAddr:$connectPort") + + val synced = SyncedSocket(sock) + activeSocket = synced + connectionTracker.onClientConnected(peerId, synced) + clientSocketFailures.remove(peerId) + try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} + try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(synced, peerId) } + handleServerKeepAlive(synced, peerId, peerHandle) + + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + meshCore.initiateNoiseHandshake(peerId) + Log.i(TAG, "CLIENT: Initiating Noise handshake to ${peerId.take(8)}") + } + // Ensure fast presence even before handshake settles + serviceScope.launch { delay(150); sendBroadcastAnnounce() } + } catch (ioe: IOException) { + Log.e(TAG, "CLIENT: socket connect failed to ${peerId.take(8)}", ioe) + if (shouldRequestRoleReversalAfterClientFailure(peerId)) { + requestRoleReversal(peerId, allowForcedClientOverride = true) + } + handleNetworkFailure(peerId) + } + } + } + override fun onLost(network: Network) { + handlePeerDisconnection(peerId, activeSocket) + Log.i(TAG, "CLIENT: WiFi Aware network lost for ${peerId.take(8)}") + } + } + + connectionTracker.addNetworkCallback(peerId, cb) + Log.i(TAG, "CLIENT: [Calling requestNetwork] for ${peerId.take(8)}") + try { + cm.requestNetwork(req, cb, NETWORK_REQUEST_TIMEOUT_MS) + } catch (e: Exception) { + Log.e(TAG, "CLIENT: ConnectivityManager.requestNetwork threw exception", e) + connectionTracker.disconnect(peerId) + } + } + + /** + * Sends periodic TCP and discovery keep-alive messages for server connections. + */ + private fun handleServerKeepAlive( + sock: SyncedSocket, + peerId: String, + peerHandle: PeerHandle + ) { + // TCP keep-alive + serviceScope.launch { + try { + while (connectionTracker.isConnected(peerId)) { + try { + sock.write(ByteArray(0)) + } catch (_: IOException) { + // The write side is dead. Tear down so the half-open socket stops counting + // as "connected" and maintenance can retry instead of silently stalling. + handlePeerDisconnection(peerId, sock) + break + } + delay(2_000) + } + } catch (_: Exception) {} + } + // Discovery keep-alive + serviceScope.launch { + var msgId = 0 + while (connectionTracker.isConnected(peerId)) { + try { subscribeSession?.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } + delay(20_000) + } + } + } + + /** + * Determines whether this device should act as the server in a given peer relationship. + */ + private fun amIServerFor(peerId: String): Boolean = when { + forcedClientPeers.contains(peerId) -> false + forcedServerPeers.contains(peerId) -> true + else -> myPeerID < peerId + } + + /** + * Listens for incoming packets from a connected peer and dispatches them through + * the packet processor. + * + * @param socket Socket connected to the peer + * @param initialLogicalPeerId Temporary identifier before peer ID resolution + */ + private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) { + var logicalPeerId = initialLogicalPeerId + while (isActive) { + val raw = socket.read() ?: break + + if (raw.isEmpty()) { + // Keep-alive (0 length frame) + continue + } + + val pkt = BitchatPacket.fromBinaryData(raw) ?: continue + + val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue + + if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) { + val previousPeerId = logicalPeerId + logicalPeerId = connectionTracker.rebindPeerId(previousPeerId, senderPeerHex, socket) + handleToPeerId.forEach { (handle, peerId) -> + if (peerId == previousPeerId) { + handleToPeerId[handle] = senderPeerHex + } + } + subscribeHandles.remove(previousPeerId)?.let { subscribeHandles[senderPeerHex] = it } + discoveredTimestamps.remove(previousPeerId) + discoveredTimestamps[senderPeerHex] = System.currentTimeMillis() + try { meshCore.setDirectConnection(previousPeerId, false) } catch (_: Exception) { } + try { meshCore.removePeer(previousPeerId) } catch (_: Exception) { } + try { meshCore.setDirectConnection(senderPeerHex, true) } catch (_: Exception) { } + publishHandles.remove(previousPeerId)?.let { publishHandles[senderPeerHex] = it } + Log.i(TAG, "RX: rebound Wi-Fi direct peer ${previousPeerId.take(8)} -> ${senderPeerHex.take(8)}") + } + + // Route the packet: + // - peerID = Originator (who signed it) + // - relayAddress = Neighbor (who sent it to us over this socket) + Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${logicalPeerId.take(8)} (bytes=${raw.size})") + meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId) + } + + // Breaking out of the loop means the socket is dead or service is stopping. + Log.i(TAG, "Socket loop terminated for ${logicalPeerId.take(8)} removing peer.") + handlePeerDisconnection(logicalPeerId, socket) + socket.close() + } + + private fun handleNetworkFailure(peerId: String) { + serviceScope.launch { + Log.d(TAG, "Network failure cleanup for: $peerId") + if (!connectionTracker.isConnected(peerId)) { + val canonicalPeerId = connectionTracker.canonicalPeerId(peerId) + connectionTracker.disconnect(peerId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != peerId) { + meshCore.removePeer(peerId) + } + } else { + Log.d(TAG, "Network failure ignored for $peerId - another socket is active") + } + } + } + + private fun handlePeerDisconnection(initialId: String, socket: SyncedSocket? = null) { + serviceScope.launch { + // Check if this socket is the current active one before nuking the session + val currentSocket = connectionTracker.getSocketForPeer(initialId) + val canonicalPeerId = connectionTracker.canonicalPeerId(initialId) + if (currentSocket === socket) { + Log.d(TAG, "Cleaning up peer: $canonicalPeerId (active socket)") + connectionTracker.disconnect(initialId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != initialId) { + meshCore.removePeer(initialId) + } + } else if (socket == null && currentSocket == null) { + // Fallback: If we don't have a specific socket context but we are already disconnected, ensure cleanup + Log.d(TAG, "Cleaning up peer: $initialId (no active socket)") + connectionTracker.disconnect(initialId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != initialId) { + meshCore.removePeer(initialId) + } + } else { + Log.d(TAG, "Ignored disconnection for $initialId - socket replaced or inactive") + // Do not remove peer/session, as a new socket has likely taken over + } + } + } + + /** + * Sends a broadcast message to all peers. + * @param content Text content of the message + * @param mentions Optional list of mentioned peer IDs + * @param channel Optional channel name + */ + override fun sendMessage(content: String, mentions: List, channel: String?) { + meshCore.sendMessage(content, mentions, channel) + } + + /** + * Sends a private encrypted message to a specific peer. + * + * @param content The message text + * @param recipientPeerID Destination peer ID + * @param recipientNickname Recipient nickname + * @param messageID Optional message ID (UUID if null) + */ + override fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String?) { + meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + } + + /** + * Sends a read receipt for a specific message to the given peer over an established + * Noise session. If no session exists, this will log an error. + * + * @param messageID The ID of the message that was read. + * @param recipientPeerID The peer to notify. + * @param readerNickname Nickname of the reader (may be shown by the receiver). + */ + override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + meshCore.sendReadReceipt(messageID, recipientPeerID, readerNickname) + } + + override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + meshCore.sendVerifyChallenge(peerID, noiseKeyHex, nonceA) + } + + override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + meshCore.sendVerifyResponse(peerID, noiseKeyHex, nonceA) + } + + /** + * Broadcasts a file (TLV payload) to all peers. Uses protocol version 2 to support + * large payloads and generates a deterministic transferId (sha256 of payload) for UI/state. + * + * @param file Encoded metadata and chunks descriptor of the file to send. + */ + override fun sendFileBroadcast(file: BitchatFilePacket) { + meshCore.sendFileBroadcast(file) + } + + /** + * Sends a file privately to a specific peer. If no Noise session is established, + * a handshake will be initiated and the send is deferred/aborted for now. + * + * @param recipientPeerID Target peer. + * @param file Encoded metadata and chunks descriptor of the file to send. + */ + override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + meshCore.sendFilePrivate(recipientPeerID, file) + } + + /** + * Attempts to cancel an in-flight file transfer identified by its transferId. + * + * @param transferId Deterministic id (usually sha256 of the file TLV). + * @return true if a transfer with this id was found and cancellation was scheduled, false otherwise. + */ + override fun cancelFileTransfer(transferId: String): Boolean { + return meshCore.cancelFileTransfer(transferId) + } + + /** + * Broadcasts an ANNOUNCE packet to the entire mesh. + */ + override fun sendBroadcastAnnounce() { + meshCore.sendBroadcastAnnounce() + } + + /** + * Sends an ANNOUNCE packet to a specific peer. + */ + override fun sendAnnouncementToPeer(peerID: String) { + meshCore.sendAnnouncementToPeer(peerID) + } + + /** @return Mapping of peer IDs to nicknames. */ + override fun getPeerNicknames(): Map = meshCore.getPeerNicknames() + + /** @return Mapping of peer IDs to RSSI values. */ + override fun getPeerRSSI(): Map = meshCore.getPeerRSSI() + + /** @return current active peer count for status surfaces. */ + override fun getActivePeerCount(): Int = meshCore.getActivePeerCount() + + /** + * @return true if a Noise session with the peer is fully established. + */ + override fun hasEstablishedSession(peerID: String) = meshCore.hasEstablishedSession(peerID) + + /** + * @return a human-readable Noise session state for the given peer (implementation-defined). + */ + override fun getSessionState(peerID: String) = meshCore.getSessionState(peerID) + + /** + * Triggers a Noise handshake with the given peer. Safe to call repeatedly; no-op if already handshaking/established. + */ + override fun initiateNoiseHandshake(peerID: String) = meshCore.initiateNoiseHandshake(peerID) + + /** + * @return the stored public-key fingerprint (hex) for a peer, if known. + */ + override fun getPeerFingerprint(peerID: String): String? = meshCore.getPeerFingerprint(peerID) + + /** + * Retrieves the full profile for a peer, including keys and verification state, if available. + */ + override fun getPeerInfo(peerID: String): PeerInfo? = meshCore.getPeerInfo(peerID) + + /** + * Updates local metadata for a peer and returns whether the change was applied. + * + * @param peerID Target peer id. + * @param nickname Display name. + * @param noisePublicKey Peer’s Noise static public key. + * @param signingPublicKey Peer’s Ed25519 signing public key. + * @param isVerified Whether this identity is verified by the user. + * @return true if the record was updated or created, false otherwise. + */ + override fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean = meshCore.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + + /** + * @return the local device’s long-term identity fingerprint (hex). + */ + override fun getIdentityFingerprint(): String = meshCore.getIdentityFingerprint() + + override fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey() + + /** + * @return true if the UI should show an “encrypted” indicator for this peer. + */ + override fun shouldShowEncryptionIcon(peerID: String) = meshCore.shouldShowEncryptionIcon(peerID) + + /** + * @return a snapshot list of peers with established Noise sessions. + */ + override fun getEncryptedPeers(): List = meshCore.getEncryptedPeers() + + /** + * @return the current IPv4/IPv6 address of a connected peer, if any. + * Prefers the scoped IPv6 address format. + */ + override fun getDeviceAddressForPeer(peerID: String): String? = + meshCore.getDeviceAddressForPeer(peerID) + + /** + * Helper to resolve a scoped IPv6 address from a socket for UI display. + */ + private fun resolveScopedAddress(sock: Socket): String? { + val addr = sock.inetAddress as? Inet6Address ?: return sock.inetAddress?.hostAddress + if (addr.scopeId != 0 || addr.isLoopbackAddress) return addr.hostAddress + + // If address has no scope but we are on Aware (Link-Local fe80), attempt interface resolution + val iface = try { + val lp = cm.getLinkProperties(cm.activeNetwork) + lp?.interfaceName ?: "aware0" + } catch (_: Exception) { "aware0" } + + return "${addr.hostAddress}%$iface" + } + + /** + * @return a mapping of peerID → connected device IP address for all active sockets. + * Results are formatted as scoped addresses if applicable. + */ + override fun getDeviceAddressToPeerMapping(): Map = + meshCore.getDeviceAddressToPeerMapping() + + /** + * @return map of peer ID to nickname, bridged for UI warning fix. + */ + fun getPeerNicknamesMap(): Map = meshCore.getPeerNicknames() + + /** Returns recently discovered peer IDs via Aware discovery (may not be connected). */ + fun getDiscoveredPeerIds(): Set = + (handleToPeerId.values + discoveredTimestamps.keys).filter { it.isNotBlank() }.toSet() + + /** + * Utility for logs/UI: pretty-prints one peer-to-address mapping per line. + */ + override fun printDeviceAddressesForPeers(): String = + getDeviceAddressToPeerMapping().entries.joinToString("\n") { "${it.key} -> ${it.value}" } + + /** + * @return A detailed string containing the debug status of all mesh components. + */ + override fun getDebugStatus(): String { + return meshCore.getDebugStatus( + transportInfo = connectionTracker.getDebugInfo(), + deviceMap = getDeviceAddressToPeerMapping(), + extraLines = listOf("Peers: ${connectionTracker.peerSockets.keys}"), + title = "Wi-Fi Aware Mesh Debug Status" + ) + } + + override fun clearAllInternalData() { + meshCore.clearAllInternalData() + } + + override fun clearAllEncryptionData() { + meshCore.clearAllEncryptionData() + } + + /** Utility extension to safely close server sockets. */ + private fun ServerSocket.closeQuietly() = try { close() } catch (_: Exception) {} + + + private inner class WifiAwareTransport : MeshTransport { + override val id: String = "WIFI" + + override fun broadcastPacket(routed: RoutedPacket) { + this@WifiAwareMeshService.broadcastPacket(routed) + } + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) + } + override fun cancelTransfer(transferId: String): Boolean { + return fragmentingSender.cancelTransfer(transferId) + } + override fun getDeviceAddressForPeer(peerID: String): String? { + return connectionTracker.getSocketForPeer(peerID)?.let { resolveScopedAddress(it.rawSocket) } + } + + override fun getDeviceAddressToPeerMapping(): Map { + val map = mutableMapOf() + connectionTracker.peerSockets.forEach { (pid, sock) -> + map[pid] = resolveScopedAddress(sock.rawSocket) ?: "unknown" + } + return map + } + override fun getTransportDebugInfo(): String { + return connectionTracker.getDebugInfo() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt new file mode 100644 index 00000000..3e7dbc58 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt @@ -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 + } + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 7ddae251..3d72337a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -362,6 +362,7 @@ كن أول من يضيف ملاحظة لهذا المكان. إغلاق أضف ملاحظة لهذا المكان + بلوتوث موصى به الشبكة تعمل — %1$d أقران verify diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 24f94630..3cda37a6 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -349,6 +349,7 @@ %d জন + ব্লুটুথ প্রস্তাবিত মেশ চলছে — %1$d পিয়ার verify diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c02bbb37..c6cb4aac 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -363,6 +363,7 @@ sei der Erste, der hier eine Notiz hinterlässt. schließen füge eine Notiz zu diesem Ort hinzu + Bluetooth empfohlen Mesh läuft — %1$d Peers verifizieren diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index eae48a0e..b8fed3aa 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -362,6 +362,7 @@ sé el primero en añadir una para este sitio. cerrar agrega una nota para este lugar + Bluetooth recomendado Mesh en ejecución — %1$d pares verificar diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 1473c5fd..8ad97fe9 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -349,6 +349,7 @@ %d نفر + بلوتوث توصیه می شود مش در حال اجرا — %1$d همتا verify diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 0a7dc2e3..34e1d5f5 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -362,6 +362,7 @@ %d tao + Bluetooth Recommended Tumatakbo ang Mesh — %1$d na peer verify diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 01f480d2..9828a76b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -376,6 +376,7 @@ soyez le premier à en ajouter pour cet endroit. fermer ajoutez une note pour cet endroit + Bluetooth recommandé Mesh actif — %1$d pairs vérifier diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index deb22711..e18b57b8 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -14,6 +14,8 @@ היה הראשון להוסיף הערה למקום זה. סגור הוסף הערה למקום זה + Skip + Bluetooth Recommended רשת Mesh פועלת — %1$d עמיתים verify diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index acaa54b6..02d64711 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -362,6 +362,7 @@ इस स्थान के लिए पहला नोट जोड़ें। बंद करें इस स्थान के लिए एक नोट जोड़ें + ब्लूटूथ अनुशंसित मेश चल रहा है — %1$d पीयर्स verify diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 18c85a5b..fb9830be 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -362,6 +362,7 @@ jadilah yang pertama menambahkan catatan untuk tempat ini. tutup tambahkan catatan untuk tempat ini + Bluetooth Recommended Mesh berjalan — %1$d peer verify diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 07fb5a76..3db4e85e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -396,6 +396,7 @@ sii il primo ad aggiungerne una per questo posto. chiudi aggiungi una nota per questo luogo + Bluetooth consigliato Mesh in esecuzione — %1$d peer verifica diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index f9c4fd64..df489157 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -362,6 +362,7 @@ この場所の最初のメモを追加しましょう。 閉じる この場所へのメモを追加 + Bluetooth推奨 メッシュ実行中 — %1$d ピア 検証 diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index db24c8a9..2d1c6693 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -349,6 +349,7 @@ %d ადამიანი + Bluetooth Recommended Mesh გაშვებულია — %1$d პირები verify diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 0510fb7d..68d46136 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -362,6 +362,7 @@ 이 장소에 첫 번째 노트를 추가해 보세요. 닫기 이 장소에 노트 추가 + 블루투스 권장 메시 실행 중 — %1$d 피어 verify diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 5fc53577..57676dec 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -376,6 +376,7 @@ Olona %d + Bluetooth Recommended Mandeha ny Mesh — %1$d peers verify diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 0e6dd945..b4b23655 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1,6 +1,8 @@ + Skip + Bluetooth Recommended Mesh sedang berjalan — %1$d rakan verify diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 6632f0dc..252f81c4 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -362,6 +362,7 @@ %d जना + ब्लुटुथ सिफारिस गरिएको मेश चलिरहेको छ — %1$d पियर्स verify diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7d19b52a..59705572 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -394,6 +394,7 @@ wees de eerste die een notitie voor deze plek toevoegt. sluiten voeg een notitie toe voor deze plek + Bluetooth aanbevolen Mesh actief — %1$d peers verify diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 3f08aa32..58135f7c 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -349,6 +349,7 @@ %d بندے + Bluetooth Recommended میش چل رہا ہے — %1$d ساتھی verify diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 56cd087a..cdbffdd0 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -14,6 +14,8 @@ bądź pierwszy, który doda notatkę dla tego miejsca. zamknij dodaj notatkę dla tego miejsca + Pomiń + Bluetooth zalecany Mesh działa — %1$d peerów verify diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e9686f99..45f41512 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -362,6 +362,7 @@ seja o primeiro a adicionar uma para este local. fechar adicione uma nota para este local + Bluetooth recomendado Mesh rodando — %1$d pares verificar diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 8f9baf72..147b5f37 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -362,6 +362,7 @@ seja o primeiro a adicionar uma para este local. fechar adicione uma nota para este local + Bluetooth recomendado Mesh em execução — %1$d pares verificar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index cad2a0d1..dade7d1d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -352,6 +352,7 @@ станьте первым, кто добавит заметку для этого места. закрыть добавьте заметку для этого места + Рекомендуется Bluetooth Mesh запущен — %1$d пиров проверить diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index ef3307e3..9ac7df67 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -350,6 +350,7 @@ var först med att lägga till en anteckning för den här platsen. stäng lägg till en anteckning för den här platsen + Bluetooth rekommenderas Mesh körs — %1$d peers verify diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 98ceef9c..1e487548 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -1,6 +1,8 @@ + Skip + Bluetooth Recommended மெஷ் இயங்குகிறது — %1$d பியர்ஸ் verify diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 343f6885..4a4c5021 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -349,6 +349,7 @@ %d คน + Bluetooth Recommended Mesh กำลังทำงาน — %1$d เพื่อน verify diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1209074c..a2df9dfe 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -350,6 +350,7 @@ bu yer için ilk notu ekleyen siz olun. kapat bu yer için bir not ekleyin + Bluetooth Önerilir Mesh çalışıyor — %1$d eş verify diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 5545b546..2c631d04 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1,6 +1,8 @@ + Пропустити + Рекомендується Bluetooth Mesh працює — %1$d пірів verify diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 1626a041..291c3e9a 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -362,6 +362,7 @@ اس جگہ کے لیے پہلا نوٹ شامل کریں۔ بند کریں اس جگہ کے لیے ایک نوٹ شامل کریں + Bluetooth Recommended میش چل رہا ہے — %1$d ساتھی verify diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index afc227b6..a1315617 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -349,6 +349,7 @@ %d người + Khuyên dùng Bluetooth Mesh đang chạy — %1$d ngang hàng verify diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9f4b17d3..69bff70c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -14,6 +14,8 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + 跳过 + 建议开启蓝牙 Mesh 运行中 — %1$d 个节点 验证 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a347d5b0..1dcf6b27 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -14,6 +14,8 @@ 成為第一個為此地點新增筆記的人。 關閉 為此地點新增一則筆記 + 跳過 + 建議開啟藍牙 Mesh 運行中 — %1$d 個節點 验证 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 3a4a628f..46caf1af 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -375,6 +375,7 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + 建议开启蓝牙 Mesh 运行中 — %1$d 个节点 验证 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d34dce0a..27a5c5e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -452,4 +452,5 @@ %d people + Bluetooth Recommended diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index f1b33d32..431d8500 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -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() { diff --git a/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt b/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt new file mode 100644 index 00000000..83802149 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt @@ -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 + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt new file mode 100644 index 00000000..2c093353 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -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() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 3c680777..eb5fcd71 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -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()) + } }