From 1c75bb098b4b035f78d706c699bd15ffb09db09d Mon Sep 17 00:00:00 2001 From: Pavel Frasyn Date: Thu, 9 Apr 2026 20:02:55 +0300 Subject: [PATCH] Dev (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * Remove background monitoring (#13) * убрано фоновое отслеживание, приложение переведено на manual refresh * minor fix --------- Co-authored-by: cherepavel * Split native details UI (#14) * убрал монолитный activity_main.xml, разделил на куски * upd * текст из ReportFormatter перенесен в strings.xml * add version and build * добавлена версия и билд в тектовый репорт --------- Co-authored-by: cherepavel * update gitignore --------- Co-authored-by: Vadim Co-authored-by: cherepavel --- .gitignore | 1 + README.md | 10 + app/build.gradle.kts | 53 +- app/src/main/AndroidManifest.xml | 19 +- app/src/main/assets/tracked_apps.json | 25 + app/src/main/cpp/CMakeLists.txt | 19 - app/src/main/cpp/ifconfigdetector.cpp | 263 --------- .../cherepavel/vpndetector/MainActivity.kt | 261 ++++----- .../detector/IfconfigInterfaceInfo.kt | 64 --- .../detector/IfconfigTermuxLikeDetector.kt | 32 -- .../detector/TrackedAppsDetector.kt | 51 -- .../vpndetector/detector/VpnDetector.kt | 63 --- .../vpndetector/model/VpnDetectionResult.kt | 9 - .../vpndetector/model/VpnNetworkInfo.kt | 8 - .../vpndetector/ui/DetectionReport.kt | 29 + .../vpndetector/ui/ReportExportFormatter.kt | 70 --- .../vpndetector/ui/ReportFormatter.kt | 508 +++++++++++++---- .../vpndetector/ui/export/ExportReport.kt | 30 + .../ui/export/ReportExportBuilder.kt | 516 +++++++++++++++++ .../ui/export/ReportExportFormatter.kt | 41 ++ .../res/drawable/ic_launcher_background.xml | 170 +----- .../res/drawable/ic_launcher_foreground.xml | 50 +- app/src/main/res/layout/activity_main.xml | 525 +----------------- .../res/layout/common_item_detail_section.xml | 30 + .../res/layout/common_item_signal_card.xml | 47 ++ .../layout/common_item_signal_card_full.xml | 47 ++ .../res/layout/main_block_transport_vpn.xml | 99 ++++ .../main/res/layout/main_section_actions.xml | 47 ++ app/src/main/res/layout/main_section_apps.xml | 31 ++ .../main/res/layout/main_section_extra.xml | 37 ++ .../main/res/layout/main_section_footer.xml | 36 ++ .../main/res/layout/main_section_header.xml | 14 + app/src/main/res/layout/main_section_java.xml | 35 ++ .../main/res/layout/main_section_native.xml | 57 ++ .../res/layout/main_section_official_api.xml | 52 ++ .../layout/main_section_overall_status.xml | 50 ++ app/src/main/res/values/strings.xml | 116 +++- build.gradle.kts | 6 +- detector/build.gradle.kts | 39 ++ detector/src/main/AndroidManifest.xml | 2 + detector/src/main/cpp/.clang-format | 14 + detector/src/main/cpp/CMakeLists.txt | 40 ++ detector/src/main/cpp/ifconfigdetector.cpp | 410 ++++++++++++++ detector/src/main/cpp/run_clang_format.sh | 16 + .../detector/AlwaysOnVpnDetector.kt | 36 ++ .../vpndetector/detector/DetectionEngine.kt | 168 ++++++ .../vpndetector/detector/DetectionScorer.kt | 140 +++++ .../vpndetector/detector/DetectionSignals.kt | 19 + .../detector/DynamicVpnAppsDetector.kt | 59 ++ .../vpndetector/detector/IDetectionEngine.kt | 7 + .../detector/IfconfigTermuxLikeDetector.kt | 77 +++ .../detector/IfconfigTermuxLikeResult.kt | 3 +- .../detector/JavaInterfacesDetector.kt | 0 .../detector/KnownVpnDnsDetector.kt | 33 ++ .../detector/TrackedAppsDetector.kt | 54 ++ .../detector/TrackedAppsRepository.kt | 40 ++ .../vpndetector/detector/TunnelNameMatcher.kt | 7 +- .../detector/VpnPermissionDetector.kt | 23 + .../detector/WorkProfileDetector.kt | 41 ++ .../vpndetector/model/DetectionModels.kt | 75 +++ .../vpndetector/model/TrackedApp.kt | 0 .../cherepavel/vpndetector/util/Extensions.kt | 0 .../vpndetector/util/NetworkSignalAnalyzer.kt | 131 +++++ .../util/TransportInfoFormatter.kt | 20 +- gradle.properties | 10 +- gradle/libs.versions.toml | 15 +- gradlew | 0 metadata/com.cherepavel.vpndetector.yml | 40 ++ settings.gradle.kts | 17 +- 69 files changed, 3460 insertions(+), 1597 deletions(-) create mode 100644 app/src/main/assets/tracked_apps.json delete mode 100644 app/src/main/cpp/CMakeLists.txt delete mode 100644 app/src/main/cpp/ifconfigdetector.cpp delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigInterfaceInfo.kt delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/detector/VpnDetector.kt delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/model/VpnDetectionResult.kt delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/model/VpnNetworkInfo.kt create mode 100644 app/src/main/java/com/cherepavel/vpndetector/ui/DetectionReport.kt delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/ui/ReportExportFormatter.kt create mode 100644 app/src/main/java/com/cherepavel/vpndetector/ui/export/ExportReport.kt create mode 100644 app/src/main/java/com/cherepavel/vpndetector/ui/export/ReportExportBuilder.kt create mode 100644 app/src/main/java/com/cherepavel/vpndetector/ui/export/ReportExportFormatter.kt create mode 100644 app/src/main/res/layout/common_item_detail_section.xml create mode 100644 app/src/main/res/layout/common_item_signal_card.xml create mode 100644 app/src/main/res/layout/common_item_signal_card_full.xml create mode 100644 app/src/main/res/layout/main_block_transport_vpn.xml create mode 100644 app/src/main/res/layout/main_section_actions.xml create mode 100644 app/src/main/res/layout/main_section_apps.xml create mode 100644 app/src/main/res/layout/main_section_extra.xml create mode 100644 app/src/main/res/layout/main_section_footer.xml create mode 100644 app/src/main/res/layout/main_section_header.xml create mode 100644 app/src/main/res/layout/main_section_java.xml create mode 100644 app/src/main/res/layout/main_section_native.xml create mode 100644 app/src/main/res/layout/main_section_official_api.xml create mode 100644 app/src/main/res/layout/main_section_overall_status.xml create mode 100644 detector/build.gradle.kts create mode 100644 detector/src/main/AndroidManifest.xml create mode 100644 detector/src/main/cpp/.clang-format create mode 100644 detector/src/main/cpp/CMakeLists.txt create mode 100644 detector/src/main/cpp/ifconfigdetector.cpp create mode 100755 detector/src/main/cpp/run_clang_format.sh create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/AlwaysOnVpnDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionEngine.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionScorer.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionSignals.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/DynamicVpnAppsDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/IDetectionEngine.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt rename {app => detector}/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeResult.kt (67%) rename {app => detector}/src/main/java/com/cherepavel/vpndetector/detector/JavaInterfacesDetector.kt (100%) create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/KnownVpnDnsDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsRepository.kt rename {app => detector}/src/main/java/com/cherepavel/vpndetector/detector/TunnelNameMatcher.kt (86%) create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/VpnPermissionDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/WorkProfileDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/model/DetectionModels.kt rename {app => detector}/src/main/java/com/cherepavel/vpndetector/model/TrackedApp.kt (100%) rename {app => detector}/src/main/java/com/cherepavel/vpndetector/util/Extensions.kt (100%) create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/util/NetworkSignalAnalyzer.kt rename {app => detector}/src/main/java/com/cherepavel/vpndetector/util/TransportInfoFormatter.kt (52%) mode change 100644 => 100755 gradlew create mode 100644 metadata/com.cherepavel.vpndetector.yml diff --git a/.gitignore b/.gitignore index e5cbb64..180645e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Gradle files .gradle/ build/ +app/release/ # Local configuration file (sdk path, etc) local.properties diff --git a/README.md b/README.md index 8c9ef97..3c677f7 100644 --- a/README.md +++ b/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)_** ![](img/vpn_split_bypass.jpg) + +## License + +MIT diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9cd4dba..9357a5b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { + 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) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d44fd7e..d7f1d34 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,18 +1,12 @@ - + - - - - - - - - - - + + - \ No newline at end of file + diff --git a/app/src/main/assets/tracked_apps.json b/app/src/main/assets/tracked_apps.json new file mode 100644 index 0000000..6834d0f --- /dev/null +++ b/app/src/main/assets/tracked_apps.json @@ -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"} +] diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt deleted file mode 100644 index ee2de6a..0000000 --- a/app/src/main/cpp/CMakeLists.txt +++ /dev/null @@ -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} -) diff --git a/app/src/main/cpp/ifconfigdetector.cpp b/app/src/main/cpp/ifconfigdetector.cpp deleted file mode 100644 index 6f2d45e..0000000 --- a/app/src/main/cpp/ifconfigdetector.cpp +++ /dev/null @@ -1,263 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -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 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(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(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 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(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& mtuMap, const std::map& 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 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 interfaces; - std::map mtuMap; - std::map 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(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 dumps; - dumps.reserve(interfaces.size()); - - for (const auto& pair : interfaces) { - dumps.push_back(buildIfconfigLikeBlock(pair.second, mtuMap, txQueueMap)); - } - - jobjectArray result = env->NewObjectArray( - static_cast(dumps.size()), - stringCls, - nullptr - ); - - for (jsize i = 0; i < static_cast(dumps.size()); ++i) { - jstring text = env->NewStringUTF(dumps[i].c_str()); - env->SetObjectArrayElement(result, i, text); - env->DeleteLocalRef(text); - } - - return result; -} diff --git a/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt b/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt index 293a8e1..b402e7b 100644 --- a/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt +++ b/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt @@ -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 - private lateinit var apiSignalTitles: List - private lateinit var apiSignalSources: List - private lateinit var apiSignalValues: List - private lateinit var apiSignalHints: List - 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 = emptyList() - private var lastInstalledVpnApps: List = 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) { + containerExtraSections.removeAllViews() + + for (section in sections) { + val itemView = layoutInflater.inflate( + R.layout.common_item_detail_section, + containerExtraSections, + false + ) + + val titleView = itemView.findViewById(R.id.textDetailTitle) + val bodyView = itemView.findViewById(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(R.id.textSignalTitle) + val sourceView = card.findViewById(R.id.textSignalSource) + val valueView = card.findViewById(R.id.textSignalValue) + val hintView = card.findViewById(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) { diff --git a/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigInterfaceInfo.kt b/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigInterfaceInfo.kt deleted file mode 100644 index d17955a..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigInterfaceInfo.kt +++ /dev/null @@ -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) - } - } -} diff --git a/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt b/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt deleted file mode 100644 index 6fab0cd..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.cherepavel.vpndetector.detector - -object IfconfigTermuxLikeDetector { - - init { - try { - System.loadLibrary("ifconfigdetector") - } catch (_: Throwable) { - } - } - - external fun getInterfacesNative(): Array - - 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 - ) - } -} diff --git a/app/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt b/app/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt deleted file mode 100644 index ce42713..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt +++ /dev/null @@ -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 { - 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") - ) - } -} diff --git a/app/src/main/java/com/cherepavel/vpndetector/detector/VpnDetector.kt b/app/src/main/java/com/cherepavel/vpndetector/detector/VpnDetector.kt deleted file mode 100644 index d14a142..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/detector/VpnDetector.kt +++ /dev/null @@ -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 { - 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 { - 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") - } - } -} diff --git a/app/src/main/java/com/cherepavel/vpndetector/model/VpnDetectionResult.kt b/app/src/main/java/com/cherepavel/vpndetector/model/VpnDetectionResult.kt deleted file mode 100644 index d823f15..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/model/VpnDetectionResult.kt +++ /dev/null @@ -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 -) diff --git a/app/src/main/java/com/cherepavel/vpndetector/model/VpnNetworkInfo.kt b/app/src/main/java/com/cherepavel/vpndetector/model/VpnNetworkInfo.kt deleted file mode 100644 index 2ff5ecf..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/model/VpnNetworkInfo.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.cherepavel.vpndetector.model - -data class VpnNetworkInfo( - val interfaceName: String?, - val transports: List, - val capabilities: List, - val transportInfoSummary: String? -) diff --git a/app/src/main/java/com/cherepavel/vpndetector/ui/DetectionReport.kt b/app/src/main/java/com/cherepavel/vpndetector/ui/DetectionReport.kt new file mode 100644 index 0000000..8176d24 --- /dev/null +++ b/app/src/main/java/com/cherepavel/vpndetector/ui/DetectionReport.kt @@ -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, + val nativeSignal: SignalItem, + val nativeDetails: String, + val extraSections: List, + val javaSignal: SignalItem, + val knownAppsText: String +) diff --git a/app/src/main/java/com/cherepavel/vpndetector/ui/ReportExportFormatter.kt b/app/src/main/java/com/cherepavel/vpndetector/ui/ReportExportFormatter.kt deleted file mode 100644 index 20dd68b..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/ui/ReportExportFormatter.kt +++ /dev/null @@ -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, - val installedVpnApps: List - ) - - 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() - } -} diff --git a/app/src/main/java/com/cherepavel/vpndetector/ui/ReportFormatter.kt b/app/src/main/java/com/cherepavel/vpndetector/ui/ReportFormatter.kt index 9c30ea2..fc09789 100644 --- a/app/src/main/java/com/cherepavel/vpndetector/ui/ReportFormatter.kt +++ b/app/src/main/java/com/cherepavel/vpndetector/ui/ReportFormatter.kt @@ -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, - 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, - val nativeDetails: List, - val javaTunnelNames: List, - val installedVpnApps: List - ) + 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") + } } diff --git a/app/src/main/java/com/cherepavel/vpndetector/ui/export/ExportReport.kt b/app/src/main/java/com/cherepavel/vpndetector/ui/export/ExportReport.kt new file mode 100644 index 0000000..2adb382 --- /dev/null +++ b/app/src/main/java/com/cherepavel/vpndetector/ui/export/ExportReport.kt @@ -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 +) + +data class ExportSection( + val title: String, + val items: List +) + +sealed class ExportItem { + data class Field( + val label: String, + val value: String + ) : ExportItem() + + data class ListBlock( + val label: String, + val values: List + ) : ExportItem() + + data class Paragraph( + val text: String + ) : ExportItem() +} diff --git a/app/src/main/java/com/cherepavel/vpndetector/ui/export/ReportExportBuilder.kt b/app/src/main/java/com/cherepavel/vpndetector/ui/export/ReportExportBuilder.kt new file mode 100644 index 0000000..8f89912 --- /dev/null +++ b/app/src/main/java/com/cherepavel/vpndetector/ui/export/ReportExportBuilder.kt @@ -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 { + 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 { + 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" + } + } +} diff --git a/app/src/main/java/com/cherepavel/vpndetector/ui/export/ReportExportFormatter.kt b/app/src/main/java/com/cherepavel/vpndetector/ui/export/ReportExportFormatter.kt new file mode 100644 index 0000000..a9db03d --- /dev/null +++ b/app/src/main/java/com/cherepavel/vpndetector/ui/export/ReportExportFormatter.kt @@ -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() + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..e7985c5 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,10 @@ - + android:viewportWidth="124" + android:viewportHeight="124"> + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + 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"/> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d1..d791708 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,30 @@ - - - - - - - - + android:viewportWidth="124" + android:viewportHeight="124"> + + android:fillColor="#FFFFFFFF" + android:pathData=" + M62,28 + L38,38 + V60 + C38,78 50,92 62,98 + C74,92 86,78 86,60 + V38 + Z"/> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 00cd2e7..a1e958f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -15,528 +15,23 @@ android:paddingEnd="16dp" android:paddingBottom="16dp"> - + - + - + - + - + - - + - + -