Add android per-app proxy
This commit is contained in:
@@ -1,10 +1,21 @@
|
|||||||
package com.hiddify.hiddify
|
package com.hiddify.hiddify
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.drawable.VectorDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Base64
|
||||||
import androidx.annotation.NonNull
|
import androidx.annotation.NonNull
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import com.hiddify.hiddify.Application.Companion.packageManager
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
@@ -12,6 +23,10 @@ import io.flutter.plugin.common.MethodCall
|
|||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.PluginRegistry
|
import io.flutter.plugin.common.PluginRegistry
|
||||||
import io.flutter.plugin.common.StandardMethodCodec
|
import io.flutter.plugin.common.StandardMethodCodec
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
|
||||||
class PlatformSettingsHandler : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware,
|
class PlatformSettingsHandler : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware,
|
||||||
PluginRegistry.ActivityResultListener {
|
PluginRegistry.ActivityResultListener {
|
||||||
@@ -23,10 +38,13 @@ class PlatformSettingsHandler : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
const val channelName = "com.hiddify.app/platform.settings"
|
const val channelName = "com.hiddify.app/platform.settings"
|
||||||
|
|
||||||
const val REQUEST_IGNORE_BATTERY_OPTIMIZATIONS = 44
|
const val REQUEST_IGNORE_BATTERY_OPTIMIZATIONS = 44
|
||||||
|
val gson = Gson()
|
||||||
|
|
||||||
enum class Trigger(val method: String) {
|
enum class Trigger(val method: String) {
|
||||||
IsIgnoringBatteryOptimizations("is_ignoring_battery_optimizations"),
|
IsIgnoringBatteryOptimizations("is_ignoring_battery_optimizations"),
|
||||||
RequestIgnoreBatteryOptimizations("request_ignore_battery_optimizations"),
|
RequestIgnoreBatteryOptimizations("request_ignore_battery_optimizations"),
|
||||||
|
GetInstalledPackages("get_installed_packages"),
|
||||||
|
GetPackagesIcon("get_package_icon"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +89,12 @@ class PlatformSettingsHandler : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class AppItem(
|
||||||
|
@SerializedName("package-name") val packageName: String,
|
||||||
|
@SerializedName("name") val name: String,
|
||||||
|
@SerializedName("is-system-app") val isSystemApp: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
Trigger.IsIgnoringBatteryOptimizations.method -> {
|
Trigger.IsIgnoringBatteryOptimizations.method -> {
|
||||||
@@ -97,6 +121,69 @@ class PlatformSettingsHandler : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
activity?.startActivityForResult(intent, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
activity?.startActivityForResult(intent, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Trigger.GetInstalledPackages.method -> {
|
||||||
|
GlobalScope.launch {
|
||||||
|
result.runCatching {
|
||||||
|
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(
|
||||||
|
PackageManager.PackageInfoFlags.of(
|
||||||
|
flag.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
packageManager.getInstalledPackages(flag)
|
||||||
|
}
|
||||||
|
val list = mutableListOf<AppItem>()
|
||||||
|
installedPackages.forEach {
|
||||||
|
if (it.packageName != Application.application.packageName &&
|
||||||
|
(it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|
||||||
|
|| it.packageName == "android")
|
||||||
|
) {
|
||||||
|
list.add(
|
||||||
|
AppItem(
|
||||||
|
it.packageName,
|
||||||
|
it.applicationInfo.loadLabel(packageManager).toString(),
|
||||||
|
it.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.sortBy { it.name }
|
||||||
|
success(gson.toJson(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Trigger.GetPackagesIcon.method -> {
|
||||||
|
result.runCatching {
|
||||||
|
val args = call.arguments as Map<*, *>
|
||||||
|
val packageName =
|
||||||
|
args["packageName"] as String? ?: return error("provide packageName")
|
||||||
|
val drawable = packageManager.getApplicationIcon(packageName)
|
||||||
|
val bitmap = Bitmap.createBitmap(
|
||||||
|
drawable.intrinsicWidth,
|
||||||
|
drawable.intrinsicHeight,
|
||||||
|
Bitmap.Config.ARGB_8888
|
||||||
|
)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
drawable.setBounds(0, 0, canvas.width, canvas.height)
|
||||||
|
drawable.draw(canvas)
|
||||||
|
val byteArrayOutputStream = ByteArrayOutputStream()
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
|
||||||
|
val base64: String =
|
||||||
|
Base64.encodeToString(byteArrayOutputStream.toByteArray(), Base64.NO_WRAP)
|
||||||
|
success(base64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,62 @@
|
|||||||
package com.hiddify.hiddify
|
package com.hiddify.hiddify
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Base64
|
||||||
import com.hiddify.hiddify.bg.ProxyService
|
import com.hiddify.hiddify.bg.ProxyService
|
||||||
import com.hiddify.hiddify.bg.VPNService
|
import com.hiddify.hiddify.bg.VPNService
|
||||||
|
import com.hiddify.hiddify.constant.PerAppProxyMode
|
||||||
import com.hiddify.hiddify.constant.ServiceMode
|
import com.hiddify.hiddify.constant.ServiceMode
|
||||||
import com.hiddify.hiddify.constant.SettingsKey
|
import com.hiddify.hiddify.constant.SettingsKey
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.ObjectInputStream
|
||||||
|
|
||||||
|
|
||||||
object Settings {
|
object Settings {
|
||||||
|
|
||||||
const val PER_APP_PROXY_DISABLED = 0
|
|
||||||
const val PER_APP_PROXY_EXCLUDE = 1
|
|
||||||
const val PER_APP_PROXY_INCLUDE = 2
|
|
||||||
|
|
||||||
private val preferences by lazy {
|
private val preferences by lazy {
|
||||||
val context = Application.application.applicationContext
|
val context = Application.application.applicationContext
|
||||||
context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
var perAppProxyEnabled = preferences.getBoolean(SettingsKey.PER_APP_PROXY_ENABLED, false)
|
private const val LIST_IDENTIFIER = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu"
|
||||||
var perAppProxyMode = preferences.getInt(SettingsKey.PER_APP_PROXY_MODE, PER_APP_PROXY_EXCLUDE)
|
|
||||||
var perAppProxyList = preferences.getStringSet(SettingsKey.PER_APP_PROXY_LIST, emptySet())!!
|
var perAppProxyMode: String
|
||||||
var perAppProxyUpdateOnChange =
|
get() = preferences.getString(SettingsKey.PER_APP_PROXY_MODE, PerAppProxyMode.OFF)!!
|
||||||
preferences.getInt(SettingsKey.PER_APP_PROXY_UPDATE_ON_CHANGE, PER_APP_PROXY_DISABLED)
|
set(value) = preferences.edit().putString(SettingsKey.PER_APP_PROXY_MODE, value).apply()
|
||||||
|
|
||||||
|
val perAppProxyEnabled: Boolean
|
||||||
|
get() = perAppProxyMode != PerAppProxyMode.OFF
|
||||||
|
|
||||||
|
val perAppProxyList: List<String>
|
||||||
|
get() {
|
||||||
|
val stringValue = if (perAppProxyMode == PerAppProxyMode.INCLUDE) {
|
||||||
|
preferences.getString(SettingsKey.PER_APP_PROXY_INCLUDE_LIST, "")!!;
|
||||||
|
} else {
|
||||||
|
preferences.getString(SettingsKey.PER_APP_PROXY_EXCLUDE_LIST, "")!!;
|
||||||
|
}
|
||||||
|
if (!stringValue.startsWith(LIST_IDENTIFIER)) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
return decodeListString(stringValue.substring(LIST_IDENTIFIER.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeListString(listString: String): List<String> {
|
||||||
|
val stream = ObjectInputStream(ByteArrayInputStream(Base64.decode(listString, 0)))
|
||||||
|
return stream.readObject() as List<String>
|
||||||
|
}
|
||||||
|
|
||||||
var activeConfigPath: String
|
var activeConfigPath: String
|
||||||
get() = preferences.getString(SettingsKey.ACTIVE_CONFIG_PATH, "") ?: ""
|
get() = preferences.getString(SettingsKey.ACTIVE_CONFIG_PATH, "")!!
|
||||||
set(value) = preferences.edit().putString(SettingsKey.ACTIVE_CONFIG_PATH, value).apply()
|
set(value) = preferences.edit().putString(SettingsKey.ACTIVE_CONFIG_PATH, value).apply()
|
||||||
|
|
||||||
var serviceMode: String
|
var serviceMode: String
|
||||||
get() = preferences.getString(SettingsKey.SERVICE_MODE, ServiceMode.NORMAL)
|
get() = preferences.getString(SettingsKey.SERVICE_MODE, ServiceMode.NORMAL)!!
|
||||||
?: ServiceMode.NORMAL
|
|
||||||
set(value) = preferences.edit().putString(SettingsKey.SERVICE_MODE, value).apply()
|
set(value) = preferences.edit().putString(SettingsKey.SERVICE_MODE, value).apply()
|
||||||
|
|
||||||
var configOptions: String
|
var configOptions: String
|
||||||
get() = preferences.getString(SettingsKey.CONFIG_OPTIONS, "") ?: ""
|
get() = preferences.getString(SettingsKey.CONFIG_OPTIONS, "")!!
|
||||||
set(value) = preferences.edit().putString(SettingsKey.CONFIG_OPTIONS, value).apply()
|
set(value) = preferences.edit().putString(SettingsKey.CONFIG_OPTIONS, value).apply()
|
||||||
|
|
||||||
var debugMode: Boolean
|
var debugMode: Boolean
|
||||||
@@ -47,11 +68,13 @@ object Settings {
|
|||||||
|
|
||||||
var disableMemoryLimit: Boolean
|
var disableMemoryLimit: Boolean
|
||||||
get() = preferences.getBoolean(SettingsKey.DISABLE_MEMORY_LIMIT, false)
|
get() = preferences.getBoolean(SettingsKey.DISABLE_MEMORY_LIMIT, false)
|
||||||
set(value) = preferences.edit().putBoolean(SettingsKey.DISABLE_MEMORY_LIMIT, value).apply()
|
set(value) =
|
||||||
|
preferences.edit().putBoolean(SettingsKey.DISABLE_MEMORY_LIMIT, value).apply()
|
||||||
|
|
||||||
var systemProxyEnabled: Boolean
|
var systemProxyEnabled: Boolean
|
||||||
get() = preferences.getBoolean(SettingsKey.SYSTEM_PROXY_ENABLED, true)
|
get() = preferences.getBoolean(SettingsKey.SYSTEM_PROXY_ENABLED, true)
|
||||||
set(value) = preferences.edit().putBoolean(SettingsKey.SYSTEM_PROXY_ENABLED, value).apply()
|
set(value) =
|
||||||
|
preferences.edit().putBoolean(SettingsKey.SYSTEM_PROXY_ENABLED, value).apply()
|
||||||
|
|
||||||
var startedByUser: Boolean
|
var startedByUser: Boolean
|
||||||
get() = preferences.getBoolean(SettingsKey.STARTED_BY_USER, false)
|
get() = preferences.getBoolean(SettingsKey.STARTED_BY_USER, false)
|
||||||
@@ -80,7 +103,7 @@ object Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun needVPNService(): Boolean {
|
private suspend fun needVPNService(): Boolean {
|
||||||
if(enableTun) return true
|
if (enableTun) return true
|
||||||
val filePath = activeConfigPath
|
val filePath = activeConfigPath
|
||||||
if (filePath.isBlank()) return false
|
if (filePath.isBlank()) return false
|
||||||
val content = JSONObject(File(filePath).readText())
|
val content = JSONObject(File(filePath).readText())
|
||||||
|
|||||||
@@ -16,22 +16,22 @@ class AppChangeReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun checkUpdate(context: Context, intent: Intent) {
|
private fun checkUpdate(context: Context, intent: Intent) {
|
||||||
if (!Settings.perAppProxyEnabled) {
|
// if (!Settings.perAppProxyEnabled) {
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
val perAppProxyUpdateOnChange = Settings.perAppProxyUpdateOnChange
|
// val perAppProxyUpdateOnChange = Settings.perAppProxyUpdateOnChange
|
||||||
if (perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_DISABLED) {
|
// if (perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_DISABLED) {
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
val packageName = intent.dataString?.substringAfter("package:")
|
// val packageName = intent.dataString?.substringAfter("package:")
|
||||||
if (packageName.isNullOrBlank()) {
|
// if (packageName.isNullOrBlank()) {
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
if ((perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_INCLUDE)) {
|
// if ((perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_INCLUDE)) {
|
||||||
Settings.perAppProxyList = Settings.perAppProxyList + packageName
|
// Settings.perAppProxyList = Settings.perAppProxyList + packageName
|
||||||
} else {
|
// } else {
|
||||||
Settings.perAppProxyList = Settings.perAppProxyList - packageName
|
// Settings.perAppProxyList = Settings.perAppProxyList - packageName
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import android.content.pm.PackageManager.NameNotFoundException
|
|||||||
import android.net.ProxyInfo
|
import android.net.ProxyInfo
|
||||||
import android.net.VpnService
|
import android.net.VpnService
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import com.hiddify.hiddify.constant.PerAppProxyMode
|
||||||
import io.nekohasekai.libbox.TunOptions
|
import io.nekohasekai.libbox.TunOptions
|
||||||
|
|
||||||
class VPNService : VpnService(), PlatformInterfaceWrapper {
|
class VPNService : VpnService(), PlatformInterfaceWrapper {
|
||||||
@@ -87,7 +88,7 @@ class VPNService : VpnService(), PlatformInterfaceWrapper {
|
|||||||
|
|
||||||
if (Settings.perAppProxyEnabled) {
|
if (Settings.perAppProxyEnabled) {
|
||||||
val appList = Settings.perAppProxyList
|
val appList = Settings.perAppProxyList
|
||||||
if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) {
|
if (Settings.perAppProxyMode == PerAppProxyMode.INCLUDE) {
|
||||||
appList.forEach {
|
appList.forEach {
|
||||||
try {
|
try {
|
||||||
builder.addAllowedApplication(it)
|
builder.addAllowedApplication(it)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.hiddify.hiddify.constant
|
||||||
|
|
||||||
|
object PerAppProxyMode {
|
||||||
|
const val OFF = "off"
|
||||||
|
const val INCLUDE = "include"
|
||||||
|
const val EXCLUDE = "exclude"
|
||||||
|
}
|
||||||
@@ -8,10 +8,9 @@ object SettingsKey {
|
|||||||
|
|
||||||
const val CONFIG_OPTIONS = "config_options_json"
|
const val CONFIG_OPTIONS = "config_options_json"
|
||||||
|
|
||||||
const val PER_APP_PROXY_ENABLED = "per_app_proxy_enabled"
|
const val PER_APP_PROXY_MODE = "${KEY_PREFIX}per_app_proxy_mode"
|
||||||
const val PER_APP_PROXY_MODE = "per_app_proxy_mode"
|
const val PER_APP_PROXY_INCLUDE_LIST = "${KEY_PREFIX}per_app_proxy_include_list"
|
||||||
const val PER_APP_PROXY_LIST = "per_app_proxy_list"
|
const val PER_APP_PROXY_EXCLUDE_LIST = "${KEY_PREFIX}per_app_proxy_exclude_list"
|
||||||
const val PER_APP_PROXY_UPDATE_ON_CHANGE = "per_app_proxy_update_on_change"
|
|
||||||
|
|
||||||
const val DEBUG_MODE = "${KEY_PREFIX}debug_mode"
|
const val DEBUG_MODE = "${KEY_PREFIX}debug_mode"
|
||||||
const val ENABLE_TUN = "${KEY_PREFIX}enable-tun"
|
const val ENABLE_TUN = "${KEY_PREFIX}enable-tun"
|
||||||
|
|||||||
@@ -117,6 +117,20 @@
|
|||||||
"debugMode": "Debug Mode",
|
"debugMode": "Debug Mode",
|
||||||
"debugModeMsg": "Restart the app for applying this change"
|
"debugModeMsg": "Restart the app for applying this change"
|
||||||
},
|
},
|
||||||
|
"network": {
|
||||||
|
"perAppProxyPageTitle": "Per-app Proxy",
|
||||||
|
"perAppProxyModes": {
|
||||||
|
"off": "All",
|
||||||
|
"offMsg": "Proxy all apps",
|
||||||
|
"include": "Proxy",
|
||||||
|
"includeMsg": "Proxy only selected apps",
|
||||||
|
"exclude": "Bypass",
|
||||||
|
"excludeMsg": "Do not proxy selected apps"
|
||||||
|
},
|
||||||
|
"showSystemApps": "Show system apps",
|
||||||
|
"hideSystemApps": "Hide system apps",
|
||||||
|
"clearSelection": "Clear selection"
|
||||||
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"section": {
|
"section": {
|
||||||
"route": "Route Options",
|
"route": "Route Options",
|
||||||
|
|||||||
@@ -117,6 +117,20 @@
|
|||||||
"debugMode": "دیباگ مود",
|
"debugMode": "دیباگ مود",
|
||||||
"debugModeMsg": "برای اعمال این تغییر اپ را ریاستارت کنید"
|
"debugModeMsg": "برای اعمال این تغییر اپ را ریاستارت کنید"
|
||||||
},
|
},
|
||||||
|
"network": {
|
||||||
|
"perAppProxyPageTitle": "پراکسی برنامهها",
|
||||||
|
"perAppProxyModes": {
|
||||||
|
"off": "همه",
|
||||||
|
"offMsg": "همه برنامهها پراکسی میشوند",
|
||||||
|
"include": "پراکسی",
|
||||||
|
"includeMsg": "تنها برنامههای انتخاب شده پراکسی میشوند",
|
||||||
|
"exclude": "بایپس",
|
||||||
|
"excludeMsg": "همه بجز برنامههای انتخاب شده پراکسی میشوند"
|
||||||
|
},
|
||||||
|
"showSystemApps": "نمایش برنامههای سیستمی",
|
||||||
|
"hideSystemApps": "مخفی کردن برنامههای سیستمی",
|
||||||
|
"clearSelection": "حذف انتخابها"
|
||||||
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"section": {
|
"section": {
|
||||||
"route": "تنظیمات مسیریاب",
|
"route": "تنظیمات مسیریاب",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:hiddify/core/core_providers.dart';
|
import 'package:hiddify/core/core_providers.dart';
|
||||||
import 'package:hiddify/data/data_providers.dart';
|
import 'package:hiddify/data/data_providers.dart';
|
||||||
import 'package:hiddify/domain/environment.dart';
|
import 'package:hiddify/domain/environment.dart';
|
||||||
|
import 'package:hiddify/domain/singbox/singbox.dart';
|
||||||
import 'package:hiddify/utils/pref_notifier.dart';
|
import 'package:hiddify/utils/pref_notifier.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
@@ -37,6 +38,54 @@ class DebugModeNotifier extends _$DebugModeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class PerAppProxyModeNotifier extends _$PerAppProxyModeNotifier {
|
||||||
|
late final _pref = Pref(
|
||||||
|
ref.watch(sharedPreferencesProvider),
|
||||||
|
"per_app_proxy_mode",
|
||||||
|
PerAppProxyMode.off,
|
||||||
|
mapFrom: PerAppProxyMode.values.byName,
|
||||||
|
mapTo: (value) => value.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
PerAppProxyMode build() => _pref.getValue();
|
||||||
|
|
||||||
|
Future<void> update(PerAppProxyMode value) {
|
||||||
|
state = value;
|
||||||
|
return _pref.update(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class PerAppProxyList extends _$PerAppProxyList {
|
||||||
|
late final _include = Pref(
|
||||||
|
ref.watch(sharedPreferencesProvider),
|
||||||
|
"per_app_proxy_include_list",
|
||||||
|
<String>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
late final _exclude = Pref(
|
||||||
|
ref.watch(sharedPreferencesProvider),
|
||||||
|
"per_app_proxy_exclude_list",
|
||||||
|
<String>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> build() =>
|
||||||
|
ref.watch(perAppProxyModeNotifierProvider) == PerAppProxyMode.include
|
||||||
|
? _include.getValue()
|
||||||
|
: _exclude.getValue();
|
||||||
|
|
||||||
|
Future<void> update(List<String> value) {
|
||||||
|
state = value;
|
||||||
|
if (ref.read(perAppProxyModeNotifierProvider) == PerAppProxyMode.include) {
|
||||||
|
return _include.update(value);
|
||||||
|
}
|
||||||
|
return _exclude.update(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
class MarkNewProfileActive extends _$MarkNewProfileActive {
|
class MarkNewProfileActive extends _$MarkNewProfileActive {
|
||||||
late final _pref = Pref(
|
late final _pref = Pref(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ part 'mobile_routes.g.dart';
|
|||||||
path: SettingsRoute.path,
|
path: SettingsRoute.path,
|
||||||
routes: [
|
routes: [
|
||||||
TypedGoRoute<ConfigOptionsRoute>(path: ConfigOptionsRoute.path),
|
TypedGoRoute<ConfigOptionsRoute>(path: ConfigOptionsRoute.path),
|
||||||
|
TypedGoRoute<PerAppProxyRoute>(path: PerAppProxyRoute.path),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
TypedGoRoute<AboutRoute>(path: AboutRoute.path),
|
TypedGoRoute<AboutRoute>(path: AboutRoute.path),
|
||||||
@@ -84,6 +85,21 @@ class ConfigOptionsRoute extends GoRouteData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PerAppProxyRoute extends GoRouteData {
|
||||||
|
const PerAppProxyRoute();
|
||||||
|
static const path = 'per-app-proxy';
|
||||||
|
|
||||||
|
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||||
|
return const MaterialPage(
|
||||||
|
fullscreenDialog: true,
|
||||||
|
child: PerAppProxyPage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AboutRoute extends GoRouteData {
|
class AboutRoute extends GoRouteData {
|
||||||
const AboutRoute();
|
const AboutRoute();
|
||||||
static const path = 'about';
|
static const path = 'about';
|
||||||
|
|||||||
24
lib/domain/singbox/rules.dart
Normal file
24
lib/domain/singbox/rules.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:hiddify/core/prefs/locale_prefs.dart';
|
||||||
|
|
||||||
|
enum PerAppProxyMode {
|
||||||
|
off,
|
||||||
|
include,
|
||||||
|
exclude;
|
||||||
|
|
||||||
|
bool get enabled => this != off;
|
||||||
|
|
||||||
|
({String title, String message}) present(TranslationsEn t) => switch (this) {
|
||||||
|
off => (
|
||||||
|
title: t.settings.network.perAppProxyModes.off,
|
||||||
|
message: t.settings.network.perAppProxyModes.offMsg,
|
||||||
|
),
|
||||||
|
include => (
|
||||||
|
title: t.settings.network.perAppProxyModes.include,
|
||||||
|
message: t.settings.network.perAppProxyModes.includeMsg,
|
||||||
|
),
|
||||||
|
exclude => (
|
||||||
|
title: t.settings.network.perAppProxyModes.exclude,
|
||||||
|
message: t.settings.network.perAppProxyModes.excludeMsg,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@ export 'config_options.dart';
|
|||||||
export 'core_status.dart';
|
export 'core_status.dart';
|
||||||
export 'outbounds.dart';
|
export 'outbounds.dart';
|
||||||
export 'proxy_type.dart';
|
export 'proxy_type.dart';
|
||||||
|
export 'rules.dart';
|
||||||
export 'singbox_facade.dart';
|
export 'singbox_facade.dart';
|
||||||
|
|||||||
226
lib/features/settings/view/per_app_proxy_page.dart
Normal file
226
lib/features/settings/view/per_app_proxy_page.dart
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import 'package:dartx/dartx.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hiddify/core/core_providers.dart';
|
||||||
|
import 'package:hiddify/core/prefs/general_prefs.dart';
|
||||||
|
import 'package:hiddify/domain/singbox/rules.dart';
|
||||||
|
import 'package:hiddify/services/platform_settings.dart';
|
||||||
|
import 'package:hiddify/services/service_providers.dart';
|
||||||
|
import 'package:hiddify/utils/riverpod_utils.dart';
|
||||||
|
import 'package:hiddify/utils/utils.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:loggy/loggy.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:sliver_tools/sliver_tools.dart';
|
||||||
|
|
||||||
|
part 'per_app_proxy_page.g.dart';
|
||||||
|
|
||||||
|
final _logger = Loggy<AppLogger>("PerAppProxySettings");
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<List<InstalledPackageInfo>> installedPackagesInfo(
|
||||||
|
InstalledPackagesInfoRef ref,
|
||||||
|
) async {
|
||||||
|
return ref
|
||||||
|
.watch(platformSettingsProvider)
|
||||||
|
.getInstalledPackages()
|
||||||
|
.getOrElse((l) {
|
||||||
|
_logger.warning("error getting installed packages: $l");
|
||||||
|
throw l;
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<ImageProvider> packageIcon(
|
||||||
|
PackageIconRef ref,
|
||||||
|
String packageName,
|
||||||
|
) async {
|
||||||
|
ref.disposeDelay(const Duration(seconds: 10));
|
||||||
|
final bytes = await ref
|
||||||
|
.watch(platformSettingsProvider)
|
||||||
|
.getPackageIcon(packageName)
|
||||||
|
.getOrElse((l) {
|
||||||
|
_logger.warning("error getting package icon: $l");
|
||||||
|
throw l;
|
||||||
|
}).run();
|
||||||
|
return MemoryImage(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PerAppProxyPage extends HookConsumerWidget with PresLogger {
|
||||||
|
const PerAppProxyPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final t = ref.watch(translationsProvider);
|
||||||
|
final localizations = MaterialLocalizations.of(context);
|
||||||
|
|
||||||
|
final asyncPackages = ref.watch(installedPackagesInfoProvider);
|
||||||
|
final perAppProxyMode = ref.watch(perAppProxyModeNotifierProvider);
|
||||||
|
final perAppProxyList = ref.watch(perAppProxyListProvider);
|
||||||
|
|
||||||
|
final showSystemApps = useState(true);
|
||||||
|
final isSearching = useState(false);
|
||||||
|
final searchQuery = useState("");
|
||||||
|
|
||||||
|
final filteredPackages = useMemoized(
|
||||||
|
() {
|
||||||
|
if (showSystemApps.value && searchQuery.value.isBlank) {
|
||||||
|
return asyncPackages;
|
||||||
|
}
|
||||||
|
return asyncPackages.whenData(
|
||||||
|
(value) {
|
||||||
|
Iterable<InstalledPackageInfo> result = value;
|
||||||
|
if (!showSystemApps.value) {
|
||||||
|
result = result.filter((e) => !e.isSystemApp);
|
||||||
|
}
|
||||||
|
if (!searchQuery.value.isBlank) {
|
||||||
|
result = result.filter(
|
||||||
|
(e) => e.name
|
||||||
|
.toLowerCase()
|
||||||
|
.contains(searchQuery.value.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.toList();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[asyncPackages, showSystemApps.value, searchQuery.value],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: isSearching.value
|
||||||
|
? AppBar(
|
||||||
|
title: TextFormField(
|
||||||
|
onChanged: (value) => searchQuery.value = value,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration.collapsed(
|
||||||
|
hintText: "${localizations.searchFieldLabel}...",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
searchQuery.value = "";
|
||||||
|
isSearching.value = false;
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: localizations.cancelButtonLabel,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: AppBar(
|
||||||
|
title: Text(t.settings.network.perAppProxyPageTitle),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
onPressed: () => isSearching.value = true,
|
||||||
|
tooltip: localizations.searchFieldLabel,
|
||||||
|
),
|
||||||
|
PopupMenuButton(
|
||||||
|
itemBuilder: (context) {
|
||||||
|
return [
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Text(
|
||||||
|
showSystemApps.value
|
||||||
|
? t.settings.network.hideSystemApps
|
||||||
|
: t.settings.network.showSystemApps,
|
||||||
|
),
|
||||||
|
onTap: () =>
|
||||||
|
showSystemApps.value = !showSystemApps.value,
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Text(t.settings.network.clearSelection),
|
||||||
|
onTap: () => ref
|
||||||
|
.read(perAppProxyListProvider.notifier)
|
||||||
|
.update([]),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverPinnedHeader(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
...PerAppProxyMode.values.map(
|
||||||
|
(e) => RadioListTile<PerAppProxyMode>(
|
||||||
|
title: Text(e.present(t).message),
|
||||||
|
dense: true,
|
||||||
|
value: e,
|
||||||
|
groupValue: perAppProxyMode,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await ref
|
||||||
|
.read(perAppProxyModeNotifierProvider.notifier)
|
||||||
|
.update(e);
|
||||||
|
if (e == PerAppProxyMode.off && context.mounted) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
switch (filteredPackages) {
|
||||||
|
AsyncData(value: final packages) => SliverList.builder(
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final package = packages[index];
|
||||||
|
final selected =
|
||||||
|
perAppProxyList.contains(package.packageName);
|
||||||
|
return CheckboxListTile(
|
||||||
|
title: Text(
|
||||||
|
package.name,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(package.packageName),
|
||||||
|
value: selected,
|
||||||
|
onChanged: (value) async {
|
||||||
|
final List<String> newSelection;
|
||||||
|
if (selected) {
|
||||||
|
newSelection = perAppProxyList
|
||||||
|
.exceptElement(package.packageName)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
newSelection = [
|
||||||
|
...perAppProxyList,
|
||||||
|
package.packageName,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
await ref
|
||||||
|
.read(perAppProxyListProvider.notifier)
|
||||||
|
.update(newSelection);
|
||||||
|
},
|
||||||
|
secondary: SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: ref
|
||||||
|
.watch(packageIconProvider(package.packageName))
|
||||||
|
.when(
|
||||||
|
data: (data) => Image(image: data),
|
||||||
|
error: (error, _) => const Icon(Icons.error),
|
||||||
|
loading: () => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemCount: packages.length,
|
||||||
|
),
|
||||||
|
AsyncLoading() => const SliverLoadingBodyPlaceholder(),
|
||||||
|
AsyncError(:final error) =>
|
||||||
|
SliverErrorBodyPlaceholder(error.toString()),
|
||||||
|
_ => const SliverToBoxAdapter(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export 'config_options_page.dart';
|
export 'config_options_page.dart';
|
||||||
|
export 'per_app_proxy_page.dart';
|
||||||
export 'settings_page.dart';
|
export 'settings_page.dart';
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hiddify/core/core_providers.dart';
|
import 'package:hiddify/core/core_providers.dart';
|
||||||
import 'package:hiddify/core/prefs/prefs.dart';
|
import 'package:hiddify/core/prefs/prefs.dart';
|
||||||
import 'package:hiddify/core/router/routes/routes.dart';
|
import 'package:hiddify/core/router/routes/routes.dart';
|
||||||
|
import 'package:hiddify/domain/singbox/singbox.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
class AdvancedSettingTiles extends HookConsumerWidget {
|
class AdvancedSettingTiles extends HookConsumerWidget {
|
||||||
@@ -13,6 +16,7 @@ class AdvancedSettingTiles extends HookConsumerWidget {
|
|||||||
final t = ref.watch(translationsProvider);
|
final t = ref.watch(translationsProvider);
|
||||||
|
|
||||||
final debug = ref.watch(debugModeNotifierProvider);
|
final debug = ref.watch(debugModeNotifierProvider);
|
||||||
|
final perAppProxy = ref.watch(perAppProxyModeNotifierProvider).enabled;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -23,6 +27,33 @@ class AdvancedSettingTiles extends HookConsumerWidget {
|
|||||||
await const ConfigOptionsRoute().push(context);
|
await const ConfigOptionsRoute().push(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (Platform.isAndroid) ...[
|
||||||
|
ListTile(
|
||||||
|
title: Text(t.settings.network.perAppProxyPageTitle),
|
||||||
|
leading: const Icon(Icons.apps),
|
||||||
|
trailing: Switch(
|
||||||
|
value: perAppProxy,
|
||||||
|
onChanged: (value) async {
|
||||||
|
final newMode =
|
||||||
|
perAppProxy ? PerAppProxyMode.off : PerAppProxyMode.exclude;
|
||||||
|
await ref
|
||||||
|
.read(perAppProxyModeNotifierProvider.notifier)
|
||||||
|
.update(newMode);
|
||||||
|
if (!perAppProxy && context.mounted) {
|
||||||
|
await const PerAppProxyRoute().push(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
if (!perAppProxy) {
|
||||||
|
await ref
|
||||||
|
.read(perAppProxyModeNotifierProvider.notifier)
|
||||||
|
.update(PerAppProxyMode.exclude);
|
||||||
|
}
|
||||||
|
if (context.mounted) await const PerAppProxyRoute().push(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: Text(t.settings.advanced.debugMode),
|
title: Text(t.settings.advanced.debugMode),
|
||||||
value: debug,
|
value: debug,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:fpdart/fpdart.dart';
|
import 'package:fpdart/fpdart.dart';
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:hiddify/utils/utils.dart';
|
import 'package:hiddify/utils/utils.dart';
|
||||||
|
|
||||||
|
part 'platform_settings.freezed.dart';
|
||||||
|
part 'platform_settings.g.dart';
|
||||||
|
|
||||||
class PlatformSettings with InfraLogger {
|
class PlatformSettings with InfraLogger {
|
||||||
late final MethodChannel _methodChannel =
|
late final MethodChannel _methodChannel =
|
||||||
const MethodChannel("com.hiddify.app/platform.settings");
|
const MethodChannel("com.hiddify.app/platform.settings");
|
||||||
@@ -29,4 +35,55 @@ class PlatformSettings with InfraLogger {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TaskEither<String, List<InstalledPackageInfo>> getInstalledPackages() {
|
||||||
|
return TaskEither(
|
||||||
|
() async {
|
||||||
|
loggy.debug("getting installed packages info");
|
||||||
|
final result =
|
||||||
|
await _methodChannel.invokeMethod<String>("get_installed_packages");
|
||||||
|
if (result == null) return left("null response");
|
||||||
|
return right(
|
||||||
|
(jsonDecode(result) as List).map((e) {
|
||||||
|
return InstalledPackageInfo.fromJson(e as Map<String, dynamic>);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskEither<String, Uint8List> getPackageIcon(
|
||||||
|
String packageName,
|
||||||
|
) {
|
||||||
|
return TaskEither(
|
||||||
|
() async {
|
||||||
|
loggy.debug("getting package [$packageName] icon");
|
||||||
|
final result = await _methodChannel.invokeMethod<String>(
|
||||||
|
"get_package_icon",
|
||||||
|
{"packageName": packageName},
|
||||||
|
);
|
||||||
|
if (result == null) return left("null response");
|
||||||
|
final Uint8List decoded;
|
||||||
|
try {
|
||||||
|
decoded = base64.decode(result);
|
||||||
|
} catch (e) {
|
||||||
|
return left("error parsing base64 response");
|
||||||
|
}
|
||||||
|
return right(decoded);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class InstalledPackageInfo with _$InstalledPackageInfo {
|
||||||
|
@JsonSerializable(fieldRename: FieldRename.kebab)
|
||||||
|
const factory InstalledPackageInfo({
|
||||||
|
required String packageName,
|
||||||
|
required String name,
|
||||||
|
required bool isSystemApp,
|
||||||
|
}) = _InstalledPackageInfo;
|
||||||
|
|
||||||
|
factory InstalledPackageInfo.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$InstalledPackageInfoFromJson(json);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user