From f4341ae1c189a601ecd9b93cb8194dd3327a722d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:24:55 +0200 Subject: [PATCH] wifi aware wip --- app/src/main/AndroidManifest.xml | 9 + .../com/bitchat/android/BitchatApplication.kt | 6 + .../onboarding/OnboardingCoordinator.kt | 1 + .../onboarding/PermissionExplanationScreen.kt | 2 + .../android/onboarding/PermissionManager.kt | 20 + .../bitchat/android/ui/SidebarComponents.kt | 16 +- .../ui/debug/DebugPreferenceManager.kt | 26 + .../android/ui/debug/DebugSettingsManager.kt | 45 + .../android/ui/debug/DebugSettingsSheet.kt | 93 ++ .../android/wifi-aware/WifiAwareController.kt | 111 ++ .../wifi-aware/WifiAwareMeshServiceLatest.kt | 1163 +++++++++++++++++ 11 files changed, 1490 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshServiceLatest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e953323..e99bb2ce 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -19,6 +20,12 @@ + + + + + + @@ -38,6 +45,8 @@ + + "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 2b84aefa..c00f35f3 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 ff0a160f..aedf85f8 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/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index a8c59ef7..2a6b2035 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,21 @@ private fun PeerItem( tint = Color.Gray ) } else { + val wifiAwarePeers by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState() + val isWifiAware = wifiAwarePeers.containsKey(peerID) + val icon = when { + isWifiAware -> Icons.Filled.Wifi + isDirect -> Icons.Outlined.SettingsInputAntenna + else -> Icons.Filled.Route + } + val cd = when { + isWifiAware -> "Direct Wi‑Fi Aware" + isDirect -> "Direct Bluetooth" + 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 e2f9d966..2d734c14 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,6 +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" + // 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 @@ -100,4 +104,26 @@ object DebugPreferenceManager { fun setGcsFprPercent(value: Double) { if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() } + + // 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 6e37286f..2749e58b 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,16 @@ 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() + + private val _wifiAwareVerbose = MutableStateFlow(false) + val wifiAwareVerbose: StateFlow = _wifiAwareVerbose.asStateFlow() + // Connection limit overrides (debug) private val _maxConnectionsOverall = MutableStateFlow(8) val maxConnectionsOverall: StateFlow = _maxConnectionsOverall.asStateFlow() @@ -54,6 +64,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. } @@ -140,6 +154,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) @@ -197,6 +232,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 6cd25547..f17080f0 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 @@ -8,6 +8,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape 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 @@ -53,6 +55,11 @@ fun DebugSettingsSheet( val seenCapacity by manager.seenPacketCapacity.collectAsState() val gcsMaxBytes by manager.gcsMaxBytes.collectAsState() val gcsFpr by manager.gcsFprPercent.collectAsState() + 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() // Push live connected devices from mesh service whenever sheet is visible LaunchedEffect(isPresented) { @@ -76,6 +83,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) } } @@ -198,6 +214,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)) { @@ -289,6 +345,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/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt new file mode 100644 index 00000000..baa58a86 --- /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().entries.associate { (ip, peer) -> peer to 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/WifiAwareMeshServiceLatest.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshServiceLatest.kt new file mode 100644 index 00000000..fccb81bc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshServiceLatest.kt @@ -0,0 +1,1163 @@ +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.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) { + + 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 + + // Transport state + private val peerSockets = ConcurrentHashMap() + private val serverSockets = ConcurrentHashMap() + private val networkCallbacks = ConcurrentHashMap() + private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val handleToPeerId = ConcurrentHashMap() // discovery mapping + private val discoveredTimestamps = ConcurrentHashMap() // peerID -> last seen time + + // Timestamp dedupe + private val lastTimestamps = ConcurrentHashMap() + + // Coroutines + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + init { + setupDelegates() + messageHandler.packetProcessor = packetProcessor + + 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) { + broadcastPacket(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) { + peerSockets.values.forEach { sock -> + try { sock.getOutputStream().write(bytes) } catch (_: IOException) {} + } + } + + /** + * Broadcasts routed packet to currently connected peers. + */ + private fun broadcastPacket(routed: RoutedPacket) { + routed.packet.toBinaryData()?.let { broadcastRaw(it) } + } + + /** + * Send packet to connected peer. + */ + private fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + val sock = peerSockets[peerID] ?: return + try { sock.getOutputStream().write(packet.toBinaryData() ?: return) } catch (_: IOException) {} + } + + /** + * 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 + ) + broadcastPacket(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) { + broadcastPacket(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) { + broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + override fun relayPacket(routed: RoutedPacket) { broadcastPacket(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) { + val hs = encryptionService.initiateHandshake(peerID) ?: return + 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 + ) + broadcastPacket(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) = broadcastPacket(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() + gossipSyncManager.start() + } + + /** + * 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") + + sendLeaveAnnouncement() + + serviceScope.launch { + delay(200) + + gossipSyncManager.stop() + + networkCallbacks.values.forEach { runCatching { cm.unregisterNetworkCallback(it) } } + networkCallbacks.clear() + publishSession?.close(); publishSession = null + subscribeSession?.close(); subscribeSession = null + wifiAwareSession?.close(); wifiAwareSession = null + + serverSockets.values.forEach { it.closeQuietly() } + peerSockets.values.forEach { it.closeQuietly() } + handleToPeerId.clear() + serverSockets.clear() + peerSockets.clear() + + cm.bindProcessToNetwork(null) + + 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) { } + } + } + } + + /** + * 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 (serverSockets.containsKey(peerId)) { + Log.v(TAG, "↪ already serving $peerId, skipping") + return + } + + val ss = ServerSocket(0) + serverSockets[peerId] = ss + val port = ss.localPort + Log.d(TAG, "SERVER: listening for ${peerId.take(8)}… on port $port") + + val spec = WifiAwareNetworkSpecifier.Builder(pubSession, peerHandle) + .setPskPassphrase(PSK) + .setPort(port) + .build() + + val req = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(spec) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + cm.bindProcessToNetwork(network) + try { + val client = ss.accept().apply { keepAlive = true } + Log.d(TAG, "SERVER: accepted TCP from ${peerId.take(8)}… addr=${client.inetAddress?.hostAddress}") + peerSockets[peerId] = client + 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) + } + } catch (ioe: IOException) { + Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}…", ioe) + } + } + override fun onLost(network: Network) { + cm.bindProcessToNetwork(null) + networkCallbacks.remove(peerId) + Log.d(TAG, "SERVER: network lost for ${peerId.take(8)}…") + } + } + + networkCallbacks[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 { + val os = client.getOutputStream() + while (peerSockets.containsKey(peerId)) { + try { os.write(0) } catch (_: IOException) { break } + delay(2_000) + } + } + // Discovery keep-alive + serviceScope.launch { + var msgId = 0 + while (peerSockets.containsKey(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 (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) { + cm.bindProcessToNetwork(network) + } + override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) { + if (peerSockets.containsKey(peerId)) return + val info = (nc.transportInfo as? WifiAwareNetworkInfo) ?: return + val addr = info.peerIpv6Addr as Inet6Address + + try { + val sock = network.socketFactory + .createSocket(addr, port) + .apply { keepAlive = true } + + Log.d(TAG, "CLIENT: TCP connected to ${peerId.take(8)}… addr=$addr:$port") + peerSockets[peerId] = sock + listenerExec.execute { listenToPeer(sock, peerId) } + handleServerKeepAlive(sock, peerId, peerHandle) + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + messageHandler.delegate?.initiateNoiseHandshake(peerId) + } + } catch (ioe: IOException) { + Log.e(TAG, "CLIENT: socket connect failed to ${peerId.take(8)}…", ioe) + } + } + override fun onLost(network: Network) { + cm.bindProcessToNetwork(null) + networkCallbacks.remove(peerId) + Log.d(TAG, "CLIENT: network lost for ${peerId.take(8)}…") + } + } + + networkCallbacks[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 { + val os = sock.getOutputStream() + while (peerSockets.containsKey(peerId)) { + try { os.write(0) } catch (_: IOException) { break } + delay(2_000) + } + } + // Discovery keep-alive + serviceScope.launch { + var msgId = 0 + while (peerSockets.containsKey(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) + var routedPeerId: String? = null + + 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 + if (senderPeerHex == myPeerID) continue + + val ts = pkt.timestamp + if (lastTimestamps.put(senderPeerHex, ts) == ts) { + continue + } + + if (routedPeerId == null) { + routedPeerId = senderPeerHex + peerSockets[routedPeerId] = socket + } + + packetProcessor.processPacket(RoutedPacket(pkt, routedPeerId)) + } + + socket.closeQuietly() + routedPeerId?.let { + peerSockets.remove(it) + peerManager.removePeer(it) + } + } + + /** + * 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) + broadcastPacket(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 + if (recipientNickname.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 + ) + broadcastPacket(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 + ) + broadcastPacket(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) + broadcastPacket(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) + broadcastPacket(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 = encryptionService.signData(announcePacket.toBinaryDataForSigning()!!)?.let { + announcePacket.copy(signature = it) + } ?: announcePacket + + broadcastPacket(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 = encryptionService.signData(packet.toBinaryDataForSigning()!!)?.let { + packet.copy(signature = it) + } ?: packet + + broadcastPacket(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() + ) + broadcastPacket(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. + */ + fun getDeviceAddressForPeer(peerID: String): String? = + peerSockets[peerID]?.inetAddress?.hostAddress + + /** + * @return a mapping of peerID → connected device IP address for all active sockets. + */ + fun getDeviceAddressToPeerMapping(): Map = + peerSockets.mapValues { it.value.inetAddress.hostAddress } + + /** 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: ${peerSockets.keys}") + 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 +}