Add connection info footer

This commit is contained in:
problematicconsumer
2024-02-09 12:02:52 +03:30
parent 37dc33667e
commit 7394f7c4c3
23 changed files with 520 additions and 65 deletions

View File

@@ -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<OutboundGroup>) {
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)
}
}

View File

@@ -2,85 +2,57 @@ package com.hiddify.hiddify
import android.util.Log import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import com.hiddify.hiddify.utils.CommandClient import com.hiddify.hiddify.utils.CommandClient
import com.hiddify.hiddify.utils.ParsedOutboundGroup
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.libbox.OutboundGroup
import io.nekohasekai.libbox.OutboundGroupItem
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
class GroupsChannel(private val scope: CoroutineScope) : FlutterPlugin, CommandClient.Handler { class GroupsChannel(private val scope: CoroutineScope) : FlutterPlugin, CommandClient.Handler {
companion object { companion object {
const val TAG = "A/GroupsChannel" const val TAG = "A/GroupsChannel"
const val GROUP_CHANNEL = "com.hiddify.app/groups" const val CHANNEL = "com.hiddify.app/groups"
val gson = Gson() val gson = Gson()
} }
private val commandClient = private val client =
CommandClient(scope, CommandClient.ConnectionType.Groups, this) CommandClient(scope, CommandClient.ConnectionType.Groups, this)
private var groupsChannel: EventChannel? = null private var channel: EventChannel? = null
private var event: EventChannel.EventSink? = null
private var groupsEvent: EventChannel.EventSink? = null
override fun updateGroups(groups: List<OutboundGroup>) { override fun updateGroups(groups: List<OutboundGroup>) {
MainActivity.instance.runOnUiThread { MainActivity.instance.runOnUiThread {
val kGroups = groups.map { group -> KOutboundGroup.fromOutbound(group) } val parsedGroups = groups.map { group -> ParsedOutboundGroup.fromOutbound(group) }
groupsEvent?.success(gson.toJson(kGroups)) event?.success(gson.toJson(parsedGroups))
} }
} }
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
groupsChannel = EventChannel( channel = EventChannel(
flutterPluginBinding.binaryMessenger, flutterPluginBinding.binaryMessenger,
GROUP_CHANNEL CHANNEL
) )
groupsChannel!!.setStreamHandler(object : EventChannel.StreamHandler { channel!!.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
groupsEvent = events event = events
Log.d(TAG, "connecting groups command client") Log.d(TAG, "connecting groups command client")
commandClient.connect() client.connect()
} }
override fun onCancel(arguments: Any?) { override fun onCancel(arguments: Any?) {
groupsEvent = null event = null
Log.d(TAG, "disconnecting groups command client") Log.d(TAG, "disconnecting groups command client")
commandClient.disconnect() client.disconnect()
} }
}) })
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
groupsEvent = null event = null
commandClient.disconnect() client.disconnect()
groupsChannel?.setStreamHandler(null) channel?.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<KOutboundGroupItem>
) {
companion object {
fun fromOutbound(group: OutboundGroup): KOutboundGroup {
val outboundItems = group.items
val items = mutableListOf<KOutboundGroupItem>()
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)
} }
} }

View File

@@ -49,6 +49,7 @@ class MainActivity : FlutterFragmentActivity(), ServiceConnection.Callback {
flutterEngine.plugins.add(EventHandler()) flutterEngine.plugins.add(EventHandler())
flutterEngine.plugins.add(LogHandler()) flutterEngine.plugins.add(LogHandler())
flutterEngine.plugins.add(GroupsChannel(lifecycleScope)) flutterEngine.plugins.add(GroupsChannel(lifecycleScope))
flutterEngine.plugins.add(ActiveGroupsChannel(lifecycleScope))
flutterEngine.plugins.add(StatsChannel(lifecycleScope)) flutterEngine.plugins.add(StatsChannel(lifecycleScope))
} }

View File

@@ -23,7 +23,7 @@ open class CommandClient(
) { ) {
enum class ConnectionType { enum class ConnectionType {
Status, Groups, Log, ClashMode Status, Groups, Log, ClashMode, GroupOnly
} }
interface Handler { interface Handler {
@@ -50,6 +50,7 @@ open class CommandClient(
ConnectionType.Groups -> Libbox.CommandGroup ConnectionType.Groups -> Libbox.CommandGroup
ConnectionType.Log -> Libbox.CommandLog ConnectionType.Log -> Libbox.CommandLog
ConnectionType.ClashMode -> Libbox.CommandClashMode ConnectionType.ClashMode -> Libbox.CommandClashMode
ConnectionType.GroupOnly -> Libbox.CommandGroupInfoOnly
} }
options.statusInterval = 2 * 1000 * 1000 * 1000 options.statusInterval = 2 * 1000 * 1000 * 1000
val commandClient = CommandClient(clientHandler, options) val commandClient = CommandClient(clientHandler, options)

View File

@@ -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<ParsedOutboundGroupItem>
) {
companion object {
fun fromOutbound(group: OutboundGroup): ParsedOutboundGroup {
val outboundItems = group.items
val items = mutableListOf<ParsedOutboundGroupItem>()
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)
}

View File

@@ -14,7 +14,8 @@
"addToClipboard": "Add to clipboard", "addToClipboard": "Add to clipboard",
"notSet": "Not Set", "notSet": "Not Set",
"agree": "Agree", "agree": "Agree",
"decline": "Decline" "decline": "Decline",
"unknown": "Unknown"
}, },
"intro": { "intro": {
"termsAndPolicyCaution(rich)": "by continuing you agree with ${tap(@:about.termsAndConditions)}", "termsAndPolicyCaution(rich)": "by continuing you agree with ${tap(@:about.termsAndConditions)}",

View File

@@ -14,7 +14,8 @@
"addToClipboard": "Añadir al portapapeles", "addToClipboard": "Añadir al portapapeles",
"notSet": "No establecido", "notSet": "No establecido",
"agree": "Aceptar", "agree": "Aceptar",
"decline": "Rechazar" "decline": "Rechazar",
"unknown": "Desconocido"
}, },
"home": { "home": {
"emptyProfilesMsg": "Comience agregando un perfil de suscripción", "emptyProfilesMsg": "Comience agregando un perfil de suscripción",

View File

@@ -14,7 +14,8 @@
"addToClipboard": "به کلیپ بورد اضافه کنید", "addToClipboard": "به کلیپ بورد اضافه کنید",
"notSet": "تنظیم نشده", "notSet": "تنظیم نشده",
"agree": "موافق", "agree": "موافق",
"decline": "کاهش می یابد" "decline": "کاهش می یابد",
"unknown": "ناشناخته"
}, },
"intro": { "intro": {
"termsAndPolicyCaution(rich)": "در صورت ادامه با ${tap(@:about.termsAndConditions)} موافقت میکنید", "termsAndPolicyCaution(rich)": "در صورت ادامه با ${tap(@:about.termsAndConditions)} موافقت میکنید",

View File

@@ -14,7 +14,8 @@
"addToClipboard": "Копировать в буфер обмена", "addToClipboard": "Копировать в буфер обмена",
"notSet": "Не задано", "notSet": "Не задано",
"agree": "Соглашаться", "agree": "Соглашаться",
"decline": "Отклонить" "decline": "Отклонить",
"unknown": "Неизвестный"
}, },
"intro": { "intro": {
"termsAndPolicyCaution(rich)": "Продолжая, Вы соглашаетесь с ${tap(@:about.termsAndConditions)}", "termsAndPolicyCaution(rich)": "Продолжая, Вы соглашаетесь с ${tap(@:about.termsAndConditions)}",

View File

@@ -14,7 +14,8 @@
"addToClipboard": "Panoya ekle", "addToClipboard": "Panoya ekle",
"notSet": "Ayarlanmadı", "notSet": "Ayarlanmadı",
"agree": "Kabul etmek", "agree": "Kabul etmek",
"decline": "Reddetmek" "decline": "Reddetmek",
"unknown": "Bilinmeyen"
}, },
"intro": { "intro": {
"termsAndPolicyCaution(rich)": "devam ederek ${tap(@:about.termsAndConditions)} kabul etmiş olursunuz", "termsAndPolicyCaution(rich)": "devam ederek ${tap(@:about.termsAndConditions)} kabul etmiş olursunuz",

View File

@@ -14,7 +14,8 @@
"addToClipboard": "添加到剪贴板", "addToClipboard": "添加到剪贴板",
"notSet": "没有设置", "notSet": "没有设置",
"agree": "同意", "agree": "同意",
"decline": "衰退" "decline": "衰退",
"unknown": "未知"
}, },
"intro": { "intro": {
"termsAndPolicyCaution(rich)": "继续即表示您同意 ${tap(@:about.termsAndConditions)}", "termsAndPolicyCaution(rich)": "继续即表示您同意 ${tap(@:about.termsAndConditions)}",

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:dartx/dartx.dart'; import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hiddify/core/app_info/app_info_provider.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/home/widget/empty_profiles_home_body.dart';
import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart';
import 'package:hiddify/features/profile/widget/profile_tile.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:hiddify/utils/utils.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
@@ -53,16 +56,14 @@ class HomePage extends HookConsumerWidget {
AsyncData(value: final profile?) => MultiSliver( AsyncData(value: final profile?) => MultiSliver(
children: [ children: [
ProfileTile(profile: profile, isMain: true), ProfileTile(profile: profile, isMain: true),
const SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Padding( child: Column(
padding: EdgeInsets.only( mainAxisAlignment: MainAxisAlignment.spaceBetween,
left: 8, children: [
right: 8, const Expanded(child: ConnectionButton()),
top: 8, if (Platform.isAndroid) const ActiveProxyFooter(),
bottom: 86, ],
),
child: ConnectionButton(),
), ),
), ),
], ],

View File

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

View File

@@ -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?> ipInfo,
});
@riverpod
Stream<ProxyItemEntity?> 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<IpInfo?> 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<ActiveProxyInfo> 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<void> refreshIpInfo() async {
if (state case AsyncData(:final value) when !value.ipInfo.isLoading) {
ref.invalidate(proxyIpInfoProvider);
}
}
}

View File

@@ -1,3 +1,4 @@
import 'package:dio/dio.dart';
import 'package:fpdart/fpdart.dart'; import 'package:fpdart/fpdart.dart';
import 'package:hiddify/core/http_client/dio_http_client.dart'; import 'package:hiddify/core/http_client/dio_http_client.dart';
import 'package:hiddify/core/utils/exception_handler.dart'; import 'package:hiddify/core/utils/exception_handler.dart';
@@ -9,7 +10,8 @@ import 'package:hiddify/utils/custom_loggers.dart';
abstract interface class ProxyRepository { abstract interface class ProxyRepository {
Stream<Either<ProxyFailure, List<ProxyGroupEntity>>> watchProxies(); Stream<Either<ProxyFailure, List<ProxyGroupEntity>>> watchProxies();
TaskEither<ProxyFailure, IpInfo> getCurrentIpInfo(); Stream<Either<ProxyFailure, List<ProxyGroupEntity>>> watchActiveProxies();
TaskEither<ProxyFailure, IpInfo> getCurrentIpInfo(CancelToken cancelToken);
TaskEither<ProxyFailure, Unit> selectProxy( TaskEither<ProxyFailure, Unit> selectProxy(
String groupTag, String groupTag,
String outboundTag, String outboundTag,
@@ -31,7 +33,6 @@ class ProxyRepositoryImpl
@override @override
Stream<Either<ProxyFailure, List<ProxyGroupEntity>>> watchProxies() { Stream<Either<ProxyFailure, List<ProxyGroupEntity>>> watchProxies() {
return singbox.watchOutbounds().map((event) { return singbox.watchOutbounds().map((event) {
print("outbounds: $event");
final groupWithSelected = { final groupWithSelected = {
for (final group in event) group.tag: group.selected, for (final group in event) group.tag: group.selected,
}; };
@@ -63,6 +64,39 @@ class ProxyRepositoryImpl
); );
} }
@override
Stream<Either<ProxyFailure, List<ProxyGroupEntity>>> 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 @override
TaskEither<ProxyFailure, Unit> selectProxy( TaskEither<ProxyFailure, Unit> selectProxy(
String groupTag, String groupTag,
@@ -92,13 +126,16 @@ class ProxyRepositoryImpl
}; };
@override @override
TaskEither<ProxyFailure, IpInfo> getCurrentIpInfo() { TaskEither<ProxyFailure, IpInfo> getCurrentIpInfo(CancelToken cancelToken) {
return TaskEither.tryCatch( return TaskEither.tryCatch(
() async { () async {
for (final source in _ipInfoSources.entries) { for (final source in _ipInfoSources.entries) {
try { try {
loggy.debug("getting current ip info using [${source.key}]"); loggy.debug("getting current ip info using [${source.key}]");
final response = await client.get<Map<String, dynamic>>(source.key); final response = await client.get<Map<String, dynamic>>(
source.key,
cancelToken: cancelToken,
);
if (response.statusCode == 200 && response.data != null) { if (response.statusCode == 200 && response.data != null) {
return source.value(response.data!); return source.value(response.data!);
} }

View File

@@ -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/data/stats_data_providers.dart';
import 'package:hiddify/features/stats/model/stats_entity.dart'; import 'package:hiddify/features/stats/model/stats_entity.dart';
import 'package:hiddify/utils/custom_loggers.dart'; import 'package:hiddify/utils/custom_loggers.dart';
import 'package:hiddify/utils/riverpod_utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'stats_notifier.g.dart'; part 'stats_notifier.g.dart';
@@ -10,6 +11,7 @@ part 'stats_notifier.g.dart';
class StatsNotifier extends _$StatsNotifier with AppLogger { class StatsNotifier extends _$StatsNotifier with AppLogger {
@override @override
Stream<StatsEntity> build() async* { Stream<StatsEntity> build() async* {
ref.disposeDelay(const Duration(seconds: 10));
final serviceRunning = await ref.watch(serviceRunningProvider.future); final serviceRunning = await ref.watch(serviceRunningProvider.future);
if (serviceRunning) { if (serviceRunning) {
yield* ref yield* ref

View File

@@ -318,6 +318,12 @@ class FFISingboxService with InfraLogger implements SingboxService {
return _outboundsStream = outboundsStream; return _outboundsStream = outboundsStream;
} }
@override
Stream<List<SingboxOutboundGroup>> watchActiveOutbounds() {
// TODO: implement watchActiveOutbounds
throw UnimplementedError();
}
@override @override
TaskEither<String, Unit> selectOutbound(String groupTag, String outboundTag) { TaskEither<String, Unit> selectOutbound(String groupTag, String outboundTag) {
return TaskEither( return TaskEither(

View File

@@ -182,6 +182,23 @@ class PlatformSingboxService with InfraLogger implements SingboxService {
); );
} }
@override
Stream<List<SingboxOutboundGroup>> 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<String, dynamic>);
}).toList();
}
loggy.error("[active group client] unexpected type, msg: $event");
throw "invalid type";
},
);
}
@override @override
Stream<SingboxStatus> watchStatus() => _status; Stream<SingboxStatus> watchStatus() => _status;

View File

@@ -56,6 +56,8 @@ abstract interface class SingboxService {
Stream<List<SingboxOutboundGroup>> watchOutbounds(); Stream<List<SingboxOutboundGroup>> watchOutbounds();
Stream<List<SingboxOutboundGroup>> watchActiveOutbounds();
TaskEither<String, Unit> selectOutbound(String groupTag, String outboundTag); TaskEither<String, Unit> selectOutbound(String groupTag, String outboundTag);
TaskEither<String, Unit> urlTest(String groupTag); TaskEither<String, Unit> urlTest(String groupTag);

View File

@@ -169,6 +169,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.0" 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: cli_util:
dependency: transitive dependency: transitive
description: description:
@@ -449,6 +457,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter

View File

@@ -72,6 +72,8 @@ dependencies:
cupertino_http: ^1.3.0 cupertino_http: ^1.3.0
wolt_modal_sheet: ^0.4.0 wolt_modal_sheet: ^0.4.0
dart_mappable: ^4.2.0 dart_mappable: ^4.2.0
fluentui_system_icons: ^1.1.225
circle_flags: ^4.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: