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: +