From 7394f7c4c36200acfc2b41da8206a13f4ef6ec9a Mon Sep 17 00:00:00 2001 From: problematicconsumer Date: Fri, 9 Feb 2024 12:02:52 +0330 Subject: [PATCH] Add connection info footer --- .../hiddify/hiddify/ActiveGroupsChannel.kt | 60 +++++++ .../com/hiddify/hiddify/GroupsChannel.kt | 62 ++------ .../com/hiddify/hiddify/MainActivity.kt | 1 + .../hiddify/hiddify/utils/CommandClient.kt | 3 +- .../hiddify/hiddify/utils/OutboundMapper.kt | 31 ++++ assets/translations/strings_en.i18n.json | 3 +- assets/translations/strings_es.i18n.json | 3 +- assets/translations/strings_fa.i18n.json | 3 +- assets/translations/strings_ru.i18n.json | 3 +- assets/translations/strings_tr.i18n.json | 3 +- assets/translations/strings_zh-CN.i18n.json | 3 +- lib/core/widget/animated_visibility.dart | 38 +++++ lib/core/widget/skeleton_widget.dart | 39 +++++ lib/features/home/widget/home_page.dart | 19 +-- .../proxy/active/active_proxy_footer.dart | 149 ++++++++++++++++++ .../proxy/active/active_proxy_notifier.dart | 75 +++++++++ lib/features/proxy/data/proxy_repository.dart | 45 +++++- .../stats/notifier/stats_notifier.dart | 2 + lib/singbox/service/ffi_singbox_service.dart | 6 + .../service/platform_singbox_service.dart | 17 ++ lib/singbox/service/singbox_service.dart | 2 + pubspec.lock | 16 ++ pubspec.yaml | 2 + 23 files changed, 520 insertions(+), 65 deletions(-) create mode 100644 android/app/src/main/kotlin/com/hiddify/hiddify/ActiveGroupsChannel.kt create mode 100644 android/app/src/main/kotlin/com/hiddify/hiddify/utils/OutboundMapper.kt create mode 100644 lib/core/widget/animated_visibility.dart create mode 100644 lib/core/widget/skeleton_widget.dart create mode 100644 lib/features/proxy/active/active_proxy_footer.dart create mode 100644 lib/features/proxy/active/active_proxy_notifier.dart diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/ActiveGroupsChannel.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/ActiveGroupsChannel.kt new file mode 100644 index 00000000..34c5d547 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/ActiveGroupsChannel.kt @@ -0,0 +1,60 @@ +package com.hiddify.hiddify + +import android.util.Log +import com.google.gson.Gson +import com.hiddify.hiddify.utils.CommandClient +import com.hiddify.hiddify.utils.ParsedOutboundGroup +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +import io.nekohasekai.libbox.OutboundGroup +import kotlinx.coroutines.CoroutineScope + + +class ActiveGroupsChannel(private val scope: CoroutineScope) : FlutterPlugin, + CommandClient.Handler { + companion object { + const val TAG = "A/ActiveGroupsChannel" + const val CHANNEL = "com.hiddify.app/active-groups" + val gson = Gson() + } + + private val client = + CommandClient(scope, CommandClient.ConnectionType.GroupOnly, this) + + private var channel: EventChannel? = null + private var event: EventChannel.EventSink? = null + + override fun updateGroups(groups: List) { + MainActivity.instance.runOnUiThread { + val parsedGroups = groups.map { group -> ParsedOutboundGroup.fromOutbound(group) } + event?.success(gson.toJson(parsedGroups)) + } + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = EventChannel( + flutterPluginBinding.binaryMessenger, + CHANNEL + ) + + channel!!.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + event = events + Log.d(TAG, "connecting active groups command client") + client.connect() + } + + override fun onCancel(arguments: Any?) { + event = null + Log.d(TAG, "disconnecting active groups command client") + client.disconnect() + } + }) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + event = null + client.disconnect() + channel?.setStreamHandler(null) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/GroupsChannel.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/GroupsChannel.kt index bf492953..7f2b907b 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/GroupsChannel.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/GroupsChannel.kt @@ -2,85 +2,57 @@ package com.hiddify.hiddify import android.util.Log import com.google.gson.Gson -import com.google.gson.annotations.SerializedName import com.hiddify.hiddify.utils.CommandClient +import com.hiddify.hiddify.utils.ParsedOutboundGroup import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.EventChannel import io.nekohasekai.libbox.OutboundGroup -import io.nekohasekai.libbox.OutboundGroupItem import kotlinx.coroutines.CoroutineScope class GroupsChannel(private val scope: CoroutineScope) : FlutterPlugin, CommandClient.Handler { companion object { const val TAG = "A/GroupsChannel" - const val GROUP_CHANNEL = "com.hiddify.app/groups" + const val CHANNEL = "com.hiddify.app/groups" val gson = Gson() } - private val commandClient = + private val client = CommandClient(scope, CommandClient.ConnectionType.Groups, this) - private var groupsChannel: EventChannel? = null - - private var groupsEvent: EventChannel.EventSink? = null + private var channel: EventChannel? = null + private var event: EventChannel.EventSink? = null override fun updateGroups(groups: List) { MainActivity.instance.runOnUiThread { - val kGroups = groups.map { group -> KOutboundGroup.fromOutbound(group) } - groupsEvent?.success(gson.toJson(kGroups)) + val parsedGroups = groups.map { group -> ParsedOutboundGroup.fromOutbound(group) } + event?.success(gson.toJson(parsedGroups)) } } override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - groupsChannel = EventChannel( + channel = EventChannel( flutterPluginBinding.binaryMessenger, - GROUP_CHANNEL + CHANNEL ) - groupsChannel!!.setStreamHandler(object : EventChannel.StreamHandler { + channel!!.setStreamHandler(object : EventChannel.StreamHandler { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - groupsEvent = events + event = events Log.d(TAG, "connecting groups command client") - commandClient.connect() + client.connect() } override fun onCancel(arguments: Any?) { - groupsEvent = null + event = null Log.d(TAG, "disconnecting groups command client") - commandClient.disconnect() + client.disconnect() } }) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - groupsEvent = null - commandClient.disconnect() - groupsChannel?.setStreamHandler(null) - } - - data class KOutboundGroup( - @SerializedName("tag") val tag: String, - @SerializedName("type") val type: String, - @SerializedName("selected") val selected: String, - @SerializedName("items") val items: List - ) { - companion object { - fun fromOutbound(group: OutboundGroup): KOutboundGroup { - val outboundItems = group.items - val items = mutableListOf() - while (outboundItems.hasNext()) { - items.add(KOutboundGroupItem(outboundItems.next())) - } - return KOutboundGroup(group.tag, group.type, group.selected, items) - } - } - } - - data class KOutboundGroupItem( - @SerializedName("tag") val tag: String, - @SerializedName("type") val type: String, - @SerializedName("url-test-delay") val urlTestDelay: Int, - ) { - constructor(item: OutboundGroupItem) : this(item.tag, item.type, item.urlTestDelay) + event = null + client.disconnect() + channel?.setStreamHandler(null) } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt index 47a452d6..1b48d640 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt @@ -49,6 +49,7 @@ class MainActivity : FlutterFragmentActivity(), ServiceConnection.Callback { flutterEngine.plugins.add(EventHandler()) flutterEngine.plugins.add(LogHandler()) flutterEngine.plugins.add(GroupsChannel(lifecycleScope)) + flutterEngine.plugins.add(ActiveGroupsChannel(lifecycleScope)) flutterEngine.plugins.add(StatsChannel(lifecycleScope)) } diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/utils/CommandClient.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/utils/CommandClient.kt index 8cf2f8bd..852a0439 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/utils/CommandClient.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/utils/CommandClient.kt @@ -23,7 +23,7 @@ open class CommandClient( ) { enum class ConnectionType { - Status, Groups, Log, ClashMode + Status, Groups, Log, ClashMode, GroupOnly } interface Handler { @@ -50,6 +50,7 @@ open class CommandClient( ConnectionType.Groups -> Libbox.CommandGroup ConnectionType.Log -> Libbox.CommandLog ConnectionType.ClashMode -> Libbox.CommandClashMode + ConnectionType.GroupOnly -> Libbox.CommandGroupInfoOnly } options.statusInterval = 2 * 1000 * 1000 * 1000 val commandClient = CommandClient(clientHandler, options) diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/utils/OutboundMapper.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/utils/OutboundMapper.kt new file mode 100644 index 00000000..27937e83 --- /dev/null +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/utils/OutboundMapper.kt @@ -0,0 +1,31 @@ +package com.hiddify.hiddify.utils + +import com.google.gson.annotations.SerializedName +import io.nekohasekai.libbox.OutboundGroup +import io.nekohasekai.libbox.OutboundGroupItem + +data class ParsedOutboundGroup( + @SerializedName("tag") val tag: String, + @SerializedName("type") val type: String, + @SerializedName("selected") val selected: String, + @SerializedName("items") val items: List +) { + companion object { + fun fromOutbound(group: OutboundGroup): ParsedOutboundGroup { + val outboundItems = group.items + val items = mutableListOf() + while (outboundItems.hasNext()) { + items.add(ParsedOutboundGroupItem(outboundItems.next())) + } + return ParsedOutboundGroup(group.tag, group.type, group.selected, items) + } + } +} + +data class ParsedOutboundGroupItem( + @SerializedName("tag") val tag: String, + @SerializedName("type") val type: String, + @SerializedName("url-test-delay") val urlTestDelay: Int, +) { + constructor(item: OutboundGroupItem) : this(item.tag, item.type, item.urlTestDelay) +} \ No newline at end of file diff --git a/assets/translations/strings_en.i18n.json b/assets/translations/strings_en.i18n.json index 669d4e97..1e7cb765 100644 --- a/assets/translations/strings_en.i18n.json +++ b/assets/translations/strings_en.i18n.json @@ -14,7 +14,8 @@ "addToClipboard": "Add to clipboard", "notSet": "Not Set", "agree": "Agree", - "decline": "Decline" + "decline": "Decline", + "unknown": "Unknown" }, "intro": { "termsAndPolicyCaution(rich)": "by continuing you agree with ${tap(@:about.termsAndConditions)}", diff --git a/assets/translations/strings_es.i18n.json b/assets/translations/strings_es.i18n.json index b15358cf..e9fc16c1 100644 --- a/assets/translations/strings_es.i18n.json +++ b/assets/translations/strings_es.i18n.json @@ -14,7 +14,8 @@ "addToClipboard": "Añadir al portapapeles", "notSet": "No establecido", "agree": "Aceptar", - "decline": "Rechazar" + "decline": "Rechazar", + "unknown": "Desconocido" }, "home": { "emptyProfilesMsg": "Comience agregando un perfil de suscripción", diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 024da3bc..b64e83d2 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -14,7 +14,8 @@ "addToClipboard": "به کلیپ بورد اضافه کنید", "notSet": "تنظیم نشده", "agree": "موافق", - "decline": "کاهش می یابد" + "decline": "کاهش می یابد", + "unknown": "ناشناخته" }, "intro": { "termsAndPolicyCaution(rich)": "در صورت ادامه با ${tap(@:about.termsAndConditions)} موافقت میکنید", diff --git a/assets/translations/strings_ru.i18n.json b/assets/translations/strings_ru.i18n.json index ce4f1eab..707eac4a 100644 --- a/assets/translations/strings_ru.i18n.json +++ b/assets/translations/strings_ru.i18n.json @@ -14,7 +14,8 @@ "addToClipboard": "Копировать в буфер обмена", "notSet": "Не задано", "agree": "Соглашаться", - "decline": "Отклонить" + "decline": "Отклонить", + "unknown": "Неизвестный" }, "intro": { "termsAndPolicyCaution(rich)": "Продолжая, Вы соглашаетесь с ${tap(@:about.termsAndConditions)}", diff --git a/assets/translations/strings_tr.i18n.json b/assets/translations/strings_tr.i18n.json index 5f4148b9..428e1a8c 100644 --- a/assets/translations/strings_tr.i18n.json +++ b/assets/translations/strings_tr.i18n.json @@ -14,7 +14,8 @@ "addToClipboard": "Panoya ekle", "notSet": "Ayarlanmadı", "agree": "Kabul etmek", - "decline": "Reddetmek" + "decline": "Reddetmek", + "unknown": "Bilinmeyen" }, "intro": { "termsAndPolicyCaution(rich)": "devam ederek ${tap(@:about.termsAndConditions)} kabul etmiş olursunuz", diff --git a/assets/translations/strings_zh-CN.i18n.json b/assets/translations/strings_zh-CN.i18n.json index 3f4e3b25..9679ac95 100644 --- a/assets/translations/strings_zh-CN.i18n.json +++ b/assets/translations/strings_zh-CN.i18n.json @@ -14,7 +14,8 @@ "addToClipboard": "添加到剪贴板", "notSet": "没有设置", "agree": "同意", - "decline": "衰退" + "decline": "衰退", + "unknown": "未知" }, "intro": { "termsAndPolicyCaution(rich)": "继续即表示您同意 ${tap(@:about.termsAndConditions)}", diff --git a/lib/core/widget/animated_visibility.dart b/lib/core/widget/animated_visibility.dart new file mode 100644 index 00000000..c1227b5c --- /dev/null +++ b/lib/core/widget/animated_visibility.dart @@ -0,0 +1,38 @@ +import 'package:flutter/widgets.dart'; + +class AnimatedVisibility extends StatelessWidget { + const AnimatedVisibility({ + super.key, + required this.visible, + this.axis = Axis.horizontal, + this.padding = EdgeInsets.zero, + required this.child, + }); + + final bool visible; + final Axis axis; + final EdgeInsets padding; + final Widget child; + + @override + Widget build(BuildContext context) { + final replacement = axis == Axis.vertical + ? const SizedBox(width: double.infinity) + : const SizedBox.shrink(); + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) => SizeTransition( + sizeFactor: animation, + child: FadeTransition(opacity: animation, child: child), + ), + child: visible + ? AnimatedPadding( + padding: padding, + duration: const Duration(milliseconds: 200), + child: child, + ) + : replacement, + ); + } +} diff --git a/lib/core/widget/skeleton_widget.dart b/lib/core/widget/skeleton_widget.dart new file mode 100644 index 00000000..5efd8b07 --- /dev/null +++ b/lib/core/widget/skeleton_widget.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class Skeleton extends StatelessWidget { + const Skeleton({ + this.width, + this.height, + this.widthFactor, + this.heightFactor, + this.shape = BoxShape.rectangle, + this.alignment = AlignmentDirectional.center, + }); + + final double? width; + final double? height; + final double? widthFactor; + final double? heightFactor; + final BoxShape shape; + final AlignmentGeometry alignment; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return FractionallySizedBox( + widthFactor: widthFactor, + heightFactor: heightFactor, + alignment: alignment, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + shape: shape, + color: theme.hintColor.withOpacity(.16), + ), + ), + ); + } +} diff --git a/lib/features/home/widget/home_page.dart b/lib/features/home/widget/home_page.dart index 785b0e2b..38c07345 100644 --- a/lib/features/home/widget/home_page.dart +++ b/lib/features/home/widget/home_page.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:dartx/dartx.dart'; import 'package:flutter/material.dart'; import 'package:hiddify/core/app_info/app_info_provider.dart'; @@ -9,6 +11,7 @@ import 'package:hiddify/features/home/widget/connection_button.dart'; import 'package:hiddify/features/home/widget/empty_profiles_home_body.dart'; import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; import 'package:hiddify/features/profile/widget/profile_tile.dart'; +import 'package:hiddify/features/proxy/active/active_proxy_footer.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:sliver_tools/sliver_tools.dart'; @@ -53,16 +56,14 @@ class HomePage extends HookConsumerWidget { AsyncData(value: final profile?) => MultiSliver( children: [ ProfileTile(profile: profile, isMain: true), - const SliverFillRemaining( + SliverFillRemaining( hasScrollBody: false, - child: Padding( - padding: EdgeInsets.only( - left: 8, - right: 8, - top: 8, - bottom: 86, - ), - child: ConnectionButton(), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Expanded(child: ConnectionButton()), + if (Platform.isAndroid) const ActiveProxyFooter(), + ], ), ), ], diff --git a/lib/features/proxy/active/active_proxy_footer.dart b/lib/features/proxy/active/active_proxy_footer.dart new file mode 100644 index 00000000..55102a11 --- /dev/null +++ b/lib/features/proxy/active/active_proxy_footer.dart @@ -0,0 +1,149 @@ +import 'package:circle_flags/circle_flags.dart'; +import 'package:dartx/dartx.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:gap/gap.dart'; +import 'package:hiddify/core/localization/translations.dart'; +import 'package:hiddify/core/widget/animated_visibility.dart'; +import 'package:hiddify/core/widget/skeleton_widget.dart'; +import 'package:hiddify/features/proxy/active/active_proxy_notifier.dart'; +import 'package:hiddify/features/stats/notifier/stats_notifier.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class ActiveProxyFooter extends HookConsumerWidget { + const ActiveProxyFooter({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + final asyncState = ref.watch(activeProxyNotifierProvider); + final stats = ref.watch(statsNotifierProvider).value; + + return AnimatedVisibility( + axis: Axis.vertical, + visible: asyncState is AsyncData, + child: switch (asyncState) { + AsyncData(value: final info) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoProp( + icon: FluentIcons.arrow_routing_24_regular, + text: info.proxy.selectedName.isNotNullOrBlank + ? info.proxy.selectedName! + : info.proxy.name, + ), + const Gap(8), + switch (info.ipInfo) { + AsyncData(value: final ip?) => _InfoProp.flag( + countryCode: ip.countryCode, + text: ip.ip, + ), + AsyncError() => _InfoProp( + icon: FluentIcons.error_circle_20_regular, + text: t.general.unknown, + ), + _ => _InfoProp.loading( + icon: FluentIcons.question_circle_24_regular, + ), + }, + ], + ), + ), + Directionality( + textDirection: TextDirection.values[ + (Directionality.of(context).index + 1) % + TextDirection.values.length], + child: Flexible( + child: Column( + children: [ + _InfoProp( + icon: FluentIcons + .arrow_bidirectional_up_down_24_regular, + text: (stats?.downlinkTotal ?? 0).size(), + ), + const Gap(8), + _InfoProp( + icon: FluentIcons.arrow_download_24_regular, + text: (stats?.downlink ?? 0).speed(), + ), + ], + ), + ), + ), + ], + ), + ), + _ => const SizedBox(), + }, + ); + } +} + +class _InfoProp extends StatelessWidget { + _InfoProp({ + required IconData icon, + required String text, + }) : icon = Icon(icon), + child = Text( + text, + overflow: TextOverflow.ellipsis, + ), + isLoading = false; + + _InfoProp.flag({ + required String countryCode, + required String text, + }) : icon = Container( + width: 24, + height: 24, + padding: const EdgeInsets.all(2), + child: CircleFlag(countryCode), + ), + child = Text( + text, + overflow: TextOverflow.ellipsis, + ), + isLoading = false; + + _InfoProp.loading({ + required IconData icon, + }) : icon = Icon(icon), + child = const SizedBox(), + isLoading = true; + + final Widget icon; + final Widget child; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + icon, + const Gap(8), + if (isLoading) + Flexible( + child: const Skeleton(height: 16, widthFactor: 1) + .animate( + onPlay: (controller) => controller.loop(), + ) + .shimmer( + duration: 1000.ms, + angle: 45, + color: Theme.of(context).colorScheme.secondary, + ), + ) + else + Flexible(child: child), + ], + ); + } +} diff --git a/lib/features/proxy/active/active_proxy_notifier.dart b/lib/features/proxy/active/active_proxy_notifier.dart new file mode 100644 index 00000000..9926a24a --- /dev/null +++ b/lib/features/proxy/active/active_proxy_notifier.dart @@ -0,0 +1,75 @@ +import 'package:dio/dio.dart'; +import 'package:hiddify/features/connection/notifier/connection_notifier.dart'; +import 'package:hiddify/features/proxy/data/proxy_data_providers.dart'; +import 'package:hiddify/features/proxy/model/ip_info_entity.dart'; +import 'package:hiddify/features/proxy/model/proxy_entity.dart'; +import 'package:hiddify/features/proxy/model/proxy_failure.dart'; +import 'package:hiddify/utils/riverpod_utils.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:loggy/loggy.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'active_proxy_notifier.g.dart'; + +typedef ActiveProxyInfo = ({ + ProxyItemEntity proxy, + AsyncValue ipInfo, +}); + +@riverpod +Stream activeProxyGroup(ActiveProxyGroupRef ref) async* { + final serviceRunning = await ref.watch(serviceRunningProvider.future); + if (!serviceRunning) { + throw const ServiceNotRunning(); + } + yield* ref + .watch(proxyRepositoryProvider) + .watchActiveProxies() + .map((event) => event.getOrElse((l) => throw l)) + .map((event) => event.firstOrNull?.items.firstOrNull); +} + +@riverpod +Future proxyIpInfo(ProxyIpInfoRef ref) async { + final serviceRunning = await ref.watch(serviceRunningProvider.future); + if (!serviceRunning) { + return null; + } + final cancelToken = CancelToken(); + ref.onDispose(() { + Loggy("ProxyIpInfo").debug("canceling"); + cancelToken.cancel(); + }); + return ref + .watch(proxyRepositoryProvider) + .getCurrentIpInfo(cancelToken) + .getOrElse( + (err) { + Loggy("ProxyIpInfo").error("error getting proxy ip info", err); + throw err; + }, + ).run(); +} + +@riverpod +class ActiveProxyNotifier extends _$ActiveProxyNotifier with AppLogger { + @override + AsyncValue build() { + ref.disposeDelay(const Duration(seconds: 20)); + final ipInfo = ref.watch(proxyIpInfoProvider); + final activeProxies = ref.watch(activeProxyGroupProvider); + return switch (activeProxies) { + AsyncData(value: final activeGroup?) => + AsyncData((proxy: activeGroup, ipInfo: ipInfo)), + AsyncError(:final error, :final stackTrace) => + AsyncError(error, stackTrace), + _ => const AsyncLoading(), + }; + } + + Future refreshIpInfo() async { + if (state case AsyncData(:final value) when !value.ipInfo.isLoading) { + ref.invalidate(proxyIpInfoProvider); + } + } +} diff --git a/lib/features/proxy/data/proxy_repository.dart b/lib/features/proxy/data/proxy_repository.dart index 9a3781b3..640558f4 100644 --- a/lib/features/proxy/data/proxy_repository.dart +++ b/lib/features/proxy/data/proxy_repository.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:fpdart/fpdart.dart'; import 'package:hiddify/core/http_client/dio_http_client.dart'; import 'package:hiddify/core/utils/exception_handler.dart'; @@ -9,7 +10,8 @@ import 'package:hiddify/utils/custom_loggers.dart'; abstract interface class ProxyRepository { Stream>> watchProxies(); - TaskEither getCurrentIpInfo(); + Stream>> watchActiveProxies(); + TaskEither getCurrentIpInfo(CancelToken cancelToken); TaskEither selectProxy( String groupTag, String outboundTag, @@ -31,7 +33,6 @@ class ProxyRepositoryImpl @override Stream>> watchProxies() { return singbox.watchOutbounds().map((event) { - print("outbounds: $event"); final groupWithSelected = { for (final group in event) group.tag: group.selected, }; @@ -63,6 +64,39 @@ class ProxyRepositoryImpl ); } + @override + Stream>> watchActiveProxies() { + return singbox.watchActiveOutbounds().map((event) { + final groupWithSelected = { + for (final group in event) group.tag: group.selected, + }; + return event + .map( + (e) => ProxyGroupEntity( + tag: e.tag, + type: e.type, + selected: e.selected, + items: e.items + .map( + (e) => ProxyItemEntity( + tag: e.tag, + type: e.type, + urlTestDelay: e.urlTestDelay, + selectedTag: groupWithSelected[e.tag], + ), + ) + .toList(), + ), + ) + .toList(); + }).handleExceptions( + (error, stackTrace) { + loggy.error("error watching active proxies", error, stackTrace); + return ProxyUnexpectedFailure(error, stackTrace); + }, + ); + } + @override TaskEither selectProxy( String groupTag, @@ -92,13 +126,16 @@ class ProxyRepositoryImpl }; @override - TaskEither getCurrentIpInfo() { + TaskEither getCurrentIpInfo(CancelToken cancelToken) { return TaskEither.tryCatch( () async { for (final source in _ipInfoSources.entries) { try { loggy.debug("getting current ip info using [${source.key}]"); - final response = await client.get>(source.key); + final response = await client.get>( + source.key, + cancelToken: cancelToken, + ); if (response.statusCode == 200 && response.data != null) { return source.value(response.data!); } diff --git a/lib/features/stats/notifier/stats_notifier.dart b/lib/features/stats/notifier/stats_notifier.dart index d04adbf8..fcce03d3 100644 --- a/lib/features/stats/notifier/stats_notifier.dart +++ b/lib/features/stats/notifier/stats_notifier.dart @@ -2,6 +2,7 @@ import 'package:hiddify/features/connection/notifier/connection_notifier.dart'; import 'package:hiddify/features/stats/data/stats_data_providers.dart'; import 'package:hiddify/features/stats/model/stats_entity.dart'; import 'package:hiddify/utils/custom_loggers.dart'; +import 'package:hiddify/utils/riverpod_utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'stats_notifier.g.dart'; @@ -10,6 +11,7 @@ part 'stats_notifier.g.dart'; class StatsNotifier extends _$StatsNotifier with AppLogger { @override Stream build() async* { + ref.disposeDelay(const Duration(seconds: 10)); final serviceRunning = await ref.watch(serviceRunningProvider.future); if (serviceRunning) { yield* ref diff --git a/lib/singbox/service/ffi_singbox_service.dart b/lib/singbox/service/ffi_singbox_service.dart index a026f2d4..c065fd62 100644 --- a/lib/singbox/service/ffi_singbox_service.dart +++ b/lib/singbox/service/ffi_singbox_service.dart @@ -318,6 +318,12 @@ class FFISingboxService with InfraLogger implements SingboxService { return _outboundsStream = outboundsStream; } + @override + Stream> watchActiveOutbounds() { + // TODO: implement watchActiveOutbounds + throw UnimplementedError(); + } + @override TaskEither selectOutbound(String groupTag, String outboundTag) { return TaskEither( diff --git a/lib/singbox/service/platform_singbox_service.dart b/lib/singbox/service/platform_singbox_service.dart index 12cc5f70..d0a3ad94 100644 --- a/lib/singbox/service/platform_singbox_service.dart +++ b/lib/singbox/service/platform_singbox_service.dart @@ -182,6 +182,23 @@ class PlatformSingboxService with InfraLogger implements SingboxService { ); } + @override + Stream> watchActiveOutbounds() { + const channel = EventChannel("com.hiddify.app/active-groups"); + loggy.debug("watching active outbounds"); + return channel.receiveBroadcastStream().map( + (event) { + if (event case String _) { + return (jsonDecode(event) as List).map((e) { + return SingboxOutboundGroup.fromJson(e as Map); + }).toList(); + } + loggy.error("[active group client] unexpected type, msg: $event"); + throw "invalid type"; + }, + ); + } + @override Stream watchStatus() => _status; diff --git a/lib/singbox/service/singbox_service.dart b/lib/singbox/service/singbox_service.dart index 7535a50e..bf3f7f6c 100644 --- a/lib/singbox/service/singbox_service.dart +++ b/lib/singbox/service/singbox_service.dart @@ -56,6 +56,8 @@ abstract interface class SingboxService { Stream> watchOutbounds(); + Stream> watchActiveOutbounds(); + TaskEither selectOutbound(String groupTag, String outboundTag); TaskEither urlTest(String groupTag); diff --git a/pubspec.lock b/pubspec.lock index c44e14a1..0b5b4908 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.0" + circle_flags: + dependency: "direct main" + description: + name: circle_flags + sha256: "028d5ca1bb12b9a4b29dc1a25f32a428ffd69e91343274375d9e574855af2daa" + url: "https://pub.dev" + source: hosted + version: "4.0.2" cli_util: dependency: transitive description: @@ -449,6 +457,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + fluentui_system_icons: + dependency: "direct main" + description: + name: fluentui_system_icons + sha256: "1c02e6a4898dfc45e470ddcd62fb9c8fe59a7f8bb380e7f3edcb0d127c23bfd3" + url: "https://pub.dev" + source: hosted + version: "1.1.225" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index fcaea7ba..60fc0534 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,8 @@ dependencies: cupertino_http: ^1.3.0 wolt_modal_sheet: ^0.4.0 dart_mappable: ^4.2.0 + fluentui_system_icons: ^1.1.225 + circle_flags: ^4.0.2 dev_dependencies: flutter_test: