diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/PlatformSettingsHandler.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/PlatformSettingsHandler.kt index 0f2a5706..e3e2f7ff 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/PlatformSettingsHandler.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/PlatformSettingsHandler.kt @@ -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() + 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() } } diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/Settings.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/Settings.kt index 9b01b4a5..e801a26a 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/Settings.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/Settings.kt @@ -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 + 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 { + val stream = ObjectInputStream(ByteArrayInputStream(Base64.decode(listString, 0))) + return stream.readObject() as List + } 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()) diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/AppChangeReceiver.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/AppChangeReceiver.kt index 0a232e1b..fd028f6c 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/AppChangeReceiver.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/AppChangeReceiver.kt @@ -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 +// } } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/VPNService.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/VPNService.kt index ca03dbc7..c0cfba41 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/VPNService.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/VPNService.kt @@ -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) diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/constant/PerAppProxyMode.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/PerAppProxyMode.kt new file mode 100644 index 00000000..aafe920a --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/PerAppProxyMode.kt @@ -0,0 +1,7 @@ +package com.hiddify.hiddify.constant + +object PerAppProxyMode { + const val OFF = "off" + const val INCLUDE = "include" + const val EXCLUDE = "exclude" +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/constant/SettingsKey.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/SettingsKey.kt index 1b6477c8..21e7dd0f 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/constant/SettingsKey.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/SettingsKey.kt @@ -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" diff --git a/assets/translations/strings.i18n.json b/assets/translations/strings.i18n.json index e479fbff..45bb39df 100644 --- a/assets/translations/strings.i18n.json +++ b/assets/translations/strings.i18n.json @@ -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", diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index f47cce18..55f652c1 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -117,6 +117,20 @@ "debugMode": "دیباگ مود", "debugModeMsg": "برای اعمال این تغییر اپ را ری‌استارت کنید" }, + "network": { + "perAppProxyPageTitle": "پراکسی برنامه‌ها", + "perAppProxyModes": { + "off": "همه", + "offMsg": "همه برنامه‌ها پراکسی میشوند", + "include": "پراکسی", + "includeMsg": "تنها برنامه‌های انتخاب شده پراکسی میشوند", + "exclude": "بایپس", + "excludeMsg": "همه بجز برنامه‌های انتخاب شده پراکسی میشوند" + }, + "showSystemApps": "نمایش برنامه‌های سیستمی", + "hideSystemApps": "مخفی کردن برنامه‌های سیستمی", + "clearSelection": "حذف انتخاب‌ها" + }, "config": { "section": { "route": "تنظیمات مسیریاب", diff --git a/lib/core/prefs/general_prefs.dart b/lib/core/prefs/general_prefs.dart index 32f93b5c..6af8727b 100644 --- a/lib/core/prefs/general_prefs.dart +++ b/lib/core/prefs/general_prefs.dart @@ -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 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", + [], + ); + + late final _exclude = Pref( + ref.watch(sharedPreferencesProvider), + "per_app_proxy_exclude_list", + [], + ); + + @override + List build() => + ref.watch(perAppProxyModeNotifierProvider) == PerAppProxyMode.include + ? _include.getValue() + : _exclude.getValue(); + + Future update(List 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( diff --git a/lib/core/router/routes/mobile_routes.dart b/lib/core/router/routes/mobile_routes.dart index c51c506f..98004ed0 100644 --- a/lib/core/router/routes/mobile_routes.dart +++ b/lib/core/router/routes/mobile_routes.dart @@ -22,6 +22,7 @@ part 'mobile_routes.g.dart'; path: SettingsRoute.path, routes: [ TypedGoRoute(path: ConfigOptionsRoute.path), + TypedGoRoute(path: PerAppProxyRoute.path), ], ), TypedGoRoute(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 $parentNavigatorKey = rootNavigatorKey; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return const MaterialPage( + fullscreenDialog: true, + child: PerAppProxyPage(), + ); + } +} + class AboutRoute extends GoRouteData { const AboutRoute(); static const path = 'about'; diff --git a/lib/domain/singbox/rules.dart b/lib/domain/singbox/rules.dart new file mode 100644 index 00000000..29cede22 --- /dev/null +++ b/lib/domain/singbox/rules.dart @@ -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, + ), + }; +} diff --git a/lib/domain/singbox/singbox.dart b/lib/domain/singbox/singbox.dart index 1d47ba21..39bb5774 100644 --- a/lib/domain/singbox/singbox.dart +++ b/lib/domain/singbox/singbox.dart @@ -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'; diff --git a/lib/features/settings/view/per_app_proxy_page.dart b/lib/features/settings/view/per_app_proxy_page.dart new file mode 100644 index 00000000..814dbf07 --- /dev/null +++ b/lib/features/settings/view/per_app_proxy_page.dart @@ -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("PerAppProxySettings"); + +@riverpod +Future> installedPackagesInfo( + InstalledPackagesInfoRef ref, +) async { + return ref + .watch(platformSettingsProvider) + .getInstalledPackages() + .getOrElse((l) { + _logger.warning("error getting installed packages: $l"); + throw l; + }).run(); +} + +@riverpod +Future 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 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( + 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 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(), + }, + ], + ), + ); + } +} diff --git a/lib/features/settings/view/view.dart b/lib/features/settings/view/view.dart index 458e1d4d..c94412af 100644 --- a/lib/features/settings/view/view.dart +++ b/lib/features/settings/view/view.dart @@ -1,2 +1,3 @@ export 'config_options_page.dart'; +export 'per_app_proxy_page.dart'; export 'settings_page.dart'; diff --git a/lib/features/settings/widgets/advanced_setting_tiles.dart b/lib/features/settings/widgets/advanced_setting_tiles.dart index a4960be0..82b94bdb 100644 --- a/lib/features/settings/widgets/advanced_setting_tiles.dart +++ b/lib/features/settings/widgets/advanced_setting_tiles.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, diff --git a/lib/services/platform_settings.dart b/lib/services/platform_settings.dart index 17267ea2..37ee6b3f 100644 --- a/lib/services/platform_settings.dart +++ b/lib/services/platform_settings.dart @@ -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> getInstalledPackages() { + return TaskEither( + () async { + loggy.debug("getting installed packages info"); + final result = + await _methodChannel.invokeMethod("get_installed_packages"); + if (result == null) return left("null response"); + return right( + (jsonDecode(result) as List).map((e) { + return InstalledPackageInfo.fromJson(e as Map); + }).toList(), + ); + }, + ); + } + + TaskEither getPackageIcon( + String packageName, + ) { + return TaskEither( + () async { + loggy.debug("getting package [$packageName] icon"); + final result = await _methodChannel.invokeMethod( + "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 json) => + _$InstalledPackageInfoFromJson(json); }