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.didUpdatePeerList(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 @@ -347,6 +395,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 +567,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) 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..c48ce087d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -94,6 +94,9 @@ 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 { @@ -268,7 +271,8 @@ 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) + connectionManager.broadcastPacket(routed) } override fun relayPacket(routed: RoutedPacket) { 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/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..223459235 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) { } 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..6e7d5d224 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -96,7 +96,10 @@ class MeshDelegateHandler( override fun didUpdatePeerList(peers: List) { coroutineScope.launch { - state.setConnectedPeers(peers) + // Merge peers from multiple transports to avoid flapping + val current = state.getConnectedPeersValue().toMutableSet() + current.addAll(peers) + state.setConnectedPeers(current.toList()) state.setIsConnected(peers.isNotEmpty()) notificationManager.showActiveUserNotification(peers) // Flush router outbox for any peers that just connected (and their noiseHex aliases) 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/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt new file mode 100644 index 000000000..d10c6741d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -0,0 +1,116 @@ +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 + + // Optional bridge to BLE mesh for cross-transport relaying + @Volatile private var bleMesh: com.bitchat.android.mesh.BluetoothMeshService? = null + fun setBleMeshService(svc: com.bitchat.android.mesh.BluetoothMeshService) { bleMesh = svc } + fun getBleMeshService(): com.bitchat.android.mesh.BluetoothMeshService? = bleMesh +} 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..f895a7cb9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -0,0 +1,1277 @@ +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 + + // 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) { + 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) { + var sent = 0 + 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})") + } + + /** + * 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 = 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 + ) + 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) { + 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 + ) + 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() + + 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 + + // 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}") + peerSockets[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) { + 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 { + try { + val os = client.getOutputStream() + while (peerSockets.containsKey(peerId)) { + try { os.write(0) } catch (_: IOException) { break } + delay(2_000) + } + } catch (_: Exception) {} + } + // 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) { + // Do not bind process for Aware; use per-socket binding instead + } + 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 ?: 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)") + + peerSockets[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) { + 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 { + try { + val os = sock.getOutputStream() + while (peerSockets.containsKey(peerId)) { + try { os.write(0) } catch (_: IOException) { break } + delay(2_000) + } + } catch (_: Exception) {} + } + // 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 + } + + Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} (bytes=${raw.size})") + packetProcessor.processPacket(RoutedPacket(pkt, routedPeerId)) + } + + // Breaking out of the loop means the socket is dead or service is stopping. + // We MUST notify the mesh layer so it removes the logical peer immediately to allow reconnection. + Log.i(TAG, "Socket loop terminated for ${initialLogicalPeerId.take(8)} removing peer.") + handlePeerDisconnection(initialLogicalPeerId, routedPeerId) + socket.closeQuietly() + } + + private fun handlePeerDisconnection(initialId: String, routedId: String?) { + serviceScope.launch { + Log.d(TAG, "Cleaning up peer: $initialId / $routedId") + + peerSockets.remove(initialId)?.closeQuietly() + serverSockets.remove(initialId)?.closeQuietly() + networkCallbacks.remove(initialId)?.let { runCatching { cm.unregisterNetworkCallback(it) } } + peerManager.removePeer(initialId) + + routedId?.let { id -> + if (id != initialId) { + peerSockets.remove(id)?.closeQuietly() + serverSockets.remove(id)?.closeQuietly() + networkCallbacks.remove(id)?.let { runCatching { cm.unregisterNetworkCallback(it) } } + peerManager.removePeer(id) + } + } + } + } + + /** + * 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 + + 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 = signPacketBeforeBroadcast(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 = signPacketBeforeBroadcast(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. + * Prefers the scoped IPv6 address format. + */ + fun getDeviceAddressForPeer(peerID: String): String? = + 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() + 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: ${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 +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 94414ce5d..3f6d7ddf4 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -362,4 +362,5 @@ كن أول من يضيف ملاحظة لهذا المكان. إغلاق أضف ملاحظة لهذا المكان + الشبكة تعمل — %1$d أقران diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index a9f826586..9948b452c 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -349,4 +349,5 @@ %d জন + মেশ চলছে — %1$d পিয়ার diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 786c9dcb4..84eb0fd4a 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 + Mesh läuft — %1$d Peers diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index acf3cb92d..b72bfdf34 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 + Mesh en ejecución — %1$d pares diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 2896a97d2..4007cf1a3 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -349,4 +349,5 @@ %d نفر + مش در حال اجرا — %1$d همتا diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index bb150d934..b89824d6b 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 + Tumatakbo ang Mesh — %1$d na peer diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b1936bce7..e0e7c7ebe 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 + Mesh actif — %1$d pairs diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index a15f87af8..059924d46 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -14,5 +14,6 @@ היה הראשון להוסיף הערה למקום זה. סגור הוסף הערה למקום זה + רשת Mesh פועלת — %1$d עמיתים diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 209813835..780015b0d 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -362,4 +362,5 @@ इस स्थान के लिए पहला नोट जोड़ें। बंद करें इस स्थान के लिए एक नोट जोड़ें + मेश चल रहा है — %1$d पीयर्स diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 00f8e7cf9..744a499c7 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 + Mesh berjalan — %1$d peer diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d086ba3e9..185dfa3f2 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 + Mesh in esecuzione — %1$d peer diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index be41d3d40..7d8f530ff 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -362,4 +362,5 @@ この場所の最初のメモを追加しましょう。 閉じる この場所へのメモを追加 + メッシュ実行中 — %1$d ピア diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index dbf16225c..7c999d96f 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -349,4 +349,5 @@ %d ადამიანი + Mesh გაშვებულია — %1$d პირები diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3163fa0c2..bf9b6d538 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -362,4 +362,5 @@ 이 장소에 첫 번째 노트를 추가해 보세요. 닫기 이 장소에 노트 추가 + 메시 실행 중 — %1$d 피어 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 8d359165e..4bed7bc05 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 + Mandeha ny Mesh — %1$d peers diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 21d90e300..5027aade6 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1,5 +1,6 @@ + Mesh sedang berjalan — %1$d rakan diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 80deea925..bae76cf00 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -362,4 +362,5 @@ %d जना + मेश चलिरहेको छ — %1$d पियर्स diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 8d4d30968..40fdf86bd 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 + Mesh actief — %1$d peers diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 2c3b7729e..80ea2b840 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 بندے + میش چل رہا ہے — %1$d ساتھی diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 492b0af00..daa016d6a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -14,5 +14,6 @@ bądź pierwszy, który doda notatkę dla tego miejsca. zamknij dodaj notatkę dla tego miejsca + Mesh działa — %1$d peerów diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7d1998be2..d736dd397 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 + Mesh rodando — %1$d pares diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 207810b48..8e87c6e66 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 + Mesh em execução — %1$d pares diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 85fd0ba4a..c6c3bd372 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -352,4 +352,5 @@ станьте первым, кто добавит заметку для этого места. закрыть добавьте заметку для этого места + Mesh запущен — %1$d пиров diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 62d8ef186..d461be4c8 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 + Mesh körs — %1$d peers diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 76893af89..c9e8943c4 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -1,5 +1,6 @@ + மெஷ் இயங்குகிறது — %1$d பியர்ஸ் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index c1cbedbf8..9ae074c79 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -349,4 +349,5 @@ %d คน + Mesh กำลังทำงาน — %1$d เพื่อน diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index cb728f5c9..291c99048 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 + Mesh çalışıyor — %1$d eş diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c644c198e..16914c6a0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1,5 +1,6 @@ + Mesh працює — %1$d пірів diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index f6a5832c7..b782115f9 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -362,4 +362,5 @@ اس جگہ کے لیے پہلا نوٹ شامل کریں۔ بند کریں اس جگہ کے لیے ایک نوٹ شامل کریں + میش چل رہا ہے — %1$d ساتھی diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 7a7df41fe..ec726fc90 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 + Mesh đang chạy — %1$d ngang hàng diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6ab11776a..b979b121d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -14,5 +14,6 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + Mesh 运行中 — %1$d 个节点 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b119edfb0..7be4525ff 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -14,5 +14,6 @@ 成為第一個為此地點新增筆記的人。 關閉 為此地點新增一則筆記 + Mesh 運行中 — %1$d 個節點 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 1ae119416..17f53bbda 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -375,4 +375,5 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + Mesh 运行中 — %1$d 个节点 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a985c6c3..d85513dec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,7 +64,7 @@ Mesh Background Service Keeps the Bluetooth mesh running in the background - Mesh running — %1$d users connected + Mesh running — %1$d peers Add to favorites