Add android per-app proxy
This commit is contained in:
@@ -1,10 +1,21 @@
|
||||
package com.hiddify.hiddify
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
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.os.Build
|
||||
import android.util.Base64
|
||||
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.activity.ActivityAware
|
||||
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.PluginRegistry
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
|
||||
class PlatformSettingsHandler : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware,
|
||||
PluginRegistry.ActivityResultListener {
|
||||
@@ -23,10 +38,13 @@ class PlatformSettingsHandler : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
const val channelName = "com.hiddify.app/platform.settings"
|
||||
|
||||
const val REQUEST_IGNORE_BATTERY_OPTIMIZATIONS = 44
|
||||
val gson = Gson()
|
||||
|
||||
enum class Trigger(val method: String) {
|
||||
IsIgnoringBatteryOptimizations("is_ignoring_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
|
||||
}
|
||||
|
||||
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) {
|
||||
when (call.method) {
|
||||
Trigger.IsIgnoringBatteryOptimizations.method -> {
|
||||
@@ -97,6 +121,69 @@ class PlatformSettingsHandler : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,62 @@
|
||||
package com.hiddify.hiddify
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import com.hiddify.hiddify.bg.ProxyService
|
||||
import com.hiddify.hiddify.bg.VPNService
|
||||
import com.hiddify.hiddify.constant.PerAppProxyMode
|
||||
import com.hiddify.hiddify.constant.ServiceMode
|
||||
import com.hiddify.hiddify.constant.SettingsKey
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.ObjectInputStream
|
||||
|
||||
|
||||
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 {
|
||||
val context = Application.application.applicationContext
|
||||
context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
var perAppProxyEnabled = preferences.getBoolean(SettingsKey.PER_APP_PROXY_ENABLED, false)
|
||||
var perAppProxyMode = preferences.getInt(SettingsKey.PER_APP_PROXY_MODE, PER_APP_PROXY_EXCLUDE)
|
||||
var perAppProxyList = preferences.getStringSet(SettingsKey.PER_APP_PROXY_LIST, emptySet())!!
|
||||
var perAppProxyUpdateOnChange =
|
||||
preferences.getInt(SettingsKey.PER_APP_PROXY_UPDATE_ON_CHANGE, PER_APP_PROXY_DISABLED)
|
||||
private const val LIST_IDENTIFIER = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu"
|
||||
|
||||
var perAppProxyMode: String
|
||||
get() = preferences.getString(SettingsKey.PER_APP_PROXY_MODE, PerAppProxyMode.OFF)!!
|
||||
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
|
||||
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()
|
||||
|
||||
var serviceMode: String
|
||||
get() = preferences.getString(SettingsKey.SERVICE_MODE, ServiceMode.NORMAL)
|
||||
?: ServiceMode.NORMAL
|
||||
get() = preferences.getString(SettingsKey.SERVICE_MODE, ServiceMode.NORMAL)!!
|
||||
set(value) = preferences.edit().putString(SettingsKey.SERVICE_MODE, value).apply()
|
||||
|
||||
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()
|
||||
|
||||
var debugMode: Boolean
|
||||
@@ -47,11 +68,13 @@ object Settings {
|
||||
|
||||
var disableMemoryLimit: Boolean
|
||||
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
|
||||
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
|
||||
get() = preferences.getBoolean(SettingsKey.STARTED_BY_USER, false)
|
||||
@@ -80,7 +103,7 @@ object Settings {
|
||||
}
|
||||
|
||||
private suspend fun needVPNService(): Boolean {
|
||||
if(enableTun) return true
|
||||
if (enableTun) return true
|
||||
val filePath = activeConfigPath
|
||||
if (filePath.isBlank()) return false
|
||||
val content = JSONObject(File(filePath).readText())
|
||||
|
||||
@@ -16,22 +16,22 @@ class AppChangeReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
private fun checkUpdate(context: Context, intent: Intent) {
|
||||
if (!Settings.perAppProxyEnabled) {
|
||||
return
|
||||
}
|
||||
val perAppProxyUpdateOnChange = Settings.perAppProxyUpdateOnChange
|
||||
if (perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_DISABLED) {
|
||||
return
|
||||
}
|
||||
val packageName = intent.dataString?.substringAfter("package:")
|
||||
if (packageName.isNullOrBlank()) {
|
||||
return
|
||||
}
|
||||
if ((perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_INCLUDE)) {
|
||||
Settings.perAppProxyList = Settings.perAppProxyList + packageName
|
||||
} else {
|
||||
Settings.perAppProxyList = Settings.perAppProxyList - packageName
|
||||
}
|
||||
// if (!Settings.perAppProxyEnabled) {
|
||||
// return
|
||||
// }
|
||||
// val perAppProxyUpdateOnChange = Settings.perAppProxyUpdateOnChange
|
||||
// if (perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_DISABLED) {
|
||||
// return
|
||||
// }
|
||||
// val packageName = intent.dataString?.substringAfter("package:")
|
||||
// if (packageName.isNullOrBlank()) {
|
||||
// return
|
||||
// }
|
||||
// if ((perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_INCLUDE)) {
|
||||
// Settings.perAppProxyList = Settings.perAppProxyList + packageName
|
||||
// } else {
|
||||
// Settings.perAppProxyList = Settings.perAppProxyList - packageName
|
||||
// }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.net.ProxyInfo
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import com.hiddify.hiddify.constant.PerAppProxyMode
|
||||
import io.nekohasekai.libbox.TunOptions
|
||||
|
||||
class VPNService : VpnService(), PlatformInterfaceWrapper {
|
||||
@@ -87,7 +88,7 @@ class VPNService : VpnService(), PlatformInterfaceWrapper {
|
||||
|
||||
if (Settings.perAppProxyEnabled) {
|
||||
val appList = Settings.perAppProxyList
|
||||
if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) {
|
||||
if (Settings.perAppProxyMode == PerAppProxyMode.INCLUDE) {
|
||||
appList.forEach {
|
||||
try {
|
||||
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 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"
|
||||
const val PER_APP_PROXY_MODE = "${KEY_PREFIX}per_app_proxy_mode"
|
||||
const val PER_APP_PROXY_INCLUDE_LIST = "${KEY_PREFIX}per_app_proxy_include_list"
|
||||
const val PER_APP_PROXY_EXCLUDE_LIST = "${KEY_PREFIX}per_app_proxy_exclude_list"
|
||||
|
||||
const val DEBUG_MODE = "${KEY_PREFIX}debug_mode"
|
||||
const val ENABLE_TUN = "${KEY_PREFIX}enable-tun"
|
||||
|
||||
@@ -117,6 +117,20 @@
|
||||
"debugMode": "Debug Mode",
|
||||
"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": {
|
||||
"section": {
|
||||
"route": "Route Options",
|
||||
|
||||
@@ -117,6 +117,20 @@
|
||||
"debugMode": "دیباگ مود",
|
||||
"debugModeMsg": "برای اعمال این تغییر اپ را ریاستارت کنید"
|
||||
},
|
||||
"network": {
|
||||
"perAppProxyPageTitle": "پراکسی برنامهها",
|
||||
"perAppProxyModes": {
|
||||
"off": "همه",
|
||||
"offMsg": "همه برنامهها پراکسی میشوند",
|
||||
"include": "پراکسی",
|
||||
"includeMsg": "تنها برنامههای انتخاب شده پراکسی میشوند",
|
||||
"exclude": "بایپس",
|
||||
"excludeMsg": "همه بجز برنامههای انتخاب شده پراکسی میشوند"
|
||||
},
|
||||
"showSystemApps": "نمایش برنامههای سیستمی",
|
||||
"hideSystemApps": "مخفی کردن برنامههای سیستمی",
|
||||
"clearSelection": "حذف انتخابها"
|
||||
},
|
||||
"config": {
|
||||
"section": {
|
||||
"route": "تنظیمات مسیریاب",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/environment.dart';
|
||||
import 'package:hiddify/domain/singbox/singbox.dart';
|
||||
import 'package:hiddify/utils/pref_notifier.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
|
||||
class MarkNewProfileActive extends _$MarkNewProfileActive {
|
||||
late final _pref = Pref(
|
||||
|
||||
@@ -22,6 +22,7 @@ part 'mobile_routes.g.dart';
|
||||
path: SettingsRoute.path,
|
||||
routes: [
|
||||
TypedGoRoute<ConfigOptionsRoute>(path: ConfigOptionsRoute.path),
|
||||
TypedGoRoute<PerAppProxyRoute>(path: PerAppProxyRoute.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 {
|
||||
const AboutRoute();
|
||||
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 'outbounds.dart';
|
||||
export 'proxy_type.dart';
|
||||
export 'rules.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 'per_app_proxy_page.dart';
|
||||
export 'settings_page.dart';
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/core/router/routes/routes.dart';
|
||||
import 'package:hiddify/domain/singbox/singbox.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class AdvancedSettingTiles extends HookConsumerWidget {
|
||||
@@ -13,6 +16,7 @@ class AdvancedSettingTiles extends HookConsumerWidget {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final debug = ref.watch(debugModeNotifierProvider);
|
||||
final perAppProxy = ref.watch(perAppProxyModeNotifierProvider).enabled;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -23,6 +27,33 @@ class AdvancedSettingTiles extends HookConsumerWidget {
|
||||
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(
|
||||
title: Text(t.settings.advanced.debugMode),
|
||||
value: debug,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
part 'platform_settings.freezed.dart';
|
||||
part 'platform_settings.g.dart';
|
||||
|
||||
class PlatformSettings with InfraLogger {
|
||||
late final MethodChannel _methodChannel =
|
||||
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