Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,8 @@ android {
minSdk 23
targetSdk 37
//noinspection HighAppVersionCode
versionCode 2026042701
versionName "2026.04.27"
versionCode 2026070801
versionName "2026.07.08"

// Secure DNS (DoH) configuration
buildConfigField "String", "DEFAULT_DOH_ENDPOINT", "\"https://dns.quad9.net/dns-query\""
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@
android:resource="@xml/widgetmain" />
</receiver>

<receiver
android:name="eu.faircode.netguard.TempUnblockReceiver"
android:exported="false" />

<receiver
android:name="eu.faircode.netguard.WidgetAdmin"
android:exported="true"
Expand Down
40 changes: 38 additions & 2 deletions app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ public class ServiceSinkhole extends VpnService {

// Cached preferences for shouldTrackApp() - refreshed in reload()
private volatile SharedPreferences cachedTrackerProtectPrefs;
private volatile SharedPreferences cachedApplyPrefs;
private volatile boolean cachedManageSystem;

private static final int NOTIFY_ENFORCING = 1;
Expand Down Expand Up @@ -615,6 +616,7 @@ private void reload(boolean interactive) {

// Refresh cached preferences for shouldTrackApp()
cachedTrackerProtectPrefs = getSharedPreferences("tracker_protect", Context.MODE_PRIVATE);
cachedApplyPrefs = getSharedPreferences("apply", Context.MODE_PRIVATE);
cachedManageSystem = prefs.getBoolean("manage_system", false);

if (state != State.enforcing) {
Expand Down Expand Up @@ -1559,10 +1561,13 @@ private Builder getBuilder(List<Rule> listAllowed, List<Rule> listRule) {
net.kollnig.missioncontrol.wg.WgConfig wgParsed = null;
String wgVpn4 = null;
String wgVpn6 = null;
List<String> wgAllowedIps = new ArrayList<>();
List<InetAddress> dnsServers = null;
if (wgEnabled && !TextUtils.isEmpty(wgConfigText)) {
try {
wgParsed = net.kollnig.missioncontrol.wg.WgConfigParser.INSTANCE.parse(wgConfigText);
for (net.kollnig.missioncontrol.wg.WgPeer peer : wgParsed.getPeers())
wgAllowedIps.addAll(peer.getAllowedIPs());
for (String addr : wgParsed.getAddress()) {
String ip = addr.split("/")[0].trim();
if (ip.contains(":")) {
Expand Down Expand Up @@ -1617,7 +1622,16 @@ private Builder getBuilder(List<Rule> listAllowed, List<Rule> listRule) {
// 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())
//
// When WireGuard remote egress is active, its AllowedIPs are
// authoritative over the RFC 1918 exclusions: private ranges the
// profile covers (e.g. 0.0.0.0/0 or an explicit LAN subnet) are routed
// into the tunnel so self-hosted services behind the endpoint stay
// reachable (issue #593). WG off keeps today's exclusions exactly.
List<IPUtil.CIDR> routes = (wgEnabled && !wgAllowedIps.isEmpty())
? VpnRoutes.getRoutes(wgAllowedIps)
: VpnRoutes.getRoutes();
for (IPUtil.CIDR route : routes)
try {
builder.addRoute(route.address, route.prefix);
} catch (Throwable ex) {
Expand Down Expand Up @@ -2340,7 +2354,8 @@ private Allowed isAddressAllowed(Packet packet) {
/**
* Check if tracking should be applied to this app (blocking/logging).
* Returns false if:
* - Tracker Protection (apply) is disabled for this app
* - Monitoring (apply) is disabled for this app
* - Tracker Protection (tracker_protect) is disabled for this app
* - It's a system app and manage_system is disabled
*/
private boolean shouldTrackApp(int uid) {
Expand All @@ -2363,6 +2378,18 @@ private boolean shouldTrackApp(int uid) {
uidToPackage.put(uid, packageName);
}

// Check if monitoring is enabled for this app. When monitoring is off the
// app is excluded from the VPN (addDisallowedApplication), but traffic from
// system daemons sharing this UID can still reach the tunnel, so no tracker
// access must be recorded for it either.
SharedPreferences applyPrefs = cachedApplyPrefs;
if (applyPrefs == null) {
applyPrefs = getSharedPreferences("apply", Context.MODE_PRIVATE);
}
if (!applyPrefs.getBoolean(packageName, true)) {
return false;
}

// Check if tracker protection is enabled for this app
SharedPreferences trackerProtectPrefs = cachedTrackerProtectPrefs;
if (trackerProtectPrefs == null) {
Expand Down Expand Up @@ -2955,6 +2982,15 @@ public void onCreate() {
boolean pcap = prefs.getBoolean("pcap", false);
setPcap(pcap, this);

// Re-block any temporary tracker allowances (#216) that expired while the
// service was down, and reschedule alarms for any still active, so a
// process death cannot leave a tracker permanently unblocked.
try {
TempUnblockReceiver.sweep(this);
} catch (Throwable ex) {
Log.e(TAG, "Temp-unblock sweep failed: " + ex);
}

Util.setTheme(this);
super.onCreate();

Expand Down
238 changes: 238 additions & 0 deletions app/src/main/java/eu/faircode/netguard/TempUnblockReceiver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*
* Copyright © 2021–2026 Konrad Kollnig (University of Oxford)
*/
package eu.faircode.netguard;

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.util.Log;

import net.kollnig.missioncontrol.data.Tracker;
import net.kollnig.missioncontrol.data.TrackerBlocklist;

import java.util.HashMap;
import java.util.Map;

/**
* Handles temporarily allowing a blocked tracker company/service for a single
* app (GitHub issue #216). The user long-presses a blocked tracker row and
* chooses "Allow for 10 minutes"; the tracker is unblocked immediately and an
* AlarmManager alarm is scheduled to re-block it once the window expires.
*
* Expiry timestamps are persisted in SharedPreferences so a process death does
* not leave a tracker permanently unblocked: {@link #sweep(Context)} runs on
* VPN service start, re-blocking anything that has expired and rescheduling the
* alarm for anything still active.
*/
public class TempUnblockReceiver extends BroadcastReceiver {
private static final String TAG = "TrackerControl.TempUnblock";

public static final String ACTION_REBLOCK = "net.kollnig.missioncontrol.TEMP_REBLOCK";
private static final String EXTRA_UID = "uid";
private static final String EXTRA_KEY = "key";

private static final String PREFS = "temp_unblock";

/**
* Fixed unblock duration. Kept intentionally non-configurable — the app's
* philosophy favours simplicity over configurability.
*/
public static final long DURATION_MS = 10 * 60 * 1000L; // 10 minutes

/**
* SharedPreferences key for a temporary allowance of tracker {@code key}
* for app {@code uid}. Value is the expiry timestamp (epoch millis).
*/
private static String prefKey(int uid, String trackerKey) {
return uid + ":" + trackerKey;
}

private static int requestCode(int uid, String trackerKey) {
return prefKey(uid, trackerKey).hashCode();
}

private static SharedPreferences prefs(Context c) {
return c.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}

/**
* Temporarily allow a tracker for an app. Unblocks it immediately (the
* packet path reads the live {@link TrackerBlocklist} singleton per
* connection, so this takes effect for new connections at once), persists
* the expiry and schedules the re-block alarm.
*/
public static void allowTemporarily(Context context, int uid, Tracker tracker) {
String key = TrackerBlocklist.getBlockingKey(tracker);
long expiry = System.currentTimeMillis() + DURATION_MS;

TrackerBlocklist b = TrackerBlocklist.getInstance(context);
b.unblock(uid, tracker);
b.saveSettings(context);

prefs(context).edit().putLong(prefKey(uid, key), expiry).apply();

scheduleAlarm(context, uid, key, expiry);
}

/**
* Remaining allowance for a tracker, or 0 if none/expired.
*/
public static long remainingMs(Context context, int uid, Tracker tracker) {
String key = TrackerBlocklist.getBlockingKey(tracker);
long expiry = prefs(context).getLong(prefKey(uid, key), 0);
long remaining = expiry - System.currentTimeMillis();
return remaining > 0 ? remaining : 0;
}

public static boolean isTemporarilyAllowed(Context context, int uid, Tracker tracker) {
return remainingMs(context, uid, tracker) > 0;
}

/**
* Cancel an active temporary allowance without re-blocking (the caller is
* expected to handle the block state, e.g. the user manually re-blocked the
* row). Clears the persisted expiry and the pending alarm.
*/
public static void cancel(Context context, int uid, Tracker tracker) {
String key = TrackerBlocklist.getBlockingKey(tracker);
prefs(context).edit().remove(prefKey(uid, key)).apply();
cancelAlarm(context, uid, key);
}

private static void scheduleAlarm(Context context, int uid, String key, long triggerAtMs) {
AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (am == null)
return;

PendingIntent pi = buildPendingIntent(context, uid, key);
// Mirror WidgetAdmin's pause alarm: allow-while-idle so it can fire in
// Doze. Inexact, so the re-block may lag a little under deep Doze — an
// acceptable trade-off that avoids the SCHEDULE_EXACT_ALARM permission.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
am.set(AlarmManager.RTC_WAKEUP, triggerAtMs, pi);
else
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pi);
Log.i(TAG, "Scheduled re-block uid=" + uid + " key=" + key + " at=" + triggerAtMs);
}

private static void cancelAlarm(Context context, int uid, String key) {
AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (am != null)
am.cancel(buildPendingIntent(context, uid, key));
}

private static PendingIntent buildPendingIntent(Context context, int uid, String key) {
Intent i = new Intent(context, TempUnblockReceiver.class);
i.setAction(ACTION_REBLOCK);
i.putExtra(EXTRA_UID, uid);
i.putExtra(EXTRA_KEY, key);
// A distinct data URI keeps the PendingIntents distinct per (uid, key)
// so concurrent temporary allowances don't overwrite each other.
i.setData(android.net.Uri.parse("tempunblock://" + uid + "/" + android.net.Uri.encode(key)));
return PendingIntentCompat.getBroadcast(context, requestCode(uid, key), i,
PendingIntent.FLAG_UPDATE_CURRENT);
}

/**
* Re-block a tracker and clear its persisted allowance. Reloads the rules
* defensively so the change is guaranteed to reach the packet path.
*/
private static void reblock(Context context, int uid, String key) {
TrackerBlocklist b = TrackerBlocklist.getInstance(context);
b.block(uid, key);
b.saveSettings(context);
prefs(context).edit().remove(prefKey(uid, key)).apply();

Rule.clearCache(context);
ServiceSinkhole.reload("temporary tracker allowance expired", context, false);
Log.i(TAG, "Re-blocked uid=" + uid + " key=" + key);
}

/**
* Re-block any expired temporary allowances and reschedule alarms for any
* still-active ones. Call on VPN service start so a process death cannot
* leave a tracker permanently unblocked.
*/
public static void sweep(Context context) {
SharedPreferences p = prefs(context);
Map<String, ?> all = new HashMap<>(p.getAll());
if (all.isEmpty())
return;

long now = System.currentTimeMillis();
boolean changed = false;
TrackerBlocklist b = TrackerBlocklist.getInstance(context);

for (Map.Entry<String, ?> 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);
}
}
Loading