From 5ad7ab3a9da1fb57bdfdf887dcda60c23dc75ead Mon Sep 17 00:00:00 2001
From: "p.frasyn"
Date: Thu, 9 Apr 2026 19:33:19 +0300
Subject: [PATCH] add version and build
---
app/build.gradle.kts | 27 +-
.../cherepavel/vpndetector/MainActivity.kt | 39 +-
.../vpndetector/ui/DetectionReport.kt | 29 +
.../vpndetector/ui/ReportExportFormatter.kt | 174 ------
.../vpndetector/ui/ReportFormatter.kt | 30 +-
.../vpndetector/ui/export/ExportReport.kt | 28 +
.../ui/export/ReportExportBuilder.kt | 524 ++++++++++++++++++
.../ui/export/ReportExportFormatter.kt | 39 ++
app/src/main/res/layout/activity_main.xml | 2 +
.../main/res/layout/main_section_footer.xml | 36 ++
app/src/main/res/values/strings.xml | 4 +
11 files changed, 719 insertions(+), 213 deletions(-)
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/main_section_footer.xml
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f95b8ea..9357a5b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -2,6 +2,21 @@ plugins {
id("com.android.application")
}
+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
@@ -10,10 +25,12 @@ extensions.configure {
applicationId = "com.cherepavel.vpndetector"
minSdk = 24
targetSdk = 36
- versionCode = 1
- versionName = "1.0"
+ versionCode = 2
+ versionName = "0.0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ buildConfigField("String", "GIT_HASH", "\"${gitCommitHash()}\"")
}
buildTypes {
@@ -23,6 +40,10 @@ extensions.configure {
}
}
+ buildFeatures {
+ buildConfig = true
+ }
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
@@ -47,4 +68,4 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt b/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt
index 9d73dfc..4d8689f 100644
--- a/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt
+++ b/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt
@@ -23,10 +23,11 @@ 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.ui.export.ReportExportBuilder
+import com.cherepavel.vpndetector.ui.export.ReportExportFormatter
import com.cherepavel.vpndetector.util.nowString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -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
@@ -100,6 +103,8 @@ class MainActivity : AppCompatActivity() {
}
bindViews()
+ renderVersion()
+ renderFooter()
setupListeners()
refreshUi()
}
@@ -117,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)
@@ -148,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,
@@ -188,12 +204,8 @@ class MainActivity : AppCompatActivity() {
val snapshot = detectionEngine.detect()
val report = ReportFormatter.build(this, snapshot)
- val exportText = ReportExportFormatter.buildText(
- ReportExportFormatter.ExportInput(
- report = report,
- snapshot = snapshot
- )
- )
+ val exportReport = ReportExportBuilder.build(snapshot)
+ val exportText = ReportExportFormatter.buildText(exportReport)
return DetectionOutput(
report = report,
@@ -303,6 +315,19 @@ class MainActivity : AppCompatActivity() {
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()
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 9a5629c..0000000
--- a/app/src/main/java/com/cherepavel/vpndetector/ui/ReportExportFormatter.kt
+++ /dev/null
@@ -1,174 +0,0 @@
-package com.cherepavel.vpndetector.ui
-
-import com.cherepavel.vpndetector.model.DetectionSnapshot
-import com.cherepavel.vpndetector.util.nowString
-
-object ReportExportFormatter {
-
- data class ExportInput(
- val report: DetectionReport,
- val snapshot: DetectionSnapshot
- )
-
- fun buildText(input: ExportInput): String {
- val report = input.report
- val snapshot = input.snapshot
-
- return buildString {
- appendLine("VPN Detector Report")
- appendLine("Generated: ${nowString()}")
- appendLine()
-
- appendLine("=== OVERALL STATUS ===")
- appendLine(report.overallTitle)
- appendLine(report.overallSummary)
- appendLine(report.overallExplanation)
- appendLine("Score: ${snapshot.assessment.score}/100")
- appendLine("Confidence: ${snapshot.assessment.confidence}")
- appendLine("Status: ${snapshot.assessment.status}")
- appendLine()
-
- appendLine("=== OFFICIAL ANDROID API ===")
- 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()
- }
-
- if (snapshot.vpnRoutes.isNotEmpty() || snapshot.vpnDnsServers.isNotEmpty() ||
- snapshot.allDnsServers.isNotEmpty() || snapshot.internalDnsServers.isNotEmpty() ||
- snapshot.contextualInternalDnsServers.isNotEmpty() ||
- snapshot.privateDnsActive || snapshot.privateDnsServerName != null ||
- snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null ||
- snapshot.vpnBandwidthSummary != null) {
- appendLine("=== VPN NETWORK DETAILS ===")
- if (snapshot.vpnRoutes.isNotEmpty()) {
- appendLine("Routes:")
- snapshot.vpnRoutes.forEach { appendLine(" $it") }
- }
- if (snapshot.vpnDnsServers.isNotEmpty()) {
- appendLine("DNS servers: ${snapshot.vpnDnsServers.joinToString(", ")}")
- }
- if (snapshot.allDnsServers.isNotEmpty()) {
- appendLine("DNS across visible networks:")
- snapshot.allDnsServers.forEach { appendLine(" $it") }
- }
- if (snapshot.internalDnsServers.isNotEmpty()) {
- appendLine("Internal/private-range DNS servers:")
- snapshot.internalDnsServers.forEach { appendLine(" $it") }
- }
- if (snapshot.contextualInternalDnsServers.isNotEmpty()) {
- appendLine("Cellular private DNS observed (not treated as VPN):")
- snapshot.contextualInternalDnsServers.forEach { appendLine(" $it") }
- }
- if (snapshot.privateDnsActive || snapshot.privateDnsServerName != null) {
- appendLine(
- "Private DNS: " + buildString {
- append(if (snapshot.privateDnsActive) "active" else "inactive")
- snapshot.privateDnsServerName?.let { append(" ($it)") }
- }
- )
- }
- if (snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null) {
- appendLine(
- "NET_CAPABILITY_NOT_VPN: active=${snapshot.activeNetworkNotVpn ?: "unknown"}, " +
- "preferred=${snapshot.preferredNetworkNotVpn ?: "unknown"}"
- )
- }
- snapshot.vpnBandwidthSummary?.let { appendLine("Bandwidth: $it") }
- appendLine()
- }
-
- if (snapshot.tunTypeInterfaces.isNotEmpty() || snapshot.lowMtuInterfaces.isNotEmpty() ||
- snapshot.kernelRoutes.isNotEmpty() || snapshot.kernelIpv6Routes.isNotEmpty() ||
- snapshot.vpnPermissionGranted) {
- appendLine("=== ADDITIONAL SIGNALS ===")
- if (snapshot.tunTypeInterfaces.isNotEmpty()) {
- appendLine("TUN interfaces (type=65534): ${snapshot.tunTypeInterfaces.joinToString(", ")}")
- }
- if (snapshot.lowMtuInterfaces.isNotEmpty()) {
- appendLine("Low-MTU interfaces (<1500):")
- snapshot.lowMtuInterfaces.forEach { appendLine(" $it") }
- }
- if (snapshot.kernelRoutes.isNotEmpty()) {
- appendLine("Kernel route table (/proc/net/route):")
- snapshot.kernelRoutes.forEach { appendLine(" $it") }
- }
- if (snapshot.kernelIpv6Routes.isNotEmpty()) {
- appendLine("Kernel route table (/proc/net/ipv6_route):")
- snapshot.kernelIpv6Routes.forEach { appendLine(" $it") }
- }
- if (snapshot.vpnPermissionGranted) {
- appendLine("VPN permission: this app holds VPN grant (anomalous).")
- }
- appendLine()
- }
-
- appendLine("=== NATIVE LOW-LEVEL ENUMERATION ===")
- if (snapshot.nativeError != null) {
- appendLine("Error: ${snapshot.nativeError}")
- }
- appendLine("Signal value: ${report.nativeSignal.value}")
- appendLine("Signal hint: ${report.nativeSignal.hint}")
- appendLine()
- appendLine(report.nativeDetails)
- appendLine()
-
- appendLine("=== JAVA INTERFACE ENUMERATION ===")
- appendLine("Signal value: ${report.javaSignal.value}")
- appendLine("Signal hint: ${report.javaSignal.hint}")
- if (snapshot.javaTunnelNames.isNotEmpty()) {
- appendLine("Matched tunnel-like names:")
- snapshot.javaTunnelNames.forEach { appendLine("- $it") }
- }
- appendLine()
-
- appendLine("=== DETECTED VPN APPS ===")
- if (snapshot.installedVpnApps.isNotEmpty()) {
- appendLine("From tracked list:")
- snapshot.installedVpnApps.forEach { appendLine("- $it") }
- }
- if (snapshot.unknownDynamicApps.isNotEmpty()) {
- appendLine("Detected via VpnService query:")
- snapshot.unknownDynamicApps.forEach { appendLine("- $it") }
- }
- if (snapshot.trackedAppsErrors.isNotEmpty()) {
- appendLine("Check errors:")
- snapshot.trackedAppsErrors.forEach { (pkg, err) -> appendLine("- $pkg: $err") }
- }
- if (snapshot.installedVpnApps.isEmpty() && snapshot.unknownDynamicApps.isEmpty()) {
- appendLine("No VPN-related apps detected.")
- }
-
- if (snapshot.lockdownLikely || snapshot.knownVpnDnsMatches.isNotEmpty() ||
- snapshot.workProfileCount > 1 || snapshot.isManagedProfile) {
- appendLine()
- appendLine("=== ADVANCED SIGNALS ===")
- if (snapshot.lockdownLikely) {
- appendLine("Always-on lockdown: likely (no validated non-VPN path exists).")
- }
- if (snapshot.knownVpnDnsMatches.isNotEmpty()) {
- appendLine("Known VPN provider DNS:")
- snapshot.knownVpnDnsMatches.forEach { appendLine("- $it") }
- }
- if (snapshot.workProfileCount > 1) {
- appendLine("Work profile: ${snapshot.workProfileCount} user profiles detected. VPN apps in other profiles are not visible.")
- }
- if (snapshot.isManagedProfile) {
- appendLine("Running inside a managed profile.")
- }
- }
- }.trim()
- }
-}
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 978ec18..fc09789 100644
--- a/app/src/main/java/com/cherepavel/vpndetector/ui/ReportFormatter.kt
+++ b/app/src/main/java/com/cherepavel/vpndetector/ui/ReportFormatter.kt
@@ -7,34 +7,6 @@ import com.cherepavel.vpndetector.model.DetectionConfidence
import com.cherepavel.vpndetector.model.DetectionSnapshot
import com.cherepavel.vpndetector.model.DetectionStatus
-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
-)
-
object ReportFormatter {
fun build(context: Context, snapshot: DetectionSnapshot): DetectionReport {
@@ -402,7 +374,7 @@ object ReportFormatter {
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 ""
+ if (lockdown) " " + context.getString(R.string.report_summary_lockdown_suffix) else ""
OverallBlock(
title = context.getString(
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..5754811
--- /dev/null
+++ b/app/src/main/java/com/cherepavel/vpndetector/ui/export/ExportReport.kt
@@ -0,0 +1,28 @@
+package com.cherepavel.vpndetector.ui.export
+
+data class ExportReport(
+ val title: String,
+ val generatedAt: 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..e097abd
--- /dev/null
+++ b/app/src/main/java/com/cherepavel/vpndetector/ui/export/ReportExportBuilder.kt
@@ -0,0 +1,524 @@
+package com.cherepavel.vpndetector.ui.export
+
+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(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(),
+ sections = sections
+ )
+ }
+
+ 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(
+ label = "Routes",
+ values = 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..a550919
--- /dev/null
+++ b/app/src/main/java/com/cherepavel/vpndetector/ui/export/ReportExportFormatter.kt
@@ -0,0 +1,39 @@
+package com.cherepavel.vpndetector.ui.export
+
+object ReportExportFormatter {
+
+ fun buildText(report: ExportReport): String {
+ return buildString {
+ appendLine(report.title)
+ appendLine("Generated: ${report.generatedAt}")
+ 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/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 23c38d0..a1e958f 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -31,5 +31,7 @@
+
+
diff --git a/app/src/main/res/layout/main_section_footer.xml b/app/src/main/res/layout/main_section_footer.xml
new file mode 100644
index 0000000..d267650
--- /dev/null
+++ b/app/src/main/res/layout/main_section_footer.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c636c18..b7ffb01 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -108,4 +108,8 @@
Running inside a managed profile.
%1$d user profiles detected. VPN apps in other profiles are not visible to this detector.
+ cherepavel
+ https://github.com/cherepavel/VPN-Detector
+ Source code:
+