android: 2.14.1 - traffic split, exit IP, dead-host visibility (UX phase 1)

- ConnectionDetailsSheet: session traffic split "via VPN / direct (RU)"
  from the native engine counters (userspace mode only; system-routes
  mode shows no split - the engine never sees that traffic), public exit
  IP + country via cloudflare cdn-cgi/trace through the tunnel, current
  underlying network (Wi-Fi + SSID / cellular)
- ConnectionCard: one-glance "N% direct" caption when the RU split moves
  traffic directly
- dead direct hosts surfaced in UI: native snapshot of the negative-cache
  table via new get_dead_targets (mutex-guarded, sorted by failures) +
  JNI getDeadDirectTargets; section on the Routing tab and a badge in the
  Home availability card naming dst, failure count, remaining cooldown
  and the owning app (from the 2.13.15 owner-attribution cache)
- HomeViewModel: split/exit/deadTargets as separate hot flows; exit
  refreshed with the probe round (60s cache), dead targets every round
This commit is contained in:
Denozordec committed 2026-10-05 00:56:47 +07:00
1 parent de5b093d62
commit 7185aa4ba5
13 files changed
+672 -26

No files matched your search

+2 -2
View File
@@ -46,8 +46,8 @@ android {
minSdkVersion 23
targetSdkVersion 36
versionCode 153
versionName "2.14.0"
versionCode 154
versionName "2.14.1"
/* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
@@ -1442,6 +1442,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
}
String[] packages = getPackageManager().getPackagesForUid(uid);
String owner = packages != null && packages.length > 0 ? packages[0] : "uid " + uid;
rememberDeadOwner(dst, owner);
DiagLog.i(TAG, "direct tcp: dst=" + dst + " owner=" + owner);
}
catch (Exception e)
@@ -1521,6 +1522,39 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
*/
public static native long[] getRoutingStats();
/**
* Direct destinations currently refused by the failure cooldown (dead
* hosts that apps keep hammering), provided by libandroidbridge.so.
* Each entry is "ip:port|fails|window_ms|left_ms", most failures first.
*/
public static native String[] getDeadDirectTargets();
/** Last known owning app per dead destination (from owner attribution). */
private static final java.util.Map<String, String> sDeadOwners =
new java.util.LinkedHashMap<>();
/** Remember the app that kept hammering a now-dead destination (UI badge). */
private static void rememberDeadOwner(String dst, String owner)
{
synchronized (sDeadOwners)
{
sDeadOwners.put(dst, owner);
while (sDeadOwners.size() > 32)
{
sDeadOwners.remove(sDeadOwners.keySet().iterator().next());
}
}
}
/** The app last seen hammering the given dead destination, if known. */
public static String getDeadTargetOwner(String dst)
{
synchronized (sDeadOwners)
{
return sDeadOwners.get(dst);
}
}
/**
* Adapter for VpnService.Builder which is used to access it safely via JNI.
* There is a corresponding C object to access it from native code.
@@ -22,7 +22,9 @@ import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Socket
import java.net.URL
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
/** What a probe measures. External targets go through the tunnel, SERVER goes direct. */
enum class ProbeTarget
@@ -42,6 +44,12 @@ data class ProbeResult(
val direct: Boolean = target == ProbeTarget.SERVER,
)
/** Public exit (as seen by the internet on the other end of the tunnel). */
data class ExitInfo(
val ip: String,
val country: String?,
)
/**
* TCP-connect "ping" for external targets and ICMP ping for the VPN server.
*
@@ -143,6 +151,53 @@ object ConnectivityProbe
return match.groupValues[1].toDoubleOrNull()?.toLong()
}
/**
* Resolve the public exit IP (and country code) as seen from the other
* end of the tunnel, via Cloudflare's cdn-cgi/trace. The plain
* connection rides the VPN (the app's default network while connected),
* so the answer reflects the tunnel egress. Returns null on any failure.
*/
suspend fun traceExit(timeoutMs: Int): ExitInfo? = withContext(Dispatchers.IO)
{
try
{
val connection = URL("https://www.cloudflare.com/cdn-cgi/trace").openConnection()
as HttpsURLConnection
try
{
connection.connectTimeout = timeoutMs
connection.readTimeout = timeoutMs
connection.instanceFollowRedirects = false
val text = connection.inputStream.bufferedReader().use { it.readText() }
parseTraceExit(text)
}
finally
{
connection.disconnect()
}
}
catch (_: Exception)
{
null
}
}
/** Extracts ip= / loc= from a cdn-cgi/trace body. Visible for tests. */
fun parseTraceExit(text: String): ExitInfo?
{
var ip: String? = null
var loc: String? = null
text.lineSequence().forEach { line ->
val value = line.substringAfter('=', "").trim()
when
{
line.startsWith("ip=") -> ip = value.takeIf { it.isNotEmpty() }
line.startsWith("loc=") -> loc = value.takeIf { it.isNotEmpty() }
}
}
return ip?.let { ExitInfo(ip = it, country = loc) }
}
private fun waitForOrTimeout(process: Process, timeoutMs: Long): Boolean
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
@@ -52,18 +52,23 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.SignalCellularAlt
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -137,6 +142,8 @@ fun HomeScreen(
val probes by viewModel.probes.collectAsStateWithLifecycle()
val probing by viewModel.probing.collectAsStateWithLifecycle()
val whiteList by viewModel.whiteList.collectAsStateWithLifecycle()
val exit by viewModel.exit.collectAsStateWithLifecycle()
val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle()
/* rolling RTT history per target, rendered as sparklines in the availability card */
val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() }
LaunchedEffect(probes)
@@ -259,6 +266,8 @@ fun HomeScreen(
probing = probing,
whiteList = whiteList,
history = probeHistory,
deadTargets = deadTargets,
onOpenRouting = { viewModel.setTab(ShxTab.ROUTING) },
onRefresh = { viewModel.refreshProbes() },
)
}
@@ -341,6 +350,7 @@ fun HomeScreen(
traffic = traffic,
durationSec = durationSec,
probes = probes,
exit = exit,
onDismiss = { viewModel.openDetails(false) },
onDisconnect = {
viewModel.openDetails(false)
@@ -576,18 +586,31 @@ private fun ConnectionCard(
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
TrafficStat(down = true, total = traffic.bytesIn, rate = traffic.rateIn)
Box(
modifier = Modifier
.size(1.dp, 32.dp)
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
)
TrafficStat(down = false, total = traffic.bytesOut, rate = traffic.rateOut)
Column {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
TrafficStat(down = true, total = traffic.bytesIn, rate = traffic.rateIn)
Box(
modifier = Modifier
.size(1.dp, 32.dp)
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
)
TrafficStat(down = false, total = traffic.bytesOut, rate = traffic.rateOut)
}
/* one-glance confirmation that the RU split is working */
traffic.split?.takeIf { it.directBytes > 0 }?.let { split ->
Text(
text = stringResource(R.string.traffic_direct_share_fmt, split.directShare),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 6.dp),
)
}
}
}
when (vpnState)
@@ -620,6 +643,7 @@ private fun ConnectionDetailsSheet(
traffic: TrafficUi,
durationSec: Long,
probes: Map<ProbeTarget, ProbeResult>,
exit: ExitUi?,
onDismiss: () -> Unit,
onDisconnect: () -> Unit,
)
@@ -661,6 +685,36 @@ private fun ConnectionDetailsSheet(
label = stringResource(R.string.traffic_up),
value = "${formatBytes(traffic.bytesOut)} · ${formatRate(traffic.rateOut)}",
)
/* RU-split session breakdown; null in the system-routes mode where
* the engine never sees the direct traffic */
traffic.split?.let { split ->
DetailRow(
icon = Icons.Rounded.AltRoute,
label = stringResource(R.string.traffic_split_tunnel),
value = "${formatBytes(split.tunnelBytes)} · ${formatRate(split.tunnelRate)}",
)
DetailRow(
icon = Icons.Rounded.AltRoute,
label = stringResource(R.string.traffic_split_direct),
value = "${formatBytes(split.directBytes)} · ${formatRate(split.directRate)}",
)
}
exit?.let { e ->
e.ip?.let { ip ->
DetailRow(
icon = Icons.Rounded.Public,
label = stringResource(R.string.exit_ip_label),
value = e.country?.let { "$ip · $it" } ?: ip,
)
}
e.network?.let { network ->
DetailRow(
icon = if (e.ssid != null) Icons.Rounded.Wifi else Icons.Rounded.SignalCellularAlt,
label = stringResource(R.string.network_label),
value = e.ssid?.let { "$network · $it" } ?: network,
)
}
}
if (probes.isNotEmpty())
{
probes.sortedForDisplay().forEach { result ->
@@ -798,6 +852,8 @@ private fun AvailabilityCard(
probing: Boolean,
whiteList: WhiteListState,
history: Map<ProbeTarget, List<ProbeResult?>>,
deadTargets: List<DeadTargetUi>,
onOpenRouting: () -> Unit,
onRefresh: () -> Unit,
)
{
@@ -866,6 +922,49 @@ private fun AvailabilityCard(
}
}
}
if (deadTargets.isNotEmpty())
{
Spacer(Modifier.height(8.dp))
ShxDivider()
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onOpenRouting)
.padding(vertical = 4.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.error, CircleShape),
)
Text(
text = stringResource(R.string.dead_targets_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Rounded.ChevronRight,
contentDescription = stringResource(R.string.dead_targets_open),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
deadTargets.take(3).forEach { target ->
DeadTargetRow(target = target)
}
if (deadTargets.size > 3)
{
Text(
text = stringResource(R.string.dead_targets_more_fmt, deadTargets.size - 3),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 18.dp, top = 4.dp),
)
}
}
if (whiteList.config.enabled)
{
Spacer(Modifier.height(8.dp))
@@ -893,10 +992,39 @@ private fun AvailabilityCard(
}
}
/** A dead direct destination: address, the app hammering it, refusal stats. */
@Composable
private fun DirectProbeRow(label: String, ok: Boolean?, rttMs: Long?)
private fun DeadTargetRow(target: DeadTargetUi)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, top = 6.dp, bottom = 2.dp),
) {
Column(Modifier.weight(1f)) {
Text(
text = target.dst,
style = MaterialTheme.typography.bodyMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
)
Text(
text = listOfNotNull(
target.owner,
stringResource(R.string.dead_targets_fails_fmt, target.fails),
stringResource(R.string.dead_targets_cooldown_fmt, formatDuration(target.leftMs / 1000)),
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun DirectProbeRow(label: String, ok: Boolean?, rttMs: Long?)
{ Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
@@ -23,6 +23,8 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.IBinder
import android.os.SystemClock
import android.widget.Toast
@@ -57,6 +59,7 @@ import org.strongswan.android.logic.connectivity.ProbeTarget
import org.strongswan.android.logic.connectivity.ServerCheckMode
import org.strongswan.android.logic.connectivity.WhiteListManager
import org.strongswan.android.logic.connectivity.WhiteListState
import org.strongswan.android.logic.autoconnect.SsidReader
import org.strongswan.android.ui.VpnProfileControlActivity
import org.strongswan.android.utils.Constants
@@ -66,6 +69,36 @@ data class TrafficUi(
val rateIn: Long = 0,
val rateOut: Long = 0,
val durationSec: Long = 0,
/** Session split between the tunnel and direct RU traffic; null when the userspace engine is not active. */
val split: SplitTrafficUi? = null,
)
data class SplitTrafficUi(
val tunnelBytes: Long = 0,
val directBytes: Long = 0,
val tunnelRate: Long = 0,
val directRate: Long = 0,
)
{
val directShare: Int
get() = if (tunnelBytes + directBytes <= 0) 0 else ((directBytes * 100) / (tunnelBytes + directBytes)).toInt()
}
/** Public exit (ip/country as seen past the tunnel) and the underlying network. */
data class ExitUi(
val ip: String? = null,
val country: String? = null,
val network: String? = null,
val ssid: String? = null,
)
/** A direct destination currently refused by the engine's failure cooldown. */
data class DeadTargetUi(
val dst: String,
val fails: Long,
val windowMs: Long,
val leftMs: Long,
val owner: String? = null,
)
enum class ShxTab
@@ -107,6 +140,15 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
private val _probing = MutableStateFlow(false)
val probing: StateFlow<Boolean> = _probing
/* public exit (ip/country) and the underlying network; refreshed with the
* probe round, cached for EXIT_REFRESH_MS */
private val _exit = MutableStateFlow<ExitUi?>(null)
val exit: StateFlow<ExitUi?> = _exit
/* direct destinations currently refused by the engine's cooldown */
private val _deadTargets = MutableStateFlow<List<DeadTargetUi>>(emptyList())
val deadTargets: StateFlow<List<DeadTargetUi>> = _deadTargets
private var service: VpnStateService? = null
/* written from the main thread (pullState) and the poll coroutine (Default) */
@@ -114,6 +156,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
@Volatile private var lastOut = 0L
@Volatile private var lastAt = 0L
@Volatile private var connectedAt = 0L
@Volatile private var lastExitAt = 0L
@Volatile private var splitAvailable = false
@Volatile private var lastTunnelBytes = 0L
@Volatile private var lastDirectBytes = 0L
@Volatile private var lastSplitAt = 0L
private val connection = object : ServiceConnection
{
@@ -180,7 +227,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
fun refreshProbes()
{
WhiteListManager.getInstance(getApplication()).checkNow()
viewModelScope.launch { probeNow() }
viewModelScope.launch {
probeNow()
refreshExit(force = true)
refreshDeadTargets()
}
}
private suspend fun probeNow()
@@ -202,6 +253,8 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
}
addAll(healthConfig.targets)
}
refreshDeadTargets()
viewModelScope.launch { refreshExit(force = false) }
if (targets.isEmpty())
{
return
@@ -367,12 +420,19 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
lastIn = 0
lastOut = 0
lastAt = 0
splitAvailable = CharonVpnService.isUserspaceRoutingEnabled(getApplication())
lastTunnelBytes = 0
lastDirectBytes = 0
lastSplitAt = 0
}
if (state != State.CONNECTED)
{
connectedAt = 0
_traffic.value = TrafficUi()
_probes.value = emptyMap()
_exit.value = null
_deadTargets.value = emptyList()
lastExitAt = 0
}
/* the retry countdown notifies every second; skip the update when nothing
* actually changed so the Home screen doesn't recompose for no reason */
@@ -422,13 +482,135 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
rateIn = rateIn,
rateOut = rateOut,
durationSec = (now - startedAt) / 1000,
split = if (splitAvailable) splitTraffic(now) else null,
)
}
/**
* Session split between the tunnel and direct RU traffic from the native
* engine counters (s[1] tunnel bytes, s[3] direct bytes). Only available
* in the userspace routing mode - with system routes the engine never
* sees the direct traffic.
*/
private suspend fun splitTraffic(now: Long): SplitTrafficUi
{
val stats = try
{
withContext(Dispatchers.Default) { CharonVpnService.getRoutingStats() }
}
catch (_: Throwable)
{
null
}
if (stats == null || stats.size < 4)
{
return SplitTrafficUi(tunnelBytes = lastTunnelBytes, directBytes = lastDirectBytes)
}
val tunnel = stats[1].coerceAtLeast(0)
val direct = stats[3].coerceAtLeast(0)
val dt = if (lastSplitAt == 0L) 0.0 else ((now - lastSplitAt) / 1000.0).coerceAtLeast(0.2)
val tunnelRate = if (lastSplitAt == 0L) 0 else ((tunnel - lastTunnelBytes) / dt).toLong().coerceAtLeast(0)
val directRate = if (lastSplitAt == 0L) 0 else ((direct - lastDirectBytes) / dt).toLong().coerceAtLeast(0)
lastTunnelBytes = tunnel
lastDirectBytes = direct
lastSplitAt = now
return SplitTrafficUi(tunnelBytes = tunnel, directBytes = direct, tunnelRate = tunnelRate, directRate = directRate)
}
/**
* Public exit ip/country via Cloudflare trace through the tunnel, plus
* the current underlying network label. Keeps the last known answer on
* failure (the sheet simply omits the rows before the first success).
*/
private suspend fun refreshExit(force: Boolean)
{
val now = SystemClock.elapsedRealtime()
if (!force && now - lastExitAt < EXIT_REFRESH_MS)
{
return
}
if (_ui.value.vpnState != State.CONNECTED)
{
return
}
val info = ConnectivityProbe.traceExit(EXIT_TIMEOUT_MS) ?: return
lastExitAt = now
val ctx = getApplication<Application>()
val (network, ssid) = underlyingNetworkLabel(ctx)
_exit.value = ExitUi(ip = info.ip, country = info.country, network = network, ssid = ssid)
}
private fun underlyingNetworkLabel(ctx: Context): Pair<String?, String?>
{
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return null to null
for (network in cm.allNetworks)
{
val caps = try
{
cm.getNetworkCapabilities(network)
}
catch (_: Exception)
{
null
} ?: continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
continue
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
{
return ctx.getString(R.string.network_cellular) to null
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
{
return ctx.getString(R.string.network_wifi) to SsidReader.from(ctx, caps)
}
}
return null to null
}
/** Snapshot of the direct destinations the engine currently refuses. */
private suspend fun refreshDeadTargets()
{
if (_ui.value.vpnState != State.CONNECTED)
{
return
}
val raw = try
{
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
}
catch (_: Throwable)
{
null
} ?: return
_deadTargets.value = parseDeadTargets(raw)
}
companion object
{
private const val TRAFFIC_POLL_MS = 1000L
private const val PROBE_TICK_MS = 1000L
private const val PROBE_INTERVAL_MS = 10_000L
private const val EXIT_REFRESH_MS = 60_000L
private const val EXIT_TIMEOUT_MS = 5000
/** Parses "ip:port|fails|window_ms|left_ms" entries of the native snapshot. */
fun parseDeadTargets(raw: Array<String>?): List<DeadTargetUi> =
raw?.mapNotNull { entry ->
val parts = entry.split('|')
if (parts.size != 4)
{
return@mapNotNull null
}
DeadTargetUi(
dst = parts[0],
fails = parts[1].toLongOrNull() ?: 0,
windowMs = parts[2].toLongOrNull() ?: 0,
leftMs = parts[3].toLongOrNull() ?: 0,
owner = CharonVpnService.getDeadTargetOwner(parts[0]),
)
}?.sortedByDescending { it.fails } ?: emptyList()
}
}
@@ -23,8 +23,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -35,6 +37,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -73,6 +76,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -84,15 +88,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
@@ -235,6 +242,71 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
modifier = Modifier.padding(horizontal = ScreenPadding),
)
/* ---- dead direct destinations the engine keeps refusing ---- */
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>())
{
while (true)
{
val raw = try
{
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
}
catch (_: Throwable)
{
null
}
value = HomeViewModel.parseDeadTargets(raw)
delay(5_000)
}
}
if (deadTargets.isNotEmpty())
{
ShxSection(title = stringResource(R.string.dead_targets_title))
{
Text(
text = stringResource(R.string.dead_targets_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
deadTargets.forEachIndexed { index, target ->
ShxSectionDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.error, CircleShape),
)
Column(Modifier.weight(1f)) {
Text(
text = target.dst,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
Text(
text = listOfNotNull(
target.owner,
stringResource(R.string.dead_targets_fails_fmt, target.fails),
stringResource(
R.string.dead_targets_cooldown_fmt,
formatDuration(target.leftMs / 1000),
),
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/* ---- RU networks direct: geoip list + routing mode ---- */
ShxSection(title = stringResource(R.string.routing_section_ru))
{
@@ -1314,6 +1314,44 @@ JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray)
return result;
}
/**
* Snapshot of direct destinations currently refused by the failure cooldown
* (dead hosts that apps keep hammering). Each entry is formatted as
* "ip:port|fails|window_ms|left_ms", most failures first.
*/
JNI_METHOD(CharonVpnService, getDeadDirectTargets, jobjectArray)
{
direct_engine_t *engine = direct_engine_get();
direct_dead_target_t t[DIRECT_DEAD_MAX];
jobjectArray result;
jclass cls;
size_t i, n = 0;
if (engine)
{
n = engine->get_dead_targets(engine, t, DIRECT_DEAD_MAX);
}
cls = (*env)->FindClass(env, "java/lang/String");
if (!cls)
{
androidjni_exception_occurred(env);
return NULL;
}
result = (*env)->NewObjectArray(env, n, cls, NULL);
for (i = 0; i < n; i++)
{
char buf[96];
jstring str;
snprintf(buf, sizeof(buf), "%s|%u|%u|%u",
t[i].dst, t[i].fails, t[i].cooldown_ms, t[i].cooldown_left_ms);
str = (*env)->NewStringUTF(env, buf);
(*env)->SetObjectArrayElement(env, result, i, str);
(*env)->DeleteLocalRef(env, str);
}
return result;
}
/**
* Utility function to verify proposal strings (static, so `this` is the class)
*/
@@ -403,6 +403,16 @@ METHOD(direct_engine_t, get_stats, void,
this->set_lock->unlock(this->set_lock);
}
METHOD(direct_engine_t, get_dead_targets, size_t,
private_direct_engine_t *this, direct_dead_target_t *out, size_t cap)
{
if (this->tcp)
{
return this->tcp->get_dead_targets(this->tcp, out, cap);
}
return 0;
}
/**
* Parse the transport protocol of a packet, rejecting fragments
*/
@@ -717,6 +727,7 @@ direct_engine_t *direct_engine_create(void)
.update_set = _update_set,
.set_enabled = _set_enabled,
.get_stats = _get_stats,
.get_dead_targets = _get_dead_targets,
.destroy = _destroy,
},
.set_lock = rwlock_create(RWLOCK_TYPE_DEFAULT),
@@ -37,6 +37,18 @@
typedef struct direct_engine_t direct_engine_t;
/**
* A direct destination currently refused by the failure cooldown (a dead
* host that apps keep hammering). For UI surfacing.
*/
#define DIRECT_DEAD_MAX 8
typedef struct {
char dst[48];
uint32_t fails;
uint32_t cooldown_ms;
uint32_t cooldown_left_ms;
} direct_dead_target_t;
/**
* Counters snapshot for the "route diag:" block (all fields relaxed
* atomics, updated from the processor and engine threads).
@@ -108,6 +120,14 @@ struct direct_engine_t {
*/
void (*get_stats)(direct_engine_t *this, direct_stats_t *stats);
/**
* Snapshot the destinations currently refused by the direct-flow failure
* cooldown (dead hosts apps keep hammering), most failures first.
* Thread-safe (may be called from the JNI thread).
*/
size_t (*get_dead_targets)(direct_engine_t *this,
direct_dead_target_t *out, size_t cap);
void (*destroy)(direct_engine_t *this);
};
@@ -48,6 +48,7 @@
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
@@ -169,9 +170,11 @@ struct private_direct_tcp_t {
linked_list_t *relays;
/**
* Per-destination failure table (negative cache); engine thread only
* Per-destination failure table (negative cache); engine thread only,
* except for get_dead_targets() which locks neg_lock from the JNI thread
*/
neg_entry_t neg[NEG_SLOTS];
pthread_mutex_t neg_lock;
};
/**
@@ -390,19 +393,22 @@ static bool neg_active(private_direct_tcp_t *this, const struct sockaddr_storage
uint64_t now)
{
size_t i;
bool active = FALSE;
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++)
{
if (neg_match(&this->neg[i], ss))
{
if (now < this->neg[i].cooldown_until)
{
return TRUE;
active = TRUE;
}
return FALSE;
break;
}
}
return FALSE;
pthread_mutex_unlock(&this->neg_lock);
return active;
}
/**
@@ -412,7 +418,9 @@ static bool neg_should_log(private_direct_tcp_t *this, const struct sockaddr_sto
uint64_t now)
{
size_t i;
bool should = TRUE;
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++)
{
if (neg_match(&this->neg[i], ss))
@@ -420,12 +428,17 @@ static bool neg_should_log(private_direct_tcp_t *this, const struct sockaddr_sto
if (now - this->neg[i].last_log >= NEG_LOG_MS)
{
this->neg[i].last_log = now;
return TRUE;
should = TRUE;
}
return FALSE;
else
{
should = FALSE;
}
break;
}
}
return TRUE;
pthread_mutex_unlock(&this->neg_lock);
return should;
}
/**
@@ -440,7 +453,9 @@ static int neg_record_fail(private_direct_tcp_t *this, const struct sockaddr_sto
{
neg_entry_t *entry = NULL, *oldest = &this->neg[0];
size_t i;
int flags = 0;
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++)
{
if (this->neg[i].used)
@@ -494,11 +509,15 @@ static int neg_record_fail(private_direct_tcp_t *this, const struct sockaddr_sto
}
if (entry->cooldown_ms > prev || entry->fails % NEG_REPORT_FAILS == 0)
{
return NEG_ARMED | NEG_ESCALATED;
flags = NEG_ARMED | NEG_ESCALATED;
}
else
{
flags = NEG_ARMED;
}
return NEG_ARMED;
}
return 0;
pthread_mutex_unlock(&this->neg_lock);
return flags;
}
/**
@@ -508,6 +527,7 @@ static void neg_clear(private_direct_tcp_t *this, const struct sockaddr_storage
{
size_t i;
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++)
{
if (neg_match(&this->neg[i], ss))
@@ -515,6 +535,7 @@ static void neg_clear(private_direct_tcp_t *this, const struct sockaddr_storage
this->neg[i].used = FALSE;
}
}
pthread_mutex_unlock(&this->neg_lock);
}
/**
@@ -522,7 +543,9 @@ static void neg_clear(private_direct_tcp_t *this, const struct sockaddr_storage
*/
static void neg_clear_all(private_direct_tcp_t *this)
{
pthread_mutex_lock(&this->neg_lock);
memset(this->neg, 0, sizeof(this->neg));
pthread_mutex_unlock(&this->neg_lock);
}
/**
@@ -1124,6 +1147,7 @@ METHOD(direct_tcp_t, destroy, void,
reset(this);
netif_remove(&this->netif);
this->relays->destroy(this->relays);
pthread_mutex_destroy(&this->neg_lock);
free(this);
}
@@ -1136,6 +1160,52 @@ u32_t sys_now(void)
return (u32_t)(time_monotonic(NULL) * 1000);
}
METHOD(direct_tcp_t, get_dead_targets, size_t,
private_direct_tcp_t *this, direct_dead_target_t *out, size_t cap)
{
neg_entry_t *sorted[NEG_SLOTS];
size_t i, j, n = 0;
uint64_t now = now_ms();
if (!cap)
{
return 0;
}
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++)
{
neg_entry_t *e = &this->neg[i];
if (!e->used || e->fails < NEG_FAILS)
{
continue;
}
/* insertion sort by failures, most failing first */
for (j = n; j > 0 && sorted[j-1]->fails < e->fails; j--)
{
sorted[j] = sorted[j-1];
}
sorted[j] = e;
n++;
}
if (n > cap)
{
n = cap;
}
for (i = 0; i < n; i++)
{
neg_entry_t *e = sorted[i];
format_dest(&e->dest, out[i].dst, sizeof(out[i].dst));
out[i].fails = e->fails;
out[i].cooldown_ms = e->cooldown_ms;
out[i].cooldown_left_ms = e->cooldown_until > now ?
(uint32_t)(e->cooldown_until - now) : 0;
}
pthread_mutex_unlock(&this->neg_lock);
return n;
}
direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
direct_stats_t *stats)
{
@@ -1155,12 +1225,14 @@ direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
.process_poll = _process_poll,
.reset = _reset,
.destroy = _destroy,
.get_dead_targets = _get_dead_targets,
},
.engine = engine,
.stats = stats,
.mtu = mtu,
.relays = linked_list_create(),
);
pthread_mutex_init(&this->neg_lock, NULL);
lwip_init();
@@ -63,6 +63,14 @@ struct direct_tcp_t {
*/
size_t (*flow_count)(direct_tcp_t *this);
/**
* Snapshot the destinations currently refused by the negative cache,
* most failures first. Thread-safe (called from the JNI thread while
* the engine thread owns the table otherwise).
*/
size_t (*get_dead_targets)(direct_tcp_t *this,
direct_dead_target_t *out, size_t cap);
/**
* Add this module's relay sockets to the poll set, returns how many.
*/
@@ -467,6 +467,19 @@
<string name="duration_label">Длительность</string>
<string name="traffic_stat_down">Загружено %1$s, %2$s</string>
<string name="traffic_stat_up">Отправлено %1$s, %2$s</string>
<string name="traffic_split_tunnel">Через VPN</string>
<string name="traffic_split_direct">Напрямую (RU)</string>
<string name="traffic_direct_share_fmt">%1$d %% напрямую</string>
<string name="exit_ip_label">Выходной IP</string>
<string name="network_label">Сеть</string>
<string name="network_wifi">Wi-Fi</string>
<string name="network_cellular">Сотовая сеть</string>
<string name="dead_targets_title">Недоступные напрямую</string>
<string name="dead_targets_summary">Приложения продолжают стучаться к этим адресам, но те не отвечают — движок отклоняет соединения на время паузы. Проверьте серверы или уберите их из настроек использующего их приложения.</string>
<string name="dead_targets_fails_fmt">отказов: %1$d</string>
<string name="dead_targets_cooldown_fmt">пауза %1$s</string>
<string name="dead_targets_more_fmt">ещё %1$d</string>
<string name="dead_targets_open">Открыть маршрутизацию</string>
<string name="profile_advanced_hint">Идентификаторы, DNS, MTU, split-туннель</string>
<string name="import_profile_title">Импорт профиля</string>
<string name="extended_diag_title">Расширенная диагностика</string>
@@ -472,6 +472,19 @@
<string name="duration_label">Duration</string>
<string name="traffic_stat_down">Downloaded %1$s, %2$s</string>
<string name="traffic_stat_up">Uploaded %1$s, %2$s</string>
<string name="traffic_split_tunnel">Via VPN</string>
<string name="traffic_split_direct">Direct (RU)</string>
<string name="traffic_direct_share_fmt">%1$d%% direct</string>
<string name="exit_ip_label">Exit IP</string>
<string name="network_label">Network</string>
<string name="network_wifi">Wi-Fi</string>
<string name="network_cellular">Mobile data</string>
<string name="dead_targets_title">Unreachable direct hosts</string>
<string name="dead_targets_summary">Apps keep connecting to these hosts, but they never answer — the engine refuses them for a cooldown window. Check the servers or remove them from the app that uses them.</string>
<string name="dead_targets_fails_fmt">%1$d failed connects</string>
<string name="dead_targets_cooldown_fmt">cooldown %1$s</string>
<string name="dead_targets_more_fmt">+%1$d more</string>
<string name="dead_targets_open">Open routing</string>
<string name="profile_advanced_hint">Identifiers, DNS, MTU, split tunneling</string>
<string name="import_profile_title">Import profile</string>
<string name="extended_diag_title">Extended diagnostics</string>