From 9575764f405122652d0a551b8ee49675d61a8b72 Mon Sep 17 00:00:00 2001 From: iKirby <6316115+ikirby@users.noreply.github.com> Date: Thu, 27 Jul 2023 22:17:25 +0800 Subject: [PATCH] Add config override and per-app proxy feature --- app/build.gradle | 11 + app/src/main/AndroidManifest.xml | 10 + app/src/main/assets/prefix-china-apps.txt | 35 ++ .../java/io/nekohasekai/sfa/Application.kt | 8 + .../nekohasekai/sfa/bg/AppChangeReceiver.kt | 49 ++ .../java/io/nekohasekai/sfa/bg/BoxService.kt | 22 +- .../java/io/nekohasekai/sfa/bg/VPNService.kt | 46 +- .../io/nekohasekai/sfa/constant/Action.kt | 1 + .../sfa/constant/PerAppProxyUpdateType.kt | 21 + .../nekohasekai/sfa/constant/SettingsKey.kt | 5 + .../io/nekohasekai/sfa/database/Settings.kt | 11 + .../io/nekohasekai/sfa/ktx/Preferences.kt | 5 + .../configoverride/ConfigOverrideActivity.kt | 59 +++ .../ui/configoverride/PerAppProxyActivity.kt | 459 ++++++++++++++++++ .../sfa/ui/main/SettingsFragment.kt | 10 +- .../res/layout/activity_config_override.xml | 90 ++++ .../res/layout/activity_per_app_proxy.xml | 55 +++ app/src/main/res/layout/dialog_progress.xml | 19 + app/src/main/res/layout/fragment_settings.xml | 51 +- .../main/res/layout/view_app_list_item.xml | 46 ++ app/src/main/res/menu/per_app_menu.xml | 20 + app/src/main/res/values-night/colors.xml | 4 + app/src/main/res/values/arrays.xml | 5 + app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/strings.xml | 26 + 25 files changed, 1052 insertions(+), 17 deletions(-) create mode 100644 app/src/main/assets/prefix-china-apps.txt create mode 100644 app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/configoverride/ConfigOverrideActivity.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/configoverride/PerAppProxyActivity.kt create mode 100644 app/src/main/res/layout/activity_config_override.xml create mode 100644 app/src/main/res/layout/activity_per_app_proxy.xml create mode 100644 app/src/main/res/layout/dialog_progress.xml create mode 100644 app/src/main/res/layout/view_app_list_item.xml create mode 100644 app/src/main/res/menu/per_app_menu.xml create mode 100644 app/src/main/res/values-night/colors.xml diff --git a/app/build.gradle b/app/build.gradle index e39187b..d250891 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,6 +98,17 @@ dependencies { implementation 'com.microsoft.appcenter:appcenter-analytics:5.0.2' implementation 'com.microsoft.appcenter:appcenter-crashes:5.0.2' implementation 'com.microsoft.appcenter:appcenter-distribute:5.0.2' + + implementation('org.smali:dexlib2:2.5.2') { + exclude group: 'com.google.guava', module: 'guava' + } + implementation('com.google.guava:guava:32.1.1-android') + // ref: https://github.com/google/guava/releases/tag/v32.1.0#user-content-duplicate-ListenableFuture + modules { + module("com.google.guava:listenablefuture") { + replacedBy("com.google.guava:guava", "listenablefuture is part of guava") + } + } } if (getProps("APPCENTER_TOKEN") != "") { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 483cc14..2c9807b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -78,6 +78,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/assets/prefix-china-apps.txt b/app/src/main/assets/prefix-china-apps.txt new file mode 100644 index 0000000..487dc03 --- /dev/null +++ b/app/src/main/assets/prefix-china-apps.txt @@ -0,0 +1,35 @@ +com.tencent +com.alibaba +com.umeng +com.qihoo +com.ali +com.alipay +com.amap +com.sina +com.weibo +com.vivo +com.xiaomi +com.huawei +com.taobao +com.secneo +s.h.e.l.l +com.stub +com.kiwisec +com.secshell +com.wrapper +cn.securitystack +com.mogosec +com.secoen +com.netease +com.mx +com.qq.e +com.baidu +com.bytedance +com.bugly +com.miui +com.oppo +com.coloros +com.iqoo +com.meizu +com.gionee +cn.nubia \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/Application.kt b/app/src/main/java/io/nekohasekai/sfa/Application.kt index d976a1b..2f9cd6f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/Application.kt +++ b/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -3,10 +3,13 @@ package io.nekohasekai.sfa import android.app.Application import android.app.NotificationManager import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.net.ConnectivityManager import android.os.PowerManager import androidx.core.content.getSystemService import go.Seq +import io.nekohasekai.sfa.bg.AppChangeReceiver import io.nekohasekai.sfa.bg.UpdateProfileWork import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -29,6 +32,11 @@ class Application : Application() { GlobalScope.launch(Dispatchers.IO) { UpdateProfileWork.reconfigureUpdater() } + + registerReceiver(AppChangeReceiver(), IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addDataScheme("package") + }) } companion object { diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt new file mode 100644 index 0000000..fc4d814 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt @@ -0,0 +1,49 @@ +package io.nekohasekai.sfa.bg + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.ui.configoverride.PerAppProxyActivity +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class AppChangeReceiver : BroadcastReceiver() { + + companion object { + private const val TAG = "AppChangeReceiver" + } + + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "onReceive: ${intent.action}") + checkUpdate(context, intent) + } + + private fun checkUpdate(context: Context, intent: Intent) { + if (!Settings.perAppProxyEnabled) { + Log.d(TAG, "per app proxy disabled") + return + } + val perAppProxyUpdateOnChange = Settings.perAppProxyUpdateOnChange + if (perAppProxyUpdateOnChange != Settings.PER_APP_PROXY_DISABLED) { + Log.d(TAG, "update on change disabled") + return + } + val packageName = intent.dataString?.substringAfter("package:") + if (packageName.isNullOrBlank()) { + Log.d(TAG, "missing package name in intent") + return + } + val isChinaApp = PerAppProxyActivity.scanChinaApps(listOf(packageName)).isNotEmpty() + Log.d(TAG, "scan china app result for $packageName: $isChinaApp") + if ((perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_INCLUDE) xor !isChinaApp) { + Settings.perAppProxyList = Settings.perAppProxyList + packageName + Log.d(TAG, "added to list") + } else { + Settings.perAppProxyList = Settings.perAppProxyList - packageName + Log.d(TAG, "removed from list") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt index d1d79e6..717c4f1 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt @@ -39,12 +39,14 @@ class BoxService( private var initializeOnce = false private fun initialize() { if (initializeOnce) return - val baseDir = Application.application.getExternalFilesDir(null) ?: return + val baseDir = Application.application.filesDir baseDir.mkdirs() + val workingDir = Application.application.getExternalFilesDir(null) ?: return + workingDir.mkdirs() val tempDir = Application.application.cacheDir tempDir.mkdirs() - Libbox.setup(baseDir.path, baseDir.path, tempDir.path, false) - Libbox.redirectStderr(File(baseDir, "stderr.log").path) + Libbox.setup(baseDir.path, workingDir.path, tempDir.path, false) + Libbox.redirectStderr(File(workingDir, "stderr.log").path) initializeOnce = true return } @@ -65,6 +67,14 @@ class BoxService( ) ) } + + fun reload() { + Application.application.sendBroadcast( + Intent(Action.SERVICE_RELOAD).setPackage( + Application.application.packageName + ) + ) + } } var fileDescriptor: ParcelFileDescriptor? = null @@ -82,6 +92,9 @@ class BoxService( Action.SERVICE_CLOSE -> { stopService() } + Action.SERVICE_RELOAD -> { + serviceReload() + } } } } @@ -94,7 +107,6 @@ class BoxService( } private suspend fun startService() { - initialize() try { val selectedProfileId = Settings.selectedProfile if (selectedProfileId == -1L) { @@ -224,6 +236,7 @@ class BoxService( if (!receiverRegistered) { service.registerReceiver(receiver, IntentFilter().apply { addAction(Action.SERVICE_CLOSE) + addAction(Action.SERVICE_RELOAD) }) receiverRegistered = true } @@ -231,6 +244,7 @@ class BoxService( notification.show() GlobalScope.launch(Dispatchers.IO) { Settings.startedByUser = true + initialize() try { startCommandServer() } catch (e: Exception) { diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt index 1cf2ba1..5be1522 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt @@ -1,10 +1,12 @@ package io.nekohasekai.sfa.bg import android.content.Intent +import android.content.pm.PackageManager.NameNotFoundException import android.net.ProxyInfo import android.net.VpnService import android.os.Build import io.nekohasekai.libbox.TunOptions +import io.nekohasekai.sfa.database.Settings class VPNService : VpnService(), PlatformInterfaceWrapper { @@ -80,17 +82,43 @@ class VPNService : VpnService(), PlatformInterfaceWrapper { builder.addRoute("::", 0) } - val includePackage = options.includePackage - if (includePackage.hasNext()) { - while (includePackage.hasNext()) { - builder.addAllowedApplication(includePackage.next()) + if (Settings.perAppProxyEnabled) { + val appList = Settings.perAppProxyList + if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) { + appList.forEach { + try { + builder.addAllowedApplication(it) + } catch (_: NameNotFoundException) { + } + } + builder.addAllowedApplication(packageName) + } else { + appList.forEach { + try { + builder.addDisallowedApplication(it) + } catch (_: NameNotFoundException) { + } + } + } + } else { + val includePackage = options.includePackage + if (includePackage.hasNext()) { + while (includePackage.hasNext()) { + try { + builder.addAllowedApplication(includePackage.next()) + } catch (_: NameNotFoundException) { + } + } } - } - val excludePackage = options.excludePackage - if (excludePackage.hasNext()) { - while (excludePackage.hasNext()) { - builder.addDisallowedApplication(excludePackage.next()) + val excludePackage = options.excludePackage + if (excludePackage.hasNext()) { + while (excludePackage.hasNext()) { + try { + builder.addDisallowedApplication(excludePackage.next()) + } catch (_: NameNotFoundException) { + } + } } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt b/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt index 5a65152..016d513 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt @@ -3,4 +3,5 @@ package io.nekohasekai.sfa.constant object Action { const val SERVICE = "io.nekohasekai.sfa.SERVICE" const val SERVICE_CLOSE = "io.nekohasekai.sfa.SERVICE_CLOSE" + const val SERVICE_RELOAD = "io.nekohasekai.sfa.SERVICE_RELOAD" } \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt b/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt new file mode 100644 index 0000000..095cec0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt @@ -0,0 +1,21 @@ +package io.nekohasekai.sfa.constant + +import io.nekohasekai.sfa.database.Settings + +enum class PerAppProxyUpdateType { + Disabled, Select, Deselect; + + fun value() = when (this) { + Disabled -> Settings.PER_APP_PROXY_DISABLED + Select -> Settings.PER_APP_PROXY_INCLUDE + Deselect -> Settings.PER_APP_PROXY_EXCLUDE + } + companion object { + fun valueOf(value: Int): PerAppProxyUpdateType = when (value) { + Settings.PER_APP_PROXY_DISABLED -> Disabled + Settings.PER_APP_PROXY_INCLUDE -> Select + Settings.PER_APP_PROXY_EXCLUDE -> Deselect + else -> throw IllegalArgumentException() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 3c1c7d9..f843e66 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -8,6 +8,11 @@ object SettingsKey { const val CHECK_UPDATE_ENABLED = "check_update_enabled" const val DISABLE_MEMORY_LIMIT = "disable_memory_limit" + const val PER_APP_PROXY_ENABLED = "per_app_proxy_enabled" + const val PER_APP_PROXY_MODE = "per_app_proxy_mode" + const val PER_APP_PROXY_LIST = "per_app_proxy_list" + const val PER_APP_PROXY_UPDATE_ON_CHANGE = "per_app_proxy_update_on_change" + // cache const val STARTED_BY_USER = "started_by_user" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index 2d8389d..da2b15a 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -13,6 +13,7 @@ import io.nekohasekai.sfa.ktx.boolean import io.nekohasekai.sfa.ktx.int import io.nekohasekai.sfa.ktx.long import io.nekohasekai.sfa.ktx.string +import io.nekohasekai.sfa.ktx.stringSet import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.json.JSONObject @@ -44,6 +45,16 @@ object Settings { var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { true } var disableMemoryLimit by dataStore.boolean(SettingsKey.DISABLE_MEMORY_LIMIT) + + const val PER_APP_PROXY_DISABLED = 0 + const val PER_APP_PROXY_EXCLUDE = 1 + const val PER_APP_PROXY_INCLUDE = 2 + + var perAppProxyEnabled by dataStore.boolean(SettingsKey.PER_APP_PROXY_ENABLED) { false } + var perAppProxyMode by dataStore.int(SettingsKey.PER_APP_PROXY_MODE) { PER_APP_PROXY_EXCLUDE } + var perAppProxyList by dataStore.stringSet(SettingsKey.PER_APP_PROXY_LIST) { emptySet() } + var perAppProxyUpdateOnChange by dataStore.int(SettingsKey.PER_APP_PROXY_UPDATE_ON_CHANGE) { PER_APP_PROXY_DISABLED } + fun serviceClass(): Class<*> { return when (serviceMode) { ServiceMode.VPN -> VPNService::class.java diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt index e6806bb..f38effe 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt @@ -53,6 +53,11 @@ fun PreferenceDataStore.stringToLong( getString(key, "$default")?.toLongOrNull() ?: default }, { key, value -> putString(key, "$value") }) +fun PreferenceDataStore.stringSet( + name: String, + defaultValue: () -> Set = { emptySet() } +) = PreferenceProxy(name, defaultValue, ::getStringSet, ::putStringSet) + class PreferenceProxy( val name: String, val defaultValue: () -> T, diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/configoverride/ConfigOverrideActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/configoverride/ConfigOverrideActivity.kt new file mode 100644 index 0000000..f41b39f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/configoverride/ConfigOverrideActivity.kt @@ -0,0 +1,59 @@ +package io.nekohasekai.sfa.ui.configoverride + +import android.content.Intent +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.constant.PerAppProxyUpdateType +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.databinding.ActivityConfigOverrideBinding +import io.nekohasekai.sfa.ktx.addTextChangedListener +import io.nekohasekai.sfa.ktx.setSimpleItems +import io.nekohasekai.sfa.ktx.text +import io.nekohasekai.sfa.ui.shared.AbstractActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ConfigOverrideActivity : AbstractActivity() { + + private lateinit var binding: ActivityConfigOverrideBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTitle(R.string.title_config_override) + binding = ActivityConfigOverrideBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + binding.switchPerAppProxy.isChecked = Settings.perAppProxyEnabled + binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked -> + Settings.perAppProxyEnabled = isChecked + binding.perAppProxyUpdateOnChange.isEnabled = binding.switchPerAppProxy.isChecked + binding.configureAppListButton.isEnabled = isChecked + } + binding.perAppProxyUpdateOnChange.isEnabled = binding.switchPerAppProxy.isChecked + binding.configureAppListButton.isEnabled = binding.switchPerAppProxy.isChecked + + binding.perAppProxyUpdateOnChange.addTextChangedListener { + lifecycleScope.launch(Dispatchers.IO) { + Settings.perAppProxyUpdateOnChange = PerAppProxyUpdateType.valueOf(it).value() + } + } + + binding.configureAppListButton.setOnClickListener { + startActivity(Intent(this, PerAppProxyActivity::class.java)) + } + lifecycleScope.launch(Dispatchers.IO) { + reloadSettings() + } + } + + private suspend fun reloadSettings() { + val perAppUpdateOnChange = Settings.perAppProxyUpdateOnChange + withContext(Dispatchers.Main) { + binding.perAppProxyUpdateOnChange.text = PerAppProxyUpdateType.valueOf(perAppUpdateOnChange).name + binding.perAppProxyUpdateOnChange.setSimpleItems(R.array.per_app_proxy_update_on_change_value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/configoverride/PerAppProxyActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/configoverride/PerAppProxyActivity.kt new file mode 100644 index 0000000..941b3ac --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/configoverride/PerAppProxyActivity.kt @@ -0,0 +1,459 @@ +package io.nekohasekai.sfa.ui.configoverride + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.ClipData +import android.content.ClipboardManager +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.PackageInfoFlags +import android.graphics.drawable.Drawable +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.getSystemService +import androidx.core.view.isGone +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.databinding.ActivityPerAppProxyBinding +import io.nekohasekai.sfa.databinding.ViewAppListItemBinding +import io.nekohasekai.sfa.ktx.errorDialogBuilder +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 + +class PerAppProxyActivity : AbstractActivity() { + + + private lateinit var binding: ActivityPerAppProxyBinding + private lateinit var adapter: AppListAdapter + + private val perAppProxyList = mutableSetOf() + private val appList = mutableListOf() + + private var hideSystem = false + private val filteredAppList = mutableListOf() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTitle(R.string.title_per_app_proxy) + binding = ActivityPerAppProxyBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + val proxyMode = Settings.perAppProxyMode + if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { + binding.radioPerAppInclude.isChecked = true + } else { + binding.radioPerAppExclude.isChecked = true + } + binding.radioGroupPerAppMode.setOnCheckedChangeListener { _, checkedId -> + if (checkedId == R.id.radio_per_app_include) { + Settings.perAppProxyMode = Settings.PER_APP_PROXY_INCLUDE + } else { + Settings.perAppProxyMode = Settings.PER_APP_PROXY_EXCLUDE + } + } + + perAppProxyList.addAll(Settings.perAppProxyList.toMutableSet()) + adapter = AppListAdapter(filteredAppList) { + val item = filteredAppList[it] + if (item.selected) { + perAppProxyList.add(item.packageName) + } else { + perAppProxyList.remove(item.packageName) + } + Settings.perAppProxyList = perAppProxyList + } + binding.recyclerViewAppList.adapter = adapter + loadAppList() + } + + @SuppressLint("NotifyDataSetChanged") + private fun loadAppList() { + binding.recyclerViewAppList.isGone = true + binding.layoutProgress.isGone = false + + lifecycleScope.launch { + val list = withContext(Dispatchers.IO) { + val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES + } else { + @Suppress("DEPRECATION") + PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES + } + val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledPackages(PackageInfoFlags.of(flag.toLong())) + } else { + @Suppress("DEPRECATION") + packageManager.getInstalledPackages(flag) + } + val list = mutableListOf() + installedPackages.forEach { + if (it.packageName != packageName && + (it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true + || it.packageName == "android") + ) { + list.add( + AppItem( + it.packageName, + it.applicationInfo.loadLabel(packageManager).toString(), + it.applicationInfo.loadIcon(packageManager), + it.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == 1, + perAppProxyList.contains(it.packageName) + ) + ) + } + } + list.sortedWith(compareBy({ !it.selected }, { it.name })) + } + appList.clear() + appList.addAll(list) + + perAppProxyList.toSet().forEach { + if (appList.find { app -> app.packageName == it } == null) { + perAppProxyList.remove(it) + } + } + Settings.perAppProxyList = perAppProxyList + + filteredAppList.clear() + if (hideSystem) { + filteredAppList.addAll(appList.filter { !it.isSystemApp }) + } else { + filteredAppList.addAll(appList) + } + adapter.notifyDataSetChanged() + + binding.recyclerViewAppList.scrollToPosition(0) + binding.layoutProgress.isGone = true + binding.recyclerViewAppList.isGone = false + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.per_app_menu, menu) + return super.onCreateOptionsMenu(menu) + } + + @SuppressLint("NotifyDataSetChanged") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_hide_system -> { + hideSystem = !hideSystem + filteredAppList.clear() + if (hideSystem) { + filteredAppList.addAll(appList.filter { !it.isSystemApp }) + item.setTitle(R.string.menu_show_system) + } else { + filteredAppList.addAll(appList) + item.setTitle(R.string.menu_hide_system) + } + adapter.notifyDataSetChanged() + return true + } + + R.id.action_import -> { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.menu_import_from_clipboard) + .setMessage(R.string.message_import_from_clipboard) + .setPositiveButton(android.R.string.ok) { _, _ -> + importFromClipboard() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + return true + } + + R.id.action_export -> { + exportToClipboard() + return true + } + + R.id.action_scan_china_apps -> { + scanChinaApps() + return true + } + } + return super.onOptionsItemSelected(item) + } + + private fun importFromClipboard() { + val clipboardManager = getSystemService()!! + if (!clipboardManager.hasPrimaryClip()) { + Toast.makeText(this, R.string.toast_clipboard_empty, Toast.LENGTH_SHORT).show() + return + } + val content = clipboardManager.primaryClip?.getItemAt(0)?.text?.split("\n")?.distinct() + if (content.isNullOrEmpty()) { + Toast.makeText(this, R.string.toast_clipboard_empty, Toast.LENGTH_SHORT).show() + return + } + perAppProxyList.clear() + perAppProxyList.addAll(content) + loadAppList() + Toast.makeText(this, R.string.toast_imported_from_clipboard, Toast.LENGTH_SHORT).show() + } + + private fun exportToClipboard() { + if (perAppProxyList.isEmpty()) { + Toast.makeText(this, R.string.toast_app_list_empty, Toast.LENGTH_SHORT).show() + return + } + val content = perAppProxyList.joinToString("\n") + val clipboardManager = getSystemService()!! + val clip = ClipData.newPlainText(null, content) + clipboardManager.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText(this, R.string.toast_copied_to_clipboard, Toast.LENGTH_SHORT).show() + } + } + + private fun scanChinaApps() { + val progressDialog = MaterialAlertDialogBuilder(this) + .setView(R.layout.dialog_progress) + .setCancelable(false) + .create() + progressDialog.setOnShowListener { + val dialog = it as Dialog + dialog.findViewById(R.id.text_message).setText(R.string.message_scanning) + } + progressDialog.show() + + lifecycleScope.launch { + val scanResult = withContext(Dispatchers.IO) { + val appNameMap = mutableMapOf() + appList.forEach { + appNameMap[it.packageName] = it.name + } + val foundChinaApps = mutableMapOf() + scanChinaApps(appList.map { it.packageName }).forEach {packageName -> + foundChinaApps[packageName] = appNameMap[packageName] ?: "Unknown" + } + foundChinaApps + } + progressDialog.dismiss() + + if (scanResult.isEmpty()) { + MaterialAlertDialogBuilder(this@PerAppProxyActivity) + .setTitle(R.string.message) + .setMessage(R.string.message_scan_app_no_apps_found) + .setPositiveButton(android.R.string.ok, null) + .show() + return@launch + } + + val dialogContent = getString(R.string.message_scan_app_found) + "\n\n" + + scanResult.entries.joinToString("\n") { + "${it.value} (${it.key})" + } + MaterialAlertDialogBuilder(this@PerAppProxyActivity) + .setTitle(R.string.title_scan_result) + .setMessage(dialogContent) + .setPositiveButton(R.string.action_select) { dialog, _ -> + perAppProxyList.addAll(scanResult.keys) + Settings.perAppProxyList = perAppProxyList + loadAppList() + dialog.dismiss() + } + .setNegativeButton(R.string.action_deselect) { dialog, _ -> + perAppProxyList.removeAll(scanResult.keys) + Settings.perAppProxyList = perAppProxyList + loadAppList() + dialog.dismiss() + } + .setNeutralButton(android.R.string.cancel, null) + .show() + } + } + + companion object { + private const val TAG = "PerAppProxyActivity" + + fun scanChinaApps(packageNameList: List): List { + val chinaAppPrefixList = try { + Application.application.assets.open("prefix-china-apps.txt").reader().readLines() + } catch (e: Exception) { + Log.w( + TAG, + "scan china apps: failed to load prefix from assets, error = ${e.message}" + ) + null + } + if (chinaAppPrefixList.isNullOrEmpty()) { + return listOf() + } + val chinaAppRegex = + ("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex() + val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.MATCH_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or + PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or + PackageManager.GET_PROVIDERS + } else { + @Suppress("DEPRECATION") + PackageManager.GET_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or + PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or + PackageManager.GET_PROVIDERS + } + val foundChinaApps = mutableListOf() + for (packageName in packageNameList) { + if (packageName == "android") { + continue + } + if (packageName.matches(chinaAppRegex)) { + foundChinaApps.add(packageName) + continue + } + + try { + val packageInfo = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Application.packageManager.getPackageInfo( + packageName, + PackageInfoFlags.of(packageManagerFlags.toLong()) + ) + } else { + @Suppress("DEPRECATION") + Application.packageManager.getPackageInfo( + packageName, + packageManagerFlags + ) + } + + if (packageInfo.services?.find { it.name.matches(chinaAppRegex) } != null + || packageInfo.activities?.find { it.name.matches(chinaAppRegex) } != null + || packageInfo.receivers?.find { it.name.matches(chinaAppRegex) } != null + || packageInfo.providers?.find { it.name.matches(chinaAppRegex) } != null) { + foundChinaApps.add(packageName) + continue + } + val packageFile = ZipFile(File(packageInfo.applicationInfo.publicSourceDir)) + for (packageEntry in packageFile.entries()) { + if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith( + ".dex" + )) + ) { + continue + } + if (packageEntry.size > 10000000) { + foundChinaApps.add(packageName) + break + } + val input = packageFile.getInputStream(packageEntry).buffered() + val dexFile = try { + DexBackedDexFile.fromInputStream(null, input) + } catch (e: Exception) { + foundChinaApps.add(packageName) + Log.w( + TAG, + "scan china apps: failed to read dex file, error = ${e.message}" + ) + break + } + for (clazz in dexFile.classes) { + val clazzName = clazz.type.substring(1, clazz.type.length - 1) + .replace("/", ".") + .replace("$", ".") + if (clazzName.matches(chinaAppRegex)) { + foundChinaApps.add(packageName) + break + } + } + } + packageFile.close() + } catch (e: Exception) { + Log.w( + TAG, + "scan china apps: something went wrong when scanning package ${packageName}, error = ${e.message}" + ) + continue + } + System.gc() + } + return foundChinaApps + } + } + + data class AppItem( + val packageName: String, + val name: String, + val icon: Drawable, + val isSystemApp: Boolean, + var selected: Boolean + ) + + class AppListAdapter( + private val list: List, + private val onSelectChange: (Int) -> Unit + ) : + RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppListViewHolder { + val binding = + ViewAppListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return AppListViewHolder(binding) + } + + override fun getItemCount(): Int { + return list.size + } + + override fun onBindViewHolder(holder: AppListViewHolder, position: Int) { + val item = list[position] + holder.bind(item) + holder.itemView.setOnClickListener { + item.selected = !item.selected + onSelectChange.invoke(position) + notifyItemChanged(position, "check") + } + } + + override fun onBindViewHolder( + holder: AppListViewHolder, + position: Int, + payloads: MutableList + ) { + if (payloads.contains("check")) { + holder.bindCheck(list[position]) + } else { + super.onBindViewHolder(holder, position, payloads) + } + } + + } + + class AppListViewHolder( + private val binding: ViewAppListItemBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: AppItem) { + binding.imageAppIcon.setImageDrawable(item.icon) + binding.textAppName.text = item.name + binding.textAppPackageName.text = item.packageName + binding.checkboxAppSelected.isChecked = item.selected + } + + fun bindCheck(item: AppItem) { + binding.checkboxAppSelected.isChecked = item.selected + } + } +} \ 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 15156b2..a797668 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.configoverride.ConfigOverrideActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -58,9 +59,6 @@ class SettingsFragment : Fragment() { reloadSettings() } } - lifecycleScope.launch(Dispatchers.IO) { - reloadSettings() - } binding.appCenterEnabled.addTextChangedListener { lifecycleScope.launch(Dispatchers.IO) { val allowed = EnabledType.valueOf(it).boolValue @@ -105,12 +103,18 @@ class SettingsFragment : Fragment() { ) ) } + binding.configureOverridesButton.setOnClickListener { + startActivity(Intent(requireContext(), ConfigOverrideActivity::class.java)) + } binding.communityButton.setOnClickListener { it.context.launchCustomTab("https://community.sagernet.org/") } binding.documentationButton.setOnClickListener { it.context.launchCustomTab("http://sing-box.sagernet.org/installation/clients/sfa/") } + lifecycleScope.launch(Dispatchers.IO) { + reloadSettings() + } } private suspend fun reloadSettings() { diff --git a/app/src/main/res/layout/activity_config_override.xml b/app/src/main/res/layout/activity_config_override.xml new file mode 100644 index 0000000..f5e1b52 --- /dev/null +++ b/app/src/main/res/layout/activity_config_override.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +