diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c523a7f058..d1939a8d98 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -119,14 +119,14 @@ jobs: uses: actions/checkout@v6 - name: Import Apple Codesign Certificates - if: ${{ inputs.upload-artifact && startsWith(matrix.os,'macos') }} + if: ${{ inputs.upload-artifact && startsWith(matrix.os,'macos') && secrets.APPLE_CERTIFICATE_P12 != '' }} uses: apple-actions/import-codesign-certs@v6 with: p12-file-base64: "${{ secrets.APPLE_CERTIFICATE_P12 }}" p12-password: "${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }}" - name: Import Apple Mobile Provisioning Profile - if: ${{ inputs.upload-artifact && startsWith(matrix.os,'macos') }} + if: ${{ inputs.upload-artifact && startsWith(matrix.os,'macos') && secrets.APPLE_MOBILE_PROVISIONING_PROFILES_TARGZ_BASE64 != '' }} run: | mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles echo "${{secrets.APPLE_MOBILE_PROVISIONING_PROFILES_TARGZ_BASE64}}"|base64 --decode | tar xz -C ~/Library/MobileDevice/Provisioning\ Profiles @@ -309,6 +309,10 @@ jobs: cp ./build/app/outputs/flutter-apk/*armeabi-v7a*.apk out/${TARGET_NAME_apk}-arm7.apk || echo "no arm7 apk" cp ./build/app/outputs/flutter-apk/*x86_64*.apk out/${TARGET_NAME_apk}-x86_64.apk || echo "no x64 apk" cp ./build/app/outputs/flutter-apk/app-release.apk out/${TARGET_NAME_apk}-universal.apk || echo "no universal apk" + cp ./build/app/outputs/apk/release/*arm64-v8a*.apk out/${TARGET_NAME_apk}-arm64.apk || echo "no arm64 apk in release" + cp ./build/app/outputs/apk/release/*armeabi-v7a*.apk out/${TARGET_NAME_apk}-arm7.apk || echo "no arm7 apk in release" + cp ./build/app/outputs/apk/release/*x86_64*.apk out/${TARGET_NAME_apk}-x86_64.apk || echo "no x64 apk in release" + cp ./build/app/outputs/apk/release/app-release.apk out/${TARGET_NAME_apk}-universal.apk || echo "no universal apk in release" - name: Copy to out Android AAB if: matrix.platform == 'android-aab' @@ -383,8 +387,10 @@ jobs: - name: Display Files Structure - run: ls -R - working-directory: ./out + run: | + mkdir -p out + ls -R + working-directory: ./ - name: Delete Current Release Assets uses: 8Mi-Tech/delete-release-assets-action@main @@ -495,17 +501,20 @@ jobs: path: ./out/ - uses: Apple-Actions/import-codesign-certs@v6 + if: ${{ secrets.APPLE_UPLOAD_CERTIFICATE_P12 != '' }} with: p12-file-base64: ${{ secrets.APPLE_UPLOAD_CERTIFICATE_P12 }} p12-password: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }} - uses: Apple-Actions/download-provisioning-profiles@v1 + if: ${{ secrets.APPSTORE_ISSUER_ID != '' }} with: bundle-id: app.hiddify.com issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} - uses: Apple-Actions/download-provisioning-profiles@v1 + if: ${{ secrets.APPSTORE_ISSUER_ID != '' }} with: bundle-id: app.hiddify.com.SingBoxPacketTunnel issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} @@ -515,12 +524,14 @@ jobs: - name: Import Apple Mobile Provisioning Profile + if: ${{ secrets.APPLE_DIST_PROVISIONING_PROFILES_TARGZ_BASE64 != '' }} run: | mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles echo "${{secrets.APPLE_DIST_PROVISIONING_PROFILES_TARGZ_BASE64}}"|base64 --decode | tar xz -C ~/Library/MobileDevice/Provisioning\ Profiles #echo "${{secrets.NEW_APPLE_STORE_PROVISIONING_PROFILES_TARXZ_BASE64}}"|base64 --decode | tar xJ -C ~/Library/MobileDevice/Provisioning\ Profiles - uses: Apple-Actions/upload-testflight-build@v4 + if: ${{ secrets.APPSTORE_ISSUER_ID != '' }} with: app-path: 'out/Hiddify-iOS.ipa' issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} diff --git a/.gitmodules b/.gitmodules index 85256c3a0e..87bd04db09 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "hiddify-core"] - path = hiddify-core - url = ssh://git@github.com/hiddify/hiddify-core - branch = v3 + path = hiddify-core + url = https://github.com/hiddify/hiddify-core.git + branch = v3 diff --git a/lib/core/battery_saver/battery_saver_service.dart b/lib/core/battery_saver/battery_saver_service.dart new file mode 100644 index 0000000000..e76d3ff8e8 --- /dev/null +++ b/lib/core/battery_saver/battery_saver_service.dart @@ -0,0 +1,30 @@ +import 'package:battery_plus/battery_plus.dart'; + +class BatterySaverService { + final Battery _battery = Battery(); + + Future isBatteryLow() async { + try { + final level = await _battery.batteryLevel; + final state = await _battery.batteryState; + return level < 20 && state != BatteryState.charging && state != BatteryState.full; + } catch (e) { + // In case of error (e.g. running on unsupported platform), assume false + return false; + } + } + + Future isInPowerSaveMode() async { + try { + return await _battery.isInBatterySaveMode; + } catch (e) { + return false; + } + } + + Future shouldPauseHeavyTasks() async { + final isLow = await isBatteryLow(); + final isSaving = await isInPowerSaveMode(); + return isLow || isSaving; + } +} diff --git a/lib/core/caching/benchmark_cache_manager.dart b/lib/core/caching/benchmark_cache_manager.dart new file mode 100644 index 0000000000..fd3de7788f --- /dev/null +++ b/lib/core/caching/benchmark_cache_manager.dart @@ -0,0 +1,64 @@ +import 'package:hiddify/core/battery_saver/battery_saver_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'benchmark_cache_manager.g.dart'; + +@Riverpod(keepAlive: true) +BenchmarkCacheManager benchmarkCacheManager(BenchmarkCacheManagerRef ref) { + return BenchmarkCacheManager(); +} + +class BenchmarkCacheManager { + final Map _cache = {}; + final BatterySaverService _batterySaver = BatterySaverService(); + + Future shouldBenchmark(String tag, {bool force = false}) async { + if (force) return true; + + if (await _batterySaver.shouldPauseHeavyTasks()) { + return false; // Skip entirely if battery is low or saving power + } + + final entry = _cache[tag]; + if (entry == null) return true; // New node + + if (entry.hasFailed) return true; // Failed node + + if (entry.isDegraded) return true; // Degraded performance + + return entry.isExpired; + } + + void updateCache(String tag, int delay, {bool isDegraded = false, bool hasFailed = false}) { + _cache[tag] = _BenchmarkResult( + timestamp: DateTime.now(), + delay: delay, + isDegraded: isDegraded, + hasFailed: hasFailed, + ); + } + + int? getCachedDelay(String tag) => _cache[tag]?.delay; + + void clearCache() { + _cache.clear(); + } +} + +class _BenchmarkResult { + final DateTime timestamp; + final int delay; + final bool isDegraded; + final bool hasFailed; + + _BenchmarkResult({ + required this.timestamp, + required this.delay, + this.isDegraded = false, + this.hasFailed = false, + }); + + bool get isExpired { + return DateTime.now().difference(timestamp) > const Duration(minutes: 10); + } +} diff --git a/lib/core/diagnostics/smart_diagnostics_service.dart b/lib/core/diagnostics/smart_diagnostics_service.dart new file mode 100644 index 0000000000..e5f0c2d949 --- /dev/null +++ b/lib/core/diagnostics/smart_diagnostics_service.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'package:hiddify/utils/custom_loggers.dart'; + +enum DiagnosticIssueType { dns, mtu, ipv6, routing, general } + +class DiagnosticResult { + final DiagnosticIssueType type; + final String description; + final String suggestion; + + DiagnosticResult({ + required this.type, + required this.description, + required this.suggestion, + }); +} + +class SmartDiagnosticsService with AppLogger { + Future> analyzeConnectionError(dynamic error, String? stackTrace) async { + loggy.info('Analyzing connection error: $error'); + final results = []; + final errorString = error.toString().toLowerCase(); + + if (errorString.contains('dns') || errorString.contains('resolve')) { + results.add( + DiagnosticResult( + type: DiagnosticIssueType.dns, + description: 'DNS resolution failed. The application could not resolve the server address.', + suggestion: 'Try changing your DNS server settings (e.g., to 1.1.1.1 or 8.8.8.8) or disable IPv6 if your network does not support it.', + ), + ); + } + + if (errorString.contains('mtu') || errorString.contains('fragment')) { + results.add( + DiagnosticResult( + type: DiagnosticIssueType.mtu, + description: 'MTU size issue detected. Packets may be dropping because they are too large.', + suggestion: 'Lower the MTU size in the connection settings. A value of 1280 to 1420 often helps.', + ), + ); + } + + if (errorString.contains('ipv6') || errorString.contains('network is unreachable')) { + results.add( + DiagnosticResult( + type: DiagnosticIssueType.ipv6, + description: 'IPv6 connectivity issue detected.', + suggestion: 'Try forcing IPv4 in the settings if your ISP does not fully support IPv6.', + ), + ); + } + + if (errorString.contains('route') || errorString.contains('no route to host')) { + results.add( + DiagnosticResult( + type: DiagnosticIssueType.routing, + description: 'Routing problem detected. The connection cannot reach the server.', + suggestion: 'Check if you are on a restricted network. You might need to change the node or enable a proxy.', + ), + ); + } + + if (results.isEmpty) { + results.add( + DiagnosticResult( + type: DiagnosticIssueType.general, + description: 'A general connection error occurred.', + suggestion: 'Check your internet connection, try a different node, or update the app.', + ), + ); + } + + return results; + } +} diff --git a/lib/features/cloudflare_scanner/cloudflare_scanner_service.dart b/lib/features/cloudflare_scanner/cloudflare_scanner_service.dart new file mode 100644 index 0000000000..ef7a1fa815 --- /dev/null +++ b/lib/features/cloudflare_scanner/cloudflare_scanner_service.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'package:dart_ping/dart_ping.dart'; +import 'package:hiddify/core/battery_saver/battery_saver_service.dart'; +import 'package:hiddify/features/cloudflare_scanner/model/cloudflare_ip_info.dart'; +import 'package:hiddify/utils/custom_loggers.dart'; + +class CloudflareScannerService with AppLogger { + List candidateIps = [ + '1.1.1.1', + '1.0.0.1', + '104.16.132.229', + '104.16.133.229', + '104.16.134.229', + ]; + + final BatterySaverService _batterySaver = BatterySaverService(); + final Map _scanCache = {}; + + Future getBestIp({bool forceScan = false}) async { + if (!forceScan && await _batterySaver.shouldPauseHeavyTasks()) { + loggy.info('Skipping Cloudflare IP scan due to battery constraints'); + return _getBestCachedIp(); + } + + final bestCached = _getBestCachedIp(); + if (!forceScan && bestCached != null && bestCached.isReachable && bestCached.latency < 200) { + loggy.debug('Reusing cached Cloudflare IP: ${bestCached.ip}'); + return bestCached; + } + + loggy.info('Starting Cloudflare IP scan'); + final results = []; + + for (final ip in candidateIps) { + final ping = Ping(ip, count: 3, timeout: 2); + int totalTime = 0; + int receivedCount = 0; + + await for (final data in ping.stream) { + if (data.response != null && data.response!.time != null) { + totalTime += data.response!.time!.inMilliseconds; + receivedCount++; + } + } + + int packetLoss = receivedCount > 0 ? ((3 - receivedCount) / 3 * 100).toInt() : 100; + int latency = receivedCount > 0 ? (totalTime / receivedCount).toInt() : -1; + + final info = CloudflareIpInfo(ip: ip, latency: latency, packetLoss: packetLoss); + _scanCache[ip] = info; + + if (info.isReachable) { + results.add(info); + } + } + + if (results.isEmpty) { + loggy.warning('No reachable Cloudflare IP found'); + return null; + } + + results.sort((a, b) { + if (a.packetLoss != b.packetLoss) return a.packetLoss.compareTo(b.packetLoss); + return a.latency.compareTo(b.latency); + }); + + return results.first; + } + + CloudflareIpInfo? _getBestCachedIp() { + if (_scanCache.isEmpty) return null; + final cachedReachable = _scanCache.values.where((info) => info.isReachable).toList(); + if (cachedReachable.isEmpty) return null; + + cachedReachable.sort((a, b) { + if (a.packetLoss != b.packetLoss) return a.packetLoss.compareTo(b.packetLoss); + return a.latency.compareTo(b.latency); + }); + return cachedReachable.first; + } +} diff --git a/lib/features/cloudflare_scanner/model/cloudflare_ip_info.dart b/lib/features/cloudflare_scanner/model/cloudflare_ip_info.dart new file mode 100644 index 0000000000..92b5c6fa3a --- /dev/null +++ b/lib/features/cloudflare_scanner/model/cloudflare_ip_info.dart @@ -0,0 +1,13 @@ +class CloudflareIpInfo { + final String ip; + final int latency; + final int packetLoss; + + CloudflareIpInfo({ + required this.ip, + required this.latency, + required this.packetLoss, + }); + + bool get isReachable => packetLoss < 100 && latency > 0; +} diff --git a/lib/features/connection/notifier/connection_notifier.dart b/lib/features/connection/notifier/connection_notifier.dart index 5953f8b9f4..f1f33e4e8e 100644 --- a/lib/features/connection/notifier/connection_notifier.dart +++ b/lib/features/connection/notifier/connection_notifier.dart @@ -8,6 +8,7 @@ import 'package:hiddify/features/connection/data/connection_data_providers.dart' import 'package:hiddify/features/connection/data/connection_repository.dart'; import 'package:hiddify/features/connection/model/connection_failure.dart'; import 'package:hiddify/features/connection/model/connection_status.dart'; +import 'package:hiddify/core/diagnostics/smart_diagnostics_service.dart'; import 'package:hiddify/features/profile/model/profile_entity.dart'; import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart'; import 'package:hiddify/hiddifycore/init_signal.dart'; @@ -20,6 +21,11 @@ import 'package:sentry_flutter/sentry_flutter.dart'; part 'connection_notifier.g.dart'; +@Riverpod(keepAlive: true) +SmartDiagnosticsService smartDiagnosticsService(SmartDiagnosticsServiceRef ref) { + return SmartDiagnosticsService(); +} + @Riverpod(keepAlive: true) class ConnectionNotifier extends _$ConnectionNotifier with AppLogger { @override @@ -145,10 +151,29 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger { ConnectionFailure err, ) async { loggy.warning("error connecting", err); - //Go err is not normal object to see the go errors are string and need to be dumped - await ref - .read(dialogNotifierProvider.notifier) - .showCustomAlertFromErr(err.present(ref.read(translationsProvider).requireValue)); + + // Perform Smart Diagnostics + try { + final diagnostics = await ref.read(smartDiagnosticsServiceProvider).analyzeConnectionError(err, null); + if (diagnostics.isNotEmpty) { + final diagMsg = diagnostics.map((d) => "${d.description}\nSuggestion: ${d.suggestion}").join("\n\n"); + final originalErr = err.present(ref.read(translationsProvider).requireValue); + final updatedMsg = "${originalErr.message ?? 'Unknown error'}\n\nDiagnostics:\n$diagMsg"; + await ref.read(dialogNotifierProvider.notifier).showCustomAlertFromErr( + (type: originalErr.type, message: updatedMsg) + ); + } else { + await ref.read(dialogNotifierProvider.notifier).showCustomAlertFromErr( + err.present(ref.read(translationsProvider).requireValue) + ); + } + } catch (e) { + loggy.warning("Diagnostics failed", e); + await ref + .read(dialogNotifierProvider.notifier) + .showCustomAlertFromErr(err.present(ref.read(translationsProvider).requireValue)); + } + loggy.warning(err); if (err.toString().contains("panic")) { await Sentry.captureException(Exception(err.toString())); diff --git a/lib/features/dashboard/notifier/performance_dashboard_notifier.dart b/lib/features/dashboard/notifier/performance_dashboard_notifier.dart new file mode 100644 index 0000000000..ee4b479e8a --- /dev/null +++ b/lib/features/dashboard/notifier/performance_dashboard_notifier.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'package:hiddify/core/battery_saver/battery_saver_service.dart'; +import 'package:hiddify/features/cloudflare_scanner/cloudflare_scanner_service.dart'; +import 'package:hiddify/features/connection/notifier/connection_notifier.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'performance_dashboard_notifier.g.dart'; + +class PerformanceDashboardState { + final String nodeScore; + final String ping; + final String downloadSpeed; + final String uploadSpeed; + final String packetLoss; + final String jitter; + final String connectionUptime; + final String memoryUsage; + final String currentProtocol; + final bool isLowBattery; + + PerformanceDashboardState({ + this.nodeScore = "Unknown", + this.ping = "0ms", + this.downloadSpeed = "0 Mbps", + this.uploadSpeed = "0 Mbps", + this.packetLoss = "0%", + this.jitter = "0ms", + this.connectionUptime = "0m", + this.memoryUsage = "0MB", + this.currentProtocol = "Unknown", + this.isLowBattery = false, + }); + + PerformanceDashboardState copyWith({ + String? nodeScore, + String? ping, + String? downloadSpeed, + String? uploadSpeed, + String? packetLoss, + String? jitter, + String? connectionUptime, + String? memoryUsage, + String? currentProtocol, + bool? isLowBattery, + }) { + return PerformanceDashboardState( + nodeScore: nodeScore ?? this.nodeScore, + ping: ping ?? this.ping, + downloadSpeed: downloadSpeed ?? this.downloadSpeed, + uploadSpeed: uploadSpeed ?? this.uploadSpeed, + packetLoss: packetLoss ?? this.packetLoss, + jitter: jitter ?? this.jitter, + connectionUptime: connectionUptime ?? this.connectionUptime, + memoryUsage: memoryUsage ?? this.memoryUsage, + currentProtocol: currentProtocol ?? this.currentProtocol, + isLowBattery: isLowBattery ?? this.isLowBattery, + ); + } +} + +@Riverpod(keepAlive: true) +class PerformanceDashboardNotifier extends _$PerformanceDashboardNotifier { + final BatterySaverService _batterySaverService = BatterySaverService(); + final CloudflareScannerService _cloudflareScannerService = CloudflareScannerService(); + + bool _running = false; + + @override + Stream build() async* { + var currentState = PerformanceDashboardState(); + yield currentState; + + _running = true; + ref.onDispose(() => _running = false); + + while (_running) { + await Future.delayed(const Duration(seconds: 30)); + if (!_running) break; + + final isLow = await _batterySaverService.isBatteryLow(); + // Scan cloudflare only if needed (e.g. low battery bypasses this). + // getBestIp respects the battery check and won't leak connections. + final bestIp = await _cloudflareScannerService.getBestIp(); + + currentState = currentState.copyWith( + isLowBattery: isLow, + ping: bestIp != null ? "${bestIp.latency}ms" : "N/A", + packetLoss: bestIp != null ? "${bestIp.packetLoss}%" : "0%", + nodeScore: bestIp != null && bestIp.latency < 100 ? "A" : "B", + ); + + yield currentState; + } + } +} diff --git a/lib/features/dashboard/widget/performance_dashboard_widget.dart b/lib/features/dashboard/widget/performance_dashboard_widget.dart new file mode 100644 index 0000000000..cfff68cbef --- /dev/null +++ b/lib/features/dashboard/widget/performance_dashboard_widget.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:hiddify/features/dashboard/notifier/performance_dashboard_notifier.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class PerformanceDashboardWidget extends HookConsumerWidget { + const PerformanceDashboardWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + final dashboardStateAsync = ref.watch(performanceDashboardNotifierProvider); + + return dashboardStateAsync.when( + data: (dashboardState) { + return Card( + margin: const EdgeInsets.all(8.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Performance Dashboard', style: textTheme.titleLarge), + const Divider(), + _buildStatRow('Node Score:', dashboardState.nodeScore), + _buildStatRow('Ping:', dashboardState.ping), + _buildStatRow('Download:', dashboardState.downloadSpeed), + _buildStatRow('Upload:', dashboardState.uploadSpeed), + _buildStatRow('Packet Loss:', dashboardState.packetLoss), + _buildStatRow('Jitter:', dashboardState.jitter), + _buildStatRow('Uptime:', dashboardState.connectionUptime), + _buildStatRow('Protocol:', dashboardState.currentProtocol), + _buildStatRow('Memory Usage:', dashboardState.memoryUsage), + _buildStatRow( + 'Battery Impact:', + dashboardState.isLowBattery ? 'High (Battery Saver Active)' : 'Normal', + valueColor: dashboardState.isLowBattery ? Colors.red : Colors.green, + ), + ], + ), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Text('Error loading dashboard: $error'), + ); + } + + Widget _buildStatRow(String label, String value, {Color? valueColor}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.w500)), + Text( + value, + style: TextStyle( + fontWeight: FontWeight.bold, + color: valueColor, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/home/widget/home_page.dart b/lib/features/home/widget/home_page.dart index 65d1eba7be..83a8bc4219 100644 --- a/lib/features/home/widget/home_page.dart +++ b/lib/features/home/widget/home_page.dart @@ -9,6 +9,7 @@ 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_card.dart'; import 'package:hiddify/features/proxy/active/active_proxy_delay_indicator.dart'; +import 'package:hiddify/features/dashboard/widget/performance_dashboard_widget.dart'; import 'package:hiddify/gen/assets.gen.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:sliver_tools/sliver_tools.dart'; @@ -118,7 +119,11 @@ class HomePage extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, - children: [ConnectionButton(), ActiveProxyDelayIndicator()], + children: [ + ConnectionButton(), + ActiveProxyDelayIndicator(), + const PerformanceDashboardWidget(), + ], ), ), ActiveProxyFooter(), diff --git a/lib/features/proxy/active/active_proxy_notifier.dart b/lib/features/proxy/active/active_proxy_notifier.dart index 646d04467f..25a30f9a5c 100644 --- a/lib/features/proxy/active/active_proxy_notifier.dart +++ b/lib/features/proxy/active/active_proxy_notifier.dart @@ -11,6 +11,8 @@ import 'package:hiddify/features/proxy/model/ip_info_entity.dart' as oldipinfo; import 'package:hiddify/features/proxy/model/proxy_failure.dart'; import 'package:hiddify/hiddifycore/generated/v2/hcore/hcore.pb.dart'; +import 'package:hiddify/core/caching/benchmark_cache_manager.dart'; +import 'package:hiddify/features/proxy/overview/proxies_overview_notifier.dart'; import 'package:hiddify/utils/riverpod_utils.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -93,6 +95,13 @@ class ActiveProxyNotifier extends _$ActiveProxyNotifier with AppLogger { Future urlTest(String? groupTag_) async { final groupTag = groupTag_ ?? ""; + + final cacheManager = ref.read(benchmarkCacheManagerProvider); + if (!await cacheManager.shouldBenchmark(groupTag)) { + loggy.info("skipping ActiveProxy urlTest for [$groupTag] due to battery save or recent cache"); + return; + } + _urlTestThrottler(() async { if (state case AsyncData()) { await ref.read(hapticServiceProvider.notifier).lightImpact(); @@ -100,6 +109,7 @@ class ActiveProxyNotifier extends _$ActiveProxyNotifier with AppLogger { loggy.warning("error testing group", err); throw err; }).run(); + cacheManager.updateCache(groupTag, 0); } }); } diff --git a/lib/features/proxy/overview/proxies_overview_notifier.dart b/lib/features/proxy/overview/proxies_overview_notifier.dart index 1d84b1d26b..f84ff314a3 100644 --- a/lib/features/proxy/overview/proxies_overview_notifier.dart +++ b/lib/features/proxy/overview/proxies_overview_notifier.dart @@ -11,6 +11,7 @@ import 'package:hiddify/features/proxy/data/proxy_data_providers.dart'; import 'package:hiddify/features/proxy/model/proxy_failure.dart'; import 'package:hiddify/hiddifycore/generated/v2/hcore/hcore.pb.dart'; +import 'package:hiddify/core/caching/benchmark_cache_manager.dart'; import 'package:hiddify/utils/riverpod_utils.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -218,6 +219,11 @@ class ProxiesOverviewNotifier extends _$ProxiesOverviewNotifier with AppLogger { } Future urlTest(String groupTag) async { + final cacheManager = ref.read(benchmarkCacheManagerProvider); + if (!await cacheManager.shouldBenchmark(groupTag)) { + loggy.info("skipping test group: [$groupTag] due to battery save or recent cache"); + return; + } loggy.debug("testing group: [$groupTag]"); if (state case AsyncData()) { await ref.read(hapticServiceProvider.notifier).lightImpact(); @@ -225,6 +231,7 @@ class ProxiesOverviewNotifier extends _$ProxiesOverviewNotifier with AppLogger { loggy.error("error testing group", err); throw err; }).run(); + cacheManager.updateCache(groupTag, 0); // we could store actual delay if returned } } } diff --git a/lib/main.dart b/lib/main.dart index 06708576f5..473690591b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,9 +3,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hiddify/bootstrap.dart'; import 'package:hiddify/core/model/environment.dart'; +import 'package:dart_ping_ios/dart_ping_ios.dart'; +import 'dart:io'; Future main() async { final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + if (Platform.isIOS) { + DartPingIOS.register(); + } // final widgetsBinding = SentryWidgetsFlutterBinding.ensureInitialized(); // debugPaintSizeEnabled = true; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 2c2de9f6bc..a588a4b4f5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import app_links +import battery_plus import device_info_plus import dynamic_color import file_picker @@ -24,6 +25,7 @@ import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 3f128e8641..f4befe69b3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.7.0" + battery_plus: + dependency: "direct main" + description: + name: battery_plus + sha256: f14567a9548ac57c010df641acbd35869429b3dc234fec0bb61b01f429285e61 + url: "https://pub.dev" + source: hosted + version: "7.1.0" + battery_plus_platform_interface: + dependency: transitive + description: + name: battery_plus_platform_interface + sha256: e8342c0f32de4b1dfd0223114b6785e48e579bfc398da9471c9179b907fa4910 + url: "https://pub.dev" + source: hosted + version: "2.0.1" bazel_worker: dependency: transitive description: @@ -205,10 +221,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -250,6 +266,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: "direct main" + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" code_builder: dependency: transitive description: @@ -354,6 +378,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.4.0" + dart_ping: + dependency: "direct main" + description: + name: dart_ping + sha256: "2f5418d0a5c64e53486caaac78677b25725b1e13c33c5be834ce874ea18bd24f" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + dart_ping_ios: + dependency: "direct main" + description: + name: dart_ping_ios + sha256: "17df1b369331ec6c30a91a51db64ac8feed47fad71e444208de06edf2a22415f" + url: "https://pub.dev" + source: hosted + version: "4.0.2" dart_style: dependency: transitive description: @@ -370,6 +410,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" decimal: dependency: transitive description: @@ -559,6 +607,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.5" + flutter_icmp_ping: + dependency: transitive + description: + name: flutter_icmp_ping + sha256: de9633cf65a8c733fae29d08a35d3d4b343620cd1d13e1bfa88eccf56696d896 + url: "https://pub.dev" + source: hosted + version: "3.1.3" flutter_keyboard_visibility: dependency: transitive description: @@ -806,6 +862,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + hooks: + dependency: "direct main" + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" hooks_riverpod: dependency: "direct main" description: @@ -1051,18 +1115,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" maybe_just_nothing: dependency: transitive description: @@ -1383,6 +1447,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + record_use: + dependency: "direct main" + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" retry: dependency: transitive description: @@ -1784,10 +1856,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" text_scroll: dependency: "direct main" description: @@ -1868,6 +1940,14 @@ packages: url: "https://pub.dev" source: hosted version: "11.3.1" + upower: + dependency: "direct main" + description: + name: upower + sha256: cf042403154751180affa1d15614db7fa50234bc2373cd21c3db666c38543ebf + url: "https://pub.dev" + source: hosted + version: "0.7.0" url_launcher: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b7d3d84872..821f605dc2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -120,8 +120,17 @@ dependencies: # Used for registering custom schemes in the Windows registry win32: ^5.12.0 humanizer: ^3.0.1 + battery_plus: ^7.1.0 + code_assets: ^1.2.1 + hooks: ^2.0.2 + record_use: ^0.6.0 + upower: ^0.7.0 + dart_ping: ^9.0.0 + dart_ping_ios: ^4.0.0 dev_dependencies: + analyzer: ^7.0.0 + msix: ^3.18.0 # flutter_test: # sdk: flutter lint: ^2.3.0 diff --git a/test/core/battery_saver/battery_saver_service_test.dart b/test/core/battery_saver/battery_saver_service_test.dart new file mode 100644 index 0000000000..3d6af87680 --- /dev/null +++ b/test/core/battery_saver/battery_saver_service_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hiddify/core/battery_saver/battery_saver_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('BatterySaverService', () { + late BatterySaverService service; + + setUp(() { + service = BatterySaverService(); + }); + + test('should return default values gracefully', () async { + // Due to the plugin running on un-implemented platforms during unit tests + // it should fall back to false gracefully without throwing exceptions. + expect(await service.isBatteryLow(), isFalse); + expect(await service.isInPowerSaveMode(), isFalse); + expect(await service.shouldPauseHeavyTasks(), isFalse); + }); + }); +} diff --git a/test/core/caching/benchmark_cache_manager_test.dart b/test/core/caching/benchmark_cache_manager_test.dart new file mode 100644 index 0000000000..0626bb6156 --- /dev/null +++ b/test/core/caching/benchmark_cache_manager_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hiddify/core/caching/benchmark_cache_manager.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('BenchmarkCacheManager', () { + late BenchmarkCacheManager cacheManager; + + setUp(() { + cacheManager = BenchmarkCacheManager(); + }); + + test('shouldBenchmark returns true for new tag', () async { + expect(await cacheManager.shouldBenchmark('node_new'), isTrue); + }); + + test('shouldBenchmark returns false for recently cached tag', () async { + cacheManager.updateCache('node_cached', 100); + expect(await cacheManager.shouldBenchmark('node_cached'), isFalse); + }); + + test('shouldBenchmark returns true when forced', () async { + cacheManager.updateCache('node_cached', 100); + expect(await cacheManager.shouldBenchmark('node_cached', force: true), isTrue); + }); + }); +} diff --git a/test/core/diagnostics/smart_diagnostics_service_test.dart b/test/core/diagnostics/smart_diagnostics_service_test.dart new file mode 100644 index 0000000000..b7f3f7e982 --- /dev/null +++ b/test/core/diagnostics/smart_diagnostics_service_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hiddify/core/diagnostics/smart_diagnostics_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('SmartDiagnosticsService', () { + late SmartDiagnosticsService service; + + setUp(() { + service = SmartDiagnosticsService(); + }); + + test('should detect DNS issues', () async { + final results = await service.analyzeConnectionError('Failed to resolve host', null); + expect(results.length, 1); + expect(results.first.type, DiagnosticIssueType.dns); + }); + + test('should detect MTU issues', () async { + final results = await service.analyzeConnectionError('fragmentation required', null); + expect(results.length, 1); + expect(results.first.type, DiagnosticIssueType.mtu); + }); + + test('should fallback to general on unknown error', () async { + final results = await service.analyzeConnectionError('unknown error', null); + expect(results.length, 1); + expect(results.first.type, DiagnosticIssueType.general); + }); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 30d74013f2..8149299319 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + BatteryPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BatteryPlusWindowsPlugin")); DynamicColorPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index fa47a18d03..71419ea83f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + battery_plus dynamic_color screen_retriever_windows sentry_flutter