Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6756d2fca5 | ||
|
|
5998c0fc46 | ||
|
|
f5b15a0394 | ||
|
|
38e5fd84bb |
No files matched your search
@@ -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}\""
|
||||||
|
|||||||
+71
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
+15
@@ -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
|
||||||
|
|||||||
+66
-12
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-11
@@ -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
|
||||||
|
|||||||
+12
-1
@@ -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()
|
||||||
|
|||||||
+12
-5
@@ -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 */
|
||||||
|
|||||||
+26
@@ -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,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» и соответствие артбордов коду.
|
||||||
|
|||||||
@@ -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`.
|
||||||
Reference in new issue
Block a user