From ff740229dc0222ce4106b3adc4378798c5850904 Mon Sep 17 00:00:00 2001 From: CC Date: Tue, 9 Jun 2026 14:25:23 +0200 Subject: [PATCH] improve --- .../java/com/bitchat/android/MainActivity.kt | 56 +-- .../mesh/BluetoothGattClientManager.kt | 137 ++++++- .../mesh/BluetoothGattServerManager.kt | 44 ++ .../android/mesh/BluetoothMeshService.kt | 18 +- .../java/com/bitchat/android/mesh/MeshCore.kt | 44 +- .../com/bitchat/android/mesh/MeshDelegate.kt | 2 + .../com/bitchat/android/mesh/MeshService.kt | 5 + .../android/mesh/UnifiedMeshService.kt | 386 ++++++++++++++++++ .../android/service/AppShutdownCoordinator.kt | 4 +- .../android/service/MeshForegroundService.kt | 21 +- .../android/service/MeshServiceHolder.kt | 24 ++ .../bitchat/android/services/MessageRouter.kt | 83 +--- .../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 | 213 ++++------ .../bitchat/android/ui/CommandProcessor.kt | 34 +- .../android/ui/LocationChannelsSheet.kt | 2 +- .../android/ui/MatrixEncryptionAnimation.kt | 6 +- .../bitchat/android/ui/MediaSendingManager.kt | 8 +- .../bitchat/android/ui/MeshDelegateHandler.kt | 34 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 38 +- .../bitchat/android/ui/MessageComponents.kt | 8 +- .../bitchat/android/ui/PrivateChatManager.kt | 25 +- .../android/ui/SecurityVerificationSheet.kt | 2 +- .../bitchat/android/ui/VerificationHandler.kt | 6 +- .../bitchat/android/ui/VerificationSheet.kt | 2 +- .../android/ui/media/AudioMessageItem.kt | 4 +- .../android/ui/media/ImageMessageItem.kt | 4 +- .../android/wifi-aware/WifiAwareController.kt | 1 + .../wifi-aware/WifiAwareMeshService.kt | 10 + 31 files changed, 837 insertions(+), 394 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 9d86fadb..3e6b482e 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.Lifecycle import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.onboarding.BluetoothCheckScreen import com.bitchat.android.onboarding.BluetoothStatus import com.bitchat.android.onboarding.BluetoothStatusManager @@ -41,7 +42,6 @@ import com.bitchat.android.ui.ChatViewModel import com.bitchat.android.ui.OrientationAwareActivity import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.wifiaware.WifiAwareController -import com.bitchat.android.wifiaware.WifiAwareMeshDelegate import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.services.VerificationService import kotlinx.coroutines.delay @@ -57,43 +57,13 @@ class MainActivity : OrientationAwareActivity() { // Core mesh service - provided by the foreground service holder private lateinit var meshService: BluetoothMeshService + private lateinit var unifiedMeshService: MeshService private val mainViewModel: MainViewModel by viewModels() private val chatViewModel: ChatViewModel by viewModels { object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") - return ChatViewModel(application, meshService) as T - } - } - } - - private val wifiAwareDelegate by lazy { - object : WifiAwareMeshDelegate { - override fun didReceiveMessage(message: com.bitchat.android.model.BitchatMessage) { - chatViewModel.didReceiveMessage(message) - } - override fun didUpdatePeerList(peers: List) { - chatViewModel.onWifiPeersUpdated(peers) - } - override fun didReceiveChannelLeave(channel: String, fromPeer: String) { - chatViewModel.didReceiveChannelLeave(channel, fromPeer) - } - override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { - chatViewModel.didReceiveDeliveryAck(messageID, recipientPeerID) - } - override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { - chatViewModel.didReceiveReadReceipt(messageID, recipientPeerID) - } - override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { - return chatViewModel.decryptChannelMessage(encryptedContent, channel) - } - override fun getNickname(): String? { - return chatViewModel.getNickname() - } - override fun isFavorite(peerID: String): Boolean { - return try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true - } catch (_: Exception) { false } + return ChatViewModel(application, meshService, unifiedMeshService) as T } } } @@ -147,6 +117,7 @@ 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 @@ -201,13 +172,12 @@ class MainActivity : OrientationAwareActivity() { } } - // Bridge Wi‑Fi Aware callbacks into ChatViewModel (reusing BLE delegate methods) + // Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI. lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { WifiAwareController.running.collect { running -> - val svc = WifiAwareController.getService() - if (running && svc != null && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - svc.delegate = wifiAwareDelegate + if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + unifiedMeshService.delegate = chatViewModel } } } @@ -742,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") @@ -788,8 +758,7 @@ 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 { WifiAwareController.getService()?.delegate = wifiAwareDelegate } catch (_: Exception) { } + try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { } // Check if Bluetooth was disabled while app was backgrounded val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() @@ -821,8 +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 { WifiAwareController.getService()?.delegate = null } catch (_: Exception) { } + try { unifiedMeshService.delegate = null } catch (_: Exception) { } } } 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 429711c5..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 @@ -78,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 @@ -121,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 @@ -136,6 +153,8 @@ class BluetoothGattClientManager( * Stop client manager */ fun stop() { + scanningDesired = false + stopScanWatchdog() if (!isActive) { // Idempotent stop stopScanning() @@ -166,6 +185,7 @@ class BluetoothGattClientManager( */ fun onScanStateChanged(shouldScan: Boolean) { val enabled = isClientRoleEnabled() + scanningDesired = shouldScan && enabled if (shouldScan && enabled) { startScanning() } else { @@ -266,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") } } } @@ -319,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 @@ -335,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) { 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 14ba0bf6..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,6 +45,7 @@ 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 @@ -385,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" } @@ -393,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") + } + } } } @@ -418,6 +445,23 @@ 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) */ 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 50194a5e..14bc6913 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -1500,18 +1500,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } /** - * 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/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index c0ea54eb..46518428 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -315,11 +315,11 @@ class MeshCore( } override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) { - // MeshDelegate intentionally does not expose QR verification yet. + delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs) } override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) { - // MeshDelegate intentionally does not expose QR verification yet. + delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) } } @@ -549,6 +549,44 @@ class MeshCore( } } + 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() @@ -718,6 +756,8 @@ class MeshCore( fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint() + fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey() + fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID) fun getEncryptedPeers(): List = emptyList() diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt index 4c12dbd1..de662384 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt @@ -11,6 +11,8 @@ interface MeshDelegate { 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/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 331b36b1..c612e8ed 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -15,6 +15,10 @@ interface MeshService { 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 @@ -38,6 +42,7 @@ interface MeshService { isVerified: Boolean ): Boolean fun getIdentityFingerprint(): String + fun getStaticNoisePublicKey(): ByteArray? fun shouldShowEncryptionIcon(peerID: String): Boolean fun getEncryptedPeers(): List 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/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 c1ba78c9..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 } @@ -256,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) @@ -276,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 71dddb66..7d8b3014 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,7 @@ package com.bitchat.android.service import android.content.Context import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.UnifiedMeshService /** * Process-wide holder to share a single BluetoothMeshService instance @@ -19,6 +20,10 @@ object MeshServiceHolder { var meshService: BluetoothMeshService? = null private set + @Volatile + var unifiedMeshService: UnifiedMeshService? = null + private set + @Synchronized fun getOrCreate(context: Context): BluetoothMeshService { val existing = meshService @@ -37,18 +42,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 } @@ -56,11 +78,13 @@ 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") meshService = null + unifiedMeshService = null } } 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 bd33be21..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,7 +2,6 @@ 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 @@ -12,14 +11,14 @@ import com.bitchat.android.nostr.NostrTransport */ 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) @@ -71,15 +70,10 @@ class MessageRouter private constructor( } } - val aware = getAwareService() val hasMesh = isConnected(mesh, toPeerID) - val hasAwareConnection = isConnected(aware, 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 (isReady(aware, toPeerID)) { - Log.d(TAG, "Routing PM via Wi‑Fi Aware to ${toPeerID} msg_id=${messageID.take(8)}…") - aware?.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) } else if (canSendViaNostr(toPeerID)) { Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}… msg_id=${messageID.take(8)}…") nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) @@ -88,23 +82,14 @@ 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)}…") - when { - hasMesh -> mesh.initiateNoiseHandshake(toPeerID) - hasAwareConnection -> aware?.initiateNoiseHandshake(toPeerID) - else -> aware?.initiateNoiseHandshake(toPeerID) - } + if (hasMesh) mesh.initiateNoiseHandshake(toPeerID) } } fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) { - val aware = getAwareService() 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 if (isReady(aware, toPeerID)) { - Log.d(TAG, "Routing READ via Wi‑Fi Aware to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") - val me = try { aware?.myPeerID } catch (_: Exception) { null } - aware?.sendReadReceipt(receipt.originalMessageID, toPeerID, me ?: "") } else { Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") nostr.sendReadReceipt(receipt, toPeerID) @@ -127,7 +112,7 @@ 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 @@ -142,34 +127,23 @@ class MessageRouter private constructor( val queued = outbox[peerID] ?: return if (queued.isEmpty()) return Log.d(TAG, "Flushing outbox for ${peerID.take(8)}… count=${queued.size}") - val aware = getAwareService() val iterator = queued.iterator() while (iterator.hasNext()) { val (content, nickname, messageID) = iterator.next() val hasMesh = isReady(mesh, peerID) - val hasAware = isReady(aware, peerID) // If this is a noiseHex key, see if there is a connected mesh peer for this identity - if (!hasMesh && !hasAware && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { + if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { val meshPeer = resolvePeerForNoiseHex(peerID, mesh) if (meshPeer != null && isReady(mesh, meshPeer)) { mesh.sendPrivateMessage(content, meshPeer, nickname, messageID) iterator.remove() continue } - val awarePeer = resolvePeerForNoiseHex(peerID, aware) - if (awarePeer != null && isReady(aware, awarePeer)) { - aware?.sendPrivateMessage(content, awarePeer, nickname, messageID) - iterator.remove() - continue - } } val canNostr = canSendViaNostr(peerID) if (hasMesh) { mesh.sendPrivateMessage(content, peerID, nickname, messageID) iterator.remove() - } else if (hasAware) { - aware?.sendPrivateMessage(content, peerID, nickname, messageID) - iterator.remove() } else if (canNostr) { nostr.sendPrivateMessage(content, peerID, nickname, messageID) iterator.remove() @@ -207,11 +181,7 @@ class MessageRouter private constructor( return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() } - private fun getAwareService(): MeshService? { - return try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } - } - - private fun isConnected(service: BluetoothMeshService, peerID: String): Boolean { + private fun isConnected(service: MeshService, peerID: String): Boolean { return try { service.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { @@ -219,15 +189,7 @@ class MessageRouter private constructor( } } - private fun isConnected(service: MeshService?, peerID: String): Boolean { - return try { - service != null && service.getPeerInfo(peerID)?.isConnected == true - } catch (_: Exception) { - false - } - } - - private fun isReady(service: BluetoothMeshService, peerID: String): Boolean { + private fun isReady(service: MeshService, peerID: String): Boolean { return try { service.getPeerInfo(peerID)?.isConnected == true && service.hasEstablishedSession(peerID) @@ -236,29 +198,8 @@ class MessageRouter private constructor( } } - private fun isReady(service: MeshService?, peerID: String): Boolean { + private fun resolvePeerForNoiseHex(noiseHex: String, service: MeshService): String? { return try { - service != null && - service.getPeerInfo(peerID)?.isConnected == true && - service.hasEstablishedSession(peerID) - } catch (_: Exception) { - false - } - } - - private fun resolvePeerForNoiseHex(noiseHex: String, service: BluetoothMeshService): 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) - } - } catch (_: Exception) { null } - } - - private fun resolvePeerForNoiseHex(noiseHex: String, service: MeshService?): String? { - return try { - if (service == null) return null service.getPeerNicknames().keys.firstOrNull { pid -> val info = service.getPeerInfo(pid) val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } @@ -275,10 +216,6 @@ class MessageRouter private constructor( mesh.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } } catch (_: Exception) { null } noiseHex?.let { flushOutboxFor(it) } - val awareNoiseHex = try { - getAwareService()?.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } - } catch (_: Exception) { null } - awareNoiseHex?.let { flushOutboxFor(it) } } } @@ -289,9 +226,5 @@ class MessageRouter private constructor( mesh.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } } catch (_: Exception) { null } noiseHex?.let { flushOutboxFor(it) } - val awareNoiseHex = try { - getAwareService()?.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } - } catch (_: Exception) { null } - awareNoiseHex?.let { flushOutboxFor(it) } } } 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 d38b1169..ed61f567 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 { @@ -95,7 +100,7 @@ class ChatViewModel( private val noiseSessionDelegate = object : NoiseSessionDelegate { override fun hasEstablishedSession(peerID: String): Boolean = hasEstablishedSessionOnAnyLocalTransport(peerID) override fun initiateHandshake(peerID: String) = initiateNoiseHandshakeOnBestLocalTransport(peerID) - override fun getMyPeerID(): String = meshService.myPeerID + 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", emptyList(), null) + 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,25 +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 + Wi‑Fi Aware - meshService.sendMessage(messageContent, mentions, channel) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(messageContent, mentions, channel) } catch (_: Exception) {} + 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" @@ -506,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) @@ -526,10 +541,10 @@ class ChatViewModel( 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 { @@ -537,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)) { @@ -560,25 +575,20 @@ class ChatViewModel( mentions, currentChannelValue, state.getNicknameValue(), - meshService.myPeerID, + mesh.myPeerID, onEncryptedPayload = { encryptedData -> - // Send encrypted payload announcement over both transports for reachability - meshService.sendMessage(content, mentions, currentChannelValue) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} + mesh.sendMessage(content, mentions, currentChannelValue) }, onFallback = { - meshService.sendMessage(content, mentions, currentChannelValue) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} + mesh.sendMessage(content, mentions, currentChannelValue) } ) } else { - meshService.sendMessage(content, mentions, currentChannelValue) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} + mesh.sendMessage(content, mentions, currentChannelValue) } } else { messageManager.addMessage(message) - meshService.sendMessage(content, mentions, null) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, null) } catch (_: Exception) {} + mesh.sendMessage(content, mentions, null) } } } @@ -587,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) { @@ -597,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 @@ -630,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) { } @@ -662,21 +659,9 @@ class ChatViewModel( Log.i("ChatViewModel", "==============================") } - private fun getWifiAwareService(): com.bitchat.android.mesh.MeshService? { - return try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } - } - private fun isConnectedOnMesh(peerID: String): Boolean { return try { - meshService.getPeerInfo(peerID)?.isConnected == true - } catch (_: Exception) { - false - } - } - - private fun isConnectedOnService(service: com.bitchat.android.mesh.MeshService?, peerID: String): Boolean { - return try { - service != null && service.getPeerInfo(peerID)?.isConnected == true + mesh.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false } @@ -684,55 +669,28 @@ class ChatViewModel( private fun hasEstablishedSessionOnMesh(peerID: String): Boolean { return try { - meshService.getPeerInfo(peerID)?.isConnected == true && - meshService.hasEstablishedSession(peerID) - } catch (_: Exception) { - false - } - } - - private fun hasEstablishedSessionOnService(service: com.bitchat.android.mesh.MeshService?, peerID: String): Boolean { - return try { - service != null && - service.getPeerInfo(peerID)?.isConnected == true && - service.hasEstablishedSession(peerID) + mesh.getPeerInfo(peerID)?.isConnected == true && + mesh.hasEstablishedSession(peerID) } catch (_: Exception) { false } } private fun hasEstablishedSessionOnAnyLocalTransport(peerID: String): Boolean { - return hasEstablishedSessionOnMesh(peerID) || - hasEstablishedSessionOnService(getWifiAwareService(), peerID) + return hasEstablishedSessionOnMesh(peerID) } private fun initiateNoiseHandshakeOnBestLocalTransport(peerID: String) { - val aware = getWifiAwareService() - when { - isConnectedOnMesh(peerID) -> meshService.initiateNoiseHandshake(peerID) - isConnectedOnService(aware, peerID) -> aware?.initiateNoiseHandshake(peerID) - else -> meshService.initiateNoiseHandshake(peerID) - } + mesh.initiateNoiseHandshake(peerID) } private fun nicknameForPeer(peerID: String): String? { return state.peerNicknames.value[peerID] - ?: try { meshService.getPeerNicknames()[peerID] } catch (_: Exception) { null } - ?: try { getWifiAwareService()?.getPeerNicknames()?.get(peerID) } catch (_: Exception) { null } + ?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null } } private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState { - val meshState = try { meshService.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized } - val awareState = try { getWifiAwareService()?.getSessionState(peerID) } catch (_: Exception) { null } - return when { - meshState is NoiseSession.NoiseSessionState.Established -> meshState - awareState is NoiseSession.NoiseSessionState.Established -> awareState - meshState is NoiseSession.NoiseSessionState.Handshaking -> meshState - awareState is NoiseSession.NoiseSessionState.Handshaking -> awareState - meshState !is NoiseSession.NoiseSessionState.Uninitialized -> meshState - awareState != null -> awareState - else -> meshState - } + return try { mesh.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized } } /** @@ -766,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) } } @@ -775,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) @@ -786,23 +744,14 @@ class ChatViewModel( } } - // Merge nicknames from BLE and Wi‑Fi Aware to display names for all peers - val bleNick = meshService.getPeerNicknames() - val awareNickRaw = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknamesMap() } catch (_: Exception) { null } - val mergedNick = if (awareNickRaw != null) bleNick + awareNickRaw.filter { it.value != null }.mapValues { it.value!! }.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick - state.setPeerNicknames(mergedNick) + state.setPeerNicknames(mesh.getPeerNicknames()) - val rssiValues = meshService.getPeerRSSI() - val awareRssi = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerRSSI() } catch (_: Exception) { null } - val mergedRssi = if (awareRssi != null) rssiValues + awareRssi.filterKeys { it !in rssiValues } else rssiValues - state.setPeerRSSI(mergedRssi) + state.setPeerRSSI(mesh.getPeerRSSI()) // Update directness per peer (driven by PeerManager state) try { val directMap = state.getConnectedPeersValue().associateWith { pid -> - val ble = meshService.getPeerInfo(pid)?.isDirectConnection == true - val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerInfo(pid)?.isDirectConnection == true } catch (_: Exception) { false } - ble || aware + mesh.getPeerInfo(pid)?.isDirectConnection == true } state.setPeerDirect(directMap) } catch (_: Exception) { } @@ -839,7 +788,7 @@ class ChatViewModel( // MARK: - Debug and Troubleshooting fun getDebugStatus(): String { - return meshService.getDebugStatus() + return mesh.getDebugStatus() } fun setCurrentPrivateChatPeer(peerID: String?) { @@ -936,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 { @@ -953,10 +902,6 @@ class ChatViewModel( meshDelegateHandler.didUpdatePeerList(peers) } - fun onWifiPeersUpdated(peers: List) { - meshDelegateHandler.onWifiPeersUpdated(peers) - } - override fun didReceiveChannelLeave(channel: String, fromPeer: String) { meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) } @@ -1040,7 +985,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}") } /** @@ -1048,25 +993,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}" ) } @@ -1076,7 +1023,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) { @@ -1090,7 +1037,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 18048f35..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,7 +29,7 @@ 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(" ") @@ -77,7 +77,7 @@ class CommandProcessor( } } - private fun handleMessageCommand(parts: List, meshService: BluetoothMeshService, viewModel: ChatViewModel?) { + private fun handleMessageCommand(parts: List, meshService: MeshService, viewModel: ChatViewModel?) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") val peerID = getPeerIDForNickname(targetName, meshService) @@ -128,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) { @@ -247,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) @@ -264,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) @@ -283,7 +283,7 @@ class CommandProcessor( parts: List, verb: String, object_: String, - meshService: BluetoothMeshService, + meshService: MeshService, myPeerID: String, onSendMessage: (String, List, String?) -> Unit, viewModel: ChatViewModel? @@ -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,31 +503,21 @@ 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 - ?: try { - com.bitchat.android.wifiaware.WifiAwareController.getService() - ?.getPeerNicknames() - ?.entries - ?.find { it.value == nickname } - ?.key - } catch (_: Exception) { - null - } } - private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String { + private fun getPeerNickname(peerID: String, meshService: MeshService): String { return meshService.getPeerNicknames()[peerID] - ?: try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknames()?.get(peerID) } catch (_: Exception) { null } ?: peerID } - private fun getMyPeerID(meshService: BluetoothMeshService): String { + private fun getMyPeerID(meshService: MeshService): String { return meshService.myPeerID } private fun sendPrivateMessageVia( - meshService: BluetoothMeshService, + meshService: MeshService, content: String, peerID: String, recipientNickname: String, 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 055825e8..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" @@ -213,7 +213,6 @@ class MediaSendingManager( Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID") meshService.sendFilePrivate(toPeerID, filePacket) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFilePrivate(toPeerID, filePacket) } catch (_: Exception) {} Log.d(TAG, "✅ File send completed successfully") } @@ -268,7 +267,6 @@ class MediaSendingManager( Log.d(TAG, "📤 Calling meshService.sendFileBroadcast") meshService.sendFileBroadcast(filePacket) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFileBroadcast(filePacket) } catch (_: Exception) {} Log.d(TAG, "✅ File broadcast completed successfully") } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 294f4ec9..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) { @@ -94,30 +94,13 @@ class MeshDelegateHandler( } } - private var blePeers: Set = emptySet() - private var wifiPeers: Set = emptySet() - override fun didUpdatePeerList(peers: List) { coroutineScope.launch { - blePeers = peers.toSet() - try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peers) } catch (_: Exception) { } - processPeerUpdate() + processPeerUpdate(peers.distinct()) } } - fun onWifiPeersUpdated(peers: List) { - coroutineScope.launch { - wifiPeers = peers.toSet() - try { com.bitchat.android.services.AppStateStore.setTransportPeers("WIFI", peers) } catch (_: Exception) { } - processPeerUpdate() - } - } - - private suspend fun processPeerUpdate() { - // Merge peers from multiple transports - val mergedPeers = com.bitchat.android.services.AppStateStore.peers.value - .ifEmpty { (blePeers + wifiPeers).toList() } - + private suspend fun processPeerUpdate(mergedPeers: List) { state.setConnectedPeers(mergedPeers) state.setIsConnected(mergedPeers.isNotEmpty()) notificationManager.showActiveUserNotification(mergedPeers) @@ -323,14 +306,7 @@ class MeshDelegateHandler( mesh.sendReadReceipt(message.id, senderPeerID, nickname) true } else { - val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } - val hasAware = try { aware?.getPeerInfo(senderPeerID)?.isConnected == true && aware.hasEstablishedSession(senderPeerID) } catch (_: Exception) { false } - if (hasAware) { - aware?.sendReadReceipt(message.id, senderPeerID, nickname) - true - } else { - false - } + false } } catch (_: Exception) { false 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 aee2ff22..f2bccdb6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -337,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!! } @@ -415,7 +415,7 @@ 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, @@ -787,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) { @@ -855,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 }, @@ -939,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) ) @@ -950,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 a0c581ee..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,9 +343,7 @@ class PrivateChatManager( } val myNickname = state.getNicknameValue() ?: "unknown" - val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } val hasMesh = try { meshService.getPeerInfo(peerID)?.isConnected == true && meshService.hasEstablishedSession(peerID) } catch (_: Exception) { false } - val hasAware = try { aware?.getPeerInfo(peerID)?.isConnected == true && aware.hasEstablishedSession(peerID) } catch (_: Exception) { false } var sentCount = 0 messages.forEach { msg -> // Only for incoming messages from this peer @@ -354,9 +352,6 @@ class PrivateChatManager( if (hasMesh) { meshService.sendReadReceipt(msg.id, peerID, myNickname) sentCount += 1 - } else if (hasAware) { - aware?.sendReadReceipt(msg.id, peerID, myNickname) - sentCount += 1 } } catch (e: Exception) { Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}") @@ -388,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 @@ -421,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/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/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt index 66853472..b89cd49e 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -151,6 +151,7 @@ object WifiAwareController { service = startedService _running.value = true } + try { com.bitchat.android.service.MeshServiceHolder.unifiedMeshService?.refreshDelegates() } catch (_: Exception) { } 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) { 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 index b4e5e202..ebd41b74 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -1103,6 +1103,14 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor 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. @@ -1205,6 +1213,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor */ 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. */