diff --git a/.idea/misc.xml b/.idea/misc.xml index 7146cb1..d852bbf 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/src/main/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatter.kt b/app/src/main/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatter.kt index f0b892a..01ce57e 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatter.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatter.kt @@ -16,6 +16,7 @@ import com.notcvnt.rknhardering.model.IpConsensusResult import com.notcvnt.rknhardering.model.LocalProxyCheckResult import com.notcvnt.rknhardering.model.LocalProxyOwner import com.notcvnt.rknhardering.model.MatchedVpnApp +import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata import com.notcvnt.rknhardering.probe.ProxyEndpoint import com.notcvnt.rknhardering.probe.XrayApiScanResult import com.notcvnt.rknhardering.probe.XrayOutboundSummary @@ -192,6 +193,7 @@ internal object CheckResultJsonExportFormatter { put("source", app.source.name) put("active", app.active) put("confidence", app.confidence.name) + app.technicalMetadata?.let { put("technicalMetadata", technicalMetadataToJson(it)) } } } @@ -203,6 +205,20 @@ internal object CheckResultJsonExportFormatter { put("kind", app.kind?.name) put("source", app.source.name) put("confidence", app.confidence.name) + app.technicalMetadata?.let { put("technicalMetadata", technicalMetadataToJson(it)) } + } + } + + private fun technicalMetadataToJson(metadata: VpnAppTechnicalMetadata): JSONObject { + return JSONObject().apply { + put("versionName", metadata.versionName) + put("serviceNames", JSONArray().apply { metadata.serviceNames.forEach { put(it) } }) + put("appType", metadata.appType) + put("coreType", metadata.coreType) + put("corePath", metadata.corePath) + put("goVersion", metadata.goVersion) + put("systemApp", metadata.systemApp) + put("matchedByNameHeuristic", metadata.matchedByNameHeuristic) } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatter.kt b/app/src/main/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatter.kt index 22b6358..9671936 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatter.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatter.kt @@ -16,6 +16,7 @@ import com.notcvnt.rknhardering.model.IpConsensusResult import com.notcvnt.rknhardering.model.LocalProxyCheckResult import com.notcvnt.rknhardering.model.MatchedVpnApp import com.notcvnt.rknhardering.model.Verdict +import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata import com.notcvnt.rknhardering.probe.XrayOutboundSummary internal object CheckResultMarkdownExportFormatter { @@ -350,6 +351,7 @@ internal object CheckResultMarkdownExportFormatter { add("source=${app.source}") add("active=${app.active}") add("confidence=${app.confidence}") + addAll(formatTechnicalMetadata(app.technicalMetadata)) }.joinToString(" | ") } @@ -361,9 +363,24 @@ internal object CheckResultMarkdownExportFormatter { add("kind=${app.kind ?: ""}") add("source=${app.source}") add("confidence=${app.confidence}") + addAll(formatTechnicalMetadata(app.technicalMetadata)) }.joinToString(" | ") } + private fun formatTechnicalMetadata(metadata: VpnAppTechnicalMetadata?): List { + if (metadata == null) return emptyList() + return buildList { + metadata.versionName?.let { add("version=$it") } + metadata.appType?.let { add("appType=$it") } + metadata.coreType?.let { add("coreType=$it") } + metadata.corePath?.let { add("corePath=$it") } + metadata.goVersion?.let { add("goVersion=$it") } + if (metadata.serviceNames.isNotEmpty()) add("services=${metadata.serviceNames.joinToString()}") + if (metadata.systemApp) add("systemApp=true") + if (metadata.matchedByNameHeuristic) add("matchedByName=true") + } + } + private fun formatCallTransportLeak( leak: CallTransportLeakResult, privacyMode: Boolean, diff --git a/app/src/main/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatter.kt b/app/src/main/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatter.kt index 62b64ac..cc703ac 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatter.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatter.kt @@ -20,6 +20,7 @@ import com.notcvnt.rknhardering.model.IpComparisonResult import com.notcvnt.rknhardering.model.LocalProxyOwner import com.notcvnt.rknhardering.model.LocalProxyCheckResult import com.notcvnt.rknhardering.model.MatchedVpnApp +import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata import com.notcvnt.rknhardering.probe.NativeInterfaceProbe import com.notcvnt.rknhardering.probe.NativeSignsBridge import com.notcvnt.rknhardering.probe.XrayApiScanResult @@ -589,6 +590,7 @@ object DebugDiagnosticsFormatter { add("source=${app.source}") add("active=${app.active}") add("confidence=${app.confidence}") + addAll(formatTechnicalMetadata(app.technicalMetadata)) }.joinToString(" ") } @@ -600,9 +602,24 @@ object DebugDiagnosticsFormatter { add("kind=${app.kind ?: ""}") add("source=${app.source}") add("confidence=${app.confidence}") + addAll(formatTechnicalMetadata(app.technicalMetadata)) }.joinToString(" ") } + private fun formatTechnicalMetadata(metadata: VpnAppTechnicalMetadata?): List { + if (metadata == null) return emptyList() + return buildList { + metadata.versionName?.let { add("version=$it") } + if (metadata.serviceNames.isNotEmpty()) add("services=${metadata.serviceNames.joinToString(",")}") + metadata.appType?.let { add("appType=$it") } + metadata.coreType?.let { add("coreType=$it") } + metadata.corePath?.let { add("corePath=$it") } + metadata.goVersion?.let { add("goVersion=$it") } + add("systemApp=${metadata.systemApp}") + add("matchedByNameHeuristic=${metadata.matchedByNameHeuristic}") + } + } + private fun formatCallTransportLeak(leak: CallTransportLeakResult): String { return buildList { add("service=${leak.service}") diff --git a/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt b/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt index 1a16228..ede624e 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt @@ -66,6 +66,7 @@ import com.notcvnt.rknhardering.model.StunProbeGroupResult import com.notcvnt.rknhardering.model.StunScope import com.notcvnt.rknhardering.model.TargetGroup import com.notcvnt.rknhardering.model.Verdict +import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata import com.notcvnt.rknhardering.network.DnsResolverConfig import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -1852,9 +1853,10 @@ class MainActivity : AppCompatActivity() { if (infoSection != null && infoDivider != null) { val infoFindings = category.findings.filter { it.isInformational } + val husiModelFindings = buildHusiModelFindings(category) val checkFindings = category.findings.filterNot { it.isInformational || it.isError } - bindInfoSection(infoFindings, infoSection, infoDivider, checkFindings.isNotEmpty(), privacyMode) + bindInfoSection(infoFindings + husiModelFindings, infoSection, infoDivider, checkFindings.isNotEmpty(), privacyMode) findingsContainer.removeAllViews() for (finding in checkFindings) { if (finding.description.startsWith("network_mcc_ru:")) continue @@ -1864,13 +1866,83 @@ class MainActivity : AppCompatActivity() { } findingsContainer.removeAllViews() - for (finding in category.findings) { + for (finding in category.findings + buildHusiModelFindings(category)) { if (finding.isError) continue if (finding.description.startsWith("network_mcc_ru:")) continue findingsContainer.addView(createFindingView(finding, privacyMode)) } } + private fun buildHusiModelFindings(category: CategoryResult): List { + val findings = buildList { + category.matchedApps.forEach { app -> + husiModelFinding( + label = app.appName, + packageName = app.packageName, + serviceName = app.technicalMetadata?.serviceNames?.firstOrNull(), + metadata = app.technicalMetadata, + source = app.source, + )?.let(::add) + } + category.activeApps.forEach { app -> + husiModelFinding( + label = app.packageName ?: app.serviceName ?: "active VPN", + packageName = app.packageName, + serviceName = app.serviceName, + metadata = app.technicalMetadata, + source = app.source, + )?.let(::add) + } + } + return findings.distinctBy { it.packageName.orEmpty() to it.description } + } + + private fun husiModelFinding( + label: String, + packageName: String?, + serviceName: String?, + metadata: VpnAppTechnicalMetadata?, + source: com.notcvnt.rknhardering.model.EvidenceSource?, + ): Finding? { + val hasMeaningfulMetadata = metadata != null || !serviceName.isNullOrBlank() || !packageName.isNullOrBlank() + if (!hasMeaningfulMetadata) return null + + val description = buildString { + append("HUSI model: ") + append(label) + packageName?.takeIf { it.isNotBlank() }?.let { + append(" · package: ") + append(it) + } + metadata?.versionName?.takeIf { it.isNotBlank() }?.let { + append(" · app version: ") + append(it) + } + append(" · app type: ") + append(metadata?.appType ?: "Other") + append(" · core type: ") + append(metadata?.coreType ?: "Unknown") + metadata?.corePath?.takeIf { it.isNotBlank() }?.let { + append(" · core path: ") + append(it) + } + metadata?.goVersion?.takeIf { it.isNotBlank() }?.let { + append(" · Go: ") + append(it) + } + serviceName?.takeIf { it.isNotBlank() }?.let { + append(" · service: ") + append(it) + } + } + return Finding( + description = description, + isInformational = true, + source = source, + packageName = packageName, + ) + } + private fun bindInfoSection( infoFindings: List, infoSection: LinearLayout, @@ -1967,6 +2039,18 @@ class MainActivity : AppCompatActivity() { row.addView(indicator) row.addView(description) + finding.packageName + ?.takeIf { it.isNotBlank() } + ?.let { packageName -> + row.isClickable = true + row.isFocusable = true + row.setOnClickListener { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + } + startActivity(intent) + } + } return row } diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/BypassChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/BypassChecker.kt index 7573681..babcd0c 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/BypassChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/BypassChecker.kt @@ -33,11 +33,14 @@ import com.notcvnt.rknhardering.probe.UnderlyingNetworkProber import com.notcvnt.rknhardering.probe.XrayApiScanResult import com.notcvnt.rknhardering.probe.XrayApiScanner import com.notcvnt.rknhardering.vpn.VpnAppCatalog +import com.notcvnt.rknhardering.vpn.VpnAppMetadataScanner +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext import java.net.InetAddress object BypassChecker { @@ -396,7 +399,7 @@ object BypassChecker { ) } - private fun reportProxyResults( + private suspend fun reportProxyResults( context: Context, directIp: String?, proxyChecks: List, @@ -416,7 +419,7 @@ object BypassChecker { } } - private fun reportProxyCheck( + private suspend fun reportProxyCheck( context: Context, proxyCheck: LocalProxyCheckResult, findings: MutableList, @@ -426,6 +429,7 @@ object BypassChecker { val proxyEndpoint = proxyCheck.endpoint val candidateFamilies = VpnAppCatalog.familiesForPort(proxyEndpoint.port) val familySuffix = candidateFamilies.takeIf { it.isNotEmpty() }?.joinToString() + val ownerMetadataSuffix = formatProxyOwnerMetadataSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus) val description = buildString { append( context.getString( @@ -440,6 +444,7 @@ object BypassChecker { append("]") } append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus)) + append(ownerMetadataSuffix) if (proxyCheck.status != LocalProxyCheckStatus.CONFIRMED_BYPASS) { append(context.getString(R.string.checker_bypass_open_proxy_review_suffix)) } @@ -463,6 +468,7 @@ object BypassChecker { description = buildString { append("Detected open ${proxyEndpoint.type.name} proxy at ${formatHostPort(proxyEndpoint.host, proxyEndpoint.port)}") append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus)) + append(ownerMetadataSuffix) }, family = familySuffix, packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner), @@ -915,6 +921,19 @@ object BypassChecker { return context.getString(R.string.checker_proxy_owner_suffix, ownerText) } + private suspend fun formatProxyOwnerMetadataSuffix( + context: Context, + owner: LocalProxyOwner?, + status: LocalProxyOwnerStatus, + ): String { + if (status != LocalProxyOwnerStatus.RESOLVED) return "" + val packageName = LocalProxyOwnerFormatter.packageName(owner) ?: return "" + val metadata = withContext(Dispatchers.IO) { + VpnAppMetadataScanner.scan(context, packageName) + } + return VpnAppMetadataScanner.formatMetadataSuffix(metadata) + } + private fun normalizeHost(host: String): String = host.substringBefore('%').lowercase() private fun isAnyAddress(host: String): Boolean = host == "0.0.0.0" || host == "::" || host == ":::" diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/IndirectSignsChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/IndirectSignsChecker.kt index 06d84f8..7b7004d 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/IndirectSignsChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/IndirectSignsChecker.kt @@ -15,11 +15,12 @@ import com.notcvnt.rknhardering.model.EvidenceSource import com.notcvnt.rknhardering.model.Finding import com.notcvnt.rknhardering.network.DnsResolverConfig import com.notcvnt.rknhardering.network.NetworkInterfaceNameNormalizer -import com.notcvnt.rknhardering.probe.LocalSocketInspector -import com.notcvnt.rknhardering.probe.LocalSocketListener -import com.notcvnt.rknhardering.vpn.VpnAppCatalog -import com.notcvnt.rknhardering.vpn.VpnClientSignal -import com.notcvnt.rknhardering.vpn.VpnDumpsysParser +import com.notcvnt.rknhardering.probe.LocalSocketInspector +import com.notcvnt.rknhardering.probe.LocalSocketListener +import com.notcvnt.rknhardering.vpn.VpnAppCatalog +import com.notcvnt.rknhardering.vpn.VpnAppMetadataScanner +import com.notcvnt.rknhardering.vpn.VpnClientSignal +import com.notcvnt.rknhardering.vpn.VpnDumpsysParser import java.io.BufferedReader import java.io.File import java.io.FileReader @@ -1129,20 +1130,28 @@ object IndirectSignsChecker { return SignalOutcome() } - var detected = false - var needsReview = false - for (record in records) { - val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) } - val confidence = when { - signature != null -> EvidenceConfidence.HIGH - record.packageName != null -> EvidenceConfidence.MEDIUM - else -> EvidenceConfidence.LOW - } - val familySuffix = signature?.family?.let { " [$it]" }.orEmpty() - val description = buildString { - append(context.getString(R.string.checker_indirect_dumpsys_vpn_line, record.rawLine)) - append(familySuffix) - } + var detected = false + var needsReview = false + for (record in records) { + val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) } + val metadata = VpnAppMetadataScanner.scan( + context = context, + packageName = record.packageName, + serviceNames = listOfNotNull(record.serviceName), + ) + val appLabel = VpnAppMetadataScanner.resolveAppLabel(context, record.packageName) + val confidence = when { + signature != null -> EvidenceConfidence.HIGH + record.packageName != null -> EvidenceConfidence.MEDIUM + else -> EvidenceConfidence.LOW + } + val familySuffix = signature?.family?.let { " [$it]" }.orEmpty() + val description = buildString { + append(context.getString(R.string.checker_indirect_dumpsys_vpn_line, record.rawLine)) + appLabel?.let { append(" ($it)") } + append(familySuffix) + append(VpnAppMetadataScanner.formatMetadataSuffix(metadata)) + } findings.add( Finding( description = description, @@ -1158,13 +1167,13 @@ object IndirectSignsChecker { source = EvidenceSource.ACTIVE_VPN, detected = true, confidence = confidence, - description = record.rawLine, - family = signature?.family, - packageName = record.packageName, - kind = signature?.kind, - ), - ) - activeApps.add( + description = record.rawLine, + family = signature?.family, + packageName = record.packageName, + kind = signature?.kind, + ), + ) + activeApps.add( ActiveVpnApp( packageName = record.packageName, serviceName = record.serviceName, @@ -1172,8 +1181,9 @@ object IndirectSignsChecker { kind = signature?.kind, source = EvidenceSource.ACTIVE_VPN, confidence = confidence, - ), - ) + technicalMetadata = metadata, + ), + ) detected = true needsReview = needsReview || signature == null } @@ -1207,13 +1217,19 @@ object IndirectSignsChecker { return SignalOutcome() } - var detected = false - var needsReview = false - for (record in records) { - val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) } - val confidence = when { - signature != null -> EvidenceConfidence.HIGH - record.packageName != null -> EvidenceConfidence.MEDIUM + var detected = false + var needsReview = false + for (record in records) { + val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) } + val metadata = VpnAppMetadataScanner.scan( + context = context, + packageName = record.packageName, + serviceNames = listOfNotNull(record.serviceName), + ) + val appLabel = VpnAppMetadataScanner.resolveAppLabel(context, record.packageName) + val confidence = when { + signature != null -> EvidenceConfidence.HIGH + record.packageName != null -> EvidenceConfidence.MEDIUM else -> EvidenceConfidence.LOW } val serviceDisplay = if (record.packageName != null && record.serviceName != null) { @@ -1221,11 +1237,13 @@ object IndirectSignsChecker { } else { record.rawLine } - val familySuffix = signature?.family?.let { " [$it]" }.orEmpty() - val description = buildString { - append(context.getString(R.string.checker_indirect_dumpsys_service_active, serviceDisplay)) - append(familySuffix) - } + val familySuffix = signature?.family?.let { " [$it]" }.orEmpty() + val description = buildString { + append(context.getString(R.string.checker_indirect_dumpsys_service_active, serviceDisplay)) + appLabel?.let { append(" ($it)") } + append(familySuffix) + append(VpnAppMetadataScanner.formatMetadataSuffix(metadata)) + } findings.add( Finding( description = description, @@ -1241,22 +1259,23 @@ object IndirectSignsChecker { source = EvidenceSource.ACTIVE_VPN, detected = true, confidence = confidence, - description = serviceDisplay, - family = signature?.family, - packageName = record.packageName, - kind = signature?.kind, - ), - ) - activeApps.add( + description = serviceDisplay, + family = signature?.family, + packageName = record.packageName, + kind = signature?.kind, + ), + ) + activeApps.add( ActiveVpnApp( packageName = record.packageName, serviceName = record.serviceName, family = signature?.family, - kind = signature?.kind, - source = EvidenceSource.ACTIVE_VPN, - confidence = confidence, - ), - ) + kind = signature?.kind, + source = EvidenceSource.ACTIVE_VPN, + confidence = confidence, + technicalMetadata = metadata, + ), + ) detected = true needsReview = needsReview || signature == null } diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt index 8d8f332..bb840c7 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt @@ -268,7 +268,7 @@ object VpnCheckRunner { } } else null - val directDeferred = safeAsync(fallback = { Fallbacks.direct(context, it) }) { + val directDeferred = safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.direct(context, it) }) { dependencies.directCheck( context, tunActiveProbeDeferred?.await(), diff --git a/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt b/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt index af9e575..3ce4ac8 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt @@ -129,6 +129,17 @@ enum class VpnAppKind { GENERIC_VPN, } +data class VpnAppTechnicalMetadata( + val versionName: String? = null, + val serviceNames: List = emptyList(), + val appType: String? = null, + val coreType: String? = null, + val corePath: String? = null, + val goVersion: String? = null, + val systemApp: Boolean = false, + val matchedByNameHeuristic: Boolean = false, +) + data class Finding( val description: String, val detected: Boolean = false, @@ -159,6 +170,7 @@ data class MatchedVpnApp( val source: EvidenceSource, val active: Boolean, val confidence: EvidenceConfidence, + val technicalMetadata: VpnAppTechnicalMetadata? = null, ) data class ActiveVpnApp( @@ -168,6 +180,7 @@ data class ActiveVpnApp( val kind: VpnAppKind?, val source: EvidenceSource, val confidence: EvidenceConfidence, + val technicalMetadata: VpnAppTechnicalMetadata? = null, ) data class LocalProxyOwner( diff --git a/app/src/main/java/com/notcvnt/rknhardering/vpn/InstalledVpnAppDetector.kt b/app/src/main/java/com/notcvnt/rknhardering/vpn/InstalledVpnAppDetector.kt index fecac11..8952032 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/vpn/InstalledVpnAppDetector.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/vpn/InstalledVpnAppDetector.kt @@ -1,5 +1,6 @@ package com.notcvnt.rknhardering.vpn +import android.Manifest import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -22,7 +23,6 @@ data class InstalledVpnDetectionResult( ) object InstalledVpnAppDetector { - private const val TAG = "VpnAppDetector" fun detect(context: Context): InstalledVpnDetectionResult { val pm = context.packageManager @@ -60,6 +60,7 @@ object InstalledVpnAppDetector { for (signature in VpnAppCatalog.signatures) { if (!isPackageInstalled(pm, signature.packageName)) continue + val metadata = VpnAppMetadataScanner.scan(context, signature.packageName) val confidence = when (signature.kind) { VpnAppKind.TARGETED_BYPASS -> EvidenceConfidence.MEDIUM VpnAppKind.GENERIC_VPN -> EvidenceConfidence.LOW @@ -68,7 +69,7 @@ object InstalledVpnAppDetector { R.string.checker_vpn_installed_app, signature.appName, signature.packageName, - ) + ) + VpnAppMetadataScanner.formatMetadataSuffix(metadata) findings.add( Finding( @@ -101,6 +102,7 @@ object InstalledVpnAppDetector { source = EvidenceSource.INSTALLED_APP, active = false, confidence = confidence, + technicalMetadata = metadata, ), ) } @@ -115,6 +117,11 @@ object InstalledVpnAppDetector { ) { for ((packageName, serviceNames) in queryVpnServiceProviders(pm)) { val signature = VpnAppCatalog.findByPackageName(packageName) + val metadata = VpnAppMetadataScanner.scan( + context = context, + packageName = packageName, + serviceNames = serviceNames, + ) val appName = signature?.appName ?: resolveAppName(pm, packageName) val family = signature?.family val kind = signature?.kind ?: VpnAppKind.GENERIC_VPN @@ -135,6 +142,7 @@ object InstalledVpnAppDetector { append(" -> ") append(serviceSuffix) } + append(VpnAppMetadataScanner.formatMetadataSuffix(metadata)) } findings.add( @@ -167,6 +175,7 @@ object InstalledVpnAppDetector { source = EvidenceSource.VPN_SERVICE_DECLARATION, active = false, confidence = confidence, + technicalMetadata = metadata, ) } } @@ -197,12 +206,17 @@ object InstalledVpnAppDetector { val normalizedAppName = appName.uppercase(Locale.ROOT) if (!normalizedAppName.contains("VPN")) continue + val metadata = VpnAppMetadataScanner.scan( + context = context, + packageName = packageName, + matchedByNameHeuristic = true, + ) val confidence = EvidenceConfidence.LOW val description = context.getString( R.string.checker_vpn_installed_app_by_name, appName, packageName, - ) + ) + VpnAppMetadataScanner.formatMetadataSuffix(metadata) findings.add( Finding( @@ -233,6 +247,7 @@ object InstalledVpnAppDetector { source = EvidenceSource.INSTALLED_APP, active = false, confidence = confidence, + technicalMetadata = metadata, ), ) } @@ -281,9 +296,13 @@ object InstalledVpnAppDetector { } return resolveInfos + .asSequence() .mapNotNull { resolveInfo -> val serviceInfo = resolveInfo.serviceInfo ?: return@mapNotNull null - serviceInfo.packageName to serviceInfo.name + if (serviceInfo.permission != Manifest.permission.BIND_VPN_SERVICE) return@mapNotNull null + val packageName = serviceInfo.packageName?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val serviceName = serviceInfo.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + packageName to serviceName } .groupBy(keySelector = { it.first }, valueTransform = { it.second }) } diff --git a/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnApkInspector.kt b/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnApkInspector.kt new file mode 100644 index 0000000..1c51254 --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnApkInspector.kt @@ -0,0 +1,77 @@ +package com.notcvnt.rknhardering.vpn + +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipFile + +data class VpnApkInspectionResult( + val appType: String? = null, + val coreType: String? = null, + val corePath: String? = null, + val goVersion: String? = null, +) { + val isEmpty: Boolean + get() = appType == null && coreType == null && corePath == null && goVersion == null +} + +object VpnApkInspector { + private const val MAX_DEX_BYTES = 15L * 1024L * 1024L + private const val MAX_NATIVE_LIB_BYTES = 64L * 1024L * 1024L + + fun inspect(apkPaths: List): VpnApkInspectionResult { + var appType: String? = null + var core: VpnCoreInspectionResult? = null + + for (path in apkPaths.filter { it.isNotBlank() }.distinct()) { + val file = File(path) + if (!file.isFile) continue + + runCatching { + ZipFile(file).use { zip -> + val entries = zip.entries().asSequence().filterNot(ZipEntry::isDirectory).toList() + if (appType == null) { + appType = inspectAppType(zip, entries) + } + if (core == null) { + core = inspectCoreType(zip, entries) + } + } + } + + if (appType != null && core != null) break + } + + return VpnApkInspectionResult( + appType = appType, + coreType = core?.coreType, + corePath = core?.corePath, + goVersion = core?.goVersion, + ) + } + + private fun inspectAppType(zip: ZipFile, entries: List): String? { + var fallbackType: String? = null + for (entry in entries) { + if (!entry.name.startsWith("classes") || !entry.name.endsWith(".dex")) continue + if (entry.size < 0L || entry.size > MAX_DEX_BYTES) continue + val content = runCatching { zip.getInputStream(entry).use { it.readBytes() } }.getOrNull() ?: continue + val appType = VpnAppClassifier.detectAppType(content) ?: continue + if (appType == VpnAppClassifier.APP_TYPE_SHADOWSOCKS_ANDROID) { + fallbackType = appType + } else { + return appType + } + } + return fallbackType + } + + private fun inspectCoreType(zip: ZipFile, entries: List): VpnCoreInspectionResult? { + for (entry in entries) { + if (!entry.name.startsWith("lib/") || !entry.name.endsWith(".so")) continue + if (entry.size < 0L || entry.size > MAX_NATIVE_LIB_BYTES) continue + val content = runCatching { zip.getInputStream(entry).use { it.readBytes() } }.getOrNull() ?: continue + VpnAppClassifier.detectCore(content, entry.name)?.let { return it } + } + return null + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppClassifier.kt b/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppClassifier.kt new file mode 100644 index 0000000..2c7c312 --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppClassifier.kt @@ -0,0 +1,94 @@ +package com.notcvnt.rknhardering.vpn + +import java.nio.charset.StandardCharsets + +data class VpnCoreInspectionResult( + val coreType: String, + val corePath: String? = null, + val goVersion: String? = null, +) + +object VpnAppClassifier { + const val APP_TYPE_V2RAYNG = "V2RayNG" + const val APP_TYPE_CLASH_FOR_ANDROID = "ClashForAndroid" + const val APP_TYPE_SING_BOX = "sing-box" + const val APP_TYPE_SAGERNET = "SagerNet" + const val APP_TYPE_SHADOWSOCKS_ANDROID = "shadowsocks-android" + + const val CORE_TYPE_SING_BOX = "sing-box" + const val CORE_TYPE_XRAY_V2RAY = "Xray/V2Ray" + const val CORE_TYPE_CLASH_MIHOMO = "Clash/Mihomo" + const val CORE_TYPE_WIREGUARD = "WireGuard" + const val CORE_TYPE_OPENVPN = "OpenVPN" + const val CORE_TYPE_SHADOWSOCKS = "Shadowsocks" + + private val appTypeMarkers = listOf( + APP_TYPE_V2RAYNG to listOf( + "com.v2ray.ang", + ".dto.V2rayConfig", + ".service.V2RayVpnService", + ), + APP_TYPE_CLASH_FOR_ANDROID to listOf( + "com.github.kr328.clash", + ".core.Clash", + ".service.TunService", + ), + APP_TYPE_SING_BOX to listOf("io.nekohasekai.sfa"), + APP_TYPE_SAGERNET to listOf( + "io.nekohasekai.sagernet", + ".fmt.ConfigBuilder", + ), + APP_TYPE_SHADOWSOCKS_ANDROID to listOf( + "com.github.shadowsocks", + ".bg.VpnService", + "GuardedProcessPool", + ), + ) + + private val coreTypeMarkers = listOf( + CORE_TYPE_SING_BOX to listOf("sing-box", "singbox"), + CORE_TYPE_XRAY_V2RAY to listOf("xray", "v2ray", "v2fly"), + CORE_TYPE_CLASH_MIHOMO to listOf("mihomo", "clash"), + CORE_TYPE_WIREGUARD to listOf("wireguard"), + CORE_TYPE_OPENVPN to listOf("openvpn", "ovpn"), + CORE_TYPE_SHADOWSOCKS to listOf("shadowsocks"), + ) + + fun detectAppType(content: ByteArray): String? { + return detectFirst(content, appTypeMarkers, ignoreCase = false) + } + + fun detectCore(content: ByteArray, entryName: String? = null): VpnCoreInspectionResult? { + val searchContent = if (entryName.isNullOrBlank()) { + content + } else { + entryName.encodeToByteArray() + byteArrayOf(0) + content + } + val coreType = detectFirst(searchContent, coreTypeMarkers, ignoreCase = true) ?: return null + return VpnCoreInspectionResult( + coreType = coreType, + corePath = entryName, + goVersion = findGoVersion(content), + ) + } + + fun findGoVersion(content: ByteArray): String? { + val text = content.toString(StandardCharsets.ISO_8859_1) + return Regex("""go1(?:\.\d+){1,2}""").find(text)?.value + } + + private fun detectFirst( + content: ByteArray, + typedMarkers: List>>, + ignoreCase: Boolean, + ): String? { + val text = content.toString(StandardCharsets.ISO_8859_1) + val normalizedDexText = text.replace('/', '.').replace('$', '.') + return typedMarkers.firstOrNull { (_, markers) -> + markers.any { marker -> + text.contains(marker, ignoreCase = ignoreCase) || + normalizedDexText.contains(marker, ignoreCase = ignoreCase) + } + }?.first + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppMetadataScanner.kt b/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppMetadataScanner.kt new file mode 100644 index 0000000..60877ef --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppMetadataScanner.kt @@ -0,0 +1,119 @@ +package com.notcvnt.rknhardering.vpn + +import android.Manifest +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata + +object VpnAppMetadataScanner { + + fun scan( + context: Context, + packageName: String?, + serviceNames: List = emptyList(), + matchedByNameHeuristic: Boolean = false, + ): VpnAppTechnicalMetadata? { + if (packageName.isNullOrBlank()) { + return serviceNames + .normalizeServiceNames() + .takeIf { it.isNotEmpty() } + ?.let { VpnAppTechnicalMetadata(serviceNames = it, matchedByNameHeuristic = matchedByNameHeuristic) } + } + + val pm = context.packageManager + val packageInfo = getPackageInfo(pm, packageName) + val appInfo = packageInfo?.applicationInfo + val apkInspection = appInfo + ?.let(::apkPaths) + ?.let(VpnApkInspector::inspect) + ?.takeUnless { it.isEmpty } + + if (packageInfo == null && apkInspection == null && serviceNames.isEmpty() && !matchedByNameHeuristic) { + return null + } + + return VpnAppTechnicalMetadata( + versionName = packageInfo?.versionName?.takeIf { it.isNotBlank() }, + serviceNames = (serviceNames + packageInfo.vpnServiceNames()).normalizeServiceNames(), + appType = apkInspection?.appType, + coreType = apkInspection?.coreType, + corePath = apkInspection?.corePath, + goVersion = apkInspection?.goVersion, + systemApp = appInfo?.isSystemApp() == true, + matchedByNameHeuristic = matchedByNameHeuristic, + ) + } + + fun resolveAppLabel(context: Context, packageName: String?): String? { + if (packageName.isNullOrBlank()) return null + val pm = context.packageManager + return runCatching { + val appInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0L)) + } else { + @Suppress("DEPRECATION") + pm.getApplicationInfo(packageName, 0) + } + pm.getApplicationLabel(appInfo)?.toString()?.trim()?.takeIf { it.isNotBlank() } + }.getOrNull() + } + + fun formatMetadataSuffix(metadata: VpnAppTechnicalMetadata?): String { + val details = metadataDetails(metadata) + return if (details.isEmpty()) "" else " [${details.joinToString(", ")}]" + } + + fun metadataDetails(metadata: VpnAppTechnicalMetadata?): List { + if (metadata == null) return emptyList() + return buildList { + metadata.versionName?.let { add("version=$it") } + metadata.appType?.let { add("app=$it") } + metadata.coreType?.let { add("core=$it") } + metadata.corePath?.let { add("path=$it") } + metadata.goVersion?.let { add("go=$it") } + if (metadata.serviceNames.isNotEmpty()) { + add("services=${metadata.serviceNames.take(3).joinToString()}") + } + if (metadata.matchedByNameHeuristic) add("nameHeuristic=true") + if (metadata.systemApp) add("systemApp=true") + } + } + + private fun getPackageInfo(pm: PackageManager, packageName: String): PackageInfo? { + return runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_SERVICES.toLong())) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(packageName, PackageManager.GET_SERVICES) + } + }.getOrNull() + } + + private fun PackageInfo?.vpnServiceNames(): List { + return this + ?.services + ?.filter { it.permission == Manifest.permission.BIND_VPN_SERVICE } + ?.mapNotNull { it.name?.trim()?.takeIf(String::isNotEmpty) } + .orEmpty() + } + + private fun apkPaths(appInfo: ApplicationInfo): List { + return buildList { + appInfo.publicSourceDir?.takeIf { it.isNotBlank() }?.let(::add) + appInfo.splitPublicSourceDirs?.filter { it.isNotBlank() }?.let(::addAll) + } + } + + private fun ApplicationInfo.isSystemApp(): Boolean { + val system = flags and ApplicationInfo.FLAG_SYSTEM != 0 + val updatedSystem = flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 + return system || updatedSystem + } + + private fun List.normalizeServiceNames(): List = + map(String::trim).filter(String::isNotEmpty).distinct() +} diff --git a/app/src/test/java/com/notcvnt/rknhardering/CheckResultExportTestFixtures.kt b/app/src/test/java/com/notcvnt/rknhardering/CheckResultExportTestFixtures.kt index 86aaf69..e3d920e 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/CheckResultExportTestFixtures.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/CheckResultExportTestFixtures.kt @@ -32,6 +32,7 @@ import com.notcvnt.rknhardering.model.ObservedIp import com.notcvnt.rknhardering.model.TargetGroup import com.notcvnt.rknhardering.model.Verdict import com.notcvnt.rknhardering.model.VpnAppKind +import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata import com.notcvnt.rknhardering.probe.ProxyEndpoint import com.notcvnt.rknhardering.probe.ProxyType import com.notcvnt.rknhardering.probe.XrayApiEndpoint @@ -192,6 +193,14 @@ internal fun exportRichCheckResult(): CheckResult { source = EvidenceSource.INSTALLED_APP, active = true, confidence = EvidenceConfidence.HIGH, + technicalMetadata = VpnAppTechnicalMetadata( + versionName = "1.2.3", + serviceNames = listOf("ExampleService"), + appType = "V2RayNG", + coreType = "Xray/V2Ray", + corePath = "lib/arm64-v8a/libxray.so", + goVersion = "go1.24.1", + ), ), ), activeApps = listOf( @@ -202,6 +211,14 @@ internal fun exportRichCheckResult(): CheckResult { kind = VpnAppKind.TARGETED_BYPASS, source = EvidenceSource.ACTIVE_VPN, confidence = EvidenceConfidence.HIGH, + technicalMetadata = VpnAppTechnicalMetadata( + versionName = "1.2.3", + serviceNames = listOf("ExampleService"), + appType = "V2RayNG", + coreType = "Xray/V2Ray", + corePath = "lib/arm64-v8a/libxray.so", + goVersion = "go1.24.1", + ), ), ), ), diff --git a/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt b/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt index 50b7011..352fa2c 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt @@ -151,4 +151,32 @@ class CheckResultJsonExportFormatterTest { assertTrue(icmp.getBoolean("needsReview")) assertEquals("ICMP spoofing", icmp.getString("name")) } + + @Test + fun `json export includes vpn app technical metadata`() { + val json = JSONObject( + CheckResultJsonExportFormatter.format( + context = context, + snapshot = createCompletedExportSnapshot( + result = exportRichCheckResult(), + privacyMode = false, + finishedAtMillis = 0L, + ), + appVersionName = "1.0", + buildType = "debug", + ), + ) + + val metadata = json + .getJSONObject("results") + .getJSONObject("directSigns") + .getJSONArray("matchedApps") + .getJSONObject(0) + .getJSONObject("technicalMetadata") + + assertEquals("V2RayNG", metadata.getString("appType")) + assertEquals("Xray/V2Ray", metadata.getString("coreType")) + assertEquals("1.2.3", metadata.getString("versionName")) + assertEquals("ExampleService", metadata.getJSONArray("serviceNames").getString(0)) + } } diff --git a/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt b/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt index fe520cb..6b1c268 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt @@ -108,4 +108,22 @@ class CheckResultMarkdownExportFormatterTest { assertFalse(markdown.contains("## IP каналы")) } + + @Test + fun `markdown export includes vpn app technical metadata`() { + val markdown = CheckResultMarkdownExportFormatter.format( + context = context, + snapshot = createCompletedExportSnapshot( + result = exportRichCheckResult(), + privacyMode = false, + finishedAtMillis = 0L, + ), + appVersionName = "1.0", + buildType = "debug", + ) + + assertTrue(markdown.contains("appType=V2RayNG")) + assertTrue(markdown.contains("coreType=Xray/V2Ray")) + assertTrue(markdown.contains("goVersion=go1.24.1")) + } } diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt index dfe86e6..9143540 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt @@ -46,6 +46,32 @@ class VerdictEngineTest { assertEquals(Verdict.NOT_DETECTED, verdict) } + @Test + fun `metadata-only installed app evidence does not affect verdict`() { + val verdict = VerdictEngine.evaluate( + geoIp = category(), + directSigns = CategoryResult( + name = "direct", + detected = false, + findings = emptyList(), + evidence = listOf( + EvidenceItem( + source = EvidenceSource.INSTALLED_APP, + detected = false, + confidence = EvidenceConfidence.LOW, + description = "Installed app with VPN in name", + ), + ), + ), + indirectSigns = category(), + locationSignals = category(), + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + ) + + assertEquals(Verdict.NOT_DETECTED, verdict) + } + @Test fun `R3a probeTargetDivergence with geoCountryMismatch yields detected`() { val verdict = VerdictEngine.evaluate( diff --git a/app/src/test/java/com/notcvnt/rknhardering/vpn/InstalledVpnAppDetectorByNameTest.kt b/app/src/test/java/com/notcvnt/rknhardering/vpn/InstalledVpnAppDetectorByNameTest.kt index e4c5086..4f0250f 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/vpn/InstalledVpnAppDetectorByNameTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/vpn/InstalledVpnAppDetectorByNameTest.kt @@ -22,10 +22,19 @@ class InstalledVpnAppDetectorByNameTest { private val pm get() = context.packageManager private val shadow get() = shadowOf(pm) - private fun installApp(packageName: String, label: String, systemApp: Boolean = false) { + private fun installApp( + packageName: String, + label: String, + systemApp: Boolean = false, + updatedSystemApp: Boolean = false, + ) { val appInfo = ApplicationInfo().apply { this.packageName = packageName - flags = if (systemApp) ApplicationInfo.FLAG_SYSTEM else 0 + flags = when { + systemApp -> ApplicationInfo.FLAG_SYSTEM + updatedSystemApp -> ApplicationInfo.FLAG_UPDATED_SYSTEM_APP + else -> 0 + } nonLocalizedLabel = label } val pkgInfo = PackageInfo().apply { @@ -91,6 +100,16 @@ class InstalledVpnAppDetectorByNameTest { assertFalse(result.evidence.any { it.packageName == "com.android.vpndialogs" }) } + @Test + fun `updated system app with VPN in label is not detected`() { + installApp("com.android.updatedvpn", "Updated VPN", updatedSystemApp = true) + + val result = InstalledVpnAppDetector.detect(context) + + assertFalse(result.matchedApps.any { it.packageName == "com.android.updatedvpn" }) + assertFalse(result.evidence.any { it.packageName == "com.android.updatedvpn" }) + } + @Test fun `app already matched by catalog is not duplicated by name heuristic`() { val catalogPkg = VpnAppCatalog.signatures.first { it.kind == VpnAppKind.TARGETED_BYPASS } diff --git a/app/src/test/java/com/notcvnt/rknhardering/vpn/VpnApkInspectorTest.kt b/app/src/test/java/com/notcvnt/rknhardering/vpn/VpnApkInspectorTest.kt new file mode 100644 index 0000000..1f22c98 --- /dev/null +++ b/app/src/test/java/com/notcvnt/rknhardering/vpn/VpnApkInspectorTest.kt @@ -0,0 +1,88 @@ +package com.notcvnt.rknhardering.vpn + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class VpnApkInspectorTest { + + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `detects app type from dex marker`() { + val apk = createApk( + "classes.dex" to "Lio/nekohasekai/sagernet/fmt/ConfigBuilder;".encodeToByteArray(), + ) + + val result = VpnApkInspector.inspect(listOf(apk)) + + assertEquals(VpnAppClassifier.APP_TYPE_SAGERNET, result.appType) + } + + @Test + fun `detects core type and go version from native library`() { + val apk = createApk( + "lib/arm64-v8a/libbox.so" to "github.com/sagernet/sing-box\u0000go1.24.2".encodeToByteArray(), + ) + + val result = VpnApkInspector.inspect(listOf(apk)) + + assertEquals(VpnAppClassifier.CORE_TYPE_SING_BOX, result.coreType) + assertEquals("lib/arm64-v8a/libbox.so", result.corePath) + assertEquals("go1.24.2", result.goVersion) + } + + @Test + fun `corrupt apk falls back without crashing`() { + val file = temp.newFile("corrupt.apk").apply { + writeText("not a zip") + } + + val result = VpnApkInspector.inspect(listOf(file.absolutePath)) + + assertNull(result.appType) + assertNull(result.coreType) + } + + @Test + fun `oversized dex is skipped`() { + val oversizedDex = ByteArray(15 * 1024 * 1024 + 1) { 0 } + val apk = createApk( + "classes.dex" to oversizedDex, + "classes2.dex" to "Lcom/github/shadowsocks/bg/VpnService;".encodeToByteArray(), + ) + + val result = VpnApkInspector.inspect(listOf(apk)) + + assertEquals(VpnAppClassifier.APP_TYPE_SHADOWSOCKS_ANDROID, result.appType) + } + + @Test + fun `shadowsocks marker is fallback when stronger app type appears later`() { + val apk = createApk( + "classes.dex" to "Lcom/github/shadowsocks/bg/VpnService;".encodeToByteArray(), + "classes2.dex" to "Lio/nekohasekai/sagernet/fmt/ConfigBuilder;".encodeToByteArray(), + ) + + val result = VpnApkInspector.inspect(listOf(apk)) + + assertEquals(VpnAppClassifier.APP_TYPE_SAGERNET, result.appType) + } + + private fun createApk(vararg entries: Pair): String { + val file = temp.newFile("test-${System.nanoTime()}.apk") + ZipOutputStream(file.outputStream()).use { zip -> + for ((name, content) in entries) { + zip.putNextEntry(ZipEntry(name)) + zip.write(content) + zip.closeEntry() + } + } + return file.absolutePath + } +} diff --git a/app/src/test/java/com/notcvnt/rknhardering/vpn/VpnAppMetadataScannerTest.kt b/app/src/test/java/com/notcvnt/rknhardering/vpn/VpnAppMetadataScannerTest.kt new file mode 100644 index 0000000..08aa9e8 --- /dev/null +++ b/app/src/test/java/com/notcvnt/rknhardering/vpn/VpnAppMetadataScannerTest.kt @@ -0,0 +1,117 @@ +package com.notcvnt.rknhardering.vpn + +import android.Manifest +import android.app.Application +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.ServiceInfo +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.rules.TemporaryFolder +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(RobolectricTestRunner::class) +class VpnAppMetadataScannerTest { + + @get:Rule + val temp = TemporaryFolder() + + private val context = ApplicationProvider.getApplicationContext() + private val pm get() = context.packageManager + private val shadow get() = shadowOf(pm) + + @Test + fun `scanner enriches version service app type and core type`() { + val apk = createApk( + "classes.dex" to "Lio/nekohasekai/sagernet/fmt/ConfigBuilder;".encodeToByteArray(), + "lib/arm64-v8a/libbox.so" to "github.com/sagernet/sing-box\u0000go1.24.1".encodeToByteArray(), + ) + installVpnPackage( + packageName = "com.example.husi.clone", + label = "Husi Clone", + versionName = "1.2.3", + apkPath = apk, + serviceName = "com.example.husi.clone.VpnService", + ) + + val metadata = VpnAppMetadataScanner.scan(context, "com.example.husi.clone") + + assertEquals("1.2.3", metadata?.versionName) + assertEquals(listOf("com.example.husi.clone.VpnService"), metadata?.serviceNames) + assertEquals(VpnAppClassifier.APP_TYPE_SAGERNET, metadata?.appType) + assertEquals(VpnAppClassifier.CORE_TYPE_SING_BOX, metadata?.coreType) + assertEquals("go1.24.1", metadata?.goVersion) + assertFalse(metadata?.systemApp ?: true) + } + + @Test + fun `declared unknown VpnService app is matched with metadata`() { + val apk = createApk( + "classes.dex" to "Lio/nekohasekai/sfa/Main;".encodeToByteArray(), + ) + installVpnPackage( + packageName = "com.example.unknownvpn", + label = "Unknown VPN", + versionName = "9.8.7", + apkPath = apk, + serviceName = "com.example.unknownvpn.TunnelService", + ) + + val result = InstalledVpnAppDetector.detect(context) + val matched = result.matchedApps.firstOrNull { it.packageName == "com.example.unknownvpn" } + + assertTrue(matched != null) + assertEquals(VpnAppClassifier.APP_TYPE_SING_BOX, matched?.technicalMetadata?.appType) + assertEquals("9.8.7", matched?.technicalMetadata?.versionName) + assertEquals(listOf("com.example.unknownvpn.TunnelService"), matched?.technicalMetadata?.serviceNames) + } + + private fun installVpnPackage( + packageName: String, + label: String, + versionName: String, + apkPath: String, + serviceName: String, + ): ServiceInfo { + val appInfo = ApplicationInfo().apply { + this.packageName = packageName + nonLocalizedLabel = label + sourceDir = apkPath + publicSourceDir = apkPath + } + val service = ServiceInfo().apply { + this.packageName = packageName + name = serviceName + permission = Manifest.permission.BIND_VPN_SERVICE + applicationInfo = appInfo + } + val pkgInfo = PackageInfo().apply { + this.packageName = packageName + applicationInfo = appInfo + this.versionName = versionName + services = arrayOf(service) + } + shadow.installPackage(pkgInfo) + return service + } + + private fun createApk(vararg entries: Pair): String { + val file = temp.newFile("metadata-${System.nanoTime()}.apk") + ZipOutputStream(file.outputStream()).use { zip -> + for ((name, content) in entries) { + zip.putNextEntry(ZipEntry(name)) + zip.write(content) + zip.closeEntry() + } + } + return file.absolutePath + } +}