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 minSdkVersion 23
targetSdkVersion 36 targetSdkVersion 36
versionCode 153 versionCode 154
versionName "2.14.0" versionName "2.14.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}\""
@@ -1442,6 +1442,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
} }
String[] packages = getPackageManager().getPackagesForUid(uid); String[] packages = getPackageManager().getPackagesForUid(uid);
String owner = packages != null && packages.length > 0 ? packages[0] : "uid " + uid; String owner = packages != null && packages.length > 0 ? packages[0] : "uid " + uid;
rememberDeadOwner(dst, owner);
DiagLog.i(TAG, "direct tcp: dst=" + dst + " owner=" + owner); DiagLog.i(TAG, "direct tcp: dst=" + dst + " owner=" + owner);
} }
catch (Exception e) catch (Exception e)
@@ -1521,6 +1522,39 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
*/ */
public static native long[] getRoutingStats(); 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. * 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. * 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.io.IOException
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.net.URL
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
/** What a probe measures. External targets go through the tunnel, SERVER goes direct. */ /** What a probe measures. External targets go through the tunnel, SERVER goes direct. */
enum class ProbeTarget enum class ProbeTarget
@@ -42,6 +44,12 @@ data class ProbeResult(
val direct: Boolean = target == ProbeTarget.SERVER, 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. * 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() 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 private fun waitForOrTimeout(process: Process, timeoutMs: Long): Boolean
{ {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) 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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add 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.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward 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.Delete
import androidx.compose.material.icons.rounded.Dns import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.ExpandLess import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.GppMaybe import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PlayArrow 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.Refresh
import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Share 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.Edit
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -137,6 +142,8 @@ fun HomeScreen(
val probes by viewModel.probes.collectAsStateWithLifecycle() val probes by viewModel.probes.collectAsStateWithLifecycle()
val probing by viewModel.probing.collectAsStateWithLifecycle() val probing by viewModel.probing.collectAsStateWithLifecycle()
val whiteList by viewModel.whiteList.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 */ /* rolling RTT history per target, rendered as sparklines in the availability card */
val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() } val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() }
LaunchedEffect(probes) LaunchedEffect(probes)
@@ -259,6 +266,8 @@ fun HomeScreen(
probing = probing, probing = probing,
whiteList = whiteList, whiteList = whiteList,
history = probeHistory, history = probeHistory,
deadTargets = deadTargets,
onOpenRouting = { viewModel.setTab(ShxTab.ROUTING) },
onRefresh = { viewModel.refreshProbes() }, onRefresh = { viewModel.refreshProbes() },
) )
} }
@@ -341,6 +350,7 @@ fun HomeScreen(
traffic = traffic, traffic = traffic,
durationSec = durationSec, durationSec = durationSec,
probes = probes, probes = probes,
exit = exit,
onDismiss = { viewModel.openDetails(false) }, onDismiss = { viewModel.openDetails(false) },
onDisconnect = { onDisconnect = {
viewModel.openDetails(false) viewModel.openDetails(false)
@@ -576,18 +586,31 @@ private fun ConnectionCard(
enter = expandVertically() + fadeIn(), enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(), exit = shrinkVertically() + fadeOut(),
) { ) {
Row( Column {
Modifier.fillMaxWidth(), Row(
horizontalArrangement = Arrangement.SpaceEvenly, Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly,
) { verticalAlignment = Alignment.CenterVertically,
TrafficStat(down = true, total = traffic.bytesIn, rate = traffic.rateIn) ) {
Box( TrafficStat(down = true, total = traffic.bytesIn, rate = traffic.rateIn)
modifier = Modifier Box(
.size(1.dp, 32.dp) modifier = Modifier
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)), .size(1.dp, 32.dp)
) .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
TrafficStat(down = false, total = traffic.bytesOut, rate = traffic.rateOut) )
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) when (vpnState)
@@ -620,6 +643,7 @@ private fun ConnectionDetailsSheet(
traffic: TrafficUi, traffic: TrafficUi,
durationSec: Long, durationSec: Long,
probes: Map<ProbeTarget, ProbeResult>, probes: Map<ProbeTarget, ProbeResult>,
exit: ExitUi?,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onDisconnect: () -> Unit, onDisconnect: () -> Unit,
) )
@@ -661,6 +685,36 @@ private fun ConnectionDetailsSheet(
label = stringResource(R.string.traffic_up), label = stringResource(R.string.traffic_up),
value = "${formatBytes(traffic.bytesOut)} · ${formatRate(traffic.rateOut)}", 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()) if (probes.isNotEmpty())
{ {
probes.sortedForDisplay().forEach { result -> probes.sortedForDisplay().forEach { result ->
@@ -798,6 +852,8 @@ private fun AvailabilityCard(
probing: Boolean, probing: Boolean,
whiteList: WhiteListState, whiteList: WhiteListState,
history: Map<ProbeTarget, List<ProbeResult?>>, history: Map<ProbeTarget, List<ProbeResult?>>,
deadTargets: List<DeadTargetUi>,
onOpenRouting: () -> Unit,
onRefresh: () -> 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) if (whiteList.config.enabled)
{ {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -893,10 +992,39 @@ private fun AvailabilityCard(
} }
} }
/** A dead direct destination: address, the app hammering it, refusal stats. */
@Composable @Composable
private fun DirectProbeRow(label: String, ok: Boolean?, rttMs: Long?) private fun DeadTargetRow(target: DeadTargetUi)
{ {
Row( 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, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -23,6 +23,8 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.IBinder import android.os.IBinder
import android.os.SystemClock import android.os.SystemClock
import android.widget.Toast 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.ServerCheckMode
import org.strongswan.android.logic.connectivity.WhiteListManager import org.strongswan.android.logic.connectivity.WhiteListManager
import org.strongswan.android.logic.connectivity.WhiteListState import org.strongswan.android.logic.connectivity.WhiteListState
import org.strongswan.android.logic.autoconnect.SsidReader
import org.strongswan.android.ui.VpnProfileControlActivity import org.strongswan.android.ui.VpnProfileControlActivity
import org.strongswan.android.utils.Constants import org.strongswan.android.utils.Constants
@@ -66,6 +69,36 @@ data class TrafficUi(
val rateIn: Long = 0, val rateIn: Long = 0,
val rateOut: Long = 0, val rateOut: Long = 0,
val durationSec: 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 enum class ShxTab
@@ -107,6 +140,15 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
private val _probing = MutableStateFlow(false) private val _probing = MutableStateFlow(false)
val probing: StateFlow<Boolean> = _probing 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 private var service: VpnStateService? = null
/* written from the main thread (pullState) and the poll coroutine (Default) */ /* 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 lastOut = 0L
@Volatile private var lastAt = 0L @Volatile private var lastAt = 0L
@Volatile private var connectedAt = 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 private val connection = object : ServiceConnection
{ {
@@ -180,7 +227,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
fun refreshProbes() fun refreshProbes()
{ {
WhiteListManager.getInstance(getApplication()).checkNow() WhiteListManager.getInstance(getApplication()).checkNow()
viewModelScope.launch { probeNow() } viewModelScope.launch {
probeNow()
refreshExit(force = true)
refreshDeadTargets()
}
} }
private suspend fun probeNow() private suspend fun probeNow()
@@ -202,6 +253,8 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
} }
addAll(healthConfig.targets) addAll(healthConfig.targets)
} }
refreshDeadTargets()
viewModelScope.launch { refreshExit(force = false) }
if (targets.isEmpty()) if (targets.isEmpty())
{ {
return return
@@ -367,12 +420,19 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
lastIn = 0 lastIn = 0
lastOut = 0 lastOut = 0
lastAt = 0 lastAt = 0
splitAvailable = CharonVpnService.isUserspaceRoutingEnabled(getApplication())
lastTunnelBytes = 0
lastDirectBytes = 0
lastSplitAt = 0
} }
if (state != State.CONNECTED) if (state != State.CONNECTED)
{ {
connectedAt = 0 connectedAt = 0
_traffic.value = TrafficUi() _traffic.value = TrafficUi()
_probes.value = emptyMap() _probes.value = emptyMap()
_exit.value = null
_deadTargets.value = emptyList()
lastExitAt = 0
} }
/* the retry countdown notifies every second; skip the update when nothing /* the retry countdown notifies every second; skip the update when nothing
* actually changed so the Home screen doesn't recompose for no reason */ * 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, rateIn = rateIn,
rateOut = rateOut, rateOut = rateOut,
durationSec = (now - startedAt) / 1000, 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 companion object
{ {
private const val TRAFFIC_POLL_MS = 1000L private const val TRAFFIC_POLL_MS = 1000L
private const val PROBE_TICK_MS = 1000L private const val PROBE_TICK_MS = 1000L
private const val PROBE_INTERVAL_MS = 10_000L 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.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -73,6 +76,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.strongswan.android.R import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource 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.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager import org.strongswan.android.logic.routing.RoutingManager
@@ -235,6 +242,71 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
modifier = Modifier.padding(horizontal = ScreenPadding), 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 ---- */ /* ---- RU networks direct: geoip list + routing mode ---- */
ShxSection(title = stringResource(R.string.routing_section_ru)) ShxSection(title = stringResource(R.string.routing_section_ru))
{ {
@@ -1314,6 +1314,44 @@ JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray)
return result; 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) * 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); 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 * Parse the transport protocol of a packet, rejecting fragments
*/ */
@@ -717,6 +727,7 @@ direct_engine_t *direct_engine_create(void)
.update_set = _update_set, .update_set = _update_set,
.set_enabled = _set_enabled, .set_enabled = _set_enabled,
.get_stats = _get_stats, .get_stats = _get_stats,
.get_dead_targets = _get_dead_targets,
.destroy = _destroy, .destroy = _destroy,
}, },
.set_lock = rwlock_create(RWLOCK_TYPE_DEFAULT), .set_lock = rwlock_create(RWLOCK_TYPE_DEFAULT),
@@ -37,6 +37,18 @@
typedef struct direct_engine_t direct_engine_t; 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 * Counters snapshot for the "route diag:" block (all fields relaxed
* atomics, updated from the processor and engine threads). * 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); 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); void (*destroy)(direct_engine_t *this);
}; };
@@ -48,6 +48,7 @@
#include <arpa/inet.h> #include <arpa/inet.h>
#include <errno.h> #include <errno.h>
#include <netinet/in.h> #include <netinet/in.h>
#include <pthread.h>
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <sys/socket.h> #include <sys/socket.h>
@@ -169,9 +170,11 @@ struct private_direct_tcp_t {
linked_list_t *relays; 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]; 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) uint64_t now)
{ {
size_t i; size_t i;
bool active = FALSE;
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++) for (i = 0; i < NEG_SLOTS; i++)
{ {
if (neg_match(&this->neg[i], ss)) if (neg_match(&this->neg[i], ss))
{ {
if (now < this->neg[i].cooldown_until) 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) uint64_t now)
{ {
size_t i; size_t i;
bool should = TRUE;
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++) for (i = 0; i < NEG_SLOTS; i++)
{ {
if (neg_match(&this->neg[i], ss)) 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) if (now - this->neg[i].last_log >= NEG_LOG_MS)
{ {
this->neg[i].last_log = now; 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]; neg_entry_t *entry = NULL, *oldest = &this->neg[0];
size_t i; size_t i;
int flags = 0;
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++) for (i = 0; i < NEG_SLOTS; i++)
{ {
if (this->neg[i].used) 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) 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; size_t i;
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++) for (i = 0; i < NEG_SLOTS; i++)
{ {
if (neg_match(&this->neg[i], ss)) 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; 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) static void neg_clear_all(private_direct_tcp_t *this)
{ {
pthread_mutex_lock(&this->neg_lock);
memset(this->neg, 0, sizeof(this->neg)); 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); reset(this);
netif_remove(&this->netif); netif_remove(&this->netif);
this->relays->destroy(this->relays); this->relays->destroy(this->relays);
pthread_mutex_destroy(&this->neg_lock);
free(this); free(this);
} }
@@ -1136,6 +1160,52 @@ u32_t sys_now(void)
return (u32_t)(time_monotonic(NULL) * 1000); 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_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
direct_stats_t *stats) 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, .process_poll = _process_poll,
.reset = _reset, .reset = _reset,
.destroy = _destroy, .destroy = _destroy,
.get_dead_targets = _get_dead_targets,
}, },
.engine = engine, .engine = engine,
.stats = stats, .stats = stats,
.mtu = mtu, .mtu = mtu,
.relays = linked_list_create(), .relays = linked_list_create(),
); );
pthread_mutex_init(&this->neg_lock, NULL);
lwip_init(); lwip_init();
@@ -63,6 +63,14 @@ struct direct_tcp_t {
*/ */
size_t (*flow_count)(direct_tcp_t *this); 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. * Add this module's relay sockets to the poll set, returns how many.
*/ */
@@ -467,6 +467,19 @@
<string name="duration_label">Длительность</string> <string name="duration_label">Длительность</string>
<string name="traffic_stat_down">Загружено %1$s, %2$s</string> <string name="traffic_stat_down">Загружено %1$s, %2$s</string>
<string name="traffic_stat_up">Отправлено %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="profile_advanced_hint">Идентификаторы, DNS, MTU, split-туннель</string>
<string name="import_profile_title">Импорт профиля</string> <string name="import_profile_title">Импорт профиля</string>
<string name="extended_diag_title">Расширенная диагностика</string> <string name="extended_diag_title">Расширенная диагностика</string>
@@ -472,6 +472,19 @@
<string name="duration_label">Duration</string> <string name="duration_label">Duration</string>
<string name="traffic_stat_down">Downloaded %1$s, %2$s</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_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="profile_advanced_hint">Identifiers, DNS, MTU, split tunneling</string>
<string name="import_profile_title">Import profile</string> <string name="import_profile_title">Import profile</string>
<string name="extended_diag_title">Extended diagnostics</string> <string name="extended_diag_title">Extended diagnostics</string>