mirror of
https://github.com/cherepavel/VPN-Detector.git
synced 2026-04-28 01:49:28 +00:00
Dev (#15)
* Merge pull request #11 from wecand0/fdroid внедрение detection engine и модульной архитектуры детектирования VPN * Feature/UI polish and readme (#12) * add icon file * update readme --------- Co-authored-by: cherepavel <frasyn.pashka@gmail.com> * Remove background monitoring (#13) * убрано фоновое отслеживание, приложение переведено на manual refresh * minor fix --------- Co-authored-by: cherepavel <frasyn.pashka@gmail.com> * Split native details UI (#14) * убрал монолитный activity_main.xml, разделил на куски * upd * текст из ReportFormatter перенесен в strings.xml * add version and build * добавлена версия и билд в тектовый репорт --------- Co-authored-by: cherepavel <frasyn.pashka@gmail.com> * update gitignore --------- Co-authored-by: Vadim <shorinvadim1@gmail.com> Co-authored-by: cherepavel <frasyn.pashka@gmail.com>
This commit is contained in:
parent
702874ca79
commit
1c75bb098b
69 changed files with 3460 additions and 1597 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
app/release/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -9,8 +9,14 @@ Research tool for analyzing VPN detection mechanisms on Android.
|
|||
- Native + Java network enumeration
|
||||
|
||||
## Purpose
|
||||
|
||||
Demonstrates how apps can detect VPN presence even with split tunneling.
|
||||
|
||||
## Permissions
|
||||
|
||||
* `android.permission.ACCESS_NETWORK_STATE` — used to access network state via `ConnectivityManager`
|
||||
* `android.permission.QUERY_ALL_PACKAGES` — used to enumerate installed applications in order to detect known VPN clients
|
||||
|
||||
## Screenshots
|
||||
|
||||
**_VPN Active (full tunnel)_**
|
||||
|
|
@ -20,3 +26,7 @@ Demonstrates how apps can detect VPN presence even with split tunneling.
|
|||
**_VPN Split / Bypass (still detectable)_**
|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
|
|||
|
|
@ -2,57 +2,70 @@ plugins {
|
|||
id("com.android.application")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.cherepavel.vpndetector"
|
||||
fun gitCommitHash(): String {
|
||||
return try {
|
||||
val process = ProcessBuilder("git", "rev-parse", "--short", "HEAD")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
val result = process.inputStream.bufferedReader().use { it.readText() }.trim()
|
||||
val exitCode = process.waitFor()
|
||||
|
||||
if (exitCode == 0 && result.isNotBlank()) result else "unknown"
|
||||
} catch (e: Exception) {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
extensions.configure<com.android.build.api.dsl.ApplicationExtension> {
|
||||
namespace = "com.cherepavel.vpndetector"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.cherepavel.vpndetector"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 2
|
||||
versionName = "0.0.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags += ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
}
|
||||
buildConfigField("String", "GIT_HASH", "\"${gitCommitHash()}\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
isShrinkResources = false
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":detector"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<queries>
|
||||
<package android:name="com.github.dyhkwong.sagernet" />
|
||||
<package android:name="com.v2ray.ang" />
|
||||
<package android:name="org.amnezia.awg" />
|
||||
<package android:name="org.amnezia.vpn" />
|
||||
<package android:name="de.blinkt.openvpn" />
|
||||
<package android:name="net.openvpn.openvpn" />
|
||||
<package android:name="com.zaneschepke.wireguardautotunnel" />
|
||||
<package android:name="moe.nb4a" />
|
||||
</queries>
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="PackageVisibilityPolicy,ProtectionLevel,QueryAllPackagesPermission" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
|
@ -31,6 +25,7 @@
|
|||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
|
|
|||
25
app/src/main/assets/tracked_apps.json
Normal file
25
app/src/main/assets/tracked_apps.json
Normal 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"}
|
||||
]
|
||||
|
|
@ -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}
|
||||
)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
package com.cherepavel.vpndetector
|
||||
|
||||
import android.content.Context
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.MotionEvent
|
||||
|
|
@ -21,17 +18,21 @@ import androidx.core.content.ContextCompat
|
|||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import com.cherepavel.vpndetector.detector.IfconfigTermuxLikeDetector
|
||||
import com.cherepavel.vpndetector.detector.JavaInterfacesDetector
|
||||
import com.cherepavel.vpndetector.detector.TrackedAppsDetector
|
||||
import com.cherepavel.vpndetector.detector.TunnelNameMatcher
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.cherepavel.vpndetector.detector.DetectionEngine
|
||||
import com.cherepavel.vpndetector.detector.IDetectionEngine
|
||||
import com.cherepavel.vpndetector.ui.DetailSection
|
||||
import com.cherepavel.vpndetector.ui.DetectionReport
|
||||
import com.cherepavel.vpndetector.ui.ReportExportFormatter
|
||||
import com.cherepavel.vpndetector.ui.ReportFormatter
|
||||
import com.cherepavel.vpndetector.ui.SignalItem
|
||||
import com.cherepavel.vpndetector.ui.SignalState
|
||||
import com.cherepavel.vpndetector.util.TransportInfoFormatter
|
||||
import com.cherepavel.vpndetector.ui.export.ReportExportBuilder
|
||||
import com.cherepavel.vpndetector.ui.export.ReportExportFormatter
|
||||
import com.cherepavel.vpndetector.util.nowString
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.OutputStreamWriter
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
|
@ -43,6 +44,8 @@ class MainActivity : AppCompatActivity() {
|
|||
private lateinit var buttonRefresh: Button
|
||||
private lateinit var buttonReport: Button
|
||||
private lateinit var textLastUpdate: TextView
|
||||
private lateinit var textVersion: TextView
|
||||
private lateinit var textFooterInfo: TextView
|
||||
|
||||
private lateinit var cardTransportVpn: LinearLayout
|
||||
private lateinit var textTransportState: TextView
|
||||
|
|
@ -52,45 +55,29 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
private lateinit var cardApiSignal1: LinearLayout
|
||||
private lateinit var cardApiSignal2: LinearLayout
|
||||
private lateinit var textApiSignalTitle1: TextView
|
||||
private lateinit var textApiSignalTitle2: TextView
|
||||
private lateinit var textApiSignalSource1: TextView
|
||||
private lateinit var textApiSignalSource2: TextView
|
||||
private lateinit var textApiSignalValue1: TextView
|
||||
private lateinit var textApiSignalValue2: TextView
|
||||
private lateinit var textApiSignalHint1: TextView
|
||||
private lateinit var textApiSignalHint2: TextView
|
||||
|
||||
private lateinit var cardNativeSignal: LinearLayout
|
||||
private lateinit var textNativeSignalTitle: TextView
|
||||
private lateinit var textNativeSignalSource: TextView
|
||||
private lateinit var textNativeSignalValue: TextView
|
||||
private lateinit var textNativeSignalHint: TextView
|
||||
private lateinit var textNativeDetails: TextView
|
||||
private lateinit var scrollNativeDetails: NestedScrollView
|
||||
|
||||
private lateinit var containerExtraSections: LinearLayout
|
||||
|
||||
private lateinit var cardJavaSignal: LinearLayout
|
||||
private lateinit var textJavaSignalTitle: TextView
|
||||
private lateinit var textJavaSignalSource: TextView
|
||||
private lateinit var textJavaSignalValue: TextView
|
||||
private lateinit var textJavaSignalHint: TextView
|
||||
|
||||
private lateinit var textKnownApps: TextView
|
||||
|
||||
private lateinit var apiSignalCards: List<LinearLayout>
|
||||
private lateinit var apiSignalTitles: List<TextView>
|
||||
private lateinit var apiSignalSources: List<TextView>
|
||||
private lateinit var apiSignalValues: List<TextView>
|
||||
private lateinit var apiSignalHints: List<TextView>
|
||||
|
||||
private val javaInterfacesDetector by lazy { JavaInterfacesDetector() }
|
||||
private val trackedAppsDetector by lazy { TrackedAppsDetector(this) }
|
||||
private val connectivityManager by lazy {
|
||||
getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
}
|
||||
|
||||
private val detectionEngine: IDetectionEngine by lazy {
|
||||
DetectionEngine(this, connectivityManager)
|
||||
}
|
||||
|
||||
private var lastDetectionReport: DetectionReport? = null
|
||||
private var lastNativeDetailsRaw: String = ""
|
||||
private var lastJavaTunnelNames: List<String> = emptyList()
|
||||
private var lastInstalledVpnApps: List<String> = emptyList()
|
||||
private var lastExportText: String = ""
|
||||
private var detectionJob: Job? = null
|
||||
|
||||
private val createDocumentLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||
|
|
@ -116,10 +103,17 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
bindViews()
|
||||
renderVersion()
|
||||
renderFooter()
|
||||
setupListeners()
|
||||
refreshUi()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
detectionJob?.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun bindViews() {
|
||||
cardStatus = findViewById(R.id.cardStatus)
|
||||
textVpnStatus = findViewById(R.id.textVpnStatus)
|
||||
|
|
@ -128,6 +122,8 @@ class MainActivity : AppCompatActivity() {
|
|||
buttonRefresh = findViewById(R.id.buttonRefresh)
|
||||
buttonReport = findViewById(R.id.buttonReport)
|
||||
textLastUpdate = findViewById(R.id.textLastUpdate)
|
||||
textVersion = findViewById(R.id.textVersion)
|
||||
textFooterInfo = findViewById(R.id.textFooterInfo)
|
||||
|
||||
cardTransportVpn = findViewById(R.id.cardTransportVpn)
|
||||
textTransportState = findViewById(R.id.textTransportState)
|
||||
|
|
@ -137,38 +133,21 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
cardApiSignal1 = findViewById(R.id.cardApiSignal1)
|
||||
cardApiSignal2 = findViewById(R.id.cardApiSignal2)
|
||||
textApiSignalTitle1 = findViewById(R.id.textApiSignalTitle1)
|
||||
textApiSignalTitle2 = findViewById(R.id.textApiSignalTitle2)
|
||||
textApiSignalSource1 = findViewById(R.id.textApiSignalSource1)
|
||||
textApiSignalSource2 = findViewById(R.id.textApiSignalSource2)
|
||||
textApiSignalValue1 = findViewById(R.id.textApiSignalValue1)
|
||||
textApiSignalValue2 = findViewById(R.id.textApiSignalValue2)
|
||||
textApiSignalHint1 = findViewById(R.id.textApiSignalHint1)
|
||||
textApiSignalHint2 = findViewById(R.id.textApiSignalHint2)
|
||||
|
||||
cardNativeSignal = findViewById(R.id.cardNativeSignal)
|
||||
textNativeSignalTitle = findViewById(R.id.textNativeSignalTitle)
|
||||
textNativeSignalSource = findViewById(R.id.textNativeSignalSource)
|
||||
textNativeSignalValue = findViewById(R.id.textNativeSignalValue)
|
||||
textNativeSignalHint = findViewById(R.id.textNativeSignalHint)
|
||||
textNativeDetails = findViewById(R.id.textNativeDetails)
|
||||
scrollNativeDetails = findViewById(R.id.scrollNativeDetails)
|
||||
|
||||
containerExtraSections = findViewById(R.id.containerExtraSections)
|
||||
|
||||
cardJavaSignal = findViewById(R.id.cardJavaSignal)
|
||||
textJavaSignalTitle = findViewById(R.id.textJavaSignalTitle)
|
||||
textJavaSignalSource = findViewById(R.id.textJavaSignalSource)
|
||||
textJavaSignalValue = findViewById(R.id.textJavaSignalValue)
|
||||
textJavaSignalHint = findViewById(R.id.textJavaSignalHint)
|
||||
|
||||
textKnownApps = findViewById(R.id.textKnownApps)
|
||||
|
||||
apiSignalCards = listOf(cardApiSignal1, cardApiSignal2)
|
||||
apiSignalTitles = listOf(textApiSignalTitle1, textApiSignalTitle2)
|
||||
apiSignalSources = listOf(textApiSignalSource1, textApiSignalSource2)
|
||||
apiSignalValues = listOf(textApiSignalValue1, textApiSignalValue2)
|
||||
apiSignalHints = listOf(textApiSignalHint1, textApiSignalHint2)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun setupListeners() {
|
||||
buttonRefresh.setOnClickListener { refreshUi() }
|
||||
|
||||
|
|
@ -176,6 +155,15 @@ class MainActivity : AppCompatActivity() {
|
|||
showReportActions()
|
||||
}
|
||||
|
||||
textFooterInfo.setOnClickListener {
|
||||
startActivity(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(getString(R.string.repo_url))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
scrollNativeDetails.setOnTouchListener { view, event ->
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN,
|
||||
|
|
@ -192,71 +180,36 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private data class DetectionOutput(
|
||||
val report: DetectionReport,
|
||||
val exportText: String
|
||||
)
|
||||
|
||||
private fun refreshUi() {
|
||||
val connectivityManager =
|
||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
detectionJob?.cancel()
|
||||
buttonRefresh.isEnabled = false
|
||||
buttonReport.isEnabled = false
|
||||
|
||||
val allNetworks = connectivityManager.allNetworks.orEmpty()
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
detectionJob = lifecycleScope.launch {
|
||||
val output = withContext(Dispatchers.IO) { runDetection() }
|
||||
renderReport(output.report)
|
||||
renderLastUpdate()
|
||||
lastExportText = output.exportText
|
||||
buttonRefresh.isEnabled = true
|
||||
buttonReport.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
val vpnNetworks = allNetworks.filter { hasTransportVpn(connectivityManager, it) }
|
||||
val anyVpn = vpnNetworks.isNotEmpty()
|
||||
val activeVpn = activeNetwork?.let { hasTransportVpn(connectivityManager, it) } ?: false
|
||||
private fun runDetection(): DetectionOutput {
|
||||
val snapshot = detectionEngine.detect()
|
||||
val report = ReportFormatter.build(this, snapshot)
|
||||
|
||||
val preferredNetwork = vpnNetworks.firstOrNull() ?: activeNetwork ?: allNetworks.firstOrNull()
|
||||
val exportReport = ReportExportBuilder.build(this, snapshot)
|
||||
val exportText = ReportExportFormatter.buildText(exportReport)
|
||||
|
||||
val preferredLinkProperties: LinkProperties? =
|
||||
preferredNetwork?.let(connectivityManager::getLinkProperties)
|
||||
|
||||
val preferredCapabilities: NetworkCapabilities? =
|
||||
preferredNetwork?.let(connectivityManager::getNetworkCapabilities)
|
||||
|
||||
val interfaceName = preferredLinkProperties
|
||||
?.interfaceName
|
||||
?.takeIf { TunnelNameMatcher.looksLikeTunnelName(it) }
|
||||
|
||||
val transportInfoSummary =
|
||||
TransportInfoFormatter.summarizeVpnTransportInfo(preferredCapabilities)
|
||||
|
||||
val nativeResult = IfconfigTermuxLikeDetector.detect()
|
||||
val javaTunnelNames = javaInterfacesDetector.detectTunnelNames()
|
||||
|
||||
val installedVpnApps = trackedAppsDetector.detect()
|
||||
.map { "${it.label} (${it.packageName})" }
|
||||
|
||||
val nativeTunnelNames = nativeResult.matchedInterfaces
|
||||
.map { it.substringBefore(':').trim() }
|
||||
.distinct()
|
||||
|
||||
val nativeDetails = nativeResult.allInterfaces
|
||||
|
||||
val report = ReportFormatter.build(
|
||||
ReportFormatter.RawInput(
|
||||
hasTransportVpnAny = anyVpn,
|
||||
hasTransportVpnActive = activeVpn,
|
||||
interfaceName = interfaceName,
|
||||
transportInfoSummary = transportInfoSummary,
|
||||
nativeTunnelNames = nativeTunnelNames,
|
||||
nativeDetails = nativeDetails,
|
||||
javaTunnelNames = javaTunnelNames,
|
||||
installedVpnApps = installedVpnApps
|
||||
)
|
||||
)
|
||||
|
||||
renderReport(report)
|
||||
renderLastUpdate()
|
||||
|
||||
lastDetectionReport = report
|
||||
lastNativeDetailsRaw = report.nativeDetails
|
||||
lastJavaTunnelNames = javaTunnelNames
|
||||
lastInstalledVpnApps = installedVpnApps
|
||||
lastExportText = ReportExportFormatter.buildText(
|
||||
ReportExportFormatter.ExportInput(
|
||||
report = report,
|
||||
nativeDetailsRaw = report.nativeDetails,
|
||||
javaTunnelNames = javaTunnelNames,
|
||||
installedVpnApps = installedVpnApps
|
||||
)
|
||||
return DetectionOutput(
|
||||
report = report,
|
||||
exportText = exportText
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -266,7 +219,7 @@ class MainActivity : AppCompatActivity() {
|
|||
textVpnExplanation.text = report.overallExplanation
|
||||
|
||||
applySectionCardBackground(cardStatus)
|
||||
applyStatusTextColor(textVpnStatus, report.overallState)
|
||||
applyValueTextColor(textVpnStatus, report.overallState)
|
||||
|
||||
textTransportState.text = report.transportStateText
|
||||
textTransportSubtitle.text = report.transportSubtitle
|
||||
|
|
@ -278,31 +231,25 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
applyTransportBadgeBackground(
|
||||
view = textTransportAnyValue,
|
||||
isDetected = report.transportAnyValue == "DETECTED"
|
||||
isDetected = report.transportAnyDetected
|
||||
)
|
||||
applyTransportBadgeBackground(
|
||||
view = textTransportActiveValue,
|
||||
isDetected = report.transportActiveValue == "DETECTED"
|
||||
isDetected = report.transportActiveDetected
|
||||
)
|
||||
|
||||
renderApiSignals(report.apiSignals)
|
||||
|
||||
bindSignalCard(
|
||||
card = cardNativeSignal,
|
||||
titleView = textNativeSignalTitle,
|
||||
sourceView = textNativeSignalSource,
|
||||
valueView = textNativeSignalValue,
|
||||
hintView = textNativeSignalHint,
|
||||
item = report.nativeSignal
|
||||
)
|
||||
|
||||
textNativeDetails.text = report.nativeDetails
|
||||
renderExtraSections(report.extraSections)
|
||||
|
||||
bindSignalCard(
|
||||
card = cardJavaSignal,
|
||||
titleView = textJavaSignalTitle,
|
||||
sourceView = textJavaSignalSource,
|
||||
valueView = textJavaSignalValue,
|
||||
hintView = textJavaSignalHint,
|
||||
item = report.javaSignal
|
||||
)
|
||||
|
||||
|
|
@ -319,23 +266,41 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
bindSignalCard(
|
||||
card = card,
|
||||
titleView = apiSignalTitles[index],
|
||||
sourceView = apiSignalSources[index],
|
||||
valueView = apiSignalValues[index],
|
||||
hintView = apiSignalHints[index],
|
||||
item = signals[index]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderExtraSections(sections: List<DetailSection>) {
|
||||
containerExtraSections.removeAllViews()
|
||||
|
||||
for (section in sections) {
|
||||
val itemView = layoutInflater.inflate(
|
||||
R.layout.common_item_detail_section,
|
||||
containerExtraSections,
|
||||
false
|
||||
)
|
||||
|
||||
val titleView = itemView.findViewById<TextView>(R.id.textDetailTitle)
|
||||
val bodyView = itemView.findViewById<TextView>(R.id.textDetailBody)
|
||||
|
||||
titleView.text = section.title
|
||||
bodyView.text = section.body
|
||||
applyValueTextColor(titleView, section.state)
|
||||
|
||||
containerExtraSections.addView(itemView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindSignalCard(
|
||||
card: LinearLayout,
|
||||
titleView: TextView,
|
||||
sourceView: TextView,
|
||||
valueView: TextView,
|
||||
hintView: TextView,
|
||||
item: SignalItem
|
||||
) {
|
||||
val titleView = card.findViewById<TextView>(R.id.textSignalTitle)
|
||||
val sourceView = card.findViewById<TextView>(R.id.textSignalSource)
|
||||
val valueView = card.findViewById<TextView>(R.id.textSignalValue)
|
||||
val hintView = card.findViewById<TextView>(R.id.textSignalHint)
|
||||
|
||||
titleView.text = item.title
|
||||
sourceView.text = item.source
|
||||
valueView.text = item.value
|
||||
|
|
@ -345,18 +310,24 @@ class MainActivity : AppCompatActivity() {
|
|||
applyValueTextColor(valueView, item.state)
|
||||
}
|
||||
|
||||
private fun hasTransportVpn(
|
||||
connectivityManager: ConnectivityManager,
|
||||
network: Network
|
||||
): Boolean {
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private fun renderLastUpdate() {
|
||||
textLastUpdate.text = "Last update: ${nowString()}"
|
||||
}
|
||||
|
||||
private fun renderVersion() {
|
||||
textVersion.text =
|
||||
"${BuildConfig.VERSION_NAME} • ${BuildConfig.GIT_HASH} • ${BuildConfig.BUILD_TYPE}"
|
||||
}
|
||||
|
||||
private fun renderFooter() {
|
||||
val repoText = getString(R.string.repo_url)
|
||||
.removePrefix("https://")
|
||||
.removePrefix("http://")
|
||||
|
||||
textFooterInfo.text = "${getString(R.string.source_code_label)} $repoText"
|
||||
}
|
||||
|
||||
private fun showReportActions() {
|
||||
if (lastExportText.isBlank()) {
|
||||
refreshUi()
|
||||
|
|
@ -417,10 +388,6 @@ class MainActivity : AppCompatActivity() {
|
|||
view.setTextColor(state.toSignalColor())
|
||||
}
|
||||
|
||||
private fun applyStatusTextColor(view: TextView, state: SignalState) {
|
||||
view.setTextColor(state.toSignalColor())
|
||||
}
|
||||
|
||||
private fun applyTransportBadgeBackground(view: TextView, isDetected: Boolean) {
|
||||
view.setBackgroundResource(
|
||||
if (isDetected) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.cherepavel.vpndetector.ui
|
||||
|
||||
data class DetailSection(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val state: SignalState = SignalState.NEUTRAL
|
||||
)
|
||||
|
||||
data class DetectionReport(
|
||||
val overallTitle: String,
|
||||
val overallSummary: String,
|
||||
val overallExplanation: String,
|
||||
val overallState: SignalState,
|
||||
|
||||
val transportCardState: SignalState,
|
||||
val transportStateText: String,
|
||||
val transportSubtitle: String,
|
||||
val transportAnyValue: String,
|
||||
val transportActiveValue: String,
|
||||
val transportAnyDetected: Boolean,
|
||||
val transportActiveDetected: Boolean,
|
||||
|
||||
val apiSignals: List<SignalItem>,
|
||||
val nativeSignal: SignalItem,
|
||||
val nativeDetails: String,
|
||||
val extraSections: List<DetailSection>,
|
||||
val javaSignal: SignalItem,
|
||||
val knownAppsText: String
|
||||
)
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
package com.cherepavel.vpndetector.ui
|
||||
|
||||
import com.cherepavel.vpndetector.util.nowString
|
||||
|
||||
object ReportExportFormatter {
|
||||
|
||||
data class ExportInput(
|
||||
val report: DetectionReport,
|
||||
val nativeDetailsRaw: String,
|
||||
val javaTunnelNames: List<String>,
|
||||
val installedVpnApps: List<String>
|
||||
)
|
||||
|
||||
fun buildText(input: ExportInput): String {
|
||||
val report = input.report
|
||||
|
||||
return buildString {
|
||||
appendLine("VPN Detector Report")
|
||||
appendLine("Generated: ${nowString()}")
|
||||
appendLine()
|
||||
|
||||
appendLine("=== OVERALL STATUS ===")
|
||||
appendLine(report.overallTitle)
|
||||
appendLine(report.overallSummary)
|
||||
appendLine(report.overallExplanation)
|
||||
appendLine()
|
||||
|
||||
appendLine("=== OFFICIAL ANDROID API ===")
|
||||
appendLine("TRANSPORT_VPN across all networks: ${report.transportAnyValue}")
|
||||
appendLine("TRANSPORT_VPN active network only: ${report.transportActiveValue}")
|
||||
appendLine("Transport state: ${report.transportStateText}")
|
||||
appendLine("Transport subtitle: ${report.transportSubtitle}")
|
||||
appendLine()
|
||||
|
||||
if (report.apiSignals.isNotEmpty()) {
|
||||
appendLine("API signals:")
|
||||
report.apiSignals.forEach { signal ->
|
||||
appendLine("- ${signal.title}")
|
||||
appendLine(" source: ${signal.source}")
|
||||
appendLine(" value: ${signal.value}")
|
||||
appendLine(" hint: ${signal.hint}")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
|
||||
appendLine("=== NATIVE LOW-LEVEL ENUMERATION ===")
|
||||
appendLine("Signal value: ${report.nativeSignal.value}")
|
||||
appendLine("Signal hint: ${report.nativeSignal.hint}")
|
||||
appendLine()
|
||||
appendLine(input.nativeDetailsRaw)
|
||||
appendLine()
|
||||
|
||||
appendLine("=== JAVA INTERFACE ENUMERATION ===")
|
||||
appendLine("Signal value: ${report.javaSignal.value}")
|
||||
appendLine("Signal hint: ${report.javaSignal.hint}")
|
||||
if (input.javaTunnelNames.isNotEmpty()) {
|
||||
appendLine("Matched tunnel-like names:")
|
||||
input.javaTunnelNames.forEach { appendLine("- $it") }
|
||||
}
|
||||
appendLine()
|
||||
|
||||
appendLine("=== DETECTED VPN APPS ===")
|
||||
if (input.installedVpnApps.isEmpty()) {
|
||||
appendLine("No known VPN-related apps from the tracked list are installed.")
|
||||
} else {
|
||||
input.installedVpnApps.forEach { appendLine("- $it") }
|
||||
}
|
||||
}.trim()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +1,317 @@
|
|||
package com.cherepavel.vpndetector.ui
|
||||
|
||||
import android.content.Context
|
||||
import com.cherepavel.vpndetector.R
|
||||
import com.cherepavel.vpndetector.detector.TunnelNameMatcher
|
||||
|
||||
data class DetectionReport(
|
||||
val overallTitle: String,
|
||||
val overallSummary: String,
|
||||
val overallExplanation: String,
|
||||
val overallState: SignalState,
|
||||
|
||||
val transportCardState: SignalState,
|
||||
val transportStateText: String,
|
||||
val transportSubtitle: String,
|
||||
val transportAnyValue: String,
|
||||
val transportActiveValue: String,
|
||||
|
||||
val apiSignals: List<SignalItem>,
|
||||
val nativeSignal: SignalItem,
|
||||
val nativeDetails: String,
|
||||
val javaSignal: SignalItem,
|
||||
val knownAppsText: String
|
||||
)
|
||||
import com.cherepavel.vpndetector.model.DetectionConfidence
|
||||
import com.cherepavel.vpndetector.model.DetectionSnapshot
|
||||
import com.cherepavel.vpndetector.model.DetectionStatus
|
||||
|
||||
object ReportFormatter {
|
||||
|
||||
data class RawInput(
|
||||
val hasTransportVpnAny: Boolean,
|
||||
val hasTransportVpnActive: Boolean,
|
||||
val interfaceName: String?,
|
||||
val transportInfoSummary: String?,
|
||||
val nativeTunnelNames: List<String>,
|
||||
val nativeDetails: List<String>,
|
||||
val javaTunnelNames: List<String>,
|
||||
val installedVpnApps: List<String>
|
||||
)
|
||||
fun build(context: Context, snapshot: DetectionSnapshot): DetectionReport {
|
||||
val anyVpn = snapshot.hasTransportVpnAny
|
||||
val activeVpn = snapshot.hasTransportVpnActive
|
||||
|
||||
fun build(input: RawInput): DetectionReport {
|
||||
val anyVpn = input.hasTransportVpnAny
|
||||
val activeVpn = input.hasTransportVpnActive
|
||||
|
||||
val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(input.interfaceName)
|
||||
val transportInfoDetected = !input.transportInfoSummary.isNullOrBlank()
|
||||
val nativeDetected = input.nativeTunnelNames.isNotEmpty()
|
||||
val javaDetected = input.javaTunnelNames.isNotEmpty()
|
||||
val appsDetected = input.installedVpnApps.isNotEmpty()
|
||||
val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(snapshot.rawInterfaceName)
|
||||
val transportInfoDetected = !snapshot.transportInfoSummary.isNullOrBlank()
|
||||
val dnsDetected = snapshot.internalDnsServers.isNotEmpty()
|
||||
val policyDetected =
|
||||
snapshot.activeNetworkNotVpn == false || snapshot.preferredNetworkNotVpn == false
|
||||
val nativeDetected = snapshot.nativeTunnelNames.isNotEmpty()
|
||||
val javaDetected = snapshot.javaTunnelNames.isNotEmpty()
|
||||
val appsDetected = snapshot.installedVpnApps.isNotEmpty()
|
||||
|
||||
val overall = buildOverallBlock(
|
||||
context = context,
|
||||
snapshot = snapshot,
|
||||
activeVpn = activeVpn,
|
||||
anyVpn = anyVpn,
|
||||
interfaceDetected = interfaceDetected,
|
||||
transportInfoDetected = transportInfoDetected,
|
||||
dnsDetected = dnsDetected,
|
||||
policyDetected = policyDetected,
|
||||
nativeDetected = nativeDetected,
|
||||
javaDetected = javaDetected,
|
||||
appsDetected = appsDetected
|
||||
)
|
||||
|
||||
val apiSignals = buildApiSignals(
|
||||
interfaceName = input.interfaceName,
|
||||
context = context,
|
||||
rawInterfaceName = snapshot.rawInterfaceName,
|
||||
interfaceDetected = interfaceDetected,
|
||||
transportInfoSummary = input.transportInfoSummary,
|
||||
transportInfoSummary = snapshot.transportInfoSummary,
|
||||
transportInfoDetected = transportInfoDetected,
|
||||
activeVpn = activeVpn,
|
||||
anyVpn = anyVpn
|
||||
)
|
||||
|
||||
val nativeSignal = SignalItem(
|
||||
title = "Tunnel-like interfaces",
|
||||
source = "Native getifaddrs() enumeration",
|
||||
value = input.nativeTunnelNames.ifEmpty { listOf("none") }.joinToString(", "),
|
||||
title = context.getString(R.string.signal_title_tunnel_like_interfaces),
|
||||
source = context.getString(R.string.signal_source_native),
|
||||
value = snapshot.nativeTunnelNames.ifEmpty {
|
||||
listOf(context.getString(R.string.report_value_none))
|
||||
}.joinToString(", "),
|
||||
state = if (nativeDetected) SignalState.WARNING else SignalState.NEGATIVE,
|
||||
hint = if (nativeDetected) {
|
||||
"Native enumeration found interfaces whose names or properties look tunnel-like."
|
||||
context.getString(R.string.signal_hint_native_found)
|
||||
} else {
|
||||
"Native enumeration did not find any tunnel-like interfaces."
|
||||
context.getString(R.string.signal_hint_native_missing)
|
||||
}
|
||||
)
|
||||
|
||||
val javaSignal = SignalItem(
|
||||
title = "Tunnel-like interfaces",
|
||||
source = "Java NetworkInterface enumeration",
|
||||
value = input.javaTunnelNames.ifEmpty { listOf("none") }.joinToString(", "),
|
||||
title = context.getString(R.string.signal_title_tunnel_like_interfaces),
|
||||
source = context.getString(R.string.signal_source_java),
|
||||
value = snapshot.javaTunnelNames.ifEmpty {
|
||||
listOf(context.getString(R.string.report_value_none))
|
||||
}.joinToString(", "),
|
||||
state = if (javaDetected) SignalState.WARNING else SignalState.NEGATIVE,
|
||||
hint = if (javaDetected) {
|
||||
"Java network enumeration found interface names that look like VPN or tunnel interfaces."
|
||||
context.getString(R.string.signal_hint_java_found)
|
||||
} else {
|
||||
"Java network enumeration did not find any tunnel-like interface names."
|
||||
context.getString(R.string.signal_hint_java_missing)
|
||||
}
|
||||
)
|
||||
|
||||
val nativeDetailsText = if (input.nativeDetails.isEmpty()) {
|
||||
"No interfaces were returned by the native detector."
|
||||
} else {
|
||||
input.nativeDetails.joinToString(separator = "\n\n")
|
||||
val nativeDetailsText = buildString {
|
||||
if (snapshot.nativeError != null) {
|
||||
appendLine(
|
||||
context.getString(
|
||||
R.string.native_error_format,
|
||||
snapshot.nativeError
|
||||
)
|
||||
)
|
||||
appendLine()
|
||||
}
|
||||
if (snapshot.nativeDetails.isNotEmpty()) {
|
||||
append(snapshot.nativeDetails.joinToString(separator = "\n\n"))
|
||||
} else if (snapshot.nativeError == null) {
|
||||
append(context.getString(R.string.native_details_empty))
|
||||
}
|
||||
}.trim()
|
||||
|
||||
val extraSections = buildList {
|
||||
if (snapshot.tunTypeInterfaces.isNotEmpty()) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_tun_interfaces),
|
||||
body = snapshot.tunTypeInterfaces.joinToString("\n"),
|
||||
state = SignalState.WARNING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.lowMtuInterfaces.isNotEmpty()) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_low_mtu_interfaces),
|
||||
body = snapshot.lowMtuInterfaces.joinToString("\n"),
|
||||
state = SignalState.WARNING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.vpnRoutes.isNotEmpty()) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_vpn_routes),
|
||||
body = snapshot.vpnRoutes.joinToString("\n"),
|
||||
state = SignalState.WARNING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.vpnDnsServers.isNotEmpty()) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_vpn_dns_servers),
|
||||
body = snapshot.vpnDnsServers.joinToString("\n"),
|
||||
state = SignalState.WARNING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.allDnsServers.isNotEmpty()) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_dns_all),
|
||||
body = snapshot.allDnsServers.joinToString("\n"),
|
||||
state = SignalState.NEUTRAL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.internalDnsServers.isNotEmpty()) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_dns_internal),
|
||||
body = snapshot.internalDnsServers.joinToString("\n"),
|
||||
state = SignalState.WARNING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.contextualInternalDnsServers.isNotEmpty()) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_dns_contextual),
|
||||
body = snapshot.contextualInternalDnsServers.joinToString("\n"),
|
||||
state = SignalState.NEUTRAL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.privateDnsActive || snapshot.privateDnsServerName != null) {
|
||||
val privateDnsState = if (snapshot.privateDnsActive) {
|
||||
context.getString(R.string.private_dns_active)
|
||||
} else {
|
||||
context.getString(R.string.private_dns_inactive)
|
||||
}
|
||||
|
||||
val privateDnsBody = snapshot.privateDnsServerName?.let {
|
||||
context.getString(R.string.private_dns_with_host, privateDnsState, it)
|
||||
} ?: privateDnsState
|
||||
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_private_dns),
|
||||
body = privateDnsBody,
|
||||
state = SignalState.NEUTRAL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null) {
|
||||
val activeText = snapshot.activeNetworkNotVpn?.toString()
|
||||
?: context.getString(R.string.report_value_unknown)
|
||||
val preferredText = snapshot.preferredNetworkNotVpn?.toString()
|
||||
?: context.getString(R.string.report_value_unknown)
|
||||
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_not_vpn),
|
||||
body = context.getString(
|
||||
R.string.not_vpn_body,
|
||||
activeText,
|
||||
preferredText
|
||||
),
|
||||
state = if (
|
||||
snapshot.activeNetworkNotVpn == false ||
|
||||
snapshot.preferredNetworkNotVpn == false
|
||||
) {
|
||||
SignalState.WARNING
|
||||
} else {
|
||||
SignalState.NEUTRAL
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
snapshot.vpnBandwidthSummary?.let {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_vpn_bandwidth),
|
||||
body = it,
|
||||
state = SignalState.NEUTRAL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.kernelRoutes.isNotEmpty()) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_kernel_routes_v4),
|
||||
body = snapshot.kernelRoutes.joinToString("\n"),
|
||||
state = SignalState.NEUTRAL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.kernelIpv6Routes.isNotEmpty()) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_kernel_routes_v6),
|
||||
body = snapshot.kernelIpv6Routes.joinToString("\n"),
|
||||
state = SignalState.NEUTRAL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.vpnPermissionGranted) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_vpn_permission),
|
||||
body = context.getString(R.string.vpn_permission_body),
|
||||
state = SignalState.WARNING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.lockdownLikely) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_lockdown),
|
||||
body = context.getString(R.string.lockdown_body),
|
||||
state = SignalState.WARNING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.knownVpnDnsMatches.isNotEmpty()) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_known_vpn_dns),
|
||||
body = snapshot.knownVpnDnsMatches.joinToString("\n"),
|
||||
state = SignalState.WARNING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.workProfileCount > 1 || snapshot.isManagedProfile) {
|
||||
add(
|
||||
DetailSection(
|
||||
title = context.getString(R.string.section_work_profile),
|
||||
body = buildString {
|
||||
if (snapshot.isManagedProfile) {
|
||||
appendLine(context.getString(R.string.managed_profile_body))
|
||||
}
|
||||
if (snapshot.workProfileCount > 1) {
|
||||
append(
|
||||
context.getString(
|
||||
R.string.work_profile_count_body,
|
||||
snapshot.workProfileCount
|
||||
)
|
||||
)
|
||||
}
|
||||
}.trim(),
|
||||
state = SignalState.NEUTRAL
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val appsText = if (input.installedVpnApps.isEmpty()) {
|
||||
"No known VPN-related apps from the tracked list are installed."
|
||||
} else {
|
||||
input.installedVpnApps.joinToString(separator = "\n") { "• $it" }
|
||||
}
|
||||
val appsText = buildString {
|
||||
if (snapshot.installedVpnApps.isEmpty() && snapshot.unknownDynamicApps.isEmpty()) {
|
||||
append(context.getString(R.string.apps_none_detected))
|
||||
} else {
|
||||
snapshot.installedVpnApps.forEach { appendLine("• $it") }
|
||||
if (snapshot.unknownDynamicApps.isNotEmpty()) {
|
||||
if (snapshot.installedVpnApps.isNotEmpty()) appendLine()
|
||||
appendLine(context.getString(R.string.apps_detected_via_vpn_service))
|
||||
snapshot.unknownDynamicApps.forEach { appendLine("• $it") }
|
||||
}
|
||||
}
|
||||
if (snapshot.trackedAppsErrors.isNotEmpty()) {
|
||||
if (snapshot.installedVpnApps.isNotEmpty() || snapshot.unknownDynamicApps.isNotEmpty()) {
|
||||
appendLine()
|
||||
}
|
||||
appendLine(context.getString(R.string.apps_check_errors))
|
||||
snapshot.trackedAppsErrors.forEach { (pkg, err) ->
|
||||
appendLine("• $pkg: $err")
|
||||
}
|
||||
}
|
||||
}.trimEnd()
|
||||
|
||||
return DetectionReport(
|
||||
overallTitle = overall.title,
|
||||
|
|
@ -108,103 +322,145 @@ object ReportFormatter {
|
|||
transportCardState = overall.transportState,
|
||||
transportStateText = overall.transportText,
|
||||
transportSubtitle = overall.transportSubtitle,
|
||||
transportAnyValue = if (anyVpn) "DETECTED" else "NOT DETECTED",
|
||||
transportActiveValue = if (activeVpn) "DETECTED" else "NOT DETECTED",
|
||||
transportAnyValue = if (anyVpn) {
|
||||
context.getString(R.string.report_value_detected)
|
||||
} else {
|
||||
context.getString(R.string.report_value_not_detected)
|
||||
},
|
||||
transportActiveValue = if (activeVpn) {
|
||||
context.getString(R.string.report_value_detected)
|
||||
} else {
|
||||
context.getString(R.string.report_value_not_detected)
|
||||
},
|
||||
transportAnyDetected = anyVpn,
|
||||
transportActiveDetected = activeVpn,
|
||||
|
||||
apiSignals = apiSignals,
|
||||
nativeSignal = nativeSignal,
|
||||
nativeDetails = nativeDetailsText,
|
||||
extraSections = extraSections,
|
||||
javaSignal = javaSignal,
|
||||
knownAppsText = appsText
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildOverallBlock(
|
||||
context: Context,
|
||||
snapshot: DetectionSnapshot,
|
||||
activeVpn: Boolean,
|
||||
anyVpn: Boolean,
|
||||
interfaceDetected: Boolean,
|
||||
transportInfoDetected: Boolean,
|
||||
dnsDetected: Boolean,
|
||||
policyDetected: Boolean,
|
||||
nativeDetected: Boolean,
|
||||
javaDetected: Boolean,
|
||||
appsDetected: Boolean
|
||||
): OverallBlock {
|
||||
val confidenceText = when (snapshot.assessment.confidence) {
|
||||
DetectionConfidence.CONFIRMED -> context.getString(R.string.report_confidence_confirmed)
|
||||
DetectionConfidence.LIKELY -> context.getString(R.string.report_confidence_likely)
|
||||
DetectionConfidence.WEAK_SIGNAL -> context.getString(R.string.report_confidence_weak_signal)
|
||||
DetectionConfidence.NO_EVIDENCE -> context.getString(R.string.report_confidence_no_evidence)
|
||||
}
|
||||
|
||||
val scoreText = context.getString(
|
||||
R.string.report_confidence_format,
|
||||
confidenceText,
|
||||
snapshot.assessment.score
|
||||
)
|
||||
|
||||
return when {
|
||||
activeVpn -> {
|
||||
snapshot.assessment.status == DetectionStatus.ACTIVE_VPN || activeVpn -> {
|
||||
val lockdown = snapshot.lockdownLikely
|
||||
val summary = context.getString(R.string.report_summary_vpn_detected) +
|
||||
if (lockdown) " " + context.getString(R.string.report_summary_lockdown_suffix) else ""
|
||||
|
||||
OverallBlock(
|
||||
title = "VPN detected",
|
||||
summary = "The active network is explicitly marked as VPN by Android.",
|
||||
explanation = "This is the strongest signal in the app: Android reports TRANSPORT_VPN on the network currently in use.",
|
||||
title = context.getString(
|
||||
if (lockdown) R.string.report_title_vpn_detected_lockdown
|
||||
else R.string.report_title_vpn_detected
|
||||
),
|
||||
summary = summary,
|
||||
explanation = context.getString(R.string.report_explanation_vpn_detected) +
|
||||
" " + scoreText,
|
||||
state = SignalState.POSITIVE,
|
||||
transportState = SignalState.POSITIVE,
|
||||
transportText = "VPN DETECTED",
|
||||
transportSubtitle = "TRANSPORT_VPN is present on the active network."
|
||||
transportText = context.getString(R.string.report_transport_text_vpn_detected),
|
||||
transportSubtitle = context.getString(R.string.report_transport_subtitle_vpn_detected)
|
||||
)
|
||||
}
|
||||
|
||||
anyVpn -> {
|
||||
snapshot.assessment.status == DetectionStatus.SPLIT_TUNNEL || anyVpn -> {
|
||||
OverallBlock(
|
||||
title = "VPN present outside active path",
|
||||
summary = "Android sees a VPN network in the system, but not on the current active network.",
|
||||
explanation = "This often matches bypass or split-tunnel behavior: a VPN exists, but current traffic may not be fully routed through it.",
|
||||
title = context.getString(R.string.report_title_split_tunnel),
|
||||
summary = context.getString(R.string.report_summary_split_tunnel),
|
||||
explanation = context.getString(R.string.report_explanation_split_tunnel) +
|
||||
" " + scoreText,
|
||||
state = SignalState.SEMI,
|
||||
transportState = SignalState.SEMI,
|
||||
transportText = "SPLIT / BYPASS",
|
||||
transportSubtitle = "A VPN-related transport exists system-wide, but it is not the current active path."
|
||||
transportText = context.getString(R.string.report_transport_text_split_tunnel),
|
||||
transportSubtitle = context.getString(R.string.report_transport_subtitle_split_tunnel)
|
||||
)
|
||||
}
|
||||
|
||||
interfaceDetected || transportInfoDetected -> {
|
||||
interfaceDetected || transportInfoDetected || dnsDetected || policyDetected -> {
|
||||
OverallBlock(
|
||||
title = "VPN-related API signal",
|
||||
summary = "Android APIs still expose VPN-like indicators even though active TRANSPORT_VPN is absent.",
|
||||
explanation = "This is weaker than a direct VPN transport flag, but it still suggests that VPN-related state may be visible through official APIs.",
|
||||
title = context.getString(R.string.report_title_api_signal),
|
||||
summary = context.getString(R.string.report_summary_api_signal),
|
||||
explanation = context.getString(R.string.report_explanation_api_signal) +
|
||||
" " + scoreText,
|
||||
state = SignalState.WARNING,
|
||||
transportState = SignalState.WARNING,
|
||||
transportText = "API SIGNAL",
|
||||
transportSubtitle = "No active TRANSPORT_VPN, but Android APIs still expose VPN-related information."
|
||||
transportText = context.getString(R.string.report_transport_text_api_signal),
|
||||
transportSubtitle = context.getString(R.string.report_transport_subtitle_api_signal)
|
||||
)
|
||||
}
|
||||
|
||||
nativeDetected || javaDetected -> {
|
||||
OverallBlock(
|
||||
title = "Low-level tunnel signal",
|
||||
summary = "No primary Android VPN signal was found, but tunnel-like interfaces were still discovered.",
|
||||
explanation = "This usually means only low-level interface heuristics fired. It is useful as an additional hint, but weaker than official Android VPN signals.",
|
||||
title = context.getString(R.string.report_title_low_level),
|
||||
summary = context.getString(R.string.report_summary_low_level),
|
||||
explanation = context.getString(R.string.report_explanation_low_level) +
|
||||
" " + scoreText,
|
||||
state = SignalState.WARNING,
|
||||
transportState = SignalState.NEGATIVE,
|
||||
transportText = "NOT DETECTED",
|
||||
transportSubtitle = "Android did not report VPN transport on the active path."
|
||||
transportText = context.getString(R.string.report_transport_text_not_detected),
|
||||
transportSubtitle = context.getString(R.string.report_transport_subtitle_not_on_active_path)
|
||||
)
|
||||
}
|
||||
|
||||
appsDetected -> {
|
||||
snapshot.assessment.status == DetectionStatus.APPS_PRESENT || appsDetected -> {
|
||||
OverallBlock(
|
||||
title = "Detected VPN apps",
|
||||
summary = "No active VPN network signal was found, but known VPN-related apps are installed on the device.",
|
||||
explanation = "Installed VPN apps do not prove that a VPN is currently active, but they are still a relevant contextual signal.",
|
||||
title = context.getString(R.string.report_title_apps_present),
|
||||
summary = context.getString(R.string.report_summary_apps_present),
|
||||
explanation = context.getString(R.string.report_explanation_apps_present) +
|
||||
" " + scoreText,
|
||||
state = SignalState.WARNING,
|
||||
transportState = SignalState.NEGATIVE,
|
||||
transportText = "NOT DETECTED",
|
||||
transportSubtitle = "Android did not report VPN transport on the active path."
|
||||
transportText = context.getString(R.string.report_transport_text_not_detected),
|
||||
transportSubtitle = context.getString(R.string.report_transport_subtitle_not_on_active_path)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
OverallBlock(
|
||||
title = "No VPN detected",
|
||||
summary = "The app did not find any high-level or low-level VPN indicators.",
|
||||
explanation = "Neither official Android network APIs nor interface enumeration produced a VPN-related signal.",
|
||||
title = context.getString(R.string.report_title_no_vpn),
|
||||
summary = context.getString(R.string.report_summary_no_vpn),
|
||||
explanation = context.getString(R.string.report_explanation_no_vpn) +
|
||||
" " + scoreText,
|
||||
state = SignalState.NEGATIVE,
|
||||
transportState = SignalState.NEGATIVE,
|
||||
transportText = "NOT DETECTED",
|
||||
transportSubtitle = "No VPN transport was reported by Android."
|
||||
transportText = context.getString(R.string.report_transport_text_not_detected),
|
||||
transportSubtitle = context.getString(R.string.report_transport_subtitle_not_detected)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildApiSignals(
|
||||
interfaceName: String?,
|
||||
context: Context,
|
||||
rawInterfaceName: String?,
|
||||
interfaceDetected: Boolean,
|
||||
transportInfoSummary: String?,
|
||||
transportInfoDetected: Boolean,
|
||||
|
|
@ -225,38 +481,42 @@ object ReportFormatter {
|
|||
|
||||
val interfaceHint = when {
|
||||
interfaceDetected && activeVpn ->
|
||||
"The interface name itself looks like a tunnel device and matches the active VPN state."
|
||||
context.getString(R.string.signal_hint_interface_active)
|
||||
interfaceDetected && anyVpn ->
|
||||
"The interface name looks tunnel-like and is consistent with a VPN being present somewhere in the system."
|
||||
context.getString(R.string.signal_hint_interface_any)
|
||||
interfaceDetected ->
|
||||
"The interface name looks tunnel-like, but Android does not currently mark the active path as VPN."
|
||||
context.getString(R.string.signal_hint_interface_only)
|
||||
rawInterfaceName != null ->
|
||||
context.getString(R.string.signal_hint_interface_normal)
|
||||
else ->
|
||||
"The interface name does not look like a typical VPN or tunnel interface."
|
||||
context.getString(R.string.signal_hint_interface_missing)
|
||||
}
|
||||
|
||||
val transportInfoHint = when {
|
||||
transportInfoDetected && activeVpn ->
|
||||
"Android returned transport info alongside an active VPN transport."
|
||||
context.getString(R.string.signal_hint_transport_active)
|
||||
transportInfoDetected && anyVpn ->
|
||||
"Transport info is present and aligns with a VPN existing somewhere in the network stack."
|
||||
context.getString(R.string.signal_hint_transport_any)
|
||||
transportInfoDetected ->
|
||||
"Transport info is present, but without a direct active VPN transport flag."
|
||||
context.getString(R.string.signal_hint_transport_only)
|
||||
else ->
|
||||
"No VPN-related transport info was exposed here."
|
||||
context.getString(R.string.signal_hint_transport_missing)
|
||||
}
|
||||
|
||||
return listOf(
|
||||
SignalItem(
|
||||
title = "Interface name",
|
||||
source = "LinkProperties.getInterfaceName()",
|
||||
value = interfaceName ?: "none",
|
||||
title = context.getString(R.string.signal_title_interface_name),
|
||||
source = context.getString(R.string.signal_source_interface_name),
|
||||
value = rawInterfaceName?.let(::softWrapToken)
|
||||
?: context.getString(R.string.report_value_none),
|
||||
state = interfaceState,
|
||||
hint = interfaceHint
|
||||
),
|
||||
SignalItem(
|
||||
title = "Transport info",
|
||||
source = "NetworkCapabilities.getTransportInfo()",
|
||||
value = transportInfoSummary ?: "none",
|
||||
title = context.getString(R.string.signal_title_transport_info),
|
||||
source = context.getString(R.string.signal_source_transport_info),
|
||||
value = formatCompactTransportInfo(transportInfoSummary)
|
||||
?: context.getString(R.string.report_value_none),
|
||||
state = transportInfoState,
|
||||
hint = transportInfoHint
|
||||
)
|
||||
|
|
@ -272,4 +532,32 @@ object ReportFormatter {
|
|||
val transportText: String,
|
||||
val transportSubtitle: String
|
||||
)
|
||||
|
||||
private fun formatCompactTransportInfo(summary: String?): String? {
|
||||
if (summary.isNullOrBlank()) return null
|
||||
|
||||
val normalized = summary
|
||||
.replace("VpnTransportInfo", "VPN")
|
||||
.replace("type=", "")
|
||||
.replace("PLATFORM", "platform")
|
||||
.replace("(", " (")
|
||||
.trim()
|
||||
|
||||
return when {
|
||||
normalized.contains("VPN", ignoreCase = true) &&
|
||||
normalized.contains("platform", ignoreCase = true) -> "VPN (platform)"
|
||||
normalized.contains("VPN", ignoreCase = true) -> "VPN"
|
||||
else -> softWrapToken(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
private fun softWrapToken(value: String): String {
|
||||
return value
|
||||
.replace("(", "(\u200B")
|
||||
.replace(")", "\u200B)")
|
||||
.replace("/", "/\u200B")
|
||||
.replace("-", "-\u200B")
|
||||
.replace("_", "_\u200B")
|
||||
.replace(",", ",\u200B")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
package com.cherepavel.vpndetector.ui.export
|
||||
|
||||
data class ExportReport(
|
||||
val title: String,
|
||||
val generatedAt: String,
|
||||
val buildInfo: String,
|
||||
val sourceCodeUrl: String,
|
||||
val sections: List<ExportSection>
|
||||
)
|
||||
|
||||
data class ExportSection(
|
||||
val title: String,
|
||||
val items: List<ExportItem>
|
||||
)
|
||||
|
||||
sealed class ExportItem {
|
||||
data class Field(
|
||||
val label: String,
|
||||
val value: String
|
||||
) : ExportItem()
|
||||
|
||||
data class ListBlock(
|
||||
val label: String,
|
||||
val values: List<String>
|
||||
) : ExportItem()
|
||||
|
||||
data class Paragraph(
|
||||
val text: String
|
||||
) : ExportItem()
|
||||
}
|
||||
|
|
@ -0,0 +1,516 @@
|
|||
package com.cherepavel.vpndetector.ui.export
|
||||
|
||||
import android.content.Context
|
||||
import com.cherepavel.vpndetector.BuildConfig
|
||||
import com.cherepavel.vpndetector.R
|
||||
import com.cherepavel.vpndetector.detector.TunnelNameMatcher
|
||||
import com.cherepavel.vpndetector.model.DetectionConfidence
|
||||
import com.cherepavel.vpndetector.model.DetectionSnapshot
|
||||
import com.cherepavel.vpndetector.model.DetectionStatus
|
||||
import com.cherepavel.vpndetector.util.nowString
|
||||
|
||||
object ReportExportBuilder {
|
||||
|
||||
fun build(
|
||||
context: Context,
|
||||
snapshot: DetectionSnapshot
|
||||
): ExportReport {
|
||||
val sections = buildList {
|
||||
add(
|
||||
ExportSection(
|
||||
title = "OVERALL STATUS",
|
||||
items = buildOverallItems(snapshot)
|
||||
)
|
||||
)
|
||||
|
||||
add(
|
||||
ExportSection(
|
||||
title = "OFFICIAL ANDROID API",
|
||||
items = buildOfficialApiItems(snapshot)
|
||||
)
|
||||
)
|
||||
|
||||
buildVpnNetworkDetailsSection(snapshot)?.let { add(it) }
|
||||
buildAdditionalSignalsSection(snapshot)?.let { add(it) }
|
||||
add(buildNativeSection(snapshot))
|
||||
add(buildJavaSection(snapshot))
|
||||
add(buildAppsSection(snapshot))
|
||||
buildAdvancedSignalsSection(snapshot)?.let { add(it) }
|
||||
}
|
||||
|
||||
return ExportReport(
|
||||
title = "VPN Detector Report",
|
||||
generatedAt = nowString(),
|
||||
buildInfo = buildBuildInfo(),
|
||||
sourceCodeUrl = context.getString(R.string.repo_url),
|
||||
sections = sections
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildBuildInfo(): String {
|
||||
return "${BuildConfig.VERSION_NAME} • ${BuildConfig.GIT_HASH} • ${BuildConfig.BUILD_TYPE}"
|
||||
}
|
||||
|
||||
private fun buildOverallItems(snapshot: DetectionSnapshot): List<ExportItem> {
|
||||
val overall = buildOverallBlock(snapshot)
|
||||
|
||||
return buildList {
|
||||
add(ExportItem.Paragraph(overall.title))
|
||||
add(ExportItem.Paragraph(overall.summary))
|
||||
add(ExportItem.Paragraph(overall.explanation))
|
||||
add(ExportItem.Field("Score", "${snapshot.assessment.score}/100"))
|
||||
add(ExportItem.Field("Confidence", confidenceLabel(snapshot.assessment.confidence)))
|
||||
add(ExportItem.Field("Status", snapshot.assessment.status.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildOfficialApiItems(snapshot: DetectionSnapshot): List<ExportItem> {
|
||||
val anyVpn = snapshot.hasTransportVpnAny
|
||||
val activeVpn = snapshot.hasTransportVpnActive
|
||||
val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(snapshot.rawInterfaceName)
|
||||
val transportInfoDetected = !snapshot.transportInfoSummary.isNullOrBlank()
|
||||
|
||||
val items = buildList {
|
||||
add(
|
||||
ExportItem.Field(
|
||||
"TRANSPORT_VPN across all networks",
|
||||
if (anyVpn) "DETECTED" else "NOT DETECTED"
|
||||
)
|
||||
)
|
||||
add(
|
||||
ExportItem.Field(
|
||||
"TRANSPORT_VPN active network only",
|
||||
if (activeVpn) "DETECTED" else "NOT DETECTED"
|
||||
)
|
||||
)
|
||||
|
||||
val overall = buildOverallBlock(snapshot)
|
||||
add(ExportItem.Field("Transport state", overall.transportText))
|
||||
add(ExportItem.Field("Transport subtitle", overall.transportSubtitle))
|
||||
|
||||
val interfaceHint = when {
|
||||
interfaceDetected && activeVpn ->
|
||||
"The interface name itself looks like a tunnel device and matches the active VPN state."
|
||||
interfaceDetected && anyVpn ->
|
||||
"The interface name looks tunnel-like and is consistent with a VPN being present somewhere in the system."
|
||||
interfaceDetected ->
|
||||
"The interface name looks tunnel-like, but Android does not currently mark the active path as VPN."
|
||||
snapshot.rawInterfaceName != null ->
|
||||
"The interface name does not look like a typical VPN or tunnel interface."
|
||||
else ->
|
||||
"Android did not expose an interface name for this network."
|
||||
}
|
||||
|
||||
val transportInfoHint = when {
|
||||
transportInfoDetected && activeVpn ->
|
||||
"Android returned transport info alongside an active VPN transport."
|
||||
transportInfoDetected && anyVpn ->
|
||||
"Transport info is present and aligns with a VPN existing somewhere in the network stack."
|
||||
transportInfoDetected ->
|
||||
"Transport info is present, but without a direct active VPN transport flag."
|
||||
else ->
|
||||
"No VPN-related transport info was exposed here."
|
||||
}
|
||||
|
||||
add(ExportItem.Paragraph("API signals:"))
|
||||
add(ExportItem.Paragraph("- Interface name"))
|
||||
add(ExportItem.Field("source", "LinkProperties.getInterfaceName()"))
|
||||
add(ExportItem.Field("value", snapshot.rawInterfaceName ?: "none"))
|
||||
add(ExportItem.Field("hint", interfaceHint))
|
||||
|
||||
add(ExportItem.Paragraph("- Transport info"))
|
||||
add(ExportItem.Field("source", "NetworkCapabilities.getTransportInfo()"))
|
||||
add(
|
||||
ExportItem.Field(
|
||||
"value",
|
||||
formatCompactTransportInfo(snapshot.transportInfoSummary) ?: "none"
|
||||
)
|
||||
)
|
||||
add(ExportItem.Field("hint", transportInfoHint))
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private fun buildVpnNetworkDetailsSection(snapshot: DetectionSnapshot): ExportSection? {
|
||||
val items = buildList {
|
||||
if (snapshot.vpnRoutes.isNotEmpty()) {
|
||||
add(ExportItem.ListBlock("Routes", snapshot.vpnRoutes))
|
||||
}
|
||||
|
||||
if (snapshot.vpnDnsServers.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.Field(
|
||||
"DNS servers",
|
||||
snapshot.vpnDnsServers.joinToString(", ")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.allDnsServers.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.ListBlock(
|
||||
label = "DNS across visible networks",
|
||||
values = snapshot.allDnsServers
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.internalDnsServers.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.ListBlock(
|
||||
label = "Internal/private-range DNS servers",
|
||||
values = snapshot.internalDnsServers
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.contextualInternalDnsServers.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.ListBlock(
|
||||
label = "Cellular private DNS observed (not treated as VPN)",
|
||||
values = snapshot.contextualInternalDnsServers
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.privateDnsActive || snapshot.privateDnsServerName != null) {
|
||||
val privateDnsValue = buildString {
|
||||
append(if (snapshot.privateDnsActive) "active" else "inactive")
|
||||
snapshot.privateDnsServerName?.let { append(" ($it)") }
|
||||
}
|
||||
add(ExportItem.Field("Private DNS", privateDnsValue))
|
||||
}
|
||||
|
||||
if (snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null) {
|
||||
add(
|
||||
ExportItem.Field(
|
||||
"NET_CAPABILITY_NOT_VPN",
|
||||
"active=${snapshot.activeNetworkNotVpn ?: "unknown"}, preferred=${snapshot.preferredNetworkNotVpn ?: "unknown"}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
snapshot.vpnBandwidthSummary?.let {
|
||||
add(ExportItem.Field("Bandwidth", it))
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty()) return null
|
||||
|
||||
return ExportSection(
|
||||
title = "VPN NETWORK DETAILS",
|
||||
items = items
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildAdditionalSignalsSection(snapshot: DetectionSnapshot): ExportSection? {
|
||||
val items = buildList {
|
||||
if (snapshot.tunTypeInterfaces.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.Field(
|
||||
"TUN interfaces (type=65534)",
|
||||
snapshot.tunTypeInterfaces.joinToString(", ")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.lowMtuInterfaces.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.ListBlock(
|
||||
label = "Low-MTU interfaces (<1500)",
|
||||
values = snapshot.lowMtuInterfaces
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.kernelRoutes.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.ListBlock(
|
||||
label = "Kernel route table (/proc/net/route)",
|
||||
values = snapshot.kernelRoutes
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.kernelIpv6Routes.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.ListBlock(
|
||||
label = "Kernel route table (/proc/net/ipv6_route)",
|
||||
values = snapshot.kernelIpv6Routes
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.vpnPermissionGranted) {
|
||||
add(
|
||||
ExportItem.Paragraph(
|
||||
"VPN permission: this app holds VPN grant (anomalous)."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty()) return null
|
||||
|
||||
return ExportSection(
|
||||
title = "ADDITIONAL SIGNALS",
|
||||
items = items
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNativeSection(snapshot: DetectionSnapshot): ExportSection {
|
||||
val nativeValue = snapshot.nativeTunnelNames.ifEmpty { listOf("none") }.joinToString(", ")
|
||||
val nativeHint = if (snapshot.nativeTunnelNames.isNotEmpty()) {
|
||||
"Native enumeration found interfaces whose names or properties look tunnel-like."
|
||||
} else {
|
||||
"Native enumeration did not find any tunnel-like interfaces."
|
||||
}
|
||||
|
||||
val nativeDetails = buildString {
|
||||
if (snapshot.nativeError != null) {
|
||||
appendLine("Native detector error: ${snapshot.nativeError}")
|
||||
appendLine()
|
||||
}
|
||||
if (snapshot.nativeDetails.isNotEmpty()) {
|
||||
append(snapshot.nativeDetails.joinToString(separator = "\n\n"))
|
||||
} else if (snapshot.nativeError == null) {
|
||||
append("No interfaces were returned by the native detector.")
|
||||
}
|
||||
}.trim()
|
||||
|
||||
return ExportSection(
|
||||
title = "NATIVE LOW-LEVEL ENUMERATION",
|
||||
items = buildList {
|
||||
snapshot.nativeError?.let {
|
||||
add(ExportItem.Field("Error", it))
|
||||
}
|
||||
add(ExportItem.Field("Signal value", nativeValue))
|
||||
add(ExportItem.Field("Signal hint", nativeHint))
|
||||
add(ExportItem.Paragraph(nativeDetails))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildJavaSection(snapshot: DetectionSnapshot): ExportSection {
|
||||
val javaValue = snapshot.javaTunnelNames.ifEmpty { listOf("none") }.joinToString(", ")
|
||||
val javaHint = if (snapshot.javaTunnelNames.isNotEmpty()) {
|
||||
"Java network enumeration found interface names that look like VPN or tunnel interfaces."
|
||||
} else {
|
||||
"Java network enumeration did not find any tunnel-like interface names."
|
||||
}
|
||||
|
||||
return ExportSection(
|
||||
title = "JAVA INTERFACE ENUMERATION",
|
||||
items = buildList {
|
||||
add(ExportItem.Field("Signal value", javaValue))
|
||||
add(ExportItem.Field("Signal hint", javaHint))
|
||||
if (snapshot.javaTunnelNames.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.ListBlock(
|
||||
label = "Matched tunnel-like names",
|
||||
values = snapshot.javaTunnelNames
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildAppsSection(snapshot: DetectionSnapshot): ExportSection {
|
||||
return ExportSection(
|
||||
title = "DETECTED VPN APPS",
|
||||
items = buildList {
|
||||
if (snapshot.installedVpnApps.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.ListBlock(
|
||||
label = "From tracked list",
|
||||
values = snapshot.installedVpnApps
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.unknownDynamicApps.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.ListBlock(
|
||||
label = "Detected via VpnService query",
|
||||
values = snapshot.unknownDynamicApps
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.trackedAppsErrors.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.ListBlock(
|
||||
label = "Check errors",
|
||||
values = snapshot.trackedAppsErrors.map { (pkg, err) -> "$pkg: $err" }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.installedVpnApps.isEmpty() && snapshot.unknownDynamicApps.isEmpty()) {
|
||||
add(ExportItem.Paragraph("No VPN-related apps detected."))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildAdvancedSignalsSection(snapshot: DetectionSnapshot): ExportSection? {
|
||||
val items = buildList {
|
||||
if (snapshot.lockdownLikely) {
|
||||
add(
|
||||
ExportItem.Paragraph(
|
||||
"Always-on lockdown: likely (no validated non-VPN path exists)."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.knownVpnDnsMatches.isNotEmpty()) {
|
||||
add(
|
||||
ExportItem.ListBlock(
|
||||
label = "Known VPN provider DNS",
|
||||
values = snapshot.knownVpnDnsMatches
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.workProfileCount > 1) {
|
||||
add(
|
||||
ExportItem.Paragraph(
|
||||
"Work profile: ${snapshot.workProfileCount} user profiles detected. VPN apps in other profiles are not visible."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.isManagedProfile) {
|
||||
add(ExportItem.Paragraph("Running inside a managed profile."))
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty()) return null
|
||||
|
||||
return ExportSection(
|
||||
title = "ADVANCED SIGNALS",
|
||||
items = items
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildOverallBlock(snapshot: DetectionSnapshot): OverallBlock {
|
||||
val anyVpn = snapshot.hasTransportVpnAny
|
||||
val activeVpn = snapshot.hasTransportVpnActive
|
||||
val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(snapshot.rawInterfaceName)
|
||||
val transportInfoDetected = !snapshot.transportInfoSummary.isNullOrBlank()
|
||||
val dnsDetected = snapshot.internalDnsServers.isNotEmpty()
|
||||
val policyDetected =
|
||||
snapshot.activeNetworkNotVpn == false || snapshot.preferredNetworkNotVpn == false
|
||||
val nativeDetected = snapshot.nativeTunnelNames.isNotEmpty()
|
||||
val javaDetected = snapshot.javaTunnelNames.isNotEmpty()
|
||||
val appsDetected = snapshot.installedVpnApps.isNotEmpty()
|
||||
|
||||
val scoreText =
|
||||
"Confidence: ${confidenceLabel(snapshot.assessment.confidence)} (${snapshot.assessment.score}/100)."
|
||||
|
||||
return when {
|
||||
snapshot.assessment.status == DetectionStatus.ACTIVE_VPN || activeVpn -> {
|
||||
val lockdownNote = if (snapshot.lockdownLikely) {
|
||||
" Lockdown mode appears active — no non-VPN path is validated."
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
OverallBlock(
|
||||
title = if (snapshot.lockdownLikely) "VPN detected (lockdown)" else "VPN detected",
|
||||
summary = "The active network is explicitly marked as VPN by Android.$lockdownNote",
|
||||
explanation = "This is the strongest signal in the app: Android reports TRANSPORT_VPN on the network currently in use. $scoreText",
|
||||
transportText = "VPN DETECTED",
|
||||
transportSubtitle = "TRANSPORT_VPN is present on the active network."
|
||||
)
|
||||
}
|
||||
|
||||
snapshot.assessment.status == DetectionStatus.SPLIT_TUNNEL || anyVpn -> {
|
||||
OverallBlock(
|
||||
title = "VPN present outside active path",
|
||||
summary = "Android sees a VPN network in the system, but not on the current active network.",
|
||||
explanation = "This often matches bypass or split-tunnel behavior: a VPN exists, but current traffic may not be fully routed through it. $scoreText",
|
||||
transportText = "SPLIT / BYPASS",
|
||||
transportSubtitle = "A VPN-related transport exists system-wide, but it is not the current active path."
|
||||
)
|
||||
}
|
||||
|
||||
interfaceDetected || transportInfoDetected || dnsDetected || policyDetected -> {
|
||||
OverallBlock(
|
||||
title = "VPN-related API signal",
|
||||
summary = "Android APIs still expose VPN-like indicators even though active TRANSPORT_VPN is absent.",
|
||||
explanation = "This is weaker than a direct VPN transport flag, but interface, DNS, or capability signals still suggest VPN-related state in the visible network stack. $scoreText",
|
||||
transportText = "API SIGNAL",
|
||||
transportSubtitle = "No active TRANSPORT_VPN, but Android APIs still expose VPN-related information."
|
||||
)
|
||||
}
|
||||
|
||||
nativeDetected || javaDetected -> {
|
||||
OverallBlock(
|
||||
title = "Low-level tunnel signal",
|
||||
summary = "No primary Android VPN signal was found, but tunnel-like interfaces were still discovered.",
|
||||
explanation = "This usually means only low-level interface heuristics fired. It is useful as an additional hint, but weaker than official Android VPN signals. $scoreText",
|
||||
transportText = "NOT DETECTED",
|
||||
transportSubtitle = "Android did not report VPN transport on the active path."
|
||||
)
|
||||
}
|
||||
|
||||
snapshot.assessment.status == DetectionStatus.APPS_PRESENT || appsDetected -> {
|
||||
OverallBlock(
|
||||
title = "Detected VPN apps",
|
||||
summary = "No active VPN network signal was found, but known VPN-related apps are installed on the device.",
|
||||
explanation = "Installed VPN apps do not prove that a VPN is currently active, but they are still a relevant contextual signal. $scoreText",
|
||||
transportText = "NOT DETECTED",
|
||||
transportSubtitle = "Android did not report VPN transport on the active path."
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
OverallBlock(
|
||||
title = "No VPN detected",
|
||||
summary = "The app did not find any high-level or low-level VPN indicators.",
|
||||
explanation = "Neither official Android network APIs nor interface enumeration produced a VPN-related signal. $scoreText",
|
||||
transportText = "NOT DETECTED",
|
||||
transportSubtitle = "No VPN transport was reported by Android."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class OverallBlock(
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val explanation: String,
|
||||
val transportText: String,
|
||||
val transportSubtitle: String
|
||||
)
|
||||
|
||||
private fun formatCompactTransportInfo(summary: String?): String? {
|
||||
if (summary.isNullOrBlank()) return null
|
||||
|
||||
val normalized = summary
|
||||
.replace("VpnTransportInfo", "VPN")
|
||||
.replace("type=", "")
|
||||
.replace("PLATFORM", "platform")
|
||||
.replace("(", " (")
|
||||
.trim()
|
||||
|
||||
return when {
|
||||
normalized.contains("VPN", ignoreCase = true) &&
|
||||
normalized.contains("platform", ignoreCase = true) -> "VPN (platform)"
|
||||
normalized.contains("VPN", ignoreCase = true) -> "VPN"
|
||||
else -> normalized
|
||||
}
|
||||
}
|
||||
|
||||
private fun confidenceLabel(confidence: DetectionConfidence): String {
|
||||
return when (confidence) {
|
||||
DetectionConfidence.CONFIRMED -> "confirmed"
|
||||
DetectionConfidence.LIKELY -> "likely"
|
||||
DetectionConfidence.WEAK_SIGNAL -> "weak signal"
|
||||
DetectionConfidence.NO_EVIDENCE -> "no evidence"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.cherepavel.vpndetector.ui.export
|
||||
|
||||
object ReportExportFormatter {
|
||||
|
||||
fun buildText(report: ExportReport): String {
|
||||
return buildString {
|
||||
appendLine(report.title)
|
||||
appendLine("Generated: ${report.generatedAt}")
|
||||
appendLine("Build: ${report.buildInfo}")
|
||||
appendLine("Source code: ${report.sourceCodeUrl}")
|
||||
appendLine()
|
||||
|
||||
report.sections.forEachIndexed { index, section ->
|
||||
appendLine("=== ${section.title} ===")
|
||||
|
||||
section.items.forEach { item ->
|
||||
when (item) {
|
||||
is ExportItem.Field -> {
|
||||
appendLine("${item.label}: ${item.value}")
|
||||
}
|
||||
|
||||
is ExportItem.ListBlock -> {
|
||||
appendLine("${item.label}:")
|
||||
item.values.forEach { value ->
|
||||
appendLine(" $value")
|
||||
}
|
||||
}
|
||||
|
||||
is ExportItem.Paragraph -> {
|
||||
appendLine(item.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (index != report.sections.lastIndex) {
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
}.trim()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,170 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
android:viewportWidth="124"
|
||||
android:viewportHeight="124">
|
||||
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
android:fillColor="#1E88E5"
|
||||
android:pathData="M24,0L100,0A24,24 0,0 1,124 24L124,100A24,24 0,0 1,100 124L24,124A24,24 0,0 1,0 100L0,24A24,24 0,0 1,24 0z"/>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
android:viewportWidth="124"
|
||||
android:viewportHeight="124">
|
||||
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="
|
||||
M62,28
|
||||
L38,38
|
||||
V60
|
||||
C38,78 50,92 62,98
|
||||
C74,92 86,78 86,60
|
||||
V38
|
||||
Z"/>
|
||||
|
||||
<path
|
||||
android:fillColor="#1E88E5"
|
||||
android:pathData="M62,60m-12,0a12,12 0,1 1,24 0a12,12 0,1 1,-24 0"/>
|
||||
|
||||
<!-- Handle -->
|
||||
<path
|
||||
android:pathData="M69.8,67.8L79.8,77.8"
|
||||
android:strokeColor="#1E88E5"
|
||||
android:strokeWidth="4.5"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000"/>
|
||||
|
||||
</vector>
|
||||
|
|
@ -15,528 +15,23 @@
|
|||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="VPN Detector"
|
||||
android:textColor="?attr/appColorOnBackground"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
<include layout="@layout/main_section_header" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_card_surface"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
<include layout="@layout/main_section_overall_status" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Overall status"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
<include layout="@layout/main_section_actions" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textVpnStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="UNKNOWN"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
<include layout="@layout/main_section_official_api" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textVpnSummary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="14sp" />
|
||||
<include layout="@layout/main_section_native" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textVpnExplanation"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
<include layout="@layout/main_section_extra" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:orientation="horizontal">
|
||||
<include layout="@layout/main_section_java" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonRefresh"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="?attr/appColorAccentPrimary"
|
||||
android:text="Refresh"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold" />
|
||||
<include layout="@layout/main_section_apps" />
|
||||
|
||||
<Space
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="1dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonReport"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="?attr/appColorAccentPrimary"
|
||||
android:text="Generate report"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textLastUpdate"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:gravity="end"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardOfficialSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_card_surface"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Official Android API"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Primary signals exposed by Android network APIs"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardTransportVpn"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@drawable/bg_card_inner"
|
||||
android:orientation="vertical"
|
||||
android:padding="14dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTransportTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="TRANSPORT_VPN"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTransportState"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTransportSubtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Across all networks"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTransportAnyValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_signal_neutral"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:text="-"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Active network only"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTransportActiveValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_signal_neutral"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:text="-"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardApiSignal1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_card_inner"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textApiSignalTitle1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textApiSignalSource1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textApiSignalValue1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textApiSignalHint1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="1dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardApiSignal2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_card_inner"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textApiSignalTitle2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textApiSignalSource2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textApiSignalValue2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textApiSignalHint2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardNativeSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_card_surface"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Native low-level enumeration"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Experimental signal from native interface discovery"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardNativeSignal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/bg_card_inner"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textNativeSignalTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textNativeSignalSource"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textNativeSignalValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textNativeSignalHint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scrollNativeDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="220dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/bg_card_inner"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls"
|
||||
android:scrollbars="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textNativeDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:padding="12dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardJavaSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_card_surface"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Java interface enumeration"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Supplementary signal from Java networking APIs"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardJavaSignal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/bg_card_inner"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textJavaSignalTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textJavaSignalSource"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textJavaSignalValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textJavaSignalHint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardAppsSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_card_surface"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Detected VPN apps"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textKnownApps"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
<include layout="@layout/main_section_footer" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
|
|
|
|||
30
app/src/main/res/layout/common_item_detail_section.xml
Normal file
30
app/src/main/res/layout/common_item_detail_section.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/bg_card_inner"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textDetailTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textDetailBody"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textIsSelectable="true"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
47
app/src/main/res/layout/common_item_signal_card.xml
Normal file
47
app/src/main/res/layout/common_item_signal_card.xml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_card_inner"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textSignalTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textSignalSource"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textSignalValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textSignalHint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
</LinearLayout>
|
||||
47
app/src/main/res/layout/common_item_signal_card_full.xml
Normal file
47
app/src/main/res/layout/common_item_signal_card_full.xml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/bg_card_inner"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textSignalTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textSignalSource"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textSignalValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textSignalHint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
</LinearLayout>
|
||||
99
app/src/main/res/layout/main_block_transport_vpn.xml
Normal file
99
app/src/main/res/layout/main_block_transport_vpn.xml
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/cardTransportVpn"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@drawable/bg_card_inner"
|
||||
android:orientation="vertical"
|
||||
android:padding="14dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTransportTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="TRANSPORT_VPN"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTransportState"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTransportSubtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Across all networks"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTransportAnyValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_signal_neutral"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:text="-"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Active network only"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTransportActiveValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_signal_neutral"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:text="-"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
47
app/src/main/res/layout/main_section_actions.xml
Normal file
47
app/src/main/res/layout/main_section_actions.xml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonRefresh"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="?attr/appColorAccentPrimary"
|
||||
android:text="Refresh"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Space
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="1dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonReport"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="?attr/appColorAccentPrimary"
|
||||
android:text="Generate report"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textLastUpdate"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:gravity="end"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
</merge>
|
||||
31
app/src/main/res/layout/main_section_apps.xml
Normal file
31
app/src/main/res/layout/main_section_apps.xml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardAppsSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_card_surface"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Detected VPN apps"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textKnownApps"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
||||
37
app/src/main/res/layout/main_section_extra.xml
Normal file
37
app/src/main/res/layout/main_section_extra.xml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardExtraSections"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_card_surface"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Additional technical details"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Routes, DNS, kernel tables and related context"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/containerExtraSections"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
||||
36
app/src/main/res/layout/main_section_footer.xml
Normal file
36
app/src/main/res/layout/main_section_footer.xml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/footerSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textVersion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp"
|
||||
android:alpha="0.70" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textFooterInfo"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="11sp"
|
||||
android:alpha="0.60"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
||||
14
app/src/main/res/layout/main_section_header.xml
Normal file
14
app/src/main/res/layout/main_section_header.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="VPN Detector"
|
||||
android:textColor="?attr/appColorOnBackground"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</merge>
|
||||
35
app/src/main/res/layout/main_section_java.xml
Normal file
35
app/src/main/res/layout/main_section_java.xml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardJavaSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_card_surface"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Java interface enumeration"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Supplementary signal from Java networking APIs"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<include
|
||||
android:id="@+id/cardJavaSignal"
|
||||
layout="@layout/common_item_signal_card_full" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
||||
57
app/src/main/res/layout/main_section_native.xml
Normal file
57
app/src/main/res/layout/main_section_native.xml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardNativeSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_card_surface"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Native low-level enumeration"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Experimental signal from native interface discovery"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<include
|
||||
android:id="@+id/cardNativeSignal"
|
||||
layout="@layout/common_item_signal_card_full" />
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scrollNativeDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="220dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/bg_card_inner"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls"
|
||||
android:scrollbars="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textNativeDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:padding="12dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
||||
52
app/src/main/res/layout/main_section_official_api.xml
Normal file
52
app/src/main/res/layout/main_section_official_api.xml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardOfficialSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_card_surface"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Official Android API"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Primary signals exposed by Android network APIs"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<include layout="@layout/main_block_transport_vpn" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<include
|
||||
android:id="@+id/cardApiSignal1"
|
||||
layout="@layout/common_item_signal_card" />
|
||||
|
||||
<Space
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="1dp" />
|
||||
|
||||
<include
|
||||
android:id="@+id/cardApiSignal2"
|
||||
layout="@layout/common_item_signal_card" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
||||
50
app/src/main/res/layout/main_section_overall_status.xml
Normal file
50
app/src/main/res/layout/main_section_overall_status.xml
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_card_surface"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Overall status"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textVpnStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="UNKNOWN"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textVpnSummary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurface"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textVpnExplanation"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
||||
|
|
@ -1,3 +1,115 @@
|
|||
<resources>
|
||||
<string name="app_name">VpnDetector</string>
|
||||
</resources>
|
||||
<string name="app_name" translatable="false">VPN Detector</string>
|
||||
|
||||
<string name="report_value_detected">DETECTED</string>
|
||||
<string name="report_value_not_detected">NOT DETECTED</string>
|
||||
<string name="report_value_none">none</string>
|
||||
<string name="report_value_unknown">unknown</string>
|
||||
|
||||
<string name="report_title_vpn_detected">VPN detected</string>
|
||||
<string name="report_title_vpn_detected_lockdown">VPN detected (lockdown)</string>
|
||||
<string name="report_summary_vpn_detected">The active network is explicitly marked as VPN by Android.</string>
|
||||
<string name="report_summary_lockdown_suffix"> Lockdown mode appears active — no non-VPN path is validated.</string>
|
||||
<string name="report_explanation_vpn_detected">This is the strongest signal in the app: Android reports TRANSPORT_VPN on the network currently in use.</string>
|
||||
|
||||
<string name="report_title_split_tunnel">VPN present outside active path</string>
|
||||
<string name="report_summary_split_tunnel">Android sees a VPN network in the system, but not on the current active network.</string>
|
||||
<string name="report_explanation_split_tunnel">This often matches bypass or split-tunnel behavior: a VPN exists, but current traffic may not be fully routed through it.</string>
|
||||
|
||||
<string name="report_title_api_signal">VPN-related API signal</string>
|
||||
<string name="report_summary_api_signal">Android APIs still expose VPN-like indicators even though active TRANSPORT_VPN is absent.</string>
|
||||
<string name="report_explanation_api_signal">This is weaker than a direct VPN transport flag, but interface, DNS, or capability signals still suggest VPN-related state in the visible network stack.</string>
|
||||
|
||||
<string name="report_title_low_level">Low-level tunnel signal</string>
|
||||
<string name="report_summary_low_level">No primary Android VPN signal was found, but tunnel-like interfaces were still discovered.</string>
|
||||
<string name="report_explanation_low_level">This usually means only low-level interface heuristics fired. It is useful as an additional hint, but weaker than official Android VPN signals.</string>
|
||||
|
||||
<string name="report_title_apps_present">Detected VPN apps</string>
|
||||
<string name="report_summary_apps_present">No active VPN network signal was found, but known VPN-related apps are installed on the device.</string>
|
||||
<string name="report_explanation_apps_present">Installed VPN apps do not prove that a VPN is currently active, but they are still a relevant contextual signal.</string>
|
||||
|
||||
<string name="report_title_no_vpn">No VPN detected</string>
|
||||
<string name="report_summary_no_vpn">The app did not find any high-level or low-level VPN indicators.</string>
|
||||
<string name="report_explanation_no_vpn">Neither official Android network APIs nor interface enumeration produced a VPN-related signal.</string>
|
||||
|
||||
<string name="report_transport_text_vpn_detected">VPN DETECTED</string>
|
||||
<string name="report_transport_text_split_tunnel">SPLIT / BYPASS</string>
|
||||
<string name="report_transport_text_api_signal">API SIGNAL</string>
|
||||
<string name="report_transport_text_not_detected">NOT DETECTED</string>
|
||||
|
||||
<string name="report_transport_subtitle_vpn_detected">TRANSPORT_VPN is present on the active network.</string>
|
||||
<string name="report_transport_subtitle_split_tunnel">A VPN-related transport exists system-wide, but it is not the current active path.</string>
|
||||
<string name="report_transport_subtitle_api_signal">No active TRANSPORT_VPN, but Android APIs still expose VPN-related information.</string>
|
||||
<string name="report_transport_subtitle_not_detected">No VPN transport was reported by Android.</string>
|
||||
<string name="report_transport_subtitle_not_on_active_path">Android did not report VPN transport on the active path.</string>
|
||||
|
||||
<string name="report_confidence_confirmed">confirmed</string>
|
||||
<string name="report_confidence_likely">likely</string>
|
||||
<string name="report_confidence_weak_signal">weak signal</string>
|
||||
<string name="report_confidence_no_evidence">no evidence</string>
|
||||
<string name="report_confidence_format">Confidence: %1$s (%2$d/100).</string>
|
||||
|
||||
<string name="signal_title_interface_name">Interface name</string>
|
||||
<string name="signal_source_interface_name">LinkProperties.getInterfaceName()</string>
|
||||
<string name="signal_title_transport_info">Transport info</string>
|
||||
<string name="signal_source_transport_info">NetworkCapabilities.getTransportInfo()</string>
|
||||
|
||||
<string name="signal_hint_interface_active">The interface name itself looks like a tunnel device and matches the active VPN state.</string>
|
||||
<string name="signal_hint_interface_any">The interface name looks tunnel-like and is consistent with a VPN being present somewhere in the system.</string>
|
||||
<string name="signal_hint_interface_only">The interface name looks tunnel-like, but Android does not currently mark the active path as VPN.</string>
|
||||
<string name="signal_hint_interface_normal">The interface name does not look like a typical VPN or tunnel interface.</string>
|
||||
<string name="signal_hint_interface_missing">Android did not expose an interface name for this network.</string>
|
||||
|
||||
<string name="signal_hint_transport_active">Android returned transport info alongside an active VPN transport.</string>
|
||||
<string name="signal_hint_transport_any">Transport info is present and aligns with a VPN existing somewhere in the network stack.</string>
|
||||
<string name="signal_hint_transport_only">Transport info is present, but without a direct active VPN transport flag.</string>
|
||||
<string name="signal_hint_transport_missing">No VPN-related transport info was exposed here.</string>
|
||||
|
||||
<string name="signal_title_tunnel_like_interfaces">Tunnel-like interfaces</string>
|
||||
<string name="signal_source_native">Native getifaddrs() enumeration</string>
|
||||
<string name="signal_source_java">Java NetworkInterface enumeration</string>
|
||||
<string name="signal_hint_native_found">Native enumeration found interfaces whose names or properties look tunnel-like.</string>
|
||||
<string name="signal_hint_native_missing">Native enumeration did not find any tunnel-like interfaces.</string>
|
||||
<string name="signal_hint_java_found">Java network enumeration found interface names that look like VPN or tunnel interfaces.</string>
|
||||
<string name="signal_hint_java_missing">Java network enumeration did not find any tunnel-like interface names.</string>
|
||||
|
||||
<string name="native_details_empty">No interfaces were returned by the native detector.</string>
|
||||
<string name="native_error_format">Native detector error: %1$s</string>
|
||||
|
||||
<string name="apps_none_detected">No VPN-related apps detected.</string>
|
||||
<string name="apps_detected_via_vpn_service">Detected via VpnService query:</string>
|
||||
<string name="apps_check_errors">Check errors (package manager returned unexpected error):</string>
|
||||
|
||||
<string name="section_tun_interfaces">TUN interfaces</string>
|
||||
<string name="section_low_mtu_interfaces">Low-MTU interfaces</string>
|
||||
<string name="section_vpn_routes">VPN network routes</string>
|
||||
<string name="section_vpn_dns_servers">VPN DNS servers</string>
|
||||
<string name="section_dns_all">DNS across visible networks</string>
|
||||
<string name="section_dns_internal">Internal/private-range DNS servers</string>
|
||||
<string name="section_dns_contextual">Cellular private DNS observed (context only)</string>
|
||||
<string name="section_private_dns">Private DNS</string>
|
||||
<string name="section_not_vpn">NET_CAPABILITY_NOT_VPN</string>
|
||||
<string name="section_vpn_bandwidth">VPN bandwidth</string>
|
||||
<string name="section_kernel_routes_v4">Kernel route table (/proc/net/route)</string>
|
||||
<string name="section_kernel_routes_v6">Kernel route table (/proc/net/ipv6_route)</string>
|
||||
<string name="section_vpn_permission">VPN permission</string>
|
||||
<string name="section_lockdown">Always-on / lockdown</string>
|
||||
<string name="section_known_vpn_dns">Known VPN provider DNS</string>
|
||||
<string name="section_work_profile">Work / managed profile</string>
|
||||
|
||||
<string name="private_dns_active">active</string>
|
||||
<string name="private_dns_inactive">inactive</string>
|
||||
<string name="private_dns_with_host">%1$s (%2$s)</string>
|
||||
|
||||
<string name="not_vpn_body">active=%1$s, preferred=%2$s</string>
|
||||
|
||||
<string name="vpn_permission_body">This app holds Android VPN permission (anomalous for a detector).</string>
|
||||
<string name="lockdown_body">VPN present and no validated non-VPN path exists. Lockdown mode is likely active.</string>
|
||||
<string name="managed_profile_body">Running inside a managed profile.</string>
|
||||
<string name="work_profile_count_body">%1$d user profiles detected. VPN apps in other profiles are not visible to this detector.</string>
|
||||
|
||||
<string name="author">cherepavel</string>
|
||||
<string name="repo_url">https://github.com/cherepavel/VPN-Detector</string>
|
||||
<string name="source_code_label">Source code:</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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
39
detector/build.gradle.kts
Normal 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)
|
||||
}
|
||||
2
detector/src/main/AndroidManifest.xml
Normal file
2
detector/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
14
detector/src/main/cpp/.clang-format
Normal file
14
detector/src/main/cpp/.clang-format
Normal 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
|
||||
40
detector/src/main/cpp/CMakeLists.txt
Normal file
40
detector/src/main/cpp/CMakeLists.txt
Normal 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}
|
||||
)
|
||||
410
detector/src/main/cpp/ifconfigdetector.cpp
Normal file
410
detector/src/main/cpp/ifconfigdetector.cpp
Normal 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());
|
||||
}
|
||||
16
detector/src/main/cpp/run_clang_format.sh
Executable file
16
detector/src/main/cpp/run_clang_format.sh
Executable 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"
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.cherepavel.vpndetector.detector
|
||||
|
||||
import com.cherepavel.vpndetector.model.DetectionSnapshot
|
||||
|
||||
interface IDetectionEngine {
|
||||
fun detect(): DetectionSnapshot
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.cherepavel.vpndetector.detector
|
||||
|
||||
import android.content.Context
|
||||
import com.cherepavel.vpndetector.model.TrackedApp
|
||||
import org.json.JSONArray
|
||||
|
||||
object TrackedAppsRepository {
|
||||
|
||||
@Volatile
|
||||
private var cached: List<TrackedApp>? = null
|
||||
|
||||
private const val FILE_NAME = "tracked_apps.json"
|
||||
|
||||
fun get(context: Context): List<TrackedApp> {
|
||||
cached?.let { return it }
|
||||
|
||||
return try {
|
||||
val json = context.applicationContext.assets
|
||||
.open(FILE_NAME)
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
|
||||
parse(json).also { cached = it }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(json: String): List<TrackedApp> {
|
||||
val array = JSONArray(json)
|
||||
return (0 until array.length()).map { i ->
|
||||
val obj = array.getJSONObject(i)
|
||||
TrackedApp(
|
||||
packageName = obj.getString("packageName"),
|
||||
label = obj.getString("label")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -13,7 +13,12 @@ object TunnelNameMatcher {
|
|||
"ipsec",
|
||||
"xfrm",
|
||||
"zt",
|
||||
"tailscale"
|
||||
"tailscale",
|
||||
"svpn",
|
||||
"ovpn",
|
||||
"l2tp",
|
||||
"gre",
|
||||
"he-ipv6"
|
||||
)
|
||||
|
||||
private val tunnelContains = listOf(
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) } }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
0
gradlew
vendored
Normal file → Executable file
40
metadata/com.cherepavel.vpndetector.yml
Normal file
40
metadata/com.cherepavel.vpndetector.yml
Normal 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
|
||||
|
|
@ -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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue