Add android per-app proxy

This commit is contained in:
problematicconsumer
2023-09-13 23:19:16 +03:30
parent f1b0f8ee4b
commit ea6f8b5fad
16 changed files with 587 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.hiddify.hiddify.constant
object PerAppProxyMode {
const val OFF = "off"
const val INCLUDE = "include"
const val EXCLUDE = "exclude"
}

View File

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

View File

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

View File

@@ -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": "تنظیمات مسیریاب",

View File

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

View File

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

View 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,
),
};
}

View File

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

View 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(),
},
],
),
);
}
}

View File

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

View File

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

View File

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