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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_per_app_proxy.xml b/app/src/main/res/layout/activity_per_app_proxy.xml
new file mode 100644
index 0000000..17a947e
--- /dev/null
+++ b/app/src/main/res/layout/activity_per_app_proxy.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_progress.xml b/app/src/main/res/layout/dialog_progress.xml
new file mode 100644
index 0000000..97cc09b
--- /dev/null
+++ b/app/src/main/res/layout/dialog_progress.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml
index 92227eb..169657e 100644
--- a/app/src/main/res/layout/fragment_settings.xml
+++ b/app/src/main/res/layout/fragment_settings.xml
@@ -8,7 +8,7 @@
@@ -232,6 +232,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/per_app_menu.xml b/app/src/main/res/menu/per_app_menu.xml
new file mode 100644
index 0000000..71a918d
--- /dev/null
+++ b/app/src/main/res/menu/per_app_menu.xml
@@ -0,0 +1,20 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..0675105
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #565656
+
\ No newline at end of file
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index bf4fe1b..276c633 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -12,4 +12,9 @@
- @string/enabled
- @string/disabled
+
+ - @string/disabled
+ - @string/action_select
+ - @string/action_deselect
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 8e0e01b..b004f45 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -12,4 +12,5 @@
#00a6b2
#ececec
+ #cfcfcf
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 846eec1..04fd658 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -94,4 +94,30 @@
Import remote profile
Are you sure to import remote configuration %s? You will connect to %s to download the configuration.
+ Config Override
+ Override configuration contents.
+ Configure
+ Per-app Proxy
+ Override include_package and exclude_package in the configuration.
+ Do not proxy selected apps
+ Proxy only selected apps
+ App icon
+ Hide system apps
+ Show system apps
+ Scan China apps
+ Import from clipboard
+ Export to clipboard
+ Clipboard is empty
+ App list is empty
+ Copied to clipboard
+ Imported from clipboard
+ Importing app list from clipboard will overwrite your current list. Are you sure to continue?
+ Scanning… Please wait
+ Error scanning apps
+ No matching apps found
+ Found the following apps, please choose the action you want.
+ Scan Result
+ Select
+ Deselect
+ Update on App Installed/Updated
\ No newline at end of file