From f5b15a03942867a531d9d5aa90eb05fcd7207192 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 14 Oct 2026 01:22:09 +0700 Subject: [PATCH] android: health check skips probes while inbound tunnel traffic proves liveness (adaptive mode, real probe at least every 5 min) and probes a single target with the screen off; unit tests; energy plan notes keepalive stays at 45 s Co-Authored-By: Claude Sonnet 5.5 --- .../logic/connectivity/HealthCheckManager.kt | 72 ++++++++++++++++++- .../connectivity/HealthCheckManagerTest.kt | 26 +++++++ src/frontends/android/docs/energy-plan.md | 21 +++--- 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/HealthCheckManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/HealthCheckManager.kt index b8188e005..6ff91247e 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/HealthCheckManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/HealthCheckManager.kt @@ -30,6 +30,7 @@ import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper +import android.os.SystemClock import android.os.PowerManager import android.util.Log import kotlinx.coroutines.CoroutineScope @@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex import org.json.JSONArray import org.json.JSONException import org.json.JSONObject +import org.strongswan.android.logic.CharonVpnService import org.strongswan.android.logic.VpnStateService import org.strongswan.android.logic.events.Events @@ -130,6 +132,9 @@ class HealthCheckManager private constructor( private var tunnelAttempts = 0 private var fastPollShifts = 0 private var powerState = PowerState() + /* tunnel inbound bytes at the previous cycle (-1: unknown) and when the last real probe ran */ + private var lastCycleInbound = -1L + private var lastProbeAt = 0L /* tracks screen/battery state so probing can back off while the device sleeps */ private val powerReceiver = object : BroadcastReceiver() @@ -360,7 +365,31 @@ class HealthCheckManager private constructor( return } val gateway = svc.profile?.gateway - val targets = config.targets + val adapted = adaptToPowerState(config, powerState) + val nowMs = SystemClock.elapsedRealtime() + /* passive liveness: payload that came back through the tunnel since the + * previous cycle proves it works without waking the radio for probes */ + val inbound = tunnelInboundBytes() + val inboundDelta = if (lastCycleInbound >= 0 && inbound >= 0) inbound - lastCycleInbound else -1L + lastCycleInbound = inbound + if (trafficProvesAlive( + adapted, serverAttempts + tunnelAttempts, fastPollShifts, pendingDecision != null, + inboundDelta, nowMs - lastProbeAt, + )) + { + scheduleCycle(effectiveIntervalSec(adapted) * 1000L) + return + } + lastProbeAt = nowMs + /* screen off: one target is enough (the confirmation probe uses it too) */ + val targets = if (adapted.adaptiveMode && !powerState.screenOn && config.targets.size > 1) + { + setOf(config.targets.first()) + } + else + { + config.targets + } val mode = config.serverCheckMode val timeout = config.timeoutMs @@ -609,6 +638,21 @@ class HealthCheckManager private constructor( tunnelAttempts = 0 fastPollShifts = 0 pendingDecision = null + lastCycleInbound = -1L + lastProbeAt = 0L + } + + /** Inbound byte counter of the tunnel's CHILD_SAs, -1 when unavailable. */ + private fun tunnelInboundBytes(): Long + { + return try + { + CharonVpnService.queryTraffic()?.getOrNull(0) ?: -1L + } + catch (_: Throwable) + { + -1L + } } private fun registerVpnCallback() @@ -705,6 +749,32 @@ class HealthCheckManager private constructor( private const val FAST_POLL_MAX_SHIFTS = 3 private const val AGGRESSIVE_INTERVAL_SEC = 5 + /** Inbound tunnel payload since the last cycle that counts as proof of life. */ + internal const val PASSIVE_MIN_INBOUND_BYTES = 4096L + + /** Even with traffic flowing a real probe runs at least this often. */ + internal const val PASSIVE_MAX_PROBE_AGE_MS = 5 * 60 * 1000L + + /** + * Pure function: whether the cycle may skip its probes because the + * tunnel carried inbound data since the previous cycle. Only in + * adaptive mode, never in aggressive mode, never while a failure is + * being observed, and never for longer than [PASSIVE_MAX_PROBE_AGE_MS]. + * Outbound-only traffic proves nothing, so it does not count. + */ + fun trafficProvesAlive( + adapted: HealthConfig, + failedAttempts: Int, + fastPollShifts: Int, + decisionPending: Boolean, + inboundDelta: Long, + sinceLastProbeMs: Long, + ): Boolean = + adapted.adaptiveMode && !adapted.aggressiveMode && + failedAttempts == 0 && fastPollShifts == 0 && !decisionPending && + inboundDelta >= PASSIVE_MIN_INBOUND_BYTES && + sinceLastProbeMs in 0 until PASSIVE_MAX_PROBE_AGE_MS + /** Minimum probe interval in adaptive mode while the screen is off. */ internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60 diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/connectivity/HealthCheckManagerTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/connectivity/HealthCheckManagerTest.kt index 0eaf912dc..b120ada46 100644 --- a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/connectivity/HealthCheckManagerTest.kt +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/connectivity/HealthCheckManagerTest.kt @@ -242,4 +242,30 @@ class HealthCheckManagerTest assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 3)).isTrue() assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse() } + + @Test + fun inboundTrafficSkipsProbesInAdaptiveMode() + { + assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, 60_000)).isTrue() + } + + @Test + fun noPassiveSkipWithoutInboundOrAfterCap() + { + assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 100, 60_000)).isFalse() + assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, -1, 60_000)).isFalse() + assertThat( + HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, HealthCheckManager.PASSIVE_MAX_PROBE_AGE_MS) + ).isFalse() + } + + @Test + fun noPassiveSkipWhileFailureIsObservedOrNotAdaptive() + { + assertThat(HealthCheckManager.trafficProvesAlive(config, 1, 0, false, 10_000, 1000)).isFalse() + assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 2, false, 10_000, 1000)).isFalse() + assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, true, 10_000, 1000)).isFalse() + assertThat(HealthCheckManager.trafficProvesAlive(config.copy(adaptiveMode = false), 0, 0, false, 10_000, 1000)).isFalse() + assertThat(HealthCheckManager.trafficProvesAlive(config.copy(aggressiveMode = true), 0, 0, false, 10_000, 1000)).isFalse() + } } diff --git a/src/frontends/android/docs/energy-plan.md b/src/frontends/android/docs/energy-plan.md index fb4f9b460..ac5918f7f 100644 --- a/src/frontends/android/docs/energy-plan.md +++ b/src/frontends/android/docs/energy-plan.md @@ -50,19 +50,24 @@ ## 3. Кандидаты на оптимизацию (по убыванию ожидаемого эффекта) -### A. Проверка здоровья: пассивные сигналы вместо проб (высокий эффект, средняя сложность) +### A. Проверка здоровья: пассивные сигналы вместо проб (сделано в этой ветке) `HealthCheckManager` каждые 30 с (по умолчанию в `HealthConfig`; проверить фактический дефолт в UI настроек) шлёт HTTP/ICMP-пробы через туннель — каждая держит радио. Идеи: - пропускать цикл, если по `TrafficStats` за интервал был входящий трафик через туннель (туннель очевидно жив); - при выключенном экране: один таргет вместо трёх, интервал 5 мин; -- не стартовать цикл вообще, пока устройство в Doze (ждать выхода/`ACTION_SCREEN_ON`). +- не стартовать цикл вообще, пока устройство в Doze — не делалось: корутинные таймеры и так не будят спящий CPU. -### B. Keepalive и будильники strongSwan (высокий эффект, проверить) -`Scheduler.java` ставит `setExactAndAllowWhileIdle(RTC_WAKEUP)` на NAT-keepalive и ребилд IKE. Профильный `nat_keepalive` -ограничен 10–120 с (`Constants.NAT_KEEPALIVE_MIN/MAX`); при пустом значении берётся умолчание charon. -- проверить, что реально приходит из `.sswan` и какое значение у charon по умолчанию; -- рассмотреть умолчание 30–60 с для мобильной сети (после проверки, что NAT оператора не рвёт соединение) и большие значения на Wi-Fi; -- замерить число будильников в `dumpsys alarm` за ночь. +Реализовано: в адаптивном режиме цикл пропускает пробы, если за интервал через туннель пришло ≥ 4 КБ входящих данных +(`CharonVpnService.queryTraffic()`), но реальная проба идёт не реже раза в 5 минут; при наблюдаемом сбое, ожидающем решении, +агрессивном режиме или неизвестном счётчике пропуска нет. При выключенном экране проверяется один адрес вместо трёх. +Сама проверка здоровья включается пользователем (`HealthConfig.enabled = false` по умолчанию), поэтому выигрыш — для тех, кто её включил. + +### B. Keepalive и будильники strongSwan — проверено, код не менялся +Умолчание уже экономное: `charonservice.c` ставит `charon.keep_alive = 45` с (`ANDROID_KEEPALIVE_INTERVAL`, у upstream-strongSwan 20 с) +и DPD-запас 20 с; профильный `nat_keepalive` (10–120 с) его переопределяет. Keepalive уходит только когда исходящего трафика не было. +Поднимать 45 с выше без замеров нельзя: UDP-таймауты NAT операторов бывают 30–60 с, туннель «жив», но молчит — это прямое нарушение условия раздела 5. +Что остаётся: замерить число будильников (`dumpsys alarm`) за ночь и, если вклад велик, добавить в профиль **необязательную** настройку, +не меняя умолчание; рекомендовать значения по результатам теста на конкретной сети пользователя. ### C. Единый хаб сетевых колбэков (средний эффект, средняя сложность) `registerNetworkCallback` вызывают минимум семь компонентов: `CharonVpnService`, `NetworkManager`, `NetworkMonitor`,