diff --git a/.gitea/workflows/android-release.yml b/.gitea/workflows/android-release.yml index cac5f63..998e83c 100644 --- a/.gitea/workflows/android-release.yml +++ b/.gitea/workflows/android-release.yml @@ -140,6 +140,8 @@ jobs: - name: Build Android release APK shell: bash + env: + IP_NODE_API_BASE_URL: ${{ secrets.IP_NODE_API_BASE_URL }} run: | set -euo pipefail @@ -151,8 +153,13 @@ jobs: rm -rf "$pkg_dir" fi + build_args=(--release) + if [ -n "${IP_NODE_API_BASE_URL:-}" ]; then + build_args+=("--dart-define=IP_NODE_API_BASE_URL=${IP_NODE_API_BASE_URL}") + fi + echo "Building Flutter Android release APK..." - flutter build apk -v --release + flutter build apk "${build_args[@]}" mkdir -p "$pkg_dir" apk_path="$(find "$PWD/build/app/outputs" -type f -name '*release*.apk' | head -n 1)" diff --git a/lib/presentation/pages/settings/network_test_page.dart b/lib/presentation/pages/settings/network_test_page.dart index a8f3141..ec878b9 100644 --- a/lib/presentation/pages/settings/network_test_page.dart +++ b/lib/presentation/pages/settings/network_test_page.dart @@ -4,8 +4,12 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../../services/api_service.dart'; +import '../../../services/storage_service.dart'; +import '../../providers/auth_provider.dart'; import '../../widgets/desktop_constrained.dart'; import '../../widgets/toast_helper.dart'; @@ -21,21 +25,29 @@ 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 _telegramUri = Uri.parse('https://t.me/GYNetwork'); static final _primaryIpLookupUri = Uri.parse('https://ipwho.is/'); static final _secondaryIpLookupUri = Uri.parse('https://ipapi.co/json/'); + static const _ipNodeApiBaseUrlOverride = String.fromEnvironment( + 'IP_NODE_API_BASE_URL', + ); static const _probeHeaders = {'Cache-Control': 'no-cache'}; static const _probeGetFallbackHeaders = { 'Cache-Control': 'no-cache', 'Range': 'bytes=0-0', }; + static const _customProbeSampleCount = 3; _NetworkTestTab _tab = _NetworkTestTab.network; bool _networkLoading = false; bool _serviceLoading = false; + bool _customLoading = false; _NetworkDiagnostics? _networkDiagnostics; _ServiceDiagnostics? _serviceDiagnostics; + _CustomLineDiagnostics? _customDiagnostics; String? _networkError; String? _serviceError; + String? _customError; @override void initState() { @@ -82,24 +94,8 @@ class _NetworkTestPageState extends State { ), ], selected: {_tab}, - onSelectionChanged: (selection) { - final selected = selection.first; - if (selected == _NetworkTestTab.custom) { - _showCustomUnavailableDialog(); - return; - } - setState(() => _tab = selected); - if (selected == _NetworkTestTab.network && - _networkDiagnostics == null && - !_networkLoading) { - _runNetworkTest(); - } - if (selected == _NetworkTestTab.service && - _serviceDiagnostics == null && - !_serviceLoading) { - _runServiceTest(); - } - }, + onSelectionChanged: (selection) => + _handleTabSelection(selection.first), ), ), ), @@ -121,7 +117,14 @@ class _NetworkTestPageState extends State { diagnostics: _serviceDiagnostics, onRefresh: _runServiceTest, ), - _NetworkTestTab.custom => const SizedBox.shrink(), + _NetworkTestTab.custom => _CustomLinePanel( + key: const ValueKey('custom'), + loading: _customLoading, + error: _customError, + diagnostics: _customDiagnostics, + onRefresh: _runCustomLineTest, + onOpenTelegram: () => _openExternalUrl(_telegramUri), + ), }, ), ), @@ -131,12 +134,54 @@ class _NetworkTestPageState extends State { ); } - Future _showCustomUnavailableDialog() async { + void _handleTabSelection(_NetworkTestTab selected) { + if (selected == _NetworkTestTab.custom) { + unawaited(_openCustomLineTab()); + return; + } + + setState(() => _tab = selected); + if (selected == _NetworkTestTab.network && + _networkDiagnostics == null && + !_networkLoading) { + _runNetworkTest(); + } + if (selected == _NetworkTestTab.service && + _serviceDiagnostics == null && + !_serviceLoading) { + _runServiceTest(); + } + } + + Future _openCustomLineTab() async { + final auth = context.read(); + final groupName = auth.user?.group?.name.trim(); + if (!auth.isAuthenticated || auth.user == null) { + await _showCustomBlockedDialog('自定义线路需要登录后使用,请先登录后再试。'); + return; + } + if (groupName == null || groupName.isEmpty || _isUserGroup(groupName)) { + await _showCustomBlockedDialog( + '自定义线路为会员功能,您所在用户组为普通用户组请成为非普通用户方可使用"自定义线路"功能。', + ); + return; + } + + setState(() => _tab = _NetworkTestTab.custom); + if (_customDiagnostics == null && !_customLoading) { + await _runCustomLineTest(); + } + } + + bool _isUserGroup(String groupName) => + groupName.trim().toLowerCase() == 'user'; + + Future _showCustomBlockedDialog(String message) async { await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('自定义线路'), - content: const Text('自定义线路为会员功能,当前版本暂未开放。'), + content: Text(message), actions: [ FilledButton( onPressed: () => Navigator.of(dialogContext).pop(), @@ -158,6 +203,191 @@ class _NetworkTestPageState extends State { } } + Future _runCustomLineTest() async { + if (_customLoading) return; + + final auth = context.read(); + final groupName = auth.user?.group?.name.trim(); + if (!auth.isAuthenticated || + auth.user == null || + groupName == null || + groupName.isEmpty || + _isUserGroup(groupName)) { + setState(() { + _customError = '当前用户组无法使用自定义线路功能'; + }); + return; + } + + setState(() { + _customLoading = true; + _customError = null; + }); + + try { + final clientId = await StorageService.instance.getOrCreateClientId(); + final nodes = await _fetchCustomNodes( + clientId: clientId, + groupName: groupName, + ); + final probes = await Future.wait(nodes.map(_testCustomNode)); + final recommended = _bestCustomNode(probes); + + if (!mounted) return; + setState(() { + _customDiagnostics = _CustomLineDiagnostics( + groupName: groupName, + nodes: probes, + recommended: recommended, + ); + }); + } catch (e) { + if (mounted) setState(() => _customError = e.toString()); + } finally { + if (mounted) setState(() => _customLoading = false); + } + } + + Future> _fetchCustomNodes({ + required String clientId, + required String groupName, + }) async { + final handshakeResponse = await http + .post( + _ipNodeApiUri('/api/v1/handshake'), + headers: const { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: jsonEncode({'client_uuid': clientId}), + ) + .timeout(const Duration(seconds: 10)); + if (handshakeResponse.statusCode < 200 || + handshakeResponse.statusCode >= 300) { + throw Exception('IP节点服务握手失败: HTTP ${handshakeResponse.statusCode}'); + } + + final handshakePayload = _payloadObject( + _decodeObject(handshakeResponse.body), + ); + final sessionToken = handshakePayload['session_token']?.toString(); + if (sessionToken == null || sessionToken.isEmpty) { + throw Exception('IP节点服务未返回有效会话密钥'); + } + + final nodesResponse = await http + .get( + _ipNodeApiUri('/api/v1/nodes', queryParameters: {'group': groupName}), + headers: { + 'Accept': 'application/json', + 'X-Client-UUID': clientId, + 'X-Session-Token': sessionToken, + }, + ) + .timeout(const Duration(seconds: 12)); + if (nodesResponse.statusCode < 200 || nodesResponse.statusCode >= 300) { + throw Exception('IP节点池获取失败: HTTP ${nodesResponse.statusCode}'); + } + + final nodesPayload = _payloadObject(_decodeObject(nodesResponse.body)); + final rawNodes = nodesPayload['nodes']; + if (rawNodes is! List) return const []; + + return rawNodes + .whereType() + .map((node) => _CustomNode.fromJson(Map.from(node))) + .toList(); + } + + Map _decodeObject(String body) { + final decoded = jsonDecode(body); + if (decoded is Map) return decoded; + if (decoded is Map) return Map.from(decoded); + throw Exception('IP节点服务返回格式错误'); + } + + Map _payloadObject(Map response) { + final data = response['data']; + if (data is Map) return data; + if (data is Map) return Map.from(data); + return response; + } + + Uri _ipNodeApiUri(String path, {Map? queryParameters}) { + final base = _ipNodeApiBaseUri; + return Uri( + scheme: base.scheme, + userInfo: base.userInfo, + host: base.host, + port: base.hasPort ? base.port : null, + path: _joinUriPath(base.path, path), + queryParameters: queryParameters, + ); + } + + Uri get _ipNodeApiBaseUri { + final override = _ipNodeApiBaseUrlOverride.trim(); + if (override.isNotEmpty) return Uri.parse(override); + + final appApiBase = Uri.tryParse(ApiService.instance.dio.options.baseUrl); + if (appApiBase != null && + appApiBase.hasScheme && + appApiBase.host.isNotEmpty) { + return Uri(scheme: appApiBase.scheme, host: appApiBase.host, port: 7998); + } + return Uri.parse('http://127.0.0.1:7998'); + } + + String _joinUriPath(String basePath, String path) { + final child = path.startsWith('/') ? path.substring(1) : path; + if (basePath.isEmpty || basePath == '/') return '/$child'; + final normalizedBase = basePath.endsWith('/') + ? basePath.substring(0, basePath.length - 1) + : basePath; + return '$normalizedBase/$child'; + } + + Future<_CustomNodeProbe> _testCustomNode(_CustomNode node) async { + final probeUri = node.probeUri ?? node.endpointUri; + if (probeUri == null) { + return _CustomNodeProbe( + node: node, + sampleCount: _customProbeSampleCount, + successCount: 0, + ); + } + + final latencies = []; + var successCount = 0; + for (var i = 0; i < _customProbeSampleCount; i++) { + final probe = await _probeUri(probeUri); + if (probe.reachable) { + successCount += 1; + if (probe.latencyMs != null) latencies.add(probe.latencyMs!); + } + if (i < _customProbeSampleCount - 1) { + await Future.delayed(const Duration(milliseconds: 250)); + } + } + + final averageLatencyMs = latencies.isEmpty + ? null + : (latencies.reduce((a, b) => a + b) / latencies.length).round(); + return _CustomNodeProbe( + node: node, + sampleCount: _customProbeSampleCount, + successCount: successCount, + averageLatencyMs: averageLatencyMs, + ); + } + + _CustomNodeProbe? _bestCustomNode(List<_CustomNodeProbe> probes) { + final reachable = probes.where((probe) => probe.successCount > 0).toList(); + if (reachable.isEmpty) return null; + reachable.sort((a, b) => a.score.compareTo(b.score)); + return reachable.first; + } + Future _runNetworkTest() async { if (_networkLoading) return; @@ -600,6 +830,154 @@ class _ServicePanel extends StatelessWidget { } } +class _CustomLinePanel extends StatelessWidget { + final bool loading; + final String? error; + final _CustomLineDiagnostics? diagnostics; + final VoidCallback onRefresh; + final VoidCallback onOpenTelegram; + + const _CustomLinePanel({ + super.key, + required this.loading, + required this.error, + required this.diagnostics, + required this.onRefresh, + required this.onOpenTelegram, + }); + + @override + Widget build(BuildContext context) { + final data = diagnostics; + final recommended = data?.recommended; + return ListView( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 18), + children: [ + _SectionCard( + title: '推荐节点', + children: [ + if (loading) const LinearProgressIndicator(), + if (error != null) _ErrorText(error!), + if (data != null) ...[ + _InfoRow(label: '当前用户组', value: data.groupName), + _InfoRow(label: '可用节点数量', value: '${data.nodes.length}'), + ], + if (recommended != null) ...[ + _InfoRow( + label: '推荐节点', + value: recommended.node.displayName, + valueColor: Colors.green, + ), + _InfoRow(label: '节点地址', value: recommended.node.endpointLabel), + _InfoRow(label: '地区', value: recommended.node.locationLabel), + _InfoRow(label: '运营商', value: recommended.node.carrierLabel), + _InfoRow(label: '平均延迟', value: recommended.latencyLabel), + _InfoRow(label: '丢包率', value: recommended.lossLabel), + ] else if (!loading && data != null) ...[ + const _DescriptionLine('暂无可推荐节点,请刷新后重试或联系官方支持。'), + ] else if (!loading && error == null) ...[ + const _DescriptionLine('进入页面后将自动获取节点并进行推荐。'), + ], + ], + ), + _SectionCard(title: '可选节点', children: _availableNodeChildren(data)), + _SectionCard( + title: '自定义节点', + children: const [_DescriptionLine('预留功能,后续将支持手动填写和保存自定义节点。')], + ), + _SectionCard( + title: '专线节点申请', + children: [ + const _DescriptionLine( + '如需申请专线节点,可以联系公云存储官方邮箱 operate@gongyun.org 或 Telegram:https://t.me/GYNetwork。', + ), + const _DescriptionLine('申请时请提供所在国家/地区、对应省份和运营商,便于官方评估线路可用性。'), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: onOpenTelegram, + icon: const Icon(Icons.open_in_new), + label: const Text('打开 Telegram'), + ), + ), + ], + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: loading ? null : onRefresh, + icon: const Icon(Icons.refresh), + label: const Text('重新检测'), + ), + ], + ); + } + + List _availableNodeChildren(_CustomLineDiagnostics? data) { + if (data == null) { + return [_InfoRow(label: '节点', value: loading ? '加载中' : '等待获取')]; + } + if (data.nodes.isEmpty) { + return const [_DescriptionLine('当前用户组暂无可选节点。')]; + } + + return [ + for (var i = 0; i < data.nodes.length; i++) ...[ + if (i > 0) const Divider(height: 18), + _CustomNodeTile(probe: data.nodes[i]), + ], + ]; + } +} + +class _CustomNodeTile extends StatelessWidget { + final _CustomNodeProbe probe; + + const _CustomNodeTile({required this.probe}); + + @override + Widget build(BuildContext context) { + final available = probe.successCount > 0; + final statusColor = _statusColor(context, available); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + probe.node.displayName, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), + ), + ), + const SizedBox(width: 12), + Text( + available ? '可用' : '不可用', + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 4), + Text(probe.node.endpointLabel), + const SizedBox(height: 4), + Text('${probe.node.locationLabel} / ${probe.node.carrierLabel}'), + const SizedBox(height: 4), + Text( + '平均延迟 ${probe.latencyLabel} / 丢包率 ${probe.lossLabel} / 样本 ${probe.sampleLabel}', + ), + ], + ), + ); + } +} + class _SectionCard extends StatelessWidget { final String title; final List children; @@ -737,6 +1115,177 @@ class _IpInfo { }); } +class _CustomLineDiagnostics { + final String groupName; + final List<_CustomNodeProbe> nodes; + final _CustomNodeProbe? recommended; + + const _CustomLineDiagnostics({ + required this.groupName, + required this.nodes, + required this.recommended, + }); +} + +class _CustomNode { + final String id; + final String name; + final String host; + final String ip; + final int? port; + final String protocol; + final String region; + final String country; + final String province; + final String carrier; + final List groups; + final bool enabled; + final String probeUrl; + + const _CustomNode({ + required this.id, + required this.name, + required this.host, + required this.ip, + required this.port, + required this.protocol, + required this.region, + required this.country, + required this.province, + required this.carrier, + required this.groups, + required this.enabled, + required this.probeUrl, + }); + + factory _CustomNode.fromJson(Map json) { + return _CustomNode( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + host: json['host']?.toString() ?? '', + ip: json['ip']?.toString() ?? '', + port: _asNullableInt(json['port']), + protocol: json['protocol']?.toString() ?? 'https', + region: json['region']?.toString() ?? '', + country: json['country']?.toString() ?? '', + province: json['province']?.toString() ?? '', + carrier: json['carrier']?.toString() ?? '', + groups: _asStringList(json['groups']), + enabled: json['enabled'] != false, + probeUrl: json['probe_url']?.toString() ?? '', + ); + } + + String get displayName { + final trimmedName = name.trim(); + if (trimmedName.isNotEmpty) return trimmedName; + final endpoint = endpointHost; + return endpoint.isEmpty ? '未命名节点' : endpoint; + } + + String get endpointHost { + final trimmedHost = host.trim(); + if (trimmedHost.isNotEmpty) return trimmedHost; + return ip.trim(); + } + + String get endpointLabel { + final endpoint = endpointHost; + if (endpoint.isEmpty) return '未配置地址'; + final scheme = protocol.trim().isEmpty ? 'https' : protocol.trim(); + final portSuffix = port != null && port! > 0 ? ':$port' : ''; + return '$scheme://$endpoint$portSuffix'; + } + + String get locationLabel { + final parts = []; + for (final value in [country, province, region]) { + final trimmed = value.trim(); + if (trimmed.isNotEmpty && !parts.contains(trimmed)) { + parts.add(trimmed); + } + } + return parts.isEmpty ? '未配置地区' : parts.join(' / '); + } + + String get carrierLabel { + final trimmedCarrier = carrier.trim(); + return trimmedCarrier.isEmpty ? '未配置运营商' : trimmedCarrier; + } + + Uri? get probeUri { + final value = probeUrl.trim(); + if (value.isEmpty) return null; + final uri = Uri.tryParse(value); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null; + return uri; + } + + Uri? get endpointUri { + final endpoint = endpointHost; + final scheme = protocol.trim().toLowerCase(); + if (endpoint.isEmpty || (scheme != 'http' && scheme != 'https')) { + return null; + } + return Uri(scheme: scheme, host: endpoint, port: port); + } + + static int? _asNullableInt(Object? value) { + if (value is int) return value; + if (value is num) return value.round(); + return int.tryParse(value?.toString() ?? ''); + } + + static List _asStringList(Object? value) { + if (value is List) { + return value + .map((item) => item.toString().trim()) + .where((item) => item.isNotEmpty) + .toList(); + } + if (value is String) { + return value + .split(',') + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(); + } + return const []; + } +} + +class _CustomNodeProbe { + final _CustomNode node; + final int sampleCount; + final int successCount; + final int? averageLatencyMs; + + const _CustomNodeProbe({ + required this.node, + required this.sampleCount, + required this.successCount, + this.averageLatencyMs, + }); + + double get packetLossPercent { + if (sampleCount <= 0) return 100; + return ((sampleCount - successCount) / sampleCount) * 100; + } + + double get score { + return (averageLatencyMs ?? 999999).toDouble() + packetLossPercent * 20; + } + + String get latencyLabel { + if (averageLatencyMs == null) return '-- ms'; + return '$averageLatencyMs ms'; + } + + String get lossLabel => '${packetLossPercent.toStringAsFixed(0)}%'; + + String get sampleLabel => '$successCount/$sampleCount'; +} + class _ServiceDiagnostics { final DateTime fetchedAt; final List<_ServiceItem> business;