From 6ec8be79a348af1229f4ae6ee6e656ac01a16b99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 09:15:02 +0000 Subject: [PATCH 01/12] Add VPN battery optimizations from DuckDuckGo ATP - Enable setBlocking(true) on VPN builder for CPU-efficient blocking I/O instead of polling the TUN file descriptor - Re-enable setUnderlyingNetworks() after establish() so Android can correctly assess VPN connectivity and metering (was disabled with && false since NetGuard v2.330, Sep 2024) - Acknowledge DuckDuckGo ATP (Apache 2.0) in README credits Both optimizations mirror DuckDuckGo's App Tracking Protection, which uses the same NetGuard native layer (libnetguard) and has validated these work correctly with epoll-based I/O. https://claude.ai/code/session_01888KqFB93HxMCJGjscYT3X --- README.md | 2 ++ .../java/eu/faircode/netguard/ServiceSinkhole.java | 14 ++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4e00db55..8be2452d 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,8 @@ that is distributed with the Firefox browser. *DuckDuckGo App Exclusions*: TrackerControl's Minimal blocking mode uses a list of excluded apps (browsers, system services, known incompatible apps) derived from DuckDuckGo's [privacy-configuration](https://github.com/duckduckgo/privacy-configuration) (Apache 2.0). +*DuckDuckGo App Tracking Protection*: TrackerControl's VPN battery optimizations (`setBlocking`, `setUnderlyingNetworks`) are informed by DuckDuckGo's [App Tracking Protection](https://github.com/duckduckgo/Android) implementation (Apache 2.0). + ## License Except where indicated otherwise, this project is licensed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html). diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 1bbd77a9..686d3df1 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -1412,13 +1412,15 @@ private ParcelFileDescriptor startVPN(Builder builder) throws SecurityException try { ParcelFileDescriptor pfd = builder.establish(); - // Set underlying network - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && false) { + // Set underlying network so Android can correctly assess connectivity, + // metering, and network scoring for the VPN. + // Mirrors DuckDuckGo ATP approach (Apache 2.0). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { ConnectivityManager cm = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); Network active = (cm == null ? null : cm.getActiveNetwork()); if (active != null) { - Log.i(TAG, "Setting underlying network=" + active + " " + cm.getNetworkInfo(active)); - setUnderlyingNetworks(new Network[] { active }); + Log.i(TAG, "Setting underlying network=" + active); + setUnderlyingNetworks(new Network[]{active}); } } @@ -1447,6 +1449,10 @@ private Builder getBuilder(List listAllowed, List listRule) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(Util.isMeteredNetwork(this)); + // Use blocking I/O on the TUN file descriptor for CPU efficiency + // (avoids polling when there is no traffic) + builder.setBlocking(true); + // VPN address String vpn4 = prefs.getString("vpn4", "10.1.10.1"); Log.i(TAG, "Using VPN4=" + vpn4); From 99348cd9b3f523d9970acc88de6b5f39b4ca6745 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 09:23:16 +0000 Subject: [PATCH 02/12] Mirror DuckDuckGo ATP VPN routing approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace dynamic subnet routing and 0.0.0.0/0 catch-all with static pre-computed routes covering all public IPv4 space. This eliminates the need for subnet/tethering/LAN user toggles — LAN access, tethering, and carrier Wi-Fi calling all work out of the box. Changes mirroring DuckDuckGo App Tracking Protection (Apache 2.0): - Add VpnRoutes.java with static route list excluding RFC1918, CGNAT, link-local, multicast, reserved, and carrier Wi-Fi calling ranges - Always use static routes (no dynamic CIDR complement computation) - setMetered(false) to prevent background sync restrictions - MTU 1280 (conservative, reduces fragmentation overhead) - Wrap each addRoute() in try/catch for OEM-specific failures - Always remove local DNS servers (LAN always excluded) - Remove unused imports (Configuration, NetworkInterface, etc.) The subnet, tethering, and lan preferences are now effectively no-ops. https://claude.ai/code/session_01888KqFB93HxMCJGjscYT3X --- .../eu/faircode/netguard/ServiceSinkhole.java | 177 ++---------------- .../java/eu/faircode/netguard/VpnRoutes.java | 146 +++++++++++++++ 2 files changed, 160 insertions(+), 163 deletions(-) create mode 100644 app/src/main/java/eu/faircode/netguard/VpnRoutes.java diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 686d3df1..618a825f 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -39,7 +39,6 @@ import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; -import android.content.res.Configuration; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Canvas; @@ -106,16 +105,12 @@ import java.net.Inet6Address; import java.net.InetAddress; import java.net.InetSocketAddress; -import java.net.InterfaceAddress; -import java.net.NetworkInterface; import java.net.Socket; import java.net.URL; import java.net.UnknownHostException; import java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.Date; -import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -1355,13 +1350,9 @@ public static List getDns(Context context) { Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); } - // Remove local DNS servers when not routing LAN + // Remove local DNS servers since LAN is always excluded from VPN routing int count = listDns.size(); - boolean lan = prefs.getBoolean("lan", false); - // boolean use_hosts = prefs.getBoolean("use_hosts", false); - if (lan - // && use_hosts - && filter) + if (filter) try { List> subnets = new ArrayList<>(); subnets.add(new Pair<>(InetAddress.getByName("10.0.0.0"), 8)); @@ -1435,9 +1426,6 @@ private ParcelFileDescriptor startVPN(Builder builder) throws SecurityException private Builder getBuilder(List listAllowed, List listRule) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - boolean subnet = prefs.getBoolean("subnet", false); - boolean tethering = prefs.getBoolean("tethering", false); - boolean lan = prefs.getBoolean("lan", false); boolean ip6 = prefs.getBoolean("ip6", true); boolean filter = prefs.getBoolean("filter", true); boolean includeSystem = prefs.getBoolean("include_system_vpn", false); @@ -1446,8 +1434,10 @@ private Builder getBuilder(List listAllowed, List listRule) { Builder builder = new Builder(); builder.setSession(getString(R.string.app_name)); + // Always mark VPN as unmetered to prevent Android from restricting + // background sync. Mirrors DuckDuckGo ATP approach (Apache 2.0). if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) - builder.setMetered(Util.isMeteredNetwork(this)); + builder.setMetered(false); // Use blocking I/O on the TUN file descriptor for CPU efficiency // (avoids polling when there is no traffic) @@ -1486,161 +1476,22 @@ private Builder getBuilder(List listAllowed, List listRule) { Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); } - // Subnet routing - if (subnet) { - // Exclude IP ranges - List listExclude = new ArrayList<>(); - listExclude.add(new IPUtil.CIDR("127.0.0.0", 8)); // localhost - - if (tethering && !lan) { - // USB tethering 192.168.42.x - // Wi-Fi tethering 192.168.43.x - listExclude.add(new IPUtil.CIDR("192.168.42.0", 23)); - // Bluetooth tethering 192.168.44.x - listExclude.add(new IPUtil.CIDR("192.168.44.0", 24)); - // Wi-Fi direct 192.168.49.x - listExclude.add(new IPUtil.CIDR("192.168.49.0", 24)); - - try { - Enumeration nis = NetworkInterface.getNetworkInterfaces(); - if (nis != null) - while (nis.hasMoreElements()) { - NetworkInterface ni = nis.nextElement(); - if (ni != null && !ni.isLoopback() && ni.isUp() && - ni.getName() != null && ni.getName().startsWith("ap_br_wlan")) { - List ias = ni.getInterfaceAddresses(); - if (ias != null) - for (InterfaceAddress ia : ias) - if (ia.getAddress() instanceof Inet4Address) - listExclude.add(new IPUtil.CIDR(ia.getAddress().getHostAddress(), 24)); - } - } - } catch (Throwable ex) { - Log.e(TAG, ex.toString()); - } - } - - if (lan) { - // https://tools.ietf.org/html/rfc1918 - listExclude.add(new IPUtil.CIDR("10.0.0.0", 8)); - listExclude.add(new IPUtil.CIDR("172.16.0.0", 12)); - listExclude.add(new IPUtil.CIDR("192.168.0.0", 16)); - } - - if (!filter) { - for (InetAddress dns : getDns(ServiceSinkhole.this)) - if (dns instanceof Inet4Address) - listExclude.add(new IPUtil.CIDR(dns.getHostAddress(), 32)); - - String dns_specifier = Util.getPrivateDnsSpecifier(ServiceSinkhole.this); - if (!TextUtils.isEmpty(dns_specifier)) - try { - Log.i(TAG, "Resolving private dns=" + dns_specifier); - for (InetAddress pdns : InetAddress.getAllByName(dns_specifier)) - if (pdns instanceof Inet4Address) - listExclude.add(new IPUtil.CIDR(pdns.getHostAddress(), 32)); - } catch (Throwable ex) { - Log.e(TAG, ex.toString()); - } - } - - // https://en.wikipedia.org/wiki/Mobile_country_code - Configuration config = getResources().getConfiguration(); - - // T-Mobile Wi-Fi calling - if (config.mcc == 310 && (config.mnc == 160 || - config.mnc == 200 || - config.mnc == 210 || - config.mnc == 220 || - config.mnc == 230 || - config.mnc == 240 || - config.mnc == 250 || - config.mnc == 260 || - config.mnc == 270 || - config.mnc == 310 || - config.mnc == 490 || - config.mnc == 660 || - config.mnc == 800)) { - listExclude.add(new IPUtil.CIDR("66.94.2.0", 24)); - listExclude.add(new IPUtil.CIDR("66.94.6.0", 23)); - listExclude.add(new IPUtil.CIDR("66.94.8.0", 22)); - listExclude.add(new IPUtil.CIDR("208.54.0.0", 16)); - } - - // Verizon wireless calling - if ((config.mcc == 310 && - (config.mnc == 4 || - config.mnc == 5 || - config.mnc == 6 || - config.mnc == 10 || - config.mnc == 12 || - config.mnc == 13 || - config.mnc == 350 || - config.mnc == 590 || - config.mnc == 820 || - config.mnc == 890 || - config.mnc == 910)) - || - (config.mcc == 311 && (config.mnc == 12 || - config.mnc == 110 || - (config.mnc >= 270 && config.mnc <= 289) || - config.mnc == 390 || - (config.mnc >= 480 && config.mnc <= 489) || - config.mnc == 590)) - || - (config.mcc == 312 && (config.mnc == 770))) { - listExclude.add(new IPUtil.CIDR("66.174.0.0", 16)); // 66.174.0.0 - 66.174.255.255 - listExclude.add(new IPUtil.CIDR("66.82.0.0", 15)); // 69.82.0.0 - 69.83.255.255 - listExclude.add(new IPUtil.CIDR("69.96.0.0", 13)); // 69.96.0.0 - 69.103.255.255 - listExclude.add(new IPUtil.CIDR("70.192.0.0", 11)); // 70.192.0.0 - 70.223.255.255 - listExclude.add(new IPUtil.CIDR("97.128.0.0", 9)); // 97.128.0.0 - 97.255.255.255 - listExclude.add(new IPUtil.CIDR("174.192.0.0", 9)); // 174.192.0.0 - 174.255.255.255 - listExclude.add(new IPUtil.CIDR("72.96.0.0", 9)); // 72.96.0.0 - 72.127.255.255 - listExclude.add(new IPUtil.CIDR("75.192.0.0", 9)); // 75.192.0.0 - 75.255.255.255 - listExclude.add(new IPUtil.CIDR("97.0.0.0", 10)); // 97.0.0.0 - 97.63.255.255 - } - - // SFR MMS - if (config.mnc == 10 && config.mcc == 208) - listExclude.add(new IPUtil.CIDR("10.151.0.0", 24)); - - // Broadcast - listExclude.add(new IPUtil.CIDR("224.0.0.0", 3)); - - Collections.sort(listExclude); - + // Static routes covering all public IPv4 space, excluding private, + // reserved, and carrier Wi-Fi calling ranges. + // Mirrors DuckDuckGo ATP approach (Apache 2.0). + for (IPUtil.CIDR route : VpnRoutes.getRoutes()) try { - InetAddress start = InetAddress.getByName("0.0.0.0"); - for (IPUtil.CIDR exclude : listExclude) { - Log.i(TAG, "Exclude " + exclude.getStart().getHostAddress() + "..." - + exclude.getEnd().getHostAddress()); - for (IPUtil.CIDR include : IPUtil.toCIDR(start, IPUtil.minus1(exclude.getStart()))) - try { - builder.addRoute(include.address, include.prefix); - } catch (Throwable ex) { - Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); - } - start = IPUtil.plus1(exclude.getEnd()); - } - String end = (lan ? "255.255.255.254" : "255.255.255.255"); - for (IPUtil.CIDR include : IPUtil.toCIDR("224.0.0.0", end)) - try { - builder.addRoute(include.address, include.prefix); - } catch (Throwable ex) { - Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); - } - } catch (UnknownHostException ex) { - Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); + builder.addRoute(route.address, route.prefix); + } catch (Throwable ex) { + Log.e(TAG, "addRoute " + route + ": " + ex); } - } else - builder.addRoute("0.0.0.0", 0); Log.i(TAG, "IPv6=" + ip6); if (ip6) builder.addRoute("2000::", 3); // unicast - // MTU - int mtu = jni_get_mtu(); + // Conservative MTU to reduce fragmentation overhead + int mtu = 1280; Log.i(TAG, "MTU=" + mtu); builder.setMtu(mtu); diff --git a/app/src/main/java/eu/faircode/netguard/VpnRoutes.java b/app/src/main/java/eu/faircode/netguard/VpnRoutes.java new file mode 100644 index 00000000..29430d62 --- /dev/null +++ b/app/src/main/java/eu/faircode/netguard/VpnRoutes.java @@ -0,0 +1,146 @@ +/* + * 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 . + * + * VPN routing approach informed by DuckDuckGo App Tracking Protection (Apache 2.0). + * See: https://github.com/duckduckgo/Android + */ + +package eu.faircode.netguard; + +import android.util.Log; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Static pre-computed 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. + */ +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 + {"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 + {"66.94.2.0", "24"}, + {"66.94.6.0", "23"}, + {"66.94.8.0", "22"}, + {"208.54.0.0", "16"}, + + // Verizon Wi-Fi calling + {"66.82.0.0", "15"}, + {"66.174.0.0", "16"}, + {"69.96.0.0", "13"}, + {"70.192.0.0", "11"}, + {"72.96.0.0", "9"}, + {"75.192.0.0", "9"}, + {"97.0.0.0", "10"}, + {"97.128.0.0", "9"}, + {"174.192.0.0", "9"}, + }; + + /** + * Returns the list of CIDR routes to add to the VPN builder. + * Computed once and cached for all subsequent calls. + */ + public static List getRoutes() { + if (cachedRoutes == null) { + synchronized (VpnRoutes.class) { + if (cachedRoutes == null) { + cachedRoutes = computeRoutes(); + } + } + } + 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]))); + } + Collections.sort(excludes); + + // Compute complement: all IP ranges 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 { + startLong = 0xFFFFFFFFL + 1; // Past end of IPv4 space + break; + } + } + // Any remaining space after last exclusion (none if 224.0.0.0/3 is last) + 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"); + return Collections.unmodifiableList(routes); + } + + private static long inetToLong(InetAddress addr) { + long result = 0; + for (byte b : addr.getAddress()) + result = result << 8 | (b & 0xFF); + return result; + } + + private static InetAddress longToInet(long addr) { + try { + byte[] b = new byte[4]; + for (int i = 3; i >= 0; i--) { + b[i] = (byte) (addr & 0xFF); + addr >>= 8; + } + return InetAddress.getByAddress(b); + } catch (UnknownHostException ignore) { + return null; + } + } +} From fe4be7cd872a0c83171221aff5d346639d409442 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 11:04:38 +0000 Subject: [PATCH 03/12] Remove subnet, tethering, and LAN toggles from UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These settings are no longer needed — static VPN routes now always exclude local networks and carrier Wi-Fi calling ranges, making LAN access, tethering, and Wi-Fi calling work out of the box. https://claude.ai/code/session_01888KqFB93HxMCJGjscYT3X --- .../eu/faircode/netguard/ActivitySettings.java | 9 --------- app/src/main/res/xml/preferences.xml | 17 ----------------- 2 files changed, 26 deletions(-) diff --git a/app/src/main/java/eu/faircode/netguard/ActivitySettings.java b/app/src/main/java/eu/faircode/netguard/ActivitySettings.java index 439deae6..006ae64b 100644 --- a/app/src/main/java/eu/faircode/netguard/ActivitySettings.java +++ b/app/src/main/java/eu/faircode/netguard/ActivitySettings.java @@ -541,15 +541,6 @@ else if ("pause".equals(name)) getPreferenceScreen().findPreference(name) .setTitle(getString(R.string.setting_pause, prefs.getString(name, "10"))); - else if ("subnet".equals(name)) - ServiceSinkhole.reload("changed " + name, this, false); - - else if ("tethering".equals(name)) - ServiceSinkhole.reload("changed " + name, this, false); - - else if ("lan".equals(name)) - ServiceSinkhole.reload("changed " + name, this, false); - else if ("ip6".equals(name)) ServiceSinkhole.reload("changed " + name, this, false); diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index b5d1dfb6..01a7b0ca 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -51,23 +51,6 @@ android:key="update_check" android:summary="@string/summary_update" android:title="@string/setting_update" /> - - - Date: Sat, 4 Apr 2026 11:08:18 +0000 Subject: [PATCH 04/12] Revert setMetered to match physical network status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setMetered(false) (DDG's approach) causes apps to think they're on an unmetered connection when on cellular, potentially increasing data usage. Matching the actual network status makes the VPN transparent with respect to metering — apps behave identically to without the VPN. https://claude.ai/code/session_01888KqFB93HxMCJGjscYT3X --- app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 618a825f..67ecf2f2 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -1434,10 +1434,10 @@ private Builder getBuilder(List listAllowed, List listRule) { Builder builder = new Builder(); builder.setSession(getString(R.string.app_name)); - // Always mark VPN as unmetered to prevent Android from restricting - // background sync. Mirrors DuckDuckGo ATP approach (Apache 2.0). + // Match the physical network's metered status so apps behave the + // same as without the VPN. (Android defaults VPNs to metered.) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) - builder.setMetered(false); + builder.setMetered(Util.isMeteredNetwork(this)); // Use blocking I/O on the TUN file descriptor for CPU efficiency // (avoids polling when there is no traffic) From 72e14ecdcd98a03666efcd5e11453dc0d27c1fb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 11:12:45 +0000 Subject: [PATCH 05/12] Route local DNS through VPN instead of removing it Instead of stripping local DNS servers (which breaks Pi-hole and similar setups), add /32 host routes for them. A /32 beats the excluded /16, so DNS traffic to e.g. 192.168.1.1 enters the tunnel where TC can filter it, while all other LAN traffic still bypasses. https://claude.ai/code/session_01888KqFB93HxMCJGjscYT3X --- .../eu/faircode/netguard/ServiceSinkhole.java | 48 ++++++------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 67ecf2f2..bdeb5fe3 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -100,7 +100,6 @@ import java.io.FileReader; import java.io.IOException; import java.io.InputStreamReader; -import java.math.BigInteger; import java.net.Inet4Address; import java.net.Inet6Address; import java.net.InetAddress; @@ -1350,38 +1349,8 @@ public static List getDns(Context context) { Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); } - // Remove local DNS servers since LAN is always excluded from VPN routing - int count = listDns.size(); - if (filter) - try { - List> subnets = new ArrayList<>(); - subnets.add(new Pair<>(InetAddress.getByName("10.0.0.0"), 8)); - subnets.add(new Pair<>(InetAddress.getByName("172.16.0.0"), 12)); - subnets.add(new Pair<>(InetAddress.getByName("192.168.0.0"), 16)); - - for (Pair subnet : subnets) { - InetAddress hostAddress = subnet.first; - BigInteger host = new BigInteger(1, hostAddress.getAddress()); - - int prefix = subnet.second; - BigInteger mask = BigInteger.valueOf(-1).shiftLeft(hostAddress.getAddress().length * 8 - prefix); - - for (InetAddress dns : new ArrayList<>(listDns)) - if (hostAddress.getAddress().length == dns.getAddress().length) { - BigInteger ip = new BigInteger(1, dns.getAddress()); - - if (host.and(mask).equals(ip.and(mask))) { - Log.i(TAG, "Local DNS server host=" + hostAddress + "/" + prefix + " dns=" + dns); - listDns.remove(dns); - } - } - } - } catch (Throwable ex) { - Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); - } - - // Always set DNS servers - if (listDns.size() == 0 || listDns.size() < count) + // Fallback DNS servers if none found + if (listDns.isEmpty()) try { listDns.add(InetAddress.getByName(net.kollnig.missioncontrol.BuildConfig.DEFAULT_DNS_IPV4)); listDns.add(InetAddress.getByName(net.kollnig.missioncontrol.BuildConfig.DEFAULT_DNS_IPV4_2)); @@ -1486,6 +1455,19 @@ private Builder getBuilder(List listAllowed, List listRule) { Log.e(TAG, "addRoute " + route + ": " + ex); } + // Add /32 host routes for local DNS servers so their traffic enters the + // tunnel (where TC can filter it) even though LAN is otherwise excluded. + // This preserves compatibility with local DNS setups like Pi-hole. + if (filter) + for (InetAddress dns : getDns(ServiceSinkhole.this)) + if (dns instanceof Inet4Address && dns.isSiteLocalAddress()) + try { + Log.i(TAG, "Adding host route for local DNS=" + dns.getHostAddress()); + builder.addRoute(dns, 32); + } catch (Throwable ex) { + Log.e(TAG, "addRoute DNS " + dns + ": " + ex); + } + Log.i(TAG, "IPv6=" + ip6); if (ip6) builder.addRoute("2000::", 3); // unicast From 60674791ee3e8ec4bc18b6aa5d55cb841312db5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 11:15:18 +0000 Subject: [PATCH 06/12] Restore jni_get_mtu() for MTU detection Using the actual network MTU avoids unnecessary packet fragmentation and overhead. NetGuard has used this reliably for years. https://claude.ai/code/session_01888KqFB93HxMCJGjscYT3X --- app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index bdeb5fe3..b72c8231 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -1472,8 +1472,8 @@ private Builder getBuilder(List listAllowed, List listRule) { if (ip6) builder.addRoute("2000::", 3); // unicast - // Conservative MTU to reduce fragmentation overhead - int mtu = 1280; + // MTU + int mtu = jni_get_mtu(); Log.i(TAG, "MTU=" + mtu); builder.setMtu(mtu); From 8d86a865f53d99882fa9af8ef3b76aa91b1a9011 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 11:27:31 +0000 Subject: [PATCH 07/12] Add dynamic ePDG resolution for global Wi-Fi calling support Resolve the carrier's ePDG domain at VPN setup using the 3GPP standard pattern (epdg.epc.mnc{MNC}.mcc{MCC}.pub.3gppnetwork.org) and exclude those IPs via excludeRoute(). This uses the carrier's own DNS (before the tunnel is established), avoiding geo-fencing issues. Works for any carrier worldwide without hardcoding IP ranges. Requires Android 13+ (API 33) for excludeRoute(); older versions fall back to the static T-Mobile/Verizon exclusions in VpnRoutes. https://claude.ai/code/session_01888KqFB93HxMCJGjscYT3X --- .../eu/faircode/netguard/ServiceSinkhole.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index b72c8231..bb25ca82 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -46,6 +46,7 @@ import android.graphics.Paint; import android.graphics.Path; import android.net.ConnectivityManager; +import android.net.IpPrefix; import android.net.LinkProperties; import android.net.Network; import android.net.NetworkCapabilities; @@ -1468,6 +1469,32 @@ private Builder getBuilder(List listAllowed, List listRule) { Log.e(TAG, "addRoute DNS " + dns + ": " + ex); } + // Dynamically exclude carrier ePDG IPs so Wi-Fi calling works globally. + // ePDG domains follow 3GPP standard: epdg.epc.mnc{MNC}.mcc{MCC}.pub.3gppnetwork.org + // Resolved before establish() using the carrier's own DNS, avoiding geo-fencing issues. + // Requires Android 13+ (API 33) for excludeRoute(). + if (Build.VERSION.SDK_INT >= 33) + try { + TelephonyManager tm = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE); + String simOperator = (tm == null ? null : tm.getSimOperator()); + if (simOperator != null && simOperator.length() >= 5) { + String mcc = simOperator.substring(0, 3); + String mnc = simOperator.substring(3); + // Pad MNC to 3 digits per 3GPP TS 23.003 + if (mnc.length() == 2) + mnc = "0" + mnc; + String epdgDomain = "epdg.epc.mnc" + mnc + ".mcc" + mcc + ".pub.3gppnetwork.org"; + Log.i(TAG, "Resolving ePDG domain=" + epdgDomain); + for (InetAddress addr : InetAddress.getAllByName(epdgDomain)) { + Log.i(TAG, "Excluding ePDG address=" + addr.getHostAddress()); + builder.excludeRoute(new IpPrefix(addr, addr instanceof Inet4Address ? 32 : 128)); + } + } + } catch (Throwable ex) { + // Resolution may fail (no SIM, airplane mode, non-standard carrier) — not fatal + Log.i(TAG, "ePDG resolution skipped: " + ex.getMessage()); + } + Log.i(TAG, "IPv6=" + ip6); if (ip6) builder.addRoute("2000::", 3); // unicast From 7d7859d4eba0cf0fd798fe9155e52f531da05705 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 11:29:04 +0000 Subject: [PATCH 08/12] Fix ePDG comment: resolution uses physical network, not pre-tunnel TC excludes itself from VPN routing (addDisallowedApplication), so ePDG DNS resolution always goes through the physical network. This re-runs on each VPN rebuild (network switch), picking up the correct carrier DNS when switching from WiFi to cellular. https://claude.ai/code/session_01888KqFB93HxMCJGjscYT3X --- app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index bb25ca82..5b1e428c 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -1471,7 +1471,9 @@ private Builder getBuilder(List listAllowed, List listRule) { // Dynamically exclude carrier ePDG IPs so Wi-Fi calling works globally. // ePDG domains follow 3GPP standard: epdg.epc.mnc{MNC}.mcc{MCC}.pub.3gppnetwork.org - // Resolved before establish() using the carrier's own DNS, avoiding geo-fencing issues. + // TC excludes itself from the VPN (addDisallowedApplication), so this DNS resolution + // goes through the physical network — using the carrier's own DNS on cellular, + // which avoids geo-fencing issues. Re-resolved on each VPN rebuild (network switch). // Requires Android 13+ (API 33) for excludeRoute(). if (Build.VERSION.SDK_INT >= 33) try { From 3bb8107be5bdaa52e14667a5049f4e3d7605b9bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 11:31:30 +0000 Subject: [PATCH 09/12] Add 1.5s timeout to ePDG DNS resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DNS resolution for non-existent ePDG domains (no SIM, non-standard carrier) could block VPN startup for 5-30 seconds. Wrap in a Future with 1.5s timeout — successful lookups typically complete in <500ms, so this catches most carriers while failing fast otherwise. https://claude.ai/code/session_01888KqFB93HxMCJGjscYT3X --- .../java/eu/faircode/netguard/ServiceSinkhole.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 5b1e428c..a379c821 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -1474,6 +1474,7 @@ private Builder getBuilder(List listAllowed, List listRule) { // TC excludes itself from the VPN (addDisallowedApplication), so this DNS resolution // goes through the physical network — using the carrier's own DNS on cellular, // which avoids geo-fencing issues. Re-resolved on each VPN rebuild (network switch). + // Uses a short timeout to avoid blocking VPN startup if resolution is slow. // Requires Android 13+ (API 33) for excludeRoute(). if (Build.VERSION.SDK_INT >= 33) try { @@ -1482,16 +1483,24 @@ private Builder getBuilder(List listAllowed, List listRule) { if (simOperator != null && simOperator.length() >= 5) { String mcc = simOperator.substring(0, 3); String mnc = simOperator.substring(3); - // Pad MNC to 3 digits per 3GPP TS 23.003 if (mnc.length() == 2) mnc = "0" + mnc; String epdgDomain = "epdg.epc.mnc" + mnc + ".mcc" + mcc + ".pub.3gppnetwork.org"; Log.i(TAG, "Resolving ePDG domain=" + epdgDomain); - for (InetAddress addr : InetAddress.getAllByName(epdgDomain)) { + + // Resolve with 1.5s timeout to avoid blocking VPN startup + final String domain = epdgDomain; + java.util.concurrent.Future future = + java.util.concurrent.Executors.newSingleThreadExecutor() + .submit(() -> InetAddress.getAllByName(domain)); + InetAddress[] addrs = future.get(1500, java.util.concurrent.TimeUnit.MILLISECONDS); + for (InetAddress addr : addrs) { Log.i(TAG, "Excluding ePDG address=" + addr.getHostAddress()); builder.excludeRoute(new IpPrefix(addr, addr instanceof Inet4Address ? 32 : 128)); } } + } catch (java.util.concurrent.TimeoutException ex) { + Log.i(TAG, "ePDG resolution timed out, skipping"); } catch (Throwable ex) { // Resolution may fail (no SIM, airplane mode, non-standard carrier) — not fatal Log.i(TAG, "ePDG resolution skipped: " + ex.getMessage()); From 50ac308ebb337fd6af32f585a72885081196eee7 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:01:30 +0200 Subject: [PATCH 10/12] Warn user when another always-on VPN blocks onboarding If another app is set as Always-on VPN, Android silently refuses to show TrackerControl's VPN consent dialog, so tapping "Enable On-Device VPN" in onboarding appears to do nothing. Detect this both proactively (pre-Android S, where the setting is readable) and reactively (via onActivityResult) and show a dialog pointing the user to system VPN settings so they can disable the other always-on VPN. Co-Authored-By: Claude Opus 4.6 --- .../missioncontrol/ActivityOnboarding.java | 60 ++++++++++++++++++- app/src/main/res/values/strings.xml | 3 + 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java b/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java index 21f6a864..f0c0ab72 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java +++ b/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java @@ -3,10 +3,12 @@ import android.Manifest; import android.content.Intent; import android.content.SharedPreferences; +import android.content.DialogInterface; import android.net.VpnService; import android.os.Build; import android.os.Bundle; import android.provider.Settings; +import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -23,6 +25,8 @@ import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager2.widget.ViewPager2; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + import java.util.ArrayList; import java.util.List; @@ -274,9 +278,29 @@ private void refreshSlides() { slide.warningResId = vpnPrepared ? 0 : R.string.onboarding_vpn_sure; slide.actionListener = v -> { if (!vpnPrepared) { - Intent intent = VpnService.prepare(ActivityOnboarding.this); - if (intent != null) { - startActivityForResult(intent, 0); + // Proactively detect another Always-on VPN on Android < S, + // where the setting is still readable. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + try { + String alwaysOn = Settings.Secure.getString( + getContentResolver(), "always_on_vpn_app"); + if (!TextUtils.isEmpty(alwaysOn) + && !getPackageName().equals(alwaysOn)) { + showAlwaysOnVpnBlockedDialog(); + return; + } + } catch (Throwable ex) { + Log.e("Onboarding", ex.toString()); + } + } + try { + Intent intent = VpnService.prepare(ActivityOnboarding.this); + if (intent != null) { + startActivityForResult(intent, 0); + } + } catch (Throwable ex) { + Log.e("Onboarding", ex.toString()); + showAlwaysOnVpnBlockedDialog(); } } }; @@ -425,6 +449,36 @@ private void updateButtons(int position) { } } + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == 0 && resultCode != RESULT_OK) { + // Consent was denied or silently blocked. If prepare() still returns a + // non-null intent, Android refused to show the consent dialog — most + // commonly because another app is set as Always-on VPN. + if (VpnService.prepare(this) != null) { + showAlwaysOnVpnBlockedDialog(); + } + } + } + + private void showAlwaysOnVpnBlockedDialog() { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.onboarding_vpn_blocked_title) + .setMessage(android.text.Html.fromHtml( + getString(R.string.onboarding_vpn_blocked_desc))) + .setPositiveButton(R.string.onboarding_vpn_blocked_action, + (DialogInterface.OnClickListener) (dialog, which) -> { + try { + startActivity(new Intent(Settings.ACTION_VPN_SETTINGS)); + } catch (Throwable ex) { + Log.e("Onboarding", ex.toString()); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + private void finishOnboarding() { boolean vpnPrepared = VpnService.prepare(this) == null; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ae4c3b8..dddf92ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -566,6 +566,9 @@ Sincerely,\n\n]]> TrackerControl uses Android\'s local VPN interface to analyse your internet traffic.\n\nImportant: No external VPN server is involved. We don\'t see your data. Enable On-Device VPN TrackerControl cannot filter any traffic without the local VPN. Are you sure you want to proceed without enabling it? + Another VPN is active + Android prevented TrackerControl from starting its local VPN because another app is set as Always-on VPN. Open VPN settings and disable Always-on for the other VPN, then try again. + Open VPN settings Prevent Interruption Android may kill background apps to save battery. To ensure continuous protection against trackers, please disable optimisations for TrackerControl. From 8cd9c25baab11d6e08804efa6e95ab2655216986 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:01:30 +0200 Subject: [PATCH 11/12] Document LAN/tethering bypass and reframe system-apps VPN note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LAN and tethering already bypass TC's VPN via OS-level routing (kernel connected-route preference for LAN, separate netd forwarding context for tethering), so the excludes in VpnRoutes are defense-in-depth, not load-bearing. Capture this so it doesn't get re-litigated. Also reframe the include_system_vpn note: the real cost is wakeup frequency from background system-app traffic, not tun throughput — the earlier "download slowdown" framing misread the battery symptom. Co-Authored-By: Claude Opus 4.6 --- TODO.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 3163aa49..7b5caecf 100644 --- a/TODO.md +++ b/TODO.md @@ -10,13 +10,26 @@ The VPN file descriptor can be closed by `stopVPN()` while native code in `jni_r **If revisited:** The proposed fix would move the FD close into `stopNative()`, after `jni_clear()`, so the sequence becomes: `jni_stop()` -> `join thread` -> `jni_clear()` -> `close FD`. Risk is high — touches the critical VPN path, and the likely failure modes (double-close, FD leak) are harder to detect than the original race. Only worth pursuing if user reports indicate the race is actually happening. +## LAN and tethering VPN routing (resolved knowledge) + +The `VpnRoutes` exclusions for RFC1918 / CGNAT / link-local are **defense-in-depth, not load-bearing**. On modern Android, LAN access and tethering already bypass the app's VPN tun for OS-level reasons: + +- **LAN access**: When Wi-Fi is on `192.168.x.0/24`, the kernel routing table has a more-specific connected route for that subnet via `wlan0`. It outranks the VPN's `0.0.0.0/0`, so local-subnet packets never hit the tun interface regardless of what the VPN advertises. +- **Tethering (USB/hotspot)**: Tethered traffic is forwarded by `netd`/`iptables` in a separate routing context and does not traverse the owning app's `VpnService` tun at all. + +The old `lan`/`tethering`/`subnet` toggles in NetGuard were effectively no-ops on modern Android and were removed in PR #546. The new static excludes still matter for the edge case of reaching an RFC1918 destination that is *not* on the currently connected subnet (e.g. talking to `10.x` from a `192.168.x` Wi-Fi), where the kernel would otherwise prefer the VPN's default route. + +Don't re-litigate this when someone asks why LAN/tethering work without any toggle — it's intentional. + ## System apps VPN routing -Including system apps in the VPN (`include_system_vpn`) causes noticeable download speed slowdowns (e.g. Play Store). Unclear if this is inherent tun overhead or a fixable implementation issue. +Routing system apps through the VPN (`include_system_vpn`) is a noticeable battery drain. The working hypothesis is **wakeup frequency**, not tun throughput: while TC runs permanently as a VPN, any system-app background activity (Play Store updates, Play Services sync, carrier services, etc.) has to traverse the tun and wakes the packet-processing threads. Excluding system apps lets those flows bypass TC entirely so the CPU can stay idle. + +Earlier framing as a "download speed slowdown" was likely a misread of the battery symptom — raw tun throughput is probably fine. -- Investigate tun performance: profile packet processing path, test buffer size tuning +- Investigate wakeup behaviour rather than throughput: count wakeups / time-in-packet-loop with system apps included vs excluded, not MB/s. - Consider simplifying UX: current flow requires three toggles (`include_system_vpn` -> `manage_system` -> `show_system`). Could consolidate to one toggle that drives both VPN routing and UI visibility. -- Note: always routing system apps through VPN was rejected due to the performance impact, but excluding them breaks Android's "Block connections without VPN" setting. +- Note: always routing system apps through VPN was rejected due to the battery impact, but excluding them breaks Android's "Block connections without VPN" setting. ## SNI inspection for tracker detection From cd9887a2b5100b9bedfb1e1fa3829950c0a9316c Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:44:05 +0200 Subject: [PATCH 12/12] Fix doubled top inset gap on Insights toolbar The navigation-bar overlap fix in #558 added fitsSystemWindows="true" to the Insights CoordinatorLayout. Because Insights uses the theme's window-decor action bar (not an in-layout AppBarLayout), the status bar inset was effectively counted twice and a large empty gap appeared between the toolbar and the hero card. Mirror the approach already used for Settings/Timeline in c50bea9: drop fitsSystemWindows from the layout root and instead apply only the bottom system-bar inset to the ScrollView so the original nav-bar overlap fix still holds. Co-Authored-By: Claude Opus 4.6 --- .../net/kollnig/missioncontrol/InsightsActivity.kt | 14 ++++++++++++++ app/src/main/res/layout/activity_insights.xml | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/net/kollnig/missioncontrol/InsightsActivity.kt b/app/src/main/java/net/kollnig/missioncontrol/InsightsActivity.kt index f4eed554..493add5e 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/InsightsActivity.kt +++ b/app/src/main/java/net/kollnig/missioncontrol/InsightsActivity.kt @@ -35,11 +35,15 @@ import android.view.View import android.view.animation.DecelerateInterpolator import android.widget.LinearLayout import android.widget.ProgressBar +import android.widget.ScrollView import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope import com.google.android.material.floatingactionbutton.FloatingActionButton import eu.faircode.netguard.Util @@ -111,6 +115,16 @@ class InsightsActivity : AppCompatActivity() { shareInsights() } + // Pad the scroll content so the last card isn't hidden behind the navigation bar. + val scroll = findViewById(R.id.insightsScroll) + scroll.clipToPadding = false + val scrollInitialBottom = scroll.paddingBottom + ViewCompat.setOnApplyWindowInsetsListener(scroll) { v, insets -> + val sysBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, scrollInitialBottom + sysBars.bottom) + insets + } + dataProvider = InsightsDataProvider(this) loadData() diff --git a/app/src/main/res/layout/activity_insights.xml b/app/src/main/res/layout/activity_insights.xml index 618d9292..c51613d6 100644 --- a/app/src/main/res/layout/activity_insights.xml +++ b/app/src/main/res/layout/activity_insights.xml @@ -4,10 +4,10 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:fitsSystemWindows="true" android:background="?android:attr/colorBackground">