add version and build

This commit is contained in:
p.frasyn 2026-04-09 19:33:19 +03:00
parent c721f9539c
commit 5ad7ab3a9d
11 changed files with 719 additions and 213 deletions

View file

@ -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<com.android.build.api.dsl.ApplicationExtension> {
namespace = "com.cherepavel.vpndetector"
compileSdk = 36
@ -10,10 +25,12 @@ extensions.configure<com.android.build.api.dsl.ApplicationExtension> {
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<com.android.build.api.dsl.ApplicationExtension> {
}
}
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)
}
}

View file

@ -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()

View file

@ -0,0 +1,29 @@
package com.cherepavel.vpndetector.ui
data class DetailSection(
val title: String,
val body: String,
val state: SignalState = SignalState.NEUTRAL
)
data class DetectionReport(
val overallTitle: String,
val overallSummary: String,
val overallExplanation: String,
val overallState: SignalState,
val transportCardState: SignalState,
val transportStateText: String,
val transportSubtitle: String,
val transportAnyValue: String,
val transportActiveValue: String,
val transportAnyDetected: Boolean,
val transportActiveDetected: Boolean,
val apiSignals: List<SignalItem>,
val nativeSignal: SignalItem,
val nativeDetails: String,
val extraSections: List<DetailSection>,
val javaSignal: SignalItem,
val knownAppsText: String
)

View file

@ -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()
}
}

View file

@ -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<SignalItem>,
val nativeSignal: SignalItem,
val nativeDetails: String,
val extraSections: List<DetailSection>,
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(

View file

@ -0,0 +1,28 @@
package com.cherepavel.vpndetector.ui.export
data class ExportReport(
val title: String,
val generatedAt: String,
val sections: List<ExportSection>
)
data class ExportSection(
val title: String,
val items: List<ExportItem>
)
sealed class ExportItem {
data class Field(
val label: String,
val value: String
) : ExportItem()
data class ListBlock(
val label: String,
val values: List<String>
) : ExportItem()
data class Paragraph(
val text: String
) : ExportItem()
}

View file

@ -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<ExportItem> {
val overall = buildOverallBlock(snapshot)
return buildList {
add(ExportItem.Paragraph(overall.title))
add(ExportItem.Paragraph(overall.summary))
add(ExportItem.Paragraph(overall.explanation))
add(ExportItem.Field("Score", "${snapshot.assessment.score}/100"))
add(ExportItem.Field("Confidence", confidenceLabel(snapshot.assessment.confidence)))
add(ExportItem.Field("Status", snapshot.assessment.status.toString()))
}
}
private fun buildOfficialApiItems(snapshot: DetectionSnapshot): List<ExportItem> {
val anyVpn = snapshot.hasTransportVpnAny
val activeVpn = snapshot.hasTransportVpnActive
val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(snapshot.rawInterfaceName)
val transportInfoDetected = !snapshot.transportInfoSummary.isNullOrBlank()
val items = buildList {
add(
ExportItem.Field(
"TRANSPORT_VPN across all networks",
if (anyVpn) "DETECTED" else "NOT DETECTED"
)
)
add(
ExportItem.Field(
"TRANSPORT_VPN active network only",
if (activeVpn) "DETECTED" else "NOT DETECTED"
)
)
val overall = buildOverallBlock(snapshot)
add(ExportItem.Field("Transport state", overall.transportText))
add(ExportItem.Field("Transport subtitle", overall.transportSubtitle))
val interfaceHint = when {
interfaceDetected && activeVpn ->
"The interface name itself looks like a tunnel device and matches the active VPN state."
interfaceDetected && anyVpn ->
"The interface name looks tunnel-like and is consistent with a VPN being present somewhere in the system."
interfaceDetected ->
"The interface name looks tunnel-like, but Android does not currently mark the active path as VPN."
snapshot.rawInterfaceName != null ->
"The interface name does not look like a typical VPN or tunnel interface."
else ->
"Android did not expose an interface name for this network."
}
val transportInfoHint = when {
transportInfoDetected && activeVpn ->
"Android returned transport info alongside an active VPN transport."
transportInfoDetected && anyVpn ->
"Transport info is present and aligns with a VPN existing somewhere in the network stack."
transportInfoDetected ->
"Transport info is present, but without a direct active VPN transport flag."
else ->
"No VPN-related transport info was exposed here."
}
add(ExportItem.Paragraph("API signals:"))
add(ExportItem.Paragraph("- Interface name"))
add(
ExportItem.Field(
"source",
"LinkProperties.getInterfaceName()"
)
)
add(
ExportItem.Field(
"value",
snapshot.rawInterfaceName ?: "none"
)
)
add(ExportItem.Field("hint", interfaceHint))
add(ExportItem.Paragraph("- Transport info"))
add(
ExportItem.Field(
"source",
"NetworkCapabilities.getTransportInfo()"
)
)
add(
ExportItem.Field(
"value",
formatCompactTransportInfo(snapshot.transportInfoSummary) ?: "none"
)
)
add(ExportItem.Field("hint", transportInfoHint))
}
return items
}
private fun buildVpnNetworkDetailsSection(snapshot: DetectionSnapshot): ExportSection? {
val items = buildList {
if (snapshot.vpnRoutes.isNotEmpty()) {
add(
ExportItem.ListBlock(
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"
}
}
}

View file

@ -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()
}
}

View file

@ -31,5 +31,7 @@
<include layout="@layout/main_section_apps" />
<include layout="@layout/main_section_footer" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:id="@+id/footerSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="16dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/textVersion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="12sp"
android:alpha="0.70" />
<TextView
android:id="@+id/textFooterInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="-"
android:textColor="?attr/appColorOnSurfaceMuted"
android:textSize="11sp"
android:alpha="0.60"
android:clickable="true"
android:focusable="true" />
</LinearLayout>
</merge>

View file

@ -108,4 +108,8 @@
<string name="managed_profile_body">Running inside a managed profile.</string>
<string name="work_profile_count_body">%1$d user profiles detected. VPN apps in other profiles are not visible to this detector.</string>
<string name="author">cherepavel</string>
<string name="repo_url">https://github.com/cherepavel/VPN-Detector</string>
<string name="source_code_label">Source code:</string>
</resources>