diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4ca2b898..e79fa8eb2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -19,6 +20,12 @@ + + + + + + @@ -47,6 +54,8 @@ + + + val svc = com.bitchat.android.wifiaware.WifiAwareController.getService() + if (running && svc != null) { + svc.delegate = object : com.bitchat.android.wifiaware.WifiAwareMeshDelegate { + override fun didReceiveMessage(message: com.bitchat.android.model.BitchatMessage) { + if (message.isPrivate) { + message.senderPeerID?.let { pid -> com.bitchat.android.services.AppStateStore.addPrivateMessage(pid, message) } + } else if (message.channel != null) { + com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel, message) + } else { + com.bitchat.android.services.AppStateStore.addPublicMessage(message) + } + 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 } + } + } + } + } + } + } // Only start onboarding process if we're in the initial CHECKING state // This prevents restarting onboarding on configuration changes @@ -217,6 +266,10 @@ class MainActivity : OrientationAwareActivity() { onRetry = { checkBluetoothAndProceed() }, + onSkip = { + mainViewModel.skipBluetoothCheck() + checkLocationAndProceed() + }, isLoading = isBluetoothLoading ) } @@ -335,6 +388,13 @@ class MainActivity : OrientationAwareActivity() { private fun checkBluetoothAndProceed() { // Log.d("MainActivity", "Checking Bluetooth status") + // Check if user has skipped Bluetooth check for this session + if (mainViewModel.isBluetoothCheckSkipped.value) { + Log.d("MainActivity", "Bluetooth check skipped by user, proceeding to location check") + checkLocationAndProceed() + return + } + // For first-time users, skip Bluetooth check and go straight to permissions // We'll check Bluetooth after permissions are granted if (permissionManager.isFirstTimeLaunch()) { @@ -347,6 +407,12 @@ class MainActivity : OrientationAwareActivity() { bluetoothStatusManager.logBluetoothStatus() mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) + val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + if (!bleRequired) { + // Skip BLE checks entirely when BLE is disabled in debug settings + checkLocationAndProceed() + return + } when (mainViewModel.bluetoothStatus.value) { BluetoothStatus.ENABLED -> { // Bluetooth is enabled, check location services next @@ -513,8 +579,9 @@ class MainActivity : OrientationAwareActivity() { else -> BatteryOptimizationStatus.ENABLED } + val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } when { - currentBluetoothStatus != BluetoothStatus.ENABLED -> { + bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> { // Bluetooth still disabled, but now we have permissions to enable it Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.") mainViewModel.updateBluetoothStatus(currentBluetoothStatus) @@ -698,7 +765,7 @@ class MainActivity : OrientationAwareActivity() { // Check if Bluetooth was disabled while app was backgrounded val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() - if (currentBluetoothStatus != BluetoothStatus.ENABLED) { + if (currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) { Log.w("MainActivity", "Bluetooth disabled while app was backgrounded") mainViewModel.updateBluetoothStatus(currentBluetoothStatus) mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) diff --git a/app/src/main/java/com/bitchat/android/MainViewModel.kt b/app/src/main/java/com/bitchat/android/MainViewModel.kt index 35125d855..15ec6fdac 100644 --- a/app/src/main/java/com/bitchat/android/MainViewModel.kt +++ b/app/src/main/java/com/bitchat/android/MainViewModel.kt @@ -35,6 +35,9 @@ class MainViewModel : ViewModel() { private val _isBatteryOptimizationLoading = MutableStateFlow(false) val isBatteryOptimizationLoading: StateFlow = _isBatteryOptimizationLoading.asStateFlow() + private val _isBluetoothCheckSkipped = MutableStateFlow(false) + val isBluetoothCheckSkipped: StateFlow = _isBluetoothCheckSkipped.asStateFlow() + // Public update functions for MainActivity fun updateOnboardingState(state: OnboardingState) { _onboardingState.value = state @@ -67,4 +70,8 @@ class MainViewModel : ViewModel() { fun updateBatteryOptimizationLoading(loading: Boolean) { _isBatteryOptimizationLoading.value = loading } + + fun skipBluetoothCheck() { + _isBluetoothCheckSkipped.value = true + } } \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index 7029185f5..1412732d8 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -16,14 +16,11 @@ import java.util.concurrent.CopyOnWriteArrayList class BluetoothConnectionTracker( private val connectionScope: CoroutineScope, private val powerManager: PowerManager -) { +) : MeshConnectionTracker(connectionScope, TAG) { companion object { private const val TAG = "BluetoothConnectionTracker" - private const val CONNECTION_RETRY_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_RETRY_DELAY_MS - private const val MAX_CONNECTION_ATTEMPTS = com.bitchat.android.util.AppConstants.Mesh.MAX_CONNECTION_ATTEMPTS private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_DELAY_MS - private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_INTERVAL_MS // 30 seconds } // Connection tracking - reduced memory footprint @@ -36,12 +33,6 @@ class BluetoothConnectionTracker( // RSSI tracking from scan results (for devices we discover but may connect as servers) private val scanRSSI = ConcurrentHashMap() - // Connection attempt tracking with automatic cleanup - private val pendingConnections = ConcurrentHashMap() - - // State management - private var isActive = false - /** * Consolidated device connection information */ @@ -54,37 +45,28 @@ class BluetoothConnectionTracker( val connectedAt: Long = System.currentTimeMillis() ) - /** - * Connection attempt tracking with automatic expiry - */ - data class ConnectionAttempt( - val attempts: Int, - val lastAttempt: Long = System.currentTimeMillis() - ) { - fun isExpired(): Boolean = - System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2 - - fun shouldRetry(): Boolean = - attempts < MAX_CONNECTION_ATTEMPTS && - System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY - } - - /** - * Start the connection tracker - */ - fun start() { - isActive = true - startPeriodicCleanup() + override fun start() { + super.start() } - /** - * Stop the connection tracker - */ - fun stop() { - isActive = false + override fun stop() { + super.stop() cleanupAllConnections() clearAllConnections() } + + // Abstract implementations + override fun isConnected(id: String): Boolean = connectedDevices.containsKey(id) + + override fun disconnect(id: String) { + connectedDevices[id]?.gatt?.let { + try { it.disconnect() } catch (_: Exception) { } + } + cleanupDeviceConnection(id) + Log.d(TAG, "Requested disconnect for $id") + } + + override fun getConnectionCount(): Int = connectedDevices.size /** * Add a device connection @@ -92,7 +74,7 @@ class BluetoothConnectionTracker( fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") connectedDevices[deviceAddress] = deviceConn - pendingConnections.remove(deviceAddress) + removePendingConnection(deviceAddress) // Mark as awaiting first ANNOUNCE on this connection firstAnnounceSeen[deviceAddress] = false } @@ -167,67 +149,17 @@ class BluetoothConnectionTracker( /** * Check if device is already connected */ - fun isDeviceConnected(deviceAddress: String): Boolean { - return connectedDevices.containsKey(deviceAddress) - } - - /** - * Check if connection attempt is allowed - */ - fun isConnectionAttemptAllowed(deviceAddress: String): Boolean { - val existingAttempt = pendingConnections[deviceAddress] - return existingAttempt?.let { - it.isExpired() || it.shouldRetry() - } ?: true - } - - /** - * Add a pending connection attempt - */ - fun addPendingConnection(deviceAddress: String): Boolean { - Log.d(TAG, "Tracker: Adding pending connection for $deviceAddress") - synchronized(pendingConnections) { - // Double-check inside synchronized block - val currentAttempt = pendingConnections[deviceAddress] - if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) { - Log.d(TAG, "Tracker: Connection attempt already in progress for $deviceAddress") - return false - } - if (currentAttempt != null) { - Log.d(TAG, "Tracker: current attempt: $currentAttempt") - } - - // Update connection attempt atomically - // If the previous attempt window expired, reset backoff to 1; otherwise increment - val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1 - pendingConnections[deviceAddress] = ConnectionAttempt(attempts) - Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)") - return true - } - } + fun isDeviceConnected(deviceAddress: String): Boolean = isConnected(deviceAddress) /** * Disconnect a specific device (by MAC address) */ - fun disconnectDevice(deviceAddress: String) { - connectedDevices[deviceAddress]?.gatt?.let { - try { it.disconnect() } catch (_: Exception) { } - } - cleanupDeviceConnection(deviceAddress) - Log.d(TAG, "Requested disconnect for $deviceAddress") - } - - /** - * Remove a pending connection - */ - fun removePendingConnection(deviceAddress: String) { - pendingConnections.remove(deviceAddress) - } + fun disconnectDevice(deviceAddress: String) = disconnect(deviceAddress) /** * Get connected device count */ - fun getConnectedDeviceCount(): Int = connectedDevices.size + fun getConnectedDeviceCount(): Int = getConnectionCount() /** * Check if connection limit is reached @@ -335,36 +267,6 @@ class BluetoothConnectionTracker( return firstAnnounceSeen[deviceAddress] == true } - /** - * Start periodic cleanup of expired connections - */ - private fun startPeriodicCleanup() { - connectionScope.launch { - while (isActive) { - delay(CLEANUP_INTERVAL) - - if (!isActive) break - - try { - // Clean up expired pending connections - val expiredConnections = pendingConnections.filter { it.value.isExpired() } - expiredConnections.keys.forEach { pendingConnections.remove(it) } - - // Log cleanup if any - if (expiredConnections.isNotEmpty()) { - Log.d(TAG, "Cleaned up ${expiredConnections.size} expired connection attempts") - } - - // Log current state - Log.d(TAG, "Periodic cleanup: ${connectedDevices.size} connections, ${pendingConnections.size} pending") - - } catch (e: Exception) { - Log.w(TAG, "Error in periodic cleanup: ${e.message}") - } - } - } - } - /** * Get debug information */ 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 3de484072..bc920f9bd 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -13,6 +13,7 @@ import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString +import com.bitchat.android.service.TransportBridgeService import kotlinx.coroutines.* import java.util.* import kotlin.math.sign @@ -31,7 +32,7 @@ import kotlin.random.Random * - BluetoothConnectionManager: BLE connections and GATT operations * - PacketProcessor: Incoming packet routing */ -class BluetoothMeshService(private val context: Context) { +class BluetoothMeshService(private val context: Context) : TransportBridgeService.TransportLayer { private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { @@ -94,13 +95,22 @@ class BluetoothMeshService(private val context: Context) { } catch (_: Exception) { 0.01 } } ) + + // Register as shared instance for Wi-Fi Aware transport + com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) // Wire sync manager delegate gossipSyncManager.delegate = object : GossipSyncManager.Delegate { override fun sendPacket(packet: BitchatPacket) { - connectionManager.broadcastPacket(RoutedPacket(packet)) + dispatchGlobal(RoutedPacket(packet)) } override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + // Point-to-point optimization if possible, but for bridge safety + // we might want to consider dispatchGlobal if peer is on another transport. + // However, sendPacketToPeer in connectionManager is BLE-specific unicast. + // If peer is on Wi-Fi, this won't reach. + // For now, let's keep unicast as-is (it's mostly for sync) + // and assume routing handles the rest via broadcasts if needed. connectionManager.sendPacketToPeer(peerID, packet) } override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { @@ -108,6 +118,26 @@ class BluetoothMeshService(private val context: Context) { } } Log.d(TAG, "Delegates set up; GossipSyncManager initialized") + + // Register with cross-layer transport bridge + TransportBridgeService.register("BLE", this) + } + + // TransportLayer implementation + override fun send(packet: RoutedPacket) { + // Received from bridge (e.g. Wi-Fi) -> Send via BLE + // Direct injection prevents routing loops (bridge handles source check) + connectionManager.broadcastPacket(packet) + } + + /** + * unified dispatch: Send to local BLE and bridge to other transports + */ + private fun dispatchGlobal(routed: RoutedPacket) { + // 1. Send to local BLE transport + connectionManager.broadcastPacket(routed) + // 2. Bridge to other transports (e.g. Wi-Fi) + TransportBridgeService.broadcast("BLE", routed) } /** @@ -161,8 +191,6 @@ class BluetoothMeshService(private val context: Context) { // PeerManager delegates to main mesh service delegate peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { - // Update process-wide state first - try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { } // Then notify UI delegate if attached delegate?.didUpdatePeerList(peerIDs) } @@ -205,7 +233,7 @@ class BluetoothMeshService(private val context: Context) { ) // Sign the handshake response val signedPacket = signPacketBeforeBroadcast(responsePacket) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)") } @@ -225,7 +253,7 @@ class BluetoothMeshService(private val context: Context) { } override fun sendPacket(packet: BitchatPacket) { - connectionManager.broadcastPacket(RoutedPacket(packet)) + dispatchGlobal(RoutedPacket(packet)) } } @@ -268,11 +296,12 @@ class BluetoothMeshService(private val context: Context) { override fun sendPacket(packet: BitchatPacket) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + val routed = RoutedPacket(signedPacket) + dispatchGlobal(routed) } override fun relayPacket(routed: RoutedPacket) { - connectionManager.broadcastPacket(routed) + dispatchGlobal(routed) } override fun getBroadcastRecipient(): ByteArray { @@ -319,7 +348,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the handshake packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)") } else { Log.w(TAG, "Failed to generate Noise handshake data for $peerID") @@ -511,7 +540,7 @@ class BluetoothMeshService(private val context: Context) { } override fun relayPacket(routed: RoutedPacket) { - connectionManager.broadcastPacket(routed) + dispatchGlobal(routed) } override fun handleRequestSync(routed: RoutedPacket) { @@ -628,6 +657,9 @@ class BluetoothMeshService(private val context: Context) { Log.i(TAG, "Stopping Bluetooth mesh service") isActive = false + + // Unregister from bridge + TransportBridgeService.unregister("BLE") // Send leave announcement sendLeaveAnnouncement() @@ -687,7 +719,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) // Track our own broadcast message for sync try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } } @@ -719,7 +751,7 @@ class BluetoothMeshService(private val context: Context) { val signed = signPacketBeforeBroadcast(packet) // Use a stable transferId based on the file TLV payload for progress tracking val transferId = sha256Hex(payload) - connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } } } catch (e: Exception) { @@ -777,7 +809,7 @@ class BluetoothMeshService(private val context: Context) { val signed = signPacketBeforeBroadcast(packet) // Use a stable transferId based on the unencrypted file TLV payload for progress tracking val transferId = sha256Hex(filePayload) - connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID") } catch (e: Exception) { @@ -857,7 +889,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)") // FIXED: Don't send didReceiveMessage for our own sent messages @@ -928,7 +960,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") // Persist as read after successful send @@ -982,7 +1014,7 @@ class BluetoothMeshService(private val context: Context) { announcePacket.copy(signature = signature) } ?: announcePacket - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)") // Track announce for sync try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } @@ -1031,7 +1063,7 @@ class BluetoothMeshService(private val context: Context) { packet.copy(signature = signature) } ?: packet - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) peerManager.markPeerAsAnnouncedTo(peerID) Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)") @@ -1052,7 +1084,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + dispatchGlobal(RoutedPacket(signedPacket)) } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt new file mode 100644 index 000000000..01df23c80 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt @@ -0,0 +1,140 @@ +package com.bitchat.android.mesh + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Abstract base tracker for mesh connections (BLE, Wi-Fi Aware, etc.) + * Encapsulates common state machine logic: + * - Connection attempt tracking (retries, backoff) + * - Pending connection management + * - Automatic cleanup of expired attempts + */ +abstract class MeshConnectionTracker( + private val scope: CoroutineScope, + protected val tag: String +) { + companion object { + const val CONNECTION_RETRY_DELAY = 5_000L + const val MAX_CONNECTION_ATTEMPTS = 3 + const val CLEANUP_INTERVAL = 30_000L + } + + /** + * Connection attempt tracking with automatic expiry + */ + protected data class ConnectionAttempt( + val attempts: Int, + val lastAttempt: Long = System.currentTimeMillis() + ) { + fun isExpired(): Boolean = + System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2 + + fun shouldRetry(): Boolean = + attempts < MAX_CONNECTION_ATTEMPTS && + System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY + } + + // Tracks in-progress or failed attempts + protected val pendingConnections = ConcurrentHashMap() + + private var isActive = false + + /** + * Start the tracker and its cleanup loop + */ + open fun start() { + isActive = true + startPeriodicCleanup() + } + + /** + * Stop the tracker + */ + open fun stop() { + isActive = false + pendingConnections.clear() + } + + /** + * Check if a connection attempt is allowed for this peer/address + */ + fun isConnectionAttemptAllowed(id: String): Boolean { + // If already connected, usually no need to retry (subclasses can override logic if needed, + // but typically the caller checks isConnected() first). + + val existingAttempt = pendingConnections[id] + return existingAttempt?.let { + it.isExpired() || it.shouldRetry() + } ?: true + } + + /** + * Record a new connection attempt. + * Returns true if the attempt was recorded (allowed), false if skipped. + */ + fun addPendingConnection(id: String): Boolean { + synchronized(pendingConnections) { + val currentAttempt = pendingConnections[id] + + // If strictly not allowed right now, reject + if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) { + Log.d(tag, "Connection attempt already in progress for $id") + return false + } + + // Update attempt count + // Reset to 1 if expired, otherwise increment + val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1 + pendingConnections[id] = ConnectionAttempt(attempts) + Log.d(tag, "Added pending connection for $id (attempts: $attempts)") + return true + } + } + + /** + * Remove a pending attempt (e.g., on success or fatal error) + */ + fun removePendingConnection(id: String) { + pendingConnections.remove(id) + } + + /** + * Abstract: Subclasses must define what "connected" means + */ + abstract fun isConnected(id: String): Boolean + + /** + * Abstract: Subclasses must implement disconnect logic + */ + abstract fun disconnect(id: String) + + /** + * Abstract: Subclasses report their active connection count + */ + abstract fun getConnectionCount(): Int + + private fun startPeriodicCleanup() { + scope.launch { + while (isActive) { + try { + delay(CLEANUP_INTERVAL) + if (!isActive) break + + // Clean up expired pending connections + val expired = pendingConnections.filter { it.value.isExpired() } + expired.keys.forEach { pendingConnections.remove(it) } + + if (expired.isNotEmpty()) { + Log.d(tag, "Cleaned up ${expired.size} expired connection attempts") + } + } catch (e: Exception) { + Log.w(tag, "Error in periodic cleanup: ${e.message}") + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt index bdfc9733d..60c6e5e47 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt @@ -26,6 +26,7 @@ fun BluetoothCheckScreen( status: BluetoothStatus, onEnableBluetooth: () -> Unit, onRetry: () -> Unit, + onSkip: () -> Unit, isLoading: Boolean = false ) { val colorScheme = MaterialTheme.colorScheme @@ -39,13 +40,15 @@ fun BluetoothCheckScreen( BluetoothDisabledContent( onEnableBluetooth = onEnableBluetooth, onRetry = onRetry, + onSkip = onSkip, colorScheme = colorScheme, isLoading = isLoading ) } BluetoothStatus.NOT_SUPPORTED -> { BluetoothNotSupportedContent( - colorScheme = colorScheme + colorScheme = colorScheme, + onSkip = onSkip ) } BluetoothStatus.ENABLED -> { @@ -61,6 +64,7 @@ fun BluetoothCheckScreen( private fun BluetoothDisabledContent( onEnableBluetooth: () -> Unit, onRetry: () -> Unit, + onSkip: () -> Unit, colorScheme: ColorScheme, isLoading: Boolean ) { @@ -77,7 +81,7 @@ private fun BluetoothDisabledContent( ) Text( - text = stringResource(R.string.bluetooth_required), + text = stringResource(R.string.bluetooth_recommended), style = MaterialTheme.typography.headlineSmall.copy( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, @@ -141,20 +145,17 @@ private fun BluetoothDisabledContent( ) } - //Since we are automatically checking bluetooth state -- commented - -// OutlinedButton( -// onClick = onRetry, -// modifier = Modifier.fillMaxWidth() -// ) { -// Text( -// text = "Check Again", -// style = MaterialTheme.typography.bodyMedium.copy( -// fontFamily = FontFamily.Monospace -// ), -// modifier = Modifier.padding(vertical = 4.dp) -// ) -// } + TextButton( + onClick = onSkip, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.skip), + style = MaterialTheme.typography.labelLarge.copy( + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + ) + } } } } @@ -162,7 +163,8 @@ private fun BluetoothDisabledContent( @Composable private fun BluetoothNotSupportedContent( - colorScheme: ColorScheme + colorScheme: ColorScheme, + onSkip: () -> Unit ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp), @@ -209,6 +211,16 @@ private fun BluetoothNotSupportedContent( textAlign = TextAlign.Center ) } + + Button( + onClick = onSkip, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.secondary + ) + ) { + Text(text = stringResource(R.string.continue_btn)) + } } } diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt index 871cc892d..ba4da701f 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt @@ -209,6 +209,7 @@ class OnboardingCoordinator( return when { permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices" permission.contains("LOCATION") -> "Location (for Bluetooth scanning)" + permission.contains("NEARBY_WIFI") -> "Nearby Wi‑Fi Devices (for Wi‑Fi Aware)" permission.contains("NOTIFICATION") -> "Notifications" else -> permission.substringAfterLast(".") } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index 2b84aefa1..c00f35f30 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* @@ -242,6 +243,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector { PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn PermissionType.MICROPHONE -> Icons.Filled.Mic PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications + PermissionType.WIFI_AWARE -> Icons.Filled.Wifi PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power PermissionType.OTHER -> Icons.Filled.Settings } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index ff0a160fd..aedf85f8e 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -67,6 +67,11 @@ class PermissionManager(private val context: Context) { Manifest.permission.ACCESS_FINE_LOCATION )) + // Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES) + } + // Notification permission intentionally excluded to keep it optional return permissions @@ -177,6 +182,20 @@ class PermissionManager(private val context: Context) { ) ) + // Wi‑Fi Aware category (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES) + categories.add( + PermissionCategory( + type = PermissionType.WIFI_AWARE, + description = "Enable Wi‑Fi Aware to discover and connect to nearby bitchat users over Wi‑Fi.", + permissions = wifiAwarePermissions, + isGranted = wifiAwarePermissions.all { isPermissionGranted(it) }, + systemDescription = "Allow bitchat to discover nearby Wi‑Fi devices" + ) + ) + } + // Notifications category (if applicable) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { categories.add( @@ -262,6 +281,7 @@ enum class PermissionType(val nameValue: String) { PRECISE_LOCATION("Precise Location"), MICROPHONE("Microphone"), NOTIFICATIONS("Notifications"), + WIFI_AWARE("Wi‑Fi Aware"), BATTERY_OPTIMIZATION("Battery Optimization"), OTHER("Other") } diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt index d271ab295..71dddb664 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -9,6 +9,12 @@ import com.bitchat.android.mesh.BluetoothMeshService */ object MeshServiceHolder { private const val TAG = "MeshServiceHolder" + @Volatile + var sharedGossipSyncManager: com.bitchat.android.sync.GossipSyncManager? = null + private set + + fun setGossipManager(mgr: com.bitchat.android.sync.GossipSyncManager) { sharedGossipSyncManager = mgr } + @Volatile var meshService: BluetoothMeshService? = null private set diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt new file mode 100644 index 000000000..61c73c822 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -0,0 +1,69 @@ +package com.bitchat.android.service + +import android.util.Log +import com.bitchat.android.model.RoutedPacket +import java.util.concurrent.ConcurrentHashMap + +/** + * Central bridge for routing packets between different transport layers + * (e.g., Bluetooth LE <-> Wi-Fi Aware). + * + * Allows a packet received on one transport to be seamlessly relayed + * to all other active transports, effectively bridging separate meshes. + */ +object TransportBridgeService { + private const val TAG = "TransportBridgeService" + + /** + * Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement + * to receive bridged packets. + */ + interface TransportLayer { + /** + * Send a packet out via this transport. + */ + fun send(packet: RoutedPacket) + } + + private val transports = ConcurrentHashMap() + + /** + * Register a transport layer to receive bridged packets. + * @param id Unique identifier (e.g., "BLE", "WIFI") + * @param layer The transport implementation + */ + fun register(id: String, layer: TransportLayer) { + Log.i(TAG, "Registering transport layer: $id") + transports[id] = layer + } + + /** + * Unregister a transport layer. + */ + fun unregister(id: String) { + Log.i(TAG, "Unregistering transport layer: $id") + transports.remove(id) + } + + /** + * Broadcast a packet from a specific source transport to ALL other registered transports. + * + * @param sourceId The ID of the transport initiating the broadcast (e.g., "BLE"). + * The packet will NOT be sent back to this source. + * @param packet The packet to bridge. + */ + fun broadcast(sourceId: String, packet: RoutedPacket) { + val targets = transports.filterKeys { it != sourceId } + if (targets.isEmpty()) return + + // Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}") + + targets.forEach { (id, layer) -> + try { + layer.send(packet) + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge packet to $id: ${e.message}") + } + } + } +} 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 8166487e4..53db2fa61 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -72,9 +72,15 @@ class MessageRouter private constructor( val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true val hasEstablished = mesh.hasEstablishedSession(toPeerID) + // Check Wi‑Fi Aware availability as a secondary transport + val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } + val hasAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false } if (hasMesh && hasEstablished) { Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…") mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) + } else if (hasAware) { + 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) @@ -83,14 +89,21 @@ class MessageRouter private constructor( val q = outbox.getOrPut(toPeerID) { mutableListOf() } q.add(Triple(content, recipientNickname, messageID)) Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…") - mesh.initiateNoiseHandshake(toPeerID) + if (hasMesh) mesh.initiateNoiseHandshake(toPeerID) else aware?.initiateNoiseHandshake(toPeerID) } } fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) { - if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)) { + val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } + val viaMesh = (mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID) + val viaAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false } + if (viaMesh) { 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 (viaAware) { + 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) 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 1196396d5..ee31ef74e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -441,8 +441,9 @@ class ChatViewModel( state.getNicknameValue() ) } else { - // Default: route via mesh + // 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) {} } }) return @@ -512,19 +513,23 @@ class ChatViewModel( state.getNicknameValue(), meshService.myPeerID, onEncryptedPayload = { encryptedData -> - // This would need proper mesh service integration + // 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) {} }, onFallback = { meshService.sendMessage(content, mentions, currentChannelValue) + try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} } ) } else { meshService.sendMessage(content, mentions, currentChannelValue) + try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} } } else { messageManager.addMessage(message) meshService.sendMessage(content, mentions, null) + try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, null) } catch (_: Exception) {} } } } @@ -647,16 +652,23 @@ class ChatViewModel( val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) - val nicknames = meshService.getPeerNicknames() - state.setPeerNicknames(nicknames) + // 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) val rssiValues = meshService.getPeerRSSI() - state.setPeerRSSI(rssiValues) + 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) // Update directness per peer (driven by PeerManager state) try { val directMap = state.getConnectedPeersValue().associateWith { pid -> - meshService.getPeerInfo(pid)?.isDirectConnection == true + 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 } state.setPeerDirect(directMap) } catch (_: Exception) { } @@ -732,6 +744,10 @@ class ChatViewModel( override fun didUpdatePeerList(peers: List) { meshDelegateHandler.didUpdatePeerList(peers) } + + fun onWifiPeersUpdated(peers: List) { + meshDelegateHandler.onWifiPeersUpdated(peers) + } override fun didReceiveChannelLeave(channel: String, fromPeer: String) { meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) 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 a3def5235..fc134a1ba 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -210,6 +210,7 @@ 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") } @@ -264,6 +265,7 @@ 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 a452616ec..559ef11a2 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -94,101 +94,123 @@ class MeshDelegateHandler( } } + private var blePeers: Set = emptySet() + private var wifiPeers: Set = emptySet() + override fun didUpdatePeerList(peers: List) { coroutineScope.launch { - state.setConnectedPeers(peers) - state.setIsConnected(peers.isNotEmpty()) - notificationManager.showActiveUserNotification(peers) - // Flush router outbox for any peers that just connected (and their noiseHex aliases) - runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) } + blePeers = peers.toSet() + processPeerUpdate() + } + } - // Clean up channel members who disconnected - channelManager.cleanupDisconnectedMembers(peers, getMyPeerID()) + fun onWifiPeersUpdated(peers: List) { + coroutineScope.launch { + wifiPeers = peers.toSet() + processPeerUpdate() + } + } - // Handle chat view migration based on current selection and new peer list - state.getSelectedPrivateChatPeerValue()?.let { currentPeer -> - val isNostrAlias = currentPeer.startsWith("nostr_") - val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) - val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) + private suspend fun processPeerUpdate() { + // Merge peers from multiple transports + val mergedPeers = (blePeers + wifiPeers).toList() + + // Update process-wide state as source of truth + try { com.bitchat.android.services.AppStateStore.setPeers(mergedPeers) } catch (_: Exception) { } - if (isNostrAlias || isNoiseHex) { - // Reverse case: Nostr/offline chat is open, and peer may have come online on mesh. - // Resolve canonical target (prefer connected mesh peer if available) - val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( - selectedPeerID = currentPeer, - connectedPeers = peers, - meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> peers.contains(pid) }, - nostrPubHexForAlias = { alias -> - // Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping - if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) { - com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) - } else { - // Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases - val prefix = alias.removePrefix("nostr_") - val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } - favs.firstNotNullOfOrNull { rel -> - rel.peerNostrPublicKey?.let { s -> - runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec -> - if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null - } - } - }?.takeIf { it.startsWith(prefix, ignoreCase = true) } - } - }, - findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } - ) - if (canonical != currentPeer) { - // Merge conversations and switch selection to the live mesh peer (or noiseHex) - com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer)) - state.setSelectedPrivateChatPeer(canonical) - } - } else if (isMeshEphemeral && !peers.contains(currentPeer)) { - // Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex) - val favoriteRel = try { - val info = getPeerInfo(currentPeer) - val noiseKey = info?.noisePublicKey - if (noiseKey != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) - } else null - } catch (_: Exception) { null } + state.setConnectedPeers(mergedPeers) + state.setIsConnected(mergedPeers.isNotEmpty()) + notificationManager.showActiveUserNotification(mergedPeers) + + // Flush router outbox for any peers that just connected (and their noiseHex aliases) + runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(mergedPeers) } - if (favoriteRel?.isMutual == true) { - val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } - if (noiseHex != currentPeer) { - com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer( - state = state, - targetPeerID = noiseHex, - keysToMerge = listOf(currentPeer) - ) - state.setSelectedPrivateChatPeer(noiseHex) + // Clean up channel members who disconnected + channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID()) + + // Handle chat view migration based on current selection and new peer list + state.getSelectedPrivateChatPeerValue()?.let { currentPeer -> + val isNostrAlias = currentPeer.startsWith("nostr_") + val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) + val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) + + if (isNostrAlias || isNoiseHex) { + // Reverse case: Nostr/offline chat is open, and peer may have come online on mesh. + // Resolve canonical target (prefer connected mesh peer if available) + val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( + selectedPeerID = currentPeer, + connectedPeers = mergedPeers, + meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey }, + meshHasPeer = { pid -> mergedPeers.contains(pid) }, + nostrPubHexForAlias = { alias -> + // Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping + if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) { + com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) + } else { + // Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases + val prefix = alias.removePrefix("nostr_") + val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } + favs.firstNotNullOfOrNull { rel -> + rel.peerNostrPublicKey?.let { s -> + runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec -> + if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null + } + } + }?.takeIf { it.startsWith(prefix, ignoreCase = true) } } - } else { - privateChatManager.cleanupDisconnectedPeer(currentPeer) + }, + findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } + ) + if (canonical != currentPeer) { + // Merge conversations and switch selection to the live mesh peer (or noiseHex) + com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer)) + state.setSelectedPrivateChatPeer(canonical) + } + } else if (isMeshEphemeral && !mergedPeers.contains(currentPeer)) { + // Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex) + val favoriteRel = try { + val info = getPeerInfo(currentPeer) + val noiseKey = info?.noisePublicKey + if (noiseKey != null) { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + } else null + } catch (_: Exception) { null } + + if (favoriteRel?.isMutual == true) { + val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + if (noiseHex != currentPeer) { + com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer( + state = state, + targetPeerID = noiseHex, + keysToMerge = listOf(currentPeer) + ) + state.setSelectedPrivateChatPeer(noiseHex) } + } else { + privateChatManager.cleanupDisconnectedPeer(currentPeer) } } + } - // Global unification: for each connected peer, merge any offline/stable conversations - // (noiseHex or nostr_) into the connected peer's chat so there is only one chat per identity. - peers.forEach { pid -> - try { - val info = getPeerInfo(pid) - val noiseKey = info?.noisePublicKey ?: return@forEach - val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } + // Global unification: for each connected peer, merge any offline/stable conversations + // (noiseHex or nostr_) into the connected peer's chat so there is only one chat per identity. + mergedPeers.forEach { pid -> + try { + val info = getPeerInfo(pid) + val noiseKey = info?.noisePublicKey ?: return@forEach + val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } - // Derive temp nostr key from favorites npub - val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey) - val tempNostrKey: String? = try { - if (npub != null) { - val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub) - if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null - } else null - } catch (_: Exception) { null } + // Derive temp nostr key from favorites npub + val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey) + val tempNostrKey: String? = try { + if (npub != null) { + val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub) + if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null + } else null + } catch (_: Exception) { null } - unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey)) - } catch (_: Exception) { } - } + unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey)) + } catch (_: Exception) { } } } diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index 4ab4360af..c504b9f93 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -576,9 +576,26 @@ private fun PeerItem( tint = Color.Gray ) } else { + val awareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState() + val awareDiscovered by com.bitchat.android.wifiaware.WifiAwareController.discoveredPeers.collectAsState() + val isWifiDirect = awareConnected.containsKey(peerID) + val isBleDirect = isDirect + val icon = when { + isWifiDirect -> Icons.Filled.Wifi + isBleDirect -> Icons.Outlined.SettingsInputAntenna + // Routed: show Route icon; optionally prefer Wi‑Fi Aware if discovered there + awareDiscovered.contains(peerID) -> Icons.Filled.WifiTethering + else -> Icons.Filled.Route + } + val cd = when { + isWifiDirect -> "Direct Wi‑Fi Aware" + isBleDirect -> "Direct Bluetooth" + awareDiscovered.contains(peerID) -> "Routed over Wi‑Fi" + else -> "Routed" + } Icon( - imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route, - contentDescription = if (isDirect) "Direct Bluetooth" else "Routed", + imageVector = icon, + contentDescription = cd, modifier = Modifier.size(16.dp), tint = colorScheme.onSurface.copy(alpha = 0.8f) ) diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt index 04ad48a2e..2d734c14e 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt @@ -20,7 +20,10 @@ object DebugPreferenceManager { // GCS keys (no migration/back-compat) private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes" private const val KEY_GCS_FPR = "gcs_filter_fpr_percent" - // Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled + // Transport master toggles + private const val KEY_BLE_ENABLED = "ble_enabled" + private const val KEY_WIFI_AWARE_ENABLED = "wifi_aware_enabled" + private const val KEY_WIFI_AWARE_VERBOSE = "wifi_aware_verbose" private lateinit var prefs: SharedPreferences @@ -102,5 +105,25 @@ object DebugPreferenceManager { if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() } - // No longer storing persistent notification in debug prefs. + // Transport toggles + fun getBleEnabled(default: Boolean = true): Boolean = + if (ready()) prefs.getBoolean(KEY_BLE_ENABLED, default) else default + + fun setBleEnabled(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_BLE_ENABLED, value).apply() + } + + fun getWifiAwareEnabled(default: Boolean = false): Boolean = + if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_ENABLED, default) else default + + fun setWifiAwareEnabled(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_ENABLED, value).apply() + } + + fun getWifiAwareVerbose(default: Boolean = false): Boolean = + if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_VERBOSE, default) else default + + fun setWifiAwareVerbose(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_VERBOSE, value).apply() + } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt index 77f6ce12a..669331e60 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt @@ -36,6 +36,17 @@ class DebugSettingsManager private constructor() { private val _packetRelayEnabled = MutableStateFlow(true) val packetRelayEnabled: StateFlow = _packetRelayEnabled.asStateFlow() + // Master transport toggles + private val _bleEnabled = MutableStateFlow(true) + val bleEnabled: StateFlow = _bleEnabled.asStateFlow() + + private val _wifiAwareEnabled = MutableStateFlow(false) + val wifiAwareEnabled: StateFlow = _wifiAwareEnabled.asStateFlow() + + // Master transport toggles + private val _wifiAwareVerbose = MutableStateFlow(false) + val wifiAwareVerbose: StateFlow = _wifiAwareVerbose.asStateFlow() + // Visibility of the debug sheet; gates heavy work private val _debugSheetVisible = MutableStateFlow(false) val debugSheetVisible: StateFlow = _debugSheetVisible.asStateFlow() @@ -59,6 +70,10 @@ class DebugSettingsManager private constructor() { _maxConnectionsOverall.value = DebugPreferenceManager.getMaxConnectionsOverall(8) _maxServerConnections.value = DebugPreferenceManager.getMaxConnectionsServer(8) _maxClientConnections.value = DebugPreferenceManager.getMaxConnectionsClient(8) + // Transport toggles + _bleEnabled.value = DebugPreferenceManager.getBleEnabled(true) + _wifiAwareEnabled.value = DebugPreferenceManager.getWifiAwareEnabled(false) + _wifiAwareVerbose.value = DebugPreferenceManager.getWifiAwareVerbose(false) } catch (_: Exception) { // Preferences not ready yet; keep defaults. They will be applied on first change. } @@ -262,6 +277,27 @@ class DebugSettingsManager private constructor() { )) } + fun setBleEnabled(enabled: Boolean) { + DebugPreferenceManager.setBleEnabled(enabled) + _bleEnabled.value = enabled + addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 BLE enabled" else "🔴 BLE disabled")) + } + + fun setWifiAwareEnabled(enabled: Boolean) { + DebugPreferenceManager.setWifiAwareEnabled(enabled) + _wifiAwareEnabled.value = enabled + addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 Wi‑Fi Aware enabled" else "🔴 Wi‑Fi Aware disabled")) + try { + com.bitchat.android.wifiaware.WifiAwareController.setEnabled(enabled) + } catch (_: Exception) { } + } + + fun setWifiAwareVerbose(enabled: Boolean) { + DebugPreferenceManager.setWifiAwareVerbose(enabled) + _wifiAwareVerbose.value = enabled + addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🔊 Wi‑Fi Aware verbose logging enabled" else "🔇 Wi‑Fi Aware verbose logging disabled")) + } + fun setMaxConnectionsOverall(value: Int) { val clamped = value.coerceIn(1, 32) DebugPreferenceManager.setMaxConnectionsOverall(clamped) @@ -319,6 +355,16 @@ class DebugSettingsManager private constructor() { fun updateConnectedDevices(devices: List) { _connectedDevices.value = devices } + + // Wi‑Fi Aware debug collections + private val _wifiAwareDiscovered = MutableStateFlow>(emptyMap()) // peerID->nickname + val wifiAwareDiscovered: StateFlow> = _wifiAwareDiscovered.asStateFlow() + + private val _wifiAwareConnected = MutableStateFlow>(emptyMap()) // peerID->ip + val wifiAwareConnected: StateFlow> = _wifiAwareConnected.asStateFlow() + + fun updateWifiAwareDiscovered(map: Map) { _wifiAwareDiscovered.value = map } + fun updateWifiAwareConnected(map: Map) { _wifiAwareConnected.value = map } fun updateRelayStats(stats: PacketRelayStats) { _relayStats.value = stats diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 4b9f983a8..94781b31d 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiTethering import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Devices @@ -50,7 +52,7 @@ fun DebugSettingsSheet( val verboseLogging by manager.verboseLoggingEnabled.collectAsState() val gattServerEnabled by manager.gattServerEnabled.collectAsState() val gattClientEnabled by manager.gattClientEnabled.collectAsState() - val packetRelayEnabled by manager.packetRelayEnabled.collectAsState() + val packetRelayed by manager.packetRelayEnabled.collectAsState() val maxOverall by manager.maxConnectionsOverall.collectAsState() val maxServer by manager.maxServerConnections.collectAsState() val maxClient by manager.maxClientConnections.collectAsState() @@ -62,6 +64,12 @@ fun DebugSettingsSheet( val gcsMaxBytes by manager.gcsMaxBytes.collectAsState() val gcsFpr by manager.gcsFprPercent.collectAsState() val context = LocalContext.current + + val bleEnabled by manager.bleEnabled.collectAsState() + val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState() + val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState() + val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState() + val wifiAwareConnected by manager.wifiAwareConnected.collectAsState() // Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled // Push live connected devices from mesh service whenever sheet is visible @@ -86,6 +94,15 @@ fun DebugSettingsSheet( ) } manager.updateConnectedDevices(devices) + // Also surface Wi‑Fi Aware status + try { + val ctrl = com.bitchat.android.wifiaware.WifiAwareController + val known = ctrl.knownPeers.value + val discovered = ctrl.discoveredPeers.value + val discoveredMap = discovered.associateWith { pid -> known[pid] ?: "" } + manager.updateWifiAwareDiscovered(discoveredMap) + manager.updateWifiAwareConnected(ctrl.connectedPeers.value) + } catch (_: Exception) { } kotlinx.coroutines.delay(1000) } } @@ -213,6 +230,46 @@ fun DebugSettingsSheet( } } + // Transport toggles (BLE + Wi‑Fi Aware) + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) + Text("Transports", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) + Spacer(Modifier.width(8.dp)) + Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Switch(checked = bleEnabled, onCheckedChange = { + manager.setBleEnabled(it) + scope.launch { + if (it) { + if (gattServerEnabled) meshService.connectionManager.startServer() + if (gattClientEnabled) meshService.connectionManager.startClient() + } else { + meshService.connectionManager.stopServer() + meshService.connectionManager.stopClient() + } + } + }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0)) + Spacer(Modifier.width(8.dp)) + Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Switch(checked = wifiAwareEnabled, onCheckedChange = { manager.setWifiAwareEnabled(it) }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(24.dp)) + Text("Wi‑Fi Aware verbose", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) }) + } + } + } + } + // Packet relay controls and stats item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { @@ -223,7 +280,7 @@ fun DebugSettingsSheet( Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500)) Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) - Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) }) + Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) }) } // Removed aggregate labels; we will show per-direction compact labels below titles // Toggle: overall vs per-connection vs per-peer @@ -472,6 +529,43 @@ fun DebugSettingsSheet( } } + // Wi‑Fi Aware controls and status + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0)) + Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.weight(1f)) + val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState() + Text(if (running) "running" else "stopped", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() }, label = { Text("Start") }) + AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.stop() }, label = { Text("Stop") }) + AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() }, label = { Text("Announce") }) + } + Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + if (wifiAwareDiscovered.isEmpty()) { + Text("No discoveries yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) -> + Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + } + } + Divider() + Text("Connected: ${wifiAwareConnected.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + if (wifiAwareConnected.isEmpty()) { + Text("No active sockets", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + wifiAwareConnected.entries.take(50).forEach { (peer, ip) -> + Text("• ${peer.take(8)}… @ $ip", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + } + } + } + } + } + // Connected devices item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt new file mode 100644 index 000000000..b99644726 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt @@ -0,0 +1,95 @@ +package com.bitchat.android.wifiaware + +import android.net.ConnectivityManager +import android.util.Log +import com.bitchat.android.mesh.MeshConnectionTracker +import kotlinx.coroutines.CoroutineScope +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks Wi-Fi Aware connections and manages retry logic using the shared state machine. + */ +class WifiAwareConnectionTracker( + scope: CoroutineScope, + private val cm: ConnectivityManager +) : MeshConnectionTracker(scope, TAG) { + + companion object { + private const val TAG = "WifiAwareConnectionTracker" + } + + // Active resources per peer + val peerSockets = ConcurrentHashMap() + val serverSockets = ConcurrentHashMap() + val networkCallbacks = ConcurrentHashMap() + + override fun isConnected(id: String): Boolean { + // We consider it connected if we have a client socket to them + return peerSockets.containsKey(id) + } + + override fun disconnect(id: String) { + Log.d(TAG, "Disconnecting peer $id") + + // 1. Close client socket + peerSockets.remove(id)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") } + } + + // 2. Close server socket + serverSockets.remove(id)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") } + } + + // 3. Unregister network callback + networkCallbacks.remove(id)?.let { + try { cm.unregisterNetworkCallback(it) } catch (e: Exception) { Log.w(TAG, "Error unregistering callback for $id: ${e.message}") } + } + } + + override fun getConnectionCount(): Int = peerSockets.size + + /** + * Successfully established a client connection + */ + fun onClientConnected(peerId: String, socket: Socket) { + peerSockets[peerId] = socket + removePendingConnection(peerId) // Clear retry state on success + } + + fun addServerSocket(peerId: String, socket: ServerSocket) { + serverSockets[peerId] = socket + } + + fun addNetworkCallback(peerId: String, callback: ConnectivityManager.NetworkCallback) { + networkCallbacks[peerId] = callback + } + + /** + * Clean up all resources + */ + override fun stop() { + super.stop() + val allIds = peerSockets.keys + serverSockets.keys + networkCallbacks.keys + allIds.toSet().forEach { disconnect(it) } + } + + fun getDebugInfo(): String { + return buildString { + appendLine("Aware Connections: ${getConnectionCount()}") + peerSockets.keys.forEach { pid -> + appendLine(" - $pid (Socket)") + } + appendLine("Server Sockets: ${serverSockets.size}") + serverSockets.keys.forEach { pid -> + appendLine(" - $pid (Listening)") + } + appendLine("Pending Attempts: ${pendingConnections.size}") + pendingConnections.forEach { (pid, attempt) -> + appendLine(" - $pid: ${attempt.attempts} attempts") + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt new file mode 100644 index 000000000..acdb3c91b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -0,0 +1,111 @@ +package com.bitchat.android.wifiaware + +import android.content.Context +import android.os.Build +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * WifiAwareController manages lifecycle and debug surfacing for the WifiAwareMeshService. + * It starts/stops the service based on debug preferences and exposes simple flows for UI. + */ +object WifiAwareController { + private const val TAG = "WifiAwareController" + + private var service: WifiAwareMeshService? = null + private var appContext: Context? = null + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _enabled = MutableStateFlow(false) + val enabled: StateFlow = _enabled.asStateFlow() + + private val _running = MutableStateFlow(false) + val running: StateFlow = _running.asStateFlow() + + // Simple debug surfacing + private val _connectedPeers = MutableStateFlow>(emptyMap()) // peerID -> ip + val connectedPeers: StateFlow> = _connectedPeers.asStateFlow() + + private val _knownPeers = MutableStateFlow>(emptyMap()) // peerID -> nickname + val knownPeers: StateFlow> = _knownPeers.asStateFlow() + + private val _discoveredPeers = MutableStateFlow>(emptySet()) + val discoveredPeers: StateFlow> = _discoveredPeers.asStateFlow() + + fun initialize(context: Context, enabledByDefault: Boolean) { + appContext = context.applicationContext + setEnabled(enabledByDefault) + // Start background poller for debug surfacing + scope.launch { + while (isActive) { + try { + val s = service + if (s != null) { + _connectedPeers.value = s.getDeviceAddressToPeerMapping() // peerID -> ip + _knownPeers.value = s.getPeerNicknames() + _discoveredPeers.value = s.getDiscoveredPeerIds() + } else { + _connectedPeers.value = emptyMap() + _knownPeers.value = emptyMap() + _discoveredPeers.value = emptySet() + } + } catch (_: Exception) { } + delay(1000) + } + } + } + + fun setEnabled(value: Boolean) { + _enabled.value = value + if (value) startIfPossible() else stop() + } + + fun startIfPossible() { + if (_running.value) return + val ctx = appContext ?: return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + Log.w(TAG, "Wi‑Fi Aware requires Android 10 (Q)+; disabled.") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware not supported on this device (requires Android 10+)")) } catch (_: Exception) {} + return + } + // Android 13+: require NEARBY_WIFI_DEVICES runtime permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.NEARBY_WIFI_DEVICES) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (!granted) { + Log.w(TAG, "Missing NEARBY_WIFI_DEVICES permission; not starting Wi‑Fi Aware") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Grant Nearby Wi‑Fi Devices to start Wi‑Fi Aware")) } catch (_: Exception) {} + return + } + } + try { + service = WifiAwareMeshService(ctx).also { + it.startServices() + _running.value = true + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware started")) } catch (_: Exception) {} + } + } catch (e: Throwable) { + Log.e(TAG, "Failed to start WifiAwareMeshService", e) + _running.value = false + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware failed to start: ${e.message}")) } catch (_: Exception) {} + } + } + + fun stop() { + try { service?.stopServices() } catch (_: Exception) { } + service = null + _running.value = false + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {} + } + + fun getService(): WifiAwareMeshService? = service +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt new file mode 100644 index 000000000..f357d7158 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -0,0 +1,1332 @@ +package com.bitchat.android.wifiaware + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.net.* +import android.net.wifi.aware.* +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.model.* +import com.bitchat.android.protocol.* +import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.toHexString +// Mesh-layer components are reused from the existing Bluetooth stack +import com.bitchat.android.mesh.PeerManager +import com.bitchat.android.mesh.PeerManagerDelegate +import com.bitchat.android.mesh.PeerInfo +import com.bitchat.android.mesh.FragmentManager +import com.bitchat.android.mesh.SecurityManager +import com.bitchat.android.mesh.SecurityManagerDelegate +import com.bitchat.android.mesh.StoreForwardManager +import com.bitchat.android.mesh.StoreForwardManagerDelegate +import com.bitchat.android.mesh.MessageHandler +import com.bitchat.android.mesh.MessageHandlerDelegate +import com.bitchat.android.mesh.PacketProcessor +import com.bitchat.android.mesh.PacketProcessorDelegate +import kotlinx.coroutines.* +import java.io.IOException +import java.net.Inet6Address +import java.net.ServerSocket +import java.net.Socket +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors + +/** + * WifiAware mesh service - LATEST + * + * This is now a coordinator that orchestrates the following components: + * - PeerManager: Peer lifecycle management + * - FragmentManager: Message fragmentation and reassembly + * - SecurityManager: Security, duplicate detection, encryption + * - StoreForwardManager: Offline message caching + * - MessageHandler: Message type processing and relay logic + * - PacketProcessor: Incoming packet routing + */ +class WifiAwareMeshService(private val context: Context) : TransportBridgeService.TransportLayer { + + companion object { + private const val TAG = "WifiAwareMeshService" + private const val MAX_TTL: UByte = 7u + private const val SERVICE_NAME = "bitchat" + private const val PSK = "bitchat_secret" + } + + // Core crypto/services + private val encryptionService = EncryptionService(context) + + // Peer ID must match BluetoothMeshService: first 16 hex chars of identity fingerprint (8 bytes) + val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) + + // Core components + private val peerManager = PeerManager() + private val fragmentManager = FragmentManager() + private val securityManager = SecurityManager(encryptionService, myPeerID) + private val storeForwardManager = StoreForwardManager() + private val messageHandler = MessageHandler(myPeerID, context.applicationContext) + private val packetProcessor = PacketProcessor(myPeerID) + + // Gossip sync + private val gossipSyncManager: GossipSyncManager + + // Wi-Fi Aware transport + private val awareManager = context.getSystemService(WifiAwareManager::class.java) + private var wifiAwareSession: WifiAwareSession? = null + private var publishSession: PublishDiscoverySession? = null + private var subscribeSession: SubscribeDiscoverySession? = null + private val listenerExec = Executors.newCachedThreadPool() + private var isActive = false + + // Delegate + var delegate: WifiAwareMeshDelegate? = null + + // Coroutines - must be initialized before tracker + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + // Transport state + private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm) + private val handleToPeerId = ConcurrentHashMap() // discovery mapping + private val discoveredTimestamps = ConcurrentHashMap() // peerID -> last seen time + + // Timestamp dedupe + private val lastTimestamps = ConcurrentHashMap() + + init { + setupDelegates() + messageHandler.packetProcessor = packetProcessor + + // Use shared GossipSyncManager from MeshServiceHolder if available (minimal refactor) + val shared = com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager + if (shared != null) { + gossipSyncManager = shared + } else { + gossipSyncManager = GossipSyncManager( + myPeerID = myPeerID, + scope = serviceScope, + configProvider = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = 500 + override fun gcsMaxBytes(): Int = 400 + override fun gcsTargetFpr(): Double = 0.01 + } + ) + gossipSyncManager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) + } + override fun sendPacket(packet: BitchatPacket) { + dispatchGlobal(RoutedPacket(packet)) + } + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { + return signPacketBeforeBroadcast(packet) + } + } + } + } + + /** + * Helper method hexToBa. + */ + private fun hexStringToByteArray(hex: String): ByteArray { + val out = ByteArray(8) + var idx = 0 + var s = hex + while (s.length >= 2 && idx < 8) { + val b = s.substring(0, 2).toIntOrNull(16)?.toByte() ?: 0 + out[idx++] = b + s = s.drop(2) + } + return out + } + + /** + * Sign packet before broadcasting. + */ + private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { + val data = packet.toBinaryDataForSigning() ?: return packet + val sig = encryptionService.signData(data) ?: return packet + return packet.copy(signature = sig) + } + + /** + * Broadcasts raw bytes to currently connected peer. + */ + private fun broadcastRaw(bytes: ByteArray) { + var sent = 0 + connectionTracker.peerSockets.forEach { (pid, sock) -> + try { + sock.getOutputStream().write(bytes) + sent++ + } catch (e: IOException) { + Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}") + } + } + Log.i(TAG, "TX: broadcast via Wi-Fi Aware to $sent peers (bytes=${bytes.size})") + } + + // TransportLayer implementation + override fun send(packet: RoutedPacket) { + // Received from bridge (e.g. BLE) -> Send via Wi-Fi + // Direct injection prevents routing loops (bridge handles source check) + broadcastPacket(packet) + } + + /** + * unified dispatch: Send to local Wi-Fi and bridge to other transports + */ + private fun dispatchGlobal(routed: RoutedPacket) { + // 1. Send to local Wi-Fi transport + broadcastPacket(routed) + // 2. Bridge to other transports (e.g. BLE) + TransportBridgeService.broadcast("WIFI", routed) + } + + /** + * Broadcasts routed packet to currently connected peers. + */ + private fun broadcastPacket(routed: RoutedPacket) { + Log.d(TAG, "TX: packet type=${routed.packet.type} broadcast (ttl=${routed.packet.ttl})") + // Wi-Fi Aware uses full packets; no fragmentation + val data = routed.packet.toBinaryData() ?: return + serviceScope.launch { broadcastRaw(data) } + } + + // Expose a public method so BLE can forward relays to Wi-Fi Aware + fun broadcastRoutedPacket(routed: RoutedPacket) { + broadcastPacket(routed) + } + + /** + * Send packet to connected peer. + */ + private fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + // Wi-Fi Aware uses full packets; no fragmentation + val data = packet.toBinaryData() ?: return + serviceScope.launch { + val sock = connectionTracker.peerSockets[peerID] + if (sock == null) { + Log.w(TAG, "TX: no socket for ${peerID.take(8)}") + return@launch + } + try { + sock.getOutputStream().write(data) + Log.d(TAG, "TX: packet type=${packet.type} to ${peerID.take(8)} (bytes=${data.size})") + } catch (e: IOException) { + Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}") + } + } + } + + /** + * Configures delegates for internal components so that events are routed back + * through this service and ultimately to the {@link WifiAwareMeshDelegate}. + */ + private fun setupDelegates() { + peerManager.delegate = object : PeerManagerDelegate { + override fun onPeerListUpdated(peerIDs: List) { + delegate?.didUpdatePeerList(peerIDs) + } + override fun onPeerRemoved(peerID: String) { + try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } + try { encryptionService.removePeer(peerID) } catch (_: Exception) { } + } + } + + securityManager.delegate = object : SecurityManagerDelegate { + override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { + serviceScope.launch { + delay(100) + sendAnnouncementToPeer(peerID) + delay(1000) + storeForwardManager.sendCachedMessages(peerID) + } + } + override fun sendHandshakeResponse(peerID: String, response: ByteArray) { + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = response, + ttl = MAX_TTL + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + override fun getPeerInfo(peerID: String): PeerInfo? { + return peerManager.getPeerInfo(peerID) + } + } + + storeForwardManager.delegate = object : StoreForwardManagerDelegate { + override fun isFavorite(peerID: String) = delegate?.isFavorite(peerID) ?: false + override fun isPeerOnline(peerID: String) = peerManager.isPeerActive(peerID) + override fun sendPacket(packet: BitchatPacket) { + dispatchGlobal(RoutedPacket(packet)) + } + } + + messageHandler.delegate = object : MessageHandlerDelegate { + override fun addOrUpdatePeer(peerID: String, nickname: String) = + peerManager.addOrUpdatePeer(peerID, nickname) + override fun removePeer(peerID: String) = peerManager.removePeer(peerID) + override fun updatePeerNickname(peerID: String, nickname: String) { + peerManager.addOrUpdatePeer(peerID, nickname) + } + override fun getPeerNickname(peerID: String) = + peerManager.getPeerNickname(peerID) + override fun getNetworkSize() = peerManager.getActivePeerCount() + override fun getMyNickname() = delegate?.getNickname() + override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + override fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + + override fun sendPacket(packet: BitchatPacket) { + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + override fun relayPacket(routed: RoutedPacket) { dispatchGlobal(routed) } + override fun getBroadcastRecipient() = SpecialRecipients.BROADCAST + + override fun verifySignature(packet: BitchatPacket, peerID: String) = + securityManager.verifySignature(packet, peerID) + override fun encryptForPeer(data: ByteArray, recipientPeerID: String) = + securityManager.encryptForPeer(data, recipientPeerID) + override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String) = + securityManager.decryptFromPeer(encryptedData, senderPeerID) + override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean = + encryptionService.verifyEd25519Signature(signature, data, publicKey) + + override fun hasNoiseSession(peerID: String) = + encryptionService.hasEstablishedSession(peerID) + override fun initiateNoiseHandshake(peerID: String) { + serviceScope.launch { + val hs = encryptionService.initiateHandshake(peerID) ?: return@launch + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = hs, + ttl = MAX_TTL + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + } + override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? = + try { encryptionService.processHandshakeMessage(payload, peerID) } catch (_: Exception) { null } + + override fun updatePeerIDBinding(newPeerID: String, nickname: String, publicKey: ByteArray, previousPeerID: String?) { + peerManager.addOrUpdatePeer(newPeerID, nickname) + val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) + previousPeerID?.let { peerManager.removePeer(it) } + Log.d(TAG, "Updated peer binding to $newPeerID, fp=${fingerprint.take(16)}") + } + + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String) = + delegate?.decryptChannelMessage(encryptedContent, channel) + + override fun onMessageReceived(message: BitchatMessage) { + delegate?.didReceiveMessage(message) + } + override fun onChannelLeave(channel: String, fromPeer: String) { + delegate?.didReceiveChannelLeave(channel, fromPeer) + } + override fun onDeliveryAckReceived(messageID: String, peerID: String) { + delegate?.didReceiveDeliveryAck(messageID, peerID) + } + override fun onReadReceiptReceived(messageID: String, peerID: String) { + delegate?.didReceiveReadReceipt(messageID, peerID) + } + } + + packetProcessor.delegate = object : PacketProcessorDelegate { + override fun validatePacketSecurity(packet: BitchatPacket, peerID: String) = + securityManager.validatePacket(packet, peerID) + override fun updatePeerLastSeen(peerID: String) = peerManager.updatePeerLastSeen(peerID) + override fun getPeerNickname(peerID: String): String? = peerManager.getPeerNickname(peerID) + override fun getNetworkSize(): Int = peerManager.getActivePeerCount() + override fun getBroadcastRecipient(): ByteArray = SpecialRecipients.BROADCAST + + override fun handleNoiseHandshake(routed: RoutedPacket): Boolean = + runBlocking { securityManager.handleNoiseHandshake(routed) } + + override fun handleNoiseEncrypted(routed: RoutedPacket) { + serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } + } + + override fun handleAnnounce(routed: RoutedPacket) { + serviceScope.launch { + val isFirst = messageHandler.handleAnnounce(routed) + routed.peerID?.let { pid -> + try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } + } + try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + } + } + + override fun handleMessage(routed: RoutedPacket) { + serviceScope.launch { messageHandler.handleMessage(routed) } + try { + val pkt = routed.packet + val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (isBroadcast && pkt.type == MessageType.MESSAGE.value) { + gossipSyncManager.onPublicPacketSeen(pkt) + } + } catch (_: Exception) { } + } + + override fun handleLeave(routed: RoutedPacket) { + serviceScope.launch { messageHandler.handleLeave(routed) } + } + + override fun handleFragment(packet: BitchatPacket): BitchatPacket? { + try { + val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (isBroadcast && packet.type == MessageType.FRAGMENT.value) { + gossipSyncManager.onPublicPacketSeen(packet) + } + } catch (_: Exception) { } + return fragmentManager.handleFragment(packet) + } + + override fun sendAnnouncementToPeer(peerID: String) = this@WifiAwareMeshService.sendAnnouncementToPeer(peerID) + override fun sendCachedMessages(peerID: String) = storeForwardManager.sendCachedMessages(peerID) + override fun relayPacket(routed: RoutedPacket) = dispatchGlobal(routed) + + override fun handleRequestSync(routed: RoutedPacket) { + val fromPeer = routed.peerID ?: return + val req = RequestSyncPacket.decode(routed.packet.payload) ?: return + gossipSyncManager.handleRequestSync(fromPeer, req) + } + } + } + + /** + * Starts Wi-Fi Aware services (publish + subscribe). + * + * Requires Wi-Fi state and location permissions. This method attaches to the + * Aware session and initializes both the publisher (server role) and subscriber + * (client role). + */ + @SuppressLint("MissingPermission") + @RequiresPermission(allOf = [ + Manifest.permission.ACCESS_WIFI_STATE, + Manifest.permission.CHANGE_WIFI_STATE + ]) + fun startServices() { + if (isActive) return + isActive = true + Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID") + + awareManager?.attach(object : AttachCallback() { + @SuppressLint("MissingPermission") + @RequiresPermission(allOf = [ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.NEARBY_WIFI_DEVICES + ]) + override fun onAttached(session: WifiAwareSession) { + wifiAwareSession = session + Log.i(TAG, "Wi-Fi Aware attached; starting publish & subscribe (peerID=$myPeerID)") + + // PUBLISH (server role) + session.publish( + PublishConfig.Builder() + .setServiceName(SERVICE_NAME) + .setServiceSpecificInfo(myPeerID.toByteArray()) + .build(), + object : DiscoverySessionCallback() { + override fun onPublishStarted(pub: PublishDiscoverySession) { + publishSession = pub + Log.d(TAG, "PUBLISH: onPublishStarted()") + } + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray, + matchFilter: List + ) { + val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } + handleToPeerId[peerHandle] = peerId + if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis() + Log.d(TAG, "PUBLISH: onServiceDiscovered ssi='${peerId.take(16)}' len=${serviceSpecificInfo.size}") + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun onMessageReceived( + peerHandle: PeerHandle, + message: ByteArray + ) { + if (message.isEmpty()) return + val subscriberId = try { String(message) } catch (_: Exception) { "" } + if (subscriberId == myPeerID) return + + handleToPeerId[peerHandle] = subscriberId + if (subscriberId.isNotBlank()) discoveredTimestamps[subscriberId] = System.currentTimeMillis() + Log.d(TAG, "PUBLISH: got ping from $subscriberId; spinning up server") + handleSubscriberPing(publishSession!!, peerHandle) + } + }, + Handler(Looper.getMainLooper()) + ) + + // SUBSCRIBE (client role) + session.subscribe( + SubscribeConfig.Builder() + .setServiceName(SERVICE_NAME) + .build(), + object : DiscoverySessionCallback() { + override fun onSubscribeStarted(sub: SubscribeDiscoverySession) { + subscribeSession = sub + Log.d(TAG, "SUBSCRIBE: onSubscribeStarted()") + } + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray, + matchFilter: List + ) { + val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } + handleToPeerId[peerHandle] = peerId + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray()) + if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis() + Log.d(TAG, "SUBSCRIBE: sent ping to '${peerId.take(16)}' (msgId=$msgId)") + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun onMessageReceived( + peerHandle: PeerHandle, + message: ByteArray + ) { + if (message.isEmpty()) return + val peerId = handleToPeerId[peerHandle] ?: return + if (peerId == myPeerID) return + + Log.d(TAG, "SUBSCRIBE: onMessageReceived() → server-ready from ${peerId.take(8)} payload=${message.size}B") + handleServerReady(peerHandle, message) + } + }, + Handler(Looper.getMainLooper()) + ) + } + override fun onAttachFailed() { + Log.e(TAG, "Wi-Fi Aware attach failed") + } + }, Handler(Looper.getMainLooper())) + + sendPeriodicBroadcastAnnounce() + startPeriodicConnectionMaintenance() + connectionTracker.start() + gossipSyncManager.start() + + // Register with cross-layer transport bridge + TransportBridgeService.register("WIFI", this) + } + + /** + * Stops the Wi-Fi Aware mesh services and cleans up sockets and sessions. + */ + fun stopServices() { + if (!isActive) return + isActive = false + Log.i(TAG, "Stopping Wi-Fi Aware mesh") + + // Unregister from bridge + TransportBridgeService.unregister("WIFI") + + sendLeaveAnnouncement() + + serviceScope.launch { + delay(200) + + gossipSyncManager.stop() + connectionTracker.stop() // Handles socket closing and callback unregistration + + publishSession?.close(); publishSession = null + subscribeSession?.close(); subscribeSession = null + wifiAwareSession?.close(); wifiAwareSession = null + + handleToPeerId.clear() + + peerManager.shutdown() + fragmentManager.shutdown() + securityManager.shutdown() + storeForwardManager.shutdown() + messageHandler.shutdown() + packetProcessor.shutdown() + + serviceScope.cancel() + } + } + + /** + * Periodically broadcasts an ANNOUNCE packet (every ~30s) while the service is active, + * so new/idle peers can discover us without user action. + */ + private fun sendPeriodicBroadcastAnnounce() { + serviceScope.launch { + while (isActive) { + try { delay(30_000); sendBroadcastAnnounce() } catch (_: Exception) { } + } + } + } + + /** + * Periodic active maintenance: retries connections to discovered but unconnected peers. + */ + private fun startPeriodicConnectionMaintenance() { + serviceScope.launch { + Log.d(TAG, "Starting periodic connection maintenance loop") + while (isActive) { + try { + delay(15_000) // Check every 15 seconds + if (!isActive) break + + val now = System.currentTimeMillis() + // 1. Identify peers that are discovered (recently seen) but not currently connected + val recentDiscovered = discoveredTimestamps.filter { (id, ts) -> + (now - ts) < 5 * 60 * 1000 // Seen in last 5 minutes + }.keys + + // 2. Filter out those who are already connected + val disconnectedPeers = recentDiscovered.filter { peerId -> + !connectionTracker.isConnected(peerId) + } + + // 3. Attempt reconnection + for (peerId in disconnectedPeers) { + // Find the PeerHandle for this peerId + val handle = handleToPeerId.entries.find { it.value == peerId }?.key ?: continue + + // Check tracker policy + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) continue + + Log.i(TAG, "🔄 Maintenance: attempting reconnect to ${peerId.take(8)}") + if (connectionTracker.addPendingConnection(peerId)) { + // Resend ping to trigger handshake + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + try { + subscribeSession?.sendMessage(handle, msgId, myPeerID.toByteArray()) + } catch (e: Exception) { + Log.w(TAG, "Failed to send maintenance ping to ${peerId.take(8)}: ${e.message}") + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error in connection maintenance: ${e.message}") + } + } + } + } + + /** + * Handles subscriber ping: spawns a server socket and responds with connection info. + * + * @param pubSession The current publish discovery session + * @param peerHandle The handle for the peer that pinged us + */ + @RequiresApi(Build.VERSION_CODES.Q) + private fun handleSubscriberPing( + pubSession: PublishDiscoverySession, + peerHandle: PeerHandle + ) { + val peerId = handleToPeerId[peerHandle] ?: return + if (!amIServerFor(peerId)) return + + if (connectionTracker.serverSockets.containsKey(peerId)) { + Log.v(TAG, "↪ already serving $peerId, skipping") + return + } + + val ss = ServerSocket(0) + connectionTracker.addServerSocket(peerId, ss) + val port = ss.localPort + + // Ensure port is set to reuse if connection was recently closed (TIME_WAIT) + try { + ss.reuseAddress = true + } catch (_: Exception) {} + + Log.d(TAG, "SERVER: listening for ${peerId.take(8)} on port $port") + + val spec = WifiAwareNetworkSpecifier.Builder(pubSession, peerHandle) + .setPskPassphrase(PSK) + .setPort(port) + .build() + // Default capabilities include NET_CAPABILITY_NOT_VPN. + // Keeping defaults for hardware interface handle acquisition compatibility with global VPNs. + val req = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(spec) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + try { + val client = ss.accept() + try { network.bindSocket(client) } catch (e: Exception) { Log.w(TAG, "Server bindSocket EPERM: ${e.message}") } + client.keepAlive = true + Log.d(TAG, "SERVER: accepted TCP from ${peerId.take(8)} addr=${client.inetAddress?.hostAddress}") + connectionTracker.onClientConnected(peerId, client) + try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {} + try { peerManager.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(client, peerId) } + handleSubscriberKeepAlive(client, peerId, pubSession, peerHandle) + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + messageHandler.delegate?.initiateNoiseHandshake(peerId) + Log.d(TAG, "SERVER: initiating Noise handshake to ${peerId.take(8)} (lower ID)") + } + // Ensure fast presence even before handshake settles + serviceScope.launch { delay(150); sendBroadcastAnnounce() } + } catch (ioe: IOException) { + Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}", ioe) + } + } + override fun onLost(network: Network) { + connectionTracker.networkCallbacks.remove(peerId) + Log.d(TAG, "SERVER: network lost for ${peerId.take(8)}") + } + } + + connectionTracker.addNetworkCallback(peerId, cb) + Log.d(TAG, "SERVER: requesting Aware network for ${peerId.take(8)}") + cm.requestNetwork(req, cb) + + val readyId = (System.nanoTime() and 0x7fffffff).toInt() + val portBytes = ByteBuffer.allocate(4) + .order(ByteOrder.BIG_ENDIAN) + .putInt(port) + .array() + Handler(Looper.getMainLooper()).post { + try { + val sent = pubSession.sendMessage(peerHandle, readyId, portBytes) + Log.d(TAG, "PUBLISH: server-ready sent=$sent (msgId=$readyId, port=$port)") + } catch (e: Exception) { + Log.e(TAG, "PUBLISH: Exception sending server-ready to $peerHandle", e) + } + } + } + + /** + * Sends periodic TCP and discovery keep-alive messages to maintain a subscriber connection. + * + * @param client Connected client socket + * @param peerId ID of the connected peer + */ + private fun handleSubscriberKeepAlive( + client: Socket, + peerId: String, + pubSession: PublishDiscoverySession, + peerHandle: PeerHandle + ) { + // TCP keep-alive pings + serviceScope.launch { + try { + val os = client.getOutputStream() + while (connectionTracker.isConnected(peerId)) { + try { os.write(0) } catch (_: IOException) { break } + delay(2_000) + } + } catch (_: Exception) {} + } + // Discovery keep-alive + serviceScope.launch { + var msgId = 0 + while (connectionTracker.isConnected(peerId)) { + try { pubSession.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } + delay(20_000) + } + } + } + + /** + * Handles a "server ready" message from a publishing peer and initiates a client connection. + */ + @RequiresApi(Build.VERSION_CODES.Q) + private fun handleServerReady( + peerHandle: PeerHandle, + payload: ByteArray + ) { + if (payload.size < Int.SIZE_BYTES) { + Log.w(TAG, "handleServerReady called with invalid payload size=${payload.size}, dropping") + return + } + + val peerId = handleToPeerId[peerHandle] ?: return + if (amIServerFor(peerId)) return + if (connectionTracker.peerSockets.containsKey(peerId)) { + Log.v(TAG, "↪ already client-connected to $peerId, skipping") + return + } + + val port = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN).int + Log.d(TAG, "CLIENT: connecting to ${peerId.take(8)} port=$port") + + val spec = WifiAwareNetworkSpecifier.Builder(subscribeSession!!, peerHandle) + .setPskPassphrase(PSK) + .build() + val req = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(spec) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + // Do not bind process for Aware; use per-socket binding instead + } + override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) { + if (connectionTracker.peerSockets.containsKey(peerId)) return + val info = (nc.transportInfo as? WifiAwareNetworkInfo) ?: return + val addr = info.peerIpv6Addr as? Inet6Address ?: return + + val lp = cm.getLinkProperties(network) + val iface = lp?.interfaceName + + try { + val sock = Socket() + try { network.bindSocket(sock) } catch (e: Exception) { Log.w(TAG, "Client bindSocket EPERM: ${e.message}") } + sock.tcpNoDelay = true + sock.keepAlive = true + + // Use scoped IPv6 if interface name is available + val scopedAddr = if (iface != null && addr.scopeId == 0) { + try { + Inet6Address.getByAddress(null, addr.address, java.net.NetworkInterface.getByName(iface)) + } catch (e: Exception) { + addr + } + } else { + addr + } + + sock.connect(java.net.InetSocketAddress(scopedAddr, port), 7000) + Log.d(TAG, "CLIENT: TCP connected to ${peerId.take(8)} addr=$scopedAddr:$port (iface=$iface)") + + connectionTracker.onClientConnected(peerId, sock) + try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {} + try { peerManager.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(sock, peerId) } + handleServerKeepAlive(sock, peerId, peerHandle) + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + messageHandler.delegate?.initiateNoiseHandshake(peerId) + Log.d(TAG, "CLIENT: initiating Noise handshake to ${peerId.take(8)} (lower ID)") + } + // Ensure fast presence even before handshake settles + serviceScope.launch { delay(150); sendBroadcastAnnounce() } + } catch (ioe: IOException) { + Log.e(TAG, "CLIENT: socket connect failed to ${peerId.take(8)}", ioe) + } + } + override fun onLost(network: Network) { + connectionTracker.networkCallbacks.remove(peerId) + Log.d(TAG, "CLIENT: network lost for ${peerId.take(8)}") + } + } + + connectionTracker.addNetworkCallback(peerId, cb) + Log.d(TAG, "CLIENT: requesting Aware network for ${peerId.take(8)}") + cm.requestNetwork(req, cb) + } + + /** + * Sends periodic TCP and discovery keep-alive messages for server connections. + */ + private fun handleServerKeepAlive( + sock: Socket, + peerId: String, + peerHandle: PeerHandle + ) { + // TCP keep-alive + serviceScope.launch { + try { + val os = sock.getOutputStream() + while (connectionTracker.isConnected(peerId)) { + try { os.write(0) } catch (_: IOException) { break } + delay(2_000) + } + } catch (_: Exception) {} + } + // Discovery keep-alive + serviceScope.launch { + var msgId = 0 + while (connectionTracker.isConnected(peerId)) { + try { subscribeSession?.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } + delay(20_000) + } + } + } + + /** + * Determines whether this device should act as the server in a given peer relationship. + */ + private fun amIServerFor(peerId: String) = myPeerID < peerId + + /** + * Listens for incoming packets from a connected peer and dispatches them through + * the packet processor. + * + * @param socket Socket connected to the peer + * @param initialLogicalPeerId Temporary identifier before peer ID resolution + */ + private fun listenToPeer(socket: Socket, initialLogicalPeerId: String) { + val inStream = socket.getInputStream() + val buf = ByteArray(64 * 1024) + + while (isActive) { + val len = try { inStream.read(buf) } catch (_: IOException) { break } + if (len <= 0) break + + val raw = buf.copyOf(len) + val pkt = BitchatPacket.fromBinaryData(raw) ?: continue + + val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue + + // Deduplicate based on timestamp + sender (standard flood fill protection) + val ts = pkt.timestamp + if (lastTimestamps.put(senderPeerHex, ts) == ts) { + continue + } + + // Route the packet: + // - peerID = Originator (who signed it) + // - relayAddress = Neighbor (who sent it to us over this socket) + // Note: We do NOT update peerSockets mapping based on senderPeerHex. + // The socket belongs to initialLogicalPeerId effectively serving as the "MAC address" layer. + Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${initialLogicalPeerId.take(8)} (bytes=${raw.size})") + packetProcessor.processPacket(RoutedPacket(pkt, senderPeerHex, initialLogicalPeerId)) + } + + // Breaking out of the loop means the socket is dead or service is stopping. + Log.i(TAG, "Socket loop terminated for ${initialLogicalPeerId.take(8)} removing peer.") + handlePeerDisconnection(initialLogicalPeerId) + socket.closeQuietly() + } + + private fun handlePeerDisconnection(initialId: String) { + serviceScope.launch { + Log.d(TAG, "Cleaning up peer: $initialId") + + connectionTracker.disconnect(initialId) + peerManager.removePeer(initialId) + } + } + + /** + * Sends a broadcast message to all peers. + * @param content Text content of the message + * @param mentions Optional list of mentioned peer IDs + * @param channel Optional channel name + */ + fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { + if (content.isEmpty()) return + + serviceScope.launch { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = content.toByteArray(Charsets.UTF_8), + signature = null, + ttl = MAX_TTL + ) + val signed = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signed)) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + } + + /** + * Sends a private encrypted message to a specific peer. + * + * @param content The message text + * @param recipientPeerID Destination peer ID + * @param recipientNickname Recipient nickname + * @param messageID Optional message ID (UUID if null) + */ + fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { + if (content.isEmpty() || recipientPeerID.isEmpty()) return + + serviceScope.launch { + val finalId = messageID ?: UUID.randomUUID().toString() + + if (encryptionService.hasEstablishedSession(recipientPeerID)) { + try { + val pm = PrivateMessagePacket(messageID = finalId, content = content) + val tlv = pm.encode() ?: return@launch + val payload = NoisePayload(type = NoisePayloadType.PRIVATE_MESSAGE, data = tlv).encode() + val enc = encryptionService.encrypt(payload, recipientPeerID) + + val pkt = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = enc, + signature = null, + ttl = MAX_TTL + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(pkt))) + } catch (e: Exception) { + Log.e(TAG, "Failed to encrypt private message: ${e.message}") + } + } else { + messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID) + } + } + } + + /** + * Sends a read receipt for a specific message to the given peer over an established + * Noise session. If no session exists, this will log an error. + * + * @param messageID The ID of the message that was read. + * @param recipientPeerID The peer to notify. + * @param readerNickname Nickname of the reader (may be shown by the receiver). + */ + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + serviceScope.launch { + try { + val payload = NoisePayload( + type = NoisePayloadType.READ_RECEIPT, + data = messageID.toByteArray(Charsets.UTF_8) + ).encode() + val enc = encryptionService.encrypt(payload, recipientPeerID) + val pkt = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = enc, + signature = null, + ttl = MAX_TTL + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(pkt))) + } catch (e: Exception) { + Log.e(TAG, "Failed to send read receipt: ${e.message}") + } + } + } + + /** + * Broadcasts a file (TLV payload) to all peers. Uses protocol version 2 to support + * large payloads and generates a deterministic transferId (sha256 of payload) for UI/state. + * + * @param file Encoded metadata and chunks descriptor of the file to send. + */ + fun sendFileBroadcast(file: BitchatFilePacket) { + try { + val payload = file.encode() ?: run { Log.e(TAG, "file TLV encode failed"); return } + serviceScope.launch { + val pkt = BitchatPacket( + version = 2u, // FILE_TRANSFER big length + type = MessageType.FILE_TRANSFER.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = MAX_TTL + ) + val signed = signPacketBeforeBroadcast(pkt) + val transferId = sha256Hex(payload) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + } catch (e: Exception) { + Log.e(TAG, "sendFileBroadcast failed: ${e.message}", e) + } + } + + /** + * Sends a file privately to a specific peer. If no Noise session is established, + * a handshake will be initiated and the send is deferred/aborted for now. + * + * @param recipientPeerID Target peer. + * @param file Encoded metadata and chunks descriptor of the file to send. + */ + fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + try { + serviceScope.launch { + if (!encryptionService.hasEstablishedSession(recipientPeerID)) { + messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID) + return@launch + } + val tlv = file.encode() ?: return@launch + val np = NoisePayload(type = NoisePayloadType.FILE_TRANSFER, data = tlv).encode() + val enc = encryptionService.encrypt(np, recipientPeerID) + val pkt = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = enc, + signature = null, + ttl = MAX_TTL + ) + val signed = signPacketBeforeBroadcast(pkt) + val transferId = sha256Hex(tlv) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) + } + } catch (e: Exception) { + Log.e(TAG, "sendFilePrivate failed: ${e.message}", e) + } + } + + /** + * Attempts to cancel an in-flight file transfer identified by its transferId. + * + * @param transferId Deterministic id (usually sha256 of the file TLV). + * @return true if a transfer with this id was found and cancellation was scheduled, false otherwise. + */ + fun cancelFileTransfer(transferId: String): Boolean { + return false + } + + /** + * Computes the SHA-256 of the given bytes and returns a lowercase hex string. + * Falls back to the byte-length in hex if MessageDigest is unavailable. + */ + private fun sha256Hex(bytes: ByteArray): String = try { + val md = java.security.MessageDigest.getInstance("SHA-256") + md.update(bytes); md.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { bytes.size.toString(16) } + + /** + * Broadcasts an ANNOUNCE packet to the entire mesh. + */ + fun sendBroadcastAnnounce() { + serviceScope.launch { + val nickname = delegate?.getNickname() ?: myPeerID + val staticKey = encryptionService.getStaticPublicKey() ?: run { + Log.e(TAG, "No static public key available for announcement"); return@launch + } + val signingKey = encryptionService.getSigningPublicKey() ?: run { + Log.e(TAG, "No signing public key available for announcement"); return@launch + } + + val tlvPayload = IdentityAnnouncement(nickname, staticKey, signingKey).encode() ?: return@launch + + val announcePacket = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = MAX_TTL, + senderID = myPeerID, + payload = tlvPayload + ) + val signed = signPacketBeforeBroadcast(announcePacket) + + dispatchGlobal(RoutedPacket(signed)) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + } + + /** + * Sends an ANNOUNCE packet to a specific peer. + */ + fun sendAnnouncementToPeer(peerID: String) { + if (peerManager.hasAnnouncedToPeer(peerID)) return + + val nickname = delegate?.getNickname() ?: myPeerID + val staticKey = encryptionService.getStaticPublicKey() ?: return + val signingKey = encryptionService.getSigningPublicKey() ?: return + + val tlvPayload = IdentityAnnouncement(nickname, staticKey, signingKey).encode() ?: return + + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = MAX_TTL, + senderID = myPeerID, + payload = tlvPayload + ) + val signed = signPacketBeforeBroadcast(packet) + + dispatchGlobal(RoutedPacket(signed)) + peerManager.markPeerAsAnnouncedTo(peerID) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + + /** + * Sends a LEAVE announcement to all peers before disconnecting. + */ + private fun sendLeaveAnnouncement() { + val nickname = delegate?.getNickname() ?: myPeerID + val packet = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = MAX_TTL, + senderID = myPeerID, + payload = nickname.toByteArray() + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + + /** @return Mapping of peer IDs to nicknames. */ + fun getPeerNicknames(): Map = peerManager.getAllPeerNicknames() + + /** @return Mapping of peer IDs to RSSI values. */ + fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI() + + /** + * @return true if a Noise session with the peer is fully established. + */ + fun hasEstablishedSession(peerID: String) = encryptionService.hasEstablishedSession(peerID) + + /** + * @return a human-readable Noise session state for the given peer (implementation-defined). + */ + fun getSessionState(peerID: String) = encryptionService.getSessionState(peerID) + + /** + * Triggers a Noise handshake with the given peer. Safe to call repeatedly; no-op if already handshaking/established. + */ + fun initiateNoiseHandshake(peerID: String) = messageHandler.delegate?.initiateNoiseHandshake(peerID) + + /** + * @return the stored public-key fingerprint (hex) for a peer, if known. + */ + fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID) + + /** + * Retrieves the full profile for a peer, including keys and verification state, if available. + */ + fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + + /** + * Updates local metadata for a peer and returns whether the change was applied. + * + * @param peerID Target peer id. + * @param nickname Display name. + * @param noisePublicKey Peer’s Noise static public key. + * @param signingPublicKey Peer’s Ed25519 signing public key. + * @param isVerified Whether this identity is verified by the user. + * @return true if the record was updated or created, false otherwise. + */ + fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + + /** + * @return the local device’s long-term identity fingerprint (hex). + */ + fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint() + + /** + * @return true if the UI should show an “encrypted” indicator for this peer. + */ + fun shouldShowEncryptionIcon(peerID: String) = encryptionService.hasEstablishedSession(peerID) + + /** + * @return a snapshot list of peers with established Noise sessions. + */ + fun getEncryptedPeers(): List = emptyList() + + /** + * @return the current IPv4/IPv6 address of a connected peer, if any. + * Prefers the scoped IPv6 address format. + */ + fun getDeviceAddressForPeer(peerID: String): String? = + connectionTracker.peerSockets[peerID]?.let { resolveScopedAddress(it) } + + /** + * Helper to resolve a scoped IPv6 address from a socket for UI display. + */ + private fun resolveScopedAddress(sock: Socket): String? { + val addr = sock.inetAddress as? Inet6Address ?: return sock.inetAddress?.hostAddress + if (addr.scopeId != 0 || addr.isLoopbackAddress) return addr.hostAddress + + // If address has no scope but we are on Aware (Link-Local fe80), attempt interface resolution + val iface = try { + val lp = cm.getLinkProperties(cm.activeNetwork) + lp?.interfaceName ?: "aware0" + } catch (_: Exception) { "aware0" } + + return "${addr.hostAddress}%$iface" + } + + /** + * @return a mapping of peerID → connected device IP address for all active sockets. + * Results are formatted as scoped addresses if applicable. + */ + fun getDeviceAddressToPeerMapping(): Map { + val map = mutableMapOf() + connectionTracker.peerSockets.forEach { (pid, sock) -> + map[pid] = resolveScopedAddress(sock) ?: "unknown" + } + return map + } + + /** + * @return map of peer ID to nickname, bridged for UI warning fix. + */ + fun getPeerNicknamesMap(): Map = peerManager.getAllPeerNicknames() + + /** Returns recently discovered peer IDs via Aware discovery (may not be connected). */ + fun getDiscoveredPeerIds(): Set = + (handleToPeerId.values + discoveredTimestamps.keys).filter { it.isNotBlank() }.toSet() + + /** + * Utility for logs/UI: pretty-prints one peer-to-address mapping per line. + */ + fun printDeviceAddressesForPeers(): String = + getDeviceAddressToPeerMapping().entries.joinToString("\n") { "${it.key} -> ${it.value}" } + + /** + * @return A detailed string containing the debug status of all mesh components. + */ + fun getDebugStatus(): String = buildString { + appendLine("=== Wi-Fi Aware Mesh Debug Status ===") + appendLine("My Peer ID: $myPeerID") + appendLine("Peers: ${connectionTracker.peerSockets.keys}") + appendLine(connectionTracker.getDebugInfo()) + appendLine(peerManager.getDebugInfo(getDeviceAddressToPeerMapping())) + appendLine(fragmentManager.getDebugInfo()) + appendLine(securityManager.getDebugInfo()) + appendLine(storeForwardManager.getDebugInfo()) + appendLine(messageHandler.getDebugInfo()) + appendLine(packetProcessor.getDebugInfo()) + } + + /** Utility extension to safely close sockets. */ + private fun Socket.closeQuietly() = try { close() } catch (_: Exception) {} + + /** Utility extension to safely close server sockets. */ + private fun ServerSocket.closeQuietly() = try { close() } catch (_: Exception) {} +} + + +/** + * Delegate interface for mesh service callbacks (maintains exact same interface) + */ +interface WifiAwareMeshDelegate { + 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 decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? + fun getNickname(): String? + fun isFavorite(peerID: String): Boolean + // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 94414ce5d..51c676139 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -362,4 +362,5 @@ كن أول من يضيف ملاحظة لهذا المكان. إغلاق أضف ملاحظة لهذا المكان + بلوتوث موصى به diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index a9f826586..57f1db651 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -349,4 +349,5 @@ %d জন + ব্লুটুথ প্রস্তাবিত diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 786c9dcb4..4f8a1e687 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -363,4 +363,5 @@ sei der Erste, der hier eine Notiz hinterlässt. schließen füge eine Notiz zu diesem Ort hinzu + Bluetooth empfohlen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index acf3cb92d..63aa601ac 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -362,4 +362,5 @@ sé el primero en añadir una para este sitio. cerrar agrega una nota para este lugar + Bluetooth recomendado diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 2896a97d2..e307c5107 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -349,4 +349,5 @@ %d نفر + بلوتوث توصیه می شود diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index bb150d934..64e4111df 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -362,4 +362,5 @@ %d tao + Bluetooth Recommended diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b1936bce7..b6bafc0b7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -376,4 +376,5 @@ soyez le premier à en ajouter pour cet endroit. fermer ajoutez une note pour cet endroit + Bluetooth recommandé diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index a15f87af8..bdf456452 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -14,5 +14,7 @@ היה הראשון להוסיף הערה למקום זה. סגור הוסף הערה למקום זה + Skip + Bluetooth Recommended diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 209813835..6a2cd9f1f 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -362,4 +362,5 @@ इस स्थान के लिए पहला नोट जोड़ें। बंद करें इस स्थान के लिए एक नोट जोड़ें + ब्लूटूथ अनुशंसित diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 00f8e7cf9..7eeac6e17 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -362,4 +362,5 @@ jadilah yang pertama menambahkan catatan untuk tempat ini. tutup tambahkan catatan untuk tempat ini + Bluetooth Recommended diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d086ba3e9..598ed79d7 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -396,4 +396,5 @@ sii il primo ad aggiungerne una per questo posto. chiudi aggiungi una nota per questo luogo + Bluetooth consigliato diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index be41d3d40..6b6f6bc9a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -362,4 +362,5 @@ この場所の最初のメモを追加しましょう。 閉じる この場所へのメモを追加 + Bluetooth推奨 diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index dbf16225c..7c64f1d0b 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -349,4 +349,5 @@ %d ადამიანი + Bluetooth Recommended diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3163fa0c2..7e47eb2ac 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -362,4 +362,5 @@ 이 장소에 첫 번째 노트를 추가해 보세요. 닫기 이 장소에 노트 추가 + 블루투스 권장 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 8d359165e..dea575eaa 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -376,4 +376,5 @@ Olona %d + Bluetooth Recommended diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 21d90e300..261d1990f 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1,5 +1,7 @@ + Skip + Bluetooth Recommended diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 80deea925..008f7fa9a 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -362,4 +362,5 @@ %d जना + ब्लुटुथ सिफारिस गरिएको diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 8d4d30968..8d4c21eb3 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -394,4 +394,5 @@ wees de eerste die een notitie voor deze plek toevoegt. sluiten voeg een notitie toe voor deze plek + Bluetooth aanbevolen diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 2c3b7729e..1dee48927 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -349,4 +349,5 @@ %d بندے + Bluetooth Recommended diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 492b0af00..3a15f7680 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -14,5 +14,7 @@ bądź pierwszy, który doda notatkę dla tego miejsca. zamknij dodaj notatkę dla tego miejsca + Pomiń + Bluetooth zalecany diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7d1998be2..7f831a487 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -362,4 +362,5 @@ seja o primeiro a adicionar uma para este local. fechar adicione uma nota para este local + Bluetooth recomendado diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 207810b48..75e257b4b 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -362,4 +362,5 @@ seja o primeiro a adicionar uma para este local. fechar adicione uma nota para este local + Bluetooth recomendado diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 85fd0ba4a..0eeb3bf9f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -352,4 +352,5 @@ станьте первым, кто добавит заметку для этого места. закрыть добавьте заметку для этого места + Рекомендуется Bluetooth diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 62d8ef186..335da8a06 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -350,4 +350,5 @@ var först med att lägga till en anteckning för den här platsen. stäng lägg till en anteckning för den här platsen + Bluetooth rekommenderas diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 76893af89..65102d928 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -1,5 +1,7 @@ + Skip + Bluetooth Recommended diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index c1cbedbf8..588779c8c 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -349,4 +349,5 @@ %d คน + Bluetooth Recommended diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index cb728f5c9..9117b92c7 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -350,4 +350,5 @@ bu yer için ilk notu ekleyen siz olun. kapat bu yer için bir not ekleyin + Bluetooth Önerilir diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c644c198e..401f4f3df 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1,5 +1,7 @@ + Пропустити + Рекомендується Bluetooth diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index f6a5832c7..e6510977c 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -362,4 +362,5 @@ اس جگہ کے لیے پہلا نوٹ شامل کریں۔ بند کریں اس جگہ کے لیے ایک نوٹ شامل کریں + Bluetooth Recommended diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 7a7df41fe..f6a96348a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -349,4 +349,5 @@ %d người + Khuyên dùng Bluetooth diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6ab11776a..14ed450a3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -14,5 +14,7 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + 跳过 + 建议开启蓝牙 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b119edfb0..de9a93333 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -14,5 +14,7 @@ 成為第一個為此地點新增筆記的人。 關閉 為此地點新增一則筆記 + 跳過 + 建議開啟藍牙 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 1ae119416..5162197d3 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -375,4 +375,5 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + 建议开启蓝牙 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a985c6c3..88c0f6ee5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -399,4 +399,5 @@ %d people + Bluetooth Recommended