mirror of
https://github.com/cherepavel/VPN-Detector.git
synced 2026-08-18 05:24:00 +00:00
add version and build
This commit is contained in:
parent
c721f9539c
commit
5ad7ab3a9d
11 changed files with 719 additions and 213 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package com.cherepavel.vpndetector.ui
|
||||
|
||||
data class DetailSection(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val state: SignalState = SignalState.NEUTRAL
|
||||
)
|
||||
|
||||
data class DetectionReport(
|
||||
val overallTitle: String,
|
||||
val overallSummary: String,
|
||||
val overallExplanation: String,
|
||||
val overallState: SignalState,
|
||||
|
||||
val transportCardState: SignalState,
|
||||
val transportStateText: String,
|
||||
val transportSubtitle: String,
|
||||
val transportAnyValue: String,
|
||||
val transportActiveValue: String,
|
||||
val transportAnyDetected: Boolean,
|
||||
val transportActiveDetected: Boolean,
|
||||
|
||||
val apiSignals: List<SignalItem>,
|
||||
val nativeSignal: SignalItem,
|
||||
val nativeDetails: String,
|
||||
val extraSections: List<DetailSection>,
|
||||
val javaSignal: SignalItem,
|
||||
val knownAppsText: String
|
||||
)
|
||||
|
|
@ -1,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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -31,5 +31,7 @@
|
|||
|
||||
<include layout="@layout/main_section_apps" />
|
||||
|
||||
<include layout="@layout/main_section_footer" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
|
|
|||
36
app/src/main/res/layout/main_section_footer.xml
Normal file
36
app/src/main/res/layout/main_section_footer.xml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/footerSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textVersion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="12sp"
|
||||
android:alpha="0.70" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textFooterInfo"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="-"
|
||||
android:textColor="?attr/appColorOnSurfaceMuted"
|
||||
android:textSize="11sp"
|
||||
android:alpha="0.60"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue