Add VPN app scanner

This commit is contained in:
世界 2023-10-27 13:09:40 +08:00
parent 953a0bee72
commit 821e713e6c
No known key found for this signature in database
GPG key ID: CD109927C34A63C4
10 changed files with 643 additions and 1 deletions

View file

@ -111,6 +111,12 @@
<activity
android:name="io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity"
android:exported="false" />
<activity
android:name="io.nekohasekai.sfa.ui.debug.DebugActivity"
android:exported="false" />
<activity
android:name="io.nekohasekai.sfa.ui.debug.VPNScanActivity"
android:exported="false" />
<service
android:name=".bg.TileService"

View file

@ -2,6 +2,20 @@ package io.nekohasekai.sfa.ktx
import io.nekohasekai.libbox.StringIterator
fun Iterable<String>.toStringIterator(): StringIterator {
return object : StringIterator {
val iterator = iterator()
override fun hasNext(): Boolean {
return iterator.hasNext()
}
override fun next(): String {
return iterator.next()
}
}
}
fun StringIterator.toList(): List<String> {
return mutableListOf<String>().apply {
while (hasNext()) {

View file

@ -0,0 +1,26 @@
package io.nekohasekai.sfa.ui.debug
import android.content.Intent
import android.os.Bundle
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.databinding.ActivityDebugBinding
import io.nekohasekai.sfa.ui.shared.AbstractActivity
class DebugActivity : AbstractActivity() {
private var binding: ActivityDebugBinding? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.title_debug)
val binding = ActivityDebugBinding.inflate(layoutInflater)
this.binding = binding
setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.scanVPNButton.setOnClickListener {
startActivity(Intent(this, VPNScanActivity::class.java))
}
}
}

View file

@ -0,0 +1,256 @@
package io.nekohasekai.sfa.ui.debug
import android.Manifest
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.databinding.ActivityVpnScanBinding
import io.nekohasekai.sfa.databinding.ViewVpnAppItemBinding
import io.nekohasekai.sfa.ktx.toStringIterator
import io.nekohasekai.sfa.ui.shared.AbstractActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jf.dexlib2.dexbacked.DexBackedDexFile
import java.io.File
import java.util.zip.ZipFile
import kotlin.math.roundToInt
class VPNScanActivity : AbstractActivity() {
private var binding: ActivityVpnScanBinding? = null
private var adapter: Adapter? = null
private val appInfoList = mutableListOf<AppInfo>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.title_scan_vpn)
val binding = ActivityVpnScanBinding.inflate(layoutInflater)
this.binding = binding
setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.scanVPNResult.adapter = Adapter().also {
adapter = it
}
binding.scanVPNResult.layoutManager = LinearLayoutManager(this)
lifecycleScope.launch(Dispatchers.IO) {
scanVPN()
}
}
class VPNType(
val appType: String?,
val coreType: VPNCoreType?,
)
class VPNCoreType(
val coreType: String,
val corePath: String,
val goVersion: String
)
class AppInfo(
val packageInfo: PackageInfo,
val vpnType: VPNType,
)
inner class Adapter : RecyclerView.Adapter<Holder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(ViewVpnAppItemBinding.inflate(layoutInflater, parent, false))
}
override fun getItemCount(): Int {
return appInfoList.size
}
override fun onBindViewHolder(holder: Holder, position: Int) {
holder.bind(appInfoList[position])
}
}
class Holder(
private val binding: ViewVpnAppItemBinding
) :
RecyclerView.ViewHolder(binding.root) {
fun bind(element: AppInfo) {
binding.appIcon.setImageDrawable(element.packageInfo.applicationInfo.loadIcon(binding.root.context.packageManager))
binding.appName.text =
element.packageInfo.applicationInfo.loadLabel(binding.root.context.packageManager)
binding.packageName.text = element.packageInfo.packageName
val appType = element.vpnType.appType
if (appType != null) {
binding.appTypeText.text = element.vpnType.appType
} else {
binding.appTypeText.setText(R.string.vpn_app_type_other)
}
val coreType = element.vpnType.coreType?.coreType
if (coreType != null) {
binding.coreTypeText.text = element.vpnType.coreType.coreType
} else {
binding.coreTypeText.setText(R.string.vpn_core_type_unknown)
}
val corePath = element.vpnType.coreType?.corePath.takeIf { !it.isNullOrBlank() }
if (corePath != null) {
binding.corePathLayout.isVisible = true
binding.corePathText.text = corePath
} else {
binding.corePathLayout.isVisible = false
}
val goVersion = element.vpnType.coreType?.goVersion.takeIf { !it.isNullOrBlank() }
if (goVersion != null) {
binding.goVersionLayout.isVisible = true
binding.goVersionText.text = goVersion
} else {
binding.goVersionLayout.isVisible = false
}
}
}
private suspend fun scanVPN() {
val adapter = adapter ?: return
val binding = binding ?: return
val flag =
PackageManager.GET_SERVICES or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
PackageManager.MATCH_UNINSTALLED_PACKAGES
} else {
@Suppress("DEPRECATION")
PackageManager.GET_UNINSTALLED_PACKAGES
}
val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(flag.toLong()))
} else {
@Suppress("DEPRECATION")
packageManager.getInstalledPackages(flag)
}
val vpnAppList =
installedPackages.filter {
it.services?.any { it.permission == Manifest.permission.BIND_VPN_SERVICE } ?: false
}
for ((index, packageInfo) in vpnAppList.withIndex()) {
val appType = runCatching { getVPNAppType(packageInfo) }.getOrNull()
val coreType = runCatching { getVPNCoreType(packageInfo) }.getOrNull()
appInfoList.add(AppInfo(packageInfo, VPNType(appType, coreType)))
withContext(Dispatchers.Main) {
adapter.notifyItemInserted(index)
binding.scanVPNResult.scrollToPosition(index)
binding.scanVPNProgress.setProgressCompat(
(((index + 1).toFloat() / vpnAppList.size.toFloat()) * 100).roundToInt(),
true
)
}
System.gc()
}
withContext(Dispatchers.Main) {
binding.scanVPNProgress.isVisible = false
}
}
companion object {
private val v2rayNGClasses = listOf(
"com.v2ray.ang",
".dto.V2rayConfig",
".service.V2RayVpnService",
)
private val clashForAndroidClasses = listOf(
"com.github.kr328.clash",
".core.Clash",
".service.TunService",
)
private val sfaClasses = listOf(
"io.nekohasekai.sfa"
)
private val legacySagerNetClasses = listOf(
"io.nekohasekai.sagernet",
".fmt.ConfigBuilder"
)
private val shadowsocksAndroidClasses = listOf(
"com.github.shadowsocks",
".bg.VpnService",
"GuardedProcessPool"
)
}
private fun getVPNAppType(packageInfo: PackageInfo): String? {
ZipFile(File(packageInfo.applicationInfo.publicSourceDir)).use { packageFile ->
for (packageEntry in packageFile.entries()) {
if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith(
".dex"
))
) {
continue
}
if (packageEntry.size > 15000000) {
continue
}
val input = packageFile.getInputStream(packageEntry).buffered()
val dexFile = try {
DexBackedDexFile.fromInputStream(null, input)
} catch (e: Exception) {
Log.e("VPNScanActivity", "Failed to read dex file", e)
continue
}
for (clazz in dexFile.classes) {
val clazzName = clazz.type.substring(1, clazz.type.length - 1)
.replace("/", ".")
.replace("$", ".")
for (v2rayNGClass in v2rayNGClasses) {
if (clazzName.contains(v2rayNGClass)) {
return "V2RayNG"
}
}
for (clashForAndroidClass in clashForAndroidClasses) {
if (clazzName.contains(clashForAndroidClass)) {
return "ClashForAndroid"
}
}
for (sfaClass in sfaClasses) {
if (clazzName.contains(sfaClass)) {
return "sing-box"
}
}
for (legacySagerNetClass in legacySagerNetClasses) {
if (clazzName.contains(legacySagerNetClass)) {
return "LegacySagerNet"
}
}
for (shadowsocksAndroidClass in shadowsocksAndroidClasses) {
if (clazzName.contains(shadowsocksAndroidClass)) {
return "shadowsocks-android"
}
}
}
}
return null
}
}
private fun getVPNCoreType(packageInfo: PackageInfo): VPNCoreType? {
val packageFiles = mutableListOf(packageInfo.applicationInfo.publicSourceDir)
packageInfo.applicationInfo.splitPublicSourceDirs?.also {
packageFiles.addAll(it)
}
val vpnType = try {
Libbox.readAndroidVPNType(packageFiles.toStringIterator())
} catch (ignored: Exception) {
return null
}
return VPNCoreType(vpnType.coreType, vpnType.corePath, vpnType.goVersion)
}
}

View file

@ -24,6 +24,7 @@ import io.nekohasekai.sfa.ktx.launchCustomTab
import io.nekohasekai.sfa.ktx.setSimpleItems
import io.nekohasekai.sfa.ktx.text
import io.nekohasekai.sfa.ui.MainActivity
import io.nekohasekai.sfa.ui.debug.DebugActivity
import io.nekohasekai.sfa.ui.profileoverride.ProfileOverrideActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -112,6 +113,9 @@ class SettingsFragment : Fragment() {
binding.documentationButton.setOnClickListener {
it.context.launchCustomTab("http://sing-box.sagernet.org/installation/clients/sfa/")
}
binding.openDebugButton.setOnClickListener {
startActivity(Intent(requireContext(), DebugActivity::class.java))
}
lifecycleScope.launch(Dispatchers.IO) {
reloadSettings()
}

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_scan_vpn"
android:textAppearance="?attr/textAppearanceTitleLarge">
</TextView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/message_scan_vpn" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical|end"
android:orientation="horizontal">
<Button
android:id="@+id/scanVPNButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_scan_vpn" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/scanVPNProgress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/scanVPNResult"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingBottom="16dp" />
</LinearLayout>

View file

@ -340,6 +340,55 @@
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_debug"
android:textAppearance="?attr/textAppearanceTitleLarge">
</TextView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/message_debug_tools" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical|end"
android:orientation="horizontal">
<Button
android:id="@+id/openDebugButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_open" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,186 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/appIcon"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="16dp"
tools:src="@android:drawable/sym_def_app_icon" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView
android:id="@+id/appName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
tools:text="App Name" />
<TextView
android:id="@+id/packageName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
tools:text="com.myapp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/vpn_app_type" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:orientation="horizontal">
<TextView
android:id="@+id/appTypeText"
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/loading"
android:textColor="?android:colorForeground" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/vpn_core_type" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:orientation="horizontal">
<TextView
android:id="@+id/coreTypeText"
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/loading"
android:textColor="?android:colorForeground" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/corePathLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/vpn_core_path" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:orientation="horizontal">
<TextView
android:id="@+id/corePathText"
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/loading"
android:textColor="?android:colorForeground" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/goVersionLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/vpn_golang_version" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:orientation="horizontal">
<TextView
android:id="@+id/goVersionText"
style="?attr/textAppearanceBodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/loading"
android:textColor="?android:colorForeground" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View file

@ -12,6 +12,7 @@
<string name="title_edit_configuration">Edit Configuration</string>
<string name="title_overview">Overview</string>
<string name="title_groups">Groups</string>
<string name="title_debug">Debug</string>
<string name="quick_toggle">Toggle</string>
<string name="profile_name">Name</string>
@ -125,7 +126,7 @@
<string name="title_scan_result">Scan Result</string>
<string name="action_select">Select</string>
<string name="action_deselect">Deselect</string>
<string name="per_app_proxy_update_on_change">Update on China App Installed/Updated</string>
<string name="per_app_proxy_update_on_change">Update on new China App Installed</string>
<string name="import_profile">Import profile</string>
<string name="import_profile_message">Are you sure to import profile %s?</string>
<string name="icloud_profile_unsupported">iCloud profile is not support on current platform</string>
@ -133,4 +134,16 @@
<string name="urltest">URLTest</string>
<string name="expand">Expand</string>
<string name="http_proxy">HTTP Proxy</string>
<string name="title_scan_vpn">Scan VPN apps</string>
<string name="message_scan_vpn">Check the VPN installed on the device and its contents</string>
<string name="action_scan_vpn">Scan</string>
<string name="message_debug_tools">Some debug utilities</string>
<string name="action_open">Open</string>
<string name="vpn_app_type">App Type</string>
<string name="vpn_core_type">Core Type</string>
<string name="vpn_core_path">Detected Path</string>
<string name="vpn_golang_version">Go Version</string>
<string name="vpn_app_type_other">Other</string>
<string name="vpn_core_type_unknown">Unknown</string>
</resources>