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:
1 parent
de5b093d62
commit
7185aa4ba5
13 files changed
+672
-26
No files matched your search
@@ -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}\""
|
||||
|
||||
+34
@@ -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.
|
||||
|
||||
+55
@@ -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)
|
||||
|
||||
+141
-13
@@ -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()
|
||||
|
||||
+183
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
+72
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user