* Merge pull request #11 from wecand0/fdroid

внедрение detection engine и модульной архитектуры детектирования VPN

* Feature/UI polish and readme (#12)

* add icon file

* update readme

---------

Co-authored-by: cherepavel <frasyn.pashka@gmail.com>

* Remove background monitoring (#13)

* убрано фоновое отслеживание, приложение переведено на manual refresh

* minor fix

---------

Co-authored-by: cherepavel <frasyn.pashka@gmail.com>

* Split native details UI (#14)

* убрал монолитный activity_main.xml, разделил на куски

* upd

* текст из ReportFormatter перенесен в strings.xml

* add version and build

* добавлена версия и билд в тектовый репорт

---------

Co-authored-by: cherepavel <frasyn.pashka@gmail.com>

* update gitignore

---------

Co-authored-by: Vadim <shorinvadim1@gmail.com>
Co-authored-by: cherepavel <frasyn.pashka@gmail.com>
This commit is contained in:
Pavel Frasyn 2026-04-09 20:02:55 +03:00 committed by GitHub
parent 702874ca79
commit 1c75bb098b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 3460 additions and 1597 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
# Gradle files
.gradle/
build/
app/release/
# Local configuration file (sdk path, etc)
local.properties

View file

@ -9,8 +9,14 @@ Research tool for analyzing VPN detection mechanisms on Android.
- Native + Java network enumeration
## Purpose
Demonstrates how apps can detect VPN presence even with split tunneling.
## Permissions
* `android.permission.ACCESS_NETWORK_STATE` — used to access network state via `ConnectivityManager`
* `android.permission.QUERY_ALL_PACKAGES` — used to enumerate installed applications in order to detect known VPN clients
## Screenshots
**_VPN Active (full tunnel)_**
@ -20,3 +26,7 @@ Demonstrates how apps can detect VPN presence even with split tunneling.
**_VPN Split / Bypass (still detectable)_**
![](img/vpn_split_bypass.jpg)
## License
MIT

View file

@ -2,57 +2,70 @@ plugins {
id("com.android.application")
}
android {
namespace = "com.cherepavel.vpndetector"
fun gitCommitHash(): String {
return try {
val process = ProcessBuilder("git", "rev-parse", "--short", "HEAD")
.redirectErrorStream(true)
.start()
val result = process.inputStream.bufferedReader().use { it.readText() }.trim()
val exitCode = process.waitFor()
if (exitCode == 0 && result.isNotBlank()) result else "unknown"
} catch (e: Exception) {
"unknown"
}
}
extensions.configure<com.android.build.api.dsl.ApplicationExtension> {
namespace = "com.cherepavel.vpndetector"
compileSdk = 36
defaultConfig {
applicationId = "com.cherepavel.vpndetector"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
versionCode = 2
versionName = "0.0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
cmake {
cppFlags += ""
}
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
}
buildConfigField("String", "GIT_HASH", "\"${gitCommitHash()}\"")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
isShrinkResources = false
}
}
buildFeatures {
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
}
}
dependencies {
api(project(":detector"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
}

View file

@ -1,18 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<queries>
<package android:name="com.github.dyhkwong.sagernet" />
<package android:name="com.v2ray.ang" />
<package android:name="org.amnezia.awg" />
<package android:name="org.amnezia.vpn" />
<package android:name="de.blinkt.openvpn" />
<package android:name="net.openvpn.openvpn" />
<package android:name="com.zaneschepke.wireguardautotunnel" />
<package android:name="moe.nb4a" />
</queries>
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="PackageVisibilityPolicy,ProtectionLevel,QueryAllPackagesPermission" />
<application
android:allowBackup="true"
@ -31,6 +25,7 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
</manifest>

View file

@ -0,0 +1,25 @@
[
{"packageName": "com.github.dyhkwong.sagernet", "label": "ExclaveVPN"},
{"packageName": "com.v2ray.ang", "label": "v2rayNG"},
{"packageName": "org.amnezia.awg", "label": "AmneziaWG"},
{"packageName": "org.amnezia.vpn", "label": "Amnezia VPN"},
{"packageName": "de.blinkt.openvpn", "label": "OpenVPN for Android"},
{"packageName": "net.openvpn.openvpn", "label": "OpenVPN Connect"},
{"packageName": "com.wireguard.android", "label": "WireGuard"},
{"packageName": "com.cloudflare.onedotonedotonedotone", "label": "Cloudflare WARP"},
{"packageName": "com.psiphon3", "label": "Psiphon"},
{"packageName": "app.hiddify.com", "label": "Hiddify"},
{"packageName": "io.nekohasekai.sfa", "label": "SFA"},
{"packageName": "com.nordvpn.android", "label": "NordVPN"},
{"packageName": "com.expressvpn.vpn", "label": "ExpressVPN"},
{"packageName": "com.protonvpn.android", "label": "Proton VPN"},
{"packageName": "ch.protonvpn.android", "label": "Proton VPN (legacy package)"},
{"packageName": "free.vpn.unblock.proxy.turbovpn", "label": "Turbo VPN"},
{"packageName": "com.zaneschepke.wireguardautotunnel", "label": "WG Tunnel"},
{"packageName": "moe.nb4a", "label": "NekoBox"},
{"packageName": "fr.husi", "label": "husi"},
{"packageName": "com.outline.android", "label": "Outline"},
{"packageName": "xyz.safetyvpn.app", "label": "SafetyVPN"},
{"packageName": "net.mullvad.mullvadvpn", "label": "Mullvad VPN"},
{"packageName": "org.torproject.android", "label": "Orbot"}
]

View file

@ -1,19 +0,0 @@
cmake_minimum_required(VERSION 3.22.1)
project("ifconfigdetector")
add_library(
ifconfigdetector
SHARED
ifconfigdetector.cpp
)
find_library(
log-lib
log
)
target_link_libraries(
ifconfigdetector
${log-lib}
)

View file

@ -1,263 +0,0 @@
#include <jni.h>
#include <string>
#include <vector>
#include <map>
#include <set>
#include <sstream>
#include <fstream>
#include <algorithm>
#include <ifaddrs.h>
#include <net/if.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
struct AddressEntry {
int family = 0;
std::string address;
std::string netmask;
std::string peerOrBroadcast;
bool isPointToPoint = false;
bool isBroadcast = false;
};
struct InterfaceDump {
std::string name;
unsigned int flags = 0;
std::vector<AddressEntry> addresses;
};
static std::string sockaddrToString(const sockaddr* sa) {
if (!sa) return "";
char buf[INET6_ADDRSTRLEN] = {0};
if (sa->sa_family == AF_INET) {
const sockaddr_in* sin = reinterpret_cast<const sockaddr_in*>(sa);
if (inet_ntop(AF_INET, &(sin->sin_addr), buf, sizeof(buf))) {
return std::string(buf);
}
} else if (sa->sa_family == AF_INET6) {
const sockaddr_in6* sin6 = reinterpret_cast<const sockaddr_in6*>(sa);
if (inet_ntop(AF_INET6, &(sin6->sin6_addr), buf, sizeof(buf))) {
return std::string(buf);
}
}
return "";
}
static std::string readFirstLine(const std::string& path) {
std::ifstream file(path);
if (!file.is_open()) return "";
std::string line;
std::getline(file, line);
return line;
}
static int readIntFromFile(const std::string& path, int fallback = -1) {
std::ifstream file(path);
if (!file.is_open()) return fallback;
int value = fallback;
file >> value;
return file.fail() ? fallback : value;
}
static std::string formatFlagNames(unsigned int flags) {
std::vector<std::string> parts;
if (flags & IFF_UP) parts.emplace_back("UP");
if (flags & IFF_BROADCAST) parts.emplace_back("BROADCAST");
if (flags & IFF_DEBUG) parts.emplace_back("DEBUG");
if (flags & IFF_LOOPBACK) parts.emplace_back("LOOPBACK");
if (flags & IFF_POINTOPOINT) parts.emplace_back("POINTOPOINT");
if (flags & IFF_RUNNING) parts.emplace_back("RUNNING");
if (flags & IFF_NOARP) parts.emplace_back("NOARP");
if (flags & IFF_PROMISC) parts.emplace_back("PROMISC");
if (flags & IFF_ALLMULTI) parts.emplace_back("ALLMULTI");
if (flags & IFF_MULTICAST) parts.emplace_back("MULTICAST");
std::ostringstream oss;
for (size_t i = 0; i < parts.size(); ++i) {
if (i > 0) oss << ",";
oss << parts[i];
}
return oss.str();
}
static int ipv6PrefixLenFromMask(const sockaddr* sa) {
if (!sa || sa->sa_family != AF_INET6) return -1;
const sockaddr_in6* sin6 = reinterpret_cast<const sockaddr_in6*>(sa);
int bits = 0;
for (int i = 0; i < 16; ++i) {
unsigned char byte = sin6->sin6_addr.s6_addr[i];
for (int bit = 7; bit >= 0; --bit) {
if (byte & (1u << bit)) {
bits++;
}
}
}
return bits;
}
static bool addressEntryLess(const AddressEntry& a, const AddressEntry& b) {
if (a.family != b.family) {
return a.family == AF_INET;
}
return a.address < b.address;
}
static std::string buildIfconfigLikeBlock(const InterfaceDump& iface, const std::map<std::string, int>& mtuMap, const std::map<std::string, int>& txQueueMap) {
std::ostringstream oss;
const auto mtuIt = mtuMap.find(iface.name);
const auto txIt = txQueueMap.find(iface.name);
const int mtu = (mtuIt != mtuMap.end()) ? mtuIt->second : -1;
const int txq = (txIt != txQueueMap.end()) ? txIt->second : -1;
oss << iface.name << ": flags=" << iface.flags
<< "<" << formatFlagNames(iface.flags) << ">";
if (mtu >= 0) {
oss << " mtu " << mtu;
}
oss << "\n";
std::vector<AddressEntry> sorted = iface.addresses;
std::sort(sorted.begin(), sorted.end(), addressEntryLess);
for (const auto& addr : sorted) {
if (addr.family == AF_INET) {
oss << " inet " << (addr.address.empty() ? "-" : addr.address);
if (!addr.netmask.empty()) {
oss << " netmask " << addr.netmask;
}
if (!addr.peerOrBroadcast.empty()) {
if (addr.isPointToPoint) {
oss << " destination " << addr.peerOrBroadcast;
} else if (addr.isBroadcast) {
oss << " broadcast " << addr.peerOrBroadcast;
}
}
oss << "\n";
} else if (addr.family == AF_INET6) {
oss << " inet6 " << (addr.address.empty() ? "-" : addr.address);
if (!addr.netmask.empty()) {
oss << " prefixlen " << addr.netmask;
}
if (!addr.peerOrBroadcast.empty() && addr.isPointToPoint) {
oss << " destination " << addr.peerOrBroadcast;
}
oss << "\n";
}
}
if (txq >= 0) {
oss << " txqueuelen " << txq << "\n";
}
return oss.str();
}
extern "C"
JNIEXPORT jobjectArray JNICALL
Java_com_cherepavel_vpndetector_detector_IfconfigTermuxLikeDetector_getInterfacesNative(
JNIEnv* env,
jobject /* thiz */) {
jclass stringCls = env->FindClass("java/lang/String");
if (stringCls == nullptr) {
return nullptr;
}
std::map<std::string, InterfaceDump> interfaces;
std::map<std::string, int> mtuMap;
std::map<std::string, int> txQueueMap;
struct ifaddrs* ifaddr = nullptr;
if (getifaddrs(&ifaddr) == -1 || ifaddr == nullptr) {
return env->NewObjectArray(0, stringCls, nullptr);
}
for (struct ifaddrs* it = ifaddr; it != nullptr; it = it->ifa_next) {
if (!it->ifa_name) continue;
std::string name(it->ifa_name);
auto& iface = interfaces[name];
iface.name = name;
iface.flags |= static_cast<unsigned int>(it->ifa_flags);
if (mtuMap.find(name) == mtuMap.end()) {
mtuMap[name] = readIntFromFile("/sys/class/net/" + name + "/mtu", -1);
}
if (txQueueMap.find(name) == txQueueMap.end()) {
txQueueMap[name] = readIntFromFile("/sys/class/net/" + name + "/tx_queue_len", -1);
}
if (!it->ifa_addr) continue;
const int family = it->ifa_addr->sa_family;
if (family != AF_INET && family != AF_INET6) continue;
AddressEntry entry;
entry.family = family;
entry.address = sockaddrToString(it->ifa_addr);
entry.isPointToPoint = (it->ifa_flags & IFF_POINTOPOINT) != 0;
entry.isBroadcast = (it->ifa_flags & IFF_BROADCAST) != 0;
if (family == AF_INET) {
entry.netmask = sockaddrToString(it->ifa_netmask);
} else if (family == AF_INET6) {
int prefixLen = ipv6PrefixLenFromMask(it->ifa_netmask);
if (prefixLen >= 0) {
entry.netmask = std::to_string(prefixLen);
}
}
if (entry.isPointToPoint && it->ifa_dstaddr) {
entry.peerOrBroadcast = sockaddrToString(it->ifa_dstaddr);
} else if (entry.isBroadcast && it->ifa_ifu.ifu_broadaddr) {
entry.peerOrBroadcast = sockaddrToString(it->ifa_ifu.ifu_broadaddr);
}
iface.addresses.push_back(entry);
}
freeifaddrs(ifaddr);
std::vector<std::string> dumps;
dumps.reserve(interfaces.size());
for (const auto& pair : interfaces) {
dumps.push_back(buildIfconfigLikeBlock(pair.second, mtuMap, txQueueMap));
}
jobjectArray result = env->NewObjectArray(
static_cast<jsize>(dumps.size()),
stringCls,
nullptr
);
for (jsize i = 0; i < static_cast<jsize>(dumps.size()); ++i) {
jstring text = env->NewStringUTF(dumps[i].c_str());
env->SetObjectArrayElement(result, i, text);
env->DeleteLocalRef(text);
}
return result;
}

View file

@ -1,11 +1,8 @@
package com.cherepavel.vpndetector
import android.content.Context
import android.annotation.SuppressLint
import android.content.Intent
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Bundle
import android.view.MotionEvent
@ -21,17 +18,21 @@ import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.NestedScrollView
import com.cherepavel.vpndetector.detector.IfconfigTermuxLikeDetector
import com.cherepavel.vpndetector.detector.JavaInterfacesDetector
import com.cherepavel.vpndetector.detector.TrackedAppsDetector
import com.cherepavel.vpndetector.detector.TunnelNameMatcher
import androidx.lifecycle.lifecycleScope
import com.cherepavel.vpndetector.detector.DetectionEngine
import com.cherepavel.vpndetector.detector.IDetectionEngine
import com.cherepavel.vpndetector.ui.DetailSection
import com.cherepavel.vpndetector.ui.DetectionReport
import com.cherepavel.vpndetector.ui.ReportExportFormatter
import com.cherepavel.vpndetector.ui.ReportFormatter
import com.cherepavel.vpndetector.ui.SignalItem
import com.cherepavel.vpndetector.ui.SignalState
import com.cherepavel.vpndetector.util.TransportInfoFormatter
import com.cherepavel.vpndetector.ui.export.ReportExportBuilder
import com.cherepavel.vpndetector.ui.export.ReportExportFormatter
import com.cherepavel.vpndetector.util.nowString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter
class MainActivity : AppCompatActivity() {
@ -43,6 +44,8 @@ class MainActivity : AppCompatActivity() {
private lateinit var buttonRefresh: Button
private lateinit var buttonReport: Button
private lateinit var textLastUpdate: TextView
private lateinit var textVersion: TextView
private lateinit var textFooterInfo: TextView
private lateinit var cardTransportVpn: LinearLayout
private lateinit var textTransportState: TextView
@ -52,45 +55,29 @@ class MainActivity : AppCompatActivity() {
private lateinit var cardApiSignal1: LinearLayout
private lateinit var cardApiSignal2: LinearLayout
private lateinit var textApiSignalTitle1: TextView
private lateinit var textApiSignalTitle2: TextView
private lateinit var textApiSignalSource1: TextView
private lateinit var textApiSignalSource2: TextView
private lateinit var textApiSignalValue1: TextView
private lateinit var textApiSignalValue2: TextView
private lateinit var textApiSignalHint1: TextView
private lateinit var textApiSignalHint2: TextView
private lateinit var cardNativeSignal: LinearLayout
private lateinit var textNativeSignalTitle: TextView
private lateinit var textNativeSignalSource: TextView
private lateinit var textNativeSignalValue: TextView
private lateinit var textNativeSignalHint: TextView
private lateinit var textNativeDetails: TextView
private lateinit var scrollNativeDetails: NestedScrollView
private lateinit var containerExtraSections: LinearLayout
private lateinit var cardJavaSignal: LinearLayout
private lateinit var textJavaSignalTitle: TextView
private lateinit var textJavaSignalSource: TextView
private lateinit var textJavaSignalValue: TextView
private lateinit var textJavaSignalHint: TextView
private lateinit var textKnownApps: TextView
private lateinit var apiSignalCards: List<LinearLayout>
private lateinit var apiSignalTitles: List<TextView>
private lateinit var apiSignalSources: List<TextView>
private lateinit var apiSignalValues: List<TextView>
private lateinit var apiSignalHints: List<TextView>
private val javaInterfacesDetector by lazy { JavaInterfacesDetector() }
private val trackedAppsDetector by lazy { TrackedAppsDetector(this) }
private val connectivityManager by lazy {
getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
}
private val detectionEngine: IDetectionEngine by lazy {
DetectionEngine(this, connectivityManager)
}
private var lastDetectionReport: DetectionReport? = null
private var lastNativeDetailsRaw: String = ""
private var lastJavaTunnelNames: List<String> = emptyList()
private var lastInstalledVpnApps: List<String> = emptyList()
private var lastExportText: String = ""
private var detectionJob: Job? = null
private val createDocumentLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
@ -116,10 +103,17 @@ class MainActivity : AppCompatActivity() {
}
bindViews()
renderVersion()
renderFooter()
setupListeners()
refreshUi()
}
override fun onDestroy() {
detectionJob?.cancel()
super.onDestroy()
}
private fun bindViews() {
cardStatus = findViewById(R.id.cardStatus)
textVpnStatus = findViewById(R.id.textVpnStatus)
@ -128,6 +122,8 @@ class MainActivity : AppCompatActivity() {
buttonRefresh = findViewById(R.id.buttonRefresh)
buttonReport = findViewById(R.id.buttonReport)
textLastUpdate = findViewById(R.id.textLastUpdate)
textVersion = findViewById(R.id.textVersion)
textFooterInfo = findViewById(R.id.textFooterInfo)
cardTransportVpn = findViewById(R.id.cardTransportVpn)
textTransportState = findViewById(R.id.textTransportState)
@ -137,38 +133,21 @@ class MainActivity : AppCompatActivity() {
cardApiSignal1 = findViewById(R.id.cardApiSignal1)
cardApiSignal2 = findViewById(R.id.cardApiSignal2)
textApiSignalTitle1 = findViewById(R.id.textApiSignalTitle1)
textApiSignalTitle2 = findViewById(R.id.textApiSignalTitle2)
textApiSignalSource1 = findViewById(R.id.textApiSignalSource1)
textApiSignalSource2 = findViewById(R.id.textApiSignalSource2)
textApiSignalValue1 = findViewById(R.id.textApiSignalValue1)
textApiSignalValue2 = findViewById(R.id.textApiSignalValue2)
textApiSignalHint1 = findViewById(R.id.textApiSignalHint1)
textApiSignalHint2 = findViewById(R.id.textApiSignalHint2)
cardNativeSignal = findViewById(R.id.cardNativeSignal)
textNativeSignalTitle = findViewById(R.id.textNativeSignalTitle)
textNativeSignalSource = findViewById(R.id.textNativeSignalSource)
textNativeSignalValue = findViewById(R.id.textNativeSignalValue)
textNativeSignalHint = findViewById(R.id.textNativeSignalHint)
textNativeDetails = findViewById(R.id.textNativeDetails)
scrollNativeDetails = findViewById(R.id.scrollNativeDetails)
containerExtraSections = findViewById(R.id.containerExtraSections)
cardJavaSignal = findViewById(R.id.cardJavaSignal)
textJavaSignalTitle = findViewById(R.id.textJavaSignalTitle)
textJavaSignalSource = findViewById(R.id.textJavaSignalSource)
textJavaSignalValue = findViewById(R.id.textJavaSignalValue)
textJavaSignalHint = findViewById(R.id.textJavaSignalHint)
textKnownApps = findViewById(R.id.textKnownApps)
apiSignalCards = listOf(cardApiSignal1, cardApiSignal2)
apiSignalTitles = listOf(textApiSignalTitle1, textApiSignalTitle2)
apiSignalSources = listOf(textApiSignalSource1, textApiSignalSource2)
apiSignalValues = listOf(textApiSignalValue1, textApiSignalValue2)
apiSignalHints = listOf(textApiSignalHint1, textApiSignalHint2)
}
@SuppressLint("ClickableViewAccessibility")
private fun setupListeners() {
buttonRefresh.setOnClickListener { refreshUi() }
@ -176,6 +155,15 @@ class MainActivity : AppCompatActivity() {
showReportActions()
}
textFooterInfo.setOnClickListener {
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.repo_url))
)
)
}
scrollNativeDetails.setOnTouchListener { view, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN,
@ -192,71 +180,36 @@ class MainActivity : AppCompatActivity() {
}
}
private data class DetectionOutput(
val report: DetectionReport,
val exportText: String
)
private fun refreshUi() {
val connectivityManager =
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
detectionJob?.cancel()
buttonRefresh.isEnabled = false
buttonReport.isEnabled = false
val allNetworks = connectivityManager.allNetworks.orEmpty()
val activeNetwork = connectivityManager.activeNetwork
detectionJob = lifecycleScope.launch {
val output = withContext(Dispatchers.IO) { runDetection() }
renderReport(output.report)
renderLastUpdate()
lastExportText = output.exportText
buttonRefresh.isEnabled = true
buttonReport.isEnabled = true
}
}
val vpnNetworks = allNetworks.filter { hasTransportVpn(connectivityManager, it) }
val anyVpn = vpnNetworks.isNotEmpty()
val activeVpn = activeNetwork?.let { hasTransportVpn(connectivityManager, it) } ?: false
private fun runDetection(): DetectionOutput {
val snapshot = detectionEngine.detect()
val report = ReportFormatter.build(this, snapshot)
val preferredNetwork = vpnNetworks.firstOrNull() ?: activeNetwork ?: allNetworks.firstOrNull()
val exportReport = ReportExportBuilder.build(this, snapshot)
val exportText = ReportExportFormatter.buildText(exportReport)
val preferredLinkProperties: LinkProperties? =
preferredNetwork?.let(connectivityManager::getLinkProperties)
val preferredCapabilities: NetworkCapabilities? =
preferredNetwork?.let(connectivityManager::getNetworkCapabilities)
val interfaceName = preferredLinkProperties
?.interfaceName
?.takeIf { TunnelNameMatcher.looksLikeTunnelName(it) }
val transportInfoSummary =
TransportInfoFormatter.summarizeVpnTransportInfo(preferredCapabilities)
val nativeResult = IfconfigTermuxLikeDetector.detect()
val javaTunnelNames = javaInterfacesDetector.detectTunnelNames()
val installedVpnApps = trackedAppsDetector.detect()
.map { "${it.label} (${it.packageName})" }
val nativeTunnelNames = nativeResult.matchedInterfaces
.map { it.substringBefore(':').trim() }
.distinct()
val nativeDetails = nativeResult.allInterfaces
val report = ReportFormatter.build(
ReportFormatter.RawInput(
hasTransportVpnAny = anyVpn,
hasTransportVpnActive = activeVpn,
interfaceName = interfaceName,
transportInfoSummary = transportInfoSummary,
nativeTunnelNames = nativeTunnelNames,
nativeDetails = nativeDetails,
javaTunnelNames = javaTunnelNames,
installedVpnApps = installedVpnApps
)
)
renderReport(report)
renderLastUpdate()
lastDetectionReport = report
lastNativeDetailsRaw = report.nativeDetails
lastJavaTunnelNames = javaTunnelNames
lastInstalledVpnApps = installedVpnApps
lastExportText = ReportExportFormatter.buildText(
ReportExportFormatter.ExportInput(
report = report,
nativeDetailsRaw = report.nativeDetails,
javaTunnelNames = javaTunnelNames,
installedVpnApps = installedVpnApps
)
return DetectionOutput(
report = report,
exportText = exportText
)
}
@ -266,7 +219,7 @@ class MainActivity : AppCompatActivity() {
textVpnExplanation.text = report.overallExplanation
applySectionCardBackground(cardStatus)
applyStatusTextColor(textVpnStatus, report.overallState)
applyValueTextColor(textVpnStatus, report.overallState)
textTransportState.text = report.transportStateText
textTransportSubtitle.text = report.transportSubtitle
@ -278,31 +231,25 @@ class MainActivity : AppCompatActivity() {
applyTransportBadgeBackground(
view = textTransportAnyValue,
isDetected = report.transportAnyValue == "DETECTED"
isDetected = report.transportAnyDetected
)
applyTransportBadgeBackground(
view = textTransportActiveValue,
isDetected = report.transportActiveValue == "DETECTED"
isDetected = report.transportActiveDetected
)
renderApiSignals(report.apiSignals)
bindSignalCard(
card = cardNativeSignal,
titleView = textNativeSignalTitle,
sourceView = textNativeSignalSource,
valueView = textNativeSignalValue,
hintView = textNativeSignalHint,
item = report.nativeSignal
)
textNativeDetails.text = report.nativeDetails
renderExtraSections(report.extraSections)
bindSignalCard(
card = cardJavaSignal,
titleView = textJavaSignalTitle,
sourceView = textJavaSignalSource,
valueView = textJavaSignalValue,
hintView = textJavaSignalHint,
item = report.javaSignal
)
@ -319,23 +266,41 @@ class MainActivity : AppCompatActivity() {
bindSignalCard(
card = card,
titleView = apiSignalTitles[index],
sourceView = apiSignalSources[index],
valueView = apiSignalValues[index],
hintView = apiSignalHints[index],
item = signals[index]
)
}
}
private fun renderExtraSections(sections: List<DetailSection>) {
containerExtraSections.removeAllViews()
for (section in sections) {
val itemView = layoutInflater.inflate(
R.layout.common_item_detail_section,
containerExtraSections,
false
)
val titleView = itemView.findViewById<TextView>(R.id.textDetailTitle)
val bodyView = itemView.findViewById<TextView>(R.id.textDetailBody)
titleView.text = section.title
bodyView.text = section.body
applyValueTextColor(titleView, section.state)
containerExtraSections.addView(itemView)
}
}
private fun bindSignalCard(
card: LinearLayout,
titleView: TextView,
sourceView: TextView,
valueView: TextView,
hintView: TextView,
item: SignalItem
) {
val titleView = card.findViewById<TextView>(R.id.textSignalTitle)
val sourceView = card.findViewById<TextView>(R.id.textSignalSource)
val valueView = card.findViewById<TextView>(R.id.textSignalValue)
val hintView = card.findViewById<TextView>(R.id.textSignalHint)
titleView.text = item.title
sourceView.text = item.source
valueView.text = item.value
@ -345,18 +310,24 @@ class MainActivity : AppCompatActivity() {
applyValueTextColor(valueView, item.state)
}
private fun hasTransportVpn(
connectivityManager: ConnectivityManager,
network: Network
): Boolean {
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
}
@SuppressLint("SetTextI18n")
private fun renderLastUpdate() {
textLastUpdate.text = "Last update: ${nowString()}"
}
private fun renderVersion() {
textVersion.text =
"${BuildConfig.VERSION_NAME}${BuildConfig.GIT_HASH}${BuildConfig.BUILD_TYPE}"
}
private fun renderFooter() {
val repoText = getString(R.string.repo_url)
.removePrefix("https://")
.removePrefix("http://")
textFooterInfo.text = "${getString(R.string.source_code_label)} $repoText"
}
private fun showReportActions() {
if (lastExportText.isBlank()) {
refreshUi()
@ -417,10 +388,6 @@ class MainActivity : AppCompatActivity() {
view.setTextColor(state.toSignalColor())
}
private fun applyStatusTextColor(view: TextView, state: SignalState) {
view.setTextColor(state.toSignalColor())
}
private fun applyTransportBadgeBackground(view: TextView, isDetected: Boolean) {
view.setBackgroundResource(
if (isDetected) {

View file

@ -1,64 +0,0 @@
package com.cherepavel.vpndetector.detector
data class IfconfigInterfaceInfo(
val name: String?,
val flags: String?,
val address: String?,
val netmask: String?,
val broadcast: String?,
val isUp: Boolean
) {
fun normalizedName(): String = name?.trim().orEmpty()
fun hasUsableAddress(): Boolean {
val value = address?.trim().orEmpty()
return value.isNotEmpty() && value != "-" && value != "null"
}
fun isLoopbackLike(): Boolean {
val lowered = normalizedName().lowercase()
return lowered == "lo" || lowered.startsWith("lo")
}
fun isPointToPointLike(): Boolean {
val loweredFlags = flags?.lowercase().orEmpty()
return loweredFlags.contains("pointopoint") ||
loweredFlags.contains("point-to-point")
}
fun looksLikeTunnel(): Boolean {
val normalized = normalizedName()
return TunnelNameMatcher.looksLikeTunnelName(normalized) ||
(!isLoopbackLike() && hasUsableAddress() && isPointToPointLike())
}
fun toDisplayBlock(): String {
val displayName = normalizedName().ifBlank { "unknown" }
val displayFlags = flags?.takeIf { it.isNotBlank() } ?: ""
val displayAddress = address ?: "-"
val displayNetmask = netmask ?: "-"
val displayBroadcast = broadcast ?: "-"
val upDown = if (isUp) "UP" else "DOWN"
return buildString {
append(displayName)
append(" ")
append(upDown)
if (displayFlags.isNotBlank()) {
append(" flags=")
append(displayFlags)
}
append("\n")
append(" addr=")
append(displayAddress)
append("\n")
append(" mask=")
append(displayNetmask)
append("\n")
append(" broadcast=")
append(displayBroadcast)
}
}
}

View file

@ -1,32 +0,0 @@
package com.cherepavel.vpndetector.detector
object IfconfigTermuxLikeDetector {
init {
try {
System.loadLibrary("ifconfigdetector")
} catch (_: Throwable) {
}
}
external fun getInterfacesNative(): Array<String>
fun detect(): IfconfigTermuxLikeResult {
val allBlocks = try {
getInterfacesNative().toList()
} catch (_: Throwable) {
emptyList()
}
val matched = allBlocks.filter { block ->
val firstLine = block.lineSequence().firstOrNull().orEmpty()
TunnelNameMatcher.looksLikeTunnelName(firstLine.substringBefore(':').trim())
}
return IfconfigTermuxLikeResult(
vpnLikely = matched.isNotEmpty(),
matchedInterfaces = matched,
allInterfaces = allBlocks
)
}
}

View file

@ -1,51 +0,0 @@
package com.cherepavel.vpndetector.detector
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import com.cherepavel.vpndetector.model.TrackedApp
class TrackedAppsDetector(
private val context: Context
) {
fun detect(): List<TrackedApp> {
return TRACKED_APPS.filter { isAppInstalled(it.packageName) }
}
private fun isAppInstalled(packageName: String): Boolean {
return try {
val pm = context.packageManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
pm.getPackageInfo(packageName, 0)
}
true
} catch (_: Throwable) {
false
}
}
companion object {
private val TRACKED_APPS = listOf(
TrackedApp("com.github.dyhkwong.sagernet", "ExclaveVPN"),
TrackedApp("com.v2ray.ang", "v2rayNG"),
TrackedApp("org.amnezia.awg", "AmneziaWG"),
TrackedApp("org.amnezia.vpn", "Amnezia VPN"),
TrackedApp("de.blinkt.openvpn", "OpenVPN for Android"),
TrackedApp("net.openvpn.openvpn", "OpenVPN Connect"),
TrackedApp("com.wireguard.android", "WireGuard"),
TrackedApp("com.cloudflare.onedotonedotonedotone", "Cloudflare WARP"),
TrackedApp("com.psiphon3", "Psiphon"),
TrackedApp("app.hiddify.com", "Hiddify"),
TrackedApp("io.nekohasekai.sfa", "SFA"),
TrackedApp("com.nordvpn.android", "NordVPN"),
TrackedApp("com.expressvpn.vpn", "ExpressVPN"),
TrackedApp("com.protonvpn.android", "Proton VPN"),
TrackedApp("free.vpn.unblock.proxy.turbovpn", "Turbo VPN"),
TrackedApp("com.zaneschepke.wireguardautotunnel", "WG Tunnel"),
TrackedApp("moe.nb4a", "NekoBox")
)
}
}

View file

@ -1,63 +0,0 @@
package com.cherepavel.vpndetector.detector
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import com.cherepavel.vpndetector.model.VpnDetectionResult
import com.cherepavel.vpndetector.model.VpnNetworkInfo
import com.cherepavel.vpndetector.util.TransportInfoFormatter
class VpnDetector(
private val context: Context
) {
fun detect(): VpnDetectionResult {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = cm.activeNetwork
val activeCaps = activeNetwork?.let(cm::getNetworkCapabilities)
val allNetworks = cm.allNetworks.toList()
val vpnNetworks = allNetworks.mapNotNull { network ->
val caps = cm.getNetworkCapabilities(network) ?: return@mapNotNull null
if (!caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return@mapNotNull null
val linkProps = cm.getLinkProperties(network)
VpnNetworkInfo(
interfaceName = linkProps?.interfaceName,
transports = extractTransports(caps),
capabilities = extractCapabilities(caps),
transportInfoSummary = TransportInfoFormatter.summarizeVpnTransportInfo(caps)
)
}
return VpnDetectionResult(
activeNetworkPresent = activeNetwork != null,
activeNetworkIsVpn = activeCaps?.hasTransport(NetworkCapabilities.TRANSPORT_VPN),
anyNetworkHasVpnTransport = vpnNetworks.isNotEmpty(),
activeNetworkHasInternet = activeCaps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true,
vpnNetworks = vpnNetworks
)
}
private fun extractTransports(caps: NetworkCapabilities): List<String> {
return buildList {
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) add("WIFI")
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) add("CELLULAR")
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) add("ETHERNET")
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) add("BLUETOOTH")
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) add("VPN")
}
}
private fun extractCapabilities(caps: NetworkCapabilities): List<String> {
return buildList {
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) add("INTERNET")
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) add("VALIDATED")
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED)) add("TRUSTED")
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)) add("NOT_RESTRICTED")
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) add("NOT_VPN")
}
}
}

View file

@ -1,9 +0,0 @@
package com.cherepavel.vpndetector.model
data class VpnDetectionResult(
val activeNetworkPresent: Boolean,
val activeNetworkIsVpn: Boolean?,
val anyNetworkHasVpnTransport: Boolean,
val activeNetworkHasInternet: Boolean,
val vpnNetworks: List<VpnNetworkInfo>
)

View file

@ -1,8 +0,0 @@
package com.cherepavel.vpndetector.model
data class VpnNetworkInfo(
val interfaceName: String?,
val transports: List<String>,
val capabilities: List<String>,
val transportInfoSummary: String?
)

View file

@ -0,0 +1,29 @@
package com.cherepavel.vpndetector.ui
data class DetailSection(
val title: String,
val body: String,
val state: SignalState = SignalState.NEUTRAL
)
data class DetectionReport(
val overallTitle: String,
val overallSummary: String,
val overallExplanation: String,
val overallState: SignalState,
val transportCardState: SignalState,
val transportStateText: String,
val transportSubtitle: String,
val transportAnyValue: String,
val transportActiveValue: String,
val transportAnyDetected: Boolean,
val transportActiveDetected: Boolean,
val apiSignals: List<SignalItem>,
val nativeSignal: SignalItem,
val nativeDetails: String,
val extraSections: List<DetailSection>,
val javaSignal: SignalItem,
val knownAppsText: String
)

View file

@ -1,70 +0,0 @@
package com.cherepavel.vpndetector.ui
import com.cherepavel.vpndetector.util.nowString
object ReportExportFormatter {
data class ExportInput(
val report: DetectionReport,
val nativeDetailsRaw: String,
val javaTunnelNames: List<String>,
val installedVpnApps: List<String>
)
fun buildText(input: ExportInput): String {
val report = input.report
return buildString {
appendLine("VPN Detector Report")
appendLine("Generated: ${nowString()}")
appendLine()
appendLine("=== OVERALL STATUS ===")
appendLine(report.overallTitle)
appendLine(report.overallSummary)
appendLine(report.overallExplanation)
appendLine()
appendLine("=== OFFICIAL ANDROID API ===")
appendLine("TRANSPORT_VPN across all networks: ${report.transportAnyValue}")
appendLine("TRANSPORT_VPN active network only: ${report.transportActiveValue}")
appendLine("Transport state: ${report.transportStateText}")
appendLine("Transport subtitle: ${report.transportSubtitle}")
appendLine()
if (report.apiSignals.isNotEmpty()) {
appendLine("API signals:")
report.apiSignals.forEach { signal ->
appendLine("- ${signal.title}")
appendLine(" source: ${signal.source}")
appendLine(" value: ${signal.value}")
appendLine(" hint: ${signal.hint}")
}
appendLine()
}
appendLine("=== NATIVE LOW-LEVEL ENUMERATION ===")
appendLine("Signal value: ${report.nativeSignal.value}")
appendLine("Signal hint: ${report.nativeSignal.hint}")
appendLine()
appendLine(input.nativeDetailsRaw)
appendLine()
appendLine("=== JAVA INTERFACE ENUMERATION ===")
appendLine("Signal value: ${report.javaSignal.value}")
appendLine("Signal hint: ${report.javaSignal.hint}")
if (input.javaTunnelNames.isNotEmpty()) {
appendLine("Matched tunnel-like names:")
input.javaTunnelNames.forEach { appendLine("- $it") }
}
appendLine()
appendLine("=== DETECTED VPN APPS ===")
if (input.installedVpnApps.isEmpty()) {
appendLine("No known VPN-related apps from the tracked list are installed.")
} else {
input.installedVpnApps.forEach { appendLine("- $it") }
}
}.trim()
}
}

View file

@ -1,103 +1,317 @@
package com.cherepavel.vpndetector.ui
import android.content.Context
import com.cherepavel.vpndetector.R
import com.cherepavel.vpndetector.detector.TunnelNameMatcher
data class DetectionReport(
val overallTitle: String,
val overallSummary: String,
val overallExplanation: String,
val overallState: SignalState,
val transportCardState: SignalState,
val transportStateText: String,
val transportSubtitle: String,
val transportAnyValue: String,
val transportActiveValue: String,
val apiSignals: List<SignalItem>,
val nativeSignal: SignalItem,
val nativeDetails: String,
val javaSignal: SignalItem,
val knownAppsText: String
)
import com.cherepavel.vpndetector.model.DetectionConfidence
import com.cherepavel.vpndetector.model.DetectionSnapshot
import com.cherepavel.vpndetector.model.DetectionStatus
object ReportFormatter {
data class RawInput(
val hasTransportVpnAny: Boolean,
val hasTransportVpnActive: Boolean,
val interfaceName: String?,
val transportInfoSummary: String?,
val nativeTunnelNames: List<String>,
val nativeDetails: List<String>,
val javaTunnelNames: List<String>,
val installedVpnApps: List<String>
)
fun build(context: Context, snapshot: DetectionSnapshot): DetectionReport {
val anyVpn = snapshot.hasTransportVpnAny
val activeVpn = snapshot.hasTransportVpnActive
fun build(input: RawInput): DetectionReport {
val anyVpn = input.hasTransportVpnAny
val activeVpn = input.hasTransportVpnActive
val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(input.interfaceName)
val transportInfoDetected = !input.transportInfoSummary.isNullOrBlank()
val nativeDetected = input.nativeTunnelNames.isNotEmpty()
val javaDetected = input.javaTunnelNames.isNotEmpty()
val appsDetected = input.installedVpnApps.isNotEmpty()
val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(snapshot.rawInterfaceName)
val transportInfoDetected = !snapshot.transportInfoSummary.isNullOrBlank()
val dnsDetected = snapshot.internalDnsServers.isNotEmpty()
val policyDetected =
snapshot.activeNetworkNotVpn == false || snapshot.preferredNetworkNotVpn == false
val nativeDetected = snapshot.nativeTunnelNames.isNotEmpty()
val javaDetected = snapshot.javaTunnelNames.isNotEmpty()
val appsDetected = snapshot.installedVpnApps.isNotEmpty()
val overall = buildOverallBlock(
context = context,
snapshot = snapshot,
activeVpn = activeVpn,
anyVpn = anyVpn,
interfaceDetected = interfaceDetected,
transportInfoDetected = transportInfoDetected,
dnsDetected = dnsDetected,
policyDetected = policyDetected,
nativeDetected = nativeDetected,
javaDetected = javaDetected,
appsDetected = appsDetected
)
val apiSignals = buildApiSignals(
interfaceName = input.interfaceName,
context = context,
rawInterfaceName = snapshot.rawInterfaceName,
interfaceDetected = interfaceDetected,
transportInfoSummary = input.transportInfoSummary,
transportInfoSummary = snapshot.transportInfoSummary,
transportInfoDetected = transportInfoDetected,
activeVpn = activeVpn,
anyVpn = anyVpn
)
val nativeSignal = SignalItem(
title = "Tunnel-like interfaces",
source = "Native getifaddrs() enumeration",
value = input.nativeTunnelNames.ifEmpty { listOf("none") }.joinToString(", "),
title = context.getString(R.string.signal_title_tunnel_like_interfaces),
source = context.getString(R.string.signal_source_native),
value = snapshot.nativeTunnelNames.ifEmpty {
listOf(context.getString(R.string.report_value_none))
}.joinToString(", "),
state = if (nativeDetected) SignalState.WARNING else SignalState.NEGATIVE,
hint = if (nativeDetected) {
"Native enumeration found interfaces whose names or properties look tunnel-like."
context.getString(R.string.signal_hint_native_found)
} else {
"Native enumeration did not find any tunnel-like interfaces."
context.getString(R.string.signal_hint_native_missing)
}
)
val javaSignal = SignalItem(
title = "Tunnel-like interfaces",
source = "Java NetworkInterface enumeration",
value = input.javaTunnelNames.ifEmpty { listOf("none") }.joinToString(", "),
title = context.getString(R.string.signal_title_tunnel_like_interfaces),
source = context.getString(R.string.signal_source_java),
value = snapshot.javaTunnelNames.ifEmpty {
listOf(context.getString(R.string.report_value_none))
}.joinToString(", "),
state = if (javaDetected) SignalState.WARNING else SignalState.NEGATIVE,
hint = if (javaDetected) {
"Java network enumeration found interface names that look like VPN or tunnel interfaces."
context.getString(R.string.signal_hint_java_found)
} else {
"Java network enumeration did not find any tunnel-like interface names."
context.getString(R.string.signal_hint_java_missing)
}
)
val nativeDetailsText = if (input.nativeDetails.isEmpty()) {
"No interfaces were returned by the native detector."
} else {
input.nativeDetails.joinToString(separator = "\n\n")
val nativeDetailsText = buildString {
if (snapshot.nativeError != null) {
appendLine(
context.getString(
R.string.native_error_format,
snapshot.nativeError
)
)
appendLine()
}
if (snapshot.nativeDetails.isNotEmpty()) {
append(snapshot.nativeDetails.joinToString(separator = "\n\n"))
} else if (snapshot.nativeError == null) {
append(context.getString(R.string.native_details_empty))
}
}.trim()
val extraSections = buildList {
if (snapshot.tunTypeInterfaces.isNotEmpty()) {
add(
DetailSection(
title = context.getString(R.string.section_tun_interfaces),
body = snapshot.tunTypeInterfaces.joinToString("\n"),
state = SignalState.WARNING
)
)
}
if (snapshot.lowMtuInterfaces.isNotEmpty()) {
add(
DetailSection(
title = context.getString(R.string.section_low_mtu_interfaces),
body = snapshot.lowMtuInterfaces.joinToString("\n"),
state = SignalState.WARNING
)
)
}
if (snapshot.vpnRoutes.isNotEmpty()) {
add(
DetailSection(
title = context.getString(R.string.section_vpn_routes),
body = snapshot.vpnRoutes.joinToString("\n"),
state = SignalState.WARNING
)
)
}
if (snapshot.vpnDnsServers.isNotEmpty()) {
add(
DetailSection(
title = context.getString(R.string.section_vpn_dns_servers),
body = snapshot.vpnDnsServers.joinToString("\n"),
state = SignalState.WARNING
)
)
}
if (snapshot.allDnsServers.isNotEmpty()) {
add(
DetailSection(
title = context.getString(R.string.section_dns_all),
body = snapshot.allDnsServers.joinToString("\n"),
state = SignalState.NEUTRAL
)
)
}
if (snapshot.internalDnsServers.isNotEmpty()) {
add(
DetailSection(
title = context.getString(R.string.section_dns_internal),
body = snapshot.internalDnsServers.joinToString("\n"),
state = SignalState.WARNING
)
)
}
if (snapshot.contextualInternalDnsServers.isNotEmpty()) {
add(
DetailSection(
title = context.getString(R.string.section_dns_contextual),
body = snapshot.contextualInternalDnsServers.joinToString("\n"),
state = SignalState.NEUTRAL
)
)
}
if (snapshot.privateDnsActive || snapshot.privateDnsServerName != null) {
val privateDnsState = if (snapshot.privateDnsActive) {
context.getString(R.string.private_dns_active)
} else {
context.getString(R.string.private_dns_inactive)
}
val privateDnsBody = snapshot.privateDnsServerName?.let {
context.getString(R.string.private_dns_with_host, privateDnsState, it)
} ?: privateDnsState
add(
DetailSection(
title = context.getString(R.string.section_private_dns),
body = privateDnsBody,
state = SignalState.NEUTRAL
)
)
}
if (snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null) {
val activeText = snapshot.activeNetworkNotVpn?.toString()
?: context.getString(R.string.report_value_unknown)
val preferredText = snapshot.preferredNetworkNotVpn?.toString()
?: context.getString(R.string.report_value_unknown)
add(
DetailSection(
title = context.getString(R.string.section_not_vpn),
body = context.getString(
R.string.not_vpn_body,
activeText,
preferredText
),
state = if (
snapshot.activeNetworkNotVpn == false ||
snapshot.preferredNetworkNotVpn == false
) {
SignalState.WARNING
} else {
SignalState.NEUTRAL
}
)
)
}
snapshot.vpnBandwidthSummary?.let {
add(
DetailSection(
title = context.getString(R.string.section_vpn_bandwidth),
body = it,
state = SignalState.NEUTRAL
)
)
}
if (snapshot.kernelRoutes.isNotEmpty()) {
add(
DetailSection(
title = context.getString(R.string.section_kernel_routes_v4),
body = snapshot.kernelRoutes.joinToString("\n"),
state = SignalState.NEUTRAL
)
)
}
if (snapshot.kernelIpv6Routes.isNotEmpty()) {
add(
DetailSection(
title = context.getString(R.string.section_kernel_routes_v6),
body = snapshot.kernelIpv6Routes.joinToString("\n"),
state = SignalState.NEUTRAL
)
)
}
if (snapshot.vpnPermissionGranted) {
add(
DetailSection(
title = context.getString(R.string.section_vpn_permission),
body = context.getString(R.string.vpn_permission_body),
state = SignalState.WARNING
)
)
}
if (snapshot.lockdownLikely) {
add(
DetailSection(
title = context.getString(R.string.section_lockdown),
body = context.getString(R.string.lockdown_body),
state = SignalState.WARNING
)
)
}
if (snapshot.knownVpnDnsMatches.isNotEmpty()) {
add(
DetailSection(
title = context.getString(R.string.section_known_vpn_dns),
body = snapshot.knownVpnDnsMatches.joinToString("\n"),
state = SignalState.WARNING
)
)
}
if (snapshot.workProfileCount > 1 || snapshot.isManagedProfile) {
add(
DetailSection(
title = context.getString(R.string.section_work_profile),
body = buildString {
if (snapshot.isManagedProfile) {
appendLine(context.getString(R.string.managed_profile_body))
}
if (snapshot.workProfileCount > 1) {
append(
context.getString(
R.string.work_profile_count_body,
snapshot.workProfileCount
)
)
}
}.trim(),
state = SignalState.NEUTRAL
)
)
}
}
val appsText = if (input.installedVpnApps.isEmpty()) {
"No known VPN-related apps from the tracked list are installed."
} else {
input.installedVpnApps.joinToString(separator = "\n") { "$it" }
}
val appsText = buildString {
if (snapshot.installedVpnApps.isEmpty() && snapshot.unknownDynamicApps.isEmpty()) {
append(context.getString(R.string.apps_none_detected))
} else {
snapshot.installedVpnApps.forEach { appendLine("$it") }
if (snapshot.unknownDynamicApps.isNotEmpty()) {
if (snapshot.installedVpnApps.isNotEmpty()) appendLine()
appendLine(context.getString(R.string.apps_detected_via_vpn_service))
snapshot.unknownDynamicApps.forEach { appendLine("$it") }
}
}
if (snapshot.trackedAppsErrors.isNotEmpty()) {
if (snapshot.installedVpnApps.isNotEmpty() || snapshot.unknownDynamicApps.isNotEmpty()) {
appendLine()
}
appendLine(context.getString(R.string.apps_check_errors))
snapshot.trackedAppsErrors.forEach { (pkg, err) ->
appendLine("$pkg: $err")
}
}
}.trimEnd()
return DetectionReport(
overallTitle = overall.title,
@ -108,103 +322,145 @@ object ReportFormatter {
transportCardState = overall.transportState,
transportStateText = overall.transportText,
transportSubtitle = overall.transportSubtitle,
transportAnyValue = if (anyVpn) "DETECTED" else "NOT DETECTED",
transportActiveValue = if (activeVpn) "DETECTED" else "NOT DETECTED",
transportAnyValue = if (anyVpn) {
context.getString(R.string.report_value_detected)
} else {
context.getString(R.string.report_value_not_detected)
},
transportActiveValue = if (activeVpn) {
context.getString(R.string.report_value_detected)
} else {
context.getString(R.string.report_value_not_detected)
},
transportAnyDetected = anyVpn,
transportActiveDetected = activeVpn,
apiSignals = apiSignals,
nativeSignal = nativeSignal,
nativeDetails = nativeDetailsText,
extraSections = extraSections,
javaSignal = javaSignal,
knownAppsText = appsText
)
}
private fun buildOverallBlock(
context: Context,
snapshot: DetectionSnapshot,
activeVpn: Boolean,
anyVpn: Boolean,
interfaceDetected: Boolean,
transportInfoDetected: Boolean,
dnsDetected: Boolean,
policyDetected: Boolean,
nativeDetected: Boolean,
javaDetected: Boolean,
appsDetected: Boolean
): OverallBlock {
val confidenceText = when (snapshot.assessment.confidence) {
DetectionConfidence.CONFIRMED -> context.getString(R.string.report_confidence_confirmed)
DetectionConfidence.LIKELY -> context.getString(R.string.report_confidence_likely)
DetectionConfidence.WEAK_SIGNAL -> context.getString(R.string.report_confidence_weak_signal)
DetectionConfidence.NO_EVIDENCE -> context.getString(R.string.report_confidence_no_evidence)
}
val scoreText = context.getString(
R.string.report_confidence_format,
confidenceText,
snapshot.assessment.score
)
return when {
activeVpn -> {
snapshot.assessment.status == DetectionStatus.ACTIVE_VPN || activeVpn -> {
val lockdown = snapshot.lockdownLikely
val summary = context.getString(R.string.report_summary_vpn_detected) +
if (lockdown) " " + context.getString(R.string.report_summary_lockdown_suffix) else ""
OverallBlock(
title = "VPN detected",
summary = "The active network is explicitly marked as VPN by Android.",
explanation = "This is the strongest signal in the app: Android reports TRANSPORT_VPN on the network currently in use.",
title = context.getString(
if (lockdown) R.string.report_title_vpn_detected_lockdown
else R.string.report_title_vpn_detected
),
summary = summary,
explanation = context.getString(R.string.report_explanation_vpn_detected) +
" " + scoreText,
state = SignalState.POSITIVE,
transportState = SignalState.POSITIVE,
transportText = "VPN DETECTED",
transportSubtitle = "TRANSPORT_VPN is present on the active network."
transportText = context.getString(R.string.report_transport_text_vpn_detected),
transportSubtitle = context.getString(R.string.report_transport_subtitle_vpn_detected)
)
}
anyVpn -> {
snapshot.assessment.status == DetectionStatus.SPLIT_TUNNEL || anyVpn -> {
OverallBlock(
title = "VPN present outside active path",
summary = "Android sees a VPN network in the system, but not on the current active network.",
explanation = "This often matches bypass or split-tunnel behavior: a VPN exists, but current traffic may not be fully routed through it.",
title = context.getString(R.string.report_title_split_tunnel),
summary = context.getString(R.string.report_summary_split_tunnel),
explanation = context.getString(R.string.report_explanation_split_tunnel) +
" " + scoreText,
state = SignalState.SEMI,
transportState = SignalState.SEMI,
transportText = "SPLIT / BYPASS",
transportSubtitle = "A VPN-related transport exists system-wide, but it is not the current active path."
transportText = context.getString(R.string.report_transport_text_split_tunnel),
transportSubtitle = context.getString(R.string.report_transport_subtitle_split_tunnel)
)
}
interfaceDetected || transportInfoDetected -> {
interfaceDetected || transportInfoDetected || dnsDetected || policyDetected -> {
OverallBlock(
title = "VPN-related API signal",
summary = "Android APIs still expose VPN-like indicators even though active TRANSPORT_VPN is absent.",
explanation = "This is weaker than a direct VPN transport flag, but it still suggests that VPN-related state may be visible through official APIs.",
title = context.getString(R.string.report_title_api_signal),
summary = context.getString(R.string.report_summary_api_signal),
explanation = context.getString(R.string.report_explanation_api_signal) +
" " + scoreText,
state = SignalState.WARNING,
transportState = SignalState.WARNING,
transportText = "API SIGNAL",
transportSubtitle = "No active TRANSPORT_VPN, but Android APIs still expose VPN-related information."
transportText = context.getString(R.string.report_transport_text_api_signal),
transportSubtitle = context.getString(R.string.report_transport_subtitle_api_signal)
)
}
nativeDetected || javaDetected -> {
OverallBlock(
title = "Low-level tunnel signal",
summary = "No primary Android VPN signal was found, but tunnel-like interfaces were still discovered.",
explanation = "This usually means only low-level interface heuristics fired. It is useful as an additional hint, but weaker than official Android VPN signals.",
title = context.getString(R.string.report_title_low_level),
summary = context.getString(R.string.report_summary_low_level),
explanation = context.getString(R.string.report_explanation_low_level) +
" " + scoreText,
state = SignalState.WARNING,
transportState = SignalState.NEGATIVE,
transportText = "NOT DETECTED",
transportSubtitle = "Android did not report VPN transport on the active path."
transportText = context.getString(R.string.report_transport_text_not_detected),
transportSubtitle = context.getString(R.string.report_transport_subtitle_not_on_active_path)
)
}
appsDetected -> {
snapshot.assessment.status == DetectionStatus.APPS_PRESENT || appsDetected -> {
OverallBlock(
title = "Detected VPN apps",
summary = "No active VPN network signal was found, but known VPN-related apps are installed on the device.",
explanation = "Installed VPN apps do not prove that a VPN is currently active, but they are still a relevant contextual signal.",
title = context.getString(R.string.report_title_apps_present),
summary = context.getString(R.string.report_summary_apps_present),
explanation = context.getString(R.string.report_explanation_apps_present) +
" " + scoreText,
state = SignalState.WARNING,
transportState = SignalState.NEGATIVE,
transportText = "NOT DETECTED",
transportSubtitle = "Android did not report VPN transport on the active path."
transportText = context.getString(R.string.report_transport_text_not_detected),
transportSubtitle = context.getString(R.string.report_transport_subtitle_not_on_active_path)
)
}
else -> {
OverallBlock(
title = "No VPN detected",
summary = "The app did not find any high-level or low-level VPN indicators.",
explanation = "Neither official Android network APIs nor interface enumeration produced a VPN-related signal.",
title = context.getString(R.string.report_title_no_vpn),
summary = context.getString(R.string.report_summary_no_vpn),
explanation = context.getString(R.string.report_explanation_no_vpn) +
" " + scoreText,
state = SignalState.NEGATIVE,
transportState = SignalState.NEGATIVE,
transportText = "NOT DETECTED",
transportSubtitle = "No VPN transport was reported by Android."
transportText = context.getString(R.string.report_transport_text_not_detected),
transportSubtitle = context.getString(R.string.report_transport_subtitle_not_detected)
)
}
}
}
private fun buildApiSignals(
interfaceName: String?,
context: Context,
rawInterfaceName: String?,
interfaceDetected: Boolean,
transportInfoSummary: String?,
transportInfoDetected: Boolean,
@ -225,38 +481,42 @@ object ReportFormatter {
val interfaceHint = when {
interfaceDetected && activeVpn ->
"The interface name itself looks like a tunnel device and matches the active VPN state."
context.getString(R.string.signal_hint_interface_active)
interfaceDetected && anyVpn ->
"The interface name looks tunnel-like and is consistent with a VPN being present somewhere in the system."
context.getString(R.string.signal_hint_interface_any)
interfaceDetected ->
"The interface name looks tunnel-like, but Android does not currently mark the active path as VPN."
context.getString(R.string.signal_hint_interface_only)
rawInterfaceName != null ->
context.getString(R.string.signal_hint_interface_normal)
else ->
"The interface name does not look like a typical VPN or tunnel interface."
context.getString(R.string.signal_hint_interface_missing)
}
val transportInfoHint = when {
transportInfoDetected && activeVpn ->
"Android returned transport info alongside an active VPN transport."
context.getString(R.string.signal_hint_transport_active)
transportInfoDetected && anyVpn ->
"Transport info is present and aligns with a VPN existing somewhere in the network stack."
context.getString(R.string.signal_hint_transport_any)
transportInfoDetected ->
"Transport info is present, but without a direct active VPN transport flag."
context.getString(R.string.signal_hint_transport_only)
else ->
"No VPN-related transport info was exposed here."
context.getString(R.string.signal_hint_transport_missing)
}
return listOf(
SignalItem(
title = "Interface name",
source = "LinkProperties.getInterfaceName()",
value = interfaceName ?: "none",
title = context.getString(R.string.signal_title_interface_name),
source = context.getString(R.string.signal_source_interface_name),
value = rawInterfaceName?.let(::softWrapToken)
?: context.getString(R.string.report_value_none),
state = interfaceState,
hint = interfaceHint
),
SignalItem(
title = "Transport info",
source = "NetworkCapabilities.getTransportInfo()",
value = transportInfoSummary ?: "none",
title = context.getString(R.string.signal_title_transport_info),
source = context.getString(R.string.signal_source_transport_info),
value = formatCompactTransportInfo(transportInfoSummary)
?: context.getString(R.string.report_value_none),
state = transportInfoState,
hint = transportInfoHint
)
@ -272,4 +532,32 @@ object ReportFormatter {
val transportText: String,
val transportSubtitle: String
)
private fun formatCompactTransportInfo(summary: String?): String? {
if (summary.isNullOrBlank()) return null
val normalized = summary
.replace("VpnTransportInfo", "VPN")
.replace("type=", "")
.replace("PLATFORM", "platform")
.replace("(", " (")
.trim()
return when {
normalized.contains("VPN", ignoreCase = true) &&
normalized.contains("platform", ignoreCase = true) -> "VPN (platform)"
normalized.contains("VPN", ignoreCase = true) -> "VPN"
else -> softWrapToken(normalized)
}
}
private fun softWrapToken(value: String): String {
return value
.replace("(", "(\u200B")
.replace(")", "\u200B)")
.replace("/", "/\u200B")
.replace("-", "-\u200B")
.replace("_", "_\u200B")
.replace(",", ",\u200B")
}
}

View file

@ -0,0 +1,30 @@
package com.cherepavel.vpndetector.ui.export
data class ExportReport(
val title: String,
val generatedAt: String,
val buildInfo: String,
val sourceCodeUrl: String,
val sections: List<ExportSection>
)
data class ExportSection(
val title: String,
val items: List<ExportItem>
)
sealed class ExportItem {
data class Field(
val label: String,
val value: String
) : ExportItem()
data class ListBlock(
val label: String,
val values: List<String>
) : ExportItem()
data class Paragraph(
val text: String
) : ExportItem()
}

View file

@ -0,0 +1,516 @@
package com.cherepavel.vpndetector.ui.export
import android.content.Context
import com.cherepavel.vpndetector.BuildConfig
import com.cherepavel.vpndetector.R
import com.cherepavel.vpndetector.detector.TunnelNameMatcher
import com.cherepavel.vpndetector.model.DetectionConfidence
import com.cherepavel.vpndetector.model.DetectionSnapshot
import com.cherepavel.vpndetector.model.DetectionStatus
import com.cherepavel.vpndetector.util.nowString
object ReportExportBuilder {
fun build(
context: Context,
snapshot: DetectionSnapshot
): ExportReport {
val sections = buildList {
add(
ExportSection(
title = "OVERALL STATUS",
items = buildOverallItems(snapshot)
)
)
add(
ExportSection(
title = "OFFICIAL ANDROID API",
items = buildOfficialApiItems(snapshot)
)
)
buildVpnNetworkDetailsSection(snapshot)?.let { add(it) }
buildAdditionalSignalsSection(snapshot)?.let { add(it) }
add(buildNativeSection(snapshot))
add(buildJavaSection(snapshot))
add(buildAppsSection(snapshot))
buildAdvancedSignalsSection(snapshot)?.let { add(it) }
}
return ExportReport(
title = "VPN Detector Report",
generatedAt = nowString(),
buildInfo = buildBuildInfo(),
sourceCodeUrl = context.getString(R.string.repo_url),
sections = sections
)
}
private fun buildBuildInfo(): String {
return "${BuildConfig.VERSION_NAME}${BuildConfig.GIT_HASH}${BuildConfig.BUILD_TYPE}"
}
private fun buildOverallItems(snapshot: DetectionSnapshot): List<ExportItem> {
val overall = buildOverallBlock(snapshot)
return buildList {
add(ExportItem.Paragraph(overall.title))
add(ExportItem.Paragraph(overall.summary))
add(ExportItem.Paragraph(overall.explanation))
add(ExportItem.Field("Score", "${snapshot.assessment.score}/100"))
add(ExportItem.Field("Confidence", confidenceLabel(snapshot.assessment.confidence)))
add(ExportItem.Field("Status", snapshot.assessment.status.toString()))
}
}
private fun buildOfficialApiItems(snapshot: DetectionSnapshot): List<ExportItem> {
val anyVpn = snapshot.hasTransportVpnAny
val activeVpn = snapshot.hasTransportVpnActive
val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(snapshot.rawInterfaceName)
val transportInfoDetected = !snapshot.transportInfoSummary.isNullOrBlank()
val items = buildList {
add(
ExportItem.Field(
"TRANSPORT_VPN across all networks",
if (anyVpn) "DETECTED" else "NOT DETECTED"
)
)
add(
ExportItem.Field(
"TRANSPORT_VPN active network only",
if (activeVpn) "DETECTED" else "NOT DETECTED"
)
)
val overall = buildOverallBlock(snapshot)
add(ExportItem.Field("Transport state", overall.transportText))
add(ExportItem.Field("Transport subtitle", overall.transportSubtitle))
val interfaceHint = when {
interfaceDetected && activeVpn ->
"The interface name itself looks like a tunnel device and matches the active VPN state."
interfaceDetected && anyVpn ->
"The interface name looks tunnel-like and is consistent with a VPN being present somewhere in the system."
interfaceDetected ->
"The interface name looks tunnel-like, but Android does not currently mark the active path as VPN."
snapshot.rawInterfaceName != null ->
"The interface name does not look like a typical VPN or tunnel interface."
else ->
"Android did not expose an interface name for this network."
}
val transportInfoHint = when {
transportInfoDetected && activeVpn ->
"Android returned transport info alongside an active VPN transport."
transportInfoDetected && anyVpn ->
"Transport info is present and aligns with a VPN existing somewhere in the network stack."
transportInfoDetected ->
"Transport info is present, but without a direct active VPN transport flag."
else ->
"No VPN-related transport info was exposed here."
}
add(ExportItem.Paragraph("API signals:"))
add(ExportItem.Paragraph("- Interface name"))
add(ExportItem.Field("source", "LinkProperties.getInterfaceName()"))
add(ExportItem.Field("value", snapshot.rawInterfaceName ?: "none"))
add(ExportItem.Field("hint", interfaceHint))
add(ExportItem.Paragraph("- Transport info"))
add(ExportItem.Field("source", "NetworkCapabilities.getTransportInfo()"))
add(
ExportItem.Field(
"value",
formatCompactTransportInfo(snapshot.transportInfoSummary) ?: "none"
)
)
add(ExportItem.Field("hint", transportInfoHint))
}
return items
}
private fun buildVpnNetworkDetailsSection(snapshot: DetectionSnapshot): ExportSection? {
val items = buildList {
if (snapshot.vpnRoutes.isNotEmpty()) {
add(ExportItem.ListBlock("Routes", snapshot.vpnRoutes))
}
if (snapshot.vpnDnsServers.isNotEmpty()) {
add(
ExportItem.Field(
"DNS servers",
snapshot.vpnDnsServers.joinToString(", ")
)
)
}
if (snapshot.allDnsServers.isNotEmpty()) {
add(
ExportItem.ListBlock(
label = "DNS across visible networks",
values = snapshot.allDnsServers
)
)
}
if (snapshot.internalDnsServers.isNotEmpty()) {
add(
ExportItem.ListBlock(
label = "Internal/private-range DNS servers",
values = snapshot.internalDnsServers
)
)
}
if (snapshot.contextualInternalDnsServers.isNotEmpty()) {
add(
ExportItem.ListBlock(
label = "Cellular private DNS observed (not treated as VPN)",
values = snapshot.contextualInternalDnsServers
)
)
}
if (snapshot.privateDnsActive || snapshot.privateDnsServerName != null) {
val privateDnsValue = buildString {
append(if (snapshot.privateDnsActive) "active" else "inactive")
snapshot.privateDnsServerName?.let { append(" ($it)") }
}
add(ExportItem.Field("Private DNS", privateDnsValue))
}
if (snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null) {
add(
ExportItem.Field(
"NET_CAPABILITY_NOT_VPN",
"active=${snapshot.activeNetworkNotVpn ?: "unknown"}, preferred=${snapshot.preferredNetworkNotVpn ?: "unknown"}"
)
)
}
snapshot.vpnBandwidthSummary?.let {
add(ExportItem.Field("Bandwidth", it))
}
}
if (items.isEmpty()) return null
return ExportSection(
title = "VPN NETWORK DETAILS",
items = items
)
}
private fun buildAdditionalSignalsSection(snapshot: DetectionSnapshot): ExportSection? {
val items = buildList {
if (snapshot.tunTypeInterfaces.isNotEmpty()) {
add(
ExportItem.Field(
"TUN interfaces (type=65534)",
snapshot.tunTypeInterfaces.joinToString(", ")
)
)
}
if (snapshot.lowMtuInterfaces.isNotEmpty()) {
add(
ExportItem.ListBlock(
label = "Low-MTU interfaces (<1500)",
values = snapshot.lowMtuInterfaces
)
)
}
if (snapshot.kernelRoutes.isNotEmpty()) {
add(
ExportItem.ListBlock(
label = "Kernel route table (/proc/net/route)",
values = snapshot.kernelRoutes
)
)
}
if (snapshot.kernelIpv6Routes.isNotEmpty()) {
add(
ExportItem.ListBlock(
label = "Kernel route table (/proc/net/ipv6_route)",
values = snapshot.kernelIpv6Routes
)
)
}
if (snapshot.vpnPermissionGranted) {
add(
ExportItem.Paragraph(
"VPN permission: this app holds VPN grant (anomalous)."
)
)
}
}
if (items.isEmpty()) return null
return ExportSection(
title = "ADDITIONAL SIGNALS",
items = items
)
}
private fun buildNativeSection(snapshot: DetectionSnapshot): ExportSection {
val nativeValue = snapshot.nativeTunnelNames.ifEmpty { listOf("none") }.joinToString(", ")
val nativeHint = if (snapshot.nativeTunnelNames.isNotEmpty()) {
"Native enumeration found interfaces whose names or properties look tunnel-like."
} else {
"Native enumeration did not find any tunnel-like interfaces."
}
val nativeDetails = buildString {
if (snapshot.nativeError != null) {
appendLine("Native detector error: ${snapshot.nativeError}")
appendLine()
}
if (snapshot.nativeDetails.isNotEmpty()) {
append(snapshot.nativeDetails.joinToString(separator = "\n\n"))
} else if (snapshot.nativeError == null) {
append("No interfaces were returned by the native detector.")
}
}.trim()
return ExportSection(
title = "NATIVE LOW-LEVEL ENUMERATION",
items = buildList {
snapshot.nativeError?.let {
add(ExportItem.Field("Error", it))
}
add(ExportItem.Field("Signal value", nativeValue))
add(ExportItem.Field("Signal hint", nativeHint))
add(ExportItem.Paragraph(nativeDetails))
}
)
}
private fun buildJavaSection(snapshot: DetectionSnapshot): ExportSection {
val javaValue = snapshot.javaTunnelNames.ifEmpty { listOf("none") }.joinToString(", ")
val javaHint = if (snapshot.javaTunnelNames.isNotEmpty()) {
"Java network enumeration found interface names that look like VPN or tunnel interfaces."
} else {
"Java network enumeration did not find any tunnel-like interface names."
}
return ExportSection(
title = "JAVA INTERFACE ENUMERATION",
items = buildList {
add(ExportItem.Field("Signal value", javaValue))
add(ExportItem.Field("Signal hint", javaHint))
if (snapshot.javaTunnelNames.isNotEmpty()) {
add(
ExportItem.ListBlock(
label = "Matched tunnel-like names",
values = snapshot.javaTunnelNames
)
)
}
}
)
}
private fun buildAppsSection(snapshot: DetectionSnapshot): ExportSection {
return ExportSection(
title = "DETECTED VPN APPS",
items = buildList {
if (snapshot.installedVpnApps.isNotEmpty()) {
add(
ExportItem.ListBlock(
label = "From tracked list",
values = snapshot.installedVpnApps
)
)
}
if (snapshot.unknownDynamicApps.isNotEmpty()) {
add(
ExportItem.ListBlock(
label = "Detected via VpnService query",
values = snapshot.unknownDynamicApps
)
)
}
if (snapshot.trackedAppsErrors.isNotEmpty()) {
add(
ExportItem.ListBlock(
label = "Check errors",
values = snapshot.trackedAppsErrors.map { (pkg, err) -> "$pkg: $err" }
)
)
}
if (snapshot.installedVpnApps.isEmpty() && snapshot.unknownDynamicApps.isEmpty()) {
add(ExportItem.Paragraph("No VPN-related apps detected."))
}
}
)
}
private fun buildAdvancedSignalsSection(snapshot: DetectionSnapshot): ExportSection? {
val items = buildList {
if (snapshot.lockdownLikely) {
add(
ExportItem.Paragraph(
"Always-on lockdown: likely (no validated non-VPN path exists)."
)
)
}
if (snapshot.knownVpnDnsMatches.isNotEmpty()) {
add(
ExportItem.ListBlock(
label = "Known VPN provider DNS",
values = snapshot.knownVpnDnsMatches
)
)
}
if (snapshot.workProfileCount > 1) {
add(
ExportItem.Paragraph(
"Work profile: ${snapshot.workProfileCount} user profiles detected. VPN apps in other profiles are not visible."
)
)
}
if (snapshot.isManagedProfile) {
add(ExportItem.Paragraph("Running inside a managed profile."))
}
}
if (items.isEmpty()) return null
return ExportSection(
title = "ADVANCED SIGNALS",
items = items
)
}
private fun buildOverallBlock(snapshot: DetectionSnapshot): OverallBlock {
val anyVpn = snapshot.hasTransportVpnAny
val activeVpn = snapshot.hasTransportVpnActive
val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(snapshot.rawInterfaceName)
val transportInfoDetected = !snapshot.transportInfoSummary.isNullOrBlank()
val dnsDetected = snapshot.internalDnsServers.isNotEmpty()
val policyDetected =
snapshot.activeNetworkNotVpn == false || snapshot.preferredNetworkNotVpn == false
val nativeDetected = snapshot.nativeTunnelNames.isNotEmpty()
val javaDetected = snapshot.javaTunnelNames.isNotEmpty()
val appsDetected = snapshot.installedVpnApps.isNotEmpty()
val scoreText =
"Confidence: ${confidenceLabel(snapshot.assessment.confidence)} (${snapshot.assessment.score}/100)."
return when {
snapshot.assessment.status == DetectionStatus.ACTIVE_VPN || activeVpn -> {
val lockdownNote = if (snapshot.lockdownLikely) {
" Lockdown mode appears active — no non-VPN path is validated."
} else {
""
}
OverallBlock(
title = if (snapshot.lockdownLikely) "VPN detected (lockdown)" else "VPN detected",
summary = "The active network is explicitly marked as VPN by Android.$lockdownNote",
explanation = "This is the strongest signal in the app: Android reports TRANSPORT_VPN on the network currently in use. $scoreText",
transportText = "VPN DETECTED",
transportSubtitle = "TRANSPORT_VPN is present on the active network."
)
}
snapshot.assessment.status == DetectionStatus.SPLIT_TUNNEL || anyVpn -> {
OverallBlock(
title = "VPN present outside active path",
summary = "Android sees a VPN network in the system, but not on the current active network.",
explanation = "This often matches bypass or split-tunnel behavior: a VPN exists, but current traffic may not be fully routed through it. $scoreText",
transportText = "SPLIT / BYPASS",
transportSubtitle = "A VPN-related transport exists system-wide, but it is not the current active path."
)
}
interfaceDetected || transportInfoDetected || dnsDetected || policyDetected -> {
OverallBlock(
title = "VPN-related API signal",
summary = "Android APIs still expose VPN-like indicators even though active TRANSPORT_VPN is absent.",
explanation = "This is weaker than a direct VPN transport flag, but interface, DNS, or capability signals still suggest VPN-related state in the visible network stack. $scoreText",
transportText = "API SIGNAL",
transportSubtitle = "No active TRANSPORT_VPN, but Android APIs still expose VPN-related information."
)
}
nativeDetected || javaDetected -> {
OverallBlock(
title = "Low-level tunnel signal",
summary = "No primary Android VPN signal was found, but tunnel-like interfaces were still discovered.",
explanation = "This usually means only low-level interface heuristics fired. It is useful as an additional hint, but weaker than official Android VPN signals. $scoreText",
transportText = "NOT DETECTED",
transportSubtitle = "Android did not report VPN transport on the active path."
)
}
snapshot.assessment.status == DetectionStatus.APPS_PRESENT || appsDetected -> {
OverallBlock(
title = "Detected VPN apps",
summary = "No active VPN network signal was found, but known VPN-related apps are installed on the device.",
explanation = "Installed VPN apps do not prove that a VPN is currently active, but they are still a relevant contextual signal. $scoreText",
transportText = "NOT DETECTED",
transportSubtitle = "Android did not report VPN transport on the active path."
)
}
else -> {
OverallBlock(
title = "No VPN detected",
summary = "The app did not find any high-level or low-level VPN indicators.",
explanation = "Neither official Android network APIs nor interface enumeration produced a VPN-related signal. $scoreText",
transportText = "NOT DETECTED",
transportSubtitle = "No VPN transport was reported by Android."
)
}
}
}
private data class OverallBlock(
val title: String,
val summary: String,
val explanation: String,
val transportText: String,
val transportSubtitle: String
)
private fun formatCompactTransportInfo(summary: String?): String? {
if (summary.isNullOrBlank()) return null
val normalized = summary
.replace("VpnTransportInfo", "VPN")
.replace("type=", "")
.replace("PLATFORM", "platform")
.replace("(", " (")
.trim()
return when {
normalized.contains("VPN", ignoreCase = true) &&
normalized.contains("platform", ignoreCase = true) -> "VPN (platform)"
normalized.contains("VPN", ignoreCase = true) -> "VPN"
else -> normalized
}
}
private fun confidenceLabel(confidence: DetectionConfidence): String {
return when (confidence) {
DetectionConfidence.CONFIRMED -> "confirmed"
DetectionConfidence.LIKELY -> "likely"
DetectionConfidence.WEAK_SIGNAL -> "weak signal"
DetectionConfidence.NO_EVIDENCE -> "no evidence"
}
}
}

View file

@ -0,0 +1,41 @@
package com.cherepavel.vpndetector.ui.export
object ReportExportFormatter {
fun buildText(report: ExportReport): String {
return buildString {
appendLine(report.title)
appendLine("Generated: ${report.generatedAt}")
appendLine("Build: ${report.buildInfo}")
appendLine("Source code: ${report.sourceCodeUrl}")
appendLine()
report.sections.forEachIndexed { index, section ->
appendLine("=== ${section.title} ===")
section.items.forEach { item ->
when (item) {
is ExportItem.Field -> {
appendLine("${item.label}: ${item.value}")
}
is ExportItem.ListBlock -> {
appendLine("${item.label}:")
item.values.forEach { value ->
appendLine(" $value")
}
}
is ExportItem.Paragraph -> {
appendLine(item.text)
}
}
}
if (index != report.sections.lastIndex) {
appendLine()
}
}
}.trim()
}
}

View file

@ -1,170 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
android:viewportWidth="124"
android:viewportHeight="124">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
android:fillColor="#1E88E5"
android:pathData="M24,0L100,0A24,24 0,0 1,124 24L124,100A24,24 0,0 1,100 124L24,124A24,24 0,0 1,0 100L0,24A24,24 0,0 1,24 0z"/>
</vector>

View file

@ -1,30 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
android:viewportWidth="124"
android:viewportHeight="124">
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
android:fillColor="#FFFFFFFF"
android:pathData="
M62,28
L38,38
V60
C38,78 50,92 62,98
C74,92 86,78 86,60
V38
Z"/>
<path
android:fillColor="#1E88E5"
android:pathData="M62,60m-12,0a12,12 0,1 1,24 0a12,12 0,1 1,-24 0"/>
<!-- Handle -->
<path
android:pathData="M69.8,67.8L79.8,77.8"
android:strokeColor="#1E88E5"
android:strokeWidth="4.5"
android:strokeLineCap="round"
android:fillColor="#00000000"/>
</vector>

View file

@ -15,528 +15,23 @@
android:paddingEnd="16dp"
android:paddingBottom="16dp">
<TextView
android:id="@+id/textTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="VPN Detector"
android:textColor="?attr/appColorOnBackground"
android:textSize="30sp"
android:textStyle="bold" />
<include layout="@layout/main_section_header" />
<LinearLayout
android:id="@+id/cardStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_card_surface"
android:orientation="vertical"
android:padding="18dp">
<include layout="@layout/main_section_overall_status" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Overall status"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="14sp"
android:textStyle="bold" />
<include layout="@layout/main_section_actions" />
<TextView
android:id="@+id/textVpnStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="UNKNOWN"
android:textColor="?attr/appColorOnSurface"
android:textSize="28sp"
android:textStyle="bold" />
<include layout="@layout/main_section_official_api" />
<TextView
android:id="@+id/textVpnSummary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="14sp" />
<include layout="@layout/main_section_native" />
<TextView
android:id="@+id/textVpnExplanation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="13sp" />
</LinearLayout>
<include layout="@layout/main_section_extra" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:orientation="horizontal">
<include layout="@layout/main_section_java" />
<Button
android:id="@+id/buttonRefresh"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:backgroundTint="?attr/appColorAccentPrimary"
android:text="Refresh"
android:textAllCaps="false"
android:textColor="@android:color/white"
android:textStyle="bold" />
<include layout="@layout/main_section_apps" />
<Space
android:layout_width="10dp"
android:layout_height="1dp" />
<Button
android:id="@+id/buttonReport"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:backgroundTint="?attr/appColorAccentPrimary"
android:text="Generate report"
android:textAllCaps="false"
android:textColor="@android:color/white"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/textLastUpdate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:gravity="end"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
<LinearLayout
android:id="@+id/cardOfficialSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_card_surface"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Official Android API"
android:textColor="?attr/appColorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Primary signals exposed by Android network APIs"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
<LinearLayout
android:id="@+id/cardTransportVpn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_card_inner"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/textTransportTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="TRANSPORT_VPN"
android:textColor="?attr/appColorOnSurface"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textTransportState"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textTransportSubtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Across all networks"
android:textColor="?attr/appColorOnSurface"
android:textSize="13sp" />
<TextView
android:id="@+id/textTransportAnyValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_signal_neutral"
android:paddingStart="10dp"
android:paddingTop="4dp"
android:paddingEnd="10dp"
android:paddingBottom="4dp"
android:text="-"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Active network only"
android:textColor="?attr/appColorOnSurface"
android:textSize="13sp" />
<TextView
android:id="@+id/textTransportActiveValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_signal_neutral"
android:paddingStart="10dp"
android:paddingTop="4dp"
android:paddingEnd="10dp"
android:paddingBottom="4dp"
android:text="-"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/cardApiSignal1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_card_inner"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/textApiSignalTitle1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textApiSignalSource1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="11sp" />
<TextView
android:id="@+id/textApiSignalValue1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textApiSignalHint1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
</LinearLayout>
<Space
android:layout_width="10dp"
android:layout_height="1dp" />
<LinearLayout
android:id="@+id/cardApiSignal2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_card_inner"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/textApiSignalTitle2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textApiSignalSource2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="11sp" />
<TextView
android:id="@+id/textApiSignalValue2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textApiSignalHint2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/cardNativeSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_card_surface"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Native low-level enumeration"
android:textColor="?attr/appColorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Experimental signal from native interface discovery"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
<LinearLayout
android:id="@+id/cardNativeSignal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/bg_card_inner"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/textNativeSignalTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textNativeSignalSource"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="11sp" />
<TextView
android:id="@+id/textNativeSignalValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textNativeSignalHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
</LinearLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollNativeDetails"
android:layout_width="match_parent"
android:layout_height="220dp"
android:layout_marginTop="10dp"
android:background="@drawable/bg_card_inner"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:scrollbars="vertical">
<TextView
android:id="@+id/textNativeDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:lineSpacingExtra="2dp"
android:padding="12dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
</androidx.core.widget.NestedScrollView>
</LinearLayout>
<LinearLayout
android:id="@+id/cardJavaSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_card_surface"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Java interface enumeration"
android:textColor="?attr/appColorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Supplementary signal from Java networking APIs"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
<LinearLayout
android:id="@+id/cardJavaSignal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/bg_card_inner"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/textJavaSignalTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textJavaSignalSource"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="11sp" />
<TextView
android:id="@+id/textJavaSignalValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textJavaSignalHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/cardAppsSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_card_surface"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Detected VPN apps"
android:textColor="?attr/appColorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textKnownApps"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="2dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="14sp" />
</LinearLayout>
<include layout="@layout/main_section_footer" />
</LinearLayout>
</ScrollView>
</ScrollView>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/bg_card_inner"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/textDetailTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textDetailBody"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:fontFamily="monospace"
android:lineSpacingExtra="2dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textIsSelectable="true"
android:textSize="12sp" />
</LinearLayout>

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_card_inner"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/textSignalTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textSignalSource"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="11sp" />
<TextView
android:id="@+id/textSignalValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textSignalHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
</LinearLayout>

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/bg_card_inner"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/textSignalTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textSignalSource"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="11sp" />
<TextView
android:id="@+id/textSignalValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textSignalHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
</LinearLayout>

View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/cardTransportVpn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_card_inner"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/textTransportTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="TRANSPORT_VPN"
android:textColor="?attr/appColorOnSurface"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textTransportState"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textTransportSubtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Across all networks"
android:textColor="?attr/appColorOnSurface"
android:textSize="13sp" />
<TextView
android:id="@+id/textTransportAnyValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_signal_neutral"
android:paddingStart="10dp"
android:paddingTop="4dp"
android:paddingEnd="10dp"
android:paddingBottom="4dp"
android:text="-"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Active network only"
android:textColor="?attr/appColorOnSurface"
android:textSize="13sp" />
<TextView
android:id="@+id/textTransportActiveValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_signal_neutral"
android:paddingStart="10dp"
android:paddingTop="4dp"
android:paddingEnd="10dp"
android:paddingBottom="4dp"
android:text="-"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:orientation="horizontal">
<Button
android:id="@+id/buttonRefresh"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:backgroundTint="?attr/appColorAccentPrimary"
android:text="Refresh"
android:textAllCaps="false"
android:textColor="@android:color/white"
android:textStyle="bold" />
<Space
android:layout_width="10dp"
android:layout_height="1dp" />
<Button
android:id="@+id/buttonReport"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:backgroundTint="?attr/appColorAccentPrimary"
android:text="Generate report"
android:textAllCaps="false"
android:textColor="@android:color/white"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/textLastUpdate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:gravity="end"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
</merge>

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:id="@+id/cardAppsSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_card_surface"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Detected VPN apps"
android:textColor="?attr/appColorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textKnownApps"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="2dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="14sp" />
</LinearLayout>
</merge>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:id="@+id/cardExtraSections"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_card_surface"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Additional technical details"
android:textColor="?attr/appColorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Routes, DNS, kernel tables and related context"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
<LinearLayout
android:id="@+id/containerExtraSections"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="vertical" />
</LinearLayout>
</merge>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:id="@+id/footerSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="16dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/textVersion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp"
android:alpha="0.70" />
<TextView
android:id="@+id/textFooterInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="11sp"
android:alpha="0.60"
android:clickable="true"
android:focusable="true" />
</LinearLayout>
</merge>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<TextView
android:id="@+id/textTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="VPN Detector"
android:textColor="?attr/appColorOnBackground"
android:textSize="30sp"
android:textStyle="bold" />
</merge>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:id="@+id/cardJavaSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_card_surface"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Java interface enumeration"
android:textColor="?attr/appColorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Supplementary signal from Java networking APIs"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
<include
android:id="@+id/cardJavaSignal"
layout="@layout/common_item_signal_card_full" />
</LinearLayout>
</merge>

View file

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:id="@+id/cardNativeSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_card_surface"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Native low-level enumeration"
android:textColor="?attr/appColorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Experimental signal from native interface discovery"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
<include
android:id="@+id/cardNativeSignal"
layout="@layout/common_item_signal_card_full" />
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollNativeDetails"
android:layout_width="match_parent"
android:layout_height="220dp"
android:layout_marginTop="10dp"
android:background="@drawable/bg_card_inner"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:scrollbars="vertical">
<TextView
android:id="@+id/textNativeDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:lineSpacingExtra="2dp"
android:padding="12dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
</androidx.core.widget.NestedScrollView>
</LinearLayout>
</merge>

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:id="@+id/cardOfficialSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_card_surface"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Official Android API"
android:textColor="?attr/appColorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Primary signals exposed by Android network APIs"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp" />
<include layout="@layout/main_block_transport_vpn" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<include
android:id="@+id/cardApiSignal1"
layout="@layout/common_item_signal_card" />
<Space
android:layout_width="10dp"
android:layout_height="1dp" />
<include
android:id="@+id/cardApiSignal2"
layout="@layout/common_item_signal_card" />
</LinearLayout>
</LinearLayout>
</merge>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:id="@+id/cardStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_card_surface"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Overall status"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textVpnStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="UNKNOWN"
android:textColor="?attr/appColorOnSurface"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textVpnSummary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="-"
android:textColor="?attr/appColorOnSurface"
android:textSize="14sp" />
<TextView
android:id="@+id/textVpnExplanation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="13sp" />
</LinearLayout>
</merge>

View file

@ -1,3 +1,115 @@
<resources>
<string name="app_name">VpnDetector</string>
</resources>
<string name="app_name" translatable="false">VPN Detector</string>
<string name="report_value_detected">DETECTED</string>
<string name="report_value_not_detected">NOT DETECTED</string>
<string name="report_value_none">none</string>
<string name="report_value_unknown">unknown</string>
<string name="report_title_vpn_detected">VPN detected</string>
<string name="report_title_vpn_detected_lockdown">VPN detected (lockdown)</string>
<string name="report_summary_vpn_detected">The active network is explicitly marked as VPN by Android.</string>
<string name="report_summary_lockdown_suffix"> Lockdown mode appears active — no non-VPN path is validated.</string>
<string name="report_explanation_vpn_detected">This is the strongest signal in the app: Android reports TRANSPORT_VPN on the network currently in use.</string>
<string name="report_title_split_tunnel">VPN present outside active path</string>
<string name="report_summary_split_tunnel">Android sees a VPN network in the system, but not on the current active network.</string>
<string name="report_explanation_split_tunnel">This often matches bypass or split-tunnel behavior: a VPN exists, but current traffic may not be fully routed through it.</string>
<string name="report_title_api_signal">VPN-related API signal</string>
<string name="report_summary_api_signal">Android APIs still expose VPN-like indicators even though active TRANSPORT_VPN is absent.</string>
<string name="report_explanation_api_signal">This is weaker than a direct VPN transport flag, but interface, DNS, or capability signals still suggest VPN-related state in the visible network stack.</string>
<string name="report_title_low_level">Low-level tunnel signal</string>
<string name="report_summary_low_level">No primary Android VPN signal was found, but tunnel-like interfaces were still discovered.</string>
<string name="report_explanation_low_level">This usually means only low-level interface heuristics fired. It is useful as an additional hint, but weaker than official Android VPN signals.</string>
<string name="report_title_apps_present">Detected VPN apps</string>
<string name="report_summary_apps_present">No active VPN network signal was found, but known VPN-related apps are installed on the device.</string>
<string name="report_explanation_apps_present">Installed VPN apps do not prove that a VPN is currently active, but they are still a relevant contextual signal.</string>
<string name="report_title_no_vpn">No VPN detected</string>
<string name="report_summary_no_vpn">The app did not find any high-level or low-level VPN indicators.</string>
<string name="report_explanation_no_vpn">Neither official Android network APIs nor interface enumeration produced a VPN-related signal.</string>
<string name="report_transport_text_vpn_detected">VPN DETECTED</string>
<string name="report_transport_text_split_tunnel">SPLIT / BYPASS</string>
<string name="report_transport_text_api_signal">API SIGNAL</string>
<string name="report_transport_text_not_detected">NOT DETECTED</string>
<string name="report_transport_subtitle_vpn_detected">TRANSPORT_VPN is present on the active network.</string>
<string name="report_transport_subtitle_split_tunnel">A VPN-related transport exists system-wide, but it is not the current active path.</string>
<string name="report_transport_subtitle_api_signal">No active TRANSPORT_VPN, but Android APIs still expose VPN-related information.</string>
<string name="report_transport_subtitle_not_detected">No VPN transport was reported by Android.</string>
<string name="report_transport_subtitle_not_on_active_path">Android did not report VPN transport on the active path.</string>
<string name="report_confidence_confirmed">confirmed</string>
<string name="report_confidence_likely">likely</string>
<string name="report_confidence_weak_signal">weak signal</string>
<string name="report_confidence_no_evidence">no evidence</string>
<string name="report_confidence_format">Confidence: %1$s (%2$d/100).</string>
<string name="signal_title_interface_name">Interface name</string>
<string name="signal_source_interface_name">LinkProperties.getInterfaceName()</string>
<string name="signal_title_transport_info">Transport info</string>
<string name="signal_source_transport_info">NetworkCapabilities.getTransportInfo()</string>
<string name="signal_hint_interface_active">The interface name itself looks like a tunnel device and matches the active VPN state.</string>
<string name="signal_hint_interface_any">The interface name looks tunnel-like and is consistent with a VPN being present somewhere in the system.</string>
<string name="signal_hint_interface_only">The interface name looks tunnel-like, but Android does not currently mark the active path as VPN.</string>
<string name="signal_hint_interface_normal">The interface name does not look like a typical VPN or tunnel interface.</string>
<string name="signal_hint_interface_missing">Android did not expose an interface name for this network.</string>
<string name="signal_hint_transport_active">Android returned transport info alongside an active VPN transport.</string>
<string name="signal_hint_transport_any">Transport info is present and aligns with a VPN existing somewhere in the network stack.</string>
<string name="signal_hint_transport_only">Transport info is present, but without a direct active VPN transport flag.</string>
<string name="signal_hint_transport_missing">No VPN-related transport info was exposed here.</string>
<string name="signal_title_tunnel_like_interfaces">Tunnel-like interfaces</string>
<string name="signal_source_native">Native getifaddrs() enumeration</string>
<string name="signal_source_java">Java NetworkInterface enumeration</string>
<string name="signal_hint_native_found">Native enumeration found interfaces whose names or properties look tunnel-like.</string>
<string name="signal_hint_native_missing">Native enumeration did not find any tunnel-like interfaces.</string>
<string name="signal_hint_java_found">Java network enumeration found interface names that look like VPN or tunnel interfaces.</string>
<string name="signal_hint_java_missing">Java network enumeration did not find any tunnel-like interface names.</string>
<string name="native_details_empty">No interfaces were returned by the native detector.</string>
<string name="native_error_format">Native detector error: %1$s</string>
<string name="apps_none_detected">No VPN-related apps detected.</string>
<string name="apps_detected_via_vpn_service">Detected via VpnService query:</string>
<string name="apps_check_errors">Check errors (package manager returned unexpected error):</string>
<string name="section_tun_interfaces">TUN interfaces</string>
<string name="section_low_mtu_interfaces">Low-MTU interfaces</string>
<string name="section_vpn_routes">VPN network routes</string>
<string name="section_vpn_dns_servers">VPN DNS servers</string>
<string name="section_dns_all">DNS across visible networks</string>
<string name="section_dns_internal">Internal/private-range DNS servers</string>
<string name="section_dns_contextual">Cellular private DNS observed (context only)</string>
<string name="section_private_dns">Private DNS</string>
<string name="section_not_vpn">NET_CAPABILITY_NOT_VPN</string>
<string name="section_vpn_bandwidth">VPN bandwidth</string>
<string name="section_kernel_routes_v4">Kernel route table (/proc/net/route)</string>
<string name="section_kernel_routes_v6">Kernel route table (/proc/net/ipv6_route)</string>
<string name="section_vpn_permission">VPN permission</string>
<string name="section_lockdown">Always-on / lockdown</string>
<string name="section_known_vpn_dns">Known VPN provider DNS</string>
<string name="section_work_profile">Work / managed profile</string>
<string name="private_dns_active">active</string>
<string name="private_dns_inactive">inactive</string>
<string name="private_dns_with_host">%1$s (%2$s)</string>
<string name="not_vpn_body">active=%1$s, preferred=%2$s</string>
<string name="vpn_permission_body">This app holds Android VPN permission (anomalous for a detector).</string>
<string name="lockdown_body">VPN present and no validated non-VPN path exists. Lockdown mode is likely active.</string>
<string name="managed_profile_body">Running inside a managed profile.</string>
<string name="work_profile_count_body">%1$d user profiles detected. VPN apps in other profiles are not visible to this detector.</string>
<string name="author">cherepavel</string>
<string name="repo_url">https://github.com/cherepavel/VPN-Detector</string>
<string name="source_code_label">Source code:</string>
</resources>

View file

@ -1,4 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
// Top-level build file
plugins {
alias(libs.plugins.android.application) apply false
id("com.android.application") apply false
id("com.android.library") apply false
id("org.jetbrains.kotlin.android") apply false
}

39
detector/build.gradle.kts Normal file
View file

@ -0,0 +1,39 @@
plugins {
id("com.android.library")
}
extensions.configure<com.android.build.api.dsl.LibraryExtension> {
namespace = "com.cherepavel.vpndetector.detector"
compileSdk = 36
ndkVersion = "27.2.12479018"
defaultConfig {
minSdk = 24
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
}
}
dependencies {
implementation(libs.androidx.core.ktx)
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View file

@ -0,0 +1,14 @@
Language: Cpp
BasedOnStyle: LLVM
ColumnLimit: 120
CompactNamespaces: true
AccessModifierOffset: -4
ContinuationIndentWidth: 4
IndentWidth: 4
SpacesBeforeTrailingComments: 2
MaxEmptyLinesToKeep: 1
Standard: Latest
TabWidth: 4
UseTab: Never
Cpp11BracedListStyle: true
AlwaysBreakTemplateDeclarations: true

View file

@ -0,0 +1,40 @@
cmake_minimum_required(VERSION 3.22.1)
project(ifconfigdetector LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Choose the type of build." FORCE)
endif()
add_library(${PROJECT_NAME}
SHARED
ifconfigdetector.cpp
)
#for tests in Linux
if(NOT ANDROID)
find_package(JNI REQUIRED)
target_include_directories(${PROJECT_NAME} PRIVATE ${JNI_INCLUDE_DIRS})
endif ()
if(ANDROID)
find_library(log-lib log)
if(log-lib)
target_link_libraries(${PROJECT_NAME} PRIVATE ${log-lib})
endif()
target_link_options(${PROJECT_NAME} PRIVATE -Wl,-z,max-page-size=16384)
endif()
target_compile_options(${PROJECT_NAME} PRIVATE
-Wall -Wextra -fPIC
$<$<CONFIG:Release>:-O3 -DNDEBUG>
$<$<CONFIG:Debug>:-g -O0>
)
set_target_properties(${PROJECT_NAME} PROPERTIES
POSITION_INDEPENDENT_CODE ON
OUTPUT_NAME ${PROJECT_NAME}
)

View file

@ -0,0 +1,410 @@
#include <jni.h>
#include <algorithm>
#include <array>
#include <cstdlib>
#include <fstream>
#include <map>
#include <sstream>
#include <string>
#include <vector>
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <net/if.h>
#include <netinet/in.h>
#include <sys/socket.h>
namespace {
struct AddressEntry {
int family = 0;
std::string address;
std::string netmask;
std::string peerOrBroadcast;
bool isPointToPoint = false;
bool isBroadcast = false;
explicit AddressEntry(const int fam = 0, std::string addr = {}, std::string mask = {}, std::string peer = {},
const bool p2p = false, const bool bc = false)
: family(fam), address(std::move(addr)), netmask(std::move(mask)), peerOrBroadcast(std::move(peer)),
isPointToPoint(p2p), isBroadcast(bc) {}
};
struct InterfaceDump {
std::string name;
unsigned int flags = 0;
std::vector<AddressEntry> addresses;
};
struct IfAddrsGuard {
::ifaddrs *ptr = nullptr;
~IfAddrsGuard() {
if (ptr)
::freeifaddrs(ptr);
}
// Использование: getifaddrs(ifaddr())
::ifaddrs **operator()() noexcept { return &ptr; }
[[nodiscard]] ::ifaddrs *get() const noexcept { return ptr; }
explicit operator ::ifaddrs *() const noexcept { return ptr; }
};
std::string sockaddrToString(const sockaddr *sa) {
if (!sa)
return {};
char buf[INET6_ADDRSTRLEN] = {};
if (sa->sa_family == AF_INET) {
const auto *sin = reinterpret_cast<const sockaddr_in *>(sa);
if (inet_ntop(AF_INET, &sin->sin_addr, buf, sizeof(buf)))
return buf;
} else if (sa->sa_family == AF_INET6) {
const auto *sin6 = reinterpret_cast<const sockaddr_in6 *>(sa);
if (inet_ntop(AF_INET6, &sin6->sin6_addr, buf, sizeof(buf)))
return buf;
}
return {};
}
int readIntFromFile(const std::string &path, const int fallback = -1) {
std::ifstream file(path);
if (!file.is_open())
return fallback;
int value = fallback;
file >> value;
return file.fail() ? fallback : value;
}
std::string formatFlagNames(const unsigned int flags) {
std::string result;
const auto append = [&](const char *name) {
if (!result.empty())
result += ',';
result += name;
};
if (flags & IFF_UP)
append("UP");
if (flags & IFF_BROADCAST)
append("BROADCAST");
if (flags & IFF_DEBUG)
append("DEBUG");
if (flags & IFF_LOOPBACK)
append("LOOPBACK");
if (flags & IFF_POINTOPOINT)
append("POINTOPOINT");
if (flags & IFF_RUNNING)
append("RUNNING");
if (flags & IFF_NOARP)
append("NOARP");
if (flags & IFF_PROMISC)
append("PROMISC");
if (flags & IFF_ALLMULTI)
append("ALLMULTI");
if (flags & IFF_MULTICAST)
append("MULTICAST");
return result;
}
int ipv6PrefixLenFromMask(const sockaddr *sa) {
if (!sa || sa->sa_family != AF_INET6)
return -1;
const auto *sin6 = reinterpret_cast<const sockaddr_in6 *>(sa);
int bits = 0;
for (int i = 0; i < 16; ++i) {
const unsigned char byte = sin6->sin6_addr.s6_addr[i];
if (byte == 0xFF) {
bits += 8;
continue;
}
for (int bit = 7; bit >= 0; --bit) {
if (byte & 1u << bit)
++bits;
else
return bits;
}
return bits;
}
return bits;
}
bool addressEntryLess(const AddressEntry &a, const AddressEntry &b) {
if (a.family != b.family)
return a.family == AF_INET;
return a.address < b.address;
}
const char *ifTypeName(const int type) {
switch (type) {
case 1:
return "ETHER";
case 772:
return "LOOPBACK";
case 65534:
return "TUN";
default:
return nullptr;
}
}
std::string buildIfconfigLikeBlock(const InterfaceDump &iface, const std::map<std::string, int> &mtuMap,
const std::map<std::string, int> &txQueueMap,
const std::map<std::string, int> &typeMap) {
std::ostringstream oss;
oss << iface.name << ": flags=" << iface.flags << "<" << formatFlagNames(iface.flags) << ">";
if (auto it = mtuMap.find(iface.name); it != mtuMap.end())
oss << " mtu " << it->second;
if (auto it = typeMap.find(iface.name); it != typeMap.end()) {
const int t = it->second;
oss << " type " << t;
if (const char *name = ifTypeName(t))
oss << " (" << name << ")";
}
oss << "\n";
auto sorted = iface.addresses;
std::sort(sorted.begin(), sorted.end(), addressEntryLess);
for (const auto &entry : sorted) {
if (entry.family == AF_INET) {
oss << " inet " << (entry.address.empty() ? "-" : entry.address);
if (!entry.netmask.empty())
oss << " netmask " << entry.netmask;
if (!entry.peerOrBroadcast.empty()) {
if (entry.isPointToPoint)
oss << " destination " << entry.peerOrBroadcast;
else if (entry.isBroadcast)
oss << " broadcast " << entry.peerOrBroadcast;
}
oss << "\n";
} else if (entry.family == AF_INET6) {
oss << " inet6 " << (entry.address.empty() ? "-" : entry.address);
if (!entry.netmask.empty())
oss << " prefixlen " << entry.netmask;
if (!entry.peerOrBroadcast.empty() && entry.isPointToPoint)
oss << " destination " << entry.peerOrBroadcast;
oss << "\n";
}
}
if (auto it = txQueueMap.find(iface.name); it != txQueueMap.end())
oss << " txqueuelen " << it->second << "\n";
return oss.str();
}
jobjectArray createStringArray(JNIEnv *env, const std::vector<std::string> &strings) {
jclass stringCls = env->FindClass("java/lang/String");
if (!stringCls)
return nullptr;
const auto size = static_cast<jsize>(strings.size());
jobjectArray result = env->NewObjectArray(size, stringCls, nullptr);
if (!result)
return nullptr;
for (jsize i = 0; i < size; ++i) {
if (jstring text = env->NewStringUTF(strings[i].c_str())) {
env->SetObjectArrayElement(result, i, text);
env->DeleteLocalRef(text);
}
}
return result;
}
// --- /proc/net/route helpers ---
std::string hexLeToIpStr(const std::string &hex) {
const auto val = strtoul(hex.c_str(), nullptr, 16);
return std::to_string(val & 0xFFu) + "." + std::to_string((val >> 8u) & 0xFFu) + "." +
std::to_string((val >> 16u) & 0xFFu) + "." + std::to_string((val >> 24u) & 0xFFu);
}
int countSetBits(unsigned long val) {
int count = 0;
while (val) {
count += static_cast<int>(val & 1u);
val >>= 1u;
}
return count;
}
std::string hexToIpv6Str(const std::string &hex) {
if (hex.size() != 32)
return {};
std::array<unsigned char, 16> bytes{};
for (size_t i = 0; i < 16; ++i) {
const auto part = hex.substr(i * 2, 2);
char *end = nullptr;
const auto value = strtoul(part.c_str(), &end, 16);
if (end == nullptr || *end != '\0' || value > 0xFFu)
return {};
bytes[i] = static_cast<unsigned char>(value);
}
char buf[INET6_ADDRSTRLEN] = {};
return inet_ntop(AF_INET6, bytes.data(), buf, sizeof(buf)) ? std::string(buf) : std::string();
}
std::vector<std::string> collectInterfaceDumps() {
std::map<std::string, InterfaceDump> interfaces;
std::map<std::string, int> mtuMap, txQueueMap, typeMap;
IfAddrsGuard ipaddr;
if (getifaddrs(ipaddr()) == -1 || ipaddr.get() == nullptr) {
return {};
}
for (const ::ifaddrs *it = ipaddr.get(); it != nullptr; it = it->ifa_next) {
if (!it->ifa_name)
continue;
const std::string name(it->ifa_name);
auto &iface = interfaces[name];
iface.name = name;
iface.flags |= it->ifa_flags;
if (mtuMap.find(name) == mtuMap.end())
mtuMap[name] = readIntFromFile("/sys/class/net/" + name + "/mtu");
if (txQueueMap.find(name) == txQueueMap.end())
txQueueMap[name] = readIntFromFile("/sys/class/net/" + name + "/tx_queue_len");
if (typeMap.find(name) == typeMap.end())
typeMap[name] = readIntFromFile("/sys/class/net/" + name + "/type");
if (!it->ifa_addr)
continue;
const int family = it->ifa_addr->sa_family;
if (family != AF_INET && family != AF_INET6)
continue;
const bool isP2P = (it->ifa_flags & IFF_POINTOPOINT) != 0;
const bool isBC = (it->ifa_flags & IFF_BROADCAST) != 0;
std::string netmaskStr, peerStr;
if (family == AF_INET) {
netmaskStr = sockaddrToString(it->ifa_netmask);
if (isP2P && it->ifa_dstaddr)
peerStr = sockaddrToString(it->ifa_dstaddr);
else if (isBC && it->ifa_ifu.ifu_broadaddr)
peerStr = sockaddrToString(it->ifa_ifu.ifu_broadaddr);
} else if (family == AF_INET6) {
if (const int prefixLen = ipv6PrefixLenFromMask(it->ifa_netmask); prefixLen >= 0)
netmaskStr = std::to_string(prefixLen);
if (isP2P && it->ifa_dstaddr)
peerStr = sockaddrToString(it->ifa_dstaddr);
}
const std::string adderStr = sockaddrToString(it->ifa_addr);
iface.addresses.emplace_back(family, adderStr, std::move(netmaskStr), std::move(peerStr), isP2P, isBC);
}
std::vector<std::string> dumps;
dumps.reserve(interfaces.size());
for (const auto &[_, iface] : interfaces) {
dumps.emplace_back(buildIfconfigLikeBlock(iface, mtuMap, txQueueMap, typeMap));
}
return dumps;
}
std::vector<std::string> parseKernelRoutes() {
std::ifstream routeFile("/proc/net/route");
if (!routeFile.is_open())
return {};
std::vector<std::string> routes;
std::string line;
std::getline(routeFile, line); // skip header
while (std::getline(routeFile, line)) {
std::istringstream ss(line);
std::string i_face, dest, gw, flagsStr, refCnt, use, metric, mask;
if (!(ss >> i_face >> dest >> gw >> flagsStr >> refCnt >> use >> metric >> mask))
continue;
const auto flags = strtoul(flagsStr.c_str(), nullptr, 16);
if (!(flags & 0x0001u))
continue;
const auto maskVal = strtoul(mask.c_str(), nullptr, 16);
const auto destVal = strtoul(dest.c_str(), nullptr, 16);
std::ostringstream route;
route << i_face << ": " << hexLeToIpStr(dest) << "/" << countSetBits(maskVal);
if (flags & 0x0002u)
route << " via " << hexLeToIpStr(gw);
if (destVal == 0 && maskVal == 0)
route << " [DEFAULT]";
routes.emplace_back(route.str());
}
return routes;
}
std::vector<std::string> parseKernelIpv6Routes() {
std::ifstream routeFile("/proc/net/ipv6_route");
if (!routeFile.is_open())
return {};
std::vector<std::string> routes;
std::string line;
while (std::getline(routeFile, line)) {
std::istringstream ss(line);
std::string destHex, destPrefixHex, srcHex, srcPrefixHex, nextHopHex, metricHex, refCntHex, useHex, flagsHex,
iface;
if (!(ss >> destHex >> destPrefixHex >> srcHex >> srcPrefixHex >> nextHopHex >> metricHex >> refCntHex >>
useHex >> flagsHex >> iface))
continue;
if (const auto flags = strtoul(flagsHex.c_str(), nullptr, 16); !(flags & 0x0001u))
continue;
const auto destPrefix = strtoul(destPrefixHex.c_str(), nullptr, 16);
const auto dest = hexToIpv6Str(destHex);
const auto nextHop = hexToIpv6Str(nextHopHex);
if (dest.empty())
continue;
std::ostringstream route;
route << iface << ": " << dest << "/" << destPrefix;
if (!nextHop.empty() && nextHop != "::")
route << " via " << nextHop;
if (destPrefix == 0 && dest == "::")
route << " [DEFAULT]";
routes.emplace_back(route.str());
}
return routes;
}
} // anonymous namespace
extern "C" JNIEXPORT jobjectArray JNICALL
Java_com_cherepavel_vpndetector_detector_IfconfigTermuxLikeDetector_getInterfacesNative(JNIEnv *env, jobject /*thiz*/) {
return createStringArray(env, collectInterfaceDumps());
}
extern "C" JNIEXPORT jobjectArray JNICALL
Java_com_cherepavel_vpndetector_detector_IfconfigTermuxLikeDetector_getKernelRoutesNative(JNIEnv *env,
jobject /*thiz*/) {
return createStringArray(env, parseKernelRoutes());
}
extern "C" JNIEXPORT jobjectArray JNICALL
Java_com_cherepavel_vpndetector_detector_IfconfigTermuxLikeDetector_getKernelIpv6RoutesNative(JNIEnv *env,
jobject /*thiz*/) {
return createStringArray(env, parseKernelIpv6Routes());
}

View file

@ -0,0 +1,16 @@
#!/bin/bash
# Get the directory this script is located in
PROJECT_FOLDER=$( dirname "$(realpath "$0")" )
cd $PROJECT_FOLDER || exit 1
printf "\n\nPROJECT_FOLDER = ${PROJECT_FOLDER}\n\n"
# Show errors
printf "\nPrint all clang-format errors:\n\n"
find . -not -path "./cmake-build-*/*" -type f \( -name "*.cpp" -o -name "*.hpp" -o -name "*.h" \) -print0 | xargs -0 -I{} clang-format -i {} --dry-run --Werror -style=file:.clang-format
# Fix errors
printf "\nApplying fixes...\n"
find . -not -path "./cmake-build-*/*" -type f \( -name "*.cpp" -o -name "*.hpp" -o -name "*.h" \) -print0 | xargs -0 -I{} clang-format -i {} --Werror -style=file:.clang-format
printf "\nDone\n"

View file

@ -0,0 +1,36 @@
package com.cherepavel.vpndetector.detector
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
object AlwaysOnVpnDetector {
data class Result(
val lockdownLikely: Boolean,
val summary: String?
)
fun detect(connectivityManager: ConnectivityManager): Result {
@Suppress("DEPRECATION")
val allNetworks = connectivityManager.allNetworks
val capsList = allNetworks.mapNotNull { connectivityManager.getNetworkCapabilities(it) }
val hasVpnNetwork = capsList.any { it.hasTransport(NetworkCapabilities.TRANSPORT_VPN) }
// Under lockdown: VPN is present but every validated path has TRANSPORT_VPN
// (non-VPN paths either don't exist or lost VALIDATED capability).
val hasValidatedNonVpnPath = capsList.any { caps ->
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
}
val lockdownLikely = hasVpnNetwork && !hasValidatedNonVpnPath
return Result(
lockdownLikely = lockdownLikely,
summary = if (lockdownLikely) {
"VPN present and no validated non-VPN path exists — lockdown mode likely."
} else null
)
}
}

View file

@ -0,0 +1,168 @@
package com.cherepavel.vpndetector.detector
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import com.cherepavel.vpndetector.model.DetectionSnapshot
import com.cherepavel.vpndetector.util.NetworkSignalAnalyzer
import com.cherepavel.vpndetector.util.TransportInfoFormatter
class DetectionEngine(
private val context: Context,
private val connectivityManager: ConnectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
private val javaInterfacesDetector: JavaInterfacesDetector = JavaInterfacesDetector(),
private val trackedAppsDetector: TrackedAppsDetector = TrackedAppsDetector(context),
private val dynamicVpnAppsDetector: DynamicVpnAppsDetector = DynamicVpnAppsDetector(context),
) : IDetectionEngine {
companion object {
private val MTU_REGEX = Regex("mtu (\\d+)")
private val TYPE_REGEX = Regex("type (\\d+)")
}
override fun detect(): DetectionSnapshot {
@Suppress("DEPRECATION")
val allNetworks = connectivityManager.allNetworks
val activeNetwork = connectivityManager.activeNetwork
val activeCapabilities = activeNetwork?.let(connectivityManager::getNetworkCapabilities)
val vpnNetworks = allNetworks.filter { hasTransportVpn(it) }
val anyVpn = vpnNetworks.isNotEmpty()
val activeVpn = activeCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
val preferredNetwork = vpnNetworks.firstOrNull() ?: activeNetwork ?: allNetworks.firstOrNull()
val preferredLinkProperties: LinkProperties? =
preferredNetwork?.let(connectivityManager::getLinkProperties)
val preferredCapabilities: NetworkCapabilities? =
preferredNetwork?.let(connectivityManager::getNetworkCapabilities)
val rawInterfaceName = preferredLinkProperties?.interfaceName
val transportInfoSummary =
TransportInfoFormatter.summarizeVpnTransportInfo(preferredCapabilities)
val vpnNetwork = vpnNetworks.firstOrNull()
val vpnLinkProps = vpnNetwork?.let(connectivityManager::getLinkProperties)
val vpnCaps = vpnNetwork?.let(connectivityManager::getNetworkCapabilities)
val vpnRoutes = vpnLinkProps?.routes?.map { route ->
buildString {
append(route.destination.toString())
route.gateway?.hostAddress?.let { gw -> append(" via $gw") }
if (route.destination.prefixLength == 0) append(" [DEFAULT]")
}
} ?: emptyList()
val vpnDnsServers = vpnLinkProps?.dnsServers?.mapNotNull { it.hostAddress } ?: emptyList()
val kernelIpv6RoutesResult = IfconfigTermuxLikeDetector.detectKernelIpv6Routes()
val dnsSummary = NetworkSignalAnalyzer.buildDnsSummary(
connectivityManager = connectivityManager,
networks = allNetworks.toList(),
preferredLinkProperties = preferredLinkProperties
)
val policySummary = NetworkSignalAnalyzer.buildPolicySummary(
activeCapabilities = activeCapabilities,
preferredCapabilities = preferredCapabilities
)
val nativeResult = IfconfigTermuxLikeDetector.detect()
val kernelRoutesResult = IfconfigTermuxLikeDetector.detectKernelRoutes()
val javaTunnelNames = javaInterfacesDetector.detectTunnelNames()
val trackedResult = trackedAppsDetector.detect()
val installedVpnApps = trackedResult.installed.map { "${it.label} (${it.packageName})" }
val dynamicVpnApps = dynamicVpnAppsDetector.detect()
val tunTypeInterfaces = nativeResult.allInterfaces.mapNotNull { block ->
val firstLine = block.lineSequence().firstOrNull() ?: return@mapNotNull null
val type = TYPE_REGEX.find(firstLine)?.groupValues?.get(1)?.toIntOrNull()
if (type == 65534) firstLine.substringBefore(':').trim() else null
}
val lowMtuInterfaces = nativeResult.allInterfaces.mapNotNull { block ->
val firstLine = block.lineSequence().firstOrNull() ?: return@mapNotNull null
val name = firstLine.substringBefore(':').trim()
val mtu = MTU_REGEX.find(firstLine)?.groupValues?.get(1)?.toIntOrNull()
val type = TYPE_REGEX.find(firstLine)?.groupValues?.get(1)?.toIntOrNull()
if (mtu != null && mtu < 1500 && type != 772 && name != "lo") "$name: mtu $mtu" else null
}
val alwaysOnResult = AlwaysOnVpnDetector.detect(connectivityManager)
val knownVpnDnsMatches = KnownVpnDnsDetector.detect(dnsSummary.allServers)
val workProfileResult = WorkProfileDetector.detect(context)
val vpnPermissionGranted = VpnPermissionDetector.isThisAppVpnOwner(context)
val vpnBandwidthSummary = vpnCaps?.let { caps ->
val down = caps.linkDownstreamBandwidthKbps
val up = caps.linkUpstreamBandwidthKbps
if (down > 0 || up > 0) "$down Kbps ↑ $up Kbps" else null
}
val nativeTunnelNames = nativeResult.matchedInterfaces
.map { it.substringBefore(':').trim() }
.distinct()
val assessment = DetectionScorer.assess(
DetectionSignals(
activeVpn = activeVpn,
anyVpn = anyVpn,
rawInterfaceName = rawInterfaceName,
transportInfoSummary = transportInfoSummary,
nativeTunnelNames = nativeTunnelNames,
javaTunnelNames = javaTunnelNames,
installedVpnApps = installedVpnApps,
internalDnsServers = dnsSummary.internalServers,
contextualInternalDnsServers = dnsSummary.contextualInternalServers,
activeNetworkNotVpn = policySummary.activeNetworkNotVpn,
preferredNetworkNotVpn = policySummary.preferredNetworkNotVpn,
tunTypeInterfaces = tunTypeInterfaces,
lowMtuInterfaces = lowMtuInterfaces,
lockdownLikely = alwaysOnResult.lockdownLikely,
knownVpnDnsMatches = knownVpnDnsMatches
)
)
return DetectionSnapshot(
hasTransportVpnAny = anyVpn,
hasTransportVpnActive = activeVpn,
rawInterfaceName = rawInterfaceName,
transportInfoSummary = transportInfoSummary,
nativeTunnelNames = nativeTunnelNames,
nativeDetails = nativeResult.allInterfaces,
javaTunnelNames = javaTunnelNames,
installedVpnApps = installedVpnApps,
dynamicVpnApps = dynamicVpnApps,
vpnRoutes = vpnRoutes,
vpnDnsServers = vpnDnsServers,
allDnsServers = dnsSummary.allServers,
internalDnsServers = dnsSummary.internalServers,
contextualInternalDnsServers = dnsSummary.contextualInternalServers,
privateDnsActive = dnsSummary.privateDnsActive,
privateDnsServerName = dnsSummary.privateDnsServerName,
activeNetworkNotVpn = policySummary.activeNetworkNotVpn,
preferredNetworkNotVpn = policySummary.preferredNetworkNotVpn,
kernelRoutes = kernelRoutesResult.routes,
kernelIpv6Routes = kernelIpv6RoutesResult.routes,
tunTypeInterfaces = tunTypeInterfaces,
lowMtuInterfaces = lowMtuInterfaces,
vpnPermissionGranted = vpnPermissionGranted,
vpnBandwidthSummary = vpnBandwidthSummary,
nativeError = listOfNotNull(
nativeResult.nativeError,
kernelRoutesResult.error?.let { "Kernel routes: $it" },
kernelIpv6RoutesResult.error?.let { "Kernel IPv6 routes: $it" }
).joinToString("\n").takeIf { it.isNotBlank() },
trackedAppsErrors = trackedResult.errors,
lockdownLikely = alwaysOnResult.lockdownLikely,
knownVpnDnsMatches = knownVpnDnsMatches,
workProfileCount = workProfileResult.profileCount,
isManagedProfile = workProfileResult.isManagedProfile,
assessment = assessment
)
}
private fun hasTransportVpn(network: Network): Boolean {
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
}
}

View file

@ -0,0 +1,140 @@
package com.cherepavel.vpndetector.detector
import com.cherepavel.vpndetector.model.DetectionAssessment
import com.cherepavel.vpndetector.model.DetectionCategory
import com.cherepavel.vpndetector.model.DetectionConfidence
import com.cherepavel.vpndetector.model.DetectionEvidence
import com.cherepavel.vpndetector.model.DetectionStatus
object DetectionScorer {
fun assess(signals: DetectionSignals): DetectionAssessment {
val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(signals.rawInterfaceName)
val transportInfoDetected = !signals.transportInfoSummary.isNullOrBlank()
val evidence = listOf(
DetectionEvidence(
key = "active_transport_vpn",
category = DetectionCategory.OFFICIAL,
weight = 100,
present = signals.activeVpn,
summary = "Android marks the active network with TRANSPORT_VPN."
),
DetectionEvidence(
key = "background_transport_vpn",
category = DetectionCategory.OFFICIAL,
weight = 70,
present = signals.anyVpn && !signals.activeVpn,
summary = "Android sees a VPN network, but not on the current active path."
),
DetectionEvidence(
key = "tunnel_like_interface",
category = DetectionCategory.HEURISTIC,
weight = 25,
present = interfaceDetected,
summary = "LinkProperties exposed a tunnel-like interface name."
),
DetectionEvidence(
key = "vpn_transport_info",
category = DetectionCategory.HEURISTIC,
weight = 25,
present = transportInfoDetected,
summary = "NetworkCapabilities exposed VPN-related transport info."
),
DetectionEvidence(
key = "native_tunnel_interfaces",
category = DetectionCategory.HEURISTIC,
weight = 30,
present = signals.nativeTunnelNames.isNotEmpty(),
summary = "Native getifaddrs() found tunnel-like interfaces."
),
DetectionEvidence(
key = "java_tunnel_interfaces",
category = DetectionCategory.HEURISTIC,
weight = 20,
present = signals.javaTunnelNames.isNotEmpty(),
summary = "Java NetworkInterface enumeration found tunnel-like interfaces."
),
DetectionEvidence(
key = "internal_dns_on_tunnel",
category = DetectionCategory.HEURISTIC,
weight = 25,
present = signals.internalDnsServers.isNotEmpty(),
summary = "Internal/private DNS servers were observed on tunnel-like interfaces."
),
DetectionEvidence(
key = "not_vpn_capability_cleared",
category = DetectionCategory.HEURISTIC,
weight = 15,
present = signals.activeNetworkNotVpn == false || signals.preferredNetworkNotVpn == false,
summary = "At least one inspected network cleared NET_CAPABILITY_NOT_VPN."
),
DetectionEvidence(
key = "tun_interface_type",
category = DetectionCategory.HEURISTIC,
weight = 15,
present = signals.tunTypeInterfaces.isNotEmpty(),
summary = "A Linux TUN interface type was observed."
),
DetectionEvidence(
key = "low_mtu_interface",
category = DetectionCategory.CONTEXT,
weight = 5,
present = signals.lowMtuInterfaces.isNotEmpty(),
summary = "A low-MTU interface was observed."
),
DetectionEvidence(
key = "installed_vpn_apps",
category = DetectionCategory.APP,
weight = 10,
present = signals.installedVpnApps.isNotEmpty(),
summary = "Known VPN-related apps are installed on the device."
),
DetectionEvidence(
key = "carrier_private_dns_context",
category = DetectionCategory.CONTEXT,
weight = 0,
present = signals.contextualInternalDnsServers.isNotEmpty(),
summary = "Carrier private DNS was observed on a cellular interface and is context only."
),
DetectionEvidence(
key = "lockdown_likely",
category = DetectionCategory.HEURISTIC,
weight = 30,
present = signals.lockdownLikely,
summary = "VPN present and no validated non-VPN path exists — always-on lockdown likely."
),
DetectionEvidence(
key = "known_vpn_dns",
category = DetectionCategory.HEURISTIC,
weight = 20,
present = signals.knownVpnDnsMatches.isNotEmpty(),
summary = "DNS servers matching known VPN provider addresses were observed."
),
)
val score = evidence.filter { it.present }.sumOf { it.weight }.coerceAtMost(100)
val status = when {
signals.activeVpn -> DetectionStatus.ACTIVE_VPN
signals.anyVpn -> DetectionStatus.SPLIT_TUNNEL
score >= 35 -> DetectionStatus.VPN_LIKE
signals.installedVpnApps.isNotEmpty() -> DetectionStatus.APPS_PRESENT
else -> DetectionStatus.NO_EVIDENCE
}
val confidence = when (status) {
DetectionStatus.ACTIVE_VPN -> DetectionConfidence.CONFIRMED
DetectionStatus.SPLIT_TUNNEL -> DetectionConfidence.LIKELY
DetectionStatus.VPN_LIKE -> DetectionConfidence.LIKELY
DetectionStatus.APPS_PRESENT -> DetectionConfidence.WEAK_SIGNAL
DetectionStatus.NO_EVIDENCE ->
if (score > 0) DetectionConfidence.WEAK_SIGNAL else DetectionConfidence.NO_EVIDENCE
}
return DetectionAssessment(
status = status,
confidence = confidence,
score = score,
evidence = evidence
)
}
}

View file

@ -0,0 +1,19 @@
package com.cherepavel.vpndetector.detector
data class DetectionSignals(
val activeVpn: Boolean,
val anyVpn: Boolean,
val rawInterfaceName: String?,
val transportInfoSummary: String?,
val nativeTunnelNames: List<String>,
val javaTunnelNames: List<String>,
val installedVpnApps: List<String>,
val internalDnsServers: List<String>,
val contextualInternalDnsServers: List<String>,
val activeNetworkNotVpn: Boolean?,
val preferredNetworkNotVpn: Boolean?,
val tunTypeInterfaces: List<String>,
val lowMtuInterfaces: List<String>,
val lockdownLikely: Boolean,
val knownVpnDnsMatches: List<String>
)

View file

@ -0,0 +1,59 @@
package com.cherepavel.vpndetector.detector
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
class DynamicVpnAppsDetector(private val context: Context) {
/** Apps that export a service with action android.net.VpnService. */
fun detectByIntent(): List<String> {
return try {
context.packageManager
.queryIntentServices(Intent("android.net.VpnService"), 0)
.map { it.serviceInfo.packageName }
.distinct()
.sorted()
} catch (_: Throwable) {
emptyList()
}
}
/**
* Apps that declare a service protected by android.permission.BIND_VPN_SERVICE.
* This catches VPN apps that don't export the service with a standard action.
*
* Requires QUERY_ALL_PACKAGES or a matching <queries> element on API 30+.
*/
fun detectByServicePermission(): List<String> {
return try {
val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.getInstalledPackages(
PackageManager.PackageInfoFlags.of(PackageManager.GET_SERVICES.toLong())
)
} else {
@Suppress("DEPRECATION")
context.packageManager.getInstalledPackages(PackageManager.GET_SERVICES)
}
packages
.filter { pkg ->
pkg.services?.any { svc ->
svc.permission == "android.permission.BIND_VPN_SERVICE"
} == true
}
.map { it.packageName }
.distinct()
.sorted()
} catch (_: Throwable) {
emptyList()
}
}
/** Combined result: union of both detection methods, deduped. */
fun detect(): List<String> {
return (detectByIntent() + detectByServicePermission())
.distinct()
.sorted()
}
}

View file

@ -0,0 +1,7 @@
package com.cherepavel.vpndetector.detector
import com.cherepavel.vpndetector.model.DetectionSnapshot
interface IDetectionEngine {
fun detect(): DetectionSnapshot
}

View file

@ -0,0 +1,77 @@
package com.cherepavel.vpndetector.detector
data class KernelRoutesResult(
val routes: List<String>,
val error: String? = null
)
object IfconfigTermuxLikeDetector {
private var libraryLoaded = false
init {
libraryLoaded = try {
System.loadLibrary("ifconfigdetector")
true
} catch (_: UnsatisfiedLinkError) {
false
}
}
external fun getInterfacesNative(): Array<String>
external fun getKernelRoutesNative(): Array<String>
external fun getKernelIpv6RoutesNative(): Array<String>
fun detect(): IfconfigTermuxLikeResult {
if (!libraryLoaded) {
return IfconfigTermuxLikeResult(
vpnLikely = false,
matchedInterfaces = emptyList(),
allInterfaces = emptyList(),
nativeError = "Native library failed to load"
)
}
val allBlocks: List<String>
try {
allBlocks = getInterfacesNative().toList()
} catch (e: Throwable) {
return IfconfigTermuxLikeResult(
vpnLikely = false,
matchedInterfaces = emptyList(),
allInterfaces = emptyList(),
nativeError = "getInterfacesNative failed: ${e.javaClass.simpleName}: ${e.message}"
)
}
val matched = allBlocks.filter { block ->
val firstLine = block.lineSequence().firstOrNull().orEmpty()
TunnelNameMatcher.looksLikeTunnelName(firstLine.substringBefore(':').trim())
}
return IfconfigTermuxLikeResult(
vpnLikely = matched.isNotEmpty(),
matchedInterfaces = matched,
allInterfaces = allBlocks,
nativeError = null
)
}
fun detectKernelRoutes(): KernelRoutesResult {
if (!libraryLoaded) return KernelRoutesResult(emptyList(), "Native library failed to load")
return try {
KernelRoutesResult(getKernelRoutesNative().toList())
} catch (e: Throwable) {
KernelRoutesResult(emptyList(), "${e.javaClass.simpleName}: ${e.message}")
}
}
fun detectKernelIpv6Routes(): KernelRoutesResult {
if (!libraryLoaded) return KernelRoutesResult(emptyList(), "Native library failed to load")
return try {
KernelRoutesResult(getKernelIpv6RoutesNative().toList())
} catch (e: Throwable) {
KernelRoutesResult(emptyList(), "${e.javaClass.simpleName}: ${e.message}")
}
}
}

View file

@ -3,5 +3,6 @@ package com.cherepavel.vpndetector.detector
data class IfconfigTermuxLikeResult(
val vpnLikely: Boolean,
val matchedInterfaces: List<String>,
val allInterfaces: List<String>
val allInterfaces: List<String>,
val nativeError: String? = null
)

View file

@ -0,0 +1,33 @@
package com.cherepavel.vpndetector.detector
object KnownVpnDnsDetector {
/**
* Public/semi-public DNS IPs that are specific to known VPN providers.
* Internal RFC-1918 addresses (e.g. ProtonVPN's 10.2.0.1) are already caught
* by NetworkSignalAnalyzer.isSuspiciousInternalDnsAddress and are not listed here.
*/
private val KNOWN_VPN_DNS: Map<String, String> = mapOf(
"193.19.108.2" to "Mullvad",
"193.19.108.3" to "Mullvad",
"185.95.218.42" to "Mullvad",
"185.95.218.43" to "Mullvad",
"100.100.100.100" to "Tailscale (MagicDNS)",
"103.86.96.100" to "NordVPN",
"103.86.99.100" to "NordVPN",
"10.64.0.1" to "Mullvad (internal)",
)
/**
* Receives labeled DNS entries in "iface:address" format from NetworkSignalAnalyzer
* and returns matches as "Provider (address)" strings.
*/
fun detect(labeledDnsServers: List<String>): List<String> {
return labeledDnsServers
.mapNotNull { labeled ->
val address = labeled.substringAfter(':').trim()
KNOWN_VPN_DNS[address]?.let { provider -> "$provider ($address)" }
}
.distinct()
}
}

View file

@ -0,0 +1,54 @@
package com.cherepavel.vpndetector.detector
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import com.cherepavel.vpndetector.model.TrackedApp
data class TrackedAppsResult(
val installed: List<TrackedApp>,
val errors: Map<String, String>
)
class TrackedAppsDetector(
private val context: Context
) {
fun detect(): TrackedAppsResult {
val installed = mutableListOf<TrackedApp>()
val errors = mutableMapOf<String, String>()
for (app in TrackedAppsRepository.get(context)) {
when (val result = checkApp(app.packageName)) {
CheckResult.Installed -> installed.add(app)
CheckResult.NotInstalled -> Unit
is CheckResult.Error -> errors[app.packageName] = result.message
}
}
return TrackedAppsResult(installed = installed, errors = errors)
}
private sealed class CheckResult {
object Installed : CheckResult()
object NotInstalled : CheckResult()
data class Error(val message: String) : CheckResult()
}
private fun checkApp(packageName: String): CheckResult {
return try {
val pm = context.packageManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
pm.getPackageInfo(packageName, 0)
}
CheckResult.Installed
} catch (_: PackageManager.NameNotFoundException) {
CheckResult.NotInstalled
} catch (e: Throwable) {
CheckResult.Error("${e.javaClass.simpleName}: ${e.message}")
}
}
}

View file

@ -0,0 +1,40 @@
package com.cherepavel.vpndetector.detector
import android.content.Context
import com.cherepavel.vpndetector.model.TrackedApp
import org.json.JSONArray
object TrackedAppsRepository {
@Volatile
private var cached: List<TrackedApp>? = null
private const val FILE_NAME = "tracked_apps.json"
fun get(context: Context): List<TrackedApp> {
cached?.let { return it }
return try {
val json = context.applicationContext.assets
.open(FILE_NAME)
.bufferedReader()
.use { it.readText() }
parse(json).also { cached = it }
} catch (_: Exception) {
emptyList()
}
}
private fun parse(json: String): List<TrackedApp> {
val array = JSONArray(json)
return (0 until array.length()).map { i ->
val obj = array.getJSONObject(i)
TrackedApp(
packageName = obj.getString("packageName"),
label = obj.getString("label")
)
}
}
}

View file

@ -13,7 +13,12 @@ object TunnelNameMatcher {
"ipsec",
"xfrm",
"zt",
"tailscale"
"tailscale",
"svpn",
"ovpn",
"l2tp",
"gre",
"he-ipv6"
)
private val tunnelContains = listOf(

View file

@ -0,0 +1,23 @@
package com.cherepavel.vpndetector.detector
import android.content.Context
import android.net.VpnService
object VpnPermissionDetector {
/**
* Returns true if the calling app currently holds Android VPN permission.
*
* VpnService.prepare() returns null when the calling app already owns the VPN
* grant, and an Intent otherwise. For a passive detector app this will almost
* always be false. A true result means the detector itself was previously
* granted VPN permission an anomalous state worth flagging.
*/
fun isThisAppVpnOwner(context: Context): Boolean {
return try {
VpnService.prepare(context) == null
} catch (_: Throwable) {
false
}
}
}

View file

@ -0,0 +1,41 @@
package com.cherepavel.vpndetector.detector
import android.content.Context
import android.os.Build
import android.os.UserManager
/**
* Detects the presence of a work/managed profile.
*
* VPN apps installed inside a work profile are invisible to the primary user's
* PackageManager, so TrackedAppsDetector and DynamicVpnAppsDetector cannot see them.
* This detector flags the limitation so it can be surfaced in the report.
*/
object WorkProfileDetector {
data class Result(
val hasMultipleProfiles: Boolean,
val profileCount: Int,
val isManagedProfile: Boolean
)
fun detect(context: Context): Result {
val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
val profileCount = runCatching { userManager.userProfiles.size }.getOrDefault(1)
val isManagedProfile = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
userManager.isManagedProfile
} else {
false
}
}.getOrDefault(false)
return Result(
hasMultipleProfiles = profileCount > 1,
profileCount = profileCount,
isManagedProfile = isManagedProfile
)
}
}

View file

@ -0,0 +1,75 @@
package com.cherepavel.vpndetector.model
enum class DetectionCategory {
OFFICIAL,
HEURISTIC,
APP,
CONTEXT
}
enum class DetectionConfidence {
CONFIRMED,
LIKELY,
WEAK_SIGNAL,
NO_EVIDENCE
}
enum class DetectionStatus {
ACTIVE_VPN,
SPLIT_TUNNEL,
VPN_LIKE,
APPS_PRESENT,
NO_EVIDENCE
}
data class DetectionEvidence(
val key: String,
val category: DetectionCategory,
val weight: Int,
val present: Boolean,
val summary: String
)
data class DetectionAssessment(
val status: DetectionStatus,
val confidence: DetectionConfidence,
val score: Int,
val evidence: List<DetectionEvidence>
)
data class DetectionSnapshot(
val hasTransportVpnAny: Boolean,
val hasTransportVpnActive: Boolean,
val rawInterfaceName: String?,
val transportInfoSummary: String?,
val nativeTunnelNames: List<String>,
val nativeDetails: List<String>,
val javaTunnelNames: List<String>,
val installedVpnApps: List<String>,
val dynamicVpnApps: List<String>,
val vpnRoutes: List<String>,
val vpnDnsServers: List<String>,
val allDnsServers: List<String>,
val internalDnsServers: List<String>,
val contextualInternalDnsServers: List<String>,
val privateDnsActive: Boolean,
val privateDnsServerName: String?,
val activeNetworkNotVpn: Boolean?,
val preferredNetworkNotVpn: Boolean?,
val kernelRoutes: List<String>,
val kernelIpv6Routes: List<String>,
val tunTypeInterfaces: List<String>,
val lowMtuInterfaces: List<String>,
val vpnPermissionGranted: Boolean,
val vpnBandwidthSummary: String?,
val nativeError: String?,
val trackedAppsErrors: Map<String, String>,
val lockdownLikely: Boolean,
val knownVpnDnsMatches: List<String>,
val workProfileCount: Int,
val isManagedProfile: Boolean,
val assessment: DetectionAssessment
) {
val unknownDynamicApps: List<String>
get() = dynamicVpnApps.filter { pkg -> installedVpnApps.none { it.contains(pkg) } }
}

View file

@ -0,0 +1,131 @@
package com.cherepavel.vpndetector.util
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import com.cherepavel.vpndetector.detector.TunnelNameMatcher
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
data class DnsSignalSummary(
val allServers: List<String>,
val internalServers: List<String>,
val contextualInternalServers: List<String>,
val privateDnsActive: Boolean,
val privateDnsServerName: String?
)
data class VpnPolicySummary(
val activeNetworkNotVpn: Boolean?,
val preferredNetworkNotVpn: Boolean?
)
object NetworkSignalAnalyzer {
fun buildDnsSummary(
connectivityManager: ConnectivityManager,
networks: List<Network>,
preferredLinkProperties: LinkProperties?
): DnsSignalSummary {
val labeledServers = linkedSetOf<String>()
val internalServers = linkedSetOf<String>()
val contextualInternalServers = linkedSetOf<String>()
for (network in networks) {
val linkProperties = connectivityManager.getLinkProperties(network) ?: continue
val iface = linkProperties.interfaceName ?: network.toString()
for (address in linkProperties.dnsServers) {
val hostAddress = address.hostAddress ?: continue
val labeled = "$iface:$hostAddress"
labeledServers += labeled
if (isSuspiciousInternalDnsAddress(iface, address)) {
internalServers += labeled
} else if (isContextualInternalDnsAddress(iface, address)) {
contextualInternalServers += labeled
}
}
}
val privateDnsActive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
preferredLinkProperties?.isPrivateDnsActive == true
} else {
false
}
val privateDnsServerName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
preferredLinkProperties?.privateDnsServerName?.takeIf { it.isNotBlank() }
} else {
null
}
return DnsSignalSummary(
allServers = labeledServers.toList(),
internalServers = internalServers.toList(),
contextualInternalServers = contextualInternalServers.toList(),
privateDnsActive = privateDnsActive,
privateDnsServerName = privateDnsServerName
)
}
fun buildPolicySummary(
activeCapabilities: NetworkCapabilities?,
preferredCapabilities: NetworkCapabilities?
): VpnPolicySummary {
return VpnPolicySummary(
activeNetworkNotVpn = activeCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN),
preferredNetworkNotVpn = preferredCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
)
}
private fun isSuspiciousInternalDnsAddress(
interfaceName: String?,
address: InetAddress
): Boolean {
val iface = interfaceName?.trim().orEmpty()
if (!isInternalDnsAddress(address)) return false
if (iface.isBlank()) return false
if (isLikelyCellularInterface(iface)) return false
return TunnelNameMatcher.looksLikeTunnelName(iface)
}
private fun isContextualInternalDnsAddress(
interfaceName: String?,
address: InetAddress
): Boolean {
val iface = interfaceName?.trim().orEmpty()
if (!isInternalDnsAddress(address)) return false
if (iface.isBlank()) return false
return isLikelyCellularInterface(iface)
}
private fun isLikelyCellularInterface(interfaceName: String): Boolean {
val lowered = interfaceName.lowercase()
return lowered.startsWith("rmnet") ||
lowered.startsWith("ccmni") ||
lowered.startsWith("pdp") ||
lowered.startsWith("v4-rmnet") ||
lowered.startsWith("vif")
}
private fun isInternalDnsAddress(address: InetAddress): Boolean {
return when (address) {
is Inet4Address -> {
val bytes = address.address
val first = bytes[0].toInt() and 0xFF
val second = bytes[1].toInt() and 0xFF
first == 10 ||
(first == 172 && second in 16..31) ||
(first == 192 && second == 168) ||
(first == 100 && second in 64..127)
}
is Inet6Address -> {
val first = address.address[0].toInt() and 0xFE
first == 0xFC
}
else -> false
}
}
}

View file

@ -11,7 +11,9 @@ object TransportInfoFormatter {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null
val transportInfo = capabilities.transportInfo ?: return null
val text = transportInfo.javaClass.simpleName ?: transportInfo.toString()
val simpleName = transportInfo.javaClass.simpleName ?: transportInfo.toString()
val vpnType = readVpnType(transportInfo)
val text = if (vpnType != null) "$simpleName(type=$vpnType)" else simpleName
return text
.takeIf { it.isNotBlank() }
@ -21,4 +23,20 @@ object TransportInfoFormatter {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
}
}
private fun readVpnType(transportInfo: Any): String? {
val className = transportInfo.javaClass.name
if (!className.endsWith("VpnTransportInfo")) return null
val typeValue = runCatching {
transportInfo.javaClass.getMethod("getType").invoke(transportInfo) as? Int
}.getOrNull() ?: return null
return when (typeValue) {
1 -> "PLATFORM"
2 -> "LEGACY"
3 -> "IKEV2"
else -> "UNKNOWN:$typeValue"
}
}
}

View file

@ -12,4 +12,12 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
kotlin.code.style=official
android.useAndroidX=true
android.suppressUnsupportedCompileSdk=36
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false

View file

@ -1,13 +1,13 @@
[versions]
agp = "9.1.0"
coreKtx = "1.10.1"
coreKtx = "1.18.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
appcompat = "1.6.1"
material = "1.10.0"
activity = "1.8.0"
constraintlayout = "2.1.4"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
appcompat = "1.7.1"
material = "1.13.0"
activity = "1.13.0"
constraintlayout = "2.2.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -21,4 +21,5 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }

0
gradlew vendored Normal file → Executable file
View file

View file

@ -0,0 +1,40 @@
Categories:
- Security
License: MIT
Web Site: https://github.com/cherepavel/VPN-Detector
Source Code: https://github.com/cherepavel/VPN-Detector
Issue Tracker: https://github.com/cherepavel/VPN-Detector/issues
AutoName: VPN Detector
Summary: Detects active VPN connections and interfaces on Android
Description: |
VPN Detector is a research and diagnostic tool for analyzing VPN detection
mechanisms on Android. No root required, no ads, no tracking.
It shows:
* Active VPN interfaces (tun0, wg0, utun, etc.)
* Kernel routing table
* DNS servers in use
* Apps currently using VPN
* NetworkCapabilities.TRANSPORT_VPN state
* Always-on VPN and Work Profile detection
* Local proxy and known VPN DNS detection
Useful for privacy researchers, developers, and power users who want to
understand how applications can detect VPN presence even with split tunneling.
VPN Detector — диагностический инструмент для анализа механизмов обнаружения
VPN на Android. Без root, без рекламы, без слежки.
Builds:
- versionName: '1.0'
versionCode: 1
commit: v1.0
subdir: app
gradle:
- yes
ndk: 27.2.12479018
AutoUpdateMode: Version v%v
UpdateCheckMode: Tags

View file

@ -1,19 +1,17 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("com.android.library") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
@ -22,5 +20,6 @@ dependencyResolutionManagement {
}
}
rootProject.name = "VpnDetector"
rootProject.name = "VPN-Detector"
include(":app")
include(":detector")