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 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-14 01:18:15 +07:00
1 parent e0113e3413
commit 38e5fd84bb
6 files changed
+316 -40

No files matched your search

@@ -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
@@ -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<List<Scenario>>
@@ -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)
}
}
@@ -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<Pair<VpnStateService.State, String?>?>(null) {
while (true)
{
val service = VpnStateService.getLiveInstance()
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
delay(1500)
val vpn by produceState<Pair<VpnStateService.State, String?>?>(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<Long?>(null, profile.gateway) {
while (true)
{
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
delay(30_000)
val rtt by produceState<Long?>(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
@@ -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
@@ -17,6 +17,9 @@
*/
#include <errno.h>
#include <poll.h>
#include <stdint.h>
#include <sys/eventfd.h>
#include <unistd.h>
#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 */
+134
View File
@@ -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 <pid>` / 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`.