entry : all.entrySet()) {
+ String prefKey = entry.getKey();
+ Object value = entry.getValue();
+ if (!(value instanceof Long))
+ continue;
+ long expiry = (Long) value;
+
+ int sep = prefKey.indexOf(':');
+ if (sep <= 0) {
+ p.edit().remove(prefKey).apply();
+ continue;
+ }
+ int uid;
+ try {
+ uid = Integer.parseInt(prefKey.substring(0, sep));
+ } catch (NumberFormatException ex) {
+ p.edit().remove(prefKey).apply();
+ continue;
+ }
+ String trackerKey = prefKey.substring(sep + 1);
+
+ if (expiry <= now) {
+ b.block(uid, trackerKey);
+ p.edit().remove(prefKey).apply();
+ changed = true;
+ } else {
+ scheduleAlarm(context, uid, trackerKey, expiry);
+ }
+ }
+
+ if (changed) {
+ b.saveSettings(context);
+ Rule.clearCache(context);
+ ServiceSinkhole.reload("expired temporary tracker allowances swept", context, false);
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null || !ACTION_REBLOCK.equals(intent.getAction()))
+ return;
+
+ int uid = intent.getIntExtra(EXTRA_UID, -1);
+ String key = intent.getStringExtra(EXTRA_KEY);
+ if (uid < 0 || key == null)
+ return;
+
+ // Guard against a stale alarm firing after the user cancelled/renewed.
+ long expiry = prefs(context).getLong(prefKey(uid, key), 0);
+ if (expiry == 0)
+ return;
+
+ reblock(context, uid, key);
+ }
+}
diff --git a/app/src/main/java/eu/faircode/netguard/VpnRoutes.java b/app/src/main/java/eu/faircode/netguard/VpnRoutes.java
index 29430d62a..af341742f 100644
--- a/app/src/main/java/eu/faircode/netguard/VpnRoutes.java
+++ b/app/src/main/java/eu/faircode/netguard/VpnRoutes.java
@@ -22,6 +22,7 @@
import android.util.Log;
+import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
@@ -29,26 +30,41 @@
import java.util.List;
/**
- * Static pre-computed VPN routes covering all public IPv4 address space,
- * excluding private, reserved, and carrier Wi-Fi calling ranges.
+ * VPN routes covering all public IPv4 address space, excluding private,
+ * reserved, and carrier Wi-Fi calling ranges.
*
- * Routes are computed once on first access from a fixed exclusion list,
- * avoiding per-rebuild dynamic computation overhead.
+ * The default route set (WireGuard remote egress off) is computed once and
+ * cached. RFC 1918 private ranges (10/8, 172.16/12, 192.168/16) are normally
+ * excluded so LAN traffic bypasses the tunnel and reaches the local network
+ * directly.
+ *
+ *
When WireGuard remote egress is active, the profile's {@code AllowedIPs}
+ * become authoritative: any part of an RFC 1918 range that a peer's
+ * {@code AllowedIPs} covers (e.g. {@code 0.0.0.0/0} or an explicit
+ * {@code 192.168.1.0/24}) is routed into the tunnel instead of being excluded,
+ * so split-DNS setups that resolve self-hosted services to private addresses
+ * behind the WireGuard endpoint become reachable (issue #593). Loopback,
+ * link-local, multicast, CGNAT, current-network, and carrier Wi-Fi-calling
+ * ranges are ALWAYS excluded regardless of AllowedIPs.
*/
public class VpnRoutes {
private static final String TAG = "TrackerControl.VpnRoutes";
private static volatile List cachedRoutes;
- // Ranges excluded from VPN routing (must not overlap)
- private static final String[][] EXCLUDED_RANGES = {
- // Reserved and private ranges
+ // Single-entry cache for the WireGuard-active case: the config rarely
+ // changes but getBuilder() runs on every VPN rebuild, so caching by the
+ // normalized AllowedIPs signature avoids recomputing the complement.
+ private static volatile String cachedAllowedKey;
+ private static volatile List cachedAllowedRoutes;
+
+ // Ranges ALWAYS excluded from VPN routing, regardless of WireGuard
+ // AllowedIPs (must not overlap each other or RFC1918_RANGES).
+ private static final String[][] ALWAYS_EXCLUDED = {
+ // Reserved ranges
{"0.0.0.0", "8"}, // Current network (RFC 1122)
- {"10.0.0.0", "8"}, // Private (RFC 1918)
{"100.64.0.0", "10"}, // CGNAT (RFC 6598)
{"127.0.0.0", "8"}, // Loopback (RFC 1122)
{"169.254.0.0", "16"}, // Link-local (RFC 3927)
- {"172.16.0.0", "12"}, // Private (RFC 1918)
- {"192.168.0.0", "16"}, // Private (RFC 1918)
{"224.0.0.0", "3"}, // Multicast (224/4) + reserved (240/4)
// T-Mobile Wi-Fi calling
@@ -69,61 +85,179 @@ public class VpnRoutes {
{"174.192.0.0", "9"},
};
+ // RFC 1918 private ranges. Excluded by default so LAN traffic bypasses the
+ // tunnel, but included (routed into the tunnel) for the portion covered by
+ // the active WireGuard profile's AllowedIPs.
+ private static final String[][] RFC1918_RANGES = {
+ {"10.0.0.0", "8"}, // Private (RFC 1918)
+ {"172.16.0.0", "12"}, // Private (RFC 1918)
+ {"192.168.0.0", "16"}, // Private (RFC 1918)
+ };
+
/**
- * Returns the list of CIDR routes to add to the VPN builder.
+ * Returns the default route list (WireGuard remote egress off): all public
+ * IPv4 space excluding private, reserved, and carrier Wi-Fi calling ranges.
* Computed once and cached for all subsequent calls.
*/
public static List getRoutes() {
if (cachedRoutes == null) {
synchronized (VpnRoutes.class) {
if (cachedRoutes == null) {
- cachedRoutes = computeRoutes();
+ cachedRoutes = computeRoutes(Collections.emptyList());
}
}
}
return cachedRoutes;
}
- private static List computeRoutes() {
- // Build sorted exclusion list
- List excludes = new ArrayList<>();
- for (String[] range : EXCLUDED_RANGES) {
- excludes.add(new IPUtil.CIDR(range[0], Integer.parseInt(range[1])));
+ /**
+ * Returns the route list for an active WireGuard remote-egress tunnel,
+ * making the profile's AllowedIPs authoritative over the RFC 1918
+ * exclusions. Any part of an RFC 1918 range covered by {@code wgAllowedIps}
+ * is routed into the tunnel; everything else behaves exactly as
+ * {@link #getRoutes()}.
+ *
+ * @param wgAllowedIps the union of every peer's {@code AllowedIPs} entries
+ * (CIDR strings; IPv6 entries are ignored for now).
+ */
+ public static List getRoutes(List wgAllowedIps) {
+ List allowedV4 = parseV4AllowedIps(wgAllowedIps);
+ if (allowedV4.isEmpty())
+ return getRoutes(); // No IPv4 AllowedIPs — identical to WG-off default
+
+ String key = allowedKey(allowedV4);
+ List cached = cachedAllowedRoutes;
+ if (cached != null && key.equals(cachedAllowedKey))
+ return cached;
+
+ synchronized (VpnRoutes.class) {
+ if (cachedAllowedRoutes != null && key.equals(cachedAllowedKey))
+ return cachedAllowedRoutes;
+ List routes = computeRoutes(allowedV4);
+ cachedAllowedRoutes = routes;
+ cachedAllowedKey = key;
+ return routes;
+ }
+ }
+
+ private static List computeRoutes(List allowedV4) {
+ // Build the excluded intervals: always-excluded ranges verbatim, plus
+ // each RFC 1918 range with any AllowedIPs-covered portion carved out.
+ List excluded = new ArrayList<>();
+ for (String[] range : ALWAYS_EXCLUDED)
+ excluded.add(toInterval(range[0], Integer.parseInt(range[1])));
+
+ List allowed = new ArrayList<>();
+ for (IPUtil.CIDR cidr : allowedV4)
+ allowed.add(new long[]{inetToLong(cidr.getStart()), inetToLong(cidr.getEnd())});
+
+ for (String[] range : RFC1918_RANGES) {
+ long[] interval = toInterval(range[0], Integer.parseInt(range[1]));
+ excluded.addAll(subtract(interval[0], interval[1], allowed));
}
- Collections.sort(excludes);
- // Compute complement: all IP ranges NOT in the exclusion list
+ excluded.sort((a, b) -> Long.compare(a[0], b[0]));
+
+ // Compute the complement: all IPv4 space NOT in the exclusion list.
List routes = new ArrayList<>();
try {
long startLong = 0; // 0.0.0.0
- for (IPUtil.CIDR exclude : excludes) {
- long excludeStartLong = inetToLong(exclude.getStart());
- if (excludeStartLong > startLong) {
- InetAddress gapStart = longToInet(startLong);
- InetAddress gapEnd = IPUtil.minus1(exclude.getStart());
- routes.addAll(IPUtil.toCIDR(gapStart, gapEnd));
- }
- long excludeEndLong = inetToLong(exclude.getEnd());
- if (excludeEndLong < 0xFFFFFFFFL) {
- startLong = excludeEndLong + 1;
- } else {
+ for (long[] exclude : excluded) {
+ if (exclude[0] > startLong)
+ routes.addAll(IPUtil.toCIDR(longToInet(startLong), longToInet(exclude[0] - 1)));
+ if (exclude[1] >= 0xFFFFFFFFL) {
startLong = 0xFFFFFFFFL + 1; // Past end of IPv4 space
break;
}
+ if (exclude[1] + 1 > startLong)
+ startLong = exclude[1] + 1;
}
- // Any remaining space after last exclusion (none if 224.0.0.0/3 is last)
- if (startLong <= 0xFFFFFFFFL) {
+ // Any remaining space after the last exclusion.
+ if (startLong <= 0xFFFFFFFFL)
routes.addAll(IPUtil.toCIDR(longToInet(startLong), longToInet(0xFFFFFFFFL)));
- }
} catch (UnknownHostException ex) {
Log.e(TAG, "Failed to compute routes: " + ex);
}
Log.i(TAG, "Computed " + routes.size() + " VPN routes from "
- + EXCLUDED_RANGES.length + " exclusions");
+ + excluded.size() + " exclusions (" + allowedV4.size() + " WG AllowedIPs)");
return Collections.unmodifiableList(routes);
}
+ /**
+ * Returns the sub-intervals of [start, end] not covered by any interval in
+ * {@code allowed}. {@code allowed} intervals may overlap and be unsorted.
+ */
+ private static List subtract(long start, long end, List allowed) {
+ // Clip the allowed intervals to [start, end] and sort by start.
+ List overlaps = new ArrayList<>();
+ for (long[] a : allowed) {
+ long s = Math.max(a[0], start);
+ long e = Math.min(a[1], end);
+ if (s <= e)
+ overlaps.add(new long[]{s, e});
+ }
+ overlaps.sort((a, b) -> Long.compare(a[0], b[0]));
+
+ List result = new ArrayList<>();
+ long cursor = start;
+ for (long[] a : overlaps) {
+ if (a[0] > cursor)
+ result.add(new long[]{cursor, a[0] - 1});
+ if (a[1] + 1 > cursor)
+ cursor = a[1] + 1;
+ if (cursor > end)
+ break;
+ }
+ if (cursor <= end)
+ result.add(new long[]{cursor, end});
+ return result;
+ }
+
+ /** Parse WireGuard AllowedIPs CIDR strings, keeping only valid IPv4 entries. */
+ private static List parseV4AllowedIps(List wgAllowedIps) {
+ List allowedV4 = new ArrayList<>();
+ if (wgAllowedIps == null)
+ return allowedV4;
+ for (String raw : wgAllowedIps) {
+ if (raw == null)
+ continue;
+ String entry = raw.trim();
+ if (entry.isEmpty())
+ continue;
+ try {
+ String[] parts = entry.split("/");
+ String ip = parts[0].trim();
+ if (ip.contains(":"))
+ continue; // IPv6 — see class docs; tracked as follow-up
+ int prefix = parts.length > 1 ? Integer.parseInt(parts[1].trim()) : 32;
+ if (prefix < 0 || prefix > 32)
+ continue;
+ InetAddress addr = InetAddress.getByName(ip);
+ if (!(addr instanceof Inet4Address))
+ continue;
+ allowedV4.add(new IPUtil.CIDR(ip, prefix));
+ } catch (Throwable ex) {
+ Log.w(TAG, "Ignoring malformed AllowedIPs entry '" + entry + "': " + ex);
+ }
+ }
+ return allowedV4;
+ }
+
+ /** Stable signature of an AllowedIPs set for single-entry cache keying. */
+ private static String allowedKey(List allowedV4) {
+ List keys = new ArrayList<>(allowedV4.size());
+ for (IPUtil.CIDR cidr : allowedV4)
+ keys.add(inetToLong(cidr.getStart()) + "/" + cidr.prefix);
+ Collections.sort(keys);
+ return String.join(",", keys);
+ }
+
+ private static long[] toInterval(String ip, int prefix) {
+ IPUtil.CIDR cidr = new IPUtil.CIDR(ip, prefix);
+ return new long[]{inetToLong(cidr.getStart()), inetToLong(cidr.getEnd())};
+ }
+
private static long inetToLong(InetAddress addr) {
long result = 0;
for (byte b : addr.getAddress())
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 4ec7ed8d7..1d2663106 100644
--- a/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java
+++ b/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java
@@ -46,6 +46,8 @@
import androidx.work.Data;
import androidx.work.WorkInfo;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
import net.kollnig.missioncontrol.Common;
import net.kollnig.missioncontrol.R;
import net.kollnig.missioncontrol.analysis.TrackerAnalysisManager;
@@ -63,6 +65,7 @@
import eu.faircode.netguard.Rule;
import eu.faircode.netguard.ServiceSinkhole;
+import eu.faircode.netguard.TempUnblockReceiver;
import eu.faircode.netguard.Util;
/**
@@ -323,6 +326,12 @@ private void updateText(TextView tv, Tracker t) {
boolean uncertainAllowed = isAmbiguousDeadToggle(t);
+ // Temporary allowance (#216): the tracker is unblocked for a
+ // fixed window, then automatically re-blocked.
+ boolean tempAllowed = trackerProtectionEnabled
+ && !BlockingMode.isMinimalMode(getContext())
+ && TempUnblockReceiver.isTemporarilyAllowed(getContext(), mAppUid, t);
+
Spannable spannable;
boolean showStatus;
boolean companyBlocked;
@@ -354,7 +363,9 @@ private void updateText(TextView tv, Tracker t) {
} else {
String status = getContext().getString(uncertainAllowed
? R.string.allowed_shared_ip
- : (companyBlocked ? R.string.blocked : R.string.allowed));
+ : (tempAllowed
+ ? R.string.temporarily_allowed
+ : (companyBlocked ? R.string.blocked : R.string.allowed)));
int color = ContextCompat.getColor(getContext(),
companyBlocked ? R.color.colorPrimary : R.color.colorAccent);
@@ -436,13 +447,56 @@ private void updateText(TextView tv, Tracker t) {
boolean blockedTracker = b.blockedTracker(mAppUid, t);
if (blockedTracker)
b.unblock(mAppUid, t);
- else
+ else {
+ // Re-blocking a company also ends any active temporary
+ // allowance (#216) and cancels its pending alarm.
+ TempUnblockReceiver.cancel(mContext, mAppUid, t);
b.block(mAppUid, t);
+ }
trackersAdapter.notifyDataSetChanged();
});
else
holder.mCompaniesList.setOnItemClickListener(null);
+
+ if (enabled)
+ holder.mCompaniesList.setOnItemLongClickListener((adapterView, v, i, l) -> {
+ if (w.blockedInternet(mAppUid))
+ return false;
+
+ Tracker t = trackersAdapter.getItem(i);
+ if (t == null)
+ return false;
+
+ // Ambiguous shared-IP trackers are already allowed at runtime.
+ if (!BlockingMode.isStrictMode(mContext) && t.isAllowedInStandardMode())
+ return false;
+
+ // Only offer a temporary allowance for a company that is
+ // currently blocked (category blocked AND company blocked)
+ // and not already temporarily allowed.
+ if (!b.blockedTracker(mAppUid, t))
+ return false;
+
+ String name = t.getName();
+ if (name.equals(TRACKER_HOSTLIST))
+ name = mContext.getString(R.string.tracker_hostlist);
+
+ final Tracker tracker = t;
+ new MaterialAlertDialogBuilder(mContext)
+ .setTitle(name)
+ .setMessage(mContext.getString(R.string.temp_allow_message, name))
+ .setPositiveButton(R.string.temp_allow_action, (dialog, which) -> {
+ TempUnblockReceiver.allowTemporarily(mContext, mAppUid, tracker);
+ Toast.makeText(mContext, R.string.temp_allow_toast, Toast.LENGTH_SHORT).show();
+ trackersAdapter.notifyDataSetChanged();
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ return true;
+ });
+ else
+ holder.mCompaniesList.setOnItemLongClickListener(null);
}
// cast holder to VHItem and set data
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e9a6ef078..5ca21baa1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -672,6 +672,10 @@ Sincerely,\n\n]]>
BLOCKED
ALLOWED
Allowed — shared IP; enable Strict mode to block
+ ALLOWED FOR 10 MIN
+ Allow for 10 minutes
+ Temporarily allow %1$s for this app? It will be blocked again automatically in 10 minutes.
+ Allowed for 10 minutes
Domain-based Blocking
Allows more granular blocking. Still in development.
diff --git a/app/src/test/java/eu/faircode/netguard/VpnRoutesTest.java b/app/src/test/java/eu/faircode/netguard/VpnRoutesTest.java
new file mode 100644
index 000000000..621693f34
--- /dev/null
+++ b/app/src/test/java/eu/faircode/netguard/VpnRoutesTest.java
@@ -0,0 +1,147 @@
+/*
+ * This file is part of TrackerControl.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with TrackerControl. If not, see .
+ */
+
+package eu.faircode.netguard;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Verifies the RFC 1918 / WireGuard AllowedIPs route-computation logic in
+ * {@link VpnRoutes} (issue #593).
+ */
+@RunWith(RobolectricTestRunner.class)
+public class VpnRoutesTest {
+
+ // --- helpers -------------------------------------------------------------
+
+ private static long toLong(java.net.InetAddress addr) {
+ long r = 0;
+ for (byte b : addr.getAddress())
+ r = r << 8 | (b & 0xFF);
+ return r;
+ }
+
+ /** True if the given IPv4 address is inside any route in the list. */
+ private static boolean isRouted(List routes, String ip) throws Exception {
+ long target = toLong(java.net.InetAddress.getByName(ip));
+ for (IPUtil.CIDR route : routes) {
+ long start = toLong(route.getStart());
+ long end = toLong(route.getEnd());
+ if (target >= start && target <= end)
+ return true;
+ }
+ return false;
+ }
+
+ // --- default (WireGuard off) --------------------------------------------
+
+ @Test
+ public void defaultRoutesExcludeAllRfc1918AndReserved() throws Exception {
+ List routes = VpnRoutes.getRoutes();
+
+ // Public space is routed.
+ assertTrue(isRouted(routes, "8.8.8.8"));
+ assertTrue(isRouted(routes, "1.1.1.1"));
+
+ // RFC 1918 is excluded (bypasses tunnel, reaches LAN directly).
+ assertFalse(isRouted(routes, "10.0.0.1"));
+ assertFalse(isRouted(routes, "172.16.5.5"));
+ assertFalse(isRouted(routes, "192.168.1.10"));
+
+ // Reserved / loopback / link-local / multicast excluded.
+ assertFalse(isRouted(routes, "127.0.0.1"));
+ assertFalse(isRouted(routes, "169.254.1.1"));
+ assertFalse(isRouted(routes, "224.0.0.1"));
+ assertFalse(isRouted(routes, "100.64.0.1"));
+ }
+
+ @Test
+ public void emptyOrIpv6OnlyAllowedIpsFallsBackToDefault() throws Exception {
+ List viaEmpty = VpnRoutes.getRoutes(Collections.emptyList());
+ List viaV6 = VpnRoutes.getRoutes(Collections.singletonList("::/0"));
+
+ assertFalse(isRouted(viaEmpty, "192.168.1.10"));
+ assertFalse(isRouted(viaV6, "192.168.1.10"));
+ }
+
+ // --- WireGuard active: AllowedIPs authoritative --------------------------
+
+ @Test
+ public void allowedIpsZeroSlashZeroRoutesAllRfc1918IntoTunnel() throws Exception {
+ List routes = VpnRoutes.getRoutes(Collections.singletonList("0.0.0.0/0"));
+
+ // All RFC 1918 now enters the tunnel.
+ assertTrue(isRouted(routes, "10.0.0.1"));
+ assertTrue(isRouted(routes, "172.16.5.5"));
+ assertTrue(isRouted(routes, "192.168.1.10"));
+ assertTrue(isRouted(routes, "8.8.8.8"));
+
+ // Loopback / link-local / multicast / CGNAT stay excluded even with /0.
+ assertFalse(isRouted(routes, "127.0.0.1"));
+ assertFalse(isRouted(routes, "169.254.1.1"));
+ assertFalse(isRouted(routes, "224.0.0.1"));
+ assertFalse(isRouted(routes, "100.64.0.1"));
+ assertFalse(isRouted(routes, "0.0.0.5"));
+ }
+
+ @Test
+ public void explicitLanSubnetRoutesOnlyThatSubnet() throws Exception {
+ List routes = VpnRoutes.getRoutes(Collections.singletonList("192.168.1.0/24"));
+
+ // The covered subnet enters the tunnel.
+ assertTrue(isRouted(routes, "192.168.1.10"));
+ assertTrue(isRouted(routes, "192.168.1.254"));
+
+ // The rest of 192.168/16 and other RFC 1918 ranges stay excluded.
+ assertFalse(isRouted(routes, "192.168.2.10"));
+ assertFalse(isRouted(routes, "10.0.0.1"));
+ assertFalse(isRouted(routes, "172.16.5.5"));
+ }
+
+ @Test
+ public void multipleAllowedIpsAreUnioned() throws Exception {
+ List routes = VpnRoutes.getRoutes(
+ Arrays.asList("10.10.0.0/16", "192.168.50.0/24"));
+
+ assertTrue(isRouted(routes, "10.10.0.1"));
+ assertTrue(isRouted(routes, "192.168.50.5"));
+
+ assertFalse(isRouted(routes, "10.20.0.1")); // outside 10.10/16
+ assertFalse(isRouted(routes, "192.168.51.5")); // outside 192.168.50/24
+ assertFalse(isRouted(routes, "172.16.5.5"));
+ }
+
+ @Test
+ public void allowedIpsNeverReExposeReservedRanges() throws Exception {
+ // A profile that lists loopback/link-local must not route them.
+ List routes = VpnRoutes.getRoutes(
+ Arrays.asList("127.0.0.0/8", "169.254.0.0/16", "192.168.0.0/16"));
+
+ assertFalse(isRouted(routes, "127.0.0.1"));
+ assertFalse(isRouted(routes, "169.254.1.1"));
+ assertTrue(isRouted(routes, "192.168.1.10"));
+ }
+}
diff --git a/fastlane/metadata/android/en-US/changelogs/2026070801.txt b/fastlane/metadata/android/en-US/changelogs/2026070801.txt
new file mode 100644
index 000000000..25db42974
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/2026070801.txt
@@ -0,0 +1,4 @@
+- New WireGuard engine: Migrated to Mullvad's gotatun for better performance and reliability.
+- Improved tracker detection: DNS-over-TCP, DNS negative caching, hostname case handling, and a locking issue.
+- Improved WireGuard reliability: More accurate connection status, fewer unnecessary reconnects on network roaming, and faster recovery after network changes.
+- Battery improvements: Batched database writes, debounced network reloads, and smarter WireGuard restart backoff.
\ No newline at end of file