Skip to content
Merged
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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.env

.claude/
fdroidserver/

Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
TrackerControl is an Android app that allows users to monitor and control the widespread,
ongoing, hidden data collection in mobile apps about user behaviour ('tracking').

TrackerControl can also route filtered traffic through a remote VPN endpoint using
its experimental WireGuard support, with built-in setup for Mullvad and IVPN and
support for custom WireGuard profiles.

To detect tracking, TrackerControl combines the power of the *Disconnect blocklist*,
used by Firefox, the *DuckDuckGo Tracker Radar* for mobile apps, and of our in-house blocklist, created *from analysing ~2 000 000 apps*! **To protect your privacy from your ISP, you can also optionally encrypt your DNS traffic using DNS-over-HTTPS (DoH).**
Additionally, TrackerControl supports custom blocklists and uses the signatures from [ClassyShark3xodus](https://f-droid.org/en/packages/com.oF2pks.classyshark3xodus/)/[Exodus Privacy](https://exodus-privacy.eu.org/) for the analysis of tracker libraries within app code.
Expand All @@ -24,13 +28,14 @@ Under the hood, TrackerControl uses Android's VPN functionality,
to analyse apps' network communications *locally on the Android device*.
This is accomplished through a local VPN server, to enable network traffic analysis by TrackerControl.

No root is required. Other VPNs or Android's "Private DNS" feature are not supported (due to Android limitations), but TrackerControl provides its own **Secure DNS (DNS-over-HTTPS / DoH)** feature to protect your DNS traffic. For users who want to combine tracker analysis with a remote VPN, TrackerControl also offers **experimental WireGuard support**, allowing filtered traffic to be tunnelled through a WireGuard endpoint of your choice.
No root is required. Other VPN apps or Android's "Private DNS" feature are not supported alongside TrackerControl due to Android limitations, but TrackerControl provides its own **Secure DNS (DNS-over-HTTPS / DoH)** feature and optional **WireGuard tunnelling** for users who want remote VPN routing.
By default, no external VPN server is used, to keep your data safe! TrackerControl even protects you
against *DNS cloaking*, a popular technique to hide trackers in websites and apps.

TrackerControl will always be free and open source, being a research project.

## Contents
- [VPN Support](#vpn-support)
- [Download / Installation](#download--installation)
- [Example Use](#example-use)
- [Contributing](#contributing)
Expand All @@ -44,6 +49,22 @@ TrackerControl will always be free and open source, being a research project.
- [License](#license)
- [Citation](#citation)

## VPN Support

TrackerControl's built-in VPN remains local by default: it analyses and filters traffic on your device without sending traffic to an external VPN provider. The experimental WireGuard support adds an optional second step for users who also want remote VPN tunnelling after TrackerControl has applied its local tracker analysis and blocking.

The VPN tab supports three modes:

| Mode | What it does |
| :--- | :--- |
| **Mullvad** | Creates WireGuard profiles from a Mullvad account number, lets you choose a relay country, and stores only the account number and generated WireGuard profile data locally. |
| **IVPN** | Creates WireGuard profiles from an IVPN account ID, including CAPTCHA handling when IVPN requires it, and lets you choose a relay country. |
| **WireGuard** | Imports and manages custom WireGuard configurations from another VPN provider, your own server, or a workplace endpoint. |

When WireGuard tunnelling is enabled, TrackerControl still uses Android's VPN service for local filtering, then routes allowed traffic through the selected WireGuard endpoint. Secure DNS (DoH) is automatically paused when the active WireGuard profile provides DNS, because DNS queries are then handled through the WireGuard tunnel instead. Provider-generated WireGuard keys can be rotated from advanced settings.

This feature is experimental. Android only allows one active VPN service at a time, so TrackerControl cannot run alongside a separate VPN app.

## Download / Installation
*Disclaimer: The usage of this app is at your own risk. No app can offer 100% protection against tracking. Analysis results shown within the app might be inaccurate.*

Expand Down Expand Up @@ -100,7 +121,7 @@ TrackerControl is mainly designed to help you investigate the tracking practices

Mobile trackers rely on the sending of personal data over the internet. This is why tracking can be detected and analysed from apps' network traffic. This is the core functionality of TrackerControl. The advantage of this approach over tracker library analysis is that actual evidence of data sharing is gathered; by contrast, when analysing solely the presence of tracking libraries in apps, some of these libraries may never be activated by an app at run-time.

TrackerControl analyses network traffic locally on the device using DNS-based detection. TLS Server Name Indication (SNI) extraction is disabled by default because it requires connecting to tracker servers, leaking the user's IP address. SNI can be re-enabled from the advanced settings for research purposes.
TrackerControl analyses network traffic locally on the device using DNS-based detection. TLS Server Name Indication (SNI) extraction is disabled by default because it requires connecting to tracker servers, leaking the user's IP address. SNI is enabled only when Research mode is turned on for measurement purposes.

You analyse apps network traffic by following the steps within the app to enable the VPN. Consequently, TrackerControl keeps track of any contacted tracking domain. Note that you need to interact with apps of interest in order to make these apps share data with tracking companies over the internet.

Expand Down
16 changes: 16 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# TODO

## Secure DNS battery and simple protection health

Secure DNS is currently Java-based and can make the phone warm while the screen is off. Do **not** make DoH a stronger default until its idle behavior is profiled and fixed.

Investigate:
- whether the local DNS proxy / DoH client stays active when there is no DNS traffic
- whether retries, circuit-breaker checks, network-change handling, or idle HTTPS connections cause wakeups while the screen is off
- whether DNS caching is effective enough to avoid repeated upstream DoH queries
- whether DoH duplicates work or conflicts with WireGuard-provided DNS
- whether system-app routing through TC/DoH is contributing to wakeups

Desired product direction after the battery issue is fixed:
- add a simple protection health screen showing tracker blocking, Secure DNS, WireGuard, Android Private DNS conflict, and battery/background permission status
- keep recommended defaults simple: low-battery tracker blocking by default; Secure DNS as a clearly explained stronger privacy option until its screen-off cost is low
- avoid exposing Rethink-style expert configuration unless it directly helps users recover from breakage

## ParcelFileDescriptor Race Fix

The VPN file descriptor can be closed by `stopVPN()` while native code in `jni_run()` is still using it, causing EBADF errors and VPN tunnel failures — typically triggered by network transitions (WiFi/mobile).
Expand Down
44 changes: 20 additions & 24 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'

// --- WireGuard bridge (built from source via gomobile) ---
// Produces app/build/wgbridge/wgbridge.aar from wgbridge/*.go on demand.
Expand Down Expand Up @@ -61,12 +60,9 @@ tasks.register('wgbridgeBind') {

// Ask Go for GOPATH so `gomobile` resolves; also add Go's own dir to
// PATH for downstream tools the binding might invoke (cgo, etc).
def gopathStream = new ByteArrayOutputStream()
exec {
def gopath = providers.exec {
commandLine goBin, 'env', 'GOPATH'
standardOutput = gopathStream
}
def gopath = gopathStream.toString().trim()
}.standardOutput.asText.get().trim()
def goDir = file(goBin).parent

def env = new HashMap<String, String>(System.getenv())
Expand All @@ -80,18 +76,18 @@ tasks.register('wgbridgeBind') {
// does the actual Java <-> Go interface generation. `gomobile
// init` used to install gobind for you but on recent versions
// we install it explicitly so the failure mode is clearer.
exec {
providers.exec {
environment env
commandLine goBin, 'install',
"golang.org/x/mobile/cmd/gomobile@${gomobileVersion}",
"golang.org/x/mobile/cmd/gobind@${gomobileVersion}"
}
}.result.get().assertNormalExitValue()
if (!file(gomobileBin).canExecute() || !file(gobindBin).canExecute()) {
throw new GradleException("Installed gomobile/gobind but ${gopath}/bin still missing one of them.")
}
}

exec {
providers.exec {
workingDir wgbridgeSrcDir
environment env
commandLine gomobileBin, 'bind',
Expand All @@ -101,7 +97,7 @@ tasks.register('wgbridgeBind') {
'-ldflags', '-extldflags "-Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384"',
'-o', wgbridgeAar.absolutePath,
'.'
}
}.result.get().assertNormalExitValue()
}
}

Expand All @@ -112,7 +108,7 @@ afterEvaluate {
}

android {
compileSdk 36
compileSdk = 36

defaultConfig {
applicationId "net.kollnig.missioncontrol"
Expand All @@ -137,7 +133,7 @@ android {
}
}

ndkVersion "27.2.12479018"
ndkVersion = "27.2.12479018"

ndk {
// https://developer.android.com/ndk/guides/abis.html#sa
Expand Down Expand Up @@ -199,12 +195,6 @@ android {
}
}

applicationVariants.configureEach { variant ->
variant.outputs.configureEach { output ->
outputFileName = "TrackerControl-${variant.name}-latest.apk"
}
}

signingConfigs {
release {
enableV1Signing = true
Expand Down Expand Up @@ -237,21 +227,27 @@ android {
androidResources {
generateLocaleConfig = true
}
kotlinOptions {
jvmTarget = '17'
}

androidComponents {
onVariants(selector().all()) { variant ->
variant.outputs.forEach { output ->
output.outputFileName.set("TrackerControl-${variant.name}-latest.apk")
}
}
}

dependencies {
implementation 'androidx.core:core-ktx:1.18.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'

coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation files(wgbridgeAar)

// https://developer.android.com/jetpack/androidx/releases/
implementation 'androidx.activity:activity:1.9.3'
implementation 'androidx.activity:activity:1.13.0'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0'
implementation 'androidx.recyclerview:recyclerview:1.4.0'
Expand All @@ -260,16 +256,16 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'com.google.android.material:material:1.13.0'
implementation 'androidx.work:work-runtime:2.11.2'
implementation 'com.google.guava:guava:33.5.0-android'
annotationProcessor 'androidx.annotation:annotation:1.9.1'
implementation 'com.google.guava:guava:33.6.0-android'
annotationProcessor 'androidx.annotation:annotation:1.10.0'

// fix errors with libraries
def lifecycle_version = '2.10.0'
//implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"

// https://bumptech.github.io/glide/
def glide_version = "5.0.5"
def glide_version = "5.0.7"
implementation("com.github.bumptech.glide:glide:$glide_version") {
exclude group: "com.android.support"
}
Expand Down
15 changes: 14 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />

<!-- https://developer.android.com/preview/privacy/package-visibility -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<!--queries>
<intent>
<action android:name="android.intent.action.MAIN" />
Expand Down Expand Up @@ -131,6 +133,7 @@
android:configChanges="orientation|screenSize"
android:exported="true"
android:label="@string/app_name"
android:permission="${applicationId}.permission.ADMIN"
android:theme="@style/AppDialog">
<intent-filter>
<action android:name="eu.faircode.netguard.START_PORT_FORWARD" />
Expand Down Expand Up @@ -169,6 +172,16 @@
android:value="eu.faircode.netguard.ActivitySettings" />
</activity>

<activity
android:name="net.kollnig.missioncontrol.ActivityWireGuardProfiles"
android:configChanges="orientation|screenSize"
android:label="@string/setting_wg_profile_manage"
android:parentActivityName="eu.faircode.netguard.ActivitySettings">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="eu.faircode.netguard.ActivitySettings" />
</activity>


<activity
android:name="net.kollnig.missioncontrol.DetailsActivity"
Expand Down
14 changes: 6 additions & 8 deletions app/src/main/java/eu/faircode/netguard/ActivityForwarding.java
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ protected void onCreate(Bundle savedInstanceState) {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Cursor cursor = (Cursor) adapter.getItem(position);
final int protocol = cursor.getInt(cursor.getColumnIndex("protocol"));
final int dport = cursor.getInt(cursor.getColumnIndex("dport"));
final String raddr = cursor.getString(cursor.getColumnIndex("raddr"));
final int rport = cursor.getInt(cursor.getColumnIndex("rport"));
final int protocol = cursor.getInt(cursor.getColumnIndexOrThrow("protocol"));
final int dport = cursor.getInt(cursor.getColumnIndexOrThrow("dport"));
final String raddr = cursor.getString(cursor.getColumnIndexOrThrow("raddr"));
final int rport = cursor.getInt(cursor.getColumnIndexOrThrow("rport"));

PopupMenu popup = new PopupMenu(ActivityForwarding.this, view);
popup.inflate(R.menu.forward);
Expand Down Expand Up @@ -150,8 +150,7 @@ public boolean onCreateOptionsMenu(Menu menu) {

@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_add:
if (item.getItemId() == R.id.menu_add) {
LayoutInflater inflater = LayoutInflater.from(this);
View view = inflater.inflate(R.layout.forwardadd, null, false);
final Spinner spProtocol = view.findViewById(R.id.spProtocol);
Expand Down Expand Up @@ -249,8 +248,7 @@ public void onDismiss(DialogInterface dialogInterface) {
.create();
dialog.show();
return true;
default:
return super.onOptionsItemSelected(item);
}
return super.onOptionsItemSelected(item);
}
}
32 changes: 15 additions & 17 deletions app/src/main/java/eu/faircode/netguard/ActivityLog.java
Original file line number Diff line number Diff line change
Expand Up @@ -151,16 +151,20 @@ protected void onCreate(Bundle savedInstanceState) {
lvLog.setOnItemClickListener((parent, view, position, id) -> {
PackageManager pm = getPackageManager();
Cursor cursor = (Cursor) adapter.getItem(position);
long time = cursor.getLong(cursor.getColumnIndex("time"));
int version = cursor.getInt(cursor.getColumnIndex("version"));
int protocol = cursor.getInt(cursor.getColumnIndex("protocol"));
final String saddr = cursor.getString(cursor.getColumnIndex("saddr"));
final int sport = (cursor.isNull(cursor.getColumnIndex("sport")) ? -1 : cursor.getInt(cursor.getColumnIndex("sport")));
final String daddr = cursor.getString(cursor.getColumnIndex("daddr"));
final int dport = (cursor.isNull(cursor.getColumnIndex("dport")) ? -1 : cursor.getInt(cursor.getColumnIndex("dport")));
final String dname = cursor.getString(cursor.getColumnIndex("dname"));
final int uid = (cursor.isNull(cursor.getColumnIndex("uid")) ? -1 : cursor.getInt(cursor.getColumnIndex("uid")));
int allowed1 = (cursor.isNull(cursor.getColumnIndex("allowed")) ? -1 : cursor.getInt(cursor.getColumnIndex("allowed")));
long time = cursor.getLong(cursor.getColumnIndexOrThrow("time"));
int version = cursor.getInt(cursor.getColumnIndexOrThrow("version"));
int protocol = cursor.getInt(cursor.getColumnIndexOrThrow("protocol"));
final String saddr = cursor.getString(cursor.getColumnIndexOrThrow("saddr"));
int colSport = cursor.getColumnIndexOrThrow("sport");
final int sport = (cursor.isNull(colSport) ? -1 : cursor.getInt(colSport));
final String daddr = cursor.getString(cursor.getColumnIndexOrThrow("daddr"));
int colDPort = cursor.getColumnIndexOrThrow("dport");
final int dport = (cursor.isNull(colDPort) ? -1 : cursor.getInt(colDPort));
final String dname = cursor.getString(cursor.getColumnIndexOrThrow("dname"));
int colUid = cursor.getColumnIndexOrThrow("uid");
final int uid = (cursor.isNull(colUid) ? -1 : cursor.getInt(colUid));
int colAllowed = cursor.getColumnIndexOrThrow("allowed");
int allowed1 = (cursor.isNull(colAllowed) ? -1 : cursor.getInt(colAllowed));

// Get external address
InetAddress addr = null;
Expand Down Expand Up @@ -207,12 +211,7 @@ protected void onCreate(Bundle savedInstanceState) {
else
popup.getMenu().findItem(R.id.menu_port).setTitle(getString(R.string.title_log_port, port));

if (prefs.getBoolean("filter", true)) {
if (uid <= 0) {
popup.getMenu().removeItem(R.id.menu_allow);
popup.getMenu().removeItem(R.id.menu_block);
}
} else {
if (uid <= 0) {
popup.getMenu().removeItem(R.id.menu_allow);
popup.getMenu().removeItem(R.id.menu_block);
}
Expand Down Expand Up @@ -346,7 +345,6 @@ public boolean onPrepareOptionsMenu(Menu menu) {
menu.findItem(R.id.menu_protocol_udp).setChecked(prefs.getBoolean("proto_udp", true));
menu.findItem(R.id.menu_protocol_tcp).setChecked(prefs.getBoolean("proto_tcp", true));
menu.findItem(R.id.menu_protocol_other).setChecked(prefs.getBoolean("proto_other", true));
menu.findItem(R.id.menu_traffic_allowed).setEnabled(prefs.getBoolean("filter", true));
menu.findItem(R.id.menu_traffic_allowed).setChecked(prefs.getBoolean("traffic_allowed", true));
menu.findItem(R.id.menu_traffic_blocked).setChecked(prefs.getBoolean("traffic_blocked", true));

Expand Down
Loading
Loading