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
This commit is contained in:
callebtc
2025-12-13 16:43:39 +07:00
committed by GitHub
parent e96330e50b
commit 3f8c236a72
24 changed files with 2079 additions and 575 deletions
File diff suppressed because it is too large Load Diff
@@ -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<String>()
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 ->
@@ -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
@@ -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)
@@ -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<String, List<BitchatMessage>>() }
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) {
@@ -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.
}
@@ -36,6 +36,11 @@ class DebugSettingsManager private constructor() {
private val _packetRelayEnabled = MutableStateFlow(true)
val packetRelayEnabled: StateFlow<Boolean> = _packetRelayEnabled.asStateFlow()
// Visibility of the debug sheet; gates heavy work
private val _debugSheetVisible = MutableStateFlow(false)
val debugSheetVisible: StateFlow<Boolean> = _debugSheetVisible.asStateFlow()
fun setDebugSheetVisible(visible: Boolean) { _debugSheetVisible.value = visible }
// Connection limit overrides (debug)
private val _maxConnectionsOverall = MutableStateFlow(8)
val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow()
@@ -75,12 +80,63 @@ class DebugSettingsManager private constructor() {
// Timestamps to compute rolling window stats
private val relayTimestamps = ConcurrentLinkedQueue<Long>()
// Per-device and per-peer rolling timestamps for stacked graphs
private val perDeviceRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
// Additional buckets to split incoming vs outgoing
private val incomingTimestamps = ConcurrentLinkedQueue<Long>()
private val outgoingTimestamps = ConcurrentLinkedQueue<Long>()
private val perDeviceIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perDeviceOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
// Expose current per-second rates (updated when logging/pruning occurs)
private val _perDeviceLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceLastSecond: StateFlow<Map<String, Int>> = _perDeviceLastSecond.asStateFlow()
private val _perPeerLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerLastSecond: StateFlow<Map<String, Int>> = _perPeerLastSecond.asStateFlow()
// New flows used by UI for incoming/outgoing stacked plots
private val _perDeviceIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceIncomingLastSecond: StateFlow<Map<String, Int>> = _perDeviceIncomingLastSecond.asStateFlow()
private val _perDeviceOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingLastSecond: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastSecond.asStateFlow()
private val _perPeerIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerIncomingLastSecond: StateFlow<Map<String, Int>> = _perPeerIncomingLastSecond.asStateFlow()
private val _perPeerOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerOutgoingLastSecond: StateFlow<Map<String, Int>> = _perPeerOutgoingLastSecond.asStateFlow()
// Per-minute counts per key
private val _perDeviceIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceIncomingLastMinute: StateFlow<Map<String, Int>> = _perDeviceIncomingLastMinute.asStateFlow()
private val _perDeviceOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingLastMinute: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastMinute.asStateFlow()
private val _perPeerIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerIncomingLastMinute: StateFlow<Map<String, Int>> = _perPeerIncomingLastMinute.asStateFlow()
private val _perPeerOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerOutgoingLastMinute: StateFlow<Map<String, Int>> = _perPeerOutgoingLastMinute.asStateFlow()
// Totals per key (since app start)
private val deviceIncomingTotalsMap = mutableMapOf<String, Long>()
private val deviceOutgoingTotalsMap = mutableMapOf<String, Long>()
private val peerIncomingTotalsMap = mutableMapOf<String, Long>()
private val peerOutgoingTotalsMap = mutableMapOf<String, Long>()
private val _perDeviceIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perDeviceIncomingTotal: StateFlow<Map<String, Long>> = _perDeviceIncomingTotalsFlow.asStateFlow()
private val _perDeviceOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingTotal: StateFlow<Map<String, Long>> = _perDeviceOutgoingTotalsFlow.asStateFlow()
private val _perPeerIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perPeerIncomingTotal: StateFlow<Map<String, Long>> = _perPeerIncomingTotalsFlow.asStateFlow()
private val _perPeerOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perPeerOutgoingTotal: StateFlow<Map<String, Long>> = _perPeerOutgoingTotalsFlow.asStateFlow()
// Internal data storage for managing debug data
private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>()
private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>()
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<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
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<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
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
)
@@ -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<Map<String, String?>>(emptyMap()) }
val devicePeerMap = remember { mutableStateOf<Map<String, String>>(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<String>()) }
var stackedKeysOutgoing by rememberSaveable { mutableStateOf(listOf<String>()) }
var stackedSeriesIncoming by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
var stackedSeriesOutgoing by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
var highlightedKey by rememberSaveable { mutableStateOf<String?>(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<String, Color>() }
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<String>) {
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<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
val next = mutableMapOf<String, List<Float>>()
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<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
val next = mutableMapOf<String, List<Float>>()
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<String>,
stackedSeries: Map<String, List<Float>>,
overallSeries: List<Float>?,
graphMode: GraphMode,
highlightedKey: String?,
onToggleHighlight: (String) -> Unit,
ensureColors: (List<String>) -> 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))
}
}
}
}
}
}
}