android: standby duty-cycle в фоне — defer ping/reconnect/probe через standby_wait

standby.h/c: duty-cycle ACTIVE/SLEEP, standby_wait() будит в burst сразу,
в SLEEP — до следующего burst; notify_network_activity для раннего пробуждения.
Интеграция (по флагу UTUN_HAVE_STANDBY, десктоп без изменений):
- conn_mgr: bg_ping/candidate_ping в SLEEP defer через standby_wait
- route_connectivity: probe_node откладывается (PROBE_STATUS_DEFERRED)
- topo_group_connect: пауза между попытками — активный режим 1с, фон standby_wait
- jni_bridge/ConfigProvider/NetworkActivityMonitor: set_active + сетевая активность
- chat_setting: standby_active_sec/sleep_sec/min_sleep_sec
+ юнит-тест test_standby (31 проверок)
This commit is contained in:
evgeny committed 2026-08-14 12:28:56 +03:00
1 parent 1ec94de852
commit b203a48063
26 files changed
+921 -14

No files matched your search

@@ -7,6 +7,7 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
@@ -133,7 +134,7 @@ class MainActivity : ComponentActivity() {
)
}
}
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
}
/* Show join dialog after QR scan returns */
@@ -42,6 +42,9 @@ class ConfigProvider(private val context: Context) {
private val chatCompressorMaxGainDb = intPreferencesKey("chat_compressor_max_gain_db")
private val chatCompressorRiseRate = intPreferencesKey("chat_compressor_rise_rate")
private val chatMediaDownloadMaxPeers = intPreferencesKey("chat_media_download_max_peers")
private val chatStandbyActiveSec = intPreferencesKey("chat_standby_active_sec")
private val chatStandbySleepSec = intPreferencesKey("chat_standby_sleep_sec")
private val chatStandbyMinSleepSec = intPreferencesKey("chat_standby_min_sleep_sec")
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
private val connectionModeKey = stringPreferencesKey("connection_mode")
@@ -117,6 +120,9 @@ class ConfigProvider(private val context: Context) {
key == "chat.compressor_max_gain_db" -> context.dataStore.data.first()[chatCompressorMaxGainDb] ?: 25
key == "chat.compressor_rise_rate" -> context.dataStore.data.first()[chatCompressorRiseRate] ?: 10
key == "chat.media_download_max_peers" -> context.dataStore.data.first()[chatMediaDownloadMaxPeers] ?: 3
key == "chat.standby_active_sec" -> context.dataStore.data.first()[chatStandbyActiveSec] ?: 2
key == "chat.standby_sleep_sec" -> context.dataStore.data.first()[chatStandbySleepSec] ?: 60
key == "chat.standby_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15
else -> 0
}
}
@@ -144,6 +150,9 @@ class ConfigProvider(private val context: Context) {
"chat.compressor_max_gain_db" -> prefs[chatCompressorMaxGainDb] = value.toIntOrNull() ?: 25
"chat.compressor_rise_rate" -> prefs[chatCompressorRiseRate] = value.toIntOrNull() ?: 10
"chat.media_download_max_peers" -> prefs[chatMediaDownloadMaxPeers] = value.toIntOrNull() ?: 3
"chat.standby_active_sec" -> prefs[chatStandbyActiveSec] = value.toIntOrNull() ?: 2
"chat.standby_sleep_sec" -> prefs[chatStandbySleepSec] = value.toIntOrNull() ?: 60
"chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15
}
}
}
@@ -336,6 +345,9 @@ class ConfigProvider(private val context: Context) {
appendLine("compressor_max_gain_db=$chatCompMaxGain")
appendLine("compressor_rise_rate=$chatCompRiseRate")
appendLine("media_download_max_peers=$chatDlMaxPeers")
appendLine("standby_active_sec=${getInt("chat.standby_active_sec")}")
appendLine("standby_sleep_sec=${getInt("chat.standby_sleep_sec")}")
appendLine("standby_min_sleep_sec=${getInt("chat.standby_min_sleep_sec")}")
appendLine()
appendLine("[debug]")
appendLine("console_level=info")
@@ -56,6 +56,9 @@ object NativeLib {
/* ── App foreground/background (Android lifecycle) ── */
fun setActive(active: Boolean) { nativeSetActive(active) }
/* ── External network activity (ConnectivityManager) ── */
fun notifyNetworkActive() { nativeNotifyNetworkActive() }
fun sendMessage(channelId: String, text: String) { nativeSendMessage(channelId, text) }
fun createChannel(name: String, channelId: String) { nativeCreateChannel(name, channelId) }
fun connectNode(address: String, port: Int, pubkeyHex: String) { nativeConnectNode(address, port, pubkeyHex) }
@@ -144,6 +147,7 @@ object NativeLib {
private external fun nativeIsResponsive(): Boolean
private external fun nativeIsRunning(): Boolean
private external fun nativeSetActive(active: Boolean)
private external fun nativeNotifyNetworkActive()
/* ── Channel members JNI ── */
private external fun nativeGetChannelMembers(channelId: String): String?
@@ -0,0 +1,48 @@
package com.utun.chat.data
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import com.utun.chat.ChatApplication
/**
* Слушатель внешней сетевой активности (ConnectivityManager.OnNetworkActiveListener).
*
* Пробуждает standby-ядро досрочно: когда в фазе SLEEP прошёл min_sleep,
* приход сетевой активности запускает следующий активный цикл раньше.
*
* В колбэке не делаем тяжёлую работу — только дёшевый JNI-вызов
* NativeLib.notifyNetworkActive(), который постит событие на uasync-поток.
*/
object NetworkActivityMonitor {
private var started = false
private val listener = ConnectivityManager.OnNetworkActiveListener {
NativeLib.notifyNetworkActive()
}
fun start() {
if (started) return
/* addDefaultNetworkActiveListener доступен с API 28 (P) */
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
val ctx = ChatApplication.instance.applicationContext
val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return
cm.addDefaultNetworkActiveListener(listener)
started = true
LogManager.addLog("INFO", "NET", "network activity listener started")
/* Если сеть уже активна в момент запуска — сразу уведомляем */
if (cm.isDefaultNetworkActive) {
NativeLib.notifyNetworkActive()
}
}
fun stop() {
if (!started) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
val ctx = ChatApplication.instance.applicationContext
val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return
cm.removeDefaultNetworkActiveListener(listener)
started = false
LogManager.addLog("INFO", "NET", "network activity listener stopped")
}
}
@@ -12,6 +12,7 @@ import com.utun.chat.ChatApplication
import com.utun.chat.MainActivity
import com.utun.chat.data.LogManager
import com.utun.chat.data.NativeLib
import com.utun.chat.data.NetworkActivityMonitor
class HeadlessService : Service() {
override fun onCreate() {
@@ -45,6 +46,7 @@ class HeadlessService : Service() {
val ok = NativeLib.start(configText)
if (ok) {
LogManager.addLog("INFO", "SRV", "uTun core started")
NetworkActivityMonitor.start()
} else {
LogManager.addLog("ERROR", "SRV", "uTun start failed")
}
@@ -52,6 +54,7 @@ class HeadlessService : Service() {
override fun onDestroy() {
LogManager.addLog("INFO", "SRV", "uTun service stopping")
NetworkActivityMonitor.stop()
NativeLib.stop()
super.onDestroy()
}
@@ -99,6 +99,9 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
var logFile by remember { mutableStateOf("") }
var logUdpIp by remember { mutableStateOf("") }
var logUdpPort by remember { mutableStateOf("") }
var standbyActiveSec by remember { mutableStateOf("2") }
var standbySleepSec by remember { mutableStateOf("60") }
var standbyMinSleepSec by remember { mutableStateOf("15") }
val pubKey by vm.pubKey.collectAsState()
val generatingKeys by vm.generatingKeys.collectAsState()
@@ -124,6 +127,9 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
logFile = provider.getString("debug.log_file") ?: "utun.log"
logUdpIp = provider.getString("log_udp.ip") ?: "192.168.40.250"
logUdpPort = provider.getInt("log_udp.port").toString().let { if (it == "0") "9999" else it }
standbyActiveSec = provider.getInt("chat.standby_active_sec").toString()
standbySleepSec = provider.getInt("chat.standby_sleep_sec").toString()
standbyMinSleepSec = provider.getInt("chat.standby_min_sleep_sec").toString()
val port = logUdpPort.toIntOrNull() ?: 0
if (logUdpIp.isNotEmpty() && port > 0) NativeLib.setUdpLogTarget(logUdpIp, port)
val stored = provider.getString("debug.categories") ?: ""
@@ -301,6 +307,28 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
HorizontalDivider(Modifier.padding(vertical = 8.dp))
/* ── Standby (background duty-cycle) ── */
Text("Standby", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(standbyActiveSec,
{ v -> standbyActiveSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..3600) {
scope.launch { provider.setValue("chat.standby_active_sec", v) }; NativeLib.setChatSetting("standby_active_sec", v) } },
label = { Text("Active (sec)") }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
OutlinedTextField(standbySleepSec,
{ v -> standbySleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) {
scope.launch { provider.setValue("chat.standby_sleep_sec", v) }; NativeLib.setChatSetting("standby_sleep_sec", v) } },
label = { Text("Sleep (sec)") }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
}
OutlinedTextField(standbyMinSleepSec,
{ v -> standbyMinSleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) {
scope.launch { provider.setValue("chat.standby_min_sleep_sec", v) }; NativeLib.setChatSetting("standby_min_sleep_sec", v) } },
label = { Text("Min sleep (sec)") }, modifier = Modifier.heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
/* Debug */
Text("Debug", style = MaterialTheme.typography.titleMedium)
DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug)