Merge pull request #11 from wecand0/fdroid

внедрение detection engine и модульной архитектуры детектирования VPN
This commit is contained in:
Vadim 2026-04-09 14:24:47 +03:00 committed by GitHub
parent 702874ca79
commit 26fc550b7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2241 additions and 727 deletions

View file

@ -2,9 +2,8 @@ plugins {
id("com.android.application")
}
android {
extensions.configure<com.android.build.api.dsl.ApplicationExtension> {
namespace = "com.cherepavel.vpndetector"
compileSdk = 36
defaultConfig {
@ -15,27 +14,12 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
cmake {
cppFlags += ""
}
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
isShrinkResources = false
}
}
@ -45,12 +29,20 @@ android {
}
}
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)

View file

@ -1,18 +1,19 @@
<?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.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
tools:ignore="ForegroundServicePermission" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<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 +32,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.VpnMonitorService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
</manifest>

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,12 @@
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.NetworkRequest
import android.net.Uri
import android.os.Bundle
import android.view.MotionEvent
@ -21,17 +22,22 @@ 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.detector.TrackedAppsRepository
import com.cherepavel.vpndetector.model.DetectionSnapshot
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.util.nowString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter
class MainActivity : AppCompatActivity() {
@ -83,14 +89,31 @@ class MainActivity : AppCompatActivity() {
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 var scheduledRefreshJob: Job? = null
private var networkCallbackRegistered = false
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = scheduleRefresh()
override fun onLost(network: Network) = scheduleRefresh()
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) = scheduleRefresh()
override fun onLinkPropertiesChanged(
network: Network,
linkProperties: LinkProperties
) = scheduleRefresh()
}
private val createDocumentLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
@ -117,9 +140,18 @@ class MainActivity : AppCompatActivity() {
bindViews()
setupListeners()
registerNetworkCallback()
lifecycleScope.launch(Dispatchers.IO) { TrackedAppsRepository.refresh(applicationContext) }
refreshUi()
}
override fun onDestroy() {
unregisterNetworkCallback()
detectionJob?.cancel()
scheduledRefreshJob?.cancel()
super.onDestroy()
}
private fun bindViews() {
cardStatus = findViewById(R.id.cardStatus)
textVpnStatus = findViewById(R.id.textVpnStatus)
@ -169,6 +201,7 @@ class MainActivity : AppCompatActivity() {
apiSignalHints = listOf(textApiSignalHint1, textApiSignalHint2)
}
@SuppressLint("ClickableViewAccessibility")
private fun setupListeners() {
buttonRefresh.setOnClickListener { refreshUi() }
@ -192,71 +225,67 @@ class MainActivity : AppCompatActivity() {
}
}
private data class DetectionOutput(
val report: DetectionReport,
val snapshot: DetectionSnapshot,
val exportText: String
)
private fun refreshUi() {
val connectivityManager =
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
detectionJob?.cancel()
buttonRefresh.isEnabled = false
buttonReport.isEnabled = false
detectionJob = lifecycleScope.launch {
val output = withContext(Dispatchers.IO) { runDetection() }
renderReport(output.report)
renderLastUpdate()
lastExportText = output.exportText
buttonRefresh.isEnabled = true
buttonReport.isEnabled = true
}
}
val allNetworks = connectivityManager.allNetworks.orEmpty()
val activeNetwork = connectivityManager.activeNetwork
private fun scheduleRefresh() {
scheduledRefreshJob?.cancel()
scheduledRefreshJob = lifecycleScope.launch {
delay(250)
refreshUi()
}
}
val vpnNetworks = allNetworks.filter { hasTransportVpn(connectivityManager, it) }
val anyVpn = vpnNetworks.isNotEmpty()
val activeVpn = activeNetwork?.let { hasTransportVpn(connectivityManager, it) } ?: false
private fun registerNetworkCallback() {
if (networkCallbackRegistered) return
val request = NetworkRequest.Builder().build()
runCatching {
connectivityManager.registerNetworkCallback(request, networkCallback)
}.onSuccess {
networkCallbackRegistered = true
}
}
val preferredNetwork = vpnNetworks.firstOrNull() ?: activeNetwork ?: allNetworks.firstOrNull()
private fun unregisterNetworkCallback() {
if (!networkCallbackRegistered) return
runCatching {
connectivityManager.unregisterNetworkCallback(networkCallback)
}
networkCallbackRegistered = false
}
val preferredLinkProperties: LinkProperties? =
preferredNetwork?.let(connectivityManager::getLinkProperties)
private fun runDetection(): DetectionOutput {
val snapshot = detectionEngine.detect()
val report = ReportFormatter.build(snapshot)
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
val exportText = ReportExportFormatter.buildText(
ReportExportFormatter.ExportInput(
report = report,
snapshot = snapshot
)
)
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,
snapshot = snapshot,
exportText = exportText
)
}
@ -266,7 +295,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,11 +307,11 @@ 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)
@ -345,14 +374,7 @@ 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()}"
}
@ -417,10 +439,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,166 @@
package com.cherepavel.vpndetector.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.cherepavel.vpndetector.MainActivity
import com.cherepavel.vpndetector.R
import com.cherepavel.vpndetector.detector.DetectionEngine
import com.cherepavel.vpndetector.detector.IDetectionEngine
import com.cherepavel.vpndetector.model.DetectionSnapshot
import com.cherepavel.vpndetector.model.DetectionStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class VpnMonitorService : Service() {
private val connectivityManager by lazy {
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
private val detectionEngine: IDetectionEngine by lazy { DetectionEngine(this) }
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var networkCallbackRegistered = false
private var debounceJob: Job? = null
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = scheduleDetection()
override fun onLost(network: Network) = scheduleDetection()
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) = scheduleDetection()
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIFICATION_ID, buildNotification("Monitoring VPN state…"))
registerNetworkCallback()
scheduleDetection()
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
unregisterNetworkCallback()
scope.cancel()
super.onDestroy()
}
private fun scheduleDetection() {
debounceJob?.cancel()
debounceJob = scope.launch {
delay(DEBOUNCE_MS)
val snapshot = withContext(Dispatchers.IO) { detectionEngine.detect() }
updateNotification(snapshot)
}
}
private fun updateNotification(snapshot: DetectionSnapshot) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(NOTIFICATION_ID, buildNotification(snapshot.toStatusText()))
}
private fun DetectionSnapshot.toStatusText(): String = when (assessment.status) {
DetectionStatus.ACTIVE_VPN ->
if (lockdownLikely) "VPN active — lockdown mode" else "VPN active"
DetectionStatus.SPLIT_TUNNEL ->
"VPN present (split tunnel / bypass)"
DetectionStatus.VPN_LIKE ->
"VPN-like signals detected (score ${assessment.score}/100)"
DetectionStatus.APPS_PRESENT ->
"No active VPN — VPN apps installed"
DetectionStatus.NO_EVIDENCE ->
"No VPN detected"
}
private fun buildNotification(text: String): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("VPN Detector")
.setContentText(text)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setShowWhen(false)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"VPN Monitor",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Live VPN detection status"
setShowBadge(false)
}
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
}
private fun registerNetworkCallback() {
if (networkCallbackRegistered) return
runCatching {
connectivityManager.registerNetworkCallback(
NetworkRequest.Builder().build(),
networkCallback
)
}.onSuccess { networkCallbackRegistered = true }
}
private fun unregisterNetworkCallback() {
if (!networkCallbackRegistered) return
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
networkCallbackRegistered = false
}
companion object {
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "vpn_monitor_channel"
private const val DEBOUNCE_MS = 300L
fun start(context: Context) {
val intent = Intent(context, VpnMonitorService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, VpnMonitorService::class.java))
}
}
}

View file

@ -1,18 +1,18 @@
package com.cherepavel.vpndetector.ui
import com.cherepavel.vpndetector.model.DetectionSnapshot
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>
val snapshot: DetectionSnapshot
)
fun buildText(input: ExportInput): String {
val report = input.report
val snapshot = input.snapshot
return buildString {
appendLine("VPN Detector Report")
@ -23,6 +23,9 @@ object ReportExportFormatter {
appendLine(report.overallTitle)
appendLine(report.overallSummary)
appendLine(report.overallExplanation)
appendLine("Score: ${snapshot.assessment.score}/100")
appendLine("Confidence: ${snapshot.assessment.confidence}")
appendLine("Status: ${snapshot.assessment.status}")
appendLine()
appendLine("=== OFFICIAL ANDROID API ===")
@ -43,27 +46,128 @@ object ReportExportFormatter {
appendLine()
}
if (snapshot.vpnRoutes.isNotEmpty() || snapshot.vpnDnsServers.isNotEmpty() ||
snapshot.allDnsServers.isNotEmpty() || snapshot.internalDnsServers.isNotEmpty() ||
snapshot.contextualInternalDnsServers.isNotEmpty() ||
snapshot.privateDnsActive || snapshot.privateDnsServerName != null ||
snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null ||
snapshot.vpnBandwidthSummary != null) {
appendLine("=== VPN NETWORK DETAILS ===")
if (snapshot.vpnRoutes.isNotEmpty()) {
appendLine("Routes:")
snapshot.vpnRoutes.forEach { appendLine(" $it") }
}
if (snapshot.vpnDnsServers.isNotEmpty()) {
appendLine("DNS servers: ${snapshot.vpnDnsServers.joinToString(", ")}")
}
if (snapshot.allDnsServers.isNotEmpty()) {
appendLine("DNS across visible networks:")
snapshot.allDnsServers.forEach { appendLine(" $it") }
}
if (snapshot.internalDnsServers.isNotEmpty()) {
appendLine("Internal/private-range DNS servers:")
snapshot.internalDnsServers.forEach { appendLine(" $it") }
}
if (snapshot.contextualInternalDnsServers.isNotEmpty()) {
appendLine("Cellular private DNS observed (not treated as VPN):")
snapshot.contextualInternalDnsServers.forEach { appendLine(" $it") }
}
if (snapshot.privateDnsActive || snapshot.privateDnsServerName != null) {
appendLine(
"Private DNS: " + buildString {
append(if (snapshot.privateDnsActive) "active" else "inactive")
snapshot.privateDnsServerName?.let { append(" ($it)") }
}
)
}
if (snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null) {
appendLine(
"NET_CAPABILITY_NOT_VPN: active=${snapshot.activeNetworkNotVpn ?: "unknown"}, " +
"preferred=${snapshot.preferredNetworkNotVpn ?: "unknown"}"
)
}
snapshot.vpnBandwidthSummary?.let { appendLine("Bandwidth: $it") }
appendLine()
}
if (snapshot.tunTypeInterfaces.isNotEmpty() || snapshot.lowMtuInterfaces.isNotEmpty() ||
snapshot.kernelRoutes.isNotEmpty() || snapshot.kernelIpv6Routes.isNotEmpty() ||
snapshot.vpnPermissionGranted) {
appendLine("=== ADDITIONAL SIGNALS ===")
if (snapshot.tunTypeInterfaces.isNotEmpty()) {
appendLine("TUN interfaces (type=65534): ${snapshot.tunTypeInterfaces.joinToString(", ")}")
}
if (snapshot.lowMtuInterfaces.isNotEmpty()) {
appendLine("Low-MTU interfaces (<1500):")
snapshot.lowMtuInterfaces.forEach { appendLine(" $it") }
}
if (snapshot.kernelRoutes.isNotEmpty()) {
appendLine("Kernel route table (/proc/net/route):")
snapshot.kernelRoutes.forEach { appendLine(" $it") }
}
if (snapshot.kernelIpv6Routes.isNotEmpty()) {
appendLine("Kernel route table (/proc/net/ipv6_route):")
snapshot.kernelIpv6Routes.forEach { appendLine(" $it") }
}
if (snapshot.vpnPermissionGranted) {
appendLine("VPN permission: this app holds VPN grant (anomalous).")
}
appendLine()
}
appendLine("=== NATIVE LOW-LEVEL ENUMERATION ===")
if (snapshot.nativeError != null) {
appendLine("Error: ${snapshot.nativeError}")
}
appendLine("Signal value: ${report.nativeSignal.value}")
appendLine("Signal hint: ${report.nativeSignal.hint}")
appendLine()
appendLine(input.nativeDetailsRaw)
appendLine(report.nativeDetails)
appendLine()
appendLine("=== JAVA INTERFACE ENUMERATION ===")
appendLine("Signal value: ${report.javaSignal.value}")
appendLine("Signal hint: ${report.javaSignal.hint}")
if (input.javaTunnelNames.isNotEmpty()) {
if (snapshot.javaTunnelNames.isNotEmpty()) {
appendLine("Matched tunnel-like names:")
input.javaTunnelNames.forEach { appendLine("- $it") }
snapshot.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") }
if (snapshot.installedVpnApps.isNotEmpty()) {
appendLine("From tracked list:")
snapshot.installedVpnApps.forEach { appendLine("- $it") }
}
if (snapshot.unknownDynamicApps.isNotEmpty()) {
appendLine("Detected via VpnService query:")
snapshot.unknownDynamicApps.forEach { appendLine("- $it") }
}
if (snapshot.trackedAppsErrors.isNotEmpty()) {
appendLine("Check errors:")
snapshot.trackedAppsErrors.forEach { (pkg, err) -> appendLine("- $pkg: $err") }
}
if (snapshot.installedVpnApps.isEmpty() && snapshot.unknownDynamicApps.isEmpty()) {
appendLine("No VPN-related apps detected.")
}
if (snapshot.lockdownLikely || snapshot.knownVpnDnsMatches.isNotEmpty() ||
snapshot.workProfileCount > 1 || snapshot.isManagedProfile) {
appendLine()
appendLine("=== ADVANCED SIGNALS ===")
if (snapshot.lockdownLikely) {
appendLine("Always-on lockdown: likely (no validated non-VPN path exists).")
}
if (snapshot.knownVpnDnsMatches.isNotEmpty()) {
appendLine("Known VPN provider DNS:")
snapshot.knownVpnDnsMatches.forEach { appendLine("- $it") }
}
if (snapshot.workProfileCount > 1) {
appendLine("Work profile: ${snapshot.workProfileCount} user profiles detected. VPN apps in other profiles are not visible.")
}
if (snapshot.isManagedProfile) {
appendLine("Running inside a managed profile.")
}
}
}.trim()
}

View file

@ -1,6 +1,9 @@
package com.cherepavel.vpndetector.ui
import com.cherepavel.vpndetector.detector.TunnelNameMatcher
import com.cherepavel.vpndetector.model.DetectionConfidence
import com.cherepavel.vpndetector.model.DetectionSnapshot
import com.cherepavel.vpndetector.model.DetectionStatus
data class DetectionReport(
val overallTitle: String,
@ -13,6 +16,8 @@ data class DetectionReport(
val transportSubtitle: String,
val transportAnyValue: String,
val transportActiveValue: String,
val transportAnyDetected: Boolean,
val transportActiveDetected: Boolean,
val apiSignals: List<SignalItem>,
val nativeSignal: SignalItem,
@ -23,42 +28,44 @@ data class DetectionReport(
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(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(
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,
rawInterfaceName = snapshot.rawInterfaceName,
interfaceDetected = interfaceDetected,
transportInfoSummary = input.transportInfoSummary,
transportInfoSummary = snapshot.transportInfoSummary,
transportInfoDetected = transportInfoDetected,
allDnsServers = snapshot.allDnsServers,
internalDnsServers = snapshot.internalDnsServers,
contextualInternalDnsServers = snapshot.contextualInternalDnsServers,
privateDnsActive = snapshot.privateDnsActive,
privateDnsServerName = snapshot.privateDnsServerName,
activeNetworkNotVpn = snapshot.activeNetworkNotVpn,
preferredNetworkNotVpn = snapshot.preferredNetworkNotVpn,
activeVpn = activeVpn,
anyVpn = anyVpn
)
@ -66,7 +73,7 @@ object ReportFormatter {
val nativeSignal = SignalItem(
title = "Tunnel-like interfaces",
source = "Native getifaddrs() enumeration",
value = input.nativeTunnelNames.ifEmpty { listOf("none") }.joinToString(", "),
value = snapshot.nativeTunnelNames.ifEmpty { listOf("none") }.joinToString(", "),
state = if (nativeDetected) SignalState.WARNING else SignalState.NEGATIVE,
hint = if (nativeDetected) {
"Native enumeration found interfaces whose names or properties look tunnel-like."
@ -78,7 +85,7 @@ object ReportFormatter {
val javaSignal = SignalItem(
title = "Tunnel-like interfaces",
source = "Java NetworkInterface enumeration",
value = input.javaTunnelNames.ifEmpty { listOf("none") }.joinToString(", "),
value = snapshot.javaTunnelNames.ifEmpty { listOf("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."
@ -87,17 +94,112 @@ object ReportFormatter {
}
)
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("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.")
}
if (snapshot.tunTypeInterfaces.isNotEmpty()) {
append("\n\n--- TUN interfaces (type=65534) ---\n")
append(snapshot.tunTypeInterfaces.joinToString(", "))
}
if (snapshot.lowMtuInterfaces.isNotEmpty()) {
append("\n\n--- Low-MTU interfaces (<1500) ---\n")
append(snapshot.lowMtuInterfaces.joinToString("\n"))
}
if (snapshot.vpnRoutes.isNotEmpty()) {
append("\n\n--- VPN network routes ---\n")
append(snapshot.vpnRoutes.joinToString("\n"))
}
if (snapshot.vpnDnsServers.isNotEmpty()) {
append("\n\n--- VPN DNS servers ---\n")
append(snapshot.vpnDnsServers.joinToString(", "))
}
if (snapshot.allDnsServers.isNotEmpty()) {
append("\n\n--- DNS servers across visible networks ---\n")
append(snapshot.allDnsServers.joinToString("\n"))
}
if (snapshot.internalDnsServers.isNotEmpty()) {
append("\n\n--- Internal/private-range DNS servers ---\n")
append(snapshot.internalDnsServers.joinToString("\n"))
}
if (snapshot.contextualInternalDnsServers.isNotEmpty()) {
append("\n\n--- Cellular private DNS observed (not treated as VPN) ---\n")
append(snapshot.contextualInternalDnsServers.joinToString("\n"))
}
if (snapshot.privateDnsActive || snapshot.privateDnsServerName != null) {
append("\n\n--- Private DNS ---\n")
append(
buildString {
append(if (snapshot.privateDnsActive) "active" else "inactive")
snapshot.privateDnsServerName?.let { append(" ($it)") }
}
)
}
if (snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null) {
append("\n\n--- NET_CAPABILITY_NOT_VPN ---\n")
append("active=")
append(snapshot.activeNetworkNotVpn?.toString() ?: "unknown")
append(", preferred=")
append(snapshot.preferredNetworkNotVpn?.toString() ?: "unknown")
}
if (snapshot.vpnBandwidthSummary != null) {
append("\n\n--- VPN bandwidth ---\n")
append(snapshot.vpnBandwidthSummary)
}
if (snapshot.kernelRoutes.isNotEmpty()) {
append("\n\n--- Kernel route table (/proc/net/route) ---\n")
append(snapshot.kernelRoutes.joinToString("\n"))
}
if (snapshot.kernelIpv6Routes.isNotEmpty()) {
append("\n\n--- Kernel route table (/proc/net/ipv6_route) ---\n")
append(snapshot.kernelIpv6Routes.joinToString("\n"))
}
if (snapshot.vpnPermissionGranted) {
append("\n\n--- VPN permission ---\n")
append("This app holds Android VPN permission (anomalous for a detector).")
}
if (snapshot.lockdownLikely) {
append("\n\n--- Always-on / lockdown ---\n")
append("VPN present and no validated non-VPN path exists. Lockdown mode is likely active.")
}
if (snapshot.knownVpnDnsMatches.isNotEmpty()) {
append("\n\n--- Known VPN provider DNS ---\n")
append(snapshot.knownVpnDnsMatches.joinToString("\n"))
}
if (snapshot.workProfileCount > 1 || snapshot.isManagedProfile) {
append("\n\n--- Work / managed profile ---\n")
if (snapshot.isManagedProfile) {
append("Running inside a managed profile.\n")
}
if (snapshot.workProfileCount > 1) {
append("${snapshot.workProfileCount} user profiles detected. VPN apps in other profiles are not visible to this detector.")
}
}
}
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("No VPN-related apps detected.")
} else {
snapshot.installedVpnApps.forEach { appendLine("$it") }
if (snapshot.unknownDynamicApps.isNotEmpty()) {
if (snapshot.installedVpnApps.isNotEmpty()) appendLine()
appendLine("Detected via VpnService query:")
snapshot.unknownDynamicApps.forEach { appendLine("$it") }
}
}
if (snapshot.trackedAppsErrors.isNotEmpty()) {
if (snapshot.installedVpnApps.isNotEmpty() || snapshot.unknownDynamicApps.isNotEmpty()) appendLine()
appendLine("Check errors (package manager returned unexpected error):")
snapshot.trackedAppsErrors.forEach { (pkg, err) -> appendLine("$pkg: $err") }
}
}.trimEnd()
return DetectionReport(
overallTitle = overall.title,
@ -110,6 +212,8 @@ object ReportFormatter {
transportSubtitle = overall.transportSubtitle,
transportAnyValue = if (anyVpn) "DETECTED" else "NOT DETECTED",
transportActiveValue = if (activeVpn) "DETECTED" else "NOT DETECTED",
transportAnyDetected = anyVpn,
transportActiveDetected = activeVpn,
apiSignals = apiSignals,
nativeSignal = nativeSignal,
@ -120,20 +224,26 @@ object ReportFormatter {
}
private fun buildOverallBlock(
snapshot: DetectionSnapshot,
activeVpn: Boolean,
anyVpn: Boolean,
interfaceDetected: Boolean,
transportInfoDetected: Boolean,
dnsDetected: Boolean,
policyDetected: Boolean,
nativeDetected: Boolean,
javaDetected: Boolean,
appsDetected: Boolean
): OverallBlock {
val confidenceText = snapshot.assessment.confidence.label()
val scoreText = "Confidence: $confidenceText (${snapshot.assessment.score}/100)."
return when {
activeVpn -> {
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 = "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 = 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",
state = SignalState.POSITIVE,
transportState = SignalState.POSITIVE,
transportText = "VPN DETECTED",
@ -141,11 +251,11 @@ object ReportFormatter {
)
}
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.",
explanation = "This often matches bypass or split-tunnel behavior: a VPN exists, but current traffic may not be fully routed through it. $scoreText",
state = SignalState.SEMI,
transportState = SignalState.SEMI,
transportText = "SPLIT / BYPASS",
@ -153,11 +263,11 @@ object ReportFormatter {
)
}
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.",
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",
state = SignalState.WARNING,
transportState = SignalState.WARNING,
transportText = "API SIGNAL",
@ -169,7 +279,7 @@ object ReportFormatter {
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.",
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",
state = SignalState.WARNING,
transportState = SignalState.NEGATIVE,
transportText = "NOT DETECTED",
@ -177,11 +287,11 @@ object ReportFormatter {
)
}
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.",
explanation = "Installed VPN apps do not prove that a VPN is currently active, but they are still a relevant contextual signal. $scoreText",
state = SignalState.WARNING,
transportState = SignalState.NEGATIVE,
transportText = "NOT DETECTED",
@ -193,7 +303,7 @@ object ReportFormatter {
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.",
explanation = "Neither official Android network APIs nor interface enumeration produced a VPN-related signal. $scoreText",
state = SignalState.NEGATIVE,
transportState = SignalState.NEGATIVE,
transportText = "NOT DETECTED",
@ -204,61 +314,98 @@ object ReportFormatter {
}
private fun buildApiSignals(
interfaceName: String?,
rawInterfaceName: String?,
interfaceDetected: Boolean,
transportInfoSummary: String?,
transportInfoDetected: Boolean,
allDnsServers: List<String>,
internalDnsServers: List<String>,
contextualInternalDnsServers: List<String>,
privateDnsActive: Boolean,
privateDnsServerName: String?,
activeNetworkNotVpn: Boolean?,
preferredNetworkNotVpn: Boolean?,
activeVpn: Boolean,
anyVpn: Boolean
): List<SignalItem> {
val interfaceTransportDetected = interfaceDetected || transportInfoDetected
val interfaceState = when {
interfaceDetected && (activeVpn || anyVpn) -> SignalState.POSITIVE
interfaceDetected -> SignalState.WARNING
interfaceTransportDetected && activeVpn -> SignalState.POSITIVE
interfaceTransportDetected && anyVpn -> SignalState.SEMI
interfaceTransportDetected -> SignalState.WARNING
else -> SignalState.NEGATIVE
}
val transportInfoState = when {
transportInfoDetected && (activeVpn || anyVpn) -> SignalState.POSITIVE
transportInfoDetected -> SignalState.WARNING
val dnsPolicyDetected =
internalDnsServers.isNotEmpty() || activeNetworkNotVpn == false || preferredNetworkNotVpn == false
val dnsState = when {
internalDnsServers.isNotEmpty() && (activeVpn || anyVpn) -> SignalState.POSITIVE
dnsPolicyDetected -> SignalState.WARNING
privateDnsActive -> SignalState.NEUTRAL
else -> SignalState.NEGATIVE
}
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."
interfaceTransportDetected && activeVpn ->
"Interface naming or transport metadata aligns with an active VPN reported by Android."
interfaceTransportDetected && anyVpn ->
"Interface naming or transport metadata aligns with a VPN that exists somewhere in the system."
interfaceTransportDetected ->
"Interface naming or transport metadata looks VPN-like, but Android does not currently mark the active path as VPN."
rawInterfaceName != null ->
"Android returned interface '$rawInterfaceName' and no VPN-like transport metadata was exposed."
else ->
"The interface name does not look like a typical VPN or tunnel interface."
"Android returned no interface or transport metadata 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."
val dnsHint = when {
internalDnsServers.isNotEmpty() && (activeVpn || anyVpn) ->
"DNS points at internal/private ranges and matches the VPN-related network state."
internalDnsServers.isNotEmpty() ->
"DNS points at internal/private ranges often used by VPN clients, but Android did not expose TRANSPORT_VPN."
contextualInternalDnsServers.isNotEmpty() ->
"Carrier/private DNS was observed on a cellular interface and is shown as context only, not as a VPN signal."
activeNetworkNotVpn == false || preferredNetworkNotVpn == false ->
"At least one inspected network is missing NET_CAPABILITY_NOT_VPN, which is unusual outside VPN-managed paths."
privateDnsActive ->
"Private DNS is enabled. This is informational on its own, but useful when correlating DNS leak behavior."
else ->
"No VPN-related transport info was exposed here."
"No suspicious DNS range or NOT_VPN capability anomaly was exposed here."
}
val interfaceValue = listOfNotNull(
rawInterfaceName,
transportInfoSummary?.let { "transport: $it" }
).ifEmpty { listOf("none") }.joinToString("\n")
val dnsValue = buildList {
when {
internalDnsServers.isNotEmpty() ->
add(internalDnsServers.map(::stripIfacePrefix).joinToString("\n"))
contextualInternalDnsServers.isNotEmpty() ->
add(contextualInternalDnsServers.map(::stripIfacePrefix).joinToString("\n") + "\n(cellular)")
allDnsServers.isNotEmpty() ->
add(allDnsServers.map(::stripIfacePrefix).joinToString("\n"))
}
if (activeNetworkNotVpn == false) add("NOT_VPN cleared (active)")
if (preferredNetworkNotVpn == false) add("NOT_VPN cleared (preferred)")
if (privateDnsActive) add("DoH: ${privateDnsServerName ?: "on"}")
}.ifEmpty { listOf("none") }.joinToString("\n")
return listOf(
SignalItem(
title = "Interface name",
source = "LinkProperties.getInterfaceName()",
value = interfaceName ?: "none",
title = "Interface / transport",
source = "LinkProperties + NetworkCapabilities",
value = interfaceValue,
state = interfaceState,
hint = interfaceHint
),
SignalItem(
title = "Transport info",
source = "NetworkCapabilities.getTransportInfo()",
value = transportInfoSummary ?: "none",
state = transportInfoState,
hint = transportInfoHint
title = "DNS / policy",
source = "LinkProperties + NetworkCapabilities",
value = dnsValue,
state = dnsState,
hint = dnsHint
)
)
}
@ -272,4 +419,17 @@ object ReportFormatter {
val transportText: String,
val transportSubtitle: String
)
/** "wlan0:10.0.2.3" → "10.0.2.3", plain IPs pass through unchanged. */
private fun stripIfacePrefix(s: String): String =
if (':' in s) s.substringAfter(':') else s
private fun DetectionConfidence.label(): String {
return when (this) {
DetectionConfidence.CONFIRMED -> "confirmed"
DetectionConfidence.LIKELY -> "likely"
DetectionConfidence.WEAK_SIGNAL -> "weak signal"
DetectionConfidence.NO_EVIDENCE -> "no evidence"
}
}
}

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,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

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,90 @@
package com.cherepavel.vpndetector.detector
import android.content.Context
import com.cherepavel.vpndetector.model.TrackedApp
import org.json.JSONArray
import java.net.HttpURLConnection
import java.net.URL
import androidx.core.content.edit
object TrackedAppsRepository {
private const val APPS_URL =
"https://raw.githubusercontent.com/cherepavel/VPN-Detector/main/tracked_apps.json"
private const val PREFS_NAME = "vpn_detector"
private const val PREFS_KEY = "tracked_apps_json"
private const val TIMEOUT_MS = 5_000
@Volatile private var cached: List<TrackedApp>? = null
/** Fetch from remote and update cache. Call from a background thread. */
fun refresh(context: Context) {
try {
val json = fetch()
val apps = parse(json)
cached = apps
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit { putString(PREFS_KEY, json) }
} catch (_: Exception) {
}
}
/** Return cached → SharedPreferences → bundled fallback. */
fun get(context: Context): List<TrackedApp> {
cached?.let { return it }
val stored = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREFS_KEY, null)
if (stored != null) {
return parse(stored).also { cached = it }
}
return fallback
}
private fun fetch(): String {
val connection = URL(APPS_URL).openConnection() as HttpURLConnection
connection.connectTimeout = TIMEOUT_MS
connection.readTimeout = TIMEOUT_MS
return try {
connection.inputStream.bufferedReader().use { it.readText() }
} finally {
connection.disconnect()
}
}
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")
)
}
}
private val fallback = 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("ch.protonvpn.android", "Proton VPN (legacy package)"),
TrackedApp("free.vpn.unblock.proxy.turbovpn", "Turbo VPN"),
TrackedApp("com.zaneschepke.wireguardautotunnel", "WG Tunnel"),
TrackedApp("moe.nb4a", "NekoBox"),
TrackedApp("fr.husi", "husi"),
TrackedApp("com.outline.android", "Outline"),
TrackedApp("xyz.safetyvpn.app", "SafetyVPN"),
TrackedApp("net.mullvad.mullvadvpn", "Mullvad VPN"),
TrackedApp("org.torproject.android", "Orbot")
)
}

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")

25
tracked_apps.json Normal file
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"}
]