Add config override and per-app proxy feature
This commit is contained in:
@@ -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 {
|
||||
|
||||
49
app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt
Normal file
49
app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user