diff --git a/lib/presentation/pages/settings/network_test_page.dart b/lib/presentation/pages/settings/network_test_page.dart index 905e31f..a8f3141 100644 --- a/lib/presentation/pages/settings/network_test_page.dart +++ b/lib/presentation/pages/settings/network_test_page.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; import '../../widgets/desktop_constrained.dart'; +import '../../widgets/toast_helper.dart'; enum _NetworkTestTab { network, service, custom } @@ -20,6 +21,13 @@ class NetworkTestPage extends StatefulWidget { class _NetworkTestPageState extends State { static final _panUri = Uri.parse('https://pan.gongyun.org'); static final _infoUri = Uri.parse('https://www.gongyun.org/appnetinfo.html'); + static final _primaryIpLookupUri = Uri.parse('https://ipwho.is/'); + static final _secondaryIpLookupUri = Uri.parse('https://ipapi.co/json/'); + static const _probeHeaders = {'Cache-Control': 'no-cache'}; + static const _probeGetFallbackHeaders = { + 'Cache-Control': 'no-cache', + 'Range': 'bytes=0-0', + }; _NetworkTestTab _tab = _NetworkTestTab.network; bool _networkLoading = false; @@ -44,8 +52,7 @@ class _NetworkTestPageState extends State { IconButton( tooltip: '说明', icon: const Icon(Icons.info_outline), - onPressed: () => - launchUrl(_infoUri, mode: LaunchMode.externalApplication), + onPressed: () => _openExternalUrl(_infoUri), ), ], ), @@ -71,7 +78,7 @@ class _NetworkTestPageState extends State { ButtonSegment( value: _NetworkTestTab.custom, icon: Icon(Icons.route_outlined), - label: Text('自定义'), + label: Text('自定义线路'), ), ], selected: {_tab}, @@ -129,7 +136,7 @@ class _NetworkTestPageState extends State { context: context, builder: (dialogContext) => AlertDialog( title: const Text('自定义线路'), - content: const Text('此功能暂不可用'), + content: const Text('自定义线路为会员功能,当前版本暂未开放。'), actions: [ FilledButton( onPressed: () => Navigator.of(dialogContext).pop(), @@ -140,7 +147,20 @@ class _NetworkTestPageState extends State { ); } + Future _openExternalUrl(Uri uri) async { + try { + final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!opened) { + ToastHelper.failure('无法打开链接: $uri'); + } + } catch (e) { + ToastHelper.failure('无法打开链接: $e'); + } + } + Future _runNetworkTest() async { + if (_networkLoading) return; + setState(() { _networkLoading = true; _networkError = null; @@ -171,6 +191,8 @@ class _NetworkTestPageState extends State { } Future _runServiceTest() async { + if (_serviceLoading) return; + setState(() { _serviceLoading = true; _serviceError = null; @@ -246,7 +268,7 @@ class _NetworkTestPageState extends State { Future<_IpInfo> _fetchIpInfo() async { try { final response = await http - .get(Uri.parse('https://ipwho.is/')) + .get(_primaryIpLookupUri) .timeout(const Duration(seconds: 8)); if (response.statusCode >= 200 && response.statusCode < 300) { final data = jsonDecode(response.body) as Map; @@ -270,7 +292,7 @@ class _NetworkTestPageState extends State { try { final response = await http - .get(Uri.parse('https://ipapi.co/json/')) + .get(_secondaryIpLookupUri) .timeout(const Duration(seconds: 8)); if (response.statusCode >= 200 && response.statusCode < 300) { final data = jsonDecode(response.body) as Map; @@ -307,7 +329,7 @@ class _NetworkTestPageState extends State { final response = await http .get( Uri.parse('https://status.gongyun.org/api/monitor?pageId=$pageId'), - headers: const {'Cache-Control': 'no-cache'}, + headers: _probeHeaders, ) .timeout(const Duration(seconds: 10)); if (response.statusCode < 200 || response.statusCode >= 300) { @@ -367,13 +389,19 @@ class _NetworkTestPageState extends State { }) async { final stopwatch = Stopwatch()..start(); try { - final response = await http - .get(uri, headers: const {'Cache-Control': 'no-cache'}) + var response = await http + .head(uri, headers: _probeHeaders) .timeout(const Duration(seconds: 8)); + if (_shouldRetryProbeWithGet(response.statusCode)) { + response = await http + .get(uri, headers: _probeGetFallbackHeaders) + .timeout(const Duration(seconds: 8)); + } stopwatch.stop(); - final isHealthy = healthyStatusCodes.isEmpty - ? response.statusCode >= 200 && response.statusCode < 500 - : healthyStatusCodes.contains(response.statusCode); + final isHealthy = _isHealthyStatus( + response.statusCode, + healthyStatusCodes, + ); return _ProbeResult( reachable: isHealthy, latencyMs: stopwatch.elapsedMilliseconds, @@ -384,6 +412,17 @@ class _NetworkTestPageState extends State { } } + bool _shouldRetryProbeWithGet(int statusCode) { + return statusCode == 403 || statusCode == 405 || statusCode == 501; + } + + bool _isHealthyStatus(int statusCode, Set healthyStatusCodes) { + if (healthyStatusCodes.isNotEmpty) { + return healthyStatusCodes.contains(statusCode); + } + return statusCode >= 200 && statusCode < 500; + } + Future<_StabilityResult> _testStability(Uri uri) async { final latencies = []; var successCount = 0; @@ -438,7 +477,7 @@ class _NetworkPanel extends StatelessWidget { if (loading) const LinearProgressIndicator(), if (error != null) _ErrorText(error!), _InfoRow( - label: '公云存储官网访问测试', + label: '公云存储网盘访问测试', value: data == null ? '检测中' : (data.panReachable ? '正常' : '离线'), valueColor: _statusColor(context, data?.panReachable), ), @@ -463,11 +502,14 @@ class _NetworkPanel extends StatelessWidget { title: '说明', children: const [ _DescriptionLine('公云存储作为国际化云存储产品,已接入Cloudflare CDN作为全球加速引擎。'), + _DescriptionLine( + '当前网络 IP 地址、AS 与运营商信息通过第三方服务 ipwho.is 查询;如该服务不可用,将使用 ipapi.co 作为备用查询。', + ), _DescriptionLine( '由于部分国家/地区有法律法规限制与审查,可能会出现包括但不限于网络波动、网络丢包以及网络连接不稳定现象', ), _DescriptionLine( - '如诺出现网络连接问题您可将本页面内容向公云存储团队进行报告,团队会根据相关诊断线索进行排查与优化。', + '如若出现网络连接问题,您可将本页面内容向公云存储团队报告,团队会根据相关诊断线索进行排查与优化。', ), ], ), diff --git a/lib/presentation/pages/settings/settings_page.dart b/lib/presentation/pages/settings/settings_page.dart index c5323a6..af6a304 100644 --- a/lib/presentation/pages/settings/settings_page.dart +++ b/lib/presentation/pages/settings/settings_page.dart @@ -167,9 +167,8 @@ class _SettingsPageState extends State { subtitle: const Text('gongyun_app'), trailing: const Icon(Icons.open_in_new, size: 16), onTap: () { - launchUrl( + _openExternalUrl( Uri.parse('https://git.saont.net/gongyun/app'), - mode: LaunchMode.externalApplication, ); }, ), @@ -179,11 +178,10 @@ class _SettingsPageState extends State { subtitle: const Text('cloudreve4_flutter'), trailing: const Icon(Icons.open_in_new, size: 16), onTap: () { - launchUrl( + _openExternalUrl( Uri.parse( 'https://github.com/LimoYuan/cloudreve4_flutter', ), - mode: LaunchMode.externalApplication, ); }, ), @@ -405,13 +403,16 @@ class _SettingsPageState extends State { return AnimatedBuilder( animation: UpdateService.instance, builder: (context, _) { + final hasUpdate = UpdateService.instance.hasUpdate; return ListTile( - leading: const Icon(Icons.tag), + leading: Icon( + hasUpdate ? Icons.new_releases_outlined : Icons.tag, + color: hasUpdate ? Theme.of(context).colorScheme.primary : null, + ), title: const Text('版本号'), subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion), - trailing: UpdateService.instance.hasUpdate - ? const _BlinkingUpdateDot() - : null, + onTap: hasUpdate ? () => _openDownloadsPage() : null, + trailing: hasUpdate ? const _UpdateAvailableBadge() : null, ); }, ); @@ -562,6 +563,25 @@ class _SettingsPageState extends State { } } + Future _openExternalUrl(Uri uri) async { + try { + final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!opened) { + ToastHelper.failure('无法打开链接: $uri'); + } + } catch (e) { + ToastHelper.failure('无法打开链接: $e'); + } + } + + Future _openDownloadsPage() async { + try { + await UpdateService.instance.openDownloadsPage(); + } catch (e) { + ToastHelper.failure('打开下载页面失败: $e'); + } + } + Future _openNetworkTest(BuildContext context) async { final agreed = await showDialog( context: context, @@ -569,13 +589,11 @@ class _SettingsPageState extends State { builder: (dialogContext) => AlertDialog( title: const Text('会话信息授权'), content: _NetworkTestConsentText( - onOpenPrivacy: () => launchUrl( + onOpenPrivacy: () => _openExternalUrl( Uri.parse('https://www.gongyun.org/policy/privacy.html'), - mode: LaunchMode.externalApplication, ), - onOpenTerms: () => launchUrl( + onOpenTerms: () => _openExternalUrl( Uri.parse('https://www.gongyun.org/policy/terms.html'), - mode: LaunchMode.externalApplication, ), ), actions: [ @@ -641,14 +659,14 @@ class _SettingsPageState extends State { } } -class _BlinkingUpdateDot extends StatefulWidget { - const _BlinkingUpdateDot(); +class _UpdateAvailableBadge extends StatefulWidget { + const _UpdateAvailableBadge(); @override - State<_BlinkingUpdateDot> createState() => _BlinkingUpdateDotState(); + State<_UpdateAvailableBadge> createState() => _UpdateAvailableBadgeState(); } -class _BlinkingUpdateDotState extends State<_BlinkingUpdateDot> +class _UpdateAvailableBadgeState extends State<_UpdateAvailableBadge> with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _opacity; @@ -674,15 +692,31 @@ class _BlinkingUpdateDotState extends State<_BlinkingUpdateDot> @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return FadeTransition( opacity: _opacity, - child: Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: colorScheme.error, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '发现新版本', + style: TextStyle( + color: colorScheme.error, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 4), + Icon(Icons.chevron_right, size: 18, color: colorScheme.error), + ], ), ); } @@ -734,9 +768,8 @@ class _NetworkTestConsentTextState extends State<_NetworkTestConsentText> { fontWeight: FontWeight.w700, ); - return RichText( - text: TextSpan( - style: Theme.of(context).textTheme.bodyMedium, + return Text.rich( + TextSpan( children: [ const TextSpan(text: '您需同意'), TextSpan( @@ -750,7 +783,10 @@ class _NetworkTestConsentTextState extends State<_NetworkTestConsentText> { style: linkStyle, recognizer: _termsRecognizer, ), - const TextSpan(text: '方可进入网络测试页。'), + const TextSpan( + text: + '方可进入网络测试页。测试会访问公云存储服务,并通过第三方服务 ipwho.is 查询当前网络 IP、AS 与运营商信息;如 ipwho.is 不可用,将使用 ipapi.co 作为备用查询。', + ), ], ), ); diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 4f3bbde..ce012b7 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -15,7 +15,16 @@ import '../data/models/app_update_model.dart'; import 'storage_service.dart'; class UpdateService extends ChangeNotifier { - UpdateService._(); + UpdateService._() + : _dio = Dio(_dioOptions), + _packageInfoProvider = PackageInfo.fromPlatform; + + @visibleForTesting + UpdateService.test({ + Dio? dio, + Future Function()? packageInfoProvider, + }) : _dio = dio ?? Dio(_dioOptions), + _packageInfoProvider = packageInfoProvider ?? PackageInfo.fromPlatform; static final UpdateService instance = UpdateService._(); @@ -29,15 +38,16 @@ class UpdateService extends ChangeNotifier { 'https://www.gongyun.org/downloads.html', ); - final Dio _dio = Dio( - BaseOptions( - connectTimeout: const Duration(seconds: 12), - receiveTimeout: const Duration(seconds: 20), - followRedirects: true, - responseType: ResponseType.plain, - ), + static final BaseOptions _dioOptions = BaseOptions( + connectTimeout: const Duration(seconds: 12), + receiveTimeout: const Duration(seconds: 20), + followRedirects: true, + responseType: ResponseType.plain, ); + final Dio _dio; + final Future Function() _packageInfoProvider; + bool _checking = false; AppUpdateInfo? _availableUpdate; @@ -49,7 +59,7 @@ class UpdateService extends ChangeNotifier { _checking = true; try { - final current = await PackageInfo.fromPlatform(); + final current = await _packageInfoProvider(); final latest = await _fetchLatestRelease(currentVersion: current.version); if (latest == null || _compareVersions(latest.version, current.version) <= 0) { diff --git a/test/services/update_service_test.dart b/test/services/update_service_test.dart new file mode 100644 index 0000000..b0631f5 --- /dev/null +++ b/test/services/update_service_test.dart @@ -0,0 +1,23 @@ +import 'package:cloudreve4_flutter/services/update_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('UpdateService', () { + test( + 'checkForUpdate releases checking flag when package info fails', + () async { + var calls = 0; + final service = UpdateService.test( + packageInfoProvider: () async { + calls += 1; + throw Exception('package info unavailable'); + }, + ); + + expect(await service.checkForUpdate(), isNull); + expect(await service.checkForUpdate(), isNull); + expect(calls, 2); + }, + ); + }); +}