From 3f8c236a7258bf52efe4b12d2c5f592d5e78d9dd Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 13 Dec 2025 16:43:39 +0700 Subject: [PATCH 01/15] Background persistence (#505) * persistence step 1 * fix build * messages in the background work, notifications not yet * app state store * DM icon shows up * notification launches when app is closed! * keep ui updated * lifecycle fixes * extensive logging, maybe revert later * send nickname in announcement * quit in notification * setting in about sheet * fix quit bitchat * lifecycle fixes * power mode based on background state * stats for both direciotns * fix graph persistence * better counting * count per device * only compute when debug sheet is open? untested * fix read receipts * fix read receipts fully * fix unread badge if messages have been read in focus * foreground promotion fix * fix app kill in notification * adjust to new tor * nice * about sheet design --- app/src/main/AndroidManifest.xml | 36 + .../com/bitchat/android/BitchatApplication.kt | 6 + .../java/com/bitchat/android/MainActivity.kt | 39 +- .../mesh/BluetoothConnectionManager.kt | 16 + .../android/mesh/BluetoothMeshService.kt | 111 ++- .../mesh/BluetoothPacketBroadcaster.kt | 16 +- .../com/bitchat/android/mesh/PowerManager.kt | 33 +- .../android/service/AppShutdownCoordinator.kt | 67 ++ .../android/service/BootCompletedReceiver.kt | 16 + .../android/service/MeshForegroundService.kt | 324 +++++++ .../android/service/MeshServiceHolder.kt | 60 ++ .../android/service/MeshServicePreferences.kt | 32 + .../bitchat/android/services/AppStateStore.kt | 109 +++ .../android/services/NicknameProvider.kt | 21 + .../java/com/bitchat/android/ui/AboutSheet.kt | 880 ++++++++++-------- .../com/bitchat/android/ui/ChatViewModel.kt | 35 + .../bitchat/android/ui/MeshDelegateHandler.kt | 55 +- .../com/bitchat/android/ui/MessageManager.kt | 51 +- .../bitchat/android/ui/PrivateChatManager.kt | 72 +- .../ui/debug/DebugPreferenceManager.kt | 3 + .../android/ui/debug/DebugSettingsManager.kt | 198 +++- .../android/ui/debug/DebugSettingsSheet.kt | 462 +++++++-- .../com/bitchat/android/util/AppConstants.kt | 4 +- app/src/main/res/values/strings.xml | 8 + 24 files changed, 2079 insertions(+), 575 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt create mode 100644 app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt create mode 100644 app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt create mode 100644 app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt create mode 100644 app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt create mode 100644 app/src/main/java/com/bitchat/android/services/AppStateStore.kt create mode 100644 app/src/main/java/com/bitchat/android/services/NicknameProvider.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e953323..b228fd34 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,13 @@ + + + + + + + @@ -77,5 +84,34 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 06fb33c7..b120b216 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -41,6 +41,12 @@ class BitchatApplication : Application() { // Initialize debug preference manager (persists debug toggles) try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { } + // Initialize mesh service preferences + try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { } + + // Proactively start the foreground service to keep mesh alive + try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { } + // TorManager already initialized above } } diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 28d67280..6e9fbd39 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -51,7 +51,7 @@ class MainActivity : OrientationAwareActivity() { private lateinit var locationStatusManager: LocationStatusManager private lateinit var batteryOptimizationManager: BatteryOptimizationManager - // Core mesh service - managed at app level + // Core mesh service - provided by the foreground service holder private lateinit var meshService: BluetoothMeshService private val mainViewModel: MainViewModel by viewModels() private val chatViewModel: ChatViewModel by viewModels { @@ -66,13 +66,21 @@ class MainActivity : OrientationAwareActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Check if this is a quit request from the notification + if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { + android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity") + finish() + return + } + // Enable edge-to-edge display for modern Android look enableEdgeToEdge() // Initialize permission management permissionManager = PermissionManager(this) - // Initialize core mesh service first - meshService = BluetoothMeshService(this) + // Ensure foreground service is running and get mesh instance from holder + try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { } + meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext) bluetoothStatusManager = BluetoothStatusManager( activity = this, context = this, @@ -630,6 +638,15 @@ class MainActivity : OrientationAwareActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) + + // Check if this is a quit request from the notification + if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { + android.util.Log.d("MainActivity", "Quit request received, finishing activity") + finish() + return + } + // Handle notification intents when app is already running if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) @@ -640,6 +657,8 @@ class MainActivity : OrientationAwareActivity() { super.onResume() // Check Bluetooth and Location status on resume and handle accordingly if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { + // Reattach mesh delegate to new ChatViewModel instance after Activity recreation + try { meshService.delegate = chatViewModel } catch (_: Exception) { } // Set app foreground state meshService.connectionManager.setAppBackgroundState(false) chatViewModel.setAppBackgroundState(false) @@ -665,13 +684,15 @@ class MainActivity : OrientationAwareActivity() { } } - override fun onPause() { + override fun onPause() { super.onPause() // Only set background state if app is fully initialized if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { // Set app background state meshService.connectionManager.setAppBackgroundState(true) chatViewModel.setAppBackgroundState(true) + // Detach UI delegate so the foreground service can own DM notifications while UI is closed + try { meshService.delegate = null } catch (_: Exception) { } } } @@ -746,14 +767,6 @@ class MainActivity : OrientationAwareActivity() { Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}") } - // Stop mesh services if app was fully initialized - if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - try { - meshService.stopServices() - Log.d("MainActivity", "Mesh services stopped successfully") - } catch (e: Exception) { - Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}") - } - } + // Do not stop mesh here; ForegroundService owns lifecycle for background reliability } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 3ac87764..8e0fa15b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -149,6 +149,7 @@ class BluetoothConnectionManager( try { isActive = true + Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)") // set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable // try { @@ -179,6 +180,7 @@ class BluetoothConnectionManager( this@BluetoothConnectionManager.isActive = false return@launch } + Log.d(TAG, "GATT Server started") } else { Log.i(TAG, "GATT Server disabled by debug settings; not starting") } @@ -189,6 +191,7 @@ class BluetoothConnectionManager( this@BluetoothConnectionManager.isActive = false return@launch } + Log.d(TAG, "GATT Client started") } else { Log.i(TAG, "GATT Client disabled by debug settings; not starting") } @@ -214,6 +217,7 @@ class BluetoothConnectionManager( isActive = false connectionScope.launch { + Log.d(TAG, "Stopping client/server and power components...") // Stop component managers clientManager.stop() serverManager.stop() @@ -230,6 +234,18 @@ class BluetoothConnectionManager( Log.i(TAG, "All Bluetooth services stopped") } } + + /** + * Indicates whether this instance can be safely reused for a future start. + * Returns false if its coroutine scope has been cancelled. + */ + fun isReusable(): Boolean { + val active = connectionScope.isActive + if (!active) { + Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)") + } + return active + } /** * Set app background state for power optimization 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 dd12138d..3de48407 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -52,6 +52,12 @@ class BluetoothMeshService(private val context: Context) { internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access private val packetProcessor = PacketProcessor(myPeerID) private lateinit var gossipSyncManager: GossipSyncManager + // Service-level notification manager for background (no-UI) DMs + private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( + context.applicationContext, + androidx.core.app.NotificationManagerCompat.from(context.applicationContext), + com.bitchat.android.util.NotificationIntervalManager() + ) // Service state management private var isActive = false @@ -61,8 +67,11 @@ class BluetoothMeshService(private val context: Context) { // Coroutines private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // Tracks whether this instance has been terminated via stopServices() + private var terminated = false init { + Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") setupDelegates() messageHandler.packetProcessor = packetProcessor //startPeriodicDebugLogging() @@ -98,6 +107,7 @@ class BluetoothMeshService(private val context: Context) { return signPacketBeforeBroadcast(packet) } } + Log.d(TAG, "Delegates set up; GossipSyncManager initialized") } /** @@ -105,6 +115,7 @@ class BluetoothMeshService(private val context: Context) { */ private fun startPeriodicDebugLogging() { serviceScope.launch { + Log.d(TAG, "Starting periodic debug logging loop") while (isActive) { try { delay(10000) // 10 seconds @@ -116,6 +127,7 @@ class BluetoothMeshService(private val context: Context) { Log.e(TAG, "Error in periodic debug logging: ${e.message}") } } + Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)") } } @@ -124,6 +136,7 @@ class BluetoothMeshService(private val context: Context) { */ private fun sendPeriodicBroadcastAnnounce() { serviceScope.launch { + Log.d(TAG, "Starting periodic announce loop") while (isActive) { try { delay(30000) // 30 seconds @@ -132,6 +145,7 @@ class BluetoothMeshService(private val context: Context) { Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") } } + Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)") } } @@ -139,6 +153,7 @@ class BluetoothMeshService(private val context: Context) { * Setup delegate connections between components */ private fun setupDelegates() { + Log.d(TAG, "Setting up component delegates") // Provide nickname resolver to BLE broadcaster for detailed logs try { connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) } @@ -146,6 +161,9 @@ class BluetoothMeshService(private val context: Context) { // PeerManager delegates to main mesh service delegate peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { + // Update process-wide state first + try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { } + // Then notify UI delegate if attached delegate?.didUpdatePeerList(peerIDs) } override fun onPeerRemoved(peerID: String) { @@ -165,6 +183,7 @@ class BluetoothMeshService(private val context: Context) { override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { // Send announcement and cached messages after key exchange serviceScope.launch { + Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups") delay(100) sendAnnouncementToPeer(peerID) @@ -352,7 +371,36 @@ class BluetoothMeshService(private val context: Context) { // Callbacks override fun onMessageReceived(message: BitchatMessage) { + // Always reflect into process-wide store so UI can hydrate after recreation + try { + when { + message.isPrivate -> { + val peer = message.senderPeerID ?: "" + if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message) + } + message.channel != null -> { + com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message) + } + else -> { + com.bitchat.android.services.AppStateStore.addPublicMessage(message) + } + } + } catch (_: Exception) { } + // And forward to UI delegate if attached delegate?.didReceiveMessage(message) + + // If no UI delegate attached (app closed), show DM notification via service manager + if (delegate == null && message.isPrivate) { + try { + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID + val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message) + serviceNotificationManager.setAppBackgroundState(true) + serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview) + } + } catch (_: Exception) { } + } } override fun onChannelLeave(channel: String, fromPeer: String) { @@ -477,12 +525,23 @@ class BluetoothMeshService(private val context: Context) { // BluetoothConnectionManager delegates connectionManager.delegate = object : BluetoothConnectionManagerDelegate { override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) { + // Log incoming for debug graphs (do not double-count anywhere else) + try { + val nick = getPeerNicknames()[peerID] + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming( + packetType = packet.type.toString(), + fromPeerID = peerID, + fromNickname = nick, + fromDeviceAddress = device?.address + ) + } catch (_: Exception) { } packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address)) } override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) { // Send initial announcements after services are ready serviceScope.launch { + Log.d(TAG, "Device connected: ${device.address}; scheduling announce") delay(200) sendBroadcastAnnounce() } @@ -497,6 +556,7 @@ class BluetoothMeshService(private val context: Context) { } override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) { + Log.d(TAG, "Device disconnected: ${device.address}") val addr = device.address // Remove mapping and, if that was the last direct path for the peer, clear direct flag val peer = connectionManager.addressPeerMap[addr] @@ -535,6 +595,11 @@ class BluetoothMeshService(private val context: Context) { Log.w(TAG, "Mesh service already active, ignoring duplicate start request") return } + if (terminated) { + // This instance's scope was cancelled previously; refuse to start to avoid using dead scopes. + Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting") + return + } Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID") @@ -546,6 +611,7 @@ class BluetoothMeshService(private val context: Context) { Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)") // Start periodic syncs gossipSyncManager.start() + Log.d(TAG, "GossipSyncManager started") } else { Log.e(TAG, "Failed to start Bluetooth services") } @@ -567,11 +633,14 @@ class BluetoothMeshService(private val context: Context) { sendLeaveAnnouncement() serviceScope.launch { + Log.d(TAG, "Stopping subcomponents and cancelling scope...") delay(200) // Give leave message time to send // Stop all components gossipSyncManager.stop() + Log.d(TAG, "GossipSyncManager stopped") connectionManager.stopServices() + Log.d(TAG, "BluetoothConnectionManager stop requested") peerManager.shutdown() fragmentManager.shutdown() securityManager.shutdown() @@ -579,9 +648,24 @@ class BluetoothMeshService(private val context: Context) { messageHandler.shutdown() packetProcessor.shutdown() + // Mark this instance as terminated and cancel its scope so it won't be reused + terminated = true serviceScope.cancel() + Log.i(TAG, "BluetoothMeshService terminated and scope cancelled") } } + + /** + * Whether this instance can be safely reused. Returns false after stopServices() or if + * any critical internal scope has been cancelled. + */ + fun isReusable(): Boolean { + val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable() + if (!reusable) { + Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})") + } + return reusable + } /** * Send public message @@ -801,7 +885,7 @@ class BluetoothMeshService(private val context: Context) { fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { serviceScope.launch { Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID") - + // Route geohash read receipts via MessageRouter instead of here val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull() val isGeoAlias = try { @@ -812,8 +896,15 @@ class BluetoothMeshService(private val context: Context) { geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID) return@launch } - + try { + // Avoid duplicate read receipts: check persistent store first + val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null } + if (seenStore?.hasRead(messageID) == true) { + Log.d(TAG, "Skipping read receipt for $messageID - already marked read") + return@launch + } + // Create read receipt payload using NoisePayloadType exactly like iOS val readReceiptPayload = com.bitchat.android.model.NoisePayload( type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT, @@ -839,7 +930,10 @@ class BluetoothMeshService(private val context: Context) { val signedPacket = signPacketBeforeBroadcast(packet) connectionManager.broadcastPacket(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") - + + // Persist as read after successful send + try { seenStore?.markRead(messageID) } catch (_: Exception) { } + } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}") } @@ -852,7 +946,7 @@ class BluetoothMeshService(private val context: Context) { fun sendBroadcastAnnounce() { Log.d(TAG, "Sending broadcast announce") serviceScope.launch { - val nickname = delegate?.getNickname() ?: myPeerID + val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID } // Get the static public key for the announcement val staticKey = encryptionService.getStaticPublicKey() @@ -901,7 +995,7 @@ class BluetoothMeshService(private val context: Context) { fun sendAnnouncementToPeer(peerID: String) { if (peerManager.hasAnnouncedToPeer(peerID)) return - val nickname = delegate?.getNickname() ?: myPeerID + val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID } // Get the static public key for the announcement val staticKey = encryptionService.getStaticPublicKey() @@ -1000,6 +1094,13 @@ class BluetoothMeshService(private val context: Context) { return peerManager.getFingerprintForPeer(peerID) } + /** + * Get current active peer count (for status/notifications) + */ + fun getActivePeerCount(): Int { + return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 } + } + /** * Get peer info for verification purposes */ diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index b3474217..04e5d756 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster( try { val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) } val toNick = toPeer?.let { nicknameResolver?.invoke(it) } - val isRelay = (incomingAddr != null || incomingPeer != null) - - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed( + val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + // Always log outgoing for the actual transmission target + manager.logOutgoing( + packetType = typeName, + toPeerID = toPeer, + toNickname = toNick, + toDeviceAddress = toDeviceAddress, + previousHopPeerID = incomingPeer + ) + // Keep the verbose relay message for human readability + manager.logPacketRelayDetailed( packetType = typeName, senderPeerID = senderPeerID, senderNickname = senderNick, @@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster( toNickname = toNick, toDeviceAddress = toDeviceAddress, ttl = ttl, - isRelay = isRelay + isRelay = true ) } catch (_: Exception) { // Silently ignore debug logging failures diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt index 9db444e6..46bc394f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt @@ -224,26 +224,29 @@ class PowerManager(private val context: Context) { } private fun updatePowerMode() { - val newMode = when { - // Always use performance mode when charging (unless in background too long) + // Determine the base mode from battery/charging state only + val baseMode = when { + // Charging in foreground may use performance isCharging && !isAppInBackground -> PowerMode.PERFORMANCE - - // Critical battery - use ultra low power + + // Critical battery - force ultra low power regardless of foreground/background batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER - - // Low battery - use power saver + + // Low battery - prefer power saver batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER - - // Background app with medium battery - use power saver - isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER - - // Background app with good battery - use balanced - isAppInBackground -> PowerMode.BALANCED - - // Foreground with good battery - use balanced + + // Otherwise balanced else -> PowerMode.BALANCED } - + + // If app is in background (including when running as a foreground service), + // cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER. + val newMode = if (isAppInBackground) { + if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER + } else { + baseMode + } + if (newMode != currentMode) { val oldMode = currentMode currentMode = newMode diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt new file mode 100644 index 00000000..d4e00104 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.service + +import android.app.Application +import android.os.Process +import androidx.core.app.NotificationManagerCompat +import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Coordinates a full application shutdown: + * - Stop mesh cleanly + * - Stop Tor without changing persistent setting + * - Clear in-memory AppState + * - Stop foreground service/notification + * - Kill the process after completion or after a 5s timeout + */ +object AppShutdownCoordinator { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + fun requestFullShutdownAndKill( + app: Application, + mesh: BluetoothMeshService?, + notificationManager: NotificationManagerCompat, + stopForeground: () -> Unit, + stopService: () -> Unit + ) { + scope.launch { + // Stop mesh (best-effort) + try { mesh?.stopServices() } catch (_: Exception) { } + + // Stop Tor temporarily (do not change user setting) + val torProvider = ArtiTorManager.getInstance() + val torStop = async { + try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { } + } + + // Clear AppState in-memory store + try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { } + + // Stop foreground and clear notification + try { stopForeground() } catch (_: Exception) { } + try { notificationManager.cancel(10001) } catch (_: Exception) { } + + // Wait up to 5 seconds for shutdown tasks + withTimeoutOrNull(5000) { + try { torStop.await() } catch (_: Exception) { } + delay(100) + } + + // Stop the service itself + try { stopService() } catch (_: Exception) { } + + // Hard kill the app process + try { Process.killProcess(Process.myPid()) } catch (_: Exception) { } + try { System.exit(0) } catch (_: Exception) { } + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt new file mode 100644 index 00000000..03a9dcd8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt @@ -0,0 +1,16 @@ +package com.bitchat.android.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class BootCompletedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // Ensure preferences are initialized on cold boot before reading values + try { MeshServicePreferences.init(context.applicationContext) } catch (_: Exception) { } + + if (MeshServicePreferences.isAutoStartEnabled(true)) { + MeshForegroundService.start(context.applicationContext) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt new file mode 100644 index 00000000..7510fa06 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -0,0 +1,324 @@ +package com.bitchat.android.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.bitchat.android.MainActivity +import com.bitchat.android.R +import com.bitchat.android.mesh.BluetoothMeshService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class MeshForegroundService : Service() { + + companion object { + private const val CHANNEL_ID = "bitchat_mesh_service" + private const val NOTIFICATION_ID = 10001 + + const val ACTION_START = "com.bitchat.android.service.START" + const val ACTION_STOP = "com.bitchat.android.service.STOP" + const val ACTION_QUIT = "com.bitchat.android.service.QUIT" + const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION" + const val ACTION_NOTIFICATION_PERMISSION_GRANTED = "com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" + + fun start(context: Context) { + val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START } + + // On API >= 26, avoid background-service start restrictions by using startForegroundService + // only when we can actually post a notification (Android 13+ requires runtime notif permission) + val bgEnabled = MeshServicePreferences.isBackgroundEnabled(true) + val hasNotifPerm = hasNotificationPermissionStatic(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (bgEnabled && hasNotifPerm) { + context.startForegroundService(intent) + } else { + // Do not attempt to start a background service from headless context without notif permission + // or when background is disabled, to avoid BackgroundServiceStartNotAllowedException. + android.util.Log.i( + "MeshForegroundService", + "Not starting service on API>=26 (bgEnabled=$bgEnabled, hasNotifPerm=$hasNotifPerm)" + ) + } + } else { + if (bgEnabled) { + context.startService(intent) + } else { + android.util.Log.i("MeshForegroundService", "Background disabled; not starting service (pre-O)") + } + } + } + + /** + * Helper to be invoked right after POST_NOTIFICATIONS is granted to try + * promoting/starting the foreground service immediately without polling. + */ + fun onNotificationPermissionGranted(context: Context) { + // If background is enabled and permission now granted, start/promo service + val hasNotifPerm = hasNotificationPermissionStatic(context) + if (!MeshServicePreferences.isBackgroundEnabled(true) || !hasNotifPerm) return + + val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Safe now that we can show a notification + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP } + context.startService(intent) + } + + private fun shouldStartAsForeground(context: Context): Boolean { + return MeshServicePreferences.isBackgroundEnabled(true) && + hasBluetoothPermissionsStatic(context) && + hasNotificationPermissionStatic(context) + } + + private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + val fine = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + fine || coarse + } + } + + private fun hasNotificationPermissionStatic(ctx: Context): Boolean { + return if (Build.VERSION.SDK_INT >= 33) { + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else true + } + } + + private lateinit var notificationManager: NotificationManagerCompat + private var updateJob: Job? = null + private var meshService: BluetoothMeshService? = null + private val serviceJob = Job() + private val scope = CoroutineScope(Dispatchers.Default + serviceJob) + private var isInForeground: Boolean = false + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(this) + createChannel() + + // Adopt or create the mesh service + val existing = MeshServiceHolder.meshService + meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext) + if (existing != null) { + android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder") + } else { + android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder") + } + MeshServiceHolder.attach(meshService!!) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + // Stop FGS and mesh cleanly + try { meshService?.stopServices() } catch (_: Exception) { } + try { MeshServiceHolder.clear() } catch (_: Exception) { } + try { stopForeground(true) } catch (_: Exception) { } + notificationManager.cancel(NOTIFICATION_ID) + isInForeground = false + stopSelf() + return START_NOT_STICKY + } + ACTION_QUIT -> { + // Fully stop all background activity, stop Tor (without changing setting), then kill the app + AppShutdownCoordinator.requestFullShutdownAndKill( + app = application, + mesh = meshService, + notificationManager = notificationManager, + stopForeground = { + try { stopForeground(true) } catch (_: Exception) { } + isInForeground = false + }, + stopService = { stopSelf() } + ) + return START_NOT_STICKY + } + ACTION_UPDATE_NOTIFICATION -> { + // If we became eligible and are not in foreground yet, promote once + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { + val n = buildNotification(meshService?.getActivePeerCount() ?: 0) + startForeground(NOTIFICATION_ID, n) + isInForeground = true + } else { + updateNotification(force = true) + } + } + else -> { /* ACTION_START or null */ } + } + + // Ensure mesh is running (only after permissions are granted) + ensureMeshStarted() + + // Promote exactly once when eligible, otherwise stay background (or stop) + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { + val notification = buildNotification(meshService?.getActivePeerCount() ?: 0) + startForeground(NOTIFICATION_ID, notification) + isInForeground = true + } + + // Periodically refresh the notification with live network size + if (updateJob == null) { + updateJob = scope.launch { + while (isActive) { + // Retry enabling mesh/foreground once permissions become available + ensureMeshStarted() + val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() + if (eligible) { + // Only update the notification; do not re-call startForeground() + updateNotification(force = false) + } else { + // If disabled or perms missing, ensure we are not in foreground and clear notif + if (isInForeground) { + try { stopForeground(false) } catch (_: Exception) { } + isInForeground = false + } + notificationManager.cancel(NOTIFICATION_ID) + } + delay(5000) + } + } + } + + return START_STICKY + } + + private fun ensureMeshStarted() { + if (!hasBluetoothPermissions()) return + try { + android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") + meshService?.startServices() + } catch (e: Exception) { + android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}") + } + } + + private fun updateNotification(force: Boolean) { + val count = meshService?.getActivePeerCount() ?: 0 + val notification = buildNotification(count) + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { + notificationManager.notify(NOTIFICATION_ID, notification) + } else if (force) { + // If disabled and forced, make sure to remove any prior foreground state + try { stopForeground(false) } catch (_: Exception) { } + notificationManager.cancel(NOTIFICATION_ID) + isInForeground = false + } + } + + private fun hasAllRequiredPermissions(): Boolean { + // For starting FGS with connectedDevice|dataSync, we need: + // - Foreground service permissions (declared in manifest) + // - One of the device-related permissions (we request BL perms at runtime) + // - On Android 13+, POST_NOTIFICATIONS to actually show notification + return hasBluetoothPermissions() && hasNotificationPermission() + } + + private fun hasBluetoothPermissions(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + // Prior to S, scanning requires location permissions + val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + fine || coarse + } + } + + private fun hasNotificationPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= 33) { + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else true + } + + private fun buildNotification(activeUsers: Int): Notification { + val openIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + // Action: Quit Bitchat + val quitIntent = Intent(this, MeshForegroundService::class.java).apply { action = ACTION_QUIT } + val quitPendingIntent = PendingIntent.getService( + this, 1, quitIntent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + val title = getString(R.string.app_name) + val content = getString(R.string.mesh_service_notification_content, activeUsers) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent(pendingIntent) + // Add an action button that appears when notification is expanded + .addAction( + android.R.drawable.ic_menu_close_clear_cancel, + getString(R.string.notification_action_quit_bitchat), + quitPendingIntent + ) + .build() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.mesh_service_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.mesh_service_channel_desc) + setShowBadge(false) + } + (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } + } + + override fun onDestroy() { + updateJob?.cancel() + updateJob = null + // Cancel the service coroutine scope to prevent leaks + try { serviceJob.cancel() } catch (_: Exception) { } + // Best-effort ensure we are not marked foreground + if (isInForeground) { + try { stopForeground(true) } catch (_: Exception) { } + isInForeground = false + } + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt new file mode 100644 index 00000000..d271ab29 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -0,0 +1,60 @@ +package com.bitchat.android.service + +import android.content.Context +import com.bitchat.android.mesh.BluetoothMeshService + +/** + * Process-wide holder to share a single BluetoothMeshService instance + * between the foreground service and UI (MainActivity/ViewModels). + */ +object MeshServiceHolder { + private const val TAG = "MeshServiceHolder" + @Volatile + var meshService: BluetoothMeshService? = null + private set + + @Synchronized + fun getOrCreate(context: Context): BluetoothMeshService { + val existing = meshService + if (existing != null) { + // If the existing instance is healthy, reuse it; otherwise, replace it. + return try { + if (existing.isReusable()) { + android.util.Log.d(TAG, "Reusing existing BluetoothMeshService instance") + existing + } else { + android.util.Log.w(TAG, "Existing BluetoothMeshService not reusable; replacing with a fresh instance") + // Best-effort stop before replacing + try { existing.stopServices() } catch (e: Exception) { + android.util.Log.w(TAG, "Error while stopping non-reusable instance: ${e.message}") + } + val created = BluetoothMeshService(context.applicationContext) + android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)") + meshService = created + created + } + } catch (e: Exception) { + android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}") + val created = BluetoothMeshService(context.applicationContext) + meshService = created + created + } + } + val created = BluetoothMeshService(context.applicationContext) + android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)") + meshService = created + return created + } + + @Synchronized + fun attach(service: BluetoothMeshService) { + android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder") + meshService = service + } + + @Synchronized + fun clear() { + android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder") + meshService = null + } +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt new file mode 100644 index 00000000..47335ee3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt @@ -0,0 +1,32 @@ +package com.bitchat.android.service + +import android.content.Context +import android.content.SharedPreferences + +object MeshServicePreferences { + private const val PREFS_NAME = "bitchat_mesh_service_prefs" + private const val KEY_AUTO_START = "auto_start_on_boot" + private const val KEY_BACKGROUND_ENABLED = "background_enabled" + + private lateinit var prefs: SharedPreferences + + fun init(context: Context) { + prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + fun isAutoStartEnabled(default: Boolean = true): Boolean { + return prefs.getBoolean(KEY_AUTO_START, default) + } + + fun setAutoStartEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_AUTO_START, enabled).apply() + } + + fun isBackgroundEnabled(default: Boolean = true): Boolean { + return prefs.getBoolean(KEY_BACKGROUND_ENABLED, default) + } + + fun setBackgroundEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_BACKGROUND_ENABLED, enabled).apply() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt new file mode 100644 index 00000000..07f146bd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -0,0 +1,109 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-wide in-memory state store that survives Activity recreation. + * The foreground Mesh service updates this store; UI subscribes/hydrates from it. + */ +object AppStateStore { + // Global de-dup set by message id to avoid duplicate keys in Compose lists + private val seenMessageIds = mutableSetOf() + // Connected peer IDs (mesh ephemeral IDs) + private val _peers = MutableStateFlow>(emptyList()) + val peers: StateFlow> = _peers.asStateFlow() + + // Public mesh timeline messages (non-channel) + private val _publicMessages = MutableStateFlow>(emptyList()) + val publicMessages: StateFlow> = _publicMessages.asStateFlow() + + // Private messages by peerID + private val _privateMessages = MutableStateFlow>>(emptyMap()) + val privateMessages: StateFlow>> = _privateMessages.asStateFlow() + + // Channel messages by channel name + private val _channelMessages = MutableStateFlow>>(emptyMap()) + val channelMessages: StateFlow>> = _channelMessages.asStateFlow() + + fun setPeers(ids: List) { + _peers.value = ids + } + + fun addPublicMessage(msg: BitchatMessage) { + synchronized(this) { + if (seenMessageIds.contains(msg.id)) return + seenMessageIds.add(msg.id) + _publicMessages.value = _publicMessages.value + msg + } + } + + fun addPrivateMessage(peerID: String, msg: BitchatMessage) { + synchronized(this) { + if (seenMessageIds.contains(msg.id)) return + seenMessageIds.add(msg.id) + val map = _privateMessages.value.toMutableMap() + val list = (map[peerID] ?: emptyList()) + msg + map[peerID] = list + _privateMessages.value = map + } + } + + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { + null -> 0 + is DeliveryStatus.Sending -> 1 + is DeliveryStatus.Sent -> 2 + is DeliveryStatus.PartiallyDelivered -> 3 + is DeliveryStatus.Delivered -> 4 + is DeliveryStatus.Read -> 5 + is DeliveryStatus.Failed -> 0 + } + + fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) { + synchronized(this) { + val map = _privateMessages.value.toMutableMap() + var changed = false + map.keys.toList().forEach { peer -> + val list = map[peer]?.toMutableList() ?: mutableListOf() + val idx = list.indexOfFirst { it.id == messageID } + if (idx >= 0) { + val current = list[idx].deliveryStatus + // Do not downgrade (e.g., Read -> Delivered) + if (statusPriority(status) >= statusPriority(current)) { + list[idx] = list[idx].copy(deliveryStatus = status) + map[peer] = list + changed = true + } + } + } + if (changed) { + _privateMessages.value = map + } + } + } + + fun addChannelMessage(channel: String, msg: BitchatMessage) { + synchronized(this) { + if (seenMessageIds.contains(msg.id)) return + seenMessageIds.add(msg.id) + val map = _channelMessages.value.toMutableMap() + val list = (map[channel] ?: emptyList()) + msg + map[channel] = list + _channelMessages.value = map + } + } + + // Clear all in-memory state (used for full app shutdown) + fun clear() { + synchronized(this) { + seenMessageIds.clear() + _peers.value = emptyList() + _publicMessages.value = emptyList() + _privateMessages.value = emptyMap() + _channelMessages.value = emptyMap() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt b/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt new file mode 100644 index 00000000..c7cc601f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.services + +import android.content.Context +import com.bitchat.android.ui.DataManager + +/** + * Provides current user's nickname for announcements and leave messages. + * If no nickname saved, falls back to the provided peerID. + */ +object NicknameProvider { + fun getNickname(context: Context, myPeerID: String): String { + return try { + val dm = DataManager(context.applicationContext) + val nick = dm.loadNickname() + if (nick.isNullOrBlank()) myPeerID else nick + } catch (_: Exception) { + myPeerID + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 49c664d3..7c5fa7a0 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -12,6 +12,9 @@ import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.NetworkCheck +import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.* import androidx.compose.runtime.* @@ -19,6 +22,7 @@ import androidx.compose.ui.Alignment import kotlinx.coroutines.launch import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily @@ -37,8 +41,162 @@ import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.net.ArtiTorManager /** - * About Sheet for bitchat app information - * Matches the design language of LocationChannelsSheet + * Feature row for displaying app capabilities + */ +@Composable +private fun FeatureRow( + icon: ImageVector, + title: String, + subtitle: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(22.dp) + ) + Spacer(modifier = Modifier.width(14.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 18.sp + ) + } + } +} + +/** + * Theme selection chip with Apple-like styling + */ +@Composable +private fun ThemeChip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + + Surface( + modifier = modifier, + onClick = onClick, + shape = RoundedCornerShape(10.dp), + color = if (selected) { + if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + } else { + colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + } +} + +/** + * Unified settings toggle row with icon, title, subtitle, and switch + * Apple-like design with proper spacing + */ +@Composable +private fun SettingsToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, + statusIndicator: (@Composable () -> Unit)? = null +) { + val colorScheme = MaterialTheme.colorScheme + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f), + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f) + ) + statusIndicator?.invoke() + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f), + lineHeight = 16.sp + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = { if (enabled) onCheckedChange(it) }, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + uncheckedThumbColor = Color.White, + uncheckedTrackColor = colorScheme.surfaceVariant + ) + ) + } +} + +/** + * Apple-like About/Settings Sheet with high-quality design + * Professional UX optimized for checkout scenarios */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -58,8 +216,6 @@ fun AboutSheet( "1.0.0" // fallback version } } - - // Bottom sheet state val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true @@ -72,11 +228,10 @@ fun AboutSheet( } } val topBarAlpha by animateFloatAsState( - targetValue = if (isScrolled) 0.95f else 0f, + targetValue = if (isScrolled) 0.98f else 0f, label = "topBarAlpha" ) - // Color scheme matching LocationChannelsSheet val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f @@ -85,424 +240,352 @@ fun AboutSheet( modifier = modifier.statusBarsPadding(), onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.background, + containerColor = colorScheme.background, dragHandle = null ) { Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = lazyListState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 80.dp, bottom = 20.dp) + contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { - // Header Section + // Header Section - App Identity item(key = "header") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Bottom - ) { - Text( - text = stringResource(R.string.app_name), - style = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 32.sp - ), - color = MaterialTheme.colorScheme.onBackground - ) - - Text( - text = stringResource(R.string.version_prefix, versionName?:""), - fontSize = 11.sp, + Text( + text = stringResource(R.string.app_name), + style = TextStyle( fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.5f), - style = MaterialTheme.typography.bodySmall.copy( - baselineShift = BaselineShift(0.1f) - ) - ) - } - + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + letterSpacing = 1.sp + ), + color = colorScheme.onBackground + ) + Text( + text = stringResource(R.string.version_prefix, versionName ?: ""), + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onBackground.copy(alpha = 0.5f) + ) Text( text = stringResource(R.string.about_tagline), - fontSize = 12.sp, + fontSize = 13.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) + color = colorScheme.onBackground.copy(alpha = 0.6f), + modifier = Modifier.padding(top = 4.dp) ) } } - // Features section - item(key = "feature_offline") { - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Filled.Bluetooth, - contentDescription = stringResource(R.string.cd_offline_mesh_chat), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) + // Features Section - Grouped Card + item(key = "features") { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Text( + text = stringResource(R.string.about_appearance).uppercase(), + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onBackground.copy(alpha = 0.5f), + letterSpacing = 0.5.sp, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = stringResource(R.string.about_offline_mesh_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.about_offline_mesh_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) - ) - } - } - } - item(key = "feature_geohash") { - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Default.Public, - contentDescription = stringResource(R.string.cd_online_geohash_channels), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = stringResource(R.string.about_online_geohash_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.about_online_geohash_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) - ) - } - } - } - item(key = "feature_encryption") { - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Default.Lock, - contentDescription = stringResource(R.string.cd_end_to_end_encryption), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = stringResource(R.string.about_e2e_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.about_e2e_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) - ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) { + Column { + FeatureRow( + icon = Icons.Filled.Bluetooth, + title = stringResource(R.string.about_offline_mesh_title), + subtitle = stringResource(R.string.about_offline_mesh_desc) + ) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + FeatureRow( + icon = Icons.Default.Public, + title = stringResource(R.string.about_online_geohash_title), + subtitle = stringResource(R.string.about_online_geohash_desc) + ) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + FeatureRow( + icon = Icons.Default.Lock, + title = stringResource(R.string.about_e2e_title), + subtitle = stringResource(R.string.about_e2e_desc) + ) + } } } } // Appearance Section - item(key = "appearance_section") { - Text( - text = stringResource(R.string.about_appearance), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(top = 24.dp, bottom = 8.dp) - ) - val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsStateWithLifecycle() - Row( - modifier = Modifier.padding(horizontal = 24.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = themePref.isSystem, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, - label = { Text(stringResource(R.string.about_system), fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = themePref.isLight, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, - label = { Text(stringResource(R.string.about_light), fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = themePref.isDark, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, - label = { Text(stringResource(R.string.about_dark), fontFamily = FontFamily.Monospace) } - ) - } - } - // Proof of Work Section - item(key = "pow_section") { - Text( - text = stringResource(R.string.about_pow), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(top = 24.dp, bottom = 8.dp) - ) - LaunchedEffect(Unit) { - PoWPreferenceManager.init(context) - } - - val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle() - - Column( - modifier = Modifier.padding(horizontal = 24.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - FilterChip( - selected = !powEnabled, - onClick = { PoWPreferenceManager.setPowEnabled(false) }, - label = { Text(stringResource(R.string.about_pow_off), fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = powEnabled, - onClick = { PoWPreferenceManager.setPowEnabled(true) }, - label = { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(R.string.about_pow_on), fontFamily = FontFamily.Monospace) - // Show current difficulty - if (powEnabled) { - Surface( - color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), - shape = RoundedCornerShape(50) - ) { Box(Modifier.size(8.dp)) } - } - } - } - ) - } - + item(key = "appearance") { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { Text( - text = stringResource(R.string.about_pow_tip), - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) + text = "THEME", + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onBackground.copy(alpha = 0.5f), + letterSpacing = 0.5.sp, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) ) - - // Show difficulty slider when enabled - if (powEnabled) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = stringResource(R.string.about_pow_difficulty, powDifficulty, NostrProofOfWork.estimateMiningTime(powDifficulty)), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, + ThemeChip( + label = stringResource(R.string.about_system), + selected = themePref.isSystem, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, + modifier = Modifier.weight(1f) ) - - Slider( - value = powDifficulty.toFloat(), - onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, - valueRange = 0f..32f, - steps = 33, - colors = SliderDefaults.colors( - thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), - activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - ) + ThemeChip( + label = stringResource(R.string.about_light), + selected = themePref.isLight, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_dark), + selected = themePref.isDark, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, + modifier = Modifier.weight(1f) ) - - // Show difficulty description - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(8.dp) - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.about_pow_difficulty_attempts, powDifficulty, NostrProofOfWork.estimateWork(powDifficulty)), - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - Text( - text = when { - powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) - powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) - powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low) - powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium) - powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high) - powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high) - else -> stringResource(R.string.about_pow_desc_extreme) - }, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - } } } } } - // Network (Tor) section - item(key = "network_section") { - val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) } + // Settings Section - Unified Card with Toggles + item(key = "settings") { + LaunchedEffect(Unit) { PoWPreferenceManager.init(context) } + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) } + val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } val torProvider = remember { ArtiTorManager.getInstance() } val torStatus by torProvider.statusFlow.collectAsState() val torAvailable = remember { torProvider.isTorAvailable() } - Text( - text = stringResource(R.string.about_network), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(top = 24.dp, bottom = 8.dp) - ) - Column(modifier = Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - FilterChip( - selected = torMode.value == TorMode.OFF, - onClick = { - torMode.value = TorMode.OFF - TorPreferenceManager.set(context, torMode.value) - }, - label = { Text("tor off", fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = torMode.value == TorMode.ON, - onClick = { - if (torAvailable) { - torMode.value = TorMode.ON - TorPreferenceManager.set(context, torMode.value) - } - }, - enabled = torAvailable, - label = { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("tor on", fontFamily = FontFamily.Monospace) - val statusColor = when { - torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) - torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - else -> Color.Red - } - Surface(color = statusColor, shape = CircleShape) { - Box(Modifier.size(8.dp)) - } - } - } - ) - if (!torAvailable) { - val tooltipState = rememberTooltipState() - val scope = rememberCoroutineScope() - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text( - text = stringResource(R.string.tor_not_available_in_this_build), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace - ) + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Text( + text = "SETTINGS", + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onBackground.copy(alpha = 0.5f), + letterSpacing = 0.5.sp, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) + ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) { + Column { + // Background Mode Toggle + SettingsToggleRow( + icon = Icons.Filled.Bluetooth, + title = stringResource(R.string.about_background_title), + subtitle = stringResource(R.string.about_background_desc), + checked = backgroundEnabled, + onCheckedChange = { enabled -> + backgroundEnabled = enabled + com.bitchat.android.service.MeshServicePreferences.setBackgroundEnabled(enabled) + if (!enabled) { + com.bitchat.android.service.MeshForegroundService.stop(context) + } else { + com.bitchat.android.service.MeshForegroundService.start(context) + } + } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // Proof of Work Toggle + SettingsToggleRow( + icon = Icons.Filled.Speed, + title = stringResource(R.string.about_pow), + subtitle = stringResource(R.string.about_pow_tip), + checked = powEnabled, + onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // Tor Toggle + SettingsToggleRow( + icon = Icons.Filled.Security, + title = "Tor Network", + subtitle = stringResource(R.string.about_tor_route), + checked = torMode.value == TorMode.ON, + onCheckedChange = { enabled -> + if (torAvailable) { + torMode.value = if (enabled) TorMode.ON else TorMode.OFF + TorPreferenceManager.set(context, torMode.value) } }, - state = tooltipState - ) { - IconButton( - onClick = { - scope.launch { - tooltipState.show() + enabled = torAvailable, + statusIndicator = if (torMode.value == TorMode.ON) { + { + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + torStatus.running -> Color(0xFFFF9500) + else -> Color(0xFFFF3B30) } - }, - modifier = Modifier.size(32.dp) + Surface( + color = statusColor, + shape = CircleShape, + modifier = Modifier.size(8.dp) + ) {} + } + } else null + ) + } + } + + // Tor unavailable hint + if (!torAvailable) { + Text( + text = stringResource(R.string.tor_not_available_in_this_build), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onBackground.copy(alpha = 0.5f), + modifier = Modifier.padding(start = 16.dp, top = 8.dp) + ) + } + } + } + + // PoW Difficulty Slider (when enabled) + item(key = "pow_slider") { + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + + if (powEnabled) { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.6f - ), - modifier = Modifier.size(18.dp) + Text( + text = "Difficulty", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.6f) ) } + + Slider( + value = powDifficulty.toFloat(), + onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, + valueRange = 0f..32f, + steps = 31, + colors = SliderDefaults.colors( + thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + ) + ) + + Text( + text = when { + powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) + powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) + powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low) + powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium) + powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high) + powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high) + else -> stringResource(R.string.about_pow_desc_extreme) + }, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.5f) + ) } } } - Text( - text = stringResource(R.string.about_tor_route), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - if (torMode.value == TorMode.ON) { - val statusText = if (torStatus.running) "Running" else "Stopped" - // Debug status (temporary) + } + } + + // Tor Status (when enabled) + item(key = "tor_status") { + val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } + val torProvider = remember { ArtiTorManager.getInstance() } + val torStatus by torProvider.statusFlow.collectAsState() + + if (torMode.value == TorMode.ON) { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { Surface( modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(8.dp) + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) ) { Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = stringResource(R.string.about_tor_status, statusText, torStatus.bootstrapPercent), - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.75f) - ) - val lastLog = torStatus.lastLogLine - if (lastLog.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + torStatus.running -> Color(0xFFFF9500) + else -> Color(0xFFFF3B30) + } + Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} Text( - text = stringResource(R.string.about_last, lastLog.take(160)), - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onSurface.copy(alpha = 0.6f) + text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + } + if (torStatus.lastLogLine.isNotEmpty()) { + Text( + text = torStatus.lastLogLine.take(120), + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.5f), + maxLines = 2 ) } } @@ -511,17 +594,14 @@ fun AboutSheet( } } - // Emergency Warning Section - item(key = "warning_section") { - val colorScheme = MaterialTheme.colorScheme - val errorColor = colorScheme.error - + // Emergency Warning + item(key = "warning") { Surface( modifier = Modifier - .padding(horizontal = 24.dp, vertical = 24.dp) + .padding(horizontal = 20.dp) .fillMaxWidth(), - color = errorColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(12.dp) + color = colorScheme.error.copy(alpha = 0.1f), + shape = RoundedCornerShape(16.dp) ) { Row( modifier = Modifier.padding(16.dp), @@ -530,61 +610,53 @@ fun AboutSheet( ) { Icon( imageVector = Icons.Filled.Warning, - contentDescription = stringResource(R.string.cd_warning), - tint = errorColor, - modifier = Modifier.size(16.dp) + contentDescription = null, + tint = colorScheme.error, + modifier = Modifier.size(20.dp) ) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = stringResource(R.string.about_emergency_title), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = errorColor + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.error ) Text( text = stringResource(R.string.about_emergency_tip), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.8f) + fontSize = 13.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f) ) } } } } - // Footer Section + // Footer item(key = "footer") { Column( modifier = Modifier - .padding(horizontal = 24.dp) - .fillMaxWidth(), + .fillMaxWidth() + .padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { if (onShowDebug != null) { - TextButton( - onClick = onShowDebug, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - ) { + TextButton(onClick = onShowDebug) { Text( text = stringResource(R.string.about_debug_settings), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.primary ) } } Text( text = stringResource(R.string.about_footer), - fontSize = 11.sp, + fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + color = colorScheme.onSurface.copy(alpha = 0.4f) ) - - // Add extra space at bottom for gesture area - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) } } } 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 7cf31d43..1196396d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -143,6 +143,41 @@ class ChatViewModel( init { // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() + // Hydrate UI state from process-wide AppStateStore to survive Activity recreation + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.peers.collect { peers -> + state.setConnectedPeers(peers) + state.setIsConnected(peers.isNotEmpty()) + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.publicMessages.collect { msgs -> + // Source of truth is AppStateStore; replace to avoid duplicate keys in LazyColumn + state.setMessages(msgs) + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer -> + // Replace with store snapshot + state.setPrivateChats(byPeer) + // Recompute unread set using SeenMessageStore for robustness across Activity recreation + try { + val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + val myNick = state.getNicknameValue() ?: meshService.myPeerID + val unread = mutableSetOf() + byPeer.forEach { (peer, list) -> + if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer) + } + state.setUnreadPrivateMessages(unread) + } catch (_: Exception) { } + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel -> + // Replace with store snapshot + state.setChannelMessages(byChannel) + } } catch (_: Exception) { } + } // Subscribe to BLE transfer progress and reflect in message deliveryStatus viewModelScope.launch { com.bitchat.android.mesh.TransferProgressManager.events.collect { evt -> 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 d6a9e746..a452616e 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -46,10 +46,10 @@ class MeshDelegateHandler( if (message.isPrivate) { // Private message privateChatManager.handleIncomingPrivateMessage(message) - - // Reactive read receipts: Send immediately if user is currently viewing this chat + + // Reactive read receipts: if chat is focused, send immediately for this message message.senderPeerID?.let { senderPeerID -> - sendReadReceiptIfFocused(senderPeerID) + sendReadReceiptIfFocused(message) } // Show notification with enhanced information - now includes senderPeerID @@ -64,15 +64,26 @@ class MeshDelegateHandler( ) } } else if (message.channel != null) { - // Channel message + // Channel message: AppStateStore is the source of truth for list; only manage unread if (state.getJoinedChannelsValue().contains(message.channel)) { - channelManager.addChannelMessage(message.channel, message, message.senderPeerID) + val channel = message.channel + val viewingClassic = state.getCurrentChannelValue() == channel + val viewingGeohash = try { + if (channel.startsWith("geo:")) { + val geo = channel.removePrefix("geo:") + val selected = state.selectedLocationChannel.value + selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true) + } else false + } catch (_: Exception) { false } + if (!viewingClassic && !viewingGeohash) { + val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap() + currentUnread[channel] = (currentUnread[channel] ?: 0) + 1 + state.setUnreadChannelMessages(currentUnread) + } } } else { - // Public mesh message - always store to preserve message history - messageManager.addMessage(message) - - // Check for mentions in mesh chat + // Public mesh message: AppStateStore is the source of truth; avoid double-adding to UI state + // Still run mention detection/notifications checkAndTriggerMeshMentionNotification(message) } @@ -263,21 +274,31 @@ class MeshDelegateHandler( * Uses same logic as notification system - send read receipt if user is currently * viewing the private chat with this sender AND app is in foreground. */ - private fun sendReadReceiptIfFocused(senderPeerID: String) { + private fun sendReadReceiptIfFocused(message: BitchatMessage) { // Get notification manager's focus state (mirror the notification logic) val isAppInBackground = notificationManager.getAppBackgroundState() val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer() // Send read receipt if user is currently focused on this specific chat - val shouldSendReadReceipt = !isAppInBackground && currentPrivateChatPeer == senderPeerID + val senderPeerID = message.senderPeerID + val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID - if (shouldSendReadReceipt) { - android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID") - privateChatManager.sendReadReceiptsForPeer(senderPeerID, getMeshService()) - } else { - android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") + if (shouldSendReadReceipt) { + android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})") + val nickname = state.getNicknameValue() ?: "unknown" + // Send directly for this message to avoid relying on unread queues + getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname) + // Ensure unread badge is cleared for this peer immediately + try { + val current = state.getUnreadPrivateMessagesValue().toMutableSet() + if (current.remove(senderPeerID)) { + state.setUnreadPrivateMessages(current) + } + } catch (_: Exception) { } + } else { + android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") + } } - } // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index f35689e7..40b7eb98 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -22,6 +22,8 @@ class MessageManager(private val state: ChatState) { val currentMessages = state.getMessagesValue().toMutableList() currentMessages.add(message) state.setMessages(currentMessages) + // Reflect into process-wide store so snapshot replacements don't drop local outgoing messages + try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { } } // Log a system message into the main chat (visible to user) @@ -52,6 +54,8 @@ class MessageManager(private val state: ChatState) { channelMessageList.add(message) currentChannelMessages[channel] = channelMessageList state.setChannelMessages(currentChannelMessages) + // Reflect into process-wide store + try { com.bitchat.android.services.AppStateStore.addChannelMessage(channel, message) } catch (_: Exception) { } // Update unread count if not currently viewing this channel // Consider both classic channels (state.currentChannel) and geohash location channel selection @@ -105,6 +109,8 @@ class MessageManager(private val state: ChatState) { chatMessages.add(message) currentPrivateChats[peerID] = chatMessages state.setPrivateChats(currentPrivateChats) + // Reflect into process-wide store + try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { } // Mark as unread if not currently viewing this chat if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) { @@ -124,6 +130,8 @@ class MessageManager(private val state: ChatState) { chatMessages.add(message) currentPrivateChats[peerID] = chatMessages state.setPrivateChats(currentPrivateChats) + // Reflect into process-wide store + try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { } } fun clearPrivateMessages(peerID: String) { @@ -206,6 +214,21 @@ class MessageManager(private val state: ChatState) { // MARK: - Delivery Status Updates + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { + null -> 0 + is DeliveryStatus.Sending -> 1 + is DeliveryStatus.Sent -> 2 + is DeliveryStatus.PartiallyDelivered -> 3 + is DeliveryStatus.Delivered -> 4 + is DeliveryStatus.Read -> 5 + is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering + } + + private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? { + // Never downgrade (e.g., Read -> Delivered). Keep the higher priority. + return if (statusPriority(new) >= statusPriority(old)) new else old + } + fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) { // Update in private chats val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap() @@ -215,22 +238,32 @@ class MessageManager(private val state: ChatState) { val updatedMessages = messages.toMutableList() val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } if (messageIndex >= 0) { - updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) - updatedPrivateChats[peerID] = updatedMessages - updated = true + val current = updatedMessages[messageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus) + updatedPrivateChats[peerID] = updatedMessages + updated = true + } } } if (updated) { state.setPrivateChats(updatedPrivateChats) + // Keep process-wide store in sync to prevent snapshot overwrites resetting status + try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(messageID, status) } catch (_: Exception) { } } // Update in main messages val updatedMessages = state.getMessagesValue().toMutableList() val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } if (messageIndex >= 0) { - updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) - state.setMessages(updatedMessages) + val current = updatedMessages[messageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus) + state.setMessages(updatedMessages) + } } // Update in channel messages @@ -239,8 +272,12 @@ class MessageManager(private val state: ChatState) { val channelMessagesList = messages.toMutableList() val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID } if (channelMessageIndex >= 0) { - channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status) - updatedChannelMessages[channel] = channelMessagesList + val current = channelMessagesList[channelMessageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus) + updatedChannelMessages[channel] = channelMessagesList + } } } state.setChannelMessages(updatedChannelMessages) diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index e8a3a461..098a83d7 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -292,28 +292,30 @@ class PrivateChatManager( } fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) { - message.senderPeerID?.let { senderPeerID -> + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + // Mesh-origin private message: AppStateStore updates the list; avoid double-add here. if (!isPeerBlocked(senderPeerID)) { - // Add to private messages - if (suppressUnread) { - messageManager.addPrivateMessageNoUnread(senderPeerID, message) - } else { - messageManager.addPrivateMessage(senderPeerID, message) - } - - // Track as unread for read receipt purposes - var unreadCount = 0 - if (!suppressUnread) { + // Ensure chat exists + messageManager.initializePrivateChat(senderPeerID) + // Track as unread for read receipt purposes if not focused + if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) { val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() } unreadList.add(message) - unreadCount = unreadList.size + Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})") + val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet() + currentUnread.add(senderPeerID) + state.setUnreadPrivateMessages(currentUnread) } - - Log.d( - TAG, - "Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)" - ) } + return + } + // Non-mesh path (e.g., Nostr): add to UI state using existing logic + val inferredPeer = state.getSelectedPrivateChatPeerValue() ?: return + if (suppressUnread) { + messageManager.addPrivateMessageNoUnread(inferredPeer, message) + } else { + messageManager.addPrivateMessage(inferredPeer, message) } } @@ -322,27 +324,33 @@ class PrivateChatManager( * Called when the user focuses on a private chat */ fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) { - val unreadList = unreadReceivedMessages[peerID] - if (unreadList.isNullOrEmpty()) { - Log.d(TAG, "No unread messages to send read receipts for peer $peerID") - return + // Collect candidate messages: all incoming messages from this peer in the conversation + val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap>() } + val messages = chats[peerID].orEmpty() + + if (messages.isEmpty()) { + Log.d(TAG, "No messages found for peer $peerID to send read receipts") } - Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID") - - // Send read receipt for each unread message - now using direct method call - unreadList.forEach { message -> - try { - val myNickname = state.getNicknameValue() ?: "unknown" - meshService.sendReadReceipt(message.id, peerID, myNickname) - Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID") - } catch (e: Exception) { - Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}") + val myNickname = state.getNicknameValue() ?: "unknown" + var sentCount = 0 + messages.forEach { msg -> + // Only for incoming messages from this peer + if (msg.senderPeerID == peerID) { + try { + meshService.sendReadReceipt(msg.id, peerID, myNickname) + sentCount += 1 + } catch (e: Exception) { + Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}") + } } } - // Clear the unread list since we've sent read receipts + // Clear any locally tracked unread queue for this peer unreadReceivedMessages.remove(peerID) + // Also clear UI unread marker for this peer now that chat is focused/read + try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { } + Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)") } fun cleanupDisconnectedPeer(peerID: String) { diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt index e2f9d966..04ad48a2 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt @@ -20,6 +20,7 @@ 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 private lateinit var prefs: SharedPreferences @@ -100,4 +101,6 @@ object DebugPreferenceManager { fun setGcsFprPercent(value: Double) { if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() } + + // No longer storing persistent notification in debug prefs. } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt index 6e37286f..77f6ce12 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,11 @@ class DebugSettingsManager private constructor() { private val _packetRelayEnabled = MutableStateFlow(true) val packetRelayEnabled: StateFlow = _packetRelayEnabled.asStateFlow() + // Visibility of the debug sheet; gates heavy work + private val _debugSheetVisible = MutableStateFlow(false) + val debugSheetVisible: StateFlow = _debugSheetVisible.asStateFlow() + fun setDebugSheetVisible(visible: Boolean) { _debugSheetVisible.value = visible } + // Connection limit overrides (debug) private val _maxConnectionsOverall = MutableStateFlow(8) val maxConnectionsOverall: StateFlow = _maxConnectionsOverall.asStateFlow() @@ -75,12 +80,63 @@ class DebugSettingsManager private constructor() { // Timestamps to compute rolling window stats private val relayTimestamps = ConcurrentLinkedQueue() + // Per-device and per-peer rolling timestamps for stacked graphs + private val perDeviceRelayTimestamps = mutableMapOf>() + private val perPeerRelayTimestamps = mutableMapOf>() + + // Additional buckets to split incoming vs outgoing + private val incomingTimestamps = ConcurrentLinkedQueue() + private val outgoingTimestamps = ConcurrentLinkedQueue() + private val perDeviceIncoming = mutableMapOf>() + private val perDeviceOutgoing = mutableMapOf>() + private val perPeerIncoming = mutableMapOf>() + private val perPeerOutgoing = mutableMapOf>() + + // Expose current per-second rates (updated when logging/pruning occurs) + private val _perDeviceLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceLastSecond: StateFlow> = _perDeviceLastSecond.asStateFlow() + private val _perPeerLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerLastSecond: StateFlow> = _perPeerLastSecond.asStateFlow() + // New flows used by UI for incoming/outgoing stacked plots + private val _perDeviceIncomingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingLastSecond: StateFlow> = _perDeviceIncomingLastSecond.asStateFlow() + private val _perDeviceOutgoingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingLastSecond: StateFlow> = _perDeviceOutgoingLastSecond.asStateFlow() + private val _perPeerIncomingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingLastSecond: StateFlow> = _perPeerIncomingLastSecond.asStateFlow() + private val _perPeerOutgoingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingLastSecond: StateFlow> = _perPeerOutgoingLastSecond.asStateFlow() + + // Per-minute counts per key + private val _perDeviceIncomingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingLastMinute: StateFlow> = _perDeviceIncomingLastMinute.asStateFlow() + private val _perDeviceOutgoingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingLastMinute: StateFlow> = _perDeviceOutgoingLastMinute.asStateFlow() + private val _perPeerIncomingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingLastMinute: StateFlow> = _perPeerIncomingLastMinute.asStateFlow() + private val _perPeerOutgoingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingLastMinute: StateFlow> = _perPeerOutgoingLastMinute.asStateFlow() + + // Totals per key (since app start) + private val deviceIncomingTotalsMap = mutableMapOf() + private val deviceOutgoingTotalsMap = mutableMapOf() + private val peerIncomingTotalsMap = mutableMapOf() + private val peerOutgoingTotalsMap = mutableMapOf() + private val _perDeviceIncomingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingTotal: StateFlow> = _perDeviceIncomingTotalsFlow.asStateFlow() + private val _perDeviceOutgoingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingTotal: StateFlow> = _perDeviceOutgoingTotalsFlow.asStateFlow() + private val _perPeerIncomingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingTotal: StateFlow> = _perPeerIncomingTotalsFlow.asStateFlow() + private val _perPeerOutgoingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingTotal: StateFlow> = _perPeerOutgoingTotalsFlow.asStateFlow() // Internal data storage for managing debug data private val debugMessageQueue = ConcurrentLinkedQueue() private val scanResultsQueue = ConcurrentLinkedQueue() private fun updateRelayStatsFromTimestamps() { + if (!_debugSheetVisible.value) return val now = System.currentTimeMillis() // prune older than 15m while (true) { @@ -89,18 +145,84 @@ class DebugSettingsManager private constructor() { relayTimestamps.poll() } else break } + // prune per-device and per-peer and compute 1s/60s rates + fun pruneAndCount1s(map: MutableMap>): Map { + val result = mutableMapOf() + val iterator = map.entries.iterator() + while (iterator.hasNext()) { + val (key, q) = iterator.next() + // prune this queue + while (true) { + val ts = q.peek() ?: break + if (now - ts > 15 * 60 * 1000L) { + q.poll() + } else break + } + // count last 1s only + val count1s = q.count { now - it <= 1_000L } + if (q.isEmpty()) { + // cleanup empty queues to prevent unbounded growth + iterator.remove() + } + if (count1s > 0) result[key] = count1s + } + return result + } + fun pruneAndCount60s(map: MutableMap>): Map { + val result = mutableMapOf() + map.forEach { (key, q) -> + val count60 = q.count { now - it <= 60_000L } + if (count60 > 0) result[key] = count60 + } + return result + } + + val perDevice1s = pruneAndCount1s(perDeviceRelayTimestamps) + val perPeer1s = pruneAndCount1s(perPeerRelayTimestamps) + + _perDeviceLastSecond.value = perDevice1s + _perPeerLastSecond.value = perPeer1s + // Also compute incoming/outgoing per-key rates + _perDeviceIncomingLastSecond.value = pruneAndCount1s(perDeviceIncoming) + _perDeviceOutgoingLastSecond.value = pruneAndCount1s(perDeviceOutgoing) + _perPeerIncomingLastSecond.value = pruneAndCount1s(perPeerIncoming) + _perPeerOutgoingLastSecond.value = pruneAndCount1s(perPeerOutgoing) + _perDeviceIncomingLastMinute.value = pruneAndCount60s(perDeviceIncoming) + _perDeviceOutgoingLastMinute.value = pruneAndCount60s(perDeviceOutgoing) + _perPeerIncomingLastMinute.value = pruneAndCount60s(perPeerIncoming) + _perPeerOutgoingLastMinute.value = pruneAndCount60s(perPeerOutgoing) val last1s = relayTimestamps.count { now - it <= 1_000L } val last10s = relayTimestamps.count { now - it <= 10_000L } val last1m = relayTimestamps.count { now - it <= 60_000L } val last15m = relayTimestamps.size - val total = _relayStats.value.totalRelaysCount + 1 + // And incoming/outgoing per-second counters + val last1sIncoming = incomingTimestamps.count { now - it <= 1_000L } + val last1sOutgoing = outgoingTimestamps.count { now - it <= 1_000L } + val last10sIncoming = incomingTimestamps.count { now - it <= 10_000L } + val last10sOutgoing = outgoingTimestamps.count { now - it <= 10_000L } + val last1mIncoming = incomingTimestamps.count { now - it <= 60_000L } + val last1mOutgoing = outgoingTimestamps.count { now - it <= 60_000L } + val last15mIncoming = incomingTimestamps.size + val last15mOutgoing = outgoingTimestamps.size + val totalIncoming = _relayStats.value.totalIncomingCount + val totalOutgoing = _relayStats.value.totalOutgoingCount _relayStats.value = PacketRelayStats( - totalRelaysCount = total, + totalRelaysCount = totalIncoming + totalOutgoing, lastSecondRelays = last1s, last10SecondRelays = last10s, lastMinuteRelays = last1m, last15MinuteRelays = last15m, - lastResetTime = _relayStats.value.lastResetTime + lastResetTime = _relayStats.value.lastResetTime, + lastSecondIncoming = last1sIncoming, + lastSecondOutgoing = last1sOutgoing, + last10SecondIncoming = last10sIncoming, + last10SecondOutgoing = last10sOutgoing, + lastMinuteIncoming = last1mIncoming, + lastMinuteOutgoing = last1mOutgoing, + last15MinuteIncoming = last15mIncoming, + last15MinuteOutgoing = last15mOutgoing, + totalIncomingCount = totalIncoming, + totalOutgoingCount = totalOutgoing ) } @@ -336,11 +458,61 @@ class DebugSettingsManager private constructor() { } } - // Update rolling statistics only for relays - if (isRelay) { - relayTimestamps.offer(System.currentTimeMillis()) - updateRelayStatsFromTimestamps() + // Do not update counters here; this path is for readable logs only. + } + + // Explicit incoming/outgoing logging to avoid double counting + fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) { + if (verboseLoggingEnabled.value) { + val who = fromNickname ?: fromPeerID ?: "unknown" + addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})")) } + val now = System.currentTimeMillis() + val visible = _debugSheetVisible.value + if (visible) incomingTimestamps.offer(now) + fromDeviceAddress?.let { + perDeviceIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L + _perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap() + } + fromPeerID?.let { + perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L + _perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap() + } + // bump totals + val cur = _relayStats.value + _relayStats.value = cur.copy( + totalIncomingCount = cur.totalIncomingCount + 1, + totalRelaysCount = cur.totalRelaysCount + 1 + ) + if (visible) updateRelayStatsFromTimestamps() + } + + fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) { + if (verboseLoggingEnabled.value) { + val who = toNickname ?: toPeerID ?: "unknown" + addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})")) + } + val now = System.currentTimeMillis() + val visible = _debugSheetVisible.value + if (visible) outgoingTimestamps.offer(now) + toDeviceAddress?.let { + perDeviceOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + deviceOutgoingTotalsMap[it] = (deviceOutgoingTotalsMap[it] ?: 0L) + 1L + _perDeviceOutgoingTotalsFlow.value = deviceOutgoingTotalsMap.toMap() + } + (toPeerID ?: previousHopPeerID)?.let { + perPeerOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + peerOutgoingTotalsMap[it] = (peerOutgoingTotalsMap[it] ?: 0L) + 1L + _perPeerOutgoingTotalsFlow.value = peerOutgoingTotalsMap.toMap() + } + val cur = _relayStats.value + _relayStats.value = cur.copy( + totalOutgoingCount = cur.totalOutgoingCount + 1, + totalRelaysCount = cur.totalRelaysCount + 1 + ) + if (visible) updateRelayStatsFromTimestamps() } // MARK: - Clear Data @@ -407,5 +579,15 @@ data class PacketRelayStats( val last10SecondRelays: Int = 0, val lastMinuteRelays: Int = 0, val last15MinuteRelays: Int = 0, - val lastResetTime: Date = Date() + val lastResetTime: Date = Date(), + val lastSecondIncoming: Int = 0, + val lastSecondOutgoing: Int = 0, + val last10SecondIncoming: Int = 0, + val last10SecondOutgoing: Int = 0, + val lastMinuteIncoming: Int = 0, + val lastMinuteOutgoing: Int = 0, + val last15MinuteIncoming: Int = 0, + val last15MinuteOutgoing: Int = 0, + val totalIncomingCount: Long = 0, + val totalOutgoingCount: Long = 0 ) diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 6cd25547..4b9f983a 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 @@ -3,9 +3,11 @@ package com.bitchat.android.ui.debug import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items 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.BugReport @@ -15,6 +17,7 @@ import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.SettingsEthernet import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -27,8 +30,13 @@ import com.bitchat.android.mesh.BluetoothMeshService import kotlinx.coroutines.launch import androidx.compose.ui.res.stringResource import com.bitchat.android.R +import androidx.compose.ui.platform.LocalContext +import com.bitchat.android.service.MeshServicePreferences +import com.bitchat.android.service.MeshForegroundService -@OptIn(ExperimentalMaterial3Api::class) +private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER } + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DebugSettingsSheet( isPresented: Boolean, @@ -53,6 +61,8 @@ fun DebugSettingsSheet( val seenCapacity by manager.seenPacketCapacity.collectAsState() val gcsMaxBytes by manager.gcsMaxBytes.collectAsState() val gcsFpr by manager.gcsFprPercent.collectAsState() + val context = LocalContext.current + // Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled // Push live connected devices from mesh service whenever sheet is visible LaunchedEffect(isPresented) { @@ -89,6 +99,11 @@ fun DebugSettingsSheet( onDismissRequest = onDismiss, sheetState = sheetState ) { + // Mark debug sheet visible/invisible to gate heavy work + LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) } + DisposableEffect(Unit) { + onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) } + } LazyColumn( modifier = Modifier .fillMaxWidth() @@ -202,88 +217,256 @@ fun DebugSettingsSheet( item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Persistent notification is controlled by About sheet (MeshServicePreferences.isBackgroundEnabled) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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) }) } - Text(stringResource(R.string.debug_since_start_fmt, relayStats.totalRelaysCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp) - Text(stringResource(R.string.debug_relays_window_fmt, relayStats.last10SecondRelays, relayStats.lastMinuteRelays, relayStats.last15MinuteRelays), fontFamily = FontFamily.Monospace, fontSize = 11.sp) - // Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay - var series by remember { mutableStateOf(List(60) { 0f }) } - LaunchedEffect(isPresented) { + // Removed aggregate labels; we will show per-direction compact labels below titles + // Toggle: overall vs per-connection vs per-peer + var graphMode by rememberSaveable { mutableStateOf(GraphMode.OVERALL) } + val perDeviceIncoming by manager.perDeviceIncomingLastSecond.collectAsState() + val perPeerIncoming by manager.perPeerIncomingLastSecond.collectAsState() + val perDeviceOutgoing by manager.perDeviceOutgoingLastSecond.collectAsState() + val perPeerOutgoing by manager.perPeerOutgoingLastSecond.collectAsState() + val perDeviceIncoming1m by manager.perDeviceIncomingLastMinute.collectAsState() + val perDeviceOutgoing1m by manager.perDeviceOutgoingLastMinute.collectAsState() + val perPeerIncoming1m by manager.perPeerIncomingLastMinute.collectAsState() + val perPeerOutgoing1m by manager.perPeerOutgoingLastMinute.collectAsState() + val perDeviceIncomingTotal by manager.perDeviceIncomingTotal.collectAsState() + val perDeviceOutgoingTotal by manager.perDeviceOutgoingTotal.collectAsState() + val perPeerIncomingTotal by manager.perPeerIncomingTotal.collectAsState() + val perPeerOutgoingTotal by manager.perPeerOutgoingTotal.collectAsState() + val nicknameMap = remember { mutableStateOf>(emptyMap()) } + val devicePeerMap = remember { mutableStateOf>(emptyMap()) } + LaunchedEffect(Unit) { + try { nicknameMap.value = meshService.getPeerNicknames() } catch (_: Exception) { } + // Try to fetch device->peer map periodically for legend resolution while (isPresented) { - val s = relayStats.lastSecondRelays.toFloat() - val last = series.lastOrNull() ?: 0f - // Faster decay and smoothing - val v = last * 0.5f + s * 0.5f - series = (series + v).takeLast(60) - kotlinx.coroutines.delay(400) + try { devicePeerMap.value = meshService.getDeviceAddressToPeerMapping() } catch (_: Exception) { } + kotlinx.coroutines.delay(1000) } } - val maxValRaw = series.maxOrNull() ?: 0f - val maxVal = if (maxValRaw > 0f) maxValRaw else 0f - val leftGutter = 40.dp - Box(Modifier.fillMaxWidth().height(56.dp)) { - // Graph canvas - androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { - val axisPx = leftGutter.toPx() // reserved left gutter for labels - val barCount = series.size - val availW = (size.width - axisPx).coerceAtLeast(1f) - val w = availW / barCount - val h = size.height - // Baseline at bottom (y = 0) - drawLine( - color = Color(0x33888888), - start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f), - end = androidx.compose.ui.geometry.Offset(size.width, h - 1f), - strokeWidth = 1f + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Mode selector + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = graphMode == GraphMode.OVERALL, + onClick = { graphMode = GraphMode.OVERALL }, + label = { Text("Overall") } ) - // Bars from bottom-up; skip zeros entirely - series.forEachIndexed { i, value -> - if (value > 0f && maxVal > 0f) { - val ratio = (value / maxVal).coerceIn(0f, 1f) - val barHeight = (h * ratio).coerceAtLeast(0f) - if (barHeight > 0.5f) { - drawRect( - color = Color(0xFF00C851), - topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight), - size = androidx.compose.ui.geometry.Size(w, barHeight) - ) + FilterChip( + selected = graphMode == GraphMode.PER_DEVICE, + onClick = { graphMode = GraphMode.PER_DEVICE }, + label = { Text("Per Device") }, + leadingIcon = { Icon(Icons.Filled.Devices, contentDescription = null) } + ) + FilterChip( + selected = graphMode == GraphMode.PER_PEER, + onClick = { graphMode = GraphMode.PER_PEER }, + label = { Text("Per Peer") }, + leadingIcon = { Icon(Icons.Filled.SettingsEthernet, contentDescription = null) } + ) + } + + // Time series state + var overallSeriesIncoming by rememberSaveable { mutableStateOf(List(60) { 0f }) } + var overallSeriesOutgoing by rememberSaveable { mutableStateOf(List(60) { 0f }) } + var stackedKeysIncoming by rememberSaveable { mutableStateOf(listOf()) } + var stackedKeysOutgoing by rememberSaveable { mutableStateOf(listOf()) } + var stackedSeriesIncoming by rememberSaveable { mutableStateOf>>(emptyMap()) } + var stackedSeriesOutgoing by rememberSaveable { mutableStateOf>>(emptyMap()) } + var highlightedKey by rememberSaveable { mutableStateOf(null) } + + // Color palette for stacked legend + val palette = remember { + listOf( + Color(0xFF00C851), Color(0xFF007AFF), Color(0xFFFF9500), Color(0xFFFF3B30), + Color(0xFF5AC8FA), Color(0xFFAF52DE), Color(0xFFFF2D55), Color(0xFF34C759), + Color(0xFFFFCC00), Color(0xFF5856D6) + ) + } + val colorForKey = remember { mutableStateMapOf() } + fun stableColorFor(key: String): Color { + // Deterministic fallback color based on key hash using HSV palette + val h = (key.hashCode().toUInt().toInt() and 0x7FFFFFFF) % 360 + return Color.hsv(h.toFloat(), 0.65f, 0.95f) + } + // Ensure colors are assigned for current keys before drawing + fun ensureColors(keys: List) { + keys.forEachIndexed { idx, k -> + colorForKey.putIfAbsent(k, palette.getOrNull(idx) ?: stableColorFor(k)) + } + } + + LaunchedEffect(isPresented, graphMode) { + while (isPresented) { + when (graphMode) { + GraphMode.OVERALL -> { + val sIn = relayStats.lastSecondIncoming.toFloat() + val sOut = relayStats.lastSecondOutgoing.toFloat() + overallSeriesIncoming = (overallSeriesIncoming + sIn).takeLast(60) + overallSeriesOutgoing = (overallSeriesOutgoing + sOut).takeLast(60) + } + GraphMode.PER_DEVICE -> { + val snapshotIn = perDeviceIncoming + val snapshotOut = perDeviceOutgoing + fun advance(base: Map>, snap: Map): Map> { + val next = mutableMapOf>() + val union = (base.keys + snap.keys).toSet() + union.forEach { k -> + val prev = base[k] ?: List(60) { 0f } + val s = (snap[k] ?: 0).toFloat() + next[k] = (prev + s).takeLast(60) + } + return next + } + // Advance and prune fully-stale series (all-zero in visible window) + stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } } + stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } } + stackedKeysIncoming = stackedSeriesIncoming.keys.sorted() + stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted() + } + GraphMode.PER_PEER -> { + val snapshotIn = perPeerIncoming + val snapshotOut = perPeerOutgoing + fun advance(base: Map>, snap: Map): Map> { + val next = mutableMapOf>() + val union = (base.keys + snap.keys).toSet() + union.forEach { k -> + val prev = base[k] ?: List(60) { 0f } + val s = (snap[k] ?: 0).toFloat() + next[k] = (prev + s).takeLast(60) + } + return next + } + stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } } + stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } } + stackedKeysIncoming = stackedSeriesIncoming.keys.sorted() + stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted() } } + kotlinx.coroutines.delay(1000) } } - // Left gutter layout: unit + ticks neatly aligned - Row(Modifier.fillMaxSize()) { - Box(Modifier.width(leftGutter).fillMaxHeight()) { - // Unit label on the far left, centered vertically - Text( - "p/s", - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f) - ) - // Tick labels right-aligned in gutter, top and bottom aligned - Text( - "${maxVal.toInt()}", - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp, top = 0.dp) - ) - Text( - "0", - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp, bottom = 0.dp) - ) + + // Helper functions moved to top-level composable below to avoid scope issues + + // Render two blocks: Incoming and Outgoing + Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text( + "${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}", + fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + DrawGraphBlock( + title = "Incoming", + stackedKeys = stackedKeysIncoming, + stackedSeries = stackedSeriesIncoming, + overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesIncoming else null, + graphMode = graphMode, + highlightedKey = highlightedKey, + onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key }, + ensureColors = { keys -> ensureColors(keys) }, + colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) }, + legendTitleFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val nick = nicknameMap.value[key] + val prefix = key.take(6) + if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix + } + GraphMode.PER_DEVICE -> { + val device = key + val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID + ?: devicePeerMap.value[device] + if (pid != null) { + val nick = nicknameMap.value[pid] + val prefix = pid.take(6) + "$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})" + } else device + } + else -> key + } + }, + legendMetricsFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val s = perPeerIncoming[key] ?: 0 + val m = perPeerIncoming1m[key] ?: 0 + val t = (perPeerIncomingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + GraphMode.PER_DEVICE -> { + val s = perDeviceIncoming[key] ?: 0 + val m = perDeviceIncoming1m[key] ?: 0 + val t = (perDeviceIncomingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + else -> "" + } } - Spacer(Modifier.weight(1f)) - } + ) + if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } + + Spacer(Modifier.height(8.dp)) + Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text( + "${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}", + fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + DrawGraphBlock( + title = "Outgoing", + stackedKeys = stackedKeysOutgoing, + stackedSeries = stackedSeriesOutgoing, + overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesOutgoing else null, + graphMode = graphMode, + highlightedKey = highlightedKey, + onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key }, + ensureColors = { keys -> ensureColors(keys) }, + colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) }, + legendTitleFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val nick = nicknameMap.value[key] + val prefix = key.take(6) + if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix + } + GraphMode.PER_DEVICE -> { + val device = key + val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID + ?: devicePeerMap.value[device] + if (pid != null) { + val nick = nicknameMap.value[pid] + val prefix = pid.take(6) + "$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})" + } else device + } + else -> key + } + }, + legendMetricsFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val s = perPeerOutgoing[key] ?: 0 + val m = perPeerOutgoing1m[key] ?: 0 + val t = (perPeerOutgoingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + GraphMode.PER_DEVICE -> { + val s = perDeviceOutgoing[key] ?: 0 + val m = perDeviceOutgoing1m[key] ?: 0 + val t = (perDeviceOutgoingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + else -> "" + } + } + ) + if (graphMode != GraphMode.OVERALL && stackedKeysOutgoing.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } } } } @@ -399,3 +582,146 @@ fun DebugSettingsSheet( } } } + +@Composable +private fun DrawGraphBlock( + title: String, + stackedKeys: List, + stackedSeries: Map>, + overallSeries: List?, + graphMode: GraphMode, + highlightedKey: String?, + onToggleHighlight: (String) -> Unit, + ensureColors: (List) -> Unit, + colorForKey: (String) -> Color, + legendTitleFor: (String) -> String, + legendMetricsFor: (String) -> String +) { + val colorScheme = MaterialTheme.colorScheme + val leftGutter = 40.dp + Box(Modifier.fillMaxWidth().height(56.dp)) { + androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { + val axisPx = leftGutter.toPx() + val barCount = 60 + val availW = (size.width - axisPx).coerceAtLeast(1f) + val w = availW / barCount + val h = size.height + drawLine( + color = Color(0x33888888), + start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f), + end = androidx.compose.ui.geometry.Offset(size.width, h - 1f), + strokeWidth = 1f + ) + + when (graphMode) { + GraphMode.OVERALL -> { + val maxValRaw = (overallSeries?.maxOrNull() ?: 0f) + val maxVal = if (maxValRaw > 0f) maxValRaw else 0f + (overallSeries ?: emptyList()).forEachIndexed { i, value -> + if (value > 0f && maxVal > 0f) { + val ratio = (value / maxVal).coerceIn(0f, 1f) + val barHeight = (h * ratio).coerceAtLeast(0f) + if (barHeight > 0.5f) { + drawRect( + color = Color(0xFF00C851), + topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight), + size = androidx.compose.ui.geometry.Size(w, barHeight) + ) + } + } + } + } + else -> { + val indices = 0 until 60 + val totals = indices.map { idx -> + stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() + } + val maxTotal = (totals.maxOrNull() ?: 0f) + val drawKeysBars = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted() + indices.forEach { i -> + var yTop = h + if (maxTotal > 0f) { + ensureColors(drawKeysBars) + drawKeysBars.forEach { k -> + val v = stackedSeries[k]?.getOrNull(i) ?: 0f + if (v > 0f) { + val ratio = (v / maxTotal).coerceIn(0f, 1f) + val segH = (h * ratio) + if (segH > 0.5f) { + val top = (yTop - segH) + val baseColor = colorForKey(k) + val c = if (highlightedKey == null || highlightedKey == k) baseColor else baseColor.copy(alpha = 0.35f) + drawRect( + color = c, + topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = top), + size = androidx.compose.ui.geometry.Size(w, segH) + ) + yTop = top + } + } + } + } + } + } + } + } + + Row(Modifier.fillMaxSize()) { + Box(Modifier.width(leftGutter).fillMaxHeight()) { + Text( + "p/s", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f) + ) + val topLabel = when (graphMode) { + GraphMode.OVERALL -> (overallSeries?.maxOrNull() ?: 0f).toInt().toString() + else -> { + val totals = (0 until 60).map { idx -> stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() } + (totals.maxOrNull() ?: 0f).toInt().toString() + } + } + Text( + topLabel, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp) + ) + Text( + "0", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp) + ) + } + Spacer(Modifier.weight(1f)) + } + } + + val drawKeys = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted() + if (graphMode != GraphMode.OVERALL && drawKeys.isNotEmpty()) { + Column(Modifier.fillMaxWidth()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + drawKeys.forEach { key -> + val baseColor = colorForKey(key) + val dimmed = highlightedKey != null && highlightedKey != key + val swatchColor = if (dimmed) baseColor.copy(alpha = 0.35f) else baseColor + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.clickable { onToggleHighlight(key) } + ) { + Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp))) + Column { + Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) + Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index a332fca2..9881cc2f 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -80,8 +80,8 @@ object AppConstants { const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L const val MAX_CONNECTIONS_NORMAL: Int = 8 - const val MAX_CONNECTIONS_POWER_SAVE: Int = 4 - const val MAX_CONNECTIONS_ULTRA_LOW: Int = 2 + const val MAX_CONNECTIONS_POWER_SAVE: Int = 8 + const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4 } object Nostr { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a140f9bb..4a985c6c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,6 +61,11 @@ and %1$d more locations and %1$d more + + Mesh Background Service + Keeps the Bluetooth mesh running in the background + Mesh running — %1$d users connected + Add to favorites Remove from favorites @@ -152,6 +157,9 @@ Remove bookmark Teleport Selected + Quit bitchat + run in background + keep mesh active when app is closed (foreground service) Leave channel Reachable via Nostr Offline favorite From 8767cfdea35abf8fa9c6dbcf93a1a4dc83495560 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 13 Dec 2025 16:46:09 +0700 Subject: [PATCH 02/15] bump version 1.6.0 (#524) --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55c6389d..f6de2444 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 26 - versionName = "1.5.1" + versionCode = 27 + versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 2a11d52d3c0c19391ab5b45444959dd6216f18fb Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 14 Dec 2025 06:06:53 +0000 Subject: [PATCH 03/15] Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025 --- app/src/main/assets/nostr_relays.csv | 549 ++++++++++++++------------- 1 file changed, 282 insertions(+), 267 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 2c07fe69..2288010a 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,270 +1,285 @@ Relay URL,Latitude,Longitude -relay02.lnfi.network,39.0997,-94.5786 -srtrelay.c-stellar.net,43.6532,-79.3832 -alienos.libretechsystems.xyz,55.4724,9.87335 -relay.uid.ovh,43.6532,-79.3832 -relay.cypherflow.ai,48.8566,2.35222 -wot.tealeaf.dev,33.7488,-84.3877 -relay.divine.video,43.6532,-79.3832 -relay.tagayasu.xyz,43.6715,-79.38 -nostr.camalolo.com,24.1469,120.684 -wot.sudocarlos.com,51.5072,-0.127586 -nostr.vulpem.com,49.4543,11.0746 -relay2.angor.io,48.1046,11.6002 -relay.nostr.nisshiee.org,37.3387,-121.885 -wot.nostr.party,36.1627,-86.7816 -nostr.luisschwab.net,43.6532,-79.3832 -cyberspace.nostr1.com,40.7128,-74.006 -strfry.felixzieger.de,50.1013,8.62643 -relay.agora.social,50.7383,15.0648 -nostr.n7ekb.net,47.4941,-122.294 -relay.bitcoinveneto.org,64.1466,-21.9426 -relay.stream.labs.h3.se,59.4016,17.9455 -nostr.azzamo.net,52.2633,21.0283 -relay.nostrhub.tech,49.0291,8.35696 -relay.21e6.cz,50.7383,15.0648 -nostr-02.czas.top,51.2277,6.77346 -nostr.czas.top,50.1109,8.68213 -wot.nostr.place,32.7767,-96.797 -strfry.shock.network,39.0438,-77.4874 -relay.orangepill.ovh,49.1689,-0.358841 -relay.illuminodes.com,47.6061,-122.333 -bcast.girino.org,43.6532,-79.3832 -nostr.stakey.net,52.3676,4.90414 -nostr.lkjsxc.com,43.6532,-79.3832 -nostr.coincrowd.fund,39.0438,-77.4874 -relay.damus.io,43.6532,-79.3832 -nostr.myshosholoza.co.za,52.3676,4.90414 -nostr.88mph.life,60.1699,24.9384 -relay.sharegap.net,43.6532,-79.3832 -purplerelay.com,50.1109,8.68213 -ribo.us.nostria.app,41.5868,-93.625 -nostr.snowbla.de,60.1699,24.9384 -relay.internationalright-wing.org,-22.5986,-48.8003 -relay.btcforplebs.com,43.6532,-79.3832 -nostr.red5d.dev,43.6532,-79.3832 -orangepiller.org,60.1699,24.9384 -relay.nosto.re,51.1792,5.89444 -relay04.lnfi.network,39.0997,-94.5786 -nostr.now,36.55,139.733 -relay.binaryrobot.com,43.6532,-79.3832 -premium.primal.net,43.6532,-79.3832 -relay.davidebtc.me,51.5072,-0.127586 -purpura.cloud,43.6532,-79.3832 -nostr.mikoshi.de,50.1109,8.68213 -nostr.simplex.icu,51.5121,-0.0005238 -articles.layer3.news,37.3387,-121.885 -nostr.spicyz.io,43.6532,-79.3832 -nostrcheck.me,43.6532,-79.3832 -relay.holzeis.me,43.6532,-79.3832 -nostr-01.uid.ovh,43.6532,-79.3832 -relay.nostar.org,43.6532,-79.3832 -soloco.nl,43.6532,-79.3832 -nostr.thebiglake.org,32.71,-96.6745 -relay.nostr-check.me,43.6532,-79.3832 -nproxy.kristapsk.lv,60.1699,24.9384 -relay-freeharmonypeople.space,38.7223,-9.13934 -nostrelay.memory-art.xyz,43.6532,-79.3832 -orangesync.tech,50.1109,8.68213 -nostr.data.haus,50.4754,12.3683 -vitor.nostr1.com,40.7057,-74.0136 -nostr.overmind.lol,43.6532,-79.3832 -nostr-relay.online,43.6532,-79.3832 -relay.bitcoindistrict.org,43.6532,-79.3832 -relay.javi.space,43.4633,11.8796 -fanfares.nostr1.com,40.7057,-74.0136 -temp.iris.to,43.6532,-79.3832 -relay.guggero.org,47.3769,8.54169 -relay.degmods.com,50.4754,12.3683 -relay.nostr.net,43.6532,-79.3832 -relay.wolfcoil.com,35.6092,139.73 -relay.sigit.io,50.4754,12.3683 -shu04.shugur.net,25.2604,55.2989 -nostr.rtvslawenia.com,49.4543,11.0746 -nostr.kalf.org,52.3676,4.90414 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -nostr.rblb.it,43.7094,10.6582 -nostr.ovia.to,43.6532,-79.3832 -nostr-relay.nextblockvending.com,47.2343,-119.853 -relay.agorist.space,52.3734,4.89406 -relay.jeffg.fyi,43.6532,-79.3832 -relay.ditto.pub,43.6532,-79.3832 -nostr.huszonegy.world,47.4979,19.0402 -nostr.coincards.com,53.5501,-113.469 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -nostream.breadslice.com,1.35208,103.82 -ribo.eu.nostria.app,52.3676,4.90414 -nostr.oxtr.dev,50.4754,12.3683 -nostr.jerrynya.fun,31.2304,121.474 -black.nostrcity.club,48.8575,2.35138 -wheat.happytavern.co,43.6532,-79.3832 -nostr-relay.psfoundation.info,39.0438,-77.4874 -relay.zone667.com,60.1699,24.9384 -theoutpost.life,64.1476,-21.9392 -nostrelay.circum.space,52.3676,4.90414 -relay.origin.land,35.6673,139.751 -relay.lumina.rocks,49.0291,8.35695 -relay.chorus.community,50.1109,8.68213 -kotukonostr.onrender.com,37.7775,-122.397 -nostrcheck.tnsor.network,43.6532,-79.3832 -relay01.lnfi.network,39.0997,-94.5786 -bitsat.molonlabe.holdings,51.4012,-1.3147 -relay.nsnip.io,60.1699,24.9384 -nostr.mehdibekhtaoui.com,49.4939,-1.54813 -relay.ru.ac.th,13.7607,100.627 -relay.thibautduchene.fr,43.6532,-79.3832 -nos.xmark.cc,50.6924,3.20113 -wot.dtonon.com,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -wot.brightbolt.net,47.6735,-116.781 -nostr.bond,50.1109,8.68213 -relay.vrtmrz.net,43.6532,-79.3832 -dev-relay.lnfi.network,39.0997,-94.5786 -wot.sebastix.social,51.1792,5.89444 -nostr.hifish.org,47.4043,8.57398 -divine.diy,43.6532,-79.3832 -bitcoiner.social,39.1585,-94.5728 -relay.islandbitcoin.com,12.8498,77.6545 -nostr-2.21crypto.ch,47.5356,8.73209 -relay.snort.social,53.3498,-6.26031 -shu05.shugur.net,48.8566,2.35222 -relay.nostrcheck.me,43.6532,-79.3832 -relay.0xchat.com,1.35208,103.82 -relay.dwadziesciajeden.pl,52.2297,21.0122 -relay.getsafebox.app,43.6532,-79.3832 -relay.moinsen.com,50.4754,12.3683 -relay.primal.net,43.6532,-79.3832 -relay.npubhaus.com,43.6532,-79.3832 -nostr.tuckerbradford.com,43.663,-70.2569 -nostr.rikmeijer.nl,50.4754,12.3683 -nostr.agentcampfire.com,52.3676,4.90414 -shu02.shugur.net,21.4902,39.2246 -librerelay.aaroniumii.com,43.6532,-79.3832 -fenrir-s.notoshi.win,43.6532,-79.3832 -relayrs.notoshi.win,43.6532,-79.3832 -nostr.girino.org,43.6532,-79.3832 -ribo.af.nostria.app,-26.2056,28.0337 -relay.bitcoinartclock.com,50.4754,12.3683 -relay.nostrdice.com,-33.8688,151.209 -strfry.elswa-dev.online,50.1109,8.68213 -nostr.21crypto.ch,47.5356,8.73209 -wot.shaving.kiwi,43.6532,-79.3832 -nostr.mom,50.4754,12.3683 -relay.lightning.pub,39.0438,-77.4874 -nostr-02.yakihonne.com,1.32123,103.695 -relay.mccormick.cx,52.3563,4.95714 -ynostr.yael.at,60.1699,24.9384 -nostr.faultables.net,43.6532,-79.3832 -prl.plus,56.9677,24.1056 -santo.iguanatech.net,40.8302,-74.1299 -nostr.bitcoiner.social,39.1585,-94.5728 -nostr-relay.corb.net,38.8353,-104.822 -relay.letsfo.com,51.098,17.0321 -nostr.calitabby.net,39.9268,-75.0246 -strfry.bonsai.com,37.8715,-122.273 -wot.nostr.net,43.6532,-79.3832 -bcast.seutoba.com.br,43.6532,-79.3832 -wot.sovbit.host,64.1466,-21.9426 -relay2.ngengine.org,43.6532,-79.3832 -r.bitcoinhold.net,43.6532,-79.3832 -ithurtswhenip.ee,51.223,6.78245 -freeben666.fr,43.7221,7.15296 -relay.notoshi.win,13.311,101.112 -yabu.me,35.6092,139.73 -dev-nostr.bityacht.io,25.0797,121.234 -nostr.commonshub.brussels,49.4543,11.0746 -relay.nostx.io,43.6532,-79.3832 -relay.nostr.band,60.1699,24.9384 -relay.barine.co,43.6532,-79.3832 -relay.arx-ccn.com,50.4754,12.3683 -satsage.xyz,37.3986,-121.964 -relay.nostr.wirednet.jp,34.706,135.493 -relay.artx.market,43.652,-79.3633 -nostr2.girino.org,43.6532,-79.3832 -nostr.tadryanom.me,43.6532,-79.3832 -relay.satlantis.io,32.8769,-80.0114 -nostr.zoracle.org,45.6018,-121.185 -nostr.bilthon.dev,25.8128,-80.2377 -relay.minibolt.info,43.6532,-79.3832 -relay.chakany.systems,43.6532,-79.3832 -nostr.spaceshell.xyz,43.6532,-79.3832 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -offchain.pub,47.6743,-117.112 -shu01.shugur.net,21.4902,39.2246 -nostr-relay.xbytez.io,50.6924,3.20113 -nostr.noones.com,50.1109,8.68213 -relay.nostriot.com,41.5695,-83.9786 -nostr-relay.zimage.com,34.0549,-118.243 -relay.siamdev.cc,13.9178,100.424 -nostr.4rs.nl,49.0291,8.35696 -relay.coinos.io,43.6532,-79.3832 -khatru.nostrver.se,51.1792,5.89444 -x.kojira.io,43.6532,-79.3832 -wot.yesnostr.net,50.9871,2.12554 -nostr.casa21.space,43.6532,-79.3832 -nos.lol,50.4754,12.3683 -relay.mattybs.lol,43.6532,-79.3832 -nostr-01.yakihonne.com,1.32123,103.695 -nostr.tac.lol,47.4748,-122.273 -relay.nuts.cash,34.0362,-118.443 -relay.cosmicbolt.net,37.3986,-121.964 -nostr.diakod.com,43.6532,-79.3832 -relay.mostro.network,40.8302,-74.1299 -relay.fr13nd5.com,52.5233,13.3426 -relay.trustroots.org,43.6532,-79.3832 -relay.thebluepulse.com,49.4521,11.0767 -relay.wavefunc.live,34.0362,-118.443 -relay.wavlake.com,41.2619,-95.8608 -relay.libernet.app,43.6532,-79.3832 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.fountain.fm,39.0997,-94.5786 -relay.hook.cafe,43.6532,-79.3832 -notemine.io,52.2026,20.9397 -relay.electriclifestyle.com,26.2897,-80.1293 -inbox.azzamo.net,52.2633,21.0283 -nostr.hekster.org,37.3986,-121.964 -relay.routstr.com,43.6532,-79.3832 -relay.puresignal.news,43.6532,-79.3832 -wot.dergigi.com,64.1476,-21.9392 -nostr-03.dorafactory.org,1.35208,103.82 -relay.fundstr.me,42.3601,-71.0589 -relayone.soundhsa.com,33.1384,-95.6011 -nostr.plantroon.com,50.1013,8.62643 -relay.ngengine.org,43.6532,-79.3832 -relay-dev.satlantis.io,40.8302,-74.1299 -relay.angor.io,48.1046,11.6002 -relay.toastr.net,40.8054,-74.0241 -relay.nostrhub.fr,48.1045,11.6004 -relay-rpi.edufeed.org,49.4521,11.0767 -relay.bitesize-media.com,49.4543,11.0746 -relay.usefusion.ai,38.7134,-78.1591 -chat-relay.zap-work.com,43.6532,-79.3832 -nostr.0x7e.xyz,47.4988,8.72369 -nostr.ps1829.com,33.8851,130.883 -relay.bullishbounty.com,43.6532,-79.3832 -nostr.openhoofd.nl,51.9229,4.40833 -nostr.blankfors.se,60.1699,24.9384 -strfry.openhoofd.nl,51.9229,4.40833 -relay.magiccity.live,25.8128,-80.2377 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -nostr.notribe.net,40.8302,-74.1299 -slick.mjex.me,39.048,-77.4817 -nostr.davidebtc.me,51.5072,-0.127586 alien.macneilmediagroup.com,43.6532,-79.3832 -pyramid.treegaze.com,43.6532,-79.3832 -relay03.lnfi.network,39.0997,-94.5786 -relay.evanverma.com,40.8302,-74.1299 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -nostr.chaima.info,51.223,6.78245 -no.str.cr,9.92857,-84.0528 -relay.jabato.space,52.52,13.405 -adre.su,59.9311,30.3609 -relay.hasenpfeffr.com,39.0438,-77.4874 -relay.etch.social,41.2619,-95.8608 -schnorr.me,43.6532,-79.3832 -relay.endfiat.money,43.6532,-79.3832 -relay.nostr.place,32.7767,-96.797 -nostr.sathoarder.com,48.5734,7.75211 -orly.ft.hn,50.4754,12.3683 -relay.samt.st,40.8302,-74.1299 +nostr.simplex.icu,51.5121,-0.0005238 +nostr.satstralia.com,64.1476,-21.9392 nostr-relay.amethyst.name,39.0438,-77.4874 +bcast.girino.org,43.6532,-79.3832 +purpura.cloud,43.6532,-79.3832 +wot.nostr.party,36.1627,-86.7816 +dev-relay.lnfi.network,39.0997,-94.5786 +temp.iris.to,43.6532,-79.3832 +relay.internationalright-wing.org,-22.5986,-48.8003 +relay.bitcoindistrict.org,43.6532,-79.3832 +srtrelay.c-stellar.net,43.6532,-79.3832 +nostr.mehdibekhtaoui.com,49.4939,-1.54813 +bucket.coracle.social,37.7775,-122.397 +relay.minibolt.info,43.6532,-79.3832 +fanfares.nostr1.com,40.7057,-74.0136 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +cyberspace.nostr1.com,40.7128,-74.006 +relay.jeffg.fyi,43.6532,-79.3832 +nostr2.girino.org,43.6532,-79.3832 +relay.stream.labs.h3.se,59.4016,17.9455 +nostr.rikmeijer.nl,50.4754,12.3683 +relay.getsafebox.app,43.6532,-79.3832 +nostr-01.uid.ovh,43.6532,-79.3832 +relay.hasenpfeffr.com,39.0438,-77.4874 +wot.dtonon.com,43.6532,-79.3832 +nostrcheck.me,43.6532,-79.3832 +strfry.bonsai.com,37.8715,-122.273 +relay.degmods.com,50.4754,12.3683 +relay.nostx.io,43.6532,-79.3832 +strfry.elswa-dev.online,50.1109,8.68213 +relay.lightning.pub,39.0438,-77.4874 +nostr.jerrynya.fun,31.2304,121.474 +satsage.xyz,37.3986,-121.964 +relay-rpi.edufeed.org,49.4521,11.0767 +nostr.bond,50.1109,8.68213 +relay.lumina.rocks,49.0291,8.35695 +relay.samt.st,40.8302,-74.1299 +nostr.snowbla.de,60.1699,24.9384 +nostr.red5d.dev,43.6532,-79.3832 +nostr.tadryanom.me,43.6532,-79.3832 +relay.bullishbounty.com,43.6532,-79.3832 +nostr.calitabby.net,39.9268,-75.0246 +relay.hook.cafe,43.6532,-79.3832 +relay.21e6.cz,50.7383,15.0648 +nostr-relay.online,43.6532,-79.3832 +inbox.azzamo.net,52.2633,21.0283 +relay.chakany.systems,43.6532,-79.3832 +relay.coinos.io,43.6532,-79.3832 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.siamdev.cc,13.9178,100.424 +relay04.lnfi.network,39.0997,-94.5786 +wot.sovbit.host,64.1466,-21.9426 +bcast.seutoba.com.br,43.6532,-79.3832 +shu05.shugur.net,48.8566,2.35222 +wot.nostr.place,32.7767,-96.797 +nos.xmark.cc,50.6924,3.20113 +nostr-relay.corb.net,38.8353,-104.822 +relay5.bitransfer.org,43.6532,-79.3832 +nostr-relay.nextblockvending.com,47.2343,-119.853 +relay.fountain.fm,39.0997,-94.5786 +santo.iguanatech.net,40.8302,-74.1299 +bitcoiner.social,39.1585,-94.5728 +relay.nostr.place,32.7767,-96.797 +relay.fr13nd5.com,52.5233,13.3426 +nostr.myshosholoza.co.za,52.3676,4.90414 +nostr.kalf.org,52.3676,4.90414 +nostr.chaima.info,51.223,6.78245 +relay.usefusion.ai,38.7134,-78.1591 +nostr.faultables.net,43.6532,-79.3832 +ynostr.yael.at,60.1699,24.9384 +nostr.4rs.nl,49.0291,8.35696 +adre.su,59.9311,30.3609 +nostr.spaceshell.xyz,43.6532,-79.3832 +relay.angor.io,48.1046,11.6002 +r.bitcoinhold.net,43.6532,-79.3832 +nostr.mom,50.4754,12.3683 +relay.cosmicbolt.net,37.3986,-121.964 +freelay.sovbit.host,64.1476,-21.9392 +relay.npubhaus.com,43.6532,-79.3832 +schnorr.me,43.6532,-79.3832 +nostr.agentcampfire.com,52.3676,4.90414 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +strfry.felixzieger.de,50.1013,8.62643 +orly.ft.hn,50.4754,12.3683 +nostr.hifish.org,47.4043,8.57398 +relayone.soundhsa.com,33.1384,-95.6011 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +relay.trustroots.org,43.6532,-79.3832 +soloco.nl,43.6532,-79.3832 +nostr.mikoshi.de,47.8786,11.911 +relay.holzeis.me,43.6532,-79.3832 +wot.sebastix.social,51.1792,5.89444 +nostr.azzamo.net,52.2633,21.0283 +wot.shaving.kiwi,43.6532,-79.3832 +nostr.night7.space,50.4754,12.3683 +nproxy.kristapsk.lv,60.1699,24.9384 +freeben666.fr,43.7221,7.15296 +relay.libernet.app,43.6532,-79.3832 +nostr.88mph.life,51.5072,-0.127586 +wot.tealeaf.dev,33.7488,-84.3877 +nostr.sathoarder.com,48.5734,7.75211 +relay.0xchat.com,1.35208,103.82 +relay.fundstr.me,42.3601,-71.0589 +nostr.lkjsxc.com,43.6532,-79.3832 +nostr.n7ekb.net,47.4941,-122.294 +relay.jabato.space,52.52,13.405 +nostr.davidebtc.me,51.5072,-0.127586 +relay.evanverma.com,40.8302,-74.1299 +wot.dergigi.com,64.1476,-21.9392 +relay.seq1.net,43.6532,-79.3832 +wot.yesnostr.net,50.9871,2.12554 +relay-freeharmonypeople.space,38.7223,-9.13934 +nostr.rblb.it,43.7094,10.6582 +ribo.us.nostria.app,41.5868,-93.625 +relay.nostar.org,43.6532,-79.3832 +purplerelay.com,50.1109,8.68213 +nostr-01.yakihonne.com,1.32123,103.695 +relay.jmoose.rocks,60.1699,24.9384 +bitsat.molonlabe.holdings,51.4012,-1.3147 +nos.lol,50.4754,12.3683 +shu02.shugur.net,21.4902,39.2246 +fenrir-s.notoshi.win,43.6532,-79.3832 +relay.puresignal.news,43.6532,-79.3832 +nostr.vulpem.com,49.4543,11.0746 +relay.etch.social,41.2619,-95.8608 +nostr.camalolo.com,24.1469,120.684 +nostr-2.21crypto.ch,47.5356,8.73209 +nostrelay.memory-art.xyz,43.6532,-79.3832 +relay.upleb.uk,51.9194,19.1451 +relay.sharegap.net,43.6532,-79.3832 +relay.2nix.de,60.1699,24.9384 +nostr.21crypto.ch,47.5356,8.73209 +relay.mattybs.lol,43.6532,-79.3832 +relayb.uid.ovh,43.6532,-79.3832 +nostr.hekster.org,37.3986,-121.964 +shu04.shugur.net,25.2604,55.2989 +nostrelay.circum.space,52.3676,4.90414 +nostr.tavux.tech,48.8575,2.35138 +nostr.thebiglake.org,32.71,-96.6745 +khatru.nostrver.se,51.1792,5.89444 +relay.damus.io,43.6532,-79.3832 +relay.wavlake.com,41.2619,-95.8608 +relay2.angor.io,48.1046,11.6002 +nostr.spicyz.io,43.6532,-79.3832 +nostr.bilthon.dev,25.8128,-80.2377 +nostr.now,36.55,139.733 +shu01.shugur.net,21.4902,39.2246 +spatia-arcana.com,34.0362,-118.443 +nos4smartnkind.tech,40.1872,44.5152 +nostr-relay.xbytez.io,50.6924,3.20113 +wheat.happytavern.co,43.6532,-79.3832 +relay.notoshi.win,13.311,101.112 +theoutpost.life,64.1476,-21.9392 +nostr.tuckerbradford.com,43.663,-70.2569 +nostrcheck.tnsor.network,43.6532,-79.3832 +nostr.coincards.com,53.5501,-113.469 +relay.cypherflow.ai,48.8566,2.35222 +relay.btcforplebs.com,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +relay.satlantis.io,32.8769,-80.0114 +relay.agora.social,50.7383,15.0648 +notemine.io,52.2026,20.9397 +relay.nostr.band,60.1699,24.9384 +relay.orangepill.ovh,49.1689,-0.358841 +strfry.shock.network,39.0438,-77.4874 +relay.dwadziesciajeden.pl,52.2297,21.0122 +articles.layer3.news,37.3387,-121.885 +nostr.czas.top,50.1109,8.68213 +relay.olas.app,50.4754,12.3683 +orangepiller.org,60.1699,24.9384 +nostr-02.yakihonne.com,1.32123,103.695 +relay.thebluepulse.com,49.4521,11.0767 +relay.binaryrobot.com,43.6532,-79.3832 +prl.plus,56.9677,24.1056 +strfry.openhoofd.nl,51.9229,4.40833 +nostr.girino.org,43.6532,-79.3832 +relay.satmaxt.xyz,43.6532,-79.3832 +nostr-02.czas.top,51.2277,6.77346 +nostrelites.org,41.8781,-87.6298 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nostr-relay.zimage.com,34.0549,-118.243 +wot.soundhsa.com,33.1384,-95.6011 +relay.letsfo.com,51.098,17.0321 +relay.vrtmrz.net,43.6532,-79.3832 +ribo.eu.nostria.app,52.3676,4.90414 +relay.nostr.wirednet.jp,34.706,135.493 +relay.snort.social,53.3498,-6.26031 +nostr.huszonegy.world,47.4979,19.0402 +nostream.breadslice.com,1.35208,103.82 +relay.credenso.cafe,43.3601,-80.3127 +relay.tagayasu.xyz,43.6715,-79.38 +nostr.stakey.net,52.3676,4.90414 +relay.artx.market,43.652,-79.3633 +relay.sigit.io,50.4754,12.3683 +librerelay.aaroniumii.com,43.6532,-79.3832 +nostr.noones.com,50.1109,8.68213 +pyramid.treegaze.com,43.6532,-79.3832 +relay.crostr.com,43.6532,-79.3832 +relay03.lnfi.network,39.0997,-94.5786 +no.str.cr,9.92857,-84.0528 +relay.nostrhub.tech,49.0291,8.35696 +nostr.overmind.lol,43.6532,-79.3832 +kotukonostr.onrender.com,37.7775,-122.397 +relay.chorus.community,50.1109,8.68213 +relay.zone667.com,60.1699,24.9384 +relay01.lnfi.network,39.0997,-94.5786 +nostr.ps1829.com,33.8851,130.883 +offchain.pub,47.6743,-117.112 +nostr.plantroon.com,50.1013,8.62643 +relay.nostr-check.me,43.6532,-79.3832 +nostr.oxtr.dev,50.4754,12.3683 +black.nostrcity.club,48.8575,2.35138 +relay.uid.ovh,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.nostriot.com,41.5695,-83.9786 +wot.brightbolt.net,47.6735,-116.781 +relay.toastr.net,40.8054,-74.0241 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +nostr.zoracle.org,45.6018,-121.185 +relay.wolfcoil.com,35.6092,139.73 +premium.primal.net,43.6532,-79.3832 +nostr.roundrockbitcoiners.com,40.8054,-74.0241 +relay.thibautduchene.fr,43.6532,-79.3832 +relay.illuminodes.com,47.6061,-122.333 +relay.ngengine.org,43.6532,-79.3832 +dev-nostr.bityacht.io,25.0797,121.234 +wot.sudocarlos.com,51.5072,-0.127586 +relay.islandbitcoin.com,12.8498,77.6545 +nostr.blankfors.se,60.1699,24.9384 +relay2.ngengine.org,43.6532,-79.3832 +relay.nostr.net,43.6532,-79.3832 +alienos.libretechsystems.xyz,55.4724,9.87335 +relay-dev.satlantis.io,40.8302,-74.1299 +relay.divine.video,43.6532,-79.3832 +nostr.tac.lol,47.4748,-122.273 +relay.zombi.cloudrodion.com,48.8566,2.35222 +relay.mostro.network,40.8302,-74.1299 +relay.arx-ccn.com,50.4754,12.3683 +relay.davidebtc.me,51.5072,-0.127586 +wot.codingarena.top,50.4754,12.3683 +relay.bitcoinartclock.com,50.4754,12.3683 +nostr.nodesmap.com,59.3327,18.0656 +relay.nostrdice.com,-33.8688,151.209 +wot.nostr.net,43.6532,-79.3832 +relay02.lnfi.network,39.0997,-94.5786 +relayrs.notoshi.win,43.6532,-79.3832 +relay.nostrhub.fr,48.1045,11.6004 +nostr.openhoofd.nl,51.9229,4.40833 +relay.wavefunc.live,34.0362,-118.443 +nostr.coincrowd.fund,39.0438,-77.4874 +relay.mccormick.cx,52.3563,4.95714 +slick.mjex.me,39.048,-77.4817 +relay.nostrcheck.me,43.6532,-79.3832 +nostr.notribe.net,40.8302,-74.1299 +relay.primal.net,43.6532,-79.3832 +vitor.nostr1.com,40.7057,-74.0136 +relay.bitcoinveneto.org,64.1466,-21.9426 +public.crostr.com,43.6532,-79.3832 +nostr.ovia.to,43.6532,-79.3832 +ithurtswhenip.ee,51.223,6.78245 +relay.origin.land,35.6673,139.751 +relay.nostrzh.org,43.6532,-79.3832 +orangesync.tech,50.1109,8.68213 +nostr.bitcoiner.social,39.1585,-94.5728 +relay.javi.space,43.4633,11.8796 +relay.electriclifestyle.com,26.2897,-80.1293 +x.kojira.io,43.6532,-79.3832 +relay.endfiat.money,43.6532,-79.3832 +relay.magiccity.live,25.8128,-80.2377 +chat-relay.zap-work.com,43.6532,-79.3832 +nostr-03.dorafactory.org,1.35208,103.82 +nostr-relay.psfoundation.info,39.0438,-77.4874 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +relay.routstr.com,43.6532,-79.3832 +nostr.luisschwab.net,43.6532,-79.3832 +nostr.diakod.com,43.6532,-79.3832 +relay.guggero.org,47.3769,8.54169 +nostr.0x7e.xyz,47.4988,8.72369 +yabu.me,35.6092,139.73 +relay.ru.ac.th,13.7607,100.627 +relay.moinsen.com,50.4754,12.3683 +relay.nuts.cash,34.0362,-118.443 +relay.ditto.pub,43.6532,-79.3832 From 743bbd0b1c1edc6d7728eb0b89eaae7334202ebf Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:55:41 +0700 Subject: [PATCH 04/15] bump targetSdk (#526) --- app/lint-baseline.xml | 11 ----------- gradle/libs.versions.toml | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index cd316d72..4bc0bb5c 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -243,17 +243,6 @@ column="35"/> - - - - Date: Sun, 21 Dec 2025 06:06:56 +0000 Subject: [PATCH 05/15] Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025 --- app/src/main/assets/nostr_relays.csv | 548 +++++++++++++-------------- 1 file changed, 273 insertions(+), 275 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 2288010a..03e8c247 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,285 +1,283 @@ Relay URL,Latitude,Longitude -alien.macneilmediagroup.com,43.6532,-79.3832 -nostr.simplex.icu,51.5121,-0.0005238 -nostr.satstralia.com,64.1476,-21.9392 -nostr-relay.amethyst.name,39.0438,-77.4874 -bcast.girino.org,43.6532,-79.3832 -purpura.cloud,43.6532,-79.3832 -wot.nostr.party,36.1627,-86.7816 -dev-relay.lnfi.network,39.0997,-94.5786 -temp.iris.to,43.6532,-79.3832 -relay.internationalright-wing.org,-22.5986,-48.8003 -relay.bitcoindistrict.org,43.6532,-79.3832 -srtrelay.c-stellar.net,43.6532,-79.3832 -nostr.mehdibekhtaoui.com,49.4939,-1.54813 -bucket.coracle.social,37.7775,-122.397 -relay.minibolt.info,43.6532,-79.3832 -fanfares.nostr1.com,40.7057,-74.0136 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -cyberspace.nostr1.com,40.7128,-74.006 -relay.jeffg.fyi,43.6532,-79.3832 -nostr2.girino.org,43.6532,-79.3832 -relay.stream.labs.h3.se,59.4016,17.9455 -nostr.rikmeijer.nl,50.4754,12.3683 -relay.getsafebox.app,43.6532,-79.3832 -nostr-01.uid.ovh,43.6532,-79.3832 -relay.hasenpfeffr.com,39.0438,-77.4874 -wot.dtonon.com,43.6532,-79.3832 -nostrcheck.me,43.6532,-79.3832 -strfry.bonsai.com,37.8715,-122.273 -relay.degmods.com,50.4754,12.3683 -relay.nostx.io,43.6532,-79.3832 -strfry.elswa-dev.online,50.1109,8.68213 -relay.lightning.pub,39.0438,-77.4874 -nostr.jerrynya.fun,31.2304,121.474 -satsage.xyz,37.3986,-121.964 -relay-rpi.edufeed.org,49.4521,11.0767 -nostr.bond,50.1109,8.68213 -relay.lumina.rocks,49.0291,8.35695 -relay.samt.st,40.8302,-74.1299 -nostr.snowbla.de,60.1699,24.9384 -nostr.red5d.dev,43.6532,-79.3832 -nostr.tadryanom.me,43.6532,-79.3832 -relay.bullishbounty.com,43.6532,-79.3832 -nostr.calitabby.net,39.9268,-75.0246 -relay.hook.cafe,43.6532,-79.3832 -relay.21e6.cz,50.7383,15.0648 -nostr-relay.online,43.6532,-79.3832 -inbox.azzamo.net,52.2633,21.0283 -relay.chakany.systems,43.6532,-79.3832 -relay.coinos.io,43.6532,-79.3832 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.siamdev.cc,13.9178,100.424 -relay04.lnfi.network,39.0997,-94.5786 -wot.sovbit.host,64.1466,-21.9426 -bcast.seutoba.com.br,43.6532,-79.3832 -shu05.shugur.net,48.8566,2.35222 -wot.nostr.place,32.7767,-96.797 -nos.xmark.cc,50.6924,3.20113 -nostr-relay.corb.net,38.8353,-104.822 -relay5.bitransfer.org,43.6532,-79.3832 -nostr-relay.nextblockvending.com,47.2343,-119.853 -relay.fountain.fm,39.0997,-94.5786 -santo.iguanatech.net,40.8302,-74.1299 -bitcoiner.social,39.1585,-94.5728 -relay.nostr.place,32.7767,-96.797 -relay.fr13nd5.com,52.5233,13.3426 -nostr.myshosholoza.co.za,52.3676,4.90414 -nostr.kalf.org,52.3676,4.90414 -nostr.chaima.info,51.223,6.78245 -relay.usefusion.ai,38.7134,-78.1591 -nostr.faultables.net,43.6532,-79.3832 -ynostr.yael.at,60.1699,24.9384 -nostr.4rs.nl,49.0291,8.35696 -adre.su,59.9311,30.3609 -nostr.spaceshell.xyz,43.6532,-79.3832 -relay.angor.io,48.1046,11.6002 -r.bitcoinhold.net,43.6532,-79.3832 -nostr.mom,50.4754,12.3683 -relay.cosmicbolt.net,37.3986,-121.964 -freelay.sovbit.host,64.1476,-21.9392 -relay.npubhaus.com,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -nostr.agentcampfire.com,52.3676,4.90414 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -strfry.felixzieger.de,50.1013,8.62643 -orly.ft.hn,50.4754,12.3683 -nostr.hifish.org,47.4043,8.57398 -relayone.soundhsa.com,33.1384,-95.6011 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -relay.trustroots.org,43.6532,-79.3832 -soloco.nl,43.6532,-79.3832 -nostr.mikoshi.de,47.8786,11.911 -relay.holzeis.me,43.6532,-79.3832 -wot.sebastix.social,51.1792,5.89444 -nostr.azzamo.net,52.2633,21.0283 -wot.shaving.kiwi,43.6532,-79.3832 -nostr.night7.space,50.4754,12.3683 -nproxy.kristapsk.lv,60.1699,24.9384 -freeben666.fr,43.7221,7.15296 -relay.libernet.app,43.6532,-79.3832 -nostr.88mph.life,51.5072,-0.127586 -wot.tealeaf.dev,33.7488,-84.3877 -nostr.sathoarder.com,48.5734,7.75211 -relay.0xchat.com,1.35208,103.82 -relay.fundstr.me,42.3601,-71.0589 -nostr.lkjsxc.com,43.6532,-79.3832 -nostr.n7ekb.net,47.4941,-122.294 -relay.jabato.space,52.52,13.405 -nostr.davidebtc.me,51.5072,-0.127586 -relay.evanverma.com,40.8302,-74.1299 -wot.dergigi.com,64.1476,-21.9392 -relay.seq1.net,43.6532,-79.3832 -wot.yesnostr.net,50.9871,2.12554 -relay-freeharmonypeople.space,38.7223,-9.13934 -nostr.rblb.it,43.7094,10.6582 -ribo.us.nostria.app,41.5868,-93.625 -relay.nostar.org,43.6532,-79.3832 -purplerelay.com,50.1109,8.68213 -nostr-01.yakihonne.com,1.32123,103.695 -relay.jmoose.rocks,60.1699,24.9384 -bitsat.molonlabe.holdings,51.4012,-1.3147 -nos.lol,50.4754,12.3683 -shu02.shugur.net,21.4902,39.2246 -fenrir-s.notoshi.win,43.6532,-79.3832 -relay.puresignal.news,43.6532,-79.3832 -nostr.vulpem.com,49.4543,11.0746 -relay.etch.social,41.2619,-95.8608 -nostr.camalolo.com,24.1469,120.684 -nostr-2.21crypto.ch,47.5356,8.73209 -nostrelay.memory-art.xyz,43.6532,-79.3832 -relay.upleb.uk,51.9194,19.1451 -relay.sharegap.net,43.6532,-79.3832 -relay.2nix.de,60.1699,24.9384 -nostr.21crypto.ch,47.5356,8.73209 -relay.mattybs.lol,43.6532,-79.3832 -relayb.uid.ovh,43.6532,-79.3832 -nostr.hekster.org,37.3986,-121.964 -shu04.shugur.net,25.2604,55.2989 -nostrelay.circum.space,52.3676,4.90414 -nostr.tavux.tech,48.8575,2.35138 -nostr.thebiglake.org,32.71,-96.6745 -khatru.nostrver.se,51.1792,5.89444 -relay.damus.io,43.6532,-79.3832 -relay.wavlake.com,41.2619,-95.8608 -relay2.angor.io,48.1046,11.6002 -nostr.spicyz.io,43.6532,-79.3832 -nostr.bilthon.dev,25.8128,-80.2377 -nostr.now,36.55,139.733 -shu01.shugur.net,21.4902,39.2246 -spatia-arcana.com,34.0362,-118.443 -nos4smartnkind.tech,40.1872,44.5152 -nostr-relay.xbytez.io,50.6924,3.20113 -wheat.happytavern.co,43.6532,-79.3832 -relay.notoshi.win,13.311,101.112 -theoutpost.life,64.1476,-21.9392 -nostr.tuckerbradford.com,43.663,-70.2569 -nostrcheck.tnsor.network,43.6532,-79.3832 -nostr.coincards.com,53.5501,-113.469 -relay.cypherflow.ai,48.8566,2.35222 -relay.btcforplebs.com,43.6532,-79.3832 -nostr.data.haus,50.4754,12.3683 -relay.satlantis.io,32.8769,-80.0114 -relay.agora.social,50.7383,15.0648 -notemine.io,52.2026,20.9397 -relay.nostr.band,60.1699,24.9384 -relay.orangepill.ovh,49.1689,-0.358841 -strfry.shock.network,39.0438,-77.4874 -relay.dwadziesciajeden.pl,52.2297,21.0122 -articles.layer3.news,37.3387,-121.885 -nostr.czas.top,50.1109,8.68213 -relay.olas.app,50.4754,12.3683 -orangepiller.org,60.1699,24.9384 -nostr-02.yakihonne.com,1.32123,103.695 -relay.thebluepulse.com,49.4521,11.0767 -relay.binaryrobot.com,43.6532,-79.3832 -prl.plus,56.9677,24.1056 -strfry.openhoofd.nl,51.9229,4.40833 -nostr.girino.org,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -nostr-02.czas.top,51.2277,6.77346 -nostrelites.org,41.8781,-87.6298 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -nostr-relay.zimage.com,34.0549,-118.243 -wot.soundhsa.com,33.1384,-95.6011 -relay.letsfo.com,51.098,17.0321 -relay.vrtmrz.net,43.6532,-79.3832 -ribo.eu.nostria.app,52.3676,4.90414 -relay.nostr.wirednet.jp,34.706,135.493 -relay.snort.social,53.3498,-6.26031 -nostr.huszonegy.world,47.4979,19.0402 -nostream.breadslice.com,1.35208,103.82 -relay.credenso.cafe,43.3601,-80.3127 -relay.tagayasu.xyz,43.6715,-79.38 -nostr.stakey.net,52.3676,4.90414 -relay.artx.market,43.652,-79.3633 -relay.sigit.io,50.4754,12.3683 -librerelay.aaroniumii.com,43.6532,-79.3832 -nostr.noones.com,50.1109,8.68213 -pyramid.treegaze.com,43.6532,-79.3832 -relay.crostr.com,43.6532,-79.3832 -relay03.lnfi.network,39.0997,-94.5786 -no.str.cr,9.92857,-84.0528 -relay.nostrhub.tech,49.0291,8.35696 -nostr.overmind.lol,43.6532,-79.3832 -kotukonostr.onrender.com,37.7775,-122.397 -relay.chorus.community,50.1109,8.68213 -relay.zone667.com,60.1699,24.9384 -relay01.lnfi.network,39.0997,-94.5786 -nostr.ps1829.com,33.8851,130.883 -offchain.pub,47.6743,-117.112 -nostr.plantroon.com,50.1013,8.62643 -relay.nostr-check.me,43.6532,-79.3832 -nostr.oxtr.dev,50.4754,12.3683 -black.nostrcity.club,48.8575,2.35138 -relay.uid.ovh,43.6532,-79.3832 -nostr.rtvslawenia.com,49.4543,11.0746 relay.nostriot.com,41.5695,-83.9786 -wot.brightbolt.net,47.6735,-116.781 -relay.toastr.net,40.8054,-74.0241 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -nostr.zoracle.org,45.6018,-121.185 -relay.wolfcoil.com,35.6092,139.73 -premium.primal.net,43.6532,-79.3832 -nostr.roundrockbitcoiners.com,40.8054,-74.0241 -relay.thibautduchene.fr,43.6532,-79.3832 relay.illuminodes.com,47.6061,-122.333 -relay.ngengine.org,43.6532,-79.3832 -dev-nostr.bityacht.io,25.0797,121.234 -wot.sudocarlos.com,51.5072,-0.127586 -relay.islandbitcoin.com,12.8498,77.6545 -nostr.blankfors.se,60.1699,24.9384 -relay2.ngengine.org,43.6532,-79.3832 -relay.nostr.net,43.6532,-79.3832 -alienos.libretechsystems.xyz,55.4724,9.87335 -relay-dev.satlantis.io,40.8302,-74.1299 -relay.divine.video,43.6532,-79.3832 -nostr.tac.lol,47.4748,-122.273 -relay.zombi.cloudrodion.com,48.8566,2.35222 -relay.mostro.network,40.8302,-74.1299 -relay.arx-ccn.com,50.4754,12.3683 -relay.davidebtc.me,51.5072,-0.127586 -wot.codingarena.top,50.4754,12.3683 -relay.bitcoinartclock.com,50.4754,12.3683 -nostr.nodesmap.com,59.3327,18.0656 -relay.nostrdice.com,-33.8688,151.209 -wot.nostr.net,43.6532,-79.3832 -relay02.lnfi.network,39.0997,-94.5786 -relayrs.notoshi.win,43.6532,-79.3832 -relay.nostrhub.fr,48.1045,11.6004 -nostr.openhoofd.nl,51.9229,4.40833 -relay.wavefunc.live,34.0362,-118.443 -nostr.coincrowd.fund,39.0438,-77.4874 -relay.mccormick.cx,52.3563,4.95714 -slick.mjex.me,39.048,-77.4817 -relay.nostrcheck.me,43.6532,-79.3832 -nostr.notribe.net,40.8302,-74.1299 -relay.primal.net,43.6532,-79.3832 -vitor.nostr1.com,40.7057,-74.0136 -relay.bitcoinveneto.org,64.1466,-21.9426 -public.crostr.com,43.6532,-79.3832 -nostr.ovia.to,43.6532,-79.3832 -ithurtswhenip.ee,51.223,6.78245 +relay.hook.cafe,43.6532,-79.3832 +relay.controller-ai.com,52.3563,4.95714 +relay.hasenpfeffr.com,39.0438,-77.4874 +nostr-relay.zimage.com,34.0549,-118.243 +relay.mattybs.lol,43.6532,-79.3832 +nostr.rblb.it,43.7094,10.6582 relay.origin.land,35.6673,139.751 -relay.nostrzh.org,43.6532,-79.3832 -orangesync.tech,50.1109,8.68213 -nostr.bitcoiner.social,39.1585,-94.5728 -relay.javi.space,43.4633,11.8796 -relay.electriclifestyle.com,26.2897,-80.1293 +nostr2.girino.org,43.6532,-79.3832 +nostr-relay.nextblockvending.com,47.2343,-119.853 +nostr.agentcampfire.com,52.3676,4.90414 +relay.olas.app,50.4754,12.3683 +soloco.nl,43.6532,-79.3832 +relay.usefusion.ai,38.7134,-78.1591 +nostr.lkjsxc.com,43.6532,-79.3832 +nostr.spicyz.io,43.6532,-79.3832 +relay.getsafebox.app,43.6532,-79.3832 +nostr.nodesmap.com,59.3327,18.0656 +nostr.88mph.life,51.5072,-0.127586 +relay04.lnfi.network,39.0997,-94.5786 +relay.uid.ovh,43.6532,-79.3832 +nostr.coincards.com,53.5501,-113.469 +srtrelay.c-stellar.net,43.6532,-79.3832 +purpura.cloud,43.6532,-79.3832 +nostream.breadslice.com,1.35208,103.82 +nostr.hifish.org,47.4043,8.57398 +nostr.data.haus,50.4754,12.3683 +relay.bullishbounty.com,43.6532,-79.3832 +nostr.superfriends.online,43.6532,-79.3832 x.kojira.io,43.6532,-79.3832 -relay.endfiat.money,43.6532,-79.3832 -relay.magiccity.live,25.8128,-80.2377 -chat-relay.zap-work.com,43.6532,-79.3832 -nostr-03.dorafactory.org,1.35208,103.82 -nostr-relay.psfoundation.info,39.0438,-77.4874 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -relay.routstr.com,43.6532,-79.3832 +nostr.czas.top,50.1109,8.68213 +relay.seq1.net,43.6532,-79.3832 nostr.luisschwab.net,43.6532,-79.3832 -nostr.diakod.com,43.6532,-79.3832 -relay.guggero.org,47.3769,8.54169 +temp.iris.to,43.6532,-79.3832 +nostr.azzamo.net,52.2633,21.0283 +orangepiller.org,60.1699,24.9384 +freeben666.fr,43.7221,7.15296 +strfry.elswa-dev.online,50.1109,8.68213 +wot.dtonon.com,43.6532,-79.3832 +relay.primal.net,43.6532,-79.3832 +schnorr.me,43.6532,-79.3832 nostr.0x7e.xyz,47.4988,8.72369 +nostr.spaceshell.xyz,43.6532,-79.3832 +relay.0xchat.com,1.35208,103.82 +nostr.kalf.org,52.3676,4.90414 +relay.cypherflow.ai,48.8566,2.35222 +chat-relay.zap-work.com,43.6532,-79.3832 +relay01.lnfi.network,39.0997,-94.5786 +nostr.commonshub.brussels,49.4543,11.0746 +nostr.rikmeijer.nl,50.4754,12.3683 +nostr.calitabby.net,39.9268,-75.0246 +relay.snort.social,53.3498,-6.26031 +nostr.sathoarder.com,48.5734,7.75211 +strfry.openhoofd.nl,51.9229,4.40833 yabu.me,35.6092,139.73 -relay.ru.ac.th,13.7607,100.627 -relay.moinsen.com,50.4754,12.3683 +relay.nostrhub.tech,49.0291,8.35696 +relay.mostro.network,40.8302,-74.1299 +bcast.seutoba.com.br,43.6532,-79.3832 +nostr.satstralia.com,64.1476,-21.9392 +nostr.21crypto.ch,47.5356,8.73209 +santo.iguanatech.net,40.8302,-74.1299 +notemine.io,52.2026,20.9397 +nostr.ps1829.com,33.8851,130.883 +relay.nostr.place,32.7767,-96.797 +alienos.libretechsystems.xyz,55.4724,9.87335 +nostr.bond,50.1109,8.68213 +relay.nostrdice.com,-33.8688,151.209 +nostr-verified.wellorder.net,45.5201,-122.99 +nostr-01.yakihonne.com,1.32123,103.695 +strfry.shock.network,39.0438,-77.4874 +relay.evanverma.com,40.8302,-74.1299 +relay.davidebtc.me,51.5072,-0.127586 +nostr-relay.online,43.6532,-79.3832 +bitsat.molonlabe.holdings,51.4012,-1.3147 +relay.angor.io,48.1046,11.6002 +nostr.openhoofd.nl,51.9229,4.40833 +relay.smies.me,33.7501,-84.3885 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +nostr.plantroon.com,50.1013,8.62643 +dev-relay.lnfi.network,39.0997,-94.5786 +nostr-relay.xbytez.io,50.6924,3.20113 +nostr.camalolo.com,24.1469,120.684 +relay-dev.satlantis.io,40.8302,-74.1299 +relay.artx.market,43.652,-79.3633 +wot.sovbit.host,64.1466,-21.9426 +relay.fundstr.me,42.3601,-71.0589 +nostr.hekster.org,37.3986,-121.964 +relay.libernet.app,43.6532,-79.3832 +nostr.n7ekb.net,47.4941,-122.294 +relay.thibautduchene.fr,43.6532,-79.3832 +nostr.mikoshi.de,47.74,12.0917 +relayone.soundhsa.com,33.1384,-95.6011 +relay.nosto.re,51.1792,5.89444 +relay.notoshi.win,13.311,101.112 +neuromancer.nettek.io,39.1429,-94.573 +nostr.ovia.to,43.6532,-79.3832 +relay.trustroots.org,43.6532,-79.3832 +relay.thebluepulse.com,49.4521,11.0767 +strfry.felixzieger.de,50.1013,8.62643 +relay.nostr.band,60.1699,24.9384 +relay2.angor.io,48.1046,11.6002 +relay.wavlake.com,41.2619,-95.8608 +ithurtswhenip.ee,51.223,6.78245 +shu05.shugur.net,48.8566,2.35222 +wot.shaving.kiwi,43.6532,-79.3832 +wot.nostr.party,36.1627,-86.7816 +relay.bitcoinveneto.org,64.1466,-21.9426 +relay.siamdev.cc,13.9178,100.424 +relay.ngengine.org,43.6532,-79.3832 +relay.sigit.io,50.4754,12.3683 +relay.electriclifestyle.com,26.2897,-80.1293 +relay.nostar.org,43.6532,-79.3832 +nostr.oxtr.dev,50.4754,12.3683 +nostr.vulpem.com,49.4543,11.0746 +relay02.lnfi.network,39.0997,-94.5786 +relay.upleb.uk,51.9194,19.1451 +relay.bitcoinartclock.com,50.4754,12.3683 +nostrelay.circum.space,52.3676,4.90414 +relay.camelus.app,45.5201,-122.99 +nostr-relay.cbrx.io,43.6532,-79.3832 +espelho.girino.org,43.6532,-79.3832 +relay.lightning.pub,39.0438,-77.4874 +ribo.us.nostria.app,41.5868,-93.625 +relay.openfarmtools.org,60.1699,24.9384 +vault.iris.to,43.6532,-79.3832 +nostr.quali.chat,60.1699,24.9384 +relay.wolfcoil.com,35.6092,139.73 +adre.su,59.9311,30.3609 +relay.nostrzh.org,43.6532,-79.3832 +cyberspace.nostr1.com,40.7128,-74.006 +relay.divine.video,43.6532,-79.3832 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +relay.cosmicbolt.net,37.3986,-121.964 +black.nostrcity.club,48.8575,2.35138 +nostr-relay.psfoundation.info,39.0438,-77.4874 +relay.tagayasu.xyz,43.6715,-79.38 +nostr.now,36.55,139.733 +shu04.shugur.net,25.2604,55.2989 +relay.samt.st,40.8302,-74.1299 +relay03.lnfi.network,39.0997,-94.5786 +nostr-02.yakihonne.com,1.32123,103.695 +nostr.tac.lol,47.4748,-122.273 +theoutpost.life,64.1476,-21.9392 +relay.dwadziesciajeden.pl,52.2297,21.0122 +nostr.huszonegy.world,47.4979,19.0402 +relay.zombi.cloudrodion.com,48.8566,2.35222 +nr.yay.so,46.2126,6.1154 +offchain.pub,47.6743,-117.112 +relay.jeffg.fyi,43.6532,-79.3832 +relay.wavefunc.live,34.0362,-118.443 +relay.islandbitcoin.com,12.8498,77.6545 +nostr-relay.amethyst.name,39.0438,-77.4874 +inbox.azzamo.net,52.2633,21.0283 +nostr.tadryanom.me,43.6532,-79.3832 +relay5.bitransfer.org,43.6532,-79.3832 +nostr.tavux.tech,48.8575,2.35138 +nostr.blankfors.se,60.1699,24.9384 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +nos.xmark.cc,50.6924,3.20113 +relay-freeharmonypeople.space,38.7223,-9.13934 +nostr.mehdibekhtaoui.com,49.4939,-1.54813 +nostr-02.czas.top,51.2277,6.77346 +relay.puresignal.news,43.6532,-79.3832 +nostr.faultables.net,43.6532,-79.3832 +relay.satmaxt.xyz,43.6532,-79.3832 +relay.degmods.com,50.4754,12.3683 +nos.lol,50.4754,12.3683 +wheat.happytavern.co,43.6532,-79.3832 +relays.diggoo.com,43.6532,-79.3832 +relay.chakany.systems,43.6532,-79.3832 +wot.sudocarlos.com,51.5072,-0.127586 +relay.vrtmrz.net,43.6532,-79.3832 +bucket.coracle.social,37.7775,-122.397 +relay.damus.io,43.6532,-79.3832 +relay.npubhaus.com,43.6532,-79.3832 relay.nuts.cash,34.0362,-118.443 relay.ditto.pub,43.6532,-79.3832 +relay.agora.social,50.7383,15.0648 +librerelay.aaroniumii.com,43.6532,-79.3832 +ribo.eu.nostria.app,52.3676,4.90414 +vitor.nostr1.com,40.7057,-74.0136 +nostr-2.21crypto.ch,47.5356,8.73209 +relay.etch.social,41.2619,-95.8608 +nostr.noones.com,50.1109,8.68213 +nostrcheck.tnsor.network,43.6532,-79.3832 +nostr.overmind.lol,43.6532,-79.3832 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +relay.coinos.io,43.6532,-79.3832 +relay.moinsen.com,50.4754,12.3683 +relay.letsfo.com,51.098,17.0321 +nostr-pub.wellorder.net,45.5201,-122.99 +relayone.geektank.ai,18.2148,-63.0574 +relay-rpi.edufeed.org,49.4521,11.0767 +relay.orangepill.ovh,49.1689,-0.358841 +relay.chorus.community,50.1109,8.68213 +ynostr.yael.at,60.1699,24.9384 +relay.agorist.space,52.3734,4.89406 +khatru.nostrver.se,51.1792,5.89444 +relay.binaryrobot.com,43.6532,-79.3832 +relay.zone667.com,60.1699,24.9384 +relay.javi.space,43.4633,11.8796 +nostr.roundrockbitcoiners.com,40.8054,-74.0241 +relay.minibolt.info,43.6532,-79.3832 +relay.nostr.wirednet.jp,34.706,135.493 +wot.dergigi.com,64.1476,-21.9392 +nostr-03.dorafactory.org,1.35208,103.82 +bitcoiner.social,39.1585,-94.5728 +prl.plus,42.6978,23.3246 +nostr.night7.space,50.4754,12.3683 +nostr.notribe.net,40.8302,-74.1299 +no.str.cr,9.92857,-84.0528 +relay.21e6.cz,50.7383,15.0648 +relay.bitcoindistrict.org,43.6532,-79.3832 +nostr.bilthon.dev,25.8128,-80.2377 +relay.endfiat.money,43.6532,-79.3832 +wot.nostr.place,32.7767,-96.797 +bcast.girino.org,43.6532,-79.3832 +nostr.thebiglake.org,32.71,-96.6745 +relay.satlantis.io,32.8769,-80.0114 +relay.btcforplebs.com,43.6532,-79.3832 +nostr.stakey.net,52.3676,4.90414 +relay.nostr-check.me,43.6532,-79.3832 +nostr.girino.org,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +relay2.ngengine.org,43.6532,-79.3832 +pyramid.treegaze.com,43.6532,-79.3832 +articles.layer3.news,37.3387,-121.885 +relay.fountain.fm,39.0997,-94.5786 +relay.mccormick.cx,52.3563,4.95714 +relay.routstr.com,43.6532,-79.3832 +relay.arx-ccn.com,50.4754,12.3683 +nostr.tuckerbradford.com,43.663,-70.2569 +nostr.zoracle.org,45.6018,-121.185 +nostr.jerrynya.fun,31.2304,121.474 +relay.nostr.net,43.6532,-79.3832 +relay.holzeis.me,43.6532,-79.3832 +relay.wellorder.net,45.5201,-122.99 +relay.guggero.org,47.3769,8.54169 +nostr.red5d.dev,43.6532,-79.3832 +nostr-01.uid.ovh,43.6532,-79.3832 +r.bitcoinhold.net,43.6532,-79.3832 +relay.nostrhub.fr,48.1045,11.6004 +wot.sebastix.social,51.1792,5.89444 +nostr.mom,50.4754,12.3683 +nos4smartnkind.tech,40.1872,44.5152 +nostr.casa21.space,43.6532,-79.3832 +relay.nostrcheck.me,43.6532,-79.3832 +kotukonostr.onrender.com,37.7775,-122.397 +nostr-dev.wellorder.net,45.5201,-122.99 +purplerelay.com,50.1109,8.68213 +strfry.bonsai.com,37.8715,-122.273 +wot.yesnostr.net,50.9871,2.12554 +relay.lumina.rocks,49.0291,8.35695 +shu01.shugur.net,21.4902,39.2246 +nostrcheck.me,43.6532,-79.3832 +nostr-relay.corb.net,38.8353,-104.822 +dev-nostr.bityacht.io,25.0797,121.234 +nostr.simplex.icu,51.5121,-0.0005238 +relay.magiccity.live,25.8128,-80.2377 +public.crostr.com,43.6532,-79.3832 +nostr.4rs.nl,49.0291,8.35696 +relay.jabato.space,52.52,13.405 +wot.nostr.net,43.6532,-79.3832 +fanfares.nostr1.com,40.7057,-74.0136 +relay.toastr.net,40.8054,-74.0241 +nostrelay.memory-art.xyz,43.6532,-79.3832 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +shu02.shugur.net,21.4902,39.2246 +nostr.davidebtc.me,51.5072,-0.127586 +premium.primal.net,43.6532,-79.3832 +nostr.chaima.info,51.223,6.78245 +nostr.snowbla.de,60.1699,24.9384 +relay.credenso.cafe,43.3601,-80.3127 +wot.soundhsa.com,33.1384,-95.6011 +nproxy.kristapsk.lv,60.1699,24.9384 +alien.macneilmediagroup.com,43.6532,-79.3832 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +nostr.bitcoiner.social,39.1585,-94.5728 +orly.ft.hn,50.4754,12.3683 +relay.threenine.services,51.5524,-0.29686 +nostr.myshosholoza.co.za,52.3676,4.90414 From ab555f25f509c0f0d9b307853e6a836ced1f09cd Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 28 Dec 2025 06:07:12 +0000 Subject: [PATCH 06/15] Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025 --- app/src/main/assets/nostr_relays.csv | 564 ++++++++++++++------------- 1 file changed, 288 insertions(+), 276 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 03e8c247..ab075fb4 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,283 +1,295 @@ Relay URL,Latitude,Longitude -relay.nostriot.com,41.5695,-83.9786 -relay.illuminodes.com,47.6061,-122.333 -relay.hook.cafe,43.6532,-79.3832 -relay.controller-ai.com,52.3563,4.95714 -relay.hasenpfeffr.com,39.0438,-77.4874 -nostr-relay.zimage.com,34.0549,-118.243 -relay.mattybs.lol,43.6532,-79.3832 -nostr.rblb.it,43.7094,10.6582 -relay.origin.land,35.6673,139.751 -nostr2.girino.org,43.6532,-79.3832 -nostr-relay.nextblockvending.com,47.2343,-119.853 -nostr.agentcampfire.com,52.3676,4.90414 -relay.olas.app,50.4754,12.3683 -soloco.nl,43.6532,-79.3832 -relay.usefusion.ai,38.7134,-78.1591 -nostr.lkjsxc.com,43.6532,-79.3832 -nostr.spicyz.io,43.6532,-79.3832 -relay.getsafebox.app,43.6532,-79.3832 -nostr.nodesmap.com,59.3327,18.0656 -nostr.88mph.life,51.5072,-0.127586 -relay04.lnfi.network,39.0997,-94.5786 -relay.uid.ovh,43.6532,-79.3832 -nostr.coincards.com,53.5501,-113.469 -srtrelay.c-stellar.net,43.6532,-79.3832 -purpura.cloud,43.6532,-79.3832 -nostream.breadslice.com,1.35208,103.82 -nostr.hifish.org,47.4043,8.57398 -nostr.data.haus,50.4754,12.3683 -relay.bullishbounty.com,43.6532,-79.3832 -nostr.superfriends.online,43.6532,-79.3832 -x.kojira.io,43.6532,-79.3832 -nostr.czas.top,50.1109,8.68213 -relay.seq1.net,43.6532,-79.3832 -nostr.luisschwab.net,43.6532,-79.3832 -temp.iris.to,43.6532,-79.3832 -nostr.azzamo.net,52.2633,21.0283 -orangepiller.org,60.1699,24.9384 -freeben666.fr,43.7221,7.15296 -strfry.elswa-dev.online,50.1109,8.68213 -wot.dtonon.com,43.6532,-79.3832 -relay.primal.net,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -nostr.0x7e.xyz,47.4988,8.72369 -nostr.spaceshell.xyz,43.6532,-79.3832 -relay.0xchat.com,1.35208,103.82 -nostr.kalf.org,52.3676,4.90414 -relay.cypherflow.ai,48.8566,2.35222 -chat-relay.zap-work.com,43.6532,-79.3832 -relay01.lnfi.network,39.0997,-94.5786 -nostr.commonshub.brussels,49.4543,11.0746 -nostr.rikmeijer.nl,50.4754,12.3683 -nostr.calitabby.net,39.9268,-75.0246 -relay.snort.social,53.3498,-6.26031 -nostr.sathoarder.com,48.5734,7.75211 -strfry.openhoofd.nl,51.9229,4.40833 -yabu.me,35.6092,139.73 -relay.nostrhub.tech,49.0291,8.35696 -relay.mostro.network,40.8302,-74.1299 -bcast.seutoba.com.br,43.6532,-79.3832 -nostr.satstralia.com,64.1476,-21.9392 -nostr.21crypto.ch,47.5356,8.73209 -santo.iguanatech.net,40.8302,-74.1299 -notemine.io,52.2026,20.9397 -nostr.ps1829.com,33.8851,130.883 -relay.nostr.place,32.7767,-96.797 -alienos.libretechsystems.xyz,55.4724,9.87335 -nostr.bond,50.1109,8.68213 -relay.nostrdice.com,-33.8688,151.209 -nostr-verified.wellorder.net,45.5201,-122.99 -nostr-01.yakihonne.com,1.32123,103.695 strfry.shock.network,39.0438,-77.4874 -relay.evanverma.com,40.8302,-74.1299 -relay.davidebtc.me,51.5072,-0.127586 -nostr-relay.online,43.6532,-79.3832 -bitsat.molonlabe.holdings,51.4012,-1.3147 -relay.angor.io,48.1046,11.6002 -nostr.openhoofd.nl,51.9229,4.40833 -relay.smies.me,33.7501,-84.3885 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -nostr.plantroon.com,50.1013,8.62643 -dev-relay.lnfi.network,39.0997,-94.5786 -nostr-relay.xbytez.io,50.6924,3.20113 -nostr.camalolo.com,24.1469,120.684 -relay-dev.satlantis.io,40.8302,-74.1299 -relay.artx.market,43.652,-79.3633 -wot.sovbit.host,64.1466,-21.9426 -relay.fundstr.me,42.3601,-71.0589 -nostr.hekster.org,37.3986,-121.964 -relay.libernet.app,43.6532,-79.3832 -nostr.n7ekb.net,47.4941,-122.294 -relay.thibautduchene.fr,43.6532,-79.3832 -nostr.mikoshi.de,47.74,12.0917 -relayone.soundhsa.com,33.1384,-95.6011 -relay.nosto.re,51.1792,5.89444 -relay.notoshi.win,13.311,101.112 -neuromancer.nettek.io,39.1429,-94.573 -nostr.ovia.to,43.6532,-79.3832 -relay.trustroots.org,43.6532,-79.3832 -relay.thebluepulse.com,49.4521,11.0767 -strfry.felixzieger.de,50.1013,8.62643 -relay.nostr.band,60.1699,24.9384 -relay2.angor.io,48.1046,11.6002 -relay.wavlake.com,41.2619,-95.8608 -ithurtswhenip.ee,51.223,6.78245 -shu05.shugur.net,48.8566,2.35222 -wot.shaving.kiwi,43.6532,-79.3832 wot.nostr.party,36.1627,-86.7816 -relay.bitcoinveneto.org,64.1466,-21.9426 -relay.siamdev.cc,13.9178,100.424 -relay.ngengine.org,43.6532,-79.3832 -relay.sigit.io,50.4754,12.3683 -relay.electriclifestyle.com,26.2897,-80.1293 -relay.nostar.org,43.6532,-79.3832 -nostr.oxtr.dev,50.4754,12.3683 -nostr.vulpem.com,49.4543,11.0746 -relay02.lnfi.network,39.0997,-94.5786 -relay.upleb.uk,51.9194,19.1451 -relay.bitcoinartclock.com,50.4754,12.3683 -nostrelay.circum.space,52.3676,4.90414 -relay.camelus.app,45.5201,-122.99 -nostr-relay.cbrx.io,43.6532,-79.3832 -espelho.girino.org,43.6532,-79.3832 -relay.lightning.pub,39.0438,-77.4874 -ribo.us.nostria.app,41.5868,-93.625 -relay.openfarmtools.org,60.1699,24.9384 -vault.iris.to,43.6532,-79.3832 -nostr.quali.chat,60.1699,24.9384 -relay.wolfcoil.com,35.6092,139.73 -adre.su,59.9311,30.3609 -relay.nostrzh.org,43.6532,-79.3832 -cyberspace.nostr1.com,40.7128,-74.006 -relay.divine.video,43.6532,-79.3832 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -relay.cosmicbolt.net,37.3986,-121.964 -black.nostrcity.club,48.8575,2.35138 -nostr-relay.psfoundation.info,39.0438,-77.4874 -relay.tagayasu.xyz,43.6715,-79.38 -nostr.now,36.55,139.733 -shu04.shugur.net,25.2604,55.2989 -relay.samt.st,40.8302,-74.1299 -relay03.lnfi.network,39.0997,-94.5786 -nostr-02.yakihonne.com,1.32123,103.695 -nostr.tac.lol,47.4748,-122.273 -theoutpost.life,64.1476,-21.9392 -relay.dwadziesciajeden.pl,52.2297,21.0122 -nostr.huszonegy.world,47.4979,19.0402 -relay.zombi.cloudrodion.com,48.8566,2.35222 -nr.yay.so,46.2126,6.1154 -offchain.pub,47.6743,-117.112 -relay.jeffg.fyi,43.6532,-79.3832 -relay.wavefunc.live,34.0362,-118.443 -relay.islandbitcoin.com,12.8498,77.6545 -nostr-relay.amethyst.name,39.0438,-77.4874 -inbox.azzamo.net,52.2633,21.0283 -nostr.tadryanom.me,43.6532,-79.3832 -relay5.bitransfer.org,43.6532,-79.3832 -nostr.tavux.tech,48.8575,2.35138 nostr.blankfors.se,60.1699,24.9384 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -nos.xmark.cc,50.6924,3.20113 -relay-freeharmonypeople.space,38.7223,-9.13934 -nostr.mehdibekhtaoui.com,49.4939,-1.54813 -nostr-02.czas.top,51.2277,6.77346 -relay.puresignal.news,43.6532,-79.3832 -nostr.faultables.net,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -relay.degmods.com,50.4754,12.3683 -nos.lol,50.4754,12.3683 -wheat.happytavern.co,43.6532,-79.3832 -relays.diggoo.com,43.6532,-79.3832 -relay.chakany.systems,43.6532,-79.3832 -wot.sudocarlos.com,51.5072,-0.127586 -relay.vrtmrz.net,43.6532,-79.3832 -bucket.coracle.social,37.7775,-122.397 -relay.damus.io,43.6532,-79.3832 -relay.npubhaus.com,43.6532,-79.3832 -relay.nuts.cash,34.0362,-118.443 -relay.ditto.pub,43.6532,-79.3832 -relay.agora.social,50.7383,15.0648 -librerelay.aaroniumii.com,43.6532,-79.3832 -ribo.eu.nostria.app,52.3676,4.90414 -vitor.nostr1.com,40.7057,-74.0136 -nostr-2.21crypto.ch,47.5356,8.73209 -relay.etch.social,41.2619,-95.8608 -nostr.noones.com,50.1109,8.68213 -nostrcheck.tnsor.network,43.6532,-79.3832 -nostr.overmind.lol,43.6532,-79.3832 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -relay.coinos.io,43.6532,-79.3832 -relay.moinsen.com,50.4754,12.3683 -relay.letsfo.com,51.098,17.0321 -nostr-pub.wellorder.net,45.5201,-122.99 -relayone.geektank.ai,18.2148,-63.0574 -relay-rpi.edufeed.org,49.4521,11.0767 -relay.orangepill.ovh,49.1689,-0.358841 -relay.chorus.community,50.1109,8.68213 -ynostr.yael.at,60.1699,24.9384 -relay.agorist.space,52.3734,4.89406 -khatru.nostrver.se,51.1792,5.89444 -relay.binaryrobot.com,43.6532,-79.3832 -relay.zone667.com,60.1699,24.9384 -relay.javi.space,43.4633,11.8796 -nostr.roundrockbitcoiners.com,40.8054,-74.0241 -relay.minibolt.info,43.6532,-79.3832 -relay.nostr.wirednet.jp,34.706,135.493 -wot.dergigi.com,64.1476,-21.9392 -nostr-03.dorafactory.org,1.35208,103.82 -bitcoiner.social,39.1585,-94.5728 -prl.plus,42.6978,23.3246 -nostr.night7.space,50.4754,12.3683 -nostr.notribe.net,40.8302,-74.1299 -no.str.cr,9.92857,-84.0528 -relay.21e6.cz,50.7383,15.0648 -relay.bitcoindistrict.org,43.6532,-79.3832 -nostr.bilthon.dev,25.8128,-80.2377 -relay.endfiat.money,43.6532,-79.3832 -wot.nostr.place,32.7767,-96.797 -bcast.girino.org,43.6532,-79.3832 -nostr.thebiglake.org,32.71,-96.6745 -relay.satlantis.io,32.8769,-80.0114 -relay.btcforplebs.com,43.6532,-79.3832 -nostr.stakey.net,52.3676,4.90414 -relay.nostr-check.me,43.6532,-79.3832 -nostr.girino.org,43.6532,-79.3832 -nostr.rtvslawenia.com,49.4543,11.0746 -relay2.ngengine.org,43.6532,-79.3832 -pyramid.treegaze.com,43.6532,-79.3832 -articles.layer3.news,37.3387,-121.885 -relay.fountain.fm,39.0997,-94.5786 -relay.mccormick.cx,52.3563,4.95714 -relay.routstr.com,43.6532,-79.3832 -relay.arx-ccn.com,50.4754,12.3683 -nostr.tuckerbradford.com,43.663,-70.2569 -nostr.zoracle.org,45.6018,-121.185 -nostr.jerrynya.fun,31.2304,121.474 -relay.nostr.net,43.6532,-79.3832 -relay.holzeis.me,43.6532,-79.3832 -relay.wellorder.net,45.5201,-122.99 -relay.guggero.org,47.3769,8.54169 -nostr.red5d.dev,43.6532,-79.3832 -nostr-01.uid.ovh,43.6532,-79.3832 -r.bitcoinhold.net,43.6532,-79.3832 -relay.nostrhub.fr,48.1045,11.6004 -wot.sebastix.social,51.1792,5.89444 -nostr.mom,50.4754,12.3683 -nos4smartnkind.tech,40.1872,44.5152 -nostr.casa21.space,43.6532,-79.3832 -relay.nostrcheck.me,43.6532,-79.3832 -kotukonostr.onrender.com,37.7775,-122.397 -nostr-dev.wellorder.net,45.5201,-122.99 -purplerelay.com,50.1109,8.68213 -strfry.bonsai.com,37.8715,-122.273 -wot.yesnostr.net,50.9871,2.12554 -relay.lumina.rocks,49.0291,8.35695 -shu01.shugur.net,21.4902,39.2246 -nostrcheck.me,43.6532,-79.3832 -nostr-relay.corb.net,38.8353,-104.822 -dev-nostr.bityacht.io,25.0797,121.234 +nostr.now,36.55,139.733 nostr.simplex.icu,51.5121,-0.0005238 -relay.magiccity.live,25.8128,-80.2377 -public.crostr.com,43.6532,-79.3832 -nostr.4rs.nl,49.0291,8.35696 -relay.jabato.space,52.52,13.405 -wot.nostr.net,43.6532,-79.3832 -fanfares.nostr1.com,40.7057,-74.0136 -relay.toastr.net,40.8054,-74.0241 -nostrelay.memory-art.xyz,43.6532,-79.3832 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -shu02.shugur.net,21.4902,39.2246 -nostr.davidebtc.me,51.5072,-0.127586 -premium.primal.net,43.6532,-79.3832 -nostr.chaima.info,51.223,6.78245 -nostr.snowbla.de,60.1699,24.9384 -relay.credenso.cafe,43.3601,-80.3127 -wot.soundhsa.com,33.1384,-95.6011 -nproxy.kristapsk.lv,60.1699,24.9384 -alien.macneilmediagroup.com,43.6532,-79.3832 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -nostr.bitcoiner.social,39.1585,-94.5728 -orly.ft.hn,50.4754,12.3683 relay.threenine.services,51.5524,-0.29686 +relay.barine.co,43.6532,-79.3832 +theoutpost.life,64.1476,-21.9392 +purplerelay.com,50.1109,8.68213 +nostrue.com,40.8054,-74.0241 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +shu02.shugur.net,21.4902,39.2246 +relayone.geektank.ai,18.2148,-63.0574 +relay03.lnfi.network,39.0997,-94.5786 +soloco.nl,43.6532,-79.3832 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +nostr.azzamo.net,52.2633,21.0283 +nostr.red5d.dev,43.6532,-79.3832 +nostr.thebiglake.org,32.71,-96.6745 +nostr.tadryanom.me,43.6532,-79.3832 +schnorr.me,43.6532,-79.3832 +ithurtswhenip.ee,51.223,6.78245 +orly.ft.hn,50.4754,12.3683 +relay02.lnfi.network,39.0997,-94.5786 +relay.nosto.re,51.1792,5.89444 +strfry.openhoofd.nl,51.9229,4.40833 +relays.diggoo.com,43.6532,-79.3832 +relay.nostrhub.tech,49.0291,8.35696 +relay.artx.market,43.652,-79.3633 +relay.sigit.io,50.4754,12.3683 +nostr-relay.psfoundation.info,39.0438,-77.4874 +wot.brightbolt.net,47.6735,-116.781 +relay.bitcoinartclock.com,50.4754,12.3683 +khatru.nostrver.se,51.1792,5.89444 +relay.cosmicbolt.net,37.3986,-121.964 +adre.su,59.9311,30.3609 +nostrelay.memory-art.xyz,43.6532,-79.3832 +temp.iris.to,43.6532,-79.3832 +nproxy.kristapsk.lv,60.1699,24.9384 +relay.ngengine.org,43.6532,-79.3832 +relay.puresignal.news,43.6532,-79.3832 +nostr.stakey.net,52.3676,4.90414 +nostr.commonshub.brussels,49.4543,11.0746 +wot.shaving.kiwi,43.6532,-79.3832 +espelho.girino.org,43.6532,-79.3832 +relay.thibautduchene.fr,43.6532,-79.3832 +nostr-relay.corb.net,38.8353,-104.822 +wot.soundhsa.com,33.1384,-95.6011 +relay.jabato.space,52.52,13.405 +relay.credenso.cafe,43.3601,-80.3127 +relayone.soundhsa.com,33.1384,-95.6011 +relay.bitcoinveneto.org,64.1466,-21.9426 +relay.samt.st,40.8302,-74.1299 +relay.vrtmrz.net,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +relay.npubhaus.com,43.6532,-79.3832 +strfry.elswa-dev.online,50.1109,8.68213 +relay.wavefunc.live,34.0362,-118.443 +wot.sovbit.host,64.1466,-21.9426 +relay.smies.me,33.7501,-84.3885 +nostr.bilthon.dev,25.8128,-80.2377 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +santo.iguanatech.net,40.8302,-74.1299 +prl.plus,42.6978,23.3246 +relay-freeharmonypeople.space,38.7223,-9.13934 +nostream.breadslice.com,1.35208,103.82 +nostr.notribe.net,40.8302,-74.1299 +relay.fountain.fm,39.0997,-94.5786 +orangepiller.org,60.1699,24.9384 +nostr.21crypto.ch,47.5356,8.73209 +nostr.camalolo.com,24.1469,120.684 +okn.czas.top,51.267,6.81738 +relay.wolfcoil.com,35.6092,139.73 +nostr.quali.chat,60.1699,24.9384 +nostr-relay.online,43.6532,-79.3832 +alienos.libretechsystems.xyz,55.4724,9.87335 +ribo.eu.nostria.app,52.3676,4.90414 +wot.yesnostr.net,50.9871,2.12554 +nostr.overmind.lol,43.6532,-79.3832 +relay.chakany.systems,43.6532,-79.3832 +relay.hasenpfeffr.com,39.0438,-77.4874 +nostrelites.org,41.8781,-87.6298 +strfry.bonsai.com,37.8715,-122.273 +relay.agora.social,50.7383,15.0648 +alien.macneilmediagroup.com,43.6532,-79.3832 +relay.getsafebox.app,43.6532,-79.3832 +relay.nuts.cash,34.0362,-118.443 +notemine.io,52.2026,20.9397 +relay.nostr.place,32.7767,-96.797 +relay.21e6.cz,50.7383,15.0648 +nostr.casa21.space,43.6532,-79.3832 +premium.primal.net,43.6532,-79.3832 +relay5.bitransfer.org,43.6532,-79.3832 +nostr.rblb.it,43.7094,10.6582 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +relay.camelus.app,45.5201,-122.99 +relay.origin.land,35.6673,139.751 +relay.fr13nd5.com,52.5233,13.3426 +nos.lol,50.4754,12.3683 +wot.nostr.net,43.6532,-79.3832 +relay.javi.space,43.4633,11.8796 +relay2.ngengine.org,43.6532,-79.3832 +relay.angor.io,48.1046,11.6002 +pyramid.treegaze.com,43.6532,-79.3832 +relay.letsfo.com,52.2633,21.0283 +relay.nostrzh.org,43.6532,-79.3832 +nostr.hekster.org,37.3986,-121.964 +relay.0xchat.com,1.35208,103.82 +nostr.czas.top,50.1109,8.68213 +nostr.superfriends.online,43.6532,-79.3832 +hsuite-nostr-relay.hbarsuite.workers.dev,43.6532,-79.3832 +relay.mccormick.cx,52.3563,4.95714 +nostr.88mph.life,51.5072,-0.127586 +strfry.ymir.cloud,34.0965,-117.585 +nostr.lkjsxc.com,43.6532,-79.3832 +yabu.me,35.6092,139.73 +relay.chorus.community,50.1109,8.68213 +nostr-02.yakihonne.com,1.32123,103.695 +neuromancer.nettek.io,39.1429,-94.573 +relay04.lnfi.network,39.0997,-94.5786 +nostr-relay.amethyst.name,39.0438,-77.4874 +nostrcheck.tnsor.network,43.6532,-79.3832 +wot.nostr.place,32.7767,-96.797 +cyberspace.nostr1.com,40.7057,-74.0136 +relay.guggero.org,47.3769,8.54169 +nostr.calitabby.net,39.9268,-75.0246 +x.kojira.io,43.6532,-79.3832 +nostr.agentcampfire.com,52.3676,4.90414 +relay.bullishbounty.com,43.6532,-79.3832 +relay.nostrverse.net,43.6532,-79.3832 +nostr.robosats.org,64.1476,-21.9392 +nostr-verified.wellorder.net,45.5201,-122.99 +nostr.huszonegy.world,47.4979,19.0402 +relay.cypherflow.ai,48.8566,2.35222 +nostr-relay.xbytez.io,50.6924,3.20113 +nostr.faultables.net,43.6532,-79.3832 +relay.libernet.app,43.6532,-79.3832 +relay.magiccity.live,25.8128,-80.2377 +relay.wellorder.net,45.5201,-122.99 +black.nostrcity.club,48.8575,2.35138 +relay.jeffg.fyi,43.6532,-79.3832 +freeben666.fr,43.7221,7.15296 +relay.nostr.wirednet.jp,34.706,135.493 +nostrelay.circum.space,52.3676,4.90414 +relay.islandbitcoin.com,12.8498,77.6545 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +relay.nostriot.com,41.5695,-83.9786 +relayrs.notoshi.win,43.6532,-79.3832 +nostr-pub.wellorder.net,45.5201,-122.99 +nostr.vulpem.com,49.4543,11.0746 +nostr.sathoarder.com,48.5734,7.75211 +wheat.happytavern.co,43.6532,-79.3832 +relay.nostr.net,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +nostr-02.czas.top,51.2277,6.77346 +vault.iris.to,43.6532,-79.3832 +ynostr.yael.at,60.1699,24.9384 +nostr.nodesmap.com,59.3327,18.0656 +nostr.n7ekb.net,47.4941,-122.294 +relayb.uid.ovh,43.6532,-79.3832 +shu05.shugur.net,48.8566,2.35222 +dev-relay.lnfi.network,39.0997,-94.5786 +relay.bitcoindistrict.org,43.6532,-79.3832 +nostr.spicyz.io,43.6532,-79.3832 +nostr.0x7e.xyz,47.4988,8.72369 +relay.dwadziesciajeden.pl,52.2297,21.0122 +relay.zone667.com,60.1699,24.9384 +nostr-dev.wellorder.net,45.5201,-122.99 +nos.xmark.cc,50.6924,3.20113 +relay.etch.social,41.2619,-95.8608 +nostr.na.social,43.6532,-79.3832 +relay.orangepill.ovh,49.1689,-0.358841 +relay.olas.app,50.4754,12.3683 +relay.holzeis.me,43.6532,-79.3832 +relay2.angor.io,48.1046,11.6002 +relay.degmods.com,50.4754,12.3683 +vitor.nostr1.com,40.7128,-74.006 +relay.btcforplebs.com,43.6532,-79.3832 +nostr.luisschwab.net,43.6532,-79.3832 +relay.moinsen.com,50.4754,12.3683 +czas.xyz,48.8566,2.35222 +nostr.bitcoiner.social,39.1585,-94.5728 +nostr.mehdibekhtaoui.com,49.4939,-1.54813 +inbox.azzamo.net,52.2633,21.0283 +nostr.ovia.to,43.6532,-79.3832 nostr.myshosholoza.co.za,52.3676,4.90414 +fenrir-s.notoshi.win,43.6532,-79.3832 +relay-rpi.edufeed.org,49.4521,11.0767 +chat-relay.zap-work.com,43.6532,-79.3832 +nostr.bond,50.1109,8.68213 +relay01.lnfi.network,39.0997,-94.5786 +relay.seq1.net,43.6532,-79.3832 +nostr.rikmeijer.nl,50.4754,12.3683 +offchain.pub,47.6743,-117.112 +nostr.spaceshell.xyz,43.6532,-79.3832 +nos4smartnkind.tech,40.1872,44.5152 +kotukonostr.onrender.com,37.7775,-122.397 +nostr.ps1829.com,33.8851,130.883 +relay.lightning.pub,39.0438,-77.4874 +nostr-relay.gateway.in.th,15.2634,100.344 +relay.thebluepulse.com,49.4521,11.0767 +relay.malxte.de,52.52,13.405 +relay.usefusion.ai,38.7134,-78.1591 +nostr-relay.cbrx.io,43.6532,-79.3832 +shu01.shugur.net,21.4902,39.2246 +nostr.jerrynya.fun,31.2304,121.474 +dev-nostr.bityacht.io,25.0797,121.234 +bitsat.molonlabe.holdings,51.4012,-1.3147 +relay.primal.net,43.6532,-79.3832 +relay.wavlake.com,41.2619,-95.8608 +nostr-relay.nextblockvending.com,47.2343,-119.853 +v-relay.d02.vrtmrz.net,34.6937,135.502 +nostr.mikoshi.de,47.74,12.0917 +nostr.coincards.com,53.5501,-113.469 +relay.snort.social,53.3498,-6.26031 +bitcoiner.social,39.1585,-94.5728 +nostr.noones.com,50.1109,8.68213 +wot.dergigi.com,64.1476,-21.9392 +relay.lumina.rocks,49.0291,8.35695 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nostr.snowbla.de,60.1699,24.9384 +strfry.felixzieger.de,50.1013,8.62643 +relay.satlantis.io,32.8769,-80.0114 +relay-dev.satlantis.io,40.8302,-74.1299 +nostr.davidebtc.me,51.5072,-0.127586 +relay.mattybs.lol,43.6532,-79.3832 +relay.fundstr.me,42.3601,-71.0589 +relay.tagayasu.xyz,43.6715,-79.38 +nostr-relay.zimage.com,34.0549,-118.243 +nostrcheck.me,43.6532,-79.3832 +bucket.coracle.social,37.7775,-122.397 +relay.siamdev.cc,13.9178,100.424 +r.bitcoinhold.net,43.6532,-79.3832 +skeme.vanderwarker.family,40.8218,-74.45 +articles.layer3.news,37.3387,-121.885 +no.str.cr,9.92857,-84.0528 +srtrelay.c-stellar.net,43.6532,-79.3832 +relay.comcomponent.com,34.7062,135.493 +relay.illuminodes.com,47.6061,-122.333 +nostr.openhoofd.nl,51.9229,4.40833 +nostr.plantroon.com,50.1013,8.62643 +slick.mjex.me,39.048,-77.4817 +relay.minibolt.info,43.6532,-79.3832 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +relay.nostrdice.com,-33.8688,151.209 +wot.dtonon.com,43.6532,-79.3832 +shu04.shugur.net,25.2604,55.2989 +nostr.tavux.tech,48.8575,2.35138 +nostr-02.uid.ovh,43.6532,-79.3832 +relay.nostrcheck.me,43.6532,-79.3832 +relay.nsnip.io,60.1699,24.9384 +relay.toastr.net,40.8054,-74.0241 +relay.divine.video,43.6532,-79.3832 +nostr.4rs.nl,49.0291,8.35696 +wot.sebastix.social,51.1792,5.89444 +relay.openfarmtools.org,60.1699,24.9384 +relay.damus.io,43.6532,-79.3832 +purpura.cloud,43.6532,-79.3832 +wot.sudocarlos.com,51.5072,-0.127586 +relay.arx-ccn.com,50.4754,12.3683 +nostr.zoracle.org,45.6018,-121.185 +nostr.tac.lol,47.4748,-122.273 +relay.coinos.io,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.davidebtc.me,51.5072,-0.127586 +ribo.us.nostria.app,41.5868,-93.625 +relay.upleb.uk,51.9194,19.1451 +librerelay.aaroniumii.com,43.6532,-79.3832 +relay.endfiat.money,43.6532,-79.3832 +relay.nostar.org,43.6532,-79.3832 +relay.electriclifestyle.com,26.2897,-80.1293 +relay.nostrhub.fr,48.1045,11.6004 +relay.agorist.space,52.3734,4.89406 +freelay.sovbit.host,64.1476,-21.9392 +nostr.hifish.org,47.4043,8.57398 +nostr-01.yakihonne.com,1.32123,103.695 +relay.routstr.com,43.6532,-79.3832 +nr.yay.so,46.2126,6.1154 +bcast.seutoba.com.br,43.6532,-79.3832 +fanfares.nostr1.com,40.7128,-74.006 +nostr.girino.org,43.6532,-79.3832 +nostr2.girino.org,43.6532,-79.3832 +relay.notoshi.win,13.311,101.112 +nostrja-kari.heguro.com,43.6532,-79.3832 +relay.mostro.network,40.8302,-74.1299 +satsage.xyz,37.3986,-121.964 +relay.evanverma.com,40.8302,-74.1299 +nostr-2.21crypto.ch,47.5356,8.73209 +relay.mitchelltribe.com,39.0438,-77.4874 +relaynostr.breadslice.com,43.6532,-79.3832 +nostr.chaima.info,51.223,6.78245 +nostr.mom,50.4754,12.3683 +relay.nostr-check.me,43.6532,-79.3832 +relay.ditto.pub,43.6532,-79.3832 From 903a4584a8f72988475a56bfcd604daf7aad99a0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:52:06 +0700 Subject: [PATCH 07/15] Prevent quit notification from reappearing (#530) * shutdown sequence * Prevent quit notification from reappearing * Restrict force-finish broadcast * Cancel quit shutdown on relaunch --- app/src/main/AndroidManifest.xml | 6 ++++ .../java/com/bitchat/android/MainActivity.kt | 35 +++++++++++++++++++ .../android/service/AppShutdownCoordinator.kt | 32 +++++++++++++++-- .../android/service/MeshForegroundService.kt | 19 ++++++++++ .../com/bitchat/android/util/AppConstants.kt | 2 ++ 5 files changed, 92 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b228fd34..f4ca2b89 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,8 @@ + + @@ -46,6 +48,10 @@ + + = 33) { + registerReceiver( + forceFinishReceiver, + filter, + com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH, + null, + android.content.Context.RECEIVER_NOT_EXPORTED + ) + } else { + @Suppress("DEPRECATION") + registerReceiver( + forceFinishReceiver, + filter, + com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH, + null + ) + } + // Check if this is a quit request from the notification if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity") finish() return } + + com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() // Enable edge-to-edge display for modern Android look enableEdgeToEdge() @@ -646,6 +677,8 @@ class MainActivity : OrientationAwareActivity() { finish() return } + + com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() // Handle notification intents when app is already running if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { @@ -759,6 +792,8 @@ class MainActivity : OrientationAwareActivity() { override fun onDestroy() { super.onDestroy() + try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { } + // Cleanup location status manager try { locationStatusManager.cleanup() diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt index d4e00104..1c3abaf9 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -9,10 +9,13 @@ import com.bitchat.android.net.TorMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.atomic.AtomicLong /** * Coordinates a full application shutdown: @@ -24,6 +27,15 @@ import kotlinx.coroutines.withTimeoutOrNull */ object AppShutdownCoordinator { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val shutdownToken = AtomicLong(0L) + @Volatile + private var shutdownJob: Job? = null + + fun cancelPendingShutdown() { + shutdownToken.incrementAndGet() + shutdownJob?.cancel() + shutdownJob = null + } fun requestFullShutdownAndKill( app: Application, @@ -32,7 +44,16 @@ object AppShutdownCoordinator { stopForeground: () -> Unit, stopService: () -> Unit ) { - scope.launch { + val token = shutdownToken.incrementAndGet() + shutdownJob?.cancel() + val job = scope.launch { + // Signal UI to finish gracefully before we kill the process + try { + val intent = android.content.Intent(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) + .setPackage(app.packageName) + app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH) + } catch (_: Exception) { } + // Stop mesh (best-effort) try { mesh?.stopServices() } catch (_: Exception) { } @@ -56,12 +77,19 @@ object AppShutdownCoordinator { } // Stop the service itself + if (!isActive || shutdownToken.get() != token) return@launch try { stopService() } catch (_: Exception) { } // Hard kill the app process + if (!isActive || shutdownToken.get() != token) return@launch try { Process.killProcess(Process.myPid()) } catch (_: Exception) { } try { System.exit(0) } catch (_: Exception) { } } + shutdownJob = job + job.invokeOnCompletion { + if (shutdownJob === job) { + shutdownJob = null + } + } } } - diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index 7510fa06..e92274f8 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -115,6 +115,7 @@ class MeshForegroundService : Service() { private val serviceJob = Job() private val scope = CoroutineScope(Dispatchers.Default + serviceJob) private var isInForeground: Boolean = false + private var isShuttingDown: Boolean = false override fun onCreate() { super.onCreate() @@ -133,6 +134,13 @@ class MeshForegroundService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (isShuttingDown && intent?.action == ACTION_START) { + AppShutdownCoordinator.cancelPendingShutdown() + isShuttingDown = false + } + if (isShuttingDown && intent?.action != ACTION_QUIT) { + return START_NOT_STICKY + } when (intent?.action) { ACTION_STOP -> { // Stop FGS and mesh cleanly @@ -145,6 +153,12 @@ class MeshForegroundService : Service() { return START_NOT_STICKY } ACTION_QUIT -> { + isShuttingDown = true + updateJob?.cancel() + updateJob = null + try { stopForeground(true) } catch (_: Exception) { } + notificationManager.cancel(NOTIFICATION_ID) + isInForeground = false // Fully stop all background activity, stop Tor (without changing setting), then kill the app AppShutdownCoordinator.requestFullShutdownAndKill( app = application, @@ -208,6 +222,7 @@ class MeshForegroundService : Service() { } private fun ensureMeshStarted() { + if (isShuttingDown) return if (!hasBluetoothPermissions()) return try { android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") @@ -218,6 +233,10 @@ class MeshForegroundService : Service() { } private fun updateNotification(force: Boolean) { + if (isShuttingDown) { + notificationManager.cancel(NOTIFICATION_ID) + return + } val count = meshService?.getActivePeerCount() ?: 0 val notification = buildNotification(count) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 9881cc2f..ca031df9 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -115,6 +115,8 @@ object AppConstants { const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L + const val ACTION_FORCE_FINISH: String = "com.bitchat.android.ACTION_FORCE_FINISH" + const val PERMISSION_FORCE_FINISH: String = "com.bitchat.android.permission.FORCE_FINISH" } object Media { From 37c764b55a50e9dded1ae3befbd914876dbffa4c Mon Sep 17 00:00:00 2001 From: aidenvalue <> Date: Sat, 3 Jan 2026 11:55:13 +0100 Subject: [PATCH 08/15] fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork --- .../wifi-aware/WifiAwareMeshServiceLatest.kt | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshServiceLatest.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshServiceLatest.kt index d049ba96..11a5ea05 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshServiceLatest.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshServiceLatest.kt @@ -536,8 +536,6 @@ class WifiAwareMeshService(private val context: Context) { serverSockets.clear() peerSockets.clear() - cm.bindProcessToNetwork(null) - peerManager.shutdown() fragmentManager.shutdown() securityManager.shutdown() @@ -597,10 +595,9 @@ class WifiAwareMeshService(private val context: Context) { val cb = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - cm.bindProcessToNetwork(network) try { val client = ss.accept().apply { keepAlive = true } - Log.d(TAG, "SERVER: accepted TCP from ${peerId.take(8)}… addr=${client.inetAddress?.hostAddress}") + 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) {} @@ -618,9 +615,8 @@ class WifiAwareMeshService(private val context: Context) { } } override fun onLost(network: Network) { - cm.bindProcessToNetwork(null) networkCallbacks.remove(peerId) - Log.d(TAG, "SERVER: network lost for ${peerId.take(8)}…") + Log.d(TAG, "SERVER: network lost for \${peerId.take(8)}…") } } @@ -713,14 +709,25 @@ class WifiAwareMeshService(private val context: Context) { val info = (nc.transportInfo as? WifiAwareNetworkInfo) ?: return val addr = info.peerIpv6Addr as Inet6Address + val lp = cm.getLinkProperties(network) + val iface = lp?.interfaceName + try { - // Some devices deny bindSocket() (EPERM). Rely on scoped IPv6 to route correctly. val sock = Socket() + network.bindSocket(sock) sock.tcpNoDelay = true sock.keepAlive = true - sock.connect(java.net.InetSocketAddress(addr, port), 7000) - Log.d(TAG, "CLIENT: TCP connected to ${peerId.take(8)}… addr=$addr:$port") + // Use scoped IPv6 if interface name is available + val scopedAddr = if (iface != null && addr.scopeId == 0) { + Inet6Address.getByAddress(null, addr.address, java.net.NetworkInterface.getByName(iface)) + } 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) {} From 64f73df362443262fbc3f59089f92414ff35d3cc Mon Sep 17 00:00:00 2001 From: aidenvalue <> Date: Sat, 3 Jan 2026 12:44:43 +0100 Subject: [PATCH 09/15] Merge branch 'upstream/main' into fix/wifi-aware-socket-binding --- ...MeshServiceLatest.kt => WifiAwareMeshService.kt} | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) rename app/src/main/java/com/bitchat/android/wifi-aware/{WifiAwareMeshServiceLatest.kt => WifiAwareMeshService.kt} (98%) diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshServiceLatest.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt similarity index 98% rename from app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshServiceLatest.kt rename to app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 11a5ea05..9f68d9f1 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshServiceLatest.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -590,13 +590,16 @@ class WifiAwareMeshService(private val context: Context) { val req = NetworkRequest.Builder() .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) .setNetworkSpecifier(spec) .build() val cb = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { try { - val client = ss.accept().apply { keepAlive = true } + 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) {} @@ -697,6 +700,7 @@ class WifiAwareMeshService(private val context: Context) { .build() val req = NetworkRequest.Builder() .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) .setNetworkSpecifier(spec) .build() @@ -715,6 +719,13 @@ class WifiAwareMeshService(private val context: Context) { try { val sock = Socket() network.bindSocket(sock) + + 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 From a5119458386e6d963b3ca17be384b58765c9693a Mon Sep 17 00:00:00 2001 From: aidenvalue <> Date: Sat, 3 Jan 2026 14:22:40 +0100 Subject: [PATCH 10/15] Fix Wi-Fi Aware connectivity and UI integration post-merge - Replace bindProcessToNetwork with bindSocket for VPN compatibility. - Implement Scoped IPv6 address resolution (aware0) for mesh routing. - Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility. - Fix syntax errors and variable name conflicts in Debug UI. --- .../java/com/bitchat/android/MainActivity.kt | 7 +++ .../android/ui/debug/DebugSettingsManager.kt | 3 -- .../android/ui/debug/DebugSettingsSheet.kt | 4 +- .../wifi-aware/WifiAwareMeshService.kt | 51 +++++++++---------- 4 files changed, 33 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 28d0d163..1f0781ae 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -170,6 +170,13 @@ class MainActivity : OrientationAwareActivity() { 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) { 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 5a70ecb6..669331e6 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,9 +36,6 @@ class DebugSettingsManager private constructor() { private val _packetRelayEnabled = MutableStateFlow(true) val packetRelayEnabled: StateFlow = _packetRelayEnabled.asStateFlow() - private val _packetRelayEnabled = MutableStateFlow(true) - val packetRelayEnabled: StateFlow = _packetRelayEnabled.asStateFlow() - // Master transport toggles private val _bleEnabled = MutableStateFlow(true) val bleEnabled: StateFlow = _bleEnabled.asStateFlow() 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 9587fd41..94781b31 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 @@ -52,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() @@ -280,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 diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 9f68d9f1..5e3c3c1c 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -619,7 +619,7 @@ class WifiAwareMeshService(private val context: Context) { } override fun onLost(network: Network) { networkCallbacks.remove(peerId) - Log.d(TAG, "SERVER: network lost for \${peerId.take(8)}…") + Log.d(TAG, "SERVER: network lost for ${peerId.take(8)}…") } } @@ -656,11 +656,13 @@ class WifiAwareMeshService(private val context: Context) { ) { // TCP keep-alive pings serviceScope.launch { - val os = client.getOutputStream() - while (peerSockets.containsKey(peerId)) { - try { os.write(0) } catch (_: IOException) { break } - delay(2_000) - } + 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 { @@ -711,14 +713,7 @@ class WifiAwareMeshService(private val context: Context) { 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 - - val lp = cm.getLinkProperties(network) - val iface = lp?.interfaceName - - try { - val sock = Socket() - network.bindSocket(sock) + val addr = info.peerIpv6Addr as? Inet6Address ?: return val lp = cm.getLinkProperties(network) val iface = lp?.interfaceName @@ -731,13 +726,17 @@ class WifiAwareMeshService(private val context: Context) { // Use scoped IPv6 if interface name is available val scopedAddr = if (iface != null && addr.scopeId == 0) { - Inet6Address.getByAddress(null, addr.address, java.net.NetworkInterface.getByName(iface)) + 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)") + 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) {} @@ -776,11 +775,13 @@ class WifiAwareMeshService(private val context: Context) { ) { // TCP keep-alive serviceScope.launch { - val os = sock.getOutputStream() - while (peerSockets.containsKey(peerId)) { - try { os.write(0) } catch (_: IOException) { break } - delay(2_000) - } + 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 { @@ -1047,9 +1048,7 @@ class WifiAwareMeshService(private val context: Context) { senderID = myPeerID, payload = tlvPayload ) - val signed = encryptionService.signData(announcePacket.toBinaryDataForSigning()!!)?.let { - announcePacket.copy(signature = it) - } ?: announcePacket + val signed = signPacketBeforeBroadcast(announcePacket) broadcastPacket(RoutedPacket(signed)) try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } @@ -1074,9 +1073,7 @@ class WifiAwareMeshService(private val context: Context) { senderID = myPeerID, payload = tlvPayload ) - val signed = encryptionService.signData(packet.toBinaryDataForSigning()!!)?.let { - packet.copy(signature = it) - } ?: packet + val signed = signPacketBeforeBroadcast(packet) broadcastPacket(RoutedPacket(signed)) peerManager.markPeerAsAnnouncedTo(peerID) From ae87530afb09de4804bf9997068b0e86e871ce50 Mon Sep 17 00:00:00 2001 From: aidenvalue <> Date: Sat, 3 Jan 2026 14:54:24 +0100 Subject: [PATCH 11/15] Enhance Wi-Fi Aware robustness and debug UI display - Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection. - Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI. - Fix Map type mismatch warning in ChatViewModel bridge. - Filter self-ID from peer cleanup tables to prevent recursive self-removal. --- .../com/bitchat/android/ui/ChatViewModel.kt | 2 +- .../wifi-aware/WifiAwareMeshService.kt | 58 +++++++++++++++---- 2 files changed, 47 insertions(+), 13 deletions(-) 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 d8e197e9..b516eb26 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -654,7 +654,7 @@ class ChatViewModel( // Merge nicknames from BLE and Wi‑Fi Aware to display names for all peers val bleNick = meshService.getPeerNicknames() - val awareNick = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknames() } catch (_: Exception) { null } + val awareNick = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknamesMap() } catch (_: Exception) { null } val mergedNick = if (awareNick != null) bleNick + awareNick.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick state.setPeerNicknames(mergedNick) diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 5e3c3c1c..b507a3e9 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -826,24 +826,30 @@ class WifiAwareMeshService(private val context: Context) { } 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)) } - + + // Loop break -> disconnection + handlePeerDisconnection(initialLogicalPeerId, routedPeerId) socket.closeQuietly() - routedPeerId?.let { - peerSockets.remove(it) - peerManager.removePeer(it) + } + + private fun handlePeerDisconnection(initialId: String, routedId: String?) { + serviceScope.launch { + Log.w(TAG, "Cleaning up peer: $initialId / $routedId") + val possible = setOfNotNull(initialId, routedId).filter { it.isNotEmpty() && it != myPeerID } + possible.forEach { id -> + 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 @@ -1160,15 +1166,43 @@ class WifiAwareMeshService(private val context: Context) { /** * @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]?.inetAddress?.hostAddress + 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 = - peerSockets.mapValues { it.value.inetAddress.hostAddress } + 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 = From ddba8412803c84e9e35a68fd75ec455a361a2cc5 Mon Sep 17 00:00:00 2001 From: aidenvalue <> Date: Sat, 3 Jan 2026 15:03:30 +0100 Subject: [PATCH 12/15] Share GossipSyncManager across transports to prevent redundant message synchronization - Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder. - Modified WifiAwareMeshService to use the shared GossipSyncManager if available. - Added background cleanup for peer mappings on socket disconnection. - Fixed Kotlin type mismatch during nickname map merging. --- .../android/mesh/BluetoothMeshService.kt | 3 ++ .../android/service/MeshServiceHolder.kt | 6 +++ .../com/bitchat/android/ui/ChatViewModel.kt | 4 +- .../wifi-aware/WifiAwareMeshService.kt | 54 +++++++++++-------- 4 files changed, 44 insertions(+), 23 deletions(-) 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 6e31932a..c48ce087 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 { diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt index d271ab29..71dddb66 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/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index b516eb26..22345923 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -654,8 +654,8 @@ class ChatViewModel( // Merge nicknames from BLE and Wi‑Fi Aware to display names for all peers val bleNick = meshService.getPeerNicknames() - val awareNick = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknamesMap() } catch (_: Exception) { null } - val mergedNick = if (awareNick != null) bleNick + awareNick.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick + 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() diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index b507a3e9..8a07364a 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -106,24 +106,30 @@ class WifiAwareMeshService(private val context: Context) { setupDelegates() messageHandler.packetProcessor = packetProcessor - gossipSyncManager = GossipSyncManager( - myPeerID = myPeerID, - scope = serviceScope, - configProvider = object : GossipSyncManager.ConfigProvider { - override fun seenCapacity(): Int = 500 - override fun gcsMaxBytes(): Int = 400 - override fun gcsTargetFpr(): Double = 0.01 - } - ) - gossipSyncManager.delegate = object : GossipSyncManager.Delegate { - override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { - this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) - } - override fun sendPacket(packet: BitchatPacket) { - broadcastPacket(RoutedPacket(packet)) - } - override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { - return signPacketBeforeBroadcast(packet) + // 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) + } } } } @@ -619,7 +625,7 @@ class WifiAwareMeshService(private val context: Context) { } override fun onLost(network: Network) { networkCallbacks.remove(peerId) - Log.d(TAG, "SERVER: network lost for ${peerId.take(8)}…") + Log.d(TAG, "SERVER: network lost for \${peerId.take(8)}…") } } @@ -826,15 +832,21 @@ class WifiAwareMeshService(private val context: Context) { } 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)) } - - // Loop break -> disconnection + handlePeerDisconnection(initialLogicalPeerId, routedPeerId) socket.closeQuietly() } + /** + * Internal helper to ensure peer is cleaned up from all managers when a socket dies. + */ private fun handlePeerDisconnection(initialId: String, routedId: String?) { serviceScope.launch { Log.w(TAG, "Cleaning up peer: $initialId / $routedId") From 2bd8f84e6a2decce9648da451dea2b5ec265ff09 Mon Sep 17 00:00:00 2001 From: aidenvalue <> Date: Sat, 3 Jan 2026 15:24:59 +0100 Subject: [PATCH 13/15] Restore VPN acquisition logic and improve peer cleanup - Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active. - Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs. - Switch cleanup logging to debug level to reduce log noise. --- .../wifi-aware/WifiAwareMeshService.kt | 96 +++++++++++-------- 1 file changed, 54 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 8a07364a..f895a7cb 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -168,10 +168,10 @@ class WifiAwareMeshService(private val context: Context) { sock.getOutputStream().write(bytes) sent++ } catch (e: IOException) { - Log.e(TAG, "TX: write failed to ${pid.take(8)}…: ${e.message}") + 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})") + Log.i(TAG, "TX: broadcast via Wi-Fi Aware to $sent peers (bytes=${bytes.size})") } /** @@ -179,12 +179,12 @@ class WifiAwareMeshService(private val context: Context) { */ 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 + // 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 + // Expose a public method so BLE can forward relays to Wi-Fi Aware fun broadcastRoutedPacket(routed: RoutedPacket) { broadcastPacket(routed) } @@ -193,19 +193,19 @@ class WifiAwareMeshService(private val context: Context) { * Send packet to connected peer. */ private fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { - // Wi‑Fi Aware uses full packets; no fragmentation + // 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)}…") + 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} → ${peerID.take(8)}… (bytes=${data.size})") + 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}") + Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}") } } } @@ -415,7 +415,7 @@ class WifiAwareMeshService(private val context: Context) { fun startServices() { if (isActive) return isActive = true - Log.i(TAG, "Starting Wi‑Fi Aware mesh with peer ID: $myPeerID") + Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID") awareManager?.attach(object : AttachCallback() { @SuppressLint("MissingPermission") @@ -425,7 +425,7 @@ class WifiAwareMeshService(private val context: Context) { ]) override fun onAttached(session: WifiAwareSession) { wifiAwareSession = session - Log.i(TAG, "Wi‑Fi Aware attached; starting publish & subscribe (peerID=$myPeerID)") + Log.i(TAG, "Wi-Fi Aware attached; starting publish & subscribe (peerID=$myPeerID)") // PUBLISH (server role) session.publish( @@ -446,7 +446,7 @@ class WifiAwareMeshService(private val context: Context) { 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}") + Log.d(TAG, "PUBLISH: onServiceDiscovered ssi='${peerId.take(16)}' len=${serviceSpecificInfo.size}") } @RequiresApi(Build.VERSION_CODES.Q) @@ -487,7 +487,7 @@ class WifiAwareMeshService(private val context: Context) { 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)") + Log.d(TAG, "SUBSCRIBE: sent ping to '${peerId.take(16)}' (msgId=$msgId)") } @RequiresApi(Build.VERSION_CODES.Q) @@ -499,7 +499,7 @@ class WifiAwareMeshService(private val context: Context) { val peerId = handleToPeerId[peerHandle] ?: return if (peerId == myPeerID) return - Log.d(TAG, "SUBSCRIBE: onMessageReceived() → server-ready from ${peerId.take(8)}… payload=${message.size}B") + Log.d(TAG, "SUBSCRIBE: onMessageReceived() → server-ready from ${peerId.take(8)} payload=${message.size}B") handleServerReady(peerHandle, message) } }, @@ -587,16 +587,22 @@ class WifiAwareMeshService(private val context: Context) { val ss = ServerSocket(0) serverSockets[peerId] = ss val port = ss.localPort - Log.d(TAG, "SERVER: listening for ${peerId.take(8)}… on port $port") + + // 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) - .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) .setNetworkSpecifier(spec) .build() @@ -604,9 +610,9 @@ class WifiAwareMeshService(private val context: Context) { 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}") } + 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}") + 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) {} @@ -615,22 +621,22 @@ class WifiAwareMeshService(private val context: Context) { // 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)") + 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) + 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)}…") + Log.d(TAG, "SERVER: network lost for ${peerId.take(8)}") } } networkCallbacks[peerId] = cb - Log.d(TAG, "SERVER: requesting Aware network for ${peerId.take(8)}…") + Log.d(TAG, "SERVER: requesting Aware network for ${peerId.take(8)}") cm.requestNetwork(req, cb) val readyId = (System.nanoTime() and 0x7fffffff).toInt() @@ -701,14 +707,13 @@ class WifiAwareMeshService(private val context: Context) { } val port = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN).int - Log.d(TAG, "CLIENT: connecting to ${peerId.take(8)}… port=$port") + 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) - .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) .setNetworkSpecifier(spec) .build() @@ -726,7 +731,7 @@ class WifiAwareMeshService(private val context: Context) { try { val sock = Socket() - try { network.bindSocket(sock) } catch (e: Exception) { Log.w(TAG, "Client bindSocket EPERM: \${e.message}") } + try { network.bindSocket(sock) } catch (e: Exception) { Log.w(TAG, "Client bindSocket EPERM: ${e.message}") } sock.tcpNoDelay = true sock.keepAlive = true @@ -742,7 +747,7 @@ class WifiAwareMeshService(private val context: Context) { } sock.connect(java.net.InetSocketAddress(scopedAddr, port), 7000) - Log.d(TAG, "CLIENT: TCP connected to ${peerId.take(8)}… addr=$scopedAddr:$port (iface=$iface)") + 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) {} @@ -752,22 +757,22 @@ class WifiAwareMeshService(private val context: Context) { // 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)") + 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) + 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)}…") + Log.d(TAG, "CLIENT: network lost for ${peerId.take(8)}") } } networkCallbacks[peerId] = cb - Log.d(TAG, "CLIENT: requesting Aware network for ${peerId.take(8)}…") + Log.d(TAG, "CLIENT: requesting Aware network for ${peerId.take(8)}") cm.requestNetwork(req, cb) } @@ -836,26 +841,33 @@ class WifiAwareMeshService(private val context: Context) { peerSockets[routedPeerId] = socket } - Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)}… (bytes=${raw.size})") + 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() } - /** - * Internal helper to ensure peer is cleaned up from all managers when a socket dies. - */ private fun handlePeerDisconnection(initialId: String, routedId: String?) { serviceScope.launch { - Log.w(TAG, "Cleaning up peer: $initialId / $routedId") - val possible = setOfNotNull(initialId, routedId).filter { it.isNotEmpty() && it != myPeerID } - possible.forEach { id -> - peerSockets.remove(id)?.closeQuietly() - serverSockets.remove(id)?.closeQuietly() - networkCallbacks.remove(id)?.let { runCatching { cm.unregisterNetworkCallback(it) } } - peerManager.removePeer(id) + 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) + } } } } From 0c7505b588b38b14f5b674ef6ec4d286cdb0f5c4 Mon Sep 17 00:00:00 2001 From: aidenvalue Date: Sat, 3 Jan 2026 17:31:20 +0100 Subject: [PATCH 14/15] fix: wifi aware socket binding (#533) * wifi aware wip * wifi aware wip * starting to work * werk * dms work * wip * fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork * Merge branch 'upstream/main' into fix/wifi-aware-socket-binding * Fix Wi-Fi Aware connectivity and UI integration post-merge - Replace bindProcessToNetwork with bindSocket for VPN compatibility. - Implement Scoped IPv6 address resolution (aware0) for mesh routing. - Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility. - Fix syntax errors and variable name conflicts in Debug UI. * Enhance Wi-Fi Aware robustness and debug UI display - Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection. - Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI. - Fix Map type mismatch warning in ChatViewModel bridge. - Filter self-ID from peer cleanup tables to prevent recursive self-removal. * Share GossipSyncManager across transports to prevent redundant message synchronization - Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder. - Modified WifiAwareMeshService to use the shared GossipSyncManager if available. - Added background cleanup for peer mappings on socket disconnection. - Fixed Kotlin type mismatch during nickname map merging. * Restore VPN acquisition logic and improve peer cleanup - Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active. - Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs. - Switch cleanup logging to debug level to reduce log noise. --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> Co-authored-by: aidenvalue <> --- app/src/main/AndroidManifest.xml | 9 + .../com/bitchat/android/BitchatApplication.kt | 5 + .../java/com/bitchat/android/MainActivity.kt | 57 +- .../android/mesh/BluetoothMeshService.kt | 6 +- .../onboarding/OnboardingCoordinator.kt | 1 + .../onboarding/PermissionExplanationScreen.kt | 2 + .../android/onboarding/PermissionManager.kt | 20 + .../android/service/MeshServiceHolder.kt | 6 + .../bitchat/android/services/MessageRouter.kt | 17 +- .../com/bitchat/android/ui/ChatViewModel.kt | 24 +- .../bitchat/android/ui/MediaSendingManager.kt | 2 + .../bitchat/android/ui/MeshDelegateHandler.kt | 5 +- .../bitchat/android/ui/SidebarComponents.kt | 21 +- .../ui/debug/DebugPreferenceManager.kt | 27 +- .../android/ui/debug/DebugSettingsManager.kt | 46 + .../android/ui/debug/DebugSettingsSheet.kt | 98 +- .../android/wifi-aware/WifiAwareController.kt | 116 ++ .../wifi-aware/WifiAwareMeshService.kt | 1277 +++++++++++++++++ 18 files changed, 1722 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4ca2b89..e79fa8eb 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 3de48407..c48ce087 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 871cc892..ba4da701 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 2b84aefa..c00f35f3 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* @@ -242,6 +243,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector { PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn PermissionType.MICROPHONE -> Icons.Filled.Mic PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications + PermissionType.WIFI_AWARE -> Icons.Filled.Wifi PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power PermissionType.OTHER -> Icons.Filled.Settings } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index ff0a160f..aedf85f8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -67,6 +67,11 @@ class PermissionManager(private val context: Context) { Manifest.permission.ACCESS_FINE_LOCATION )) + // Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES) + } + // Notification permission intentionally excluded to keep it optional return permissions @@ -177,6 +182,20 @@ class PermissionManager(private val context: Context) { ) ) + // Wi‑Fi Aware category (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES) + categories.add( + PermissionCategory( + type = PermissionType.WIFI_AWARE, + description = "Enable Wi‑Fi Aware to discover and connect to nearby bitchat users over Wi‑Fi.", + permissions = wifiAwarePermissions, + isGranted = wifiAwarePermissions.all { isPermissionGranted(it) }, + systemDescription = "Allow bitchat to discover nearby Wi‑Fi devices" + ) + ) + } + // Notifications category (if applicable) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { categories.add( @@ -262,6 +281,7 @@ enum class PermissionType(val nameValue: String) { PRECISE_LOCATION("Precise Location"), MICROPHONE("Microphone"), NOTIFICATIONS("Notifications"), + WIFI_AWARE("Wi‑Fi Aware"), BATTERY_OPTIMIZATION("Battery Optimization"), OTHER("Other") } diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt index d271ab29..71dddb66 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 8166487e..53db2fa6 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 1196396d..22345923 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 a3def523..fc134a1b 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 a452616e..6e7d5d22 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 4ab4360a..c504b9f9 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 04ad48a2..2d734c14 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt @@ -20,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 77f6ce12..669331e6 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 4b9f983a..94781b31 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 00000000..d10c6741 --- /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 00000000..f895a7cb --- /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 +} From fa1978d587ff38c4cdd3ba9d30cfa33e6294fb79 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 3 Jan 2026 23:37:49 +0700 Subject: [PATCH 15/15] Revert "fix: wifi aware socket binding (#533)" (#535) This reverts commit 0c7505b588b38b14f5b674ef6ec4d286cdb0f5c4. --- app/src/main/AndroidManifest.xml | 9 - .../com/bitchat/android/BitchatApplication.kt | 5 - .../java/com/bitchat/android/MainActivity.kt | 57 +- .../android/mesh/BluetoothMeshService.kt | 6 +- .../onboarding/OnboardingCoordinator.kt | 1 - .../onboarding/PermissionExplanationScreen.kt | 2 - .../android/onboarding/PermissionManager.kt | 20 - .../android/service/MeshServiceHolder.kt | 6 - .../bitchat/android/services/MessageRouter.kt | 17 +- .../com/bitchat/android/ui/ChatViewModel.kt | 24 +- .../bitchat/android/ui/MediaSendingManager.kt | 2 - .../bitchat/android/ui/MeshDelegateHandler.kt | 5 +- .../bitchat/android/ui/SidebarComponents.kt | 21 +- .../ui/debug/DebugPreferenceManager.kt | 27 +- .../android/ui/debug/DebugSettingsManager.kt | 46 - .../android/ui/debug/DebugSettingsSheet.kt | 98 +- .../android/wifi-aware/WifiAwareController.kt | 116 -- .../wifi-aware/WifiAwareMeshService.kt | 1277 ----------------- 18 files changed, 17 insertions(+), 1722 deletions(-) delete mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt delete mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e79fa8eb..f4ca2b89 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,7 +5,6 @@ - @@ -20,12 +19,6 @@ - - - - - - @@ -54,8 +47,6 @@ - - - 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 @@ -395,12 +347,6 @@ 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 @@ -567,9 +513,8 @@ class MainActivity : OrientationAwareActivity() { else -> BatteryOptimizationStatus.ENABLED } - val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } when { - bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> { + 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 c48ce087..3de48407 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -94,9 +94,6 @@ 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 { @@ -271,8 +268,7 @@ class BluetoothMeshService(private val context: Context) { override fun sendPacket(packet: BitchatPacket) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - val routed = RoutedPacket(signedPacket) - connectionManager.broadcastPacket(routed) + connectionManager.broadcastPacket(RoutedPacket(signedPacket)) } 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 ba4da701..871cc892 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt @@ -209,7 +209,6 @@ 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 c00f35f3..2b84aefa 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -11,7 +11,6 @@ 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.* @@ -243,7 +242,6 @@ 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 aedf85f8..ff0a160f 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -67,11 +67,6 @@ 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 @@ -182,20 +177,6 @@ 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( @@ -281,7 +262,6 @@ 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 71dddb66..d271ab29 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -9,12 +9,6 @@ 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 53db2fa6..8166487e 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -72,15 +72,9 @@ 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) @@ -89,21 +83,14 @@ class MessageRouter private constructor( val q = outbox.getOrPut(toPeerID) { mutableListOf() } q.add(Triple(content, recipientNickname, messageID)) Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…") - if (hasMesh) mesh.initiateNoiseHandshake(toPeerID) else aware?.initiateNoiseHandshake(toPeerID) + mesh.initiateNoiseHandshake(toPeerID) } } fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) { - 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) { + if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)) { Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID) - } else if (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 22345923..1196396d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -441,9 +441,8 @@ class ChatViewModel( state.getNicknameValue() ) } else { - // Default: route via mesh + Wi‑Fi Aware + // Default: route via mesh meshService.sendMessage(messageContent, mentions, channel) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(messageContent, mentions, channel) } catch (_: Exception) {} } }) return @@ -513,23 +512,19 @@ class ChatViewModel( state.getNicknameValue(), meshService.myPeerID, onEncryptedPayload = { encryptedData -> - // Send encrypted payload announcement over both transports for reachability + // This would need proper mesh service integration 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) {} } } } @@ -652,23 +647,16 @@ class ChatViewModel( val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) - // 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 nicknames = meshService.getPeerNicknames() + state.setPeerNicknames(nicknames) val rssiValues = meshService.getPeerRSSI() - val awareRssi = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerRSSI() } catch (_: Exception) { null } - val mergedRssi = if (awareRssi != null) rssiValues + awareRssi.filterKeys { it !in rssiValues } else rssiValues - state.setPeerRSSI(mergedRssi) + state.setPeerRSSI(rssiValues) // Update directness per peer (driven by PeerManager state) try { val directMap = state.getConnectedPeersValue().associateWith { pid -> - val ble = meshService.getPeerInfo(pid)?.isDirectConnection == true - val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerInfo(pid)?.isDirectConnection == true } catch (_: Exception) { false } - ble || aware + meshService.getPeerInfo(pid)?.isDirectConnection == true } 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 fc134a1b..a3def523 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -210,7 +210,6 @@ class MediaSendingManager( Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID") meshService.sendFilePrivate(toPeerID, filePacket) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFilePrivate(toPeerID, filePacket) } catch (_: Exception) {} Log.d(TAG, "✅ File send completed successfully") } @@ -265,7 +264,6 @@ class MediaSendingManager( Log.d(TAG, "📤 Calling meshService.sendFileBroadcast") meshService.sendFileBroadcast(filePacket) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFileBroadcast(filePacket) } catch (_: Exception) {} Log.d(TAG, "✅ File broadcast completed successfully") } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 6e7d5d22..a452616e 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -96,10 +96,7 @@ class MeshDelegateHandler( override fun didUpdatePeerList(peers: List) { coroutineScope.launch { - // Merge peers from multiple transports to avoid flapping - val current = state.getConnectedPeersValue().toMutableSet() - current.addAll(peers) - state.setConnectedPeers(current.toList()) + state.setConnectedPeers(peers) 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 c504b9f9..4ab4360a 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -576,26 +576,9 @@ 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 = icon, - contentDescription = cd, + imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route, + contentDescription = if (isDirect) "Direct Bluetooth" else "Routed", 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 2d734c14..04ad48a2 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,10 +20,7 @@ object DebugPreferenceManager { // GCS keys (no migration/back-compat) private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes" private const val KEY_GCS_FPR = "gcs_filter_fpr_percent" - // Transport master toggles - private const val KEY_BLE_ENABLED = "ble_enabled" - private const val KEY_WIFI_AWARE_ENABLED = "wifi_aware_enabled" - private const val KEY_WIFI_AWARE_VERBOSE = "wifi_aware_verbose" + // Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled private lateinit var prefs: SharedPreferences @@ -105,25 +102,5 @@ object DebugPreferenceManager { if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() } - // Transport toggles - fun getBleEnabled(default: Boolean = true): Boolean = - if (ready()) prefs.getBoolean(KEY_BLE_ENABLED, default) else default - - fun setBleEnabled(value: Boolean) { - if (ready()) prefs.edit().putBoolean(KEY_BLE_ENABLED, value).apply() - } - - fun getWifiAwareEnabled(default: Boolean = false): Boolean = - if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_ENABLED, default) else default - - fun setWifiAwareEnabled(value: Boolean) { - if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_ENABLED, value).apply() - } - - fun getWifiAwareVerbose(default: Boolean = false): Boolean = - if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_VERBOSE, default) else default - - fun setWifiAwareVerbose(value: Boolean) { - if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_VERBOSE, value).apply() - } + // No longer storing persistent notification in debug prefs. } 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 669331e6..77f6ce12 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,17 +36,6 @@ 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() @@ -70,10 +59,6 @@ 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. } @@ -277,27 +262,6 @@ 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) @@ -355,16 +319,6 @@ 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 94781b31..4b9f983a 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,8 +10,6 @@ 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 @@ -52,7 +50,7 @@ fun DebugSettingsSheet( val verboseLogging by manager.verboseLoggingEnabled.collectAsState() val gattServerEnabled by manager.gattServerEnabled.collectAsState() val gattClientEnabled by manager.gattClientEnabled.collectAsState() - val packetRelayed by manager.packetRelayEnabled.collectAsState() + val packetRelayEnabled by manager.packetRelayEnabled.collectAsState() val maxOverall by manager.maxConnectionsOverall.collectAsState() val maxServer by manager.maxServerConnections.collectAsState() val maxClient by manager.maxClientConnections.collectAsState() @@ -64,12 +62,6 @@ 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 @@ -94,15 +86,6 @@ 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) } } @@ -230,46 +213,6 @@ 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)) { @@ -280,7 +223,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 = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) }) + Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) }) } // Removed aggregate labels; we will show per-direction compact labels below titles // Toggle: overall vs per-connection vs per-peer @@ -529,43 +472,6 @@ 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 deleted file mode 100644 index d10c6741..00000000 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index f895a7cb..00000000 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ /dev/null @@ -1,1277 +0,0 @@ -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 -}