listRule) {
for (Rule rule : listRule)
mapUidKnown.put(rule.uid, rule.uid);
+ defaultTunnel = RemoteRoutingLogic.defaultTunnel(
+ RemoteRoutingLogic.normalizeMode(
+ PreferenceManager.getDefaultSharedPreferences(this)
+ .getString(Rule.PREF_WG_ROUTE_MODE,
+ RemoteRoutingLogic.getDefaultMode())));
+ mapUidRouteOverride = mapUidRouteOverride(listRule, defaultTunnel);
+ anyDirectRouting = anyDirectRouting(listRule);
+ // Util.getDefaultDNS does binder calls, so resolve it once per reload
+ // rather than on the DNS path.
+ directDnsTarget = resolveDirectDnsTarget();
+
lock.writeLock().unlock();
}
+ /**
+ * The UIDs whose routing differs from the global default.
+ *
+ * A shared UID is tunnelled if any of its packages is: the packet path only
+ * ever sees the UID, so the finer per-package answer cannot be honoured and
+ * the more private of the two is the right way to round.
+ *
+ * Bypassed apps are skipped rather than counted as direct. They are handed
+ * to {@code addDisallowedApplication}, so no packet of theirs ever reaches
+ * the tun — listing them would put an entry in the override set for every
+ * bypassed app and defeat the empty-set fast path for no benefit.
+ */
+ private static Set mapUidRouteOverride(List listRule, boolean defaultTunnel) {
+ Map tunnelByUid = new HashMap<>();
+ for (Rule rule : listRule) {
+ if (!rule.apply)
+ continue;
+ Boolean current = tunnelByUid.get(rule.uid);
+ tunnelByUid.put(rule.uid, (current != null && current) || rule.wg_route);
+ }
+
+ Set overrides = new HashSet<>();
+ for (Map.Entry entry : tunnelByUid.entrySet())
+ if (RemoteRoutingLogic.isRouteOverride(entry.getValue(), defaultTunnel))
+ overrides.add(entry.getKey());
+ return overrides;
+ }
+
+ /**
+ * The system resolver for apps routed around the remote tunnel.
+ *
+ * With the tunnel up, the tun advertises the tunnel's own resolvers, which
+ * a direct app may not be able to reach at all. Redirecting its queries to
+ * the underlying network's resolver is what makes direct routing work when
+ * the tunnel drops — at the cost, stated in the UI, of that app's DNS being
+ * visible to its local network rather than to the VPN provider.
+ */
+ private String resolveDirectDnsTarget() {
+ List sysDns = Util.getDefaultDNS(ServiceSinkhole.this);
+ for (String dns : sysDns)
+ if (!TextUtils.isEmpty(dns))
+ return dns;
+
+ Log.w(TAG, "No system DNS for direct apps; keeping their DNS in the tunnel");
+ return null;
+ }
+
+ private static boolean anyDirectRouting(List listRule) {
+ for (Rule rule : listRule)
+ if (rule.apply && !rule.wg_route)
+ return true;
+ return false;
+ }
+
+ /**
+ * Whether this UID is routed around the remote tunnel. Mirrors the native
+ * is_tunnel_uid: a UID absent from the override set follows the global
+ * default rather than counting as "not tunnelled", which a bare
+ * mapUidRouteOverride lookup would wrongly report.
+ */
+ private boolean routesDirect(int uid) {
+ return RemoteRoutingLogic.routesDirect(
+ mapUidRouteOverride.contains(uid), defaultTunnel);
+ }
+
+ /** Hands the current routing decision to the packet path. */
+ private void pushRoutingToNative() {
+ lock.readLock().lock();
+ int[] uids = new int[mapUidRouteOverride.size()];
+ int i = 0;
+ for (Integer uid : mapUidRouteOverride)
+ uids[i++] = uid;
+ boolean defaults = defaultTunnel;
+ boolean dnsDirect = RemoteRoutingLogic.redirectDirectDns(anyDirectRouting,
+ directDnsTarget != null);
+ lock.readLock().unlock();
+
+ jni_wireguard_route(uids, defaults, dnsDirect);
+ }
+
public static void prepareHostsBlocked(Context c) {
BufferedReader br = null;
InputStreamReader is = null;
@@ -2393,6 +2519,13 @@ private Allowed isAddressAllowed(Packet packet) {
allowed = new Allowed(fwd.raddr, fwd.rport);
packet.data = "> " + fwd.raddr + "/" + fwd.rport;
}
+ } else if (packet.dport == 53 && directDnsTarget != null
+ && routesDirect(packet.uid)) {
+ // An app routed around the remote tunnel resolves against the
+ // underlying network instead of the tunnel's resolver. The
+ // redirect is transparent: replies are rebuilt from the
+ // session's original addresses, so the app never sees it.
+ allowed = new Allowed(directDnsTarget, 53);
} else
allowed = new Allowed();
@@ -2919,6 +3052,7 @@ private void handlePackageChanged(Context context, Intent intent) {
context.getSharedPreferences("apply", Context.MODE_PRIVATE).edit().remove(packageName).apply();
BlockingMode.clearAutoExcludedApp(context, packageName);
context.getSharedPreferences("tracker_protect", Context.MODE_PRIVATE).edit().remove(packageName).apply();
+ context.getSharedPreferences(Rule.PREF_WG_ROUTE, Context.MODE_PRIVATE).edit().remove(packageName).apply();
context.getSharedPreferences("notify", Context.MODE_PRIVATE).edit().remove(packageName).apply();
int uid = intent.getIntExtra(Intent.EXTRA_UID, 0);
diff --git a/app/src/main/java/net/kollnig/missioncontrol/data/RemoteRoutingLogic.java b/app/src/main/java/net/kollnig/missioncontrol/data/RemoteRoutingLogic.java
new file mode 100644
index 000000000..92f4e6c22
--- /dev/null
+++ b/app/src/main/java/net/kollnig/missioncontrol/data/RemoteRoutingLogic.java
@@ -0,0 +1,184 @@
+/*
+ * TrackerControl is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * TrackerControl is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * Copyright © 2026
+ */
+
+package net.kollnig.missioncontrol.data;
+
+/**
+ * Decides which apps are forwarded through the remote WireGuard tunnel.
+ *
+ * Whether an app is filtered by TrackerControl and whether it is
+ * forwarded through the remote VPN are independent choices. Before
+ * this existed the only way to keep an app off the remote tunnel was to
+ * exclude it from the VPN altogether, which also dropped local monitoring and
+ * blocking (#723).
+ *
+ * Pure helpers mirroring the native routing decision, no Android dependencies,
+ * kept JVM-testable so the decision table is covered by unit tests.
+ */
+public final class RemoteRoutingLogic {
+ /** Every app goes through the remote tunnel. The shipped default. */
+ public static final String MODE_ALL = "all";
+ /** Only apps with an explicit per-app override go through the tunnel. */
+ public static final String MODE_SELECTED = "selected";
+
+ private RemoteRoutingLogic() {
+ }
+
+ public static String getDefaultMode() {
+ return MODE_ALL;
+ }
+
+ public static String normalizeMode(String mode) {
+ return MODE_SELECTED.equals(mode) ? MODE_SELECTED : MODE_ALL;
+ }
+
+ /**
+ * Whether unknown-UID and system traffic takes the tunnel. In
+ * {@link #MODE_ALL} this is true, which makes the whole feature a no-op
+ * against the behaviour that shipped before it.
+ */
+ public static boolean defaultTunnel(String mode) {
+ return !MODE_SELECTED.equals(normalizeMode(mode));
+ }
+
+ /**
+ * Whether one app is routed through the remote tunnel.
+ *
+ * @param mode the global routing mode
+ * @param override the per-app override, or {@code null} when unset
+ * @param apply the app's "apply" preference; a bypassed app is outside
+ * the tun entirely, so it has no routing to decide
+ */
+ public static boolean routesThroughTunnel(String mode, Boolean override, boolean apply) {
+ if (!apply)
+ return false;
+
+ if (override != null)
+ return override;
+
+ return defaultTunnel(mode);
+ }
+
+ /**
+ * Whether the per-app routing control should be offered at all.
+ *
+ * Routes come from the tunnel's AllowedIPs, and they are a property of the
+ * one tun every app shares — narrowing them to route some apps around the
+ * tunnel would shrink them for the tunnelled apps too. v1 therefore only
+ * offers the control for a default-route tunnel. gotatun silently drops
+ * packets whose destination matches no peer's AllowedIPs, so a narrower
+ * tunnel would blackhole traffic rather than fail visibly.
+ *
+ * @param wgEnabled whether remote egress is configured and on
+ * @param defaultRoutes whether AllowedIPs covers 0.0.0.0/0 (and ::/0 when
+ * IPv6 is enabled)
+ * @param apply the app's "apply" preference
+ */
+ public static boolean isControlAvailable(boolean wgEnabled, boolean defaultRoutes, boolean apply) {
+ return wgEnabled && defaultRoutes && apply;
+ }
+
+ /**
+ * Why the control is unavailable, for the explanation shown in its place.
+ */
+ public static Unavailable getUnavailableReason(boolean wgEnabled, boolean defaultRoutes,
+ boolean apply) {
+ if (!wgEnabled)
+ return Unavailable.NO_REMOTE_VPN;
+ if (!apply)
+ return Unavailable.BYPASSED;
+ if (!defaultRoutes)
+ return Unavailable.PARTIAL_ROUTES;
+ return null;
+ }
+
+ /**
+ * Whether direct apps' DNS is redirected to the system resolver.
+ *
+ * Turning this on makes every DNS query cost a UID lookup, an
+ * isAddressAllowed upcall and a real UDP session — today port 53 skips all
+ * three. It is therefore only enabled once some app is routed around the
+ * tunnel and a non-null underlying resolver target is available, so
+ * everyone else keeps the existing zero-cost DNS path. Note this is a
+ * property of the resolved rules, not of the mode: a per-app override
+ * sends an app direct in either mode.
+ */
+ public static boolean redirectDirectDns(boolean anyAppRoutedDirect,
+ boolean resolverTargetAvailable) {
+ return anyAppRoutedDirect && resolverTargetAvailable;
+ }
+
+ /**
+ * Whether a UID whose routing differs from the global default should be
+ * pushed to the packet path.
+ *
+ * Only the exceptions are pushed, never the whole tunnelled set: with no
+ * per-app override the set is then empty, which is what lets the packet
+ * path skip the UID lookup entirely. Pushing every tunnelled UID instead
+ * made the default mode — where every applied app is tunnelled — look
+ * indistinguishable from a heavily-overridden one.
+ */
+ public static boolean isRouteOverride(boolean tunnelled, boolean defaultTunnel) {
+ return tunnelled != defaultTunnel;
+ }
+
+ /**
+ * Whether one UID's traffic leaves outside the tunnel, mirroring the native
+ * is_tunnel_uid.
+ *
+ * A UID absent from the override set follows the global default, whatever
+ * it is. That includes system traffic and UIDs belonging to no installed
+ * app, which is what makes the default mode identical to the behaviour
+ * before per-app routing existed.
+ *
+ * @param inOverrideSet whether the UID's routing differs from the default
+ * @param defaultTunnel the global default
+ */
+ public static boolean routesDirect(boolean inOverrideSet, boolean defaultTunnel) {
+ return !(defaultTunnel != inOverrideSet);
+ }
+
+ /**
+ * Whether the tunnel's AllowedIPs is a default route.
+ *
+ * Routes are a property of the single tun every app shares, so a narrower
+ * AllowedIPs shrinks them for directly-routed apps too. v1 therefore only
+ * offers per-app routing for a full-tunnel config.
+ *
+ * @param allowedIps the union of every peer's AllowedIPs
+ * @param ip6 whether IPv6 is enabled, in which case ::/0 is required too
+ */
+ public static boolean hasDefaultRoutes(java.util.List allowedIps, boolean ip6) {
+ boolean v4 = false;
+ boolean v6 = false;
+ for (String allowedIp : allowedIps) {
+ String trimmed = allowedIp == null ? "" : allowedIp.trim();
+ if ("0.0.0.0/0".equals(trimmed))
+ v4 = true;
+ else if ("::/0".equals(trimmed))
+ v6 = true;
+ }
+
+ return v4 && (!ip6 || v6);
+ }
+
+ public enum Unavailable {
+ /** No remote VPN is configured or enabled. */
+ NO_REMOTE_VPN,
+ /** The app bypasses TrackerControl, so it is outside the tun. */
+ BYPASSED,
+ /** The tunnel's AllowedIPs is not a default route. */
+ PARTIAL_ROUTES
+ }
+}
diff --git a/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java b/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java
index 52b4d6ff4..44c9ef101 100644
--- a/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java
+++ b/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java
@@ -25,6 +25,7 @@
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.AsyncTask;
+import android.util.Log;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
@@ -43,6 +44,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
+import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import androidx.work.Data;
@@ -54,6 +56,7 @@
import net.kollnig.missioncontrol.analysis.TrackerAnalysisWorker;
import net.kollnig.missioncontrol.data.AppProtectionState;
import net.kollnig.missioncontrol.data.BlockingMode;
+import net.kollnig.missioncontrol.data.RemoteRoutingLogic;
import net.kollnig.missioncontrol.data.InternetBlocklist;
import net.kollnig.missioncontrol.data.Tracker;
import net.kollnig.missioncontrol.data.TrackerBlocklist;
@@ -475,6 +478,88 @@ private void updateText(TextView tv, Tracker t) {
applyState(selected, w);
notifyDataSetChanged();
});
+
+ bindRemoteRouting(holder);
+ }
+ }
+
+ /**
+ * The remote-routing control, which is deliberately independent of the
+ * protection state above: whether an app is filtered and whether it is
+ * forwarded through the remote VPN are separate choices (#723).
+ */
+ private void bindRemoteRouting(VHHeader holder) {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mContext);
+ boolean wgEnabled = prefs.getBoolean("wg_enabled", false)
+ && !TextUtils.isEmpty(prefs.getString("wg_config", ""));
+ boolean applyApp = apply.getBoolean(mAppId, true);
+ boolean defaultRoutes = wgEnabled && hasDefaultRoutes(prefs);
+
+ RemoteRoutingLogic.Unavailable unavailable =
+ RemoteRoutingLogic.getUnavailableReason(wgEnabled, defaultRoutes, applyApp);
+ if (unavailable != null) {
+ holder.mAppRoute.setVisibility(View.GONE);
+ holder.mAppRouteUnavailable.setVisibility(View.VISIBLE);
+ holder.mAppRouteUnavailable.setText(explainUnavailable(unavailable));
+ return;
+ }
+
+ holder.mAppRouteUnavailable.setVisibility(View.GONE);
+ holder.mAppRoute.setVisibility(View.VISIBLE);
+
+ String mode = RemoteRoutingLogic.normalizeMode(
+ prefs.getString(Rule.PREF_WG_ROUTE_MODE, RemoteRoutingLogic.getDefaultMode()));
+ boolean tunnelled = RemoteRoutingLogic.routesThroughTunnel(mode, getRouteOverride(), true);
+
+ holder.mAppRoute.setOnCheckedChangeListener(null);
+ holder.mAppRoute.check(tunnelled ? R.id.rbRouteTunnel : R.id.rbRouteDirect);
+ holder.mAppRoute.setOnCheckedChangeListener((group, checkedId) -> {
+ boolean wantsTunnel = (checkedId == R.id.rbRouteTunnel);
+ if (wantsTunnel == RemoteRoutingLogic.routesThroughTunnel(mode, getRouteOverride(), true))
+ return;
+
+ mContext.getSharedPreferences(Rule.PREF_WG_ROUTE, Context.MODE_PRIVATE)
+ .edit().putBoolean(mAppId, wantsTunnel).apply();
+
+ AsyncTask.execute(() -> {
+ Rule.clearCache(mContext);
+ ServiceSinkhole.reload("app routing changed", mContext, false);
+ });
+ });
+ }
+
+ @Nullable
+ private Boolean getRouteOverride() {
+ SharedPreferences wgRoute = mContext.getSharedPreferences(Rule.PREF_WG_ROUTE,
+ Context.MODE_PRIVATE);
+ return wgRoute.contains(mAppId) ? wgRoute.getBoolean(mAppId, true) : null;
+ }
+
+ private boolean hasDefaultRoutes(SharedPreferences prefs) {
+ try {
+ net.kollnig.missioncontrol.wg.WgConfig config =
+ net.kollnig.missioncontrol.wg.WgConfigParser.INSTANCE
+ .parse(prefs.getString("wg_config", ""));
+ List allowedIps = new ArrayList<>();
+ for (net.kollnig.missioncontrol.wg.WgPeer peer : config.getPeers())
+ allowedIps.addAll(peer.getAllowedIPs());
+ return RemoteRoutingLogic.hasDefaultRoutes(allowedIps,
+ prefs.getBoolean("ip6", true));
+ } catch (Throwable ex) {
+ Log.w(TAG, "Cannot read AllowedIPs, hiding per-app routing: " + ex);
+ return false;
+ }
+ }
+
+ private String explainUnavailable(RemoteRoutingLogic.Unavailable unavailable) {
+ switch (unavailable) {
+ case BYPASSED:
+ return mContext.getString(R.string.app_route_unavailable_bypassed);
+ case PARTIAL_ROUTES:
+ return mContext.getString(R.string.app_route_unavailable_partial_routes);
+ case NO_REMOTE_VPN:
+ default:
+ return mContext.getString(R.string.app_route_unavailable_no_vpn);
}
}
@@ -615,6 +700,9 @@ static class VHHeader extends RecyclerView.ViewHolder {
final TextView mLibraryDisclaimer;
final RadioGroup mAppState;
final TextView mNoInternetExplanation;
+ final View mAppRouteCard;
+ final RadioGroup mAppRoute;
+ final TextView mAppRouteUnavailable;
VHHeader(View view) {
super(view);
@@ -622,6 +710,9 @@ static class VHHeader extends RecyclerView.ViewHolder {
mLibraryDisclaimer = view.findViewById(R.id.tvLibraryDisclaimer);
mAppState = view.findViewById(R.id.rgAppState);
mNoInternetExplanation = view.findViewById(R.id.tvStateNoInternetDesc);
+ mAppRouteCard = view.findViewById(R.id.cardAppRoute);
+ mAppRoute = view.findViewById(R.id.rgAppRoute);
+ mAppRouteUnavailable = view.findViewById(R.id.tvAppRouteUnavailable);
}
}
}
diff --git a/app/src/main/jni/netguard/ip.c b/app/src/main/jni/netguard/ip.c
index 3deb7336c..f361f991c 100644
--- a/app/src/main/jni/netguard/ip.c
+++ b/app/src/main/jni/netguard/ip.c
@@ -53,6 +53,76 @@ static int is_local_dest(int version, const void *daddr) {
}
}
+// The UID of an established flow, for packets that arrive without one.
+//
+// Packet 2+ of a direct flow reaches the routing fork with uid == -1: the
+// expensive lookup above is deliberately skipped for existing UDP sessions and
+// non-SYN TCP. The uid cache usually answers, but it expires and is evicted,
+// and defaulting on a miss would send the rest of an established, already-NATted
+// flow into the tunnel mid-stream. The session table is the authoritative and
+// free source, so consult it before giving up.
+//
+// This runs only behind a route_flow_lookup miss, so it is the second fallback
+// rather than the per-packet cost it once was. The UDP arm repeats the 5-tuple
+// match in udp.c's has_udp_session; keep the two in step.
+static jint get_session_uid(const struct arguments *args, int version, int protocol,
+ const uint8_t *pkt, const uint8_t *payload) {
+ const struct iphdr *ip4 = (struct iphdr *) pkt;
+ const struct ip6_hdr *ip6 = (struct ip6_hdr *) pkt;
+
+ for (struct ng_session *cur = args->ctx->ng_session; cur != NULL; cur = cur->next) {
+ if (cur->protocol != protocol)
+ continue;
+
+ if (protocol == IPPROTO_ICMP || protocol == IPPROTO_ICMPV6) {
+ const struct icmp *icmp = (struct icmp *) payload;
+ if (cur->icmp.version == version && cur->icmp.id == icmp->icmp_id &&
+ (version == 4
+ ? cur->icmp.saddr.ip4 == ip4->saddr && cur->icmp.daddr.ip4 == ip4->daddr
+ : memcmp(&cur->icmp.saddr.ip6, &ip6->ip6_src, 16) == 0 &&
+ memcmp(&cur->icmp.daddr.ip6, &ip6->ip6_dst, 16) == 0))
+ return cur->icmp.uid;
+ continue;
+ }
+
+ if (protocol == IPPROTO_UDP) {
+ const struct udphdr *udphdr = (struct udphdr *) payload;
+ if (cur->udp.version == version &&
+ cur->udp.source == udphdr->source && cur->udp.dest == udphdr->dest &&
+ (version == 4
+ ? cur->udp.saddr.ip4 == ip4->saddr && cur->udp.daddr.ip4 == ip4->daddr
+ : memcmp(&cur->udp.saddr.ip6, &ip6->ip6_src, 16) == 0 &&
+ memcmp(&cur->udp.daddr.ip6, &ip6->ip6_dst, 16) == 0))
+ return cur->udp.uid;
+ } else if (protocol == IPPROTO_TCP) {
+ const struct tcphdr *tcphdr = (struct tcphdr *) payload;
+ if (cur->tcp.version == version &&
+ cur->tcp.source == tcphdr->source && cur->tcp.dest == tcphdr->dest &&
+ (version == 4
+ ? cur->tcp.saddr.ip4 == ip4->saddr && cur->tcp.daddr.ip4 == ip4->daddr
+ : memcmp(&cur->tcp.saddr.ip6, &ip6->ip6_src, 16) == 0 &&
+ memcmp(&cur->tcp.daddr.ip6, &ip6->ip6_dst, 16) == 0))
+ return cur->tcp.uid;
+ }
+ }
+
+ return -1;
+}
+
+// Re-run the authoritative UID lookup only after both the flow cache and the
+// native session table miss. This is deliberately off the hot path: established
+// UDP/TCP packets normally hit the flow cache, while a miss can be caused by a
+// cache expiry or hash collision. Keep the Android <=28 procfs path identical
+// to the initial lookup and use ConnectivityManager on newer releases.
+static jint get_route_uid(const struct arguments *args, int version, int protocol,
+ const void *saddr, uint16_t sport,
+ const void *daddr, uint16_t dport,
+ const char *source, const char *dest) {
+ if (args->ctx->sdk <= 28)
+ return get_uid(version, protocol, saddr, sport, daddr, dport);
+ return get_uid_q(args, version, protocol, source, sport, dest, dport);
+}
+
uint16_t get_mtu() {
return 10000;
}
@@ -330,7 +400,13 @@ void handle_ip(const struct arguments *args,
jint uid = -1;
if (protocol == IPPROTO_ICMP || protocol == IPPROTO_ICMPV6 ||
(protocol == IPPROTO_UDP && !has_udp_session(args, pkt, payload)) ||
- (protocol == IPPROTO_TCP && syn && (dport != 443 || !is_play))) {
+ // SNI research mode lets a 443 SYN through without a UID so the
+ // ClientHello can be reassembled first. That is fine for the block
+ // decision, but the routing fork needs the UID now: with no UID and no
+ // session, the SYN falls to the global default and every later packet
+ // of that flow inherits the answer from it.
+ (protocol == IPPROTO_TCP && syn &&
+ (dport != 443 || !is_play || route_uid_relevant()))) {
if (args->ctx->sdk <= 28) // Android 9 Pie
uid = get_uid(version, protocol, saddr, sport, daddr, dport);
else
@@ -484,7 +560,66 @@ void handle_ip(const struct arguments *args,
int is_dns = (dport == 53 &&
(protocol == IPPROTO_UDP || protocol == IPPROTO_TCP));
int wg_is_required = atomic_load_explicit(&wg_required, memory_order_acquire);
- int wg_dest = !is_local_dest(version, daddr) || is_dns;
+
+ // Which app this packet belongs to — but only when that can change the
+ // answer. With no per-app override configured every UID routes the same
+ // way, and this is the per-packet path: resolving a UID there would
+ // cost a lock and, for the established flows that arrive with uid == -1
+ // (existing UDP sessions, non-SYN TCP, i.e. most packets), a walk of the
+ // whole session table, which grows with load. That is pure waste for
+ // everyone who has not opted in.
+ int tunnel_uid;
+ if (route_uid_relevant()) {
+ // A flow keeps the verdict its first packet was given. A fresh UID
+ // is authoritative even when a previous flow happened to reuse the
+ // same 5-tuple, so do not consult the flow cache in that case.
+ jint route_uid = uid;
+ if (route_uid >= 0) {
+ tunnel_uid = is_tunnel_uid(route_uid);
+ route_flow_store(version, protocol, saddr, sport, daddr, dport,
+ tunnel_uid);
+ } else if (route_flow_lookup(version, protocol, saddr, sport, daddr, dport,
+ &tunnel_uid)) {
+ // Established tunnelled flows never create an ng_session — the
+ // WireGuard write below returns first — so the cache preserves
+ // their first-packet answer without a per-packet UID lookup.
+ } else {
+ // A cache expiry or collision is rare, but falling back to the
+ // selected-mode default would divert an already-established
+ // tunnelled flow direct and can make TCP reset. Recover the UID
+ // from the native session table first, then from the
+ // authoritative Android/procfs lookup.
+ route_uid = get_session_uid(args, version, protocol, pkt, payload);
+ if (route_uid < 0)
+ route_uid = get_route_uid(args, version, protocol,
+ saddr, sport, daddr, dport,
+ source, dest);
+
+ if (route_uid >= 0) {
+ tunnel_uid = is_tunnel_uid(route_uid);
+ route_flow_store(version, protocol, saddr, sport, daddr, dport,
+ tunnel_uid);
+ } else {
+ // Unknown ownership is privacy-sensitive: keep the packet
+ // in the remote tunnel rather than fail-open to direct
+ // routing in selected mode. Cache that explicit fail-closed
+ // verdict so the rest of this flow does not repeat a Binder
+ // or procfs lookup on every packet. A later flow with the
+ // same tuple still wins because a freshly resolved UID is
+ // handled before the cache above.
+ tunnel_uid = 1;
+ route_flow_store(version, protocol, saddr, sport, daddr, dport,
+ tunnel_uid);
+ log_android(ANDROID_LOG_WARN,
+ "Route UID unavailable for v%d p%d %s/%u > %s/%u; tunnelling",
+ version, protocol, source, sport, dest, dport);
+ }
+ }
+ } else
+ tunnel_uid = route_default_is_tunnel();
+
+ int wg_dest = route_wants_tunnel(is_local_dest(version, daddr), is_dns,
+ tunnel_uid, route_dns_direct());
if (wg_dest) {
ssize_t w;
diff --git a/app/src/main/jni/netguard/netguard.c b/app/src/main/jni/netguard/netguard.c
index 1f2c246b2..e0f2969d3 100644
--- a/app/src/main/jni/netguard/netguard.c
+++ b/app/src/main/jni/netguard/netguard.c
@@ -453,6 +453,34 @@ Java_eu_faircode_netguard_ServiceSinkhole_jni_1wireguard_1required(JNIEnv *env,
atomic_store_explicit(&wg_required, required ? 1 : 0, memory_order_release);
}
+JNIEXPORT void JNICALL
+Java_eu_faircode_netguard_ServiceSinkhole_jni_1wireguard_1route(JNIEnv *env, jobject instance,
+ jintArray uids_,
+ jboolean default_tunnel,
+ jboolean dns_direct) {
+ jint count = 0;
+ jint *uids = NULL;
+ if (uids_ != NULL) {
+ count = (*env)->GetArrayLength(env, uids_);
+ if (count > 0) {
+ uids = (*env)->GetIntArrayElements(env, uids_, NULL);
+ if (uids == NULL) {
+ log_android(ANDROID_LOG_ERROR, "wg route uids unavailable, keeping previous");
+ return;
+ }
+ }
+ }
+
+ set_route_uids(uids, count, default_tunnel ? 1 : 0, dns_direct ? 1 : 0);
+
+ if (uids != NULL)
+ (*env)->ReleaseIntArrayElements(env, uids_, uids, JNI_ABORT);
+
+ log_android(ANDROID_LOG_WARN,
+ "WireGuard routing: default %s, %d uid overrides, direct DNS %s",
+ default_tunnel ? "tunnel" : "direct", count, dns_direct ? "on" : "off");
+}
+
JNIEXPORT void JNICALL
Java_eu_faircode_netguard_ServiceSinkhole_jni_1wireguard_1stop(JNIEnv *env, jobject instance) {
if (pthread_mutex_lock(&wg_outbound_lock)) {
@@ -490,6 +518,8 @@ Java_eu_faircode_netguard_ServiceSinkhole_jni_1done(
uid_cache_size = 0;
uid_cache = NULL;
+ clear_route_uids();
+
ng_free(ctx, __FILE__, __LINE__);
}
diff --git a/app/src/main/jni/netguard/netguard.h b/app/src/main/jni/netguard/netguard.h
index 7ec615db1..ae25a1c8f 100644
--- a/app/src/main/jni/netguard/netguard.h
+++ b/app/src/main/jni/netguard/netguard.h
@@ -116,6 +116,7 @@ struct allowed {
int write_wireguard_packet(const void *packet, size_t length,
ssize_t *written, int *write_errno);
+
struct segment {
uint32_t seq;
uint16_t len;
@@ -536,6 +537,38 @@ void dns_resolved(const struct arguments *args,
jboolean is_domain_blocked(const struct arguments *args, const char *name);
+// Per-app remote routing (route.c). Java pushes down only the UIDs whose
+// routing differs from the global default; the packet path only reads them.
+void set_route_uids(const jint *uids, int count, int default_tunnel, int dns_direct);
+
+void clear_route_uids();
+
+int is_tunnel_uid(jint uid);
+
+// Lock-free fast path: whether a UID lookup can change the answer at all, the
+// answer everything gets when it cannot, and whether DNS follows that answer.
+int route_uid_relevant();
+
+int route_default_is_tunnel();
+
+int route_dns_direct();
+
+int route_wants_tunnel(int local_dest, int is_dns, int tunnel_uid, int dns_direct);
+
+// Per-flow verdict cache, so a flow that has already been routed keeps its
+// answer once its packets stop carrying a UID. Tunnel-thread only.
+int route_flow_lookup(int version, int protocol,
+ const void *saddr, uint16_t sport,
+ const void *daddr, uint16_t dport,
+ int *tunnel);
+
+void route_flow_store(int version, int protocol,
+ const void *saddr, uint16_t sport,
+ const void *daddr, uint16_t dport,
+ int tunnel);
+
+void route_flow_invalidate();
+
jint get_uid_q(const struct arguments *args,
jint version,
jint protocol,
diff --git a/app/src/main/jni/netguard/route.c b/app/src/main/jni/netguard/route.c
new file mode 100644
index 000000000..3bedda1b1
--- /dev/null
+++ b/app/src/main/jni/netguard/route.c
@@ -0,0 +1,295 @@
+/*
+ * TrackerControl is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * TrackerControl is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * Copyright © 2026
+ */
+
+#include "netguard.h"
+
+#include
+#include
+
+// Sorted array of the UIDs whose routing *differs* from the global default,
+// plus that default. Written from the Java thread during a reload and read by
+// the tunnel thread on the packet path, so both sides take route_lock. The
+// packet path is otherwise single-threaded (one tunnelThread runs jni_run),
+// which is why nothing else here needs a lock.
+//
+// Only the exceptions are pushed, never the whole tunnelled set. In the default
+// mode every applied app is tunnelled, so a "tunnelled UIDs" array held every
+// installed app and was indistinguishable from a heavily-overridden one — which
+// made route_uid_relevant() below always true and cost every user, WireGuard or
+// not, a per-packet lock and session-table walk.
+static pthread_mutex_t route_lock = PTHREAD_MUTEX_INITIALIZER;
+static jint *route_uids = NULL;
+static int route_uid_count = 0;
+static int route_default_tunnel = 1;
+
+// Fast-path mirrors of the facts the packet path needs before it knows whether
+// resolving a UID is worth anything. All are read per packet, so they are
+// atomics rather than lock-protected: with no per-app override configured —
+// the shipped default — every UID gets the same answer, and the packet path
+// must not pay a mutex or a session-table walk to rediscover that.
+static _Atomic int route_has_overrides = 0;
+static _Atomic int route_default_tunnel_fast = 1;
+
+// Whether direct apps' DNS is redirected to the system resolver. Its own flag
+// rather than a reuse of args->fwd53: that one is also set by an unrelated
+// port-53 forward (Secure DNS runs one whenever WireGuard carries no DNS line
+// of its own), and borrowing it silently switched off the rule that every
+// resolver query takes the tunnel.
+static _Atomic int route_dns_direct_fast = 0;
+
+static int compare_uid(const void *a, const void *b) {
+ jint ua = *(const jint *) a;
+ jint ub = *(const jint *) b;
+ return (ua > ub) - (ua < ub);
+}
+
+void set_route_uids(const jint *uids, int count, int default_tunnel, int dns_direct) {
+ jint *copy = NULL;
+ if (count > 0) {
+ copy = ng_malloc(sizeof(jint) * (size_t) count, "route uids");
+ if (copy == NULL) {
+ log_android(ANDROID_LOG_ERROR, "route uids alloc failed, keeping previous routing");
+ return;
+ }
+ memcpy(copy, uids, sizeof(jint) * (size_t) count);
+ qsort(copy, (size_t) count, sizeof(jint), compare_uid);
+ }
+
+ if (pthread_mutex_lock(&route_lock)) {
+ log_android(ANDROID_LOG_ERROR, "route lock failed, keeping previous routing");
+ if (copy != NULL)
+ ng_free(copy, __FILE__, __LINE__);
+ return;
+ }
+
+ jint *previous = route_uids;
+ route_uids = copy;
+ route_uid_count = count;
+ route_default_tunnel = default_tunnel;
+
+ atomic_store_explicit(&route_has_overrides, count > 0 ? 1 : 0, memory_order_release);
+ atomic_store_explicit(&route_default_tunnel_fast, default_tunnel, memory_order_release);
+ atomic_store_explicit(&route_dns_direct_fast, dns_direct, memory_order_release);
+
+ if (pthread_mutex_unlock(&route_lock))
+ log_android(ANDROID_LOG_ERROR, "route unlock failed");
+
+ // Verdicts cached against the previous rules must not survive them.
+ route_flow_invalidate();
+
+ if (previous != NULL)
+ ng_free(previous, __FILE__, __LINE__);
+}
+
+void clear_route_uids() {
+ set_route_uids(NULL, 0, 1, 0);
+}
+
+int is_tunnel_uid(jint uid) {
+ if (pthread_mutex_lock(&route_lock)) {
+ // Fall back to the safest answer: keep the app in the tunnel.
+ log_android(ANDROID_LOG_ERROR, "route lock failed, tunnelling uid %d", uid);
+ return 1;
+ }
+
+ int tunnel;
+ // Only UIDs the user gave an explicit, differing answer for are listed.
+ // Everything else — an unresolved UID, system traffic, an app installed
+ // since the last reload — follows the global default, which is what makes
+ // the default mode identical to the behaviour before per-app routing
+ // existed.
+ if (uid < 0 || route_uids == NULL)
+ tunnel = route_default_tunnel;
+ else if (bsearch(&uid, route_uids, (size_t) route_uid_count, sizeof(jint), compare_uid)
+ != NULL)
+ tunnel = !route_default_tunnel;
+ else
+ tunnel = route_default_tunnel;
+
+ if (pthread_mutex_unlock(&route_lock))
+ log_android(ANDROID_LOG_ERROR, "route unlock failed");
+
+ return tunnel;
+}
+
+/**
+ * Whether resolving this packet's UID can change the routing answer.
+ *
+ * With no per-app override configured, every UID resolves to the same global
+ * default, so the packet path can skip both the UID lookup and the lock. This
+ * is the shipped default, and keeping it free is what holds the per-packet cost
+ * at what it was before per-app routing existed.
+ */
+int route_uid_relevant() {
+ return atomic_load_explicit(&route_has_overrides, memory_order_acquire);
+}
+
+/** The answer every UID gets when no override is configured. */
+int route_default_is_tunnel() {
+ return atomic_load_explicit(&route_default_tunnel_fast, memory_order_acquire);
+}
+
+/** Whether direct apps' DNS is redirected, in which case DNS follows the UID. */
+int route_dns_direct() {
+ return atomic_load_explicit(&route_dns_direct_fast, memory_order_acquire);
+}
+
+/**
+ * Whether this packet belongs in the tunnel. Pure, so the rule can be read
+ * straight through — the caller still decides what to do when the tunnel is
+ * down, because only the write attempt can say whether it is.
+ *
+ * @param local_dest loopback / link-local / multicast destination
+ * @param is_dns port 53, over UDP or TCP
+ * @param tunnel_uid whether this packet's UID is routed through the tunnel
+ * @param dns_direct whether direct apps' DNS is redirected to the system
+ * resolver; while false, DNS always takes the tunnel
+ */
+int route_wants_tunnel(int local_dest, int is_dns, int tunnel_uid, int dns_direct) {
+ // Destinations WireGuard cannot meaningfully forward never take the tunnel,
+ // whichever app sent them.
+ if (local_dest && !is_dns)
+ return 0;
+
+ // Unless a direct app's DNS is being redirected, every resolver query takes
+ // the tunnel: sending it out directly would expose the user's physical
+ // network to the resolver.
+ if (is_dns && !dns_direct)
+ return 1;
+
+ return tunnel_uid;
+}
+
+// --- Per-flow verdict cache -------------------------------------------------
+//
+// A tunnelled packet is handed to WireGuard and returns before handle_tcp /
+// handle_udp run, so no ng_session is ever created for it. Meanwhile the UID
+// lookup upstream is skipped for non-SYN TCP and existing UDP, so packet 2+ of
+// a tunnelled flow arrives with uid == -1 and nothing left to resolve it from.
+// Falling back to the global default there is wrong in exactly the mode the
+// feature exists for: in "selected" mode the default is direct, so the rest of
+// a tunnelled TCP flow was routed direct and handle_tcp, finding no session for
+// a non-SYN segment, answered it with an RST.
+//
+// So remember the verdict per flow, keyed on the 5-tuple, and consult it before
+// giving up. Written and read only by the tunnel thread, which is why nothing
+// here takes a lock; a reload bumps route_flow_gen instead of clearing the
+// table, so entries decided under superseded rules simply stop matching.
+
+#define ROUTE_FLOW_SIZE 1024 // power of two; ~40 KB resident
+#define ROUTE_FLOW_MAX_AGE 300 // seconds idle before an entry is reusable
+
+struct route_flow_entry {
+ uint32_t gen; // 0 = free
+ uint8_t version;
+ uint8_t protocol;
+ uint8_t tunnel;
+ uint16_t sport;
+ uint16_t dport;
+ uint8_t saddr[16];
+ uint8_t daddr[16];
+ time_t time;
+};
+
+static struct route_flow_entry route_flows[ROUTE_FLOW_SIZE];
+static _Atomic uint32_t route_flow_gen = 1;
+
+void route_flow_invalidate() {
+ // Wrapping past 0 would resurrect free slots, so skip it.
+ uint32_t next = atomic_fetch_add_explicit(&route_flow_gen, 1, memory_order_release) + 1;
+ if (next == 0)
+ atomic_store_explicit(&route_flow_gen, 1, memory_order_release);
+}
+
+static size_t route_flow_slot(int version, int protocol,
+ const void *saddr, uint16_t sport,
+ const void *daddr, uint16_t dport) {
+ size_t alen = (version == 4 ? 4u : 16u);
+ // FNV-1a
+ uint32_t h = 2166136261u;
+ const uint8_t *s = saddr;
+ const uint8_t *d = daddr;
+ for (size_t i = 0; i < alen; i++) {
+ h = (h ^ s[i]) * 16777619u;
+ h = (h ^ d[i]) * 16777619u;
+ }
+ h = (h ^ (uint8_t) version) * 16777619u;
+ h = (h ^ (uint8_t) protocol) * 16777619u;
+ h = (h ^ (uint8_t) (sport & 0xff)) * 16777619u;
+ h = (h ^ (uint8_t) (sport >> 8)) * 16777619u;
+ h = (h ^ (uint8_t) (dport & 0xff)) * 16777619u;
+ h = (h ^ (uint8_t) (dport >> 8)) * 16777619u;
+ return (size_t) (h & (ROUTE_FLOW_SIZE - 1));
+}
+
+static int route_flow_matches(const struct route_flow_entry *e, uint32_t gen,
+ int version, int protocol,
+ const void *saddr, uint16_t sport,
+ const void *daddr, uint16_t dport, time_t now) {
+ size_t alen = (version == 4 ? 4u : 16u);
+ return e->gen == gen &&
+ e->version == (uint8_t) version && e->protocol == (uint8_t) protocol &&
+ e->sport == sport && e->dport == dport &&
+ memcmp(e->saddr, saddr, alen) == 0 && memcmp(e->daddr, daddr, alen) == 0 &&
+ now - e->time <= ROUTE_FLOW_MAX_AGE;
+}
+
+/**
+ * The remembered verdict for this flow, if any.
+ *
+ * @return 1 when *tunnel was filled in, 0 on a miss.
+ */
+int route_flow_lookup(int version, int protocol,
+ const void *saddr, uint16_t sport,
+ const void *daddr, uint16_t dport,
+ int *tunnel) {
+ uint32_t gen = atomic_load_explicit(&route_flow_gen, memory_order_acquire);
+ struct route_flow_entry *e = &route_flows[route_flow_slot(
+ version, protocol, saddr, sport, daddr, dport)];
+
+ time_t now = time(NULL);
+ if (!route_flow_matches(e, gen, version, protocol, saddr, sport, daddr, dport, now))
+ return 0;
+
+ e->time = now;
+ *tunnel = e->tunnel;
+ return 1;
+}
+
+/**
+ * Remember this flow's verdict. One slot per hash, overwritten on collision —
+ * it is a cache, and a miss only costs the fallback path that ran before it
+ * existed. Never called for a packet whose UID was unknown: pinning a guessed
+ * default for the life of a flow is the failure this exists to prevent.
+ */
+void route_flow_store(int version, int protocol,
+ const void *saddr, uint16_t sport,
+ const void *daddr, uint16_t dport,
+ int tunnel) {
+ size_t alen = (version == 4 ? 4u : 16u);
+ struct route_flow_entry *e = &route_flows[route_flow_slot(
+ version, protocol, saddr, sport, daddr, dport)];
+
+ e->gen = atomic_load_explicit(&route_flow_gen, memory_order_acquire);
+ e->version = (uint8_t) version;
+ e->protocol = (uint8_t) protocol;
+ e->tunnel = (uint8_t) (tunnel ? 1 : 0);
+ e->sport = sport;
+ e->dport = dport;
+ memset(e->saddr, 0, sizeof(e->saddr));
+ memset(e->daddr, 0, sizeof(e->daddr));
+ memcpy(e->saddr, saddr, alen);
+ memcpy(e->daddr, daddr, alen);
+ e->time = time(NULL);
+}
diff --git a/app/src/main/res/layout/list_item_trackers_header.xml b/app/src/main/res/layout/list_item_trackers_header.xml
index 02b82a72d..194b82e4f 100644
--- a/app/src/main/res/layout/list_item_trackers_header.xml
+++ b/app/src/main/res/layout/list_item_trackers_header.xml
@@ -289,4 +289,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-ar-rSA/strings.xml b/app/src/main/res/values-ar-rSA/strings.xml
index 7e0d2115f..2de33128e 100644
--- a/app/src/main/res/values-ar-rSA/strings.xml
+++ b/app/src/main/res/values-ar-rSA/strings.xml
@@ -388,4 +388,18 @@
ينطبق أيضًا على %1$s، الذي يشارك هذا التطبيق مُعرِّف مستخدم أندرويد.
تجاوز TrackerControl
يتصل التطبيق مباشرةً، خارج TrackerControl. لا تتم مراقبة أو حظر أي شيء له. استخدم هذا كملاذ أخير إذا كان التطبيق لا يزال لا يعمل.\n\nملاحظة: يجب تعطيل \"حظر الاتصالات بدون VPN\" في إعدادات VPN بنظام أندرويد حتى يعمل هذا.
+
+
+ التوجيه عبر VPN البعيد
+ أي التطبيقات تُمرَّر عبر VPN البعيد. التطبيقات التي لا تُمرَّر تبقى مراقَبة ومُرشَّحة بالكامل — لكنها تتصل من شبكة هذا الجهاز.
+ كل التطبيقات
+ التطبيقات المحددة فقط
+ VPN البعيد
+ عبر VPN البعيد
+ يتصل هذا التطبيق عبر VPN البعيد الخاص بك، الذي يرى حركته بدلاً من شبكتك.
+ مباشرةً من هذا الجهاز
+ يتصل هذا التطبيق من شبكتك. تظل مراقبة أدوات التتبع وحظرها سارية. تذهب استعلامات DNS الخاصة به إلى محلل شبكتك بدلاً من محلل VPN، ويرى العنوان الحقيقي لهذا الجهاز — بعض التطبيقات تحتاج ذلك، مثلاً للوصول إلى الأجهزة المحلية أو فحص بوابة الاتصال.
+ لا يوجد VPN بعيد مُعد، لذا فإن كل الحركة تغادر هذا الجهاز مباشرةً بالفعل.
+ يتجاوز هذا التطبيق TrackerControl تمامًا، لذا لا توجد حركة لتوجيهها.
+ لا ينقل VPN البعيد كل الحركة (قيمة AllowedIPs لديه ليست نفقًا كاملاً). يتطلب التوجيه لكل تطبيق نفقًا كاملاً، لأن المسارات مشتركة بين كل التطبيقات.
diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml
index 89b6f1267..ed4b606a4 100644
--- a/app/src/main/res/values-bn-rBD/strings.xml
+++ b/app/src/main/res/values-bn-rBD/strings.xml
@@ -71,4 +71,18 @@
%1$s-এর ক্ষেত্রেও প্রযোজ্য, যেটি এই অ্যাপের সঙ্গে একই Android ব্যবহারকারী আইডি ভাগ করে।
TrackerControl এড়িয়ে যান
অ্যাপটি TrackerControl-এর বাইরে সরাসরি সংযোগ করে। এর জন্য কিছুই পর্যবেক্ষণ বা ব্লক করা হয় না। অ্যাপ তবুও কাজ না করলে শেষ উপায় হিসেবে এটি ব্যবহার করুন।\n\nদ্রষ্টব্য: এটি কাজ করতে হলে Android-এর VPN সেটিংসে \"VPN ছাড়া সংযোগ ব্লক করুন\" বন্ধ থাকতে হবে।
+
+
+ দূরবর্তী VPN দিয়ে পাঠান
+ কোন অ্যাপগুলি দূরবর্তী VPN দিয়ে যাবে। যেগুলি যায় না সেগুলিতেও ট্র্যাকার পর্যবেক্ষণ ও ব্লকিং পুরোপুরি চলে — সেগুলি কেবল এই ডিভাইসের নিজের নেটওয়ার্ক থেকে সংযোগ করে।
+ সব অ্যাপ
+ শুধু নির্বাচিত অ্যাপ
+ দূরবর্তী VPN
+ দূরবর্তী VPN দিয়ে
+ এই অ্যাপটি আপনার দূরবর্তী VPN দিয়ে সংযোগ করে, যা আপনার নেটওয়ার্কের বদলে এর ট্রাফিক দেখে।
+ সরাসরি এই ডিভাইস থেকে
+ এই অ্যাপটি আপনার নিজের নেটওয়ার্ক থেকে সংযোগ করে। ট্র্যাকার পর্যবেক্ষণ ও ব্লকিং তবুও প্রযোজ্য। এর DNS অনুরোধ VPN-এর বদলে আপনার নেটওয়ার্কের রিজলভারে যায় এবং এটি এই ডিভাইসের প্রকৃত ঠিকানা দেখে — কিছু অ্যাপের এটি দরকার, যেমন স্থানীয় ডিভাইসে পৌঁছাতে বা ক্যাপটিভ পোর্টাল যাচাই করতে।
+ কোনও দূরবর্তী VPN সেট করা নেই, তাই সব ট্রাফিক ইতিমধ্যেই সরাসরি এই ডিভাইস থেকে যাচ্ছে।
+ এই অ্যাপটি TrackerControl সম্পূর্ণ এড়িয়ে যায়, তাই পাঠানোর মতো কোনও ট্রাফিক নেই।
+ আপনার দূরবর্তী VPN সব ট্রাফিক বহন করে না (এর AllowedIPs পূর্ণ টানেল নয়)। অ্যাপভিত্তিক রাউটিংয়ের জন্য পূর্ণ টানেল দরকার, কারণ রুটগুলি সব অ্যাপ ভাগ করে নেয়।
diff --git a/app/src/main/res/values-cs-rCZ/strings.xml b/app/src/main/res/values-cs-rCZ/strings.xml
index 0b58c2613..c40fe4bf1 100644
--- a/app/src/main/res/values-cs-rCZ/strings.xml
+++ b/app/src/main/res/values-cs-rCZ/strings.xml
@@ -404,4 +404,18 @@ Privátní DNS musí být vypnutá.
Platí i pro %1$s, která s touto aplikací sdílí uživatelské ID systému Android.
Obejít TrackerControl
Aplikace se připojuje přímo, mimo TrackerControl. Nic se pro ni nesleduje ani neblokuje. Použijte jako poslední možnost, pokud aplikace stále nefunguje.\n\nPoznámka: Aby to fungovalo, musí být volba \"Blokovat připojení bez VPN\" v nastavení VPN systému Android VYPNUTA.
+
+
+ Směrovat přes vzdálenou VPN
+ Které aplikace se přeposílají přes vzdálenou VPN. Ostatní zůstávají plně sledované a filtrované — jen se připojují ze sítě tohoto zařízení.
+ Všechny aplikace
+ Jen vybrané aplikace
+ Vzdálená VPN
+ Přes vzdálenou VPN
+ Tato aplikace se připojuje přes vaši vzdálenou VPN, která místo vaší sítě vidí její provoz.
+ Přímo z tohoto zařízení
+ Tato aplikace se připojuje z vaší vlastní sítě. Sledování a blokování trackerů dál platí. Její DNS dotazy míří na resolver vaší sítě místo na VPN a aplikace vidí skutečnou adresu tohoto zařízení — některé aplikace to potřebují, třeba k dosažení místních zařízení nebo kontrole captive portálu.
+ Není nastavena žádná vzdálená VPN, takže veškerý provoz už z tohoto zařízení odchází přímo.
+ Tato aplikace TrackerControl zcela obchází, takže není co směrovat.
+ Vaše vzdálená VPN nepřenáší veškerý provoz (její AllowedIPs netvoří úplný tunel). Směrování po aplikacích vyžaduje úplný tunel, protože trasy sdílejí všechny aplikace.
diff --git a/app/src/main/res/values-da-rDK/strings.xml b/app/src/main/res/values-da-rDK/strings.xml
index 897627458..dedca0f6f 100644
--- a/app/src/main/res/values-da-rDK/strings.xml
+++ b/app/src/main/res/values-da-rDK/strings.xml
@@ -126,4 +126,18 @@
Gælder også %1$s, som deler et Android-bruger-id med denne app.
Omgå TrackerControl
Appen forbinder direkte, uden om TrackerControl. Intet overvåges eller blokeres for den. Brug dette som sidste udvej, hvis appen stadig ikke virker.\n\nBemærk: \"Bloker forbindelser uden VPN\" i Androids VPN-indstillinger skal være DEAKTIVERET, for at dette virker.
+
+
+ Send gennem fjern-VPN
+ Hvilke apps der sendes gennem fjern-VPN\'et. De øvrige overvåges og filtreres fortsat fuldt ud — de forbinder blot fra enhedens eget netværk.
+ Alle apps
+ Kun valgte apps
+ Fjern-VPN
+ Gennem fjern-VPN\'et
+ Denne app forbinder gennem dit fjern-VPN, som ser dens trafik i stedet for dit netværk.
+ Direkte fra denne enhed
+ Denne app forbinder fra dit eget netværk. Sporingsovervågning og -blokering gælder stadig. Dens DNS-forespørgsler går til dit netværks resolver i stedet for VPN\'ets, og appen ser enhedens rigtige adresse — nogle apps har brug for det, for eksempel for at nå lokale enheder eller tjekke en captive portal.
+ Der er ikke opsat noget fjern-VPN, så al trafik forlader allerede enheden direkte.
+ Denne app omgår TrackerControl helt, så der er ingen trafik at sende.
+ Dit fjern-VPN bærer ikke al trafik (dets AllowedIPs er ikke en fuld tunnel). Routing pr. app kræver en fuld tunnel, fordi ruter deles af alle apps.
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index c1669f1a1..f6c18fb06 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -353,4 +353,18 @@
Gilt auch für %1$s, da diese App sich eine Android-Benutzer-ID mit dieser App teilt.
TrackerControl umgehen
Die App verbindet sich direkt, außerhalb von TrackerControl. Nichts wird für sie überwacht oder blockiert. Nutze dies als letzten Ausweg, wenn die App weiterhin nicht funktioniert.\n\nHinweis: \"Verbindungen ohne VPN blockieren\" in den Android-VPN-Einstellungen muss DEAKTIVIERT sein, damit dies funktioniert.
+
+
+ Über Remote-VPN leiten
+ Welche Apps über das Remote-VPN geleitet werden. Apps, die es nicht sind, werden weiterhin vollständig auf Tracker überwacht und gefiltert — sie verbinden sich nur über das eigene Netzwerk dieses Geräts.
+ Alle Apps
+ Nur ausgewählte Apps
+ Remote-VPN
+ Über das Remote-VPN
+ Diese App verbindet sich über dein Remote-VPN, das ihren Datenverkehr anstelle deines Netzwerks sieht.
+ Direkt von diesem Gerät
+ Diese App verbindet sich über dein eigenes Netzwerk. Tracker-Überwachung und -Blockierung gelten weiterhin. Ihre DNS-Anfragen gehen an den Resolver deines Netzwerks statt an den des VPNs, und sie sieht die echte Adresse dieses Geräts — manche Apps brauchen das, etwa für lokale Geräte oder die Prüfung eines Captive Portals.
+ Es ist kein Remote-VPN eingerichtet, daher verlässt bereits der gesamte Verkehr dieses Gerät direkt.
+ Diese App umgeht TrackerControl vollständig, daher gibt es keinen Verkehr zu leiten.
+ Dein Remote-VPN überträgt nicht den gesamten Verkehr (seine AllowedIPs bilden keinen vollständigen Tunnel). Per-App-Routing benötigt einen vollständigen Tunnel, da Routen von allen Apps geteilt werden.
diff --git a/app/src/main/res/values-el-rGR/strings.xml b/app/src/main/res/values-el-rGR/strings.xml
index 654ff5a39..d1bdb96cd 100644
--- a/app/src/main/res/values-el-rGR/strings.xml
+++ b/app/src/main/res/values-el-rGR/strings.xml
@@ -330,4 +330,18 @@
Ισχύει και για το %1$s, το οποίο μοιράζεται αναγνωριστικό χρήστη Android με αυτήν την εφαρμογή.
Παράκαμψη του TrackerControl
Η εφαρμογή συνδέεται απευθείας, εκτός του TrackerControl. Τίποτα δεν παρακολουθείται ούτε αποκλείεται για αυτήν. Χρησιμοποιήστε το ως έσχατη λύση αν η εφαρμογή εξακολουθεί να μη λειτουργεί.\n\nΣημείωση: Η επιλογή \"Αποκλεισμός συνδέσεων χωρίς VPN\" στις ρυθμίσεις VPN του Android πρέπει να είναι ΑΠΕΝΕΡΓΟΠΟΙΗΜΕΝΗ για να λειτουργήσει αυτό.
+
+
+ Δρομολόγηση μέσω απομακρυσμένου VPN
+ Ποιες εφαρμογές προωθούνται μέσω του απομακρυσμένου VPN. Όσες δεν προωθούνται παραμένουν πλήρως παρακολουθούμενες και φιλτραρισμένες — απλώς συνδέονται από το δίκτυο αυτής της συσκευής.
+ Όλες οι εφαρμογές
+ Μόνο επιλεγμένες εφαρμογές
+ Απομακρυσμένο VPN
+ Μέσω του απομακρυσμένου VPN
+ Αυτή η εφαρμογή συνδέεται μέσω του απομακρυσμένου VPN σας, το οποίο βλέπει την κίνησή της αντί για το δίκτυό σας.
+ Απευθείας από αυτήν τη συσκευή
+ Αυτή η εφαρμογή συνδέεται από το δικό σας δίκτυο. Η παρακολούθηση και ο αποκλεισμός ιχνηλατών εξακολουθούν να ισχύουν. Τα ερωτήματα DNS της πηγαίνουν στον resolver του δικτύου σας αντί του VPN, και βλέπει την πραγματική διεύθυνση της συσκευής — ορισμένες εφαρμογές το χρειάζονται, π.χ. για πρόσβαση σε τοπικές συσκευές ή έλεγχο captive portal.
+ Δεν έχει ρυθμιστεί απομακρυσμένο VPN, οπότε όλη η κίνηση φεύγει ήδη απευθείας από αυτήν τη συσκευή.
+ Αυτή η εφαρμογή παρακάμπτει πλήρως το TrackerControl, οπότε δεν υπάρχει κίνηση προς δρομολόγηση.
+ Το απομακρυσμένο VPN σας δεν μεταφέρει όλη την κίνηση (τα AllowedIPs του δεν είναι πλήρης σήραγγα). Η δρομολόγηση ανά εφαρμογή απαιτεί πλήρη σήραγγα, καθώς οι διαδρομές είναι κοινές για όλες τις εφαρμογές.
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index a53c0ab0c..06f8f7214 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -405,4 +405,18 @@ Atentamente,\n\n]]>
También se aplica a %1$s, que comparte un ID de usuario de Android con esta aplicación.
Omitir TrackerControl
La aplicación se conecta directamente, fuera de TrackerControl. No se supervisa ni se bloquea nada para ella. Usa esto como último recurso si la aplicación sigue sin funcionar.\n\nNota: \"Bloquear conexiones sin VPN\" en los ajustes de VPN de Android debe estar DESACTIVADO para que esto funcione.
+
+
+ Enrutar por el VPN remoto
+ Qué aplicaciones se envían a través del VPN remoto. Las que no lo hacen siguen totalmente supervisadas y filtradas — simplemente se conectan desde la red de este dispositivo.
+ Todas las aplicaciones
+ Solo aplicaciones seleccionadas
+ VPN remoto
+ A través del VPN remoto
+ Esta aplicación se conecta a través de tu VPN remoto, que ve su tráfico en lugar de tu red.
+ Directamente desde este dispositivo
+ Esta aplicación se conecta desde tu propia red. La supervisión y el bloqueo de rastreadores siguen aplicándose. Sus consultas DNS van al resolutor de tu red en lugar del VPN, y ve la dirección real de este dispositivo — algunas aplicaciones lo necesitan, por ejemplo para llegar a dispositivos locales o comprobar un portal cautivo.
+ No hay ningún VPN remoto configurado, así que todo el tráfico ya sale directamente de este dispositivo.
+ Esta aplicación omite TrackerControl por completo, así que no hay tráfico que enrutar.
+ Tu VPN remoto no transporta todo el tráfico (sus AllowedIPs no forman un túnel completo). El enrutamiento por aplicación necesita un túnel completo, porque las rutas las comparten todas las aplicaciones.
diff --git a/app/src/main/res/values-et-rEE/strings.xml b/app/src/main/res/values-et-rEE/strings.xml
index de7343b9c..588db1049 100644
--- a/app/src/main/res/values-et-rEE/strings.xml
+++ b/app/src/main/res/values-et-rEE/strings.xml
@@ -248,4 +248,18 @@
Kehtib ka rakendusele %1$s, mis jagab selle rakendusega Androidi kasutaja-ID-d.
TrackerControlist mööda
Rakendus ühendub otse, TrackerControlist mööda. Tema jaoks ei jälgita ega blokeerita midagi. Kasuta seda viimase abinõuna, kui rakendus ikka ei tööta.\n\nMärkus: Androidi VPN-i sätetes peab \"Blokeeri ühendused ilma VPN-ita\" olema VÄLJA LÜLITATUD, et see toimiks.
+
+
+ Suuna läbi kaug-VPN-i
+ Millised rakendused suunatakse läbi kaug-VPN-i. Ülejäänud jäävad täielikult jälgituks ja filtreerituks — nad lihtsalt ühenduvad selle seadme enda võrgust.
+ Kõik rakendused
+ Ainult valitud rakendused
+ Kaug-VPN
+ Läbi kaug-VPN-i
+ See rakendus ühendub sinu kaug-VPN-i kaudu, mis näeb selle liiklust sinu võrgu asemel.
+ Otse sellest seadmest
+ See rakendus ühendub sinu enda võrgust. Jälitajate seire ja blokeerimine kehtivad edasi. Selle DNS-päringud lähevad sinu võrgu nimeserverisse, mitte VPN-i omasse, ja rakendus näeb seadme tegelikku aadressi — mõni rakendus vajab seda, näiteks kohalike seadmete jaoks või captive portali kontrolliks.
+ Kaug-VPN-i pole seadistatud, seega kogu liiklus lahkub juba otse sellest seadmest.
+ See rakendus möödub TrackerControlist täielikult, seega pole liiklust, mida suunata.
+ Sinu kaug-VPN ei kanna kogu liiklust (selle AllowedIPs pole täistunnel). Rakendusepõhine suunamine vajab täistunnelit, sest marsruudid on kõigil rakendustel ühised.
diff --git a/app/src/main/res/values-eu-rES/strings.xml b/app/src/main/res/values-eu-rES/strings.xml
index 6765cc6f5..cb177e636 100644
--- a/app/src/main/res/values-eu-rES/strings.xml
+++ b/app/src/main/res/values-eu-rES/strings.xml
@@ -369,4 +369,18 @@ Sareko trafikoan antzematen diren aztarnariak TrackerControl-ekin blokeatu daite
%1$s aplikazioari ere aplikatzen zaio, Android erabiltzaile-ID bera partekatzen baitu honekin.
Saihestu TrackerControl
Aplikazioa zuzenean konektatzen da, TrackerControletik kanpo. Ez zaio ezer monitorizatzen ez blokeatzen. Erabili hau azken aukera gisa aplikazioak oraindik funtzionatzen ez badu.\n\nOharra: Androiden VPN ezarpenetako \"Blokeatu VPNrik gabeko konexioak\" DESAKTIBATUTA egon behar da hau funtziona dezan.
+
+
+ Urruneko VPN bidez bideratu
+ Zein aplikazio bidaltzen diren urruneko VPN bidez. Gainerakoak guztiz monitorizatuta eta iragazita jarraitzen dute — gailu honen sare propiotik konektatzen dira, besterik ez.
+ Aplikazio guztiak
+ Hautatutako aplikazioak bakarrik
+ Urruneko VPNa
+ Urruneko VPN bidez
+ Aplikazio hau zure urruneko VPN bidez konektatzen da, eta hark ikusten du bere trafikoa zure sarearen ordez.
+ Zuzenean gailu honetatik
+ Aplikazio hau zure sare propiotik konektatzen da. Jarraitzaileen monitorizazioa eta blokeoa indarrean daude oraindik. Bere DNS kontsultak zure sarearen ebazlera doaz, ez VPNarenera, eta gailu honen benetako helbidea ikusten du — aplikazio batzuek hori behar dute, adibidez tokiko gailuetara iristeko edo captive portal bat egiaztatzeko.
+ Ez dago urruneko VPNrik konfiguratuta, beraz trafiko guztia dagoeneko zuzenean ateratzen da gailu honetatik.
+ Aplikazio honek TrackerControl guztiz saihesten du, beraz ez dago bideratzeko trafikorik.
+ Zure urruneko VPNak ez du trafiko guztia garraiatzen (bere AllowedIPs ez da tunel osoa). Aplikazioz aplikazioko bideratzeak tunel osoa behar du, ibilbideak aplikazio guztien artean partekatzen baitira.
diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml
index a37ac8618..06813ba29 100644
--- a/app/src/main/res/values-fa-rIR/strings.xml
+++ b/app/src/main/res/values-fa-rIR/strings.xml
@@ -28,4 +28,18 @@
برای %1$s نیز اعمال میشود، که شناسهٔ کاربری اندروید را با این برنامه به اشتراک میگذارد.
دور زدن TrackerControl
برنامه مستقیماً و خارج از TrackerControl متصل میشود. هیچ چیزی برای آن پایش یا مسدود نمیشود. اگر برنامه هنوز کار نمیکند، این را بهعنوان آخرین راهحل استفاده کنید.\n\nتوجه: گزینهٔ \"مسدودسازی اتصالهای بدون VPN\" در تنظیمات VPN اندروید باید غیرفعال باشد تا این کار کند.
+
+
+ مسیریابی از طریق VPN راه دور
+ کدام برنامهها از طریق VPN راه دور فرستاده شوند. برنامههایی که فرستاده نمیشوند همچنان بهطور کامل پایش و فیلتر میشوند — فقط از شبکهٔ خودِ این دستگاه متصل میشوند.
+ همهٔ برنامهها
+ فقط برنامههای انتخابشده
+ VPN راه دور
+ از طریق VPN راه دور
+ این برنامه از طریق VPN راه دور شما متصل میشود، که بهجای شبکهٔ شما ترافیک آن را میبیند.
+ مستقیم از این دستگاه
+ این برنامه از شبکهٔ خودتان متصل میشود. پایش و مسدودسازی ردیابها همچنان اعمال میشود. پرسوجوهای DNS آن بهجای VPN به حلکنندهٔ شبکهٔ شما میرود و برنامه نشانی واقعی این دستگاه را میبیند — برخی برنامهها به این نیاز دارند، مثلاً برای دسترسی به دستگاههای محلی یا بررسی captive portal.
+ هیچ VPN راه دوری تنظیم نشده است، بنابراین همهٔ ترافیک از پیش مستقیماً این دستگاه را ترک میکند.
+ این برنامه بهطور کامل TrackerControl را دور میزند، بنابراین ترافیکی برای مسیریابی وجود ندارد.
+ VPN راه دور شما همهٔ ترافیک را حمل نمیکند (AllowedIPs آن تونل کامل نیست). مسیریابی جداگانه برای هر برنامه به تونل کامل نیاز دارد، چون مسیرها میان همهٔ برنامهها مشترک است.
diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml
index d20c8328b..c5b74a484 100644
--- a/app/src/main/res/values-fi-rFI/strings.xml
+++ b/app/src/main/res/values-fi-rFI/strings.xml
@@ -458,4 +458,18 @@ Ystävällisin terveisin,\n\n]]>
Koskee myös sovellusta %1$s, jolla on sama Android-käyttäjätunnus kuin tällä sovelluksella.
Ohita TrackerControl
Sovellus muodostaa yhteyden suoraan, TrackerControlin ohi. Sille ei valvota eikä estetä mitään. Käytä tätä viimeisenä keinona, jos sovellus ei vieläkään toimi.\n\nHuomaa: Androidin VPN-asetusten kohdan \"Estä yhteydet ilman VPN:ää\" on oltava POIS KÄYTÖSTÄ, jotta tämä toimii.
+
+
+ Reititä etä-VPN:n kautta
+ Mitkä sovellukset kulkevat etä-VPN:n kautta. Muita valvotaan ja suodatetaan edelleen täysin — ne vain yhdistävät tämän laitteen omasta verkosta.
+ Kaikki sovellukset
+ Vain valitut sovellukset
+ Etä-VPN
+ Etä-VPN:n kautta
+ Tämä sovellus yhdistää etä-VPN:si kautta, joka näkee sen liikenteen verkkosi sijaan.
+ Suoraan tästä laitteesta
+ Tämä sovellus yhdistää omasta verkostasi. Seuraimien valvonta ja esto ovat edelleen voimassa. Sen DNS-kyselyt menevät verkkosi nimipalvelimelle VPN:n sijaan, ja sovellus näkee laitteen todellisen osoitteen — jotkin sovellukset tarvitsevat sitä, esimerkiksi paikallisten laitteiden tavoittamiseen tai captive portalin tarkistamiseen.
+ Etä-VPN:ää ei ole määritetty, joten kaikki liikenne lähtee jo suoraan tästä laitteesta.
+ Tämä sovellus ohittaa TrackerControlin kokonaan, joten reititettävää liikennettä ei ole.
+ Etä-VPN:si ei kuljeta kaikkea liikennettä (sen AllowedIPs ei ole täysi tunneli). Sovelluskohtainen reititys vaatii täyden tunnelin, koska reitit ovat kaikille sovelluksille yhteiset.
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 6a85fd3a2..7c99004a1 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -586,4 +586,18 @@ Cordialement,\n\n]]>