Add config override and per-app proxy feature

This commit is contained in:
iKirby 2023-07-27 22:17:25 +08:00 committed by 世界
parent af0046ce1c
commit 9575764f40
No known key found for this signature in database
GPG key ID: CD109927C34A63C4
25 changed files with 1052 additions and 17 deletions

View file

@ -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") != "") {

View file

@ -78,6 +78,12 @@
<activity
android:name="io.nekohasekai.sfa.ui.profile.EditProfileContentActivity"
android:exported="false" />
<activity
android:name="io.nekohasekai.sfa.ui.configoverride.ConfigOverrideActivity"
android:exported="false" />
<activity
android:name="io.nekohasekai.sfa.ui.configoverride.PerAppProxyActivity"
android:exported="false" />
<service
android:name=".bg.TileService"
@ -115,6 +121,10 @@
</intent-filter>
</receiver>
<receiver
android:name="io.nekohasekai.sfa.bg.AppChangeReceiver"
android:exported="true" />
</application>
</manifest>

View file

@ -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

View file

@ -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 {

View file

@ -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")
}
}
}

View file

@ -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) {

View file

@ -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) {
}
}
}
}
}

View file

@ -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"
}

View file

@ -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()
}
}
}

View file

@ -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"

View file

@ -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

View file

@ -53,6 +53,11 @@ fun PreferenceDataStore.stringToLong(
getString(key, "$default")?.toLongOrNull() ?: default
}, { key, value -> putString(key, "$value") })
fun PreferenceDataStore.stringSet(
name: String,
defaultValue: () -> Set<String> = { emptySet() }
) = PreferenceProxy(name, defaultValue, ::getStringSet, ::putStringSet)
class PreferenceProxy<T>(
val name: String,
val defaultValue: () -> T,

View file

@ -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)
}
}
}

View file

@ -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<String>()
private val appList = mutableListOf<AppItem>()
private var hideSystem = false
private val filteredAppList = mutableListOf<AppItem>()
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<AppItem>()
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<ClipboardManager>()!!
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<ClipboardManager>()!!
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<TextView>(R.id.text_message).setText(R.string.message_scanning)
}
progressDialog.show()
lifecycleScope.launch {
val scanResult = withContext(Dispatchers.IO) {
val appNameMap = mutableMapOf<String, String>()
appList.forEach {
appNameMap[it.packageName] = it.name
}
val foundChinaApps = mutableMapOf<String, String>()
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<String>): List<String> {
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<String>()
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<AppItem>,
private val onSelectChange: (Int) -> Unit
) :
RecyclerView.Adapter<AppListViewHolder>() {
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<Any>
) {
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
}
}
}

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.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() {

View file

@ -0,0 +1,90 @@
<?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"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.card.MaterialCardView
android:id="@+id/perAppProxyCard"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchPerAppProxy"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/title_per_app_proxy"
android:textAppearance="?attr/textAppearanceTitleLarge" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/per_app_proxy_description" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/perAppProxyUpdateOnChange"
style="@style/Widget.Material3.TextInputLayout.FilledBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/per_app_proxy_update_on_change">
<AutoCompleteTextView
app:simpleItems="@array/per_app_proxy_update_on_change_value"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:text="@string/disabled" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical|end"
android:orientation="horizontal">
<Button
android:id="@+id/configureAppListButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/config_override_configure" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<RadioGroup
android:id="@+id/radio_group_per_app_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp">
<RadioButton
android:id="@+id/radio_per_app_exclude"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/per_app_proxy_mode_exclude" />
<RadioButton
android:id="@+id/radio_per_app_include"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/per_app_proxy_mode_include" />
</RadioGroup>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/divider" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view_app_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:scrollbars="vertical"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
<RelativeLayout
android:id="@+id/layout_progress"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true" />
</RelativeLayout>
</LinearLayout>

View file

@ -0,0 +1,19 @@
<?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="wrap_content"
android:padding="16dp">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/text_message"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="16dp"
android:layout_weight="1" />
</LinearLayout>

View file

@ -8,7 +8,7 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
@ -232,6 +232,55 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/configOverrideCard"
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_config_override"
android:textAppearance="?attr/textAppearanceTitleLarge">
</TextView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/config_override_description" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical|end"
android:orientation="horizontal">
<Button
android:id="@+id/configureOverridesButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/config_override_configure" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/aboutCard"
style="?attr/materialCardViewElevatedStyle"

View file

@ -0,0 +1,46 @@
<?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="wrap_content"
android:background="?selectableItemBackground"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp">
<ImageView
android:id="@+id/image_app_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/content_description_app_icon" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/text_app_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textColor="?android:textColorPrimary"
android:textSize="16sp" />
<TextView
android:id="@+id/text_app_package_name"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<CheckBox
android:id="@+id/checkbox_app_selected"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:focusable="false" />
</LinearLayout>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_hide_system"
android:title="@string/menu_hide_system" />
<item
android:id="@+id/action_scan_china_apps"
android:title="@string/menu_scan_china_apps" />
<item
android:id="@+id/action_import"
android:title="@string/menu_import_from_clipboard" />
<item
android:id="@+id/action_export"
android:title="@string/menu_export_to_clipboard" />
</menu>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="divider">#565656</color>
</resources>

View file

@ -12,4 +12,9 @@
<item>@string/enabled</item>
<item>@string/disabled</item>
</array>
<array name="per_app_proxy_update_on_change_value">
<item>@string/disabled</item>
<item>@string/action_select</item>
<item>@string/action_deselect</item>
</array>
</resources>

View file

@ -12,4 +12,5 @@
<color name="log_blue_light">#00a6b2</color>
<color name="log_white">#ececec</color>
<color name="divider">#cfcfcf</color>
</resources>

View file

@ -94,4 +94,30 @@
<string name="import_remote_profile">Import remote profile</string>
<string name="import_remote_profile_message">Are you sure to import remote configuration %s? You will connect to %s to download the configuration.</string>
<string name="title_config_override">Config Override</string>
<string name="config_override_description">Override configuration contents.</string>
<string name="config_override_configure">Configure</string>
<string name="title_per_app_proxy">Per-app Proxy</string>
<string name="per_app_proxy_description">Override include_package and exclude_package in the configuration.</string>
<string name="per_app_proxy_mode_exclude">Do not proxy selected apps</string>
<string name="per_app_proxy_mode_include">Proxy only selected apps</string>
<string name="content_description_app_icon">App icon</string>
<string name="menu_hide_system">Hide system apps</string>
<string name="menu_show_system">Show system apps</string>
<string name="menu_scan_china_apps">Scan China apps</string>
<string name="menu_import_from_clipboard">Import from clipboard</string>
<string name="menu_export_to_clipboard">Export to clipboard</string>
<string name="toast_clipboard_empty">Clipboard is empty</string>
<string name="toast_app_list_empty">App list is empty</string>
<string name="toast_copied_to_clipboard">Copied to clipboard</string>
<string name="toast_imported_from_clipboard">Imported from clipboard</string>
<string name="message_import_from_clipboard">Importing app list from clipboard will overwrite your current list. Are you sure to continue?</string>
<string name="message_scanning">Scanning… Please wait</string>
<string name="message_scan_app_error">Error scanning apps</string>
<string name="message_scan_app_no_apps_found">No matching apps found</string>
<string name="message_scan_app_found">Found the following apps, please choose the action you want.</string>
<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 App Installed/Updated</string>
</resources>