Measure uasync CPU load and display it in desktop and Android GUIs

Sample thread CPU usage without waking idle loops, expose a one-second moving average, and refresh compact GUI indicators at 3 Hz while visible on Android. Add load and bridge regression coverage, streaming notes, and task list cleanup.
This commit is contained in:
evgeny committed 2026-10-10 12:31:13 +02:00
1 parent 8bd6e4fb2d
commit 5a9e61164b
25 files changed
+692 -587

No files matched your search

@@ -358,11 +358,14 @@ class MainActivity : AppCompatActivity() {
val channels by vm.channels.collectAsState()
val dmConversations by vm.dmConversations.collectAsState()
val stuck by vm.isStuck.collectAsState()
val cpuLoad by vm.cpuLoad.collectAsState()
ChannelListScreen(
channels = channels,
dmConversations = dmConversations,
connected = connected,
isStuck = stuck,
cpuLoad = cpuLoad,
onCpuLoadVisible = vm::setCpuLoadVisible,
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm },
onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
@@ -84,6 +84,7 @@ object NativeLib {
fun requestInviteLink(channelId: String, targetNodeId: Long, requestId: Long) { nativeRequestInviteLink(channelId, targetNodeId, requestId) }
fun ping() { nativePing() }
fun requestCpuLoad(): Boolean = nativeRequestCpuLoad()
fun isResponsive(): Boolean = nativeIsResponsive()
/* ── App foreground/background (Android lifecycle) ── */
@@ -253,6 +254,7 @@ object NativeLib {
private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
private external fun nativeStart(configText: String): Boolean
private external fun nativeStop()
private external fun nativeRequestCpuLoad(): Boolean
private external fun nativeSetEventCallback(cb: Any)
private external fun nativeDestroy()
private external fun nativeSendMessage(channelId: String, text: ByteArray, replyTo: ByteArray, requestId: Long): Boolean
@@ -0,0 +1,39 @@
package com.utun.chat.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/* Градиент привязан ко всей ширине: низкая нагрузка зелёная, полная доходит до красного. */
@Composable
fun CpuLoadIndicator(percent: Int, modifier: Modifier = Modifier) {
val value = percent.coerceIn(0, 100)
Box(modifier.size(60.dp, 24.dp).semantics { contentDescription = "CPU $value%" }, contentAlignment = Alignment.Center) {
Canvas(Modifier.matchParentSize()) {
val shape = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(5.dp.toPx()))) }
clipPath(shape) {
drawRect(Color(0xFFE4E9E6))
clipRect(right = size.width * value / 100f) {
drawRect(Brush.horizontalGradient(listOf(Color(0xFF66BB6A), Color(0xFFFFCA28), Color(0xFFEF5350)), endX = size.width))
}
}
}
Text("$value%", color = Color(0xFF172019), fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1)
}
}
@@ -18,6 +18,8 @@ import androidx.compose.material.icons.filled.Build
import androidx.compose.material3.*
import androidx.compose.runtime.*
import com.utun.chat.ui.components.PeerStatusDot
import com.utun.chat.ui.components.CpuLoadIndicator
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.utun.chat.ui.components.GroupAvatar
import com.utun.chat.ui.components.UserAvatar
import com.utun.chat.ui.components.GroupPropertiesDialog
@@ -43,6 +45,8 @@ fun ChannelListScreen(
dmConversations: List<DmConversation> = emptyList(),
connected: Boolean,
isStuck: Boolean = false,
cpuLoad: Int = 0,
onCpuLoadVisible: (Boolean) -> Unit = {},
onSelectChannel: (Channel) -> Unit,
onSelectDm: (DmConversation) -> Unit = {},
onCreateChannel: (String) -> Unit,
@@ -53,6 +57,10 @@ fun ChannelListScreen(
onLogs: () -> Unit,
onRefresh: () -> Unit = {}
) {
LifecycleResumeEffect(Unit) {
onCpuLoadVisible(true)
onPauseOrDispose { onCpuLoadVisible(false) }
}
var showCreateDialog by remember { mutableStateOf(false) }
var menuChannelId by remember { mutableStateOf<String?>(null) }
var optionsChannel by remember { mutableStateOf<Channel?>(null) }
@@ -81,6 +89,7 @@ fun ChannelListScreen(
},
floatingActionButton = {
Row(verticalAlignment = Alignment.CenterVertically) {
CpuLoadIndicator(cpuLoad, Modifier.padding(end = 12.dp))
Text(
text = "v${BuildConfig.VERSION_NAME}",
modifier = Modifier.padding(end = 16.dp),
@@ -250,6 +250,13 @@ class ChatViewModel : ViewModel() {
private val _isStuck = MutableStateFlow(false)
val isStuck: StateFlow<Boolean> = _isStuck
private val _cpuLoad = MutableStateFlow(0)
val cpuLoad: StateFlow<Int> = _cpuLoad
private val _cpuLoadVisible = MutableStateFlow(false)
private var cpuLoadPending = false
fun setCpuLoadVisible(visible: Boolean) { _cpuLoadVisible.value = visible }
private val _nodeStatuses = MutableStateFlow<Map<Long, NodeStatus>>(emptyMap())
val nodeStatuses: StateFlow<Map<Long, NodeStatus>> = _nodeStatuses
@@ -324,6 +331,21 @@ class ChatViewModel : ViewModel() {
}
init {
viewModelScope.launch {
_cpuLoadVisible.collectLatest { visible ->
if (!visible) return@collectLatest
LogManager.addLog("DEBUG", "CPU", "GUI polling started: period=333ms")
try {
while (isActive) {
if (!NativeLib.isReady()) { cpuLoadPending = false; _cpuLoad.value = 0 }
else if (!cpuLoadPending) cpuLoadPending = NativeLib.requestCpuLoad()
delay(333)
}
} finally {
LogManager.addLog("DEBUG", "CPU", "GUI polling stopped")
}
}
}
viewModelScope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
}
@@ -386,11 +408,21 @@ class ChatViewModel : ViewModel() {
private fun handleEvent(type: Int, data: ByteArray?) {
if (type == 13) {
cpuLoadPending = false; _cpuLoad.value = 0
dmPresenceRefreshJob?.cancel()
_dmCallAvailable.value = false
_dmConversations.value = _dmConversations.value.map { it.copy(online = false) }
} else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute()
when (type) {
57 -> { /* CPU_LOAD: valid:1, average_percent:1. */
cpuLoadPending = false
if (data == null || data.size != 2 || data[0].toInt() !in 0..1 || data[1].toInt() !in 0..100) {
LogManager.addLog("ERROR", "CPU", "Invalid CPU load response: size=${data?.size}")
_cpuLoad.value = 0
return
}
if (_cpuLoadVisible.value) _cpuLoad.value = if (data[0].toInt() == 1) data[1].toInt() else 0
}
56 -> { /* MSG_SUBMITTED: request_id:8LE, success. */
if (data == null || data.size != 9) { LogManager.addLog("ERROR", "Reply", "invalid submission result"); return }
val id = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).long