Compare commits

4 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 6756d2fca5 android: version 2.18.1 (166)
Android / pre-check (push) Failing after 5s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 5s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:52:12 +07:00
DenozordecandClaude Sonnet 5.5 5998c0fc46 android: Home view model no longer re-reads the Wi-Fi SSID on every signal-strength tick (state recomputed only when the transports change); energy plan: no shared network-callback hub, why
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:44:03 +07:00
DenozordecandClaude Sonnet 5.5 f5b15a0394 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 <noreply@anthropic.com>
2026-10-14 01:22:09 +07:00
DenozordecandClaude Sonnet 5.5 38e5fd84bb 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>
2026-10-14 01:18:15 +07:00
11 changed files with 435 additions and 45 deletions

No files matched your search

+2 -2
View File
@@ -45,8 +45,8 @@ android {
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode 165 versionCode 166
versionName "2.18.0" versionName "2.18.1"
/* Base URL of the update storage, trailing slash included */ /* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\"" buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
@@ -30,6 +30,7 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.os.SystemClock
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.VpnStateService import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.events.Events import org.strongswan.android.logic.events.Events
@@ -130,6 +132,9 @@ class HealthCheckManager private constructor(
private var tunnelAttempts = 0 private var tunnelAttempts = 0
private var fastPollShifts = 0 private var fastPollShifts = 0
private var powerState = PowerState() 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 */ /* tracks screen/battery state so probing can back off while the device sleeps */
private val powerReceiver = object : BroadcastReceiver() private val powerReceiver = object : BroadcastReceiver()
@@ -360,7 +365,31 @@ class HealthCheckManager private constructor(
return return
} }
val gateway = svc.profile?.gateway 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 mode = config.serverCheckMode
val timeout = config.timeoutMs val timeout = config.timeoutMs
@@ -609,6 +638,21 @@ class HealthCheckManager private constructor(
tunnelAttempts = 0 tunnelAttempts = 0
fastPollShifts = 0 fastPollShifts = 0
pendingDecision = null 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() private fun registerVpnCallback()
@@ -705,6 +749,32 @@ class HealthCheckManager private constructor(
private const val FAST_POLL_MAX_SHIFTS = 3 private const val FAST_POLL_MAX_SHIFTS = 3
private const val AGGRESSIVE_INTERVAL_SEC = 5 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. */ /** Minimum probe interval in adaptive mode while the screen is off. */
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60 internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
@@ -542,10 +542,22 @@ class RoutingManager private constructor(private val context: Context)
break break
} }
delay(SITE_REFRESH_MS) 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 * Like [updateConfig], but force prefixes apply on the fly through the
* atomic set swap of the userspace router - no reconnect needed. * 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 KEY_NOTICE_ONLY = "notice_only_profiles"
private const val RECONNECT_DEBOUNCE_MS = 2000L private const val RECONNECT_DEBOUNCE_MS = 2000L
const val SITE_REFRESH_MS = 30L * 60 * 1000 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. */ /** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
const val CONFIG_VERSION = 2 const val CONFIG_VERSION = 2
@@ -21,6 +21,7 @@ import android.app.usage.UsageStatsManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.VpnService import android.net.VpnService
@@ -161,6 +162,9 @@ object ScenarioEngine
private const val TAG = "Scenarios" private const val TAG = "Scenarios"
private const val FILE_NAME = "scenarios.json" private const val FILE_NAME = "scenarios.json"
private const val POLL_MS = 4000L 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_APPLIED = "pref_scenario_applied"
private const val PREF_WAS_ON = "pref_scenario_was_on" private const val PREF_WAS_ON = "pref_scenario_was_on"
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile" private const val PREF_WAS_PROFILE = "pref_scenario_was_profile"
@@ -174,6 +178,7 @@ object ScenarioEngine
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var appContext: Context? = null private var appContext: Context? = null
private var polling = false private var polling = false
private var unchangedPolls = 0
private var lastForeground: String? = null private var lastForeground: String? = null
fun flow(context: Context): StateFlow<List<Scenario>> fun flow(context: Context): StateFlow<List<Scenario>>
@@ -203,35 +208,84 @@ object ScenarioEngine
if (needPoll && !polling) if (needPoll && !polling)
{ {
polling = true polling = true
handler.post(poll) registerScreenReceiver(app)
if (isScreenOn(app))
{
handler.post(poll)
}
} }
else if (!needPoll) else if (!needPoll)
{ {
polling = false polling = false
handler.removeCallbacks(poll) 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 private val poll = object : Runnable
{ {
override fun run() override fun run()
{ {
val app = appContext ?: return val app = appContext ?: return
if (!polling) if (!polling || !isScreenOn(app))
{ { /* resumed by the screen-on broadcast */
return return
} }
val screenOn = (app.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true val fg = foregroundApp(app)
if (screenOn) if (fg != null && fg != lastForeground)
{ {
val fg = foregroundApp(app) lastForeground = fg
if (fg != null && fg != lastForeground) unchangedPolls = 0
{ evaluate(app)
lastForeground = fg
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.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions 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 extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
val extra = extras[uuid] ?: ProfileExtra(uuid) val extra = extras[uuid] ?: ProfileExtra(uuid)
/* the live state of the tunnel and a direct ping of the server */ /* the live state of the tunnel and a direct ping of the server */
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null) { val vpn by produceState<Pair<VpnStateService.State, String?>?>(null, owner) {
while (true) /* only while the page is on screen */
{ owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
val service = VpnStateService.getLiveInstance() while (true)
value = service?.let { it.state to it.profile?.getUUID()?.toString() } {
delay(1500) val service = VpnStateService.getLiveInstance()
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
delay(1500)
}
} }
} }
val rtt by produceState<Long?>(null, profile.gateway) { val rtt by produceState<Long?>(null, profile.gateway, owner) {
while (true) owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
{ while (true)
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first } {
delay(30_000) value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
delay(30_000)
}
} }
} }
val active = vpn?.second == uuid val active = vpn?.second == uuid
@@ -199,7 +199,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
{ {
synchronized(underlyingNetworks) synchronized(underlyingNetworks)
{ {
underlyingNetworks[network] = capabilities val previous = underlyingNetworks.put(network, capabilities)
/* fired on every signal-strength tick: the published state (network
* kinds, SSID) only depends on the transports, and reading the SSID
* is a system call, so unchanged transports end here */
if (previous != null && sameTransports(previous, capabilities))
{
return
}
publishNetworks() publishNetworks()
} }
} }
@@ -214,6 +221,10 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
} }
} }
private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
private fun publishNetworks() private fun publishNetworks()
{ {
_networkAvailable.value = underlyingNetworks.isNotEmpty() _networkAvailable.value = underlyingNetworks.isNotEmpty()
@@ -40,6 +40,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.strongswan.android.R import org.strongswan.android.R
import org.strongswan.android.logic.PauseManager import org.strongswan.android.logic.PauseManager
@@ -103,12 +106,16 @@ internal fun HomeProblem.titleRes(): Int = when (this)
internal fun rememberNow(): Long internal fun rememberNow(): Long
{ {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) } var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) val lifecycle = LocalLifecycleOwner.current.lifecycle
{ LaunchedEffect(lifecycle)
while (true) { /* no ticking while the app is in the background */
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
{ {
now = System.currentTimeMillis() while (true)
delay(1000L - now % 1000L) {
now = System.currentTimeMillis()
delay(1000L - now % 1000L)
}
} }
} }
return now return now
@@ -17,6 +17,9 @@
*/ */
#include <errno.h> #include <errno.h>
#include <poll.h>
#include <stdint.h>
#include <sys/eventfd.h>
#include <unistd.h> #include <unistd.h>
#include "android_jni.h" #include "android_jni.h"
@@ -70,6 +73,12 @@ struct private_android_service_t {
*/ */
int tunfd; 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 * MTU of TUN device
*/ */
@@ -136,6 +145,21 @@ static void engine_write_packet(void *ctx, ip_packet_t *packet)
deliver_plain(ctx, 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 * Job handling outbound plaintext packets
*/ */
@@ -143,16 +167,10 @@ static job_requeue_t handle_plain(private_android_service_t *this)
{ {
ip_packet_t *packet; ip_packet_t *packet;
chunk_t raw; chunk_t raw;
fd_set set; struct pollfd fds[2];
ssize_t len; ssize_t len;
int tunfd; int tunfd, wakefd, nfds = 1, timeout;
bool old, dns_proxy; 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); this->lock->read_lock(this->lock);
if (this->tunfd < 0) if (this->tunfd < 0)
@@ -161,28 +179,60 @@ static job_requeue_t handle_plain(private_android_service_t *this)
return JOB_REQUEUE_NONE; return JOB_REQUEUE_NONE;
} }
tunfd = this->tunfd; tunfd = this->tunfd;
FD_SET(tunfd, &set); wakefd = this->wakefd;
/* cache this while we have the lock */ /* cache this while we have the lock */
dns_proxy = this->use_dns_proxy; dns_proxy = this->use_dns_proxy;
this->lock->unlock(this->lock); 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); old = thread_cancelability(TRUE);
len = select(tunfd + 1, &set, NULL, NULL, &tv); len = poll(fds, nfds, timeout);
thread_cancelability(old); thread_cancelability(old);
if (len < 0) if (len < 0)
{ {
if (errno == EBADF) 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; 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; return JOB_REQUEUE_NONE;
} }
else if (len == 0) else if (len == 0)
{ /* timeout, check again right away */ { /* timeout, check again right away */
return JOB_REQUEUE_DIRECT; 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); raw = chunk_alloc(this->mtu);
len = read(tunfd, raw.ptr, raw.len); 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->tunfd = tunfd;
this->lock->unlock(this->lock); 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"); 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->tunfd = tunfd;
this->lock->unlock(this->lock); this->lock->unlock(this->lock);
wake_reader(this);
/* the fd changed, re-bind the userspace router to the new TUN */ /* the fd changed, re-bind the userspace router to the new TUN */
direct_engine_set_tun(this, engine_write_packet, this->mtu); 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; tunfd = this->tunfd;
this->tunfd = -1; this->tunfd = -1;
this->lock->unlock(this->lock); 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 /* drop the remembered binding too, so a lazily created engine does not
* attach to the dead TUN, and flush the engine if it exists */ * 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); close_tun_device(this);
this->dns_proxy->destroy(this->dns_proxy); this->dns_proxy->destroy(this->dns_proxy);
this->lock->destroy(this->lock); this->lock->destroy(this->lock);
if (this->wakefd >= 0)
{
close(this->wakefd);
}
this->settings->destroy(this->settings); this->settings->destroy(this->settings);
free(this); free(this);
} }
@@ -1005,6 +1064,7 @@ android_service_t *android_service_create(android_creds_t *creds,
.settings = settings, .settings = settings,
.creds = creds, .creds = creds,
.tunfd = -1, .tunfd = -1,
.wakefd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC),
.mtu = settings->get_int(settings, "global.mtu", ANDROID_DEFAULT_MTU), .mtu = settings->get_int(settings, "global.mtu", ANDROID_DEFAULT_MTU),
); );
/* only allow queries for the VPN gateway */ /* only allow queries for the VPN gateway */
@@ -242,4 +242,30 @@ class HealthCheckManagerTest
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 3)).isTrue() assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 3)).isTrue()
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse() 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()
}
} }
+1 -1
View File
@@ -1,6 +1,6 @@
# SHX VPN для Android # SHX VPN для Android
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.18.0 (versionCode 165)**. Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.18.1 (versionCode 166)**.
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само. Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду. - Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
+141
View File
@@ -0,0 +1,141 @@
# План оптимизации энергопотребления 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 — не делалось: корутинные таймеры и так не будят спящий CPU.
Реализовано: в адаптивном режиме цикл пропускает пробы, если за интервал через туннель пришло ≥ 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. Сетевые колбэки — единый хаб отклонён, исправлен один шумный
Разбор показал, что хаб не нужен: `NetworkMonitor` и `WhiteListManager` уже отсекают тики уровня сигнала в `onCapabilitiesChanged`,
`PauseManager` регистрируется только на время паузы, проверка здоровья — только при включённой функции и соединённом VPN,
а `NetworkManager` и `UnderlyingNetworkTracker` в `CharonVpnService` — часть самого туннеля (трогать нельзя по разделу 5).
Выигрыш от объединения — единицы вызовов Binder на смену сети при заметном риске для автоподключения и белых списков.
Исправлено единственное реальное место: `HomeViewModel.onCapabilitiesChanged` на каждый тик сигнала Wi-Fi заново читал SSID (системный вызов);
теперь состояние пересчитывается только при смене транспорта.
### 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`.