Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ In combination with F-Droid, this repository uses automated builds and follows a

You need:
- Android Studio (with the Android SDK and build tools)
- Android NDK 27.2.12479018 (r27c)
- Android NDK 29.0.14206865 (r29)
- Rust via [rustup](https://rustup.rs), for the WireGuard engine ([gotatun](https://github.com/mullvad/gotatun), built from source in `wgbridge-rs/`). The compiler, Android targets, and `cargo-ndk` version are pinned; install them and pre-fetch locked crates with:
```bash
./scripts/setup_rust_android.sh
Expand Down
2 changes: 1 addition & 1 deletion agents/docs/build-and-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ flavour matrix, the native builds, and the reproducibility flags.

## Prerequisites

JDK 17, Android SDK (compile/target SDK 37, min SDK 23), NDK `27.2.12479018`,
JDK 17, Android SDK (compile/target SDK 37, min SDK 23), NDK `29.0.14206865`,
CMake. Native builds also need Rust ≥ 1.95 with the four Android targets;
the WireGuard bridge additionally needs `cargo-ndk`. Gradle wires both Rust
builds in but deliberately does not install tools or fetch crates. See
Expand Down
2 changes: 1 addition & 1 deletion app/gradle/wgbridge.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import org.gradle.process.ExecOperations
def wgbridgeSrcDir = file("$rootDir/wgbridge-rs")
def wgbridgeOutDir = layout.buildDirectory.dir("rustJniLibs").get().asFile
def wgbridgeAbis = ['armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64']
ext.wgbridgeNdkVersion = '27.2.12479018' // keep in sync with defaultConfig.ndkVersion
ext.wgbridgeNdkVersion = '29.0.14206865' // keep in sync with defaultConfig.ndkVersion
def wgbridgeNdkVersion = ext.wgbridgeNdkVersion
def wgbridgeCargoNdkVersion = '4.1.2' // keep in sync with scripts/setup_rust_android.sh
def wgbridgeExecOperations = project.services.get(ExecOperations)
Expand Down
12 changes: 5 additions & 7 deletions app/src/main/java/eu/faircode/netguard/ActivitySettings.java
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,11 @@ protected void onPostExecute(Throwable ex) {
});
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
TwoStatePreference pref_reload_onconnectivity = (TwoStatePreference) screen
.findPreference("reload_onconnectivity");
if (pref_reload_onconnectivity != null) {
pref_reload_onconnectivity.setChecked(true);
pref_reload_onconnectivity.setEnabled(false);
}
TwoStatePreference pref_reload_onconnectivity = (TwoStatePreference) screen
.findPreference("reload_onconnectivity");
if (pref_reload_onconnectivity != null) {
pref_reload_onconnectivity.setChecked(true);
pref_reload_onconnectivity.setEnabled(false);
}

// Handle port forwarding
Expand Down
76 changes: 3 additions & 73 deletions app/src/main/java/eu/faircode/netguard/NetworkReloadPolicy.java
Original file line number Diff line number Diff line change
@@ -1,92 +1,22 @@
package eu.faircode.netguard;

import java.util.List;
import java.util.Objects;

final class NetworkReloadPolicy {
static final String REASON_NETWORK_AVAILABLE = "network available";
static final String REASON_NETWORK_LOST = "network lost";
static final String REASON_NETWORK_CHANGED = "Network changed";
static final String REASON_CONNECTED_CHANGED = "Connected state changed";
static final String REASON_LINK_PROPERTIES_CHANGED = "link properties changed";
static final String REASON_PRIVATE_DNS_CHANGED = "private DNS changed";
static final String REASON_METERED_CHANGED = "Metered state changed";
static final String REASON_DNS_CHANGED = "DNS servers changed";
static final String REASON_CONNECTIVITY_CHANGED = "connectivity changed";

private NetworkReloadPolicy() {
}

static String onNetworkAvailable() {
return REASON_NETWORK_AVAILABLE;
}

static String onNetworkLost(Object lostNetwork, Object lastActiveNetwork) {
return lastActiveNetwork != null && Objects.equals(lastActiveNetwork, lostNetwork)
? REASON_NETWORK_LOST
: null;
}
private NetworkReloadPolicy() { }

static String onConnectivityChanged() {
return REASON_CONNECTIVITY_CHANGED;
}

static String onLinkPropertiesChanged(List<?> lastDns, List<?> currentDns,
boolean compareDns, boolean reloadOnConnectivity,
String lastPrivateDns, String currentPrivateDns) {
if (compareDns ? !same(lastDns, currentDns) : reloadOnConnectivity)
return REASON_LINK_PROPERTIES_CHANGED;

// Pinning Private DNS to a hostname leaves the resolver list alone, so
// the comparison above never sees it — yet it decides whether blocking
// DoT stops name resolution outright, which the user has to be told.
if (!Objects.equals(lastPrivateDns, currentPrivateDns))
return REASON_PRIVATE_DNS_CHANGED;

return null;
}

static String onCapabilitiesChanged(Object network, Object lastNetwork,
Boolean lastConnected, boolean connected,
Boolean lastMetered, boolean metered) {
if (!Objects.equals(network, lastNetwork))
return REASON_NETWORK_CHANGED;

if (lastConnected != null && !lastConnected.equals(connected))
return REASON_CONNECTED_CHANGED;

if (lastMetered != null && !lastMetered.equals(metered))
return REASON_METERED_CHANGED;

return null;
}

static boolean shouldRestartWireGuard(String reason) {
return REASON_NETWORK_AVAILABLE.equals(reason) ||
REASON_NETWORK_LOST.equals(reason) ||
REASON_NETWORK_CHANGED.equals(reason) ||
REASON_CONNECTED_CHANGED.equals(reason) ||
return REASON_NETWORK_CHANGED.equals(reason) ||
REASON_LINK_PROPERTIES_CHANGED.equals(reason) ||
REASON_METERED_CHANGED.equals(reason) ||
REASON_CONNECTIVITY_CHANGED.equals(reason);
}

/**
* The same decision across a coalesced burst of callbacks, which keeps only
* the last reason. The need for a rebind is sticky: once any reason in the
* burst required one, a later reason that does not must not cancel it.
*/
static boolean shouldRestartWireGuard(boolean pendingRestart, String reason) {
return pendingRestart || shouldRestartWireGuard(reason);
}

static boolean same(List<?> last, List<?> current) {
if (last == null || current == null || last.size() != current.size())
return false;

for (int i = 0; i < current.size(); i++)
if (!Objects.equals(last.get(i), current.get(i)))
return false;

return true;
}
}
166 changes: 166 additions & 0 deletions app/src/main/java/eu/faircode/netguard/PhysicalNetworkState.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package eu.faircode.netguard;

import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.os.Build;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/** Callback-owned snapshots: never query ConnectivityManager from its callbacks. */
final class PhysicalNetworkState {
private static final class Entry {
List<Integer> transports;
Boolean metered;
List<String> routes;
List<String> dns;
String privateDns;
boolean privateDnsActive;
}

private final Map<Network, Entry> entries = new HashMap<>();
private Network defaultNetwork;
private boolean defaultIsVpn;
private List<Integer> vpnTransports = Collections.emptyList();
private Network egress;

synchronized String onPhysicalAvailable(Network network) {
if (network != null && !entries.containsKey(network)) entries.put(network, new Entry());
return null; // Availability alone says nothing about the selected egress.
}

synchronized String onPhysicalCapabilitiesChanged(Network network, NetworkCapabilities caps) {
Entry entry = entries.get(network);
if (entry == null || caps == null ||
!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) return null;
List<Integer> previous = entry.transports;
Boolean previousMetered = entry.metered;
entry.transports = transports(caps);
entry.metered = isMetered(caps);
String change = selectEgress();
if (change != null || !network.equals(egress)) return change;
if (previous != null && !previous.equals(entry.transports))
return NetworkReloadPolicy.REASON_NETWORK_CHANGED;
return previousMetered != null && !previousMetered.equals(entry.metered)
? NetworkReloadPolicy.REASON_METERED_CHANGED : null;
}

synchronized String onPhysicalLinkPropertiesChanged(Network network, LinkProperties props) {
Entry entry = entries.get(network);
if (entry == null || props == null) return null;
List<String> routes = new ArrayList<>();
for (Object address : props.getLinkAddresses()) routes.add("address:" + address);
for (Object route : props.getRoutes()) routes.add("route:" + route);
Collections.sort(routes);
List<String> dns = new ArrayList<>();
for (java.net.InetAddress server : props.getDnsServers()) dns.add(server.getHostAddress());
dns.add("domains:" + props.getDomains());
Collections.sort(dns);
String privateDns = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
? props.getPrivateDnsServerName() : null;
boolean active = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && props.isPrivateDnsActive();
boolean routeChanged = entry.routes != null && !entry.routes.equals(routes);
boolean dnsChanged = entry.dns != null && !entry.dns.equals(dns);
boolean privateChanged = !Objects.equals(privateDns, entry.privateDns) ||
active != entry.privateDnsActive;
entry.routes = routes;
entry.dns = dns;
entry.privateDns = privateDns;
entry.privateDnsActive = active;
if (!network.equals(egress)) return null;
if (routeChanged) return NetworkReloadPolicy.REASON_LINK_PROPERTIES_CHANGED;
if (dnsChanged) return NetworkReloadPolicy.REASON_DNS_CHANGED;
return privateChanged ? NetworkReloadPolicy.REASON_PRIVATE_DNS_CHANGED : null;
}

synchronized String onPhysicalLost(Network network) {
if (entries.remove(network) == null) return null;
return selectEgress();
}

synchronized String onDefaultNetworkAvailable(Network network) {
// onCapabilitiesChanged identifies physical versus VPN. Guessing here
// would treat our own replacement VPN as a physical handover.
return null;
}

synchronized String onDefaultNetworkCapabilitiesChanged(Network network, NetworkCapabilities caps) {
if (network == null || caps == null) return null;
boolean vpn = !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN);
List<Integer> snapshot = transports(caps);
if (vpn && snapshot.isEmpty()) return null; // Replacement VPN has not inherited transports yet.
boolean transportChanged = vpn && defaultIsVpn && !vpnTransports.equals(snapshot);
defaultNetwork = network;
defaultIsVpn = vpn;
vpnTransports = vpn ? snapshot : Collections.emptyList();
// Never create physical entries from this unfiltered callback: only
// the physical registration guarantees a matching onLost later.
String change = vpn ? selectEgress() : onPhysicalCapabilitiesChanged(network, caps);
if (change == null) change = selectEgress();
return change != null ? change : transportChanged ? NetworkReloadPolicy.REASON_NETWORK_CHANGED : null;
}

synchronized String onDefaultNetworkLinkPropertiesChanged(Network network, LinkProperties props) {
return network != null && network.equals(defaultNetwork) && !defaultIsVpn
? onPhysicalLinkPropertiesChanged(network, props) : null;
}

synchronized String onDefaultNetworkLost(Network network) {
if (!Objects.equals(network, defaultNetwork) || defaultIsVpn) return null;
defaultNetwork = null;
return selectEgress();
}

private String selectEgress() {
Network selected = null;
if (!defaultIsVpn) {
Entry entry = entries.get(defaultNetwork);
if (entry != null && entry.transports != null) selected = defaultNetwork;
} else {
// VPN capabilities expose physical transports, not necessarily an
// underlying Network identity. Retain a still-matching selection;
// otherwise select only an unambiguous candidate, never a standby
// merely because its validation/metered state changed.
Entry current = entries.get(egress);
if (current != null && current.transports != null &&
!Collections.disjoint(current.transports, vpnTransports)) selected = egress;
else for (Map.Entry<Network, Entry> candidate : entries.entrySet()) {
List<Integer> transports = candidate.getValue().transports;
if (transports == null || Collections.disjoint(transports, vpnTransports)) continue;
if (selected != null) { selected = null; break; }
selected = candidate.getKey();
}
}
boolean changed = !Objects.equals(egress, selected);
egress = selected;
return changed ? NetworkReloadPolicy.REASON_NETWORK_CHANGED : null;
}

@android.annotation.SuppressLint("InlinedApi")
private static boolean isMetered(NetworkCapabilities caps) {
return !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) &&
!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED);
}

private static List<Integer> transports(NetworkCapabilities caps) {
List<Integer> result = new ArrayList<>();
for (int transport = 0; transport < 32; transport++)
if (transport != NetworkCapabilities.TRANSPORT_VPN && caps.hasTransport(transport)) result.add(transport);
return result;
}

synchronized Network getDefaultNetwork() { return egress; }

synchronized void reset() {
entries.clear();
defaultNetwork = null;
defaultIsVpn = false;
vpnTransports = Collections.emptyList();
egress = null;
}
}
Loading