From 38e5fd84bbd01bb5da71697d27768001af98e981 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 14 Oct 2026 01:18:15 +0700 Subject: [PATCH] android: less idle CPU work: TUN reader waits on poll+eventfd instead of a 1 s select; scenario app polling stops with the screen and slows down when idle; site DNS refresh waits for the screen; ServerActivity and rememberNow loops follow the lifecycle; energy plan Co-Authored-By: Claude Sonnet 5.5 --- .../android/logic/routing/RoutingManager.kt | 15 ++ .../android/logic/scenarios/Scenarios.kt | 78 ++++++++-- .../strongswan/android/ui/ServerActivity.kt | 28 ++-- .../android/ui/compose/home/HomeStatus.kt | 17 ++- .../backend/android_service.c | 84 +++++++++-- src/frontends/android/docs/energy-plan.md | 134 ++++++++++++++++++ 6 files changed, 316 insertions(+), 40 deletions(-) create mode 100644 src/frontends/android/docs/energy-plan.md diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt index 897d1c7a4..48d434bcd 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt @@ -542,10 +542,22 @@ class RoutingManager private constructor(private val context: Context) break } delay(SITE_REFRESH_MS) + /* nobody looks at the screen: resolving only wakes the radio and + * CPU, so wait for it to come on (re-checked every few minutes), + * but never let the answers get older than the cap */ + var waited = SITE_REFRESH_MS + while (waited < SITE_REFRESH_SCREEN_OFF_MAX_MS && !isScreenOn()) + { + delay(SITE_SCREEN_RECHECK_MS) + waited += SITE_SCREEN_RECHECK_MS + } } } } + private fun isScreenOn(): Boolean = + (context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager)?.isInteractive ?: true + /** * Like [updateConfig], but force prefixes apply on the fly through the * atomic set swap of the userspace router - no reconnect needed. @@ -833,6 +845,9 @@ class RoutingManager private constructor(private val context: Context) private const val KEY_NOTICE_ONLY = "notice_only_profiles" private const val RECONNECT_DEBOUNCE_MS = 2000L const val SITE_REFRESH_MS = 30L * 60 * 1000 + /* with the screen off the refresh waits for it, up to this age */ + private const val SITE_REFRESH_SCREEN_OFF_MAX_MS = 3L * 60 * 60 * 1000 + private const val SITE_SCREEN_RECHECK_MS = 5L * 60 * 1000 /** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */ const val CONFIG_VERSION = 2 diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt index 684396777..200a725e6 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt @@ -21,6 +21,7 @@ import android.app.usage.UsageStatsManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.VpnService @@ -161,6 +162,9 @@ object ScenarioEngine private const val TAG = "Scenarios" private const val FILE_NAME = "scenarios.json" private const val POLL_MS = 4000L + /* the foreground app did not change for a minute: poll half as often */ + private const val SLOW_AFTER_POLLS = 15 + private const val SLOW_POLL_MS = 8000L private const val PREF_APPLIED = "pref_scenario_applied" private const val PREF_WAS_ON = "pref_scenario_was_on" private const val PREF_WAS_PROFILE = "pref_scenario_was_profile" @@ -174,6 +178,7 @@ object ScenarioEngine private val handler = Handler(Looper.getMainLooper()) private var appContext: Context? = null private var polling = false + private var unchangedPolls = 0 private var lastForeground: String? = null fun flow(context: Context): StateFlow> @@ -203,35 +208,84 @@ object ScenarioEngine if (needPoll && !polling) { polling = true - handler.post(poll) + registerScreenReceiver(app) + if (isScreenOn(app)) + { + handler.post(poll) + } } else if (!needPoll) { polling = false handler.removeCallbacks(poll) + unregisterScreenReceiver(app) } } + private fun isScreenOn(context: Context): Boolean = + (context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true + + /* the foreground app only changes while the screen is on: no polling (and no + * wake-ups for it) while it is off, one immediate poll when it comes back */ + private val screenReceiver = object : BroadcastReceiver() + { + override fun onReceive(context: Context, intent: Intent) + { + handler.removeCallbacks(poll) + if (polling && intent.action == Intent.ACTION_SCREEN_ON) + { + unchangedPolls = 0 + handler.post(poll) + } + } + } + private var screenReceiverRegistered = false + + private fun registerScreenReceiver(app: Context) + { + if (screenReceiverRegistered) + { + return + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + } + runCatching { app.registerReceiver(screenReceiver, filter, null, handler) } + .onSuccess { screenReceiverRegistered = true } + } + + private fun unregisterScreenReceiver(app: Context) + { + if (!screenReceiverRegistered) + { + return + } + runCatching { app.unregisterReceiver(screenReceiver) } + screenReceiverRegistered = false + } + private val poll = object : Runnable { override fun run() { val app = appContext ?: return - if (!polling) - { + if (!polling || !isScreenOn(app)) + { /* resumed by the screen-on broadcast */ return } - val screenOn = (app.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true - if (screenOn) + val fg = foregroundApp(app) + if (fg != null && fg != lastForeground) { - val fg = foregroundApp(app) - if (fg != null && fg != lastForeground) - { - lastForeground = fg - evaluate(app) - } + lastForeground = fg + unchangedPolls = 0 + evaluate(app) } - handler.postDelayed(this, POLL_MS) + else + { + unchangedPolls++ + } + handler.postDelayed(this, if (unchangedPolls >= SLOW_AFTER_POLLS) SLOW_POLL_MS else POLL_MS) } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt index 42d123828..f583d21d4 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt @@ -71,6 +71,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions @@ -193,19 +194,24 @@ private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit) val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle() val extra = extras[uuid] ?: ProfileExtra(uuid) /* the live state of the tunnel and a direct ping of the server */ - val vpn by produceState?>(null) { - while (true) - { - val service = VpnStateService.getLiveInstance() - value = service?.let { it.state to it.profile?.getUUID()?.toString() } - delay(1500) + val vpn by produceState?>(null, owner) { + /* only while the page is on screen */ + owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) + { + val service = VpnStateService.getLiveInstance() + value = service?.let { it.state to it.profile?.getUUID()?.toString() } + delay(1500) + } } } - val rtt by produceState(null, profile.gateway) { - while (true) - { - value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first } - delay(30_000) + val rtt by produceState(null, profile.gateway, owner) { + owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) + { + value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first } + delay(30_000) + } } } val active = vpn?.second == uuid diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt index e136e5097..703b0dd10 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt @@ -40,6 +40,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.delay import org.strongswan.android.R import org.strongswan.android.logic.PauseManager @@ -103,12 +106,16 @@ internal fun HomeProblem.titleRes(): Int = when (this) internal fun rememberNow(): Long { var now by remember { mutableLongStateOf(System.currentTimeMillis()) } - LaunchedEffect(Unit) - { - while (true) + val lifecycle = LocalLifecycleOwner.current.lifecycle + LaunchedEffect(lifecycle) + { /* no ticking while the app is in the background */ + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - now = System.currentTimeMillis() - delay(1000L - now % 1000L) + while (true) + { + now = System.currentTimeMillis() + delay(1000L - now % 1000L) + } } } return now diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c index c901f0657..c8fc5296e 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c @@ -17,6 +17,9 @@ */ #include +#include +#include +#include #include #include "android_jni.h" @@ -70,6 +73,12 @@ struct private_android_service_t { */ int tunfd; + /** + * eventfd that wakes the TUN reader whenever tunfd is replaced or closed + * (-1 if it could not be created, then the reader falls back to a 1 s poll) + */ + int wakefd; + /** * MTU of TUN device */ @@ -136,6 +145,21 @@ static void engine_write_packet(void *ctx, ip_packet_t *packet) deliver_plain(ctx, packet); } +/** + * Wake the TUN reader so it picks up a replaced or closed tunfd. Call it + * after this->tunfd was changed (the eventfd counter stays readable until the + * reader drains it, so a wake-up before the reader polls is not lost). + */ +static void wake_reader(private_android_service_t *this) +{ + uint64_t one = 1; + + if (this->wakefd >= 0 && write(this->wakefd, &one, sizeof(one)) < 0) + { + DBG1(DBG_DMN, "waking the TUN reader failed: %s", strerror(errno)); + } +} + /** * Job handling outbound plaintext packets */ @@ -143,16 +167,10 @@ static job_requeue_t handle_plain(private_android_service_t *this) { ip_packet_t *packet; chunk_t raw; - fd_set set; + struct pollfd fds[2]; ssize_t len; - int tunfd; + int tunfd, wakefd, nfds = 1, timeout; bool old, dns_proxy; - timeval_t tv = { - /* check every second if tunfd is still valid */ - .tv_sec = 1, - }; - - FD_ZERO(&set); this->lock->read_lock(this->lock); if (this->tunfd < 0) @@ -161,28 +179,60 @@ static job_requeue_t handle_plain(private_android_service_t *this) return JOB_REQUEUE_NONE; } tunfd = this->tunfd; - FD_SET(tunfd, &set); + wakefd = this->wakefd; /* cache this while we have the lock */ dns_proxy = this->use_dns_proxy; this->lock->unlock(this->lock); + fds[0].fd = tunfd; + fds[0].events = POLLIN; + fds[0].revents = 0; + if (wakefd >= 0) + { /* a replaced or closed tunfd wakes us, so the timeout is only a + * safety net; without the eventfd re-check every second as before */ + fds[1].fd = wakefd; + fds[1].events = POLLIN; + fds[1].revents = 0; + nfds = 2; + timeout = 60000; + } + else + { + timeout = 1000; + } + old = thread_cancelability(TRUE); - len = select(tunfd + 1, &set, NULL, NULL, &tv); + len = poll(fds, nfds, timeout); thread_cancelability(old); if (len < 0) { if (errno == EBADF) - { /* the TUN device got closed just before calling select(), retry */ + { /* the TUN device got closed just before calling poll(), retry */ return JOB_REQUEUE_FAIR; } - DBG1(DBG_DMN, "select on TUN device failed: %s", strerror(errno)); + DBG1(DBG_DMN, "poll on TUN device failed: %s", strerror(errno)); return JOB_REQUEUE_NONE; } else if (len == 0) { /* timeout, check again right away */ return JOB_REQUEUE_DIRECT; } + if (nfds == 2 && (fds[1].revents & POLLIN)) + { /* tunfd was replaced or closed: drain the counter and start over with + * the current fd (pending TUN data stays, poll is level-triggered) */ + uint64_t cnt; + + if (read(wakefd, &cnt, sizeof(cnt)) < 0 && errno != EAGAIN) + { + DBG1(DBG_DMN, "reading the wake-up fd failed: %s", strerror(errno)); + } + return JOB_REQUEUE_DIRECT; + } + if (fds[0].revents & POLLNVAL) + { /* the fd was closed while polling, retry with the current one */ + return JOB_REQUEUE_FAIR; + } raw = chunk_alloc(this->mtu); len = read(tunfd, raw.ptr, raw.len); @@ -375,6 +425,8 @@ static bool setup_tun_device(private_android_service_t *this, } this->tunfd = tunfd; this->lock->unlock(this->lock); + /* a reader polling the previous fd must switch to the new one */ + wake_reader(this); DBG1(DBG_DMN, "successfully created TUN device"); @@ -422,6 +474,7 @@ static bool setup_tun_device_without_dns(private_android_service_t *this) } this->tunfd = tunfd; this->lock->unlock(this->lock); + wake_reader(this); /* the fd changed, re-bind the userspace router to the new TUN */ direct_engine_set_tun(this, engine_write_packet, this->mtu); @@ -446,6 +499,8 @@ static void close_tun_device(private_android_service_t *this) tunfd = this->tunfd; this->tunfd = -1; this->lock->unlock(this->lock); + /* let the reader notice tunfd < 0 and quit instead of polling a dead fd */ + wake_reader(this); /* drop the remembered binding too, so a lazily created engine does not * attach to the dead TUN, and flush the engine if it exists */ @@ -977,6 +1032,10 @@ METHOD(android_service_t, destroy, void, close_tun_device(this); this->dns_proxy->destroy(this->dns_proxy); this->lock->destroy(this->lock); + if (this->wakefd >= 0) + { + close(this->wakefd); + } this->settings->destroy(this->settings); free(this); } @@ -1005,6 +1064,7 @@ android_service_t *android_service_create(android_creds_t *creds, .settings = settings, .creds = creds, .tunfd = -1, + .wakefd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC), .mtu = settings->get_int(settings, "global.mtu", ANDROID_DEFAULT_MTU), ); /* only allow queries for the VPN gateway */ diff --git a/src/frontends/android/docs/energy-plan.md b/src/frontends/android/docs/energy-plan.md new file mode 100644 index 000000000..fb4f9b460 --- /dev/null +++ b/src/frontends/android/docs/energy-plan.md @@ -0,0 +1,134 @@ +# План оптимизации энергопотребления SHX VPN (Android) + +Основа — разбор кода версии 2.18.0 (165). Пункты, помеченные «проверить», в коде не подтверждены и требуют замера +или чтения кода перед правкой. Цель: меньше пробуждений CPU и радио при подключённом туннеле с погашенным экраном. + +## 1. Что уже сделано (не трогать без замеров) + +- `HealthCheckManager`: адаптивный режим (экран выключен → интервал не меньше 60 с, экономия заряда или низкий заряд → 300 с, без быстрых опросов), + экспоненциальный бэкофф при сбое, без будильников и wakelock'ов. +- `WatchdogReceiver`: будильники растягиваются — «припаркован» 5 мин / 30 мин (wakeup), «соединён» 5 мин / 60 мин. +- `HomeViewModel`: опрос трафика (1 с) и проб (10 с) только пока видна вкладка «Главная» и VPN соединён (`repeatOnLifecycle` + `whileHomeConnected`). +- `CharonVpnService`: диагностика маршрутов раз в 5 мин только в расширенном режиме; уведомление не чаще раза в 5 с (`NOTIFICATION_MIN_INTERVAL_MS`). +- Фоновая работа: `WorkManager` (обновления 12 ч — с условиями сети и зарядки, профили 24 ч, итоги недели 7 дн.), wakelock'ов в коде нет. +- `DiagLog`: писатель открыт, ротация по счётчику байт, а не по `stat` на каждую строку. + +## 1a. Главный упор: лишняя работа CPU + +Приоритет всего плана — убрать работу, которую телефон делает без пользы (пробуждения, холостые циклы, повторные вычисления). +Критерий: меньше CPU-времени и пробуждений в час на подключённом туннеле, при погашенном экране — близко к нулю сверх IKE/ESP. + +**Подтверждено по коду:** +- **Холостое пробуждение раз в секунду в нативе.** `backend/android_service.c`: цикл чтения TUN делает `select()` с таймаутом 1 с + («check every second if tunfd is still valid») и по таймауту сразу возвращается в очередь заданий — поток просыпается каждую секунду + даже без трафика. Заменить на ожидание без таймера: `poll()` по TUN и `eventfd`/pipe, который будит закрытие TUN (или таймаут 30–60 с как страховка). + Это самый дешёвый и самый бесспорный выигрыш: один поток, ~3600 пробуждений в час → единицы. +- Движок прямых потоков (`routing/direct_engine.c`) уже адаптивен: простой растягивает `poll` до 4 с (1 с, пока есть TCP-потоки). + Остаётся: не вызывать `tcp->check_timers` / `sys_check_timeouts` и не пересобирать `pollfd` в холостых раундах, довести холостой интервал + до 10–30 с при погашенном экране (будить через `wake`-fd при новом пакете — он уже есть). +- Java: `ScenarioEngine.poll` (4 с, IPC в `UsageStatsManager`), `rememberNow()` (1 с), опрос трафика 1 с — только при видимой «Главной», это нормально. +- `RoutingManager.refreshSites` каждые 30 мин резолвит все домены — разовая нагрузка на радио и CPU, но без пользы при погашенном экране. +- `DiagLog`: каждая строка — `format.format(Date())` + `toByteArray` + `flush()` (системный вызов). При частых событиях сети это лишний CPU и запись во флеш. + +**Проверить замером (гипотезы):** +- сколько CPU съедает разбор/поиск по базе адресов РФ (`RuIpListManager`, `IPRangeSet`) при загрузке и обновлении, и не пересобирается ли набор без изменений + (сравнивать хэш до применения, не вызывать `pushUserspaceRouting` без реального диффа); +- Compose: лишние рекомпозиции «Главной» (счётчик трафика раз в секунду трогает большое дерево?) — Layout Inspector / recomposition counts; +- число одновременных `NetworkCallback` (см. C) — каждое событие сети запускает дублирующую работу в семи местах. + +## 2. Фаза 0 — замер базовой линии (до любых правок) + +Без цифр правки ниже — гипотезы. Сценарии (эмулятор не годится, нужен реальный телефон): + +1. Ночь: VPN соединён, экран выключен, Wi-Fi, 8 ч. То же на мобильной сети. +2. Те же условия с включёнными: проверкой здоровья, сценарием «приложение открыто», сайтами в правилах, «сайты РФ напрямую». +3. День: экран включён, вкладки «Главная» / «Маршруты» / «Диагностика». + +Инструменты: `adb shell dumpsys batterystats --reset` → прогон → `dumpsys batterystats one.shx.strongswan.ext`, +`dumpsys alarm` (число и тип будильников пакета), `dumpsys jobscheduler`, Perfetto (пробуждения CPU, радио), Battery Historian. +Фиксировать: mAh за час, число wakeup-будильников в час, время активного радио, CPU нативного роутера. + +## 3. Кандидаты на оптимизацию (по убыванию ожидаемого эффекта) + +### A. Проверка здоровья: пассивные сигналы вместо проб (высокий эффект, средняя сложность) +`HealthCheckManager` каждые 30 с (по умолчанию в `HealthConfig`; проверить фактический дефолт в UI настроек) шлёт HTTP/ICMP-пробы +через туннель — каждая держит радио. Идеи: +- пропускать цикл, если по `TrafficStats` за интервал был входящий трафик через туннель (туннель очевидно жив); +- при выключенном экране: один таргет вместо трёх, интервал 5 мин; +- не стартовать цикл вообще, пока устройство в Doze (ждать выхода/`ACTION_SCREEN_ON`). + +### 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` за ночь. + +### C. Единый хаб сетевых колбэков (средний эффект, средняя сложность) +`registerNetworkCallback` вызывают минимум семь компонентов: `CharonVpnService`, `NetworkManager`, `NetworkMonitor`, +`WhiteListManager`, `HealthCheckManager`, `PauseManager`, `HomeViewModel`. Каждое изменение сети будит их все и дублирует +работу (запросы SSID, перепроверки). Сделать один подписчик в приложении, раздающий состояние через `StateFlow`; +остальные подписываются на него. Попутно уменьшает риск лимита колбэков (в `HomeViewModel` он уже обрабатывается `catch`). + +### D. Опрос переднего приложения в сценариях (средний эффект, низкая сложность) +`ScenarioEngine.poll` — каждые 4 с `UsageStatsManager.queryEvents` (IPC), пока есть сценарий с триггером «приложение». +При выключенном экране выборка пропускается, но `postDelayed` продолжает тикать. +- остановить цикл по `ACTION_SCREEN_OFF`, запускать по `ACTION_SCREEN_ON`; +- увеличить интервал до 8–10 с, если foreground-приложение не менялось несколько циклов. + +### E. Обновление адресов сайтов из правил (средний эффект, низкая сложность) +`RoutingManager.refreshSites` каждые 30 мин резолвит все домены правил, пока соединён VPN, независимо от экрана. +- пока экран выключен — не резолвить; обновить один раз при `ACTION_SCREEN_ON`, если прошло больше `SITE_REFRESH_MS`; +- резолвить только домены, чьи TTL истекли. + +### F. UI-циклы после ухода с экрана (низкий эффект, низкая сложность) +Проверить, что все циклы отвязаны от жизненного цикла (через `repeatOnLifecycle(STARTED)`): +`RoutingScreen` (5 с, уже на `repeatOnLifecycle`), `DiagnosticsScreen` (5 с), `ProfilesScreen` и `ServerActivity` (30 с, 1,5 с), +`RecentAppsScreen` (10 с), `LogActivity` (800 мс), `rememberNow()` в `HomeStatus.kt` (тик каждую секунду, пока composable жив). +Отдельно: `rememberNow()` должен тикать только если на экране есть обратный отсчёт паузы или время подключения. + +### G. Нативный роутер «сайты РФ напрямую» (неизвестный эффект, замер) +Пользовательский роутер (`getRoutingStats`, relay UDP/TCP) обрабатывает пакеты в процессе. Замерить CPU-время потоков +в Perfetto в режимах «Стабильно» и «Гибко» на типовой нагрузке (видео, мессенджер). Если вклад заметный — смотреть размеры очередей, +размер буферов, пакетную обработку и спин-ожидание в нативной части (`app/src/main/jni`). + +### H. Мелочи +- `DiagLog`: flush на каждую строку нужен для живучести; при выключенном экране можно понизить подробность (watchdog, network-change) + или не логировать повторы. +- Проверить `WhiteListManager` (перепроверки `recheckJob`, `refreshIfStale`) на периодические пробы при выключенном экране. +- Подсказка пользователю на экране «Батарея» (`BatteryActivity`): показывать, какие функции (проверка здоровья, сценарии по приложению) + заметно расходуют заряд и как их смягчить. + +## 4. Порядок работ + +1. Фаза 0: базовая линия на реальном телефоне (раздел 2); для CPU добавить `adb shell top -H -p ` / Perfetto по потокам (счёт пробуждений потока чтения TUN). +2. Быстрые правки с низким риском: секундный `select` TUN (раздел 1a), D, E, F, `DiagLog` — один релиз, сравнить с базой. +3. A (пассивная проверка здоровья) и B (keepalive) — отдельные релизы, каждый со своим замером; B только после проверки на сетях операторов. +4. C (хаб колбэков) — рефакторинг, после A/B; регрессия по сценариям смены сети (Wi-Fi ↔ мобильная, роуминг, белые списки, пауза). +5. G — по результатам Perfetto. +6. После каждой правки: тот же прогон из фазы 0; принимается, если mAh/ч и число будильников не растут, а надёжность не падает. + +## 5. Жёсткое условие: туннель не должен падать в фоне + +Экономия не оправдывает потери туннеля при свёрнутом приложении, погашенном экране или Doze. Правила для каждой правки: + +- **Не трогать** то, что держит процесс и туннель живыми: foreground-сервис `CharonVpnService` (`startForegroundSafe`), будильники `Scheduler` + (NAT-keepalive, rekey), `FgsStartRetry`, страховочные будильники `WatchdogReceiver`, DPD charon. Их интервалы не растягивать «заодно». +- **Растягивать можно только** вторичные проверки поверх IKE DPD (пробы здоровья, опросы UI/сценариев, диагностика, DNS сайтов). + Любое растяжение обязано иметь быстрый выход по событию: смена сети, `ACTION_SCREEN_ON`, потеря валидации VPN-сети (`vpnCallback`). +- **TUN `select` (1а):** секундный таймаут сейчас ещё и ловит закрытие TUN (`EBADF`). Замена обязана будить поток при закрытии + (`eventfd` пишется в `close`/реконнекте/реавторизации) — иначе поток зависнет на закрытом дескрипторе, и туннель не переподнимется. + Страховочный таймаут оставить (30–60 с), не убирать совсем. +- **Keepalive (B):** менять умолчание только после проверки на реальных операторах; слишком большой интервал — NAT закрывает соединение, туннель «жив», но молчит. +- **Пробы здоровья (A):** пассивный сигнал «трафик идёт» не должен подавлять проверку, если исходящий трафик есть, а входящего нет. +- **Приёмка каждого релиза** (кроме замеров): ночь с погашенным экраном — туннель не разорван, входящий пуш/пинг проходит утром без ручных действий; + `dumpsys deviceidle force-idle` → выход из Doze → трафик идёт; смена Wi-Fi ↔ мобильная сеть при погашенном экране; убийство процесса + (`adb shell am kill`) → watchdog поднимает туннель; `adb logcat -b crash` чист. Не проходит хотя бы один пункт — правка откатывается. + +## 6. Риски и ограничения + +- Надёжность туннеля важнее экономии: правки A, B, E не должны ухудшать восстановление после смены сети и Doze + (смоделировать `adb shell dumpsys deviceidle force-idle`, отключение и возврат сети, подмену SSID). +- minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать. +- Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения). +- Строки UI, если появятся (подсказки в «Батарее»), добавлять сразу во все семь `strings.xml`.