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:
1 parent
e0113e3413
commit
38e5fd84bb
6 files changed
+316
-40
No files matched your search
+15
@@ -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
|
||||
|
||||
+66
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-11
@@ -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
|
||||
|
||||
+12
-5
@@ -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 */
|
||||
|
||||
@@ -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`.
|
||||
Reference in new issue
Block a user