Refactors cleanup (#372)

* cleanup peermanager

* cleanup geohash code

* direct connections fix

* pow display

* track disconnects too

* display pow only if enabled

* display pow only if enabled

* direct connection tracking
This commit is contained in:
callebtc
2025-09-02 22:09:44 +02:00
committed by GitHub
parent bbf5918896
commit 8b3dc71dc6
13 changed files with 181 additions and 147 deletions
@@ -43,12 +43,6 @@ class BluetoothConnectionManager(
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) { override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) {
Log.d(TAG, "onPacketReceived: Packet received from ${device?.address} ($peerID)") Log.d(TAG, "onPacketReceived: Packet received from ${device?.address} ($peerID)")
device?.let { bluetoothDevice -> device?.let { bluetoothDevice ->
// if connection does not have a peerID yet, we assume that the first package
// we receive from that connection is from the peer
if (!connectionTracker.addressPeerMap.containsKey(device.address)) {
Log.d(TAG, "First packet received from new device: ${bluetoothDevice.address}, assuming peerID: $peerID")
connectionTracker.addressPeerMap[device.address] = peerID
}
// Get current RSSI for this device and update if available // Get current RSSI for this device and update if available
val currentRSSI = connectionTracker.getBestRSSI(bluetoothDevice.address) val currentRSSI = connectionTracker.getBestRSSI(bluetoothDevice.address)
if (currentRSSI != null) { if (currentRSSI != null) {
@@ -65,6 +59,10 @@ class BluetoothConnectionManager(
delegate?.onDeviceConnected(device) delegate?.onDeviceConnected(device)
} }
override fun onDeviceDisconnected(device: BluetoothDevice) {
delegate?.onDeviceDisconnected(device)
}
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
delegate?.onRSSIUpdated(deviceAddress, rssi) delegate?.onRSSIUpdated(deviceAddress, rssi)
} }
@@ -262,5 +260,6 @@ class BluetoothConnectionManager(
interface BluetoothConnectionManagerDelegate { interface BluetoothConnectionManagerDelegate {
fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?)
fun onDeviceConnected(device: BluetoothDevice) fun onDeviceConnected(device: BluetoothDevice)
fun onDeviceDisconnected(device: BluetoothDevice)
fun onRSSIUpdated(deviceAddress: String, rssi: Int) fun onRSSIUpdated(deviceAddress: String, rssi: Int)
} }
@@ -343,6 +343,9 @@ class BluetoothGattClientManager(
connectionTracker.cleanupDeviceConnection(deviceAddress) connectionTracker.cleanupDeviceConnection(deviceAddress)
} }
// Notify higher layers about device disconnection to update direct flags
delegate?.onDeviceDisconnected(gatt.device)
connectionScope.launch { connectionScope.launch {
delay(500) // CLEANUP_DELAY delay(500) // CLEANUP_DELAY
try { try {
@@ -144,6 +144,8 @@ class BluetoothGattServerManager(
BluetoothProfile.STATE_DISCONNECTED -> { BluetoothProfile.STATE_DISCONNECTED -> {
Log.i(TAG, "Server: Device disconnected ${device.address}") Log.i(TAG, "Server: Device disconnected ${device.address}")
connectionTracker.cleanupDeviceConnection(device.address) connectionTracker.cleanupDeviceConnection(device.address)
// Notify delegate about device disconnection so higher layers can update direct flags
delegate?.onDeviceDisconnected(device)
} }
} }
} }
@@ -343,7 +343,34 @@ class BluetoothMeshService(private val context: Context) {
} }
override fun handleAnnounce(routed: RoutedPacket) { override fun handleAnnounce(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleAnnounce(routed) } serviceScope.launch {
// Process the announce
val isFirst = messageHandler.handleAnnounce(routed)
// Map device address -> peerID on first announce seen over this device connection
val deviceAddress = routed.relayAddress
val pid = routed.peerID
if (deviceAddress != null && pid != null) {
// Only set mapping if not already mapped
if (!connectionManager.addressPeerMap.containsKey(deviceAddress)) {
connectionManager.addressPeerMap[deviceAddress] = pid
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on ANNOUNCE")
// Mark this peer as directly connected for UI
try {
peerManager.getPeerInfo(pid)?.let {
// Set direct connection flag
// (This will also trigger a peer list update)
peerManager.setDirectConnection(pid, true)
// Also push reactive directness state to UI (best-effort)
try {
// Note: UI observes via didUpdatePeerList, but we can also update ChatState on a timer
} catch (_: Exception) { }
}
} catch (_: Exception) { }
}
}
}
} }
override fun handleMessage(routed: RoutedPacket) { override fun handleMessage(routed: RoutedPacket) {
@@ -385,6 +412,21 @@ class BluetoothMeshService(private val context: Context) {
} }
} }
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
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]
// ConnectionTracker has already removed the address mapping; be defensive either way
connectionManager.addressPeerMap.remove(addr)
if (peer != null) {
val stillMapped = connectionManager.addressPeerMap.values.any { it == peer }
if (!stillMapped) {
// Peer might still be reachable indirectly; mark as not-direct
try { peerManager.setDirectConnection(peer, false) } catch (_: Exception) { }
}
}
}
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
// Find the peer ID for this device address and update RSSI in PeerManager // Find the peer ID for this device address and update RSSI in PeerManager
connectionManager.addressPeerMap[deviceAddress]?.let { peerID -> connectionManager.addressPeerMap[deviceAddress]?.let { peerID ->
@@ -13,6 +13,7 @@ data class PeerInfo(
val id: String, val id: String,
var nickname: String, var nickname: String,
var isConnected: Boolean, var isConnected: Boolean,
var isDirectConnection: Boolean,
var noisePublicKey: ByteArray?, var noisePublicKey: ByteArray?,
var signingPublicKey: ByteArray?, // NEW: Ed25519 public key for verification var signingPublicKey: ByteArray?, // NEW: Ed25519 public key for verification
var isVerifiedNickname: Boolean, // NEW: Verification status flag var isVerifiedNickname: Boolean, // NEW: Verification status flag
@@ -27,6 +28,7 @@ data class PeerInfo(
if (id != other.id) return false if (id != other.id) return false
if (nickname != other.nickname) return false if (nickname != other.nickname) return false
if (isConnected != other.isConnected) return false if (isConnected != other.isConnected) return false
if (isDirectConnection != other.isDirectConnection) return false
if (noisePublicKey != null) { if (noisePublicKey != null) {
if (other.noisePublicKey == null) return false if (other.noisePublicKey == null) return false
if (!noisePublicKey.contentEquals(other.noisePublicKey)) return false if (!noisePublicKey.contentEquals(other.noisePublicKey)) return false
@@ -45,6 +47,7 @@ data class PeerInfo(
var result = id.hashCode() var result = id.hashCode()
result = 31 * result + nickname.hashCode() result = 31 * result + nickname.hashCode()
result = 31 * result + isConnected.hashCode() result = 31 * result + isConnected.hashCode()
result = 31 * result + isDirectConnection.hashCode()
result = 31 * result + (noisePublicKey?.contentHashCode() ?: 0) result = 31 * result + (noisePublicKey?.contentHashCode() ?: 0)
result = 31 * result + (signingPublicKey?.contentHashCode() ?: 0) result = 31 * result + (signingPublicKey?.contentHashCode() ?: 0)
result = 31 * result + isVerifiedNickname.hashCode() result = 31 * result + isVerifiedNickname.hashCode()
@@ -74,11 +77,7 @@ class PeerManager {
private val announcedPeers = CopyOnWriteArrayList<String>() private val announcedPeers = CopyOnWriteArrayList<String>()
private val announcedToPeers = CopyOnWriteArrayList<String>() private val announcedToPeers = CopyOnWriteArrayList<String>()
// Legacy support for existing code // Legacy fields removed: use PeerInfo map exclusively
@Deprecated("Use PeerInfo structure instead")
private val peerNicknames = ConcurrentHashMap<String, String>()
@Deprecated("Use PeerInfo structure instead")
private val activePeers = ConcurrentHashMap<String, Long>() // peerID -> lastSeen timestamp
// Centralized fingerprint management // Centralized fingerprint management
private val fingerprintManager = PeerFingerprintManager.getInstance() private val fingerprintManager = PeerFingerprintManager.getInstance()
@@ -117,6 +116,7 @@ class PeerManager {
id = peerID, id = peerID,
nickname = nickname, nickname = nickname,
isConnected = true, isConnected = true,
isDirectConnection = existingPeer?.isDirectConnection ?: false,
noisePublicKey = noisePublicKey, noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey, signingPublicKey = signingPublicKey,
isVerifiedNickname = isVerified, isVerifiedNickname = isVerified,
@@ -125,9 +125,9 @@ class PeerManager {
peers[peerID] = peerInfo peers[peerID] = peerInfo
// Update legacy structures for compatibility // Update derived state only
peerNicknames[peerID] = nickname // No legacy maps; peers map is the single source of truth
activePeers[peerID] = now // Maintain announcedPeers for first-time announce semantics
if (isNewPeer && isVerified) { if (isNewPeer && isVerified) {
announcedPeers.add(peerID) announcedPeers.add(peerID)
@@ -164,6 +164,24 @@ class PeerManager {
return peers.filterValues { it.isVerifiedNickname } return peers.filterValues { it.isVerifiedNickname }
} }
/**
* Set whether a peer is directly connected over Bluetooth.
* Triggers a peer list update to refresh UI badges.
*/
fun setDirectConnection(peerID: String, isDirect: Boolean) {
peers[peerID]?.let { existing ->
if (existing.isDirectConnection != isDirect) {
peers[peerID] = existing.copy(isDirectConnection = isDirect)
notifyPeerListUpdate()
// NEW: notify UI state (if available via delegate path) about directness change
try {
// Best-effort: delegate path flows up to ChatViewModel via didUpdatePeerList
// No direct reference to UI layer here by design.
} catch (_: Exception) { }
}
}
}
// MARK: - Legacy Methods (maintained for compatibility) // MARK: - Legacy Methods (maintained for compatibility)
/** /**
@@ -171,8 +189,6 @@ class PeerManager {
*/ */
fun updatePeerLastSeen(peerID: String) { fun updatePeerLastSeen(peerID: String) {
if (peerID != "unknown") { if (peerID != "unknown") {
activePeers[peerID] = System.currentTimeMillis()
// Also update PeerInfo if it exists
peers[peerID]?.let { info -> peers[peerID]?.let { info ->
peers[peerID] = info.copy(lastSeen = System.currentTimeMillis()) peers[peerID] = info.copy(lastSeen = System.currentTimeMillis())
} }
@@ -181,16 +197,17 @@ class PeerManager {
/** /**
* Add or update peer with nickname * Add or update peer with nickname
* Maintained for compatibility. Uses peers map exclusively now.
*/ */
fun addOrUpdatePeer(peerID: String, nickname: String): Boolean { fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
if (peerID == "unknown") return false if (peerID == "unknown") return false
// Clean up stale peer IDs with the same nickname (exact same logic as iOS) // Clean up stale peer IDs with the same nickname (exact same logic as iOS)
val now = System.currentTimeMillis()
val stalePeerIDs = mutableListOf<String>() val stalePeerIDs = mutableListOf<String>()
peerNicknames.forEach { (existingPeerID, existingNickname) -> peers.forEach { (existingPeerID, info) ->
if (existingNickname == nickname && existingPeerID != peerID) { if (info.nickname == nickname && existingPeerID != peerID) {
val lastSeen = activePeers[existingPeerID] ?: 0 val wasRecentlySeen = (now - info.lastSeen) < 10000
val wasRecentlySeen = (System.currentTimeMillis() - lastSeen) < 10000
if (!wasRecentlySeen) { if (!wasRecentlySeen) {
stalePeerIDs.add(existingPeerID) stalePeerIDs.add(existingPeerID)
} }
@@ -206,8 +223,21 @@ class PeerManager {
val isFirstAnnounce = !announcedPeers.contains(peerID) val isFirstAnnounce = !announcedPeers.contains(peerID)
// Update peer data // Update peer data
peerNicknames[peerID] = nickname val existing = peers[peerID]
activePeers[peerID] = System.currentTimeMillis() if (existing != null) {
peers[peerID] = existing.copy(nickname = nickname, lastSeen = now, isConnected = true)
} else {
peers[peerID] = PeerInfo(
id = peerID,
nickname = nickname,
isConnected = true,
isDirectConnection = false,
noisePublicKey = null,
signingPublicKey = null,
isVerifiedNickname = false,
lastSeen = now
)
}
// Handle first announcement // Handle first announcement
if (isFirstAnnounce) { if (isFirstAnnounce) {
@@ -223,8 +253,7 @@ class PeerManager {
* Remove peer * Remove peer
*/ */
fun removePeer(peerID: String, notifyDelegate: Boolean = true) { fun removePeer(peerID: String, notifyDelegate: Boolean = true) {
val nickname = peerNicknames.remove(peerID) val removed = peers.remove(peerID)
activePeers.remove(peerID)
peerRSSI.remove(peerID) peerRSSI.remove(peerID)
announcedPeers.remove(peerID) announcedPeers.remove(peerID)
announcedToPeers.remove(peerID) announcedToPeers.remove(peerID)
@@ -232,7 +261,7 @@ class PeerManager {
// Also remove fingerprint mappings // Also remove fingerprint mappings
fingerprintManager.removePeer(peerID) fingerprintManager.removePeer(peerID)
if (notifyDelegate && nickname != null) { if (notifyDelegate && removed != null) {
notifyPeerListUpdate() notifyPeerListUpdate()
} }
} }
@@ -266,21 +295,23 @@ class PeerManager {
* Check if peer is active * Check if peer is active
*/ */
fun isPeerActive(peerID: String): Boolean { fun isPeerActive(peerID: String): Boolean {
return activePeers.containsKey(peerID) val info = peers[peerID] ?: return false
val now = System.currentTimeMillis()
return (now - info.lastSeen) <= STALE_PEER_TIMEOUT && info.isConnected
} }
/** /**
* Get peer nickname * Get peer nickname
*/ */
fun getPeerNickname(peerID: String): String? { fun getPeerNickname(peerID: String): String? {
return peerNicknames[peerID] return peers[peerID]?.nickname
} }
/** /**
* Get all peer nicknames * Get all peer nicknames
*/ */
fun getAllPeerNicknames(): Map<String, String> { fun getAllPeerNicknames(): Map<String, String> {
return peerNicknames.toMap() return peers.mapValues { it.value.nickname }
} }
/** /**
@@ -294,22 +325,25 @@ class PeerManager {
* Get list of active peer IDs * Get list of active peer IDs
*/ */
fun getActivePeerIDs(): List<String> { fun getActivePeerIDs(): List<String> {
return activePeers.keys.toList().sorted() val now = System.currentTimeMillis()
return peers.filterValues { (now - it.lastSeen) <= STALE_PEER_TIMEOUT && it.isConnected }
.keys
.toList()
.sorted()
} }
/** /**
* Get active peer count * Get active peer count
*/ */
fun getActivePeerCount(): Int { fun getActivePeerCount(): Int {
return activePeers.size return getActivePeerIDs().size
} }
/** /**
* Clear all peer data * Clear all peer data
*/ */
fun clearAllPeers() { fun clearAllPeers() {
peerNicknames.clear() peers.clear()
activePeers.clear()
peerRSSI.clear() peerRSSI.clear()
announcedPeers.clear() announcedPeers.clear()
announcedToPeers.clear() announcedToPeers.clear()
@@ -324,19 +358,19 @@ class PeerManager {
* Get debug information * Get debug information
*/ */
fun getDebugInfo(addressPeerMap: Map<String, String>? = null): String { fun getDebugInfo(addressPeerMap: Map<String, String>? = null): String {
val now = System.currentTimeMillis()
val activeIds = getActivePeerIDs().toSet()
return buildString { return buildString {
appendLine("=== Peer Manager Debug Info ===") appendLine("=== Peer Manager Debug Info ===")
appendLine("Active Peers: ${activePeers.size}") appendLine("Active Peers: ${activeIds.size}")
activePeers.forEach { (peerID, lastSeen) -> peers.forEach { (peerID, info) ->
val nickname = peerNicknames[peerID] ?: "Unknown" val timeSince = (now - info.lastSeen) / 1000
val timeSince = (System.currentTimeMillis() - lastSeen) / 1000
val rssi = peerRSSI[peerID]?.let { "${it} dBm" } ?: "No RSSI" val rssi = peerRSSI[peerID]?.let { "${it} dBm" } ?: "No RSSI"
// Find device address for this peer ID
val deviceAddress = addressPeerMap?.entries?.find { it.value == peerID }?.key val deviceAddress = addressPeerMap?.entries?.find { it.value == peerID }?.key
val addressInfo = deviceAddress?.let { " [Device: $it]" } ?: " [Device: Unknown]" val addressInfo = deviceAddress?.let { " [Device: $it]" } ?: " [Device: Unknown]"
val status = if (activeIds.contains(peerID)) "ACTIVE" else "INACTIVE"
appendLine(" - $peerID ($nickname)$addressInfo - last seen ${timeSince}s ago, RSSI: $rssi") val direct = if (info.isDirectConnection) "DIRECT" else "ROUTED"
appendLine(" - $peerID (${info.nickname})$addressInfo - $status/$direct, last seen ${timeSince}s ago, RSSI: $rssi")
} }
appendLine("Announced Peers: ${announcedPeers.size}") appendLine("Announced Peers: ${announcedPeers.size}")
appendLine("Announced To Peers: ${announcedToPeers.size}") appendLine("Announced To Peers: ${announcedToPeers.size}")
@@ -353,8 +387,8 @@ class PeerManager {
appendLine("No device address mappings available") appendLine("No device address mappings available")
} else { } else {
addressPeerMap.forEach { (deviceAddress, peerID) -> addressPeerMap.forEach { (deviceAddress, peerID) ->
val nickname = peerNicknames[peerID] ?: "Unknown" val nickname = peers[peerID]?.nickname ?: "Unknown"
val isActive = activePeers.containsKey(peerID) val isActive = isPeerActive(peerID)
val status = if (isActive) "ACTIVE" else "INACTIVE" val status = if (isActive) "ACTIVE" else "INACTIVE"
appendLine(" Device: $deviceAddress -> Peer: $peerID ($nickname) [$status]") appendLine(" Device: $deviceAddress -> Peer: $peerID ($nickname) [$status]")
} }
@@ -390,9 +424,9 @@ class PeerManager {
private fun cleanupStalePeers() { private fun cleanupStalePeers() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val peersToRemove = activePeers.entries.filter { (_, lastSeen) -> val peersToRemove = peers.filterValues { (now - it.lastSeen) > STALE_PEER_TIMEOUT }
now - lastSeen > STALE_PEER_TIMEOUT .keys
}.map { it.key } .toList()
peersToRemove.forEach { peerID -> peersToRemove.forEach { peerID ->
Log.d(TAG, "Removing stale peer: $peerID") Log.d(TAG, "Removing stale peer: $peerID")
@@ -242,10 +242,9 @@ class NostrGeohashService(
*/ */
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
coroutineScope.launch { coroutineScope.launch {
// Generate a temporary message ID for tracking animation
val tempMessageId = "temp_${System.currentTimeMillis()}_${Random.nextInt(1000)}"
try { try {
// Generate a temporary message ID for tracking animation
val tempMessageId = "temp_${System.currentTimeMillis()}_${Random.nextInt(1000)}"
// Add local echo message IMMEDIATELY (with temporary ID) // Add local echo message IMMEDIATELY (with temporary ID)
val powSettingsLocal = PoWPreferenceManager.getCurrentSettings() val powSettingsLocal = PoWPreferenceManager.getCurrentSettings()
val localMessage = BitchatMessage( val localMessage = BitchatMessage(
@@ -289,12 +288,6 @@ class NostrGeohashService(
teleported = teleported teleported = teleported
) )
// Stop animation when PoW completes
if (powSettings.enabled && powSettings.difficulty > 0) {
com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempMessageId)
Log.d(TAG, "🎭 Stopped matrix animation for message: $tempMessageId")
}
val nostrRelayManager = NostrRelayManager.getInstance(application) val nostrRelayManager = NostrRelayManager.getInstance(application)
nostrRelayManager.sendEventToGeohash( nostrRelayManager.sendEventToGeohash(
event = event, event = event,
@@ -307,8 +300,8 @@ class NostrGeohashService(
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to send geohash message: ${e.message}") Log.e(TAG, "Failed to send geohash message: ${e.message}")
// Make sure to stop animation even if there's an error } finally {
com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage("temp_${System.currentTimeMillis()}") com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempMessageId)
} }
} }
} }
@@ -1275,6 +1268,7 @@ class NostrGeohashService(
// val mentions = messageManager.parseMentions(content, peerNicknames, nickname) // val mentions = messageManager.parseMentions(content, peerNicknames, nickname)
// Calculate actual PoW difficulty from the finalized event ID so we can show it for incoming messages too // Calculate actual PoW difficulty from the finalized event ID so we can show it for incoming messages too
val eventHasNonseTag = event.tags.any { it.isNotEmpty() && it[0] == "nonce" }
val actualPow = try { NostrProofOfWork.calculateDifficulty(event.id) } catch (e: Exception) { 0 } val actualPow = try { NostrProofOfWork.calculateDifficulty(event.id) } catch (e: Exception) { 0 }
val message = BitchatMessage( val message = BitchatMessage(
@@ -1287,7 +1281,7 @@ class NostrGeohashService(
senderPeerID = "nostr:${event.pubkey.take(8)}", senderPeerID = "nostr:${event.pubkey.take(8)}",
mentions = null, // mentions need to be passed from outside mentions = null, // mentions need to be passed from outside
channel = "#$geohash", channel = "#$geohash",
powDifficulty = actualPow.takeIf { it > 0 } powDifficulty = actualPow.takeIf { it > 0 && eventHasNonseTag } ?: null
) )
// Store in geohash history for persistence across channel switches // Store in geohash history for persistence across channel switches
@@ -16,8 +16,8 @@ object PoWPreferenceManager {
private const val KEY_POW_DIFFICULTY = "pow_difficulty" private const val KEY_POW_DIFFICULTY = "pow_difficulty"
// Default values // Default values
private const val DEFAULT_POW_ENABLED = true private const val DEFAULT_POW_ENABLED = false
private const val DEFAULT_POW_DIFFICULTY = 10 // Reasonable default for geohash spam prevention private const val DEFAULT_POW_DIFFICULTY = 16 // Reasonable default for geohash spam prevention
// State flows for reactive UI // State flows for reactive UI
private val _powEnabled = MutableStateFlow(DEFAULT_POW_ENABLED) private val _powEnabled = MutableStateFlow(DEFAULT_POW_ENABLED)
@@ -248,8 +248,8 @@ fun AboutSheet(
Slider( Slider(
value = powDifficulty.toFloat(), value = powDifficulty.toFloat(),
onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) },
valueRange = 0f..20f, valueRange = 0f..32f,
steps = 21, // 20 discrete values (0-20) steps = 33, // 33 discrete values (0-32)
colors = SliderDefaults.colors( colors = SliderDefaults.colors(
thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
@@ -104,6 +104,10 @@ class ChatState {
private val _peerRSSI = MutableLiveData<Map<String, Int>>(emptyMap()) private val _peerRSSI = MutableLiveData<Map<String, Int>>(emptyMap())
val peerRSSI: LiveData<Map<String, Int>> = _peerRSSI val peerRSSI: LiveData<Map<String, Int>> = _peerRSSI
// Direct connection status per peer (for live UI updates)
private val _peerDirect = MutableLiveData<Map<String, Boolean>>(emptyMap())
val peerDirect: LiveData<Map<String, Boolean>> = _peerDirect
// peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager // peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager
// Navigation state // Navigation state
@@ -277,6 +281,10 @@ class ChatState {
_peerRSSI.value = rssi _peerRSSI.value = rssi
} }
fun setPeerDirect(direct: Map<String, Boolean>) {
_peerDirect.value = direct
}
fun setShowAppInfo(show: Boolean) { fun setShowAppInfo(show: Boolean) {
_showAppInfo.value = show _showAppInfo.value = show
} }
@@ -7,6 +7,9 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.ui.graphics.vector.ImageVector
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.BluetoothMeshService
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
@@ -124,11 +127,11 @@ fun formatMessageAsAnnotatedString(
)) ))
builder.append(" [${timeFormatter.format(message.timestamp)}]") builder.append(" [${timeFormatter.format(message.timestamp)}]")
// If message has valid PoW difficulty, append bits immediately after timestamp with minimal spacing // If message has valid PoW difficulty, append bits immediately after timestamp with minimal spacing
//message.powDifficulty?.let { bits -> message.powDifficulty?.let { bits ->
// if (bits > 0) { if (bits > 0) {
// builder.append(" ${bits}b") builder.append("${bits}b")
// } }
//} }
builder.pop() builder.pop()
} else { } else {
@@ -106,6 +106,7 @@ class ChatViewModel(
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints
val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames
val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI
val peerDirect: LiveData<Map<String, Boolean>> = state.peerDirect
val showAppInfo: LiveData<Boolean> = state.showAppInfo val showAppInfo: LiveData<Boolean> = state.showAppInfo
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val isTeleported: LiveData<Boolean> = state.isTeleported val isTeleported: LiveData<Boolean> = state.isTeleported
@@ -437,6 +438,14 @@ class ChatViewModel(
val rssiValues = meshService.getPeerRSSI() val rssiValues = meshService.getPeerRSSI()
state.setPeerRSSI(rssiValues) state.setPeerRSSI(rssiValues)
// Update directness per peer (driven by PeerManager state)
try {
val directMap = state.getConnectedPeersValue().associateWith { pid ->
meshService.getPeerInfo(pid)?.isDirectConnection == true
}
state.setPeerDirect(directMap)
} catch (_: Exception) { }
} }
// MARK: - Debug and Troubleshooting // MARK: - Debug and Troubleshooting
@@ -128,67 +128,3 @@ enum class PoWIndicatorStyle {
COMPACT, // Small icon + difficulty number COMPACT, // Small icon + difficulty number
DETAILED // Icon + status text + time estimate DETAILED // Icon + status text + time estimate
} }
/**
* Shows mining progress with animated indicator
*/
@Composable
fun PoWMiningIndicator(
modifier: Modifier = Modifier,
difficulty: Int,
iterations: Int? = null
) {
val colorScheme = MaterialTheme.colorScheme
Surface(
modifier = modifier,
color = Color(0xFFFF9500).copy(alpha = 0.1f),
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Animated security icon
val rotation by rememberInfiniteTransition(label = "mining-rotation").animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "mining-icon-rotation"
)
Icon(
imageVector = Icons.Filled.Security,
contentDescription = "Mining Proof of Work",
tint = Color(0xFFFF9500),
modifier = Modifier
.size(16.dp)
.graphicsLayer { rotationZ = rotation }
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = "mining proof of work...",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = Color(0xFFFF9500)
)
Text(
text = "difficulty: ${difficulty}bit (~${NostrProofOfWork.estimateMiningTime(difficulty)})" +
if (iterations != null) "${iterations} attempts" else "",
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
}
@@ -384,10 +384,12 @@ fun PeopleSection(
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName) val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
val showHash = (baseNameCounts[bName] ?: 0) > 1 val showHash = (baseNameCounts[bName] ?: 0) > 1
val directMap by viewModel.peerDirect.observeAsState(emptyMap())
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem( PeerItem(
peerID = peerID, peerID = peerID,
displayName = displayName, displayName = displayName,
signalStrength = convertRSSIToSignalStrength(peerRSSI[peerID]), isDirect = isDirectLive,
isSelected = peerID == selectedPrivatePeer, isSelected = peerID == selectedPrivatePeer,
isFavorite = isFavorite, isFavorite = isFavorite,
hasUnreadDM = combinedHasUnread, hasUnreadDM = combinedHasUnread,
@@ -421,7 +423,7 @@ fun PeopleSection(
PeerItem( PeerItem(
peerID = favPeerID, peerID = favPeerID,
displayName = dn, displayName = dn,
signalStrength = 0, isDirect = false,
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true, isFavorite = true,
hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID), hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID),
@@ -458,15 +460,15 @@ fun PeopleSection(
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
val showHash = (baseNameCounts[bName] ?: 0) > 1 val showHash = (baseNameCounts[bName] ?: 0) > 1
PeerItem( PeerItem(
peerID = convKey, peerID = convKey,
displayName = dn, displayName = dn,
signalStrength = 0, isDirect = false,
isSelected = convKey == selectedPrivatePeer, isSelected = convKey == selectedPrivatePeer,
isFavorite = false, isFavorite = false,
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey), hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
colorScheme = colorScheme, colorScheme = colorScheme,
viewModel = viewModel, viewModel = viewModel,
onItemClick = { onPrivateChatStart(convKey) }, onItemClick = { onPrivateChatStart(convKey) },
onToggleFavorite = { viewModel.toggleFavorite(convKey) }, onToggleFavorite = { viewModel.toggleFavorite(convKey) },
unreadCount = privateChats[convKey]?.count { msg -> unreadCount = privateChats[convKey]?.count { msg ->
@@ -483,7 +485,7 @@ fun PeopleSection(
private fun PeerItem( private fun PeerItem(
peerID: String, peerID: String,
displayName: String, displayName: String,
signalStrength: Int, isDirect: Boolean,
isSelected: Boolean, isSelected: Boolean,
isFavorite: Boolean, isFavorite: Boolean,
hasUnreadDM: Boolean, hasUnreadDM: Boolean,
@@ -527,7 +529,7 @@ private fun PeerItem(
tint = Color(0xFFFF9500) // iOS orange tint = Color(0xFFFF9500) // iOS orange
) )
} else { } else {
// Signal strength indicators // Connection indicator icons
if (showNostrGlobe) { if (showNostrGlobe) {
// Purple globe to indicate Nostr availability // Purple globe to indicate Nostr availability
Icon( Icon(
@@ -537,9 +539,11 @@ private fun PeerItem(
tint = Color(0xFF9C27B0) // Purple tint = Color(0xFF9C27B0) // Purple
) )
} else { } else {
SignalStrengthIndicator( Icon(
signalStrength = signalStrength, imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route,
colorScheme = colorScheme contentDescription = if (isDirect) "Direct Bluetooth" else "Routed",
modifier = Modifier.size(16.dp),
tint = colorScheme.onSurface.copy(alpha = 0.8f)
) )
} }
} }