From 821e713e6c651653e63b67898bb4f20fadb051bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 27 Oct 2023 13:09:40 +0800 Subject: [PATCH] Add VPN app scanner --- app/src/main/AndroidManifest.xml | 6 + .../java/io/nekohasekai/sfa/ktx/Wrappers.kt | 14 + .../nekohasekai/sfa/ui/debug/DebugActivity.kt | 26 ++ .../sfa/ui/debug/VPNScanActivity.kt | 256 ++++++++++++++++++ .../sfa/ui/main/SettingsFragment.kt | 4 + app/src/main/res/layout/activity_debug.xml | 66 +++++ app/src/main/res/layout/activity_vpn_scan.xml | 22 ++ app/src/main/res/layout/fragment_settings.xml | 49 ++++ app/src/main/res/layout/view_vpn_app_item.xml | 186 +++++++++++++ app/src/main/res/values/strings.xml | 15 +- 10 files changed, 643 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt create mode 100644 app/src/main/res/layout/activity_debug.xml create mode 100644 app/src/main/res/layout/activity_vpn_scan.xml create mode 100644 app/src/main/res/layout/view_vpn_app_item.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index be108df..14ca0b9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -111,6 +111,12 @@ + + .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 { return mutableListOf().apply { while (hasNext()) { diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt new file mode 100644 index 0000000..cc98a53 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt @@ -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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt new file mode 100644 index 0000000..6de9151 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt @@ -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() + 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() { + 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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt index bc6fe2e..f1921d9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt @@ -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() } diff --git a/app/src/main/res/layout/activity_debug.xml b/app/src/main/res/layout/activity_debug.xml new file mode 100644 index 0000000..e9438ee --- /dev/null +++ b/app/src/main/res/layout/activity_debug.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + +