refactor: 完成v15.0.0版本迭代更新

主要变更:
1. 新增鸿蒙端桌面快捷操作、无障碍服务支持
2. 替换Hive依赖为hive_flutter,统一存储实现
3. 新增多语言ohos设备识别异常提示
4. 重构图表组件生命周期,新增SafeChartWidget统一管理
5. 优化日志分类与高频日志屏蔽,减少性能开销
6. 新增WebSocket P2P传输方式与相关组件
7. 修复路由返回、文章缓存等已知问题
8. 新增PlatformCapabilities统一平台能力抽象
9. 调整macOS端编译配置与依赖库
This commit is contained in:
Developer
2026-06-05 06:31:18 +08:00
parent c014ad7dec
commit 733f77ac63
106 changed files with 4225 additions and 626 deletions

View File

@@ -1,9 +1,9 @@
// ============================================================
// 闲言APP — 文件传输助手枚举定义
// 创建时间: 2026-05-09
// 更新时间: 2026-05-19
// 更新时间: 2026-06-05
// 作用: 文件传输助手所有枚举类型 — 配对方式/传输方式/任务状态/设备类型/传输方向
// 上次更新: 新增harmonyNearby配对方式
// 上次更新: 新增wsP2p传输方式(WebSocket P2P直连)
// ============================================================
enum PairingMethod {
@@ -49,6 +49,7 @@ enum PairingMethod {
enum TransportType {
localsendHttp('localsend_http', 'LocalSend HTTP', '🔗'),
tcpSocket('tcp_socket', 'TCP直连', ''),
wsP2p('ws_p2p', 'WS P2P直连', '🔗'),
webrtcP2p('webrtc_p2p', 'WebRTC P2P', '🌐'),
webrtcRelay('webrtc_relay', 'WebRTC中继', '🔄'),
wsRelay('ws_relay', 'WebSocket中转', '📡'),
@@ -78,6 +79,7 @@ enum TransportType {
bool get isLan =>
this == TransportType.localsendHttp ||
this == TransportType.tcpSocket ||
this == TransportType.wsP2p ||
this == TransportType.wifiDirect;
bool get isRemote =>
this == TransportType.webrtcP2p ||

View File

@@ -1,14 +1,15 @@
// ============================================================
// 闲言APP — 设备配对页面
// 创建时间: 2026-05-09
// 更新时间: 2026-05-19
// 更新时间: 2026-06-05
// 作用: 设备配对 — 配对码/扫码/雷达/其他方式 + DegradationManager降级提示
// 上次更新: 新增鸿蒙Nearby配对方式 + Web/鸿蒙平台适配
// 上次更新: 修复返回按钮在GoRouter导航时可能不显示的问题
// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:xianyan/core/theme/app_theme.dart';
import 'package:xianyan/core/theme/app_spacing.dart';
@@ -23,7 +24,6 @@ import 'package:xianyan/features/file_transfer/presentation/pages/pairing_code_t
import 'package:xianyan/features/file_transfer/presentation/pages/qr_code_tab.dart';
import 'package:xianyan/features/file_transfer/presentation/pages/radar_scan_tab.dart';
import 'package:xianyan/features/file_transfer/services/degradation_manager.dart';
import '../../../../shared/widgets/adaptive/adaptive_back_button.dart';
class DevicePairingPage extends ConsumerStatefulWidget {
const DevicePairingPage({super.key});
@@ -60,7 +60,21 @@ class _DevicePairingPageState extends ConsumerState<DevicePairingPage>
return CupertinoPageScaffold(
backgroundColor: ext.bgPrimary,
navigationBar: CupertinoNavigationBar(
leading: const AdaptiveBackButton(),
leading: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
Navigator.of(context).maybePop();
}
},
child: Icon(
CupertinoIcons.chevron_left,
color: ext.accent,
size: 24,
),
),
backgroundColor: ext.bgPrimary.withValues(alpha: 0.85),
border: null,
middle: Row(

View File

@@ -1,9 +1,9 @@
// ============================================================
// 闲言APP — 配对码Tab
// 创建时间: 2026-05-19
// 更新时间: 2026-05-20
// 作用: 配对码生成与输入配对流程
// 上次更新: v13.10.0 配对码改为4位纯数字
// 更新时间: 2026-06-05
// 作用: 配对码生成与输入配对流程 + P2P直连状态指示
// 上次更新: v15.0.0 配对成功后显示P2P连接状态指示
// ============================================================
import 'dart:async';
@@ -24,6 +24,7 @@ import 'package:xianyan/shared/widgets/containers/glass_container.dart';
import 'package:xianyan/features/file_transfer/providers/providers.dart';
import 'package:xianyan/features/file_transfer/models/transfer_device.dart';
import 'package:xianyan/features/file_transfer/models/transfer_enums.dart';
import 'package:xianyan/features/file_transfer/services/transport/ws_p2p_service.dart';
import 'package:xianyan/features/file_transfer/presentation/pages/transfer_chat_page.dart';
class PairingCodeTab extends ConsumerStatefulWidget {
@@ -39,6 +40,9 @@ class _PairingCodeTabState extends ConsumerState<PairingCodeTab> {
bool _isJoining = false;
int _countdownSeconds = 300;
// P2P连接状态
WsP2pConnectionState _p2pState = WsP2pConnectionState.disconnected;
final List<TextEditingController> _codeControllers = List.generate(
4,
(_) => TextEditingController(),
@@ -46,6 +50,7 @@ class _PairingCodeTabState extends ConsumerState<PairingCodeTab> {
final List<FocusNode> _focusNodes = List.generate(4, (_) => FocusNode());
StreamSubscription<Map<String, dynamic>>? _pairingSub;
StreamSubscription<WsP2pConnectionState>? _p2pStateSub;
Timer? _countdownTimer;
final GlobalKey<CelebrationOverlayState> _celebrationKey =
GlobalKey<CelebrationOverlayState>();
@@ -54,11 +59,13 @@ class _PairingCodeTabState extends ConsumerState<PairingCodeTab> {
void initState() {
super.initState();
_listenPairingEvents();
_listenP2pState();
}
@override
void dispose() {
_pairingSub?.cancel();
_p2pStateSub?.cancel();
_countdownTimer?.cancel();
for (final c in _codeControllers) {
c.dispose();
@@ -92,6 +99,16 @@ class _PairingCodeTabState extends ConsumerState<PairingCodeTab> {
});
}
/// 监听P2P连接状态变化
void _listenP2pState() {
_p2pStateSub = WsP2pService.instance.onStateChanged.listen((state) {
if (!mounted) return;
setState(() {
_p2pState = state;
});
});
}
void _startCountdown() {
_countdownTimer?.cancel();
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
@@ -308,6 +325,10 @@ class _PairingCodeTabState extends ConsumerState<PairingCodeTab> {
const SizedBox(height: AppSpacing.lg),
_buildDivider(ext),
const SizedBox(height: AppSpacing.lg),
_buildP2pStatusSection(ext),
const SizedBox(height: AppSpacing.lg),
_buildDivider(ext),
const SizedBox(height: AppSpacing.lg),
_buildJoinSection(ext),
],
),
@@ -459,6 +480,143 @@ class _PairingCodeTabState extends ConsumerState<PairingCodeTab> {
);
}
/// P2P直连状态指示区域
Widget _buildP2pStatusSection(AppThemeExtension ext) {
final p2p = WsP2pService.instance;
final isSupported = p2p.isPlatformSupported;
// 状态图标和颜色
final (icon, color, statusText) = switch (_p2pState) {
WsP2pConnectionState.connected => (
CupertinoIcons.link,
ext.accent,
'🔗 P2P直连已建立',
),
WsP2pConnectionState.listening => (
CupertinoIcons.antenna_radiowaves_left_right,
ext.accent,
'📡 P2P等待连接中...',
),
WsP2pConnectionState.connecting => (
CupertinoIcons.arrow_right_arrow_left,
ext.accent,
'🔄 P2P连接中...',
),
WsP2pConnectionState.error => (
CupertinoIcons.exclamationmark_triangle,
ext.errorColor,
'⚠️ P2P连接异常',
),
WsP2pConnectionState.disconnected => (
CupertinoIcons.link,
ext.textHint,
isSupported ? '🔗 P2P直连未连接' : '🔗 P2P直连(当前平台不支持)',
),
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: AppRadius.mdBorder,
),
child: Center(child: Icon(icon, size: 18, color: color)),
),
const SizedBox(width: AppSpacing.sm),
Text(
'P2P直连',
style: AppTypography.headline.copyWith(color: ext.textPrimary),
),
const Spacer(),
// 连接状态指示点
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _p2pState == WsP2pConnectionState.connected
? ext.accent
: _p2pState == WsP2pConnectionState.error
? ext.errorColor
: ext.textHint,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(height: AppSpacing.sm),
Text(
statusText,
style: AppTypography.footnote.copyWith(color: ext.textSecondary),
),
if (p2p.p2pAddress != null) ...[
const SizedBox(height: AppSpacing.xs),
Text(
'本机地址: ${p2p.p2pAddress}',
style: AppTypography.caption1.copyWith(
color: ext.textHint,
fontFamily: 'Courier',
),
),
],
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
child: CupertinoButton(
color: _p2pState == WsP2pConnectionState.listening
? ext.bgSecondary
: ext.accent.withValues(alpha: 0.15),
borderRadius: AppRadius.lgBorder,
onPressed: isSupported ? _toggleP2pServer : null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_p2pState == WsP2pConnectionState.listening
? CupertinoIcons.stop
: CupertinoIcons.antenna_radiowaves_left_right,
size: 16,
color: _p2pState == WsP2pConnectionState.listening
? ext.textSecondary
: ext.accent,
),
const SizedBox(width: AppSpacing.xs),
Text(
_p2pState == WsP2pConnectionState.listening
? '停止P2P服务'
: '启动P2P服务',
style: AppTypography.subhead.copyWith(
color: _p2pState == WsP2pConnectionState.listening
? ext.textSecondary
: ext.accent,
),
),
],
),
),
),
],
);
}
/// 切换P2P服务端开关
Future<void> _toggleP2pServer() async {
final p2p = WsP2pService.instance;
if (_p2pState == WsP2pConnectionState.listening) {
await p2p.disconnect();
} else {
final started = await p2p.startServer();
if (!started && mounted) {
_showAlert('启动失败', 'P2P服务启动失败请检查网络权限');
}
}
}
Widget _buildDivider(AppThemeExtension ext) {
return Row(
children: [

View File

@@ -1,9 +1,9 @@
// ============================================================
// 闲言APP — 传输统计页面
// 创建时间: 2026-05-12
// 更新时间: 2026-05-12
// 更新时间: 2026-06-05
// 作用: 传输数据可视化 — 总览/趋势/排行/类型/质量
// 上次更新: emoji替换为CupertinoIcons统一图标风格
// 上次更新: DeferredBuilder替换为SafeChartWidget统一图表生命周期保护
// ============================================================
import 'package:flutter/cupertino.dart';
@@ -15,7 +15,7 @@ import 'package:xianyan/core/theme/app_theme.dart';
import 'package:xianyan/core/theme/app_spacing.dart';
import 'package:xianyan/core/theme/app_typography.dart';
import 'package:xianyan/core/theme/app_radius.dart';
import 'package:xianyan/shared/widgets/containers/deferred_builder.dart';
import 'package:xianyan/shared/widgets/charts/safe_chart_widget.dart';
import 'package:xianyan/features/file_transfer/providers/transfer_stats_provider.dart';
import 'package:xianyan/features/file_transfer/services/transfer_stats_service.dart';
import '../../../../shared/widgets/adaptive/adaptive_back_button.dart';
@@ -34,7 +34,7 @@ class _TransferStatsPageState extends ConsumerState<TransferStatsPage> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(transferStatsProvider.notifier).loadAllStats();
if (mounted) ref.read(transferStatsProvider.notifier).loadAllStats();
});
}
@@ -233,7 +233,10 @@ class _TransferStatsPageState extends ConsumerState<TransferStatsPage> {
);
}).toList();
return DeferredBuilder(builder: (context) => SfCartesianChart(
return SafeChartWidget(
chartName: '传输趋势图',
placeholder: const SizedBox.expand(),
chartBuilder: (context) => SfCartesianChart(
plotAreaBorderWidth: 0,
margin: EdgeInsets.zero,
primaryXAxis: CategoryAxis(
@@ -401,47 +404,50 @@ class _TransferStatsPageState extends ConsumerState<TransferStatsPage> {
child: Row(
children: [
Expanded(
child: DeferredBuilder(builder: (context) => SfCircularChart(
margin: EdgeInsets.zero,
series: [
DoughnutSeries<_FileTypePie, String>(
dataSource: stats.fileTypeDistribution
.asMap()
.entries
.map((entry) {
final index = entry.key;
final item = entry.value;
final percent = total > 0
? item.fileCount / total
: 0.0;
return _FileTypePie(
item.category,
item.fileCount.toDouble(),
colors[index % colors.length],
percent > 0.05
? '${(percent * 100).toStringAsFixed(0)}%'
: '',
);
})
.toList(),
xValueMapper: (d, _) => d.label,
yValueMapper: (d, _) => d.value,
pointColorMapper: (d, _) => d.color,
innerRadius: '35%',
radius: '70%',
dataLabelSettings: DataLabelSettings(
isVisible: true,
textStyle: TextStyle(
fontSize: 11,
color: ext.textOnAccent,
fontWeight: FontWeight.bold,
child: SafeChartWidget(
chartName: '文件类型饼图',
placeholder: const SizedBox.expand(),
chartBuilder: (context) => SfCircularChart(
margin: EdgeInsets.zero,
series: [
DoughnutSeries<_FileTypePie, String>(
dataSource: stats.fileTypeDistribution
.asMap()
.entries
.map((entry) {
final index = entry.key;
final item = entry.value;
final percent = total > 0
? item.fileCount / total
: 0.0;
return _FileTypePie(
item.category,
item.fileCount.toDouble(),
colors[index % colors.length],
percent > 0.05
? '${(percent * 100).toStringAsFixed(0)}%'
: '',
);
})
.toList(),
xValueMapper: (d, _) => d.label,
yValueMapper: (d, _) => d.value,
pointColorMapper: (d, _) => d.color,
innerRadius: '35%',
radius: '70%',
dataLabelSettings: DataLabelSettings(
isVisible: true,
textStyle: TextStyle(
fontSize: 11,
color: ext.textOnAccent,
fontWeight: FontWeight.bold,
),
),
),
),
],
],
),
),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(

View File

@@ -1,9 +1,9 @@
// ============================================================
// 闲言APP — 传输速度实时折线图
// 创建时间: 2026-05-19
// 更新时间: 2026-05-19
// 更新时间: 2026-06-05
// 作用: 紧凑型实时速度图表 — 嵌入聊天页输入栏上方
// 上次更新: 初始创建
// 上次更新: DeferredBuilder替换为SafeChartWidget统一图表生命周期保护
// ============================================================
import 'package:flutter/cupertino.dart';
@@ -11,7 +11,7 @@ import 'package:flutter/cupertino.dart';
import 'package:xianyan/core/theme/app_theme.dart';
import 'package:xianyan/core/theme/app_spacing.dart';
import 'package:xianyan/core/theme/app_typography.dart';
import 'package:xianyan/shared/widgets/containers/deferred_builder.dart';
import 'package:xianyan/shared/widgets/charts/safe_chart_widget.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
class TransferSpeedChart extends StatelessWidget {
@@ -63,8 +63,10 @@ class TransferSpeedChart extends StatelessWidget {
final data = speedHistory.isEmpty ? [0.0] : speedHistory;
final chartData = data.asMap().entries.map((e) => _SpeedPt(e.key, e.value)).toList();
return DeferredBuilder(
builder: (context) => SfCartesianChart(
return SafeChartWidget(
chartName: '传输速度实时图',
placeholder: const SizedBox.expand(),
chartBuilder: (context) => SfCartesianChart(
plotAreaBorderWidth: 0,
margin: EdgeInsets.zero,
primaryXAxis: const NumericAxis(

View File

@@ -1,9 +1,9 @@
// ============================================================
// 闲言APP — 传输文件处理器
// 创建时间: 2026-05-14
// 更新时间: 2026-05-19
// 更新时间: 2026-06-05
// 作用: 文件发送/接收/任务控制/进度追踪/Wi-Fi Direct/USB OTG — 从TransferNotifier拆分
// 上次更新: v13.8.0 updateFileMessageProgress增加errorMessage参数/传输异常时同步更新消息状态/错误消息传播到聊天气泡
// 上次更新: v15.0.0 新增wsP2p传输方式支持
// ============================================================
import 'dart:async';
@@ -20,6 +20,7 @@ import 'package:xianyan/features/file_transfer/database/transfer_database.dart';
import 'package:xianyan/features/file_transfer/services/transport/transport_router.dart';
import 'package:xianyan/features/file_transfer/services/transport/usb_transport_service.dart';
import 'package:xianyan/features/file_transfer/services/transport/ws_relay_service.dart';
import 'package:xianyan/features/file_transfer/services/transport/ws_p2p_service.dart';
import 'package:xianyan/features/file_transfer/services/transport/nearby_service_adapter.dart';
import 'transfer_state.dart';
@@ -319,6 +320,8 @@ class TransferFileHandler {
await transportRouter.webRtcService.cancelTransfer(taskId);
case TransportType.wsRelay:
wsRelayService.cancelTransfer(taskId);
case TransportType.wsP2p:
await WsP2pService.instance.disconnect();
case TransportType.usbTether:
await transportRouter.usbTransportService.disconnect();
case TransportType.wifiDirect:
@@ -345,6 +348,8 @@ class TransferFileHandler {
await transportRouter.webRtcService.pauseTransfer(taskId);
case TransportType.wsRelay:
wsRelayService.pauseTransfer(taskId);
case TransportType.wsP2p:
break;
case TransportType.usbTether:
transportRouter.usbTransportService.handleUsbDisconnected();
case TransportType.wifiDirect:
@@ -373,6 +378,8 @@ class TransferFileHandler {
await transportRouter.webRtcService.resumeTransfer(taskId);
case TransportType.wsRelay:
wsRelayService.resumeTransfer(taskId);
case TransportType.wsP2p:
break;
case TransportType.usbTether:
transportRouter.usbTransportService.handleUsbConnected();
case TransportType.wifiDirect:

View File

@@ -12,7 +12,7 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import 'package:xianyan/core/utils/logger.dart';
import 'package:xianyan/core/utils/logger.dart' show Log, LogCategory;
import 'package:xianyan/core/utils/safe_init_mixin.dart';
import 'package:xianyan/core/services/device/device_info_service.dart';
import 'package:xianyan/shared/widgets/feedback/app_toast.dart';
@@ -246,12 +246,12 @@ class TransferNotifier extends Notifier<TransferState> with SafeNotifierInit {
state = state.copyWith(isLoading: false);
}
} catch (e) {
Log.e('TransferNotifier: Init failed: $e');
Log.e('TransferNotifier: Init failed: $e', null, null, LogCategory.transfer);
if (mounted) {
state = state.copyWith(isLoading: false, errorMessage: e.toString());
}
}
Log.i('TransferNotifier: Initialized for device $_deviceId');
Log.i('TransferNotifier: Initialized for device $_deviceId', null, null, LogCategory.transfer);
}
void _setupListeners() {

View File

@@ -1,9 +1,9 @@
// ============================================================
// 闲言APP — 传输服务统一导出
// 创建时间: 2026-05-09
// 更新时间: 2026-05-14
// 更新时间: 2026-06-05
// 作用: 统一导出所有传输协议服务
// 上次更新: v6.3.0 新增web_transfer_handler导出
// 上次更新: v15.0.0 新增ws_p2p_service导出
// ============================================================
export 'localsend_service.dart';
@@ -13,5 +13,6 @@ export 'usb_transport_service.dart';
export 'ws_relay_service.dart';
export 'ws_relay_chunk_assembler.dart';
export 'ws_relay_resume_handler.dart';
export 'ws_p2p_service.dart';
export 'transport_router.dart';
export 'web_transfer_handler.dart';

View File

@@ -1,10 +1,10 @@
// ============================================================
// 闲言APP — 传输路由器
// 创建时间: 2026-05-09
// 更新时间: 2026-05-19
// 作用: 自动选择最优传输路径 — USB>Wi-Fi Direct>LAN>TCP>WebRTC>WS中转>热点>蓝牙>NFC
// 更新时间: 2026-06-05
// 作用: 自动选择最优传输路径 — USB>Wi-Fi Direct>LAN>WS P2P>TCP>WebRTC>WS中转>热点>蓝牙>NFC
// + sendWithFallback自动降级重试 + DegradationManager平台能力过滤
// 上次更新: v14.5.0 集成DegradationManager过滤不可用传输方式
// 上次更新: v15.0.0 新增WsP2pService局域网直连传输
// ============================================================
import 'dart:async';
@@ -22,6 +22,7 @@ import 'nearby_service_adapter.dart';
import 'tcp_socket_service.dart';
import 'webrtc_service.dart';
import 'usb_transport_service.dart';
import 'ws_p2p_service.dart';
import 'ws_relay_service.dart';
class TransportRouteResult {
@@ -45,6 +46,7 @@ class TransportRouter {
WebRtcService? webRtcService,
UsbTransportService? usbTransportService,
WsRelayService? wsRelayService,
WsP2pService? wsP2pService,
NearbyServiceAdapter? nearbyServiceAdapter,
TlsSecurityService? tlsSecurity,
}) : _localSendService =
@@ -53,6 +55,7 @@ class TransportRouter {
_webRtcService = webRtcService ?? WebRtcService(),
_usbTransportService = usbTransportService ?? UsbTransportService(),
_wsRelayService = wsRelayService,
_wsP2pService = wsP2pService ?? WsP2pService.instance,
_nearbyServiceAdapter = nearbyServiceAdapter ?? NearbyServiceAdapter();
final LocalSendService _localSendService;
@@ -60,6 +63,7 @@ class TransportRouter {
final WebRtcService _webRtcService;
final UsbTransportService _usbTransportService;
final WsRelayService? _wsRelayService;
final WsP2pService _wsP2pService;
final NearbyServiceAdapter _nearbyServiceAdapter;
bool _isSameSubnet(String? ip1, String? ip2) {
@@ -78,6 +82,8 @@ class TransportRouter {
return DegradationManager.isFeatureAvailable('usbTransfer');
case TransportType.wifiDirect:
return DegradationManager.isFeatureAvailable('wifiDirect');
case TransportType.wsP2p:
return _wsP2pService.isPlatformSupported;
case TransportType.localsendHttp:
case TransportType.tcpSocket:
case TransportType.webrtcP2p:
@@ -124,6 +130,21 @@ class TransportRouter {
if (peer.ip != null && peer.ip!.isNotEmpty) {
final isSameLan = localIp != null && _isSameSubnet(localIp, peer.ip);
if (isSameLan || localIp == null) {
// 同局域网优先WS P2P直连无需中继、低延迟、高速度
if (isSameLan && _wsP2pService.isPlatformSupported) {
candidates.add(
const TransportRouteResult(
transport: TransportType.wsP2p,
confidence: 0.93,
reason: '局域网WebSocket P2P直连低延迟高速度',
alternatives: [
TransportType.tcpSocket,
TransportType.localsendHttp,
],
),
);
}
if (fileSize > 100 * 1024 * 1024) {
candidates.add(
TransportRouteResult(
@@ -234,6 +255,9 @@ class TransportRouter {
}
if (peer.ip != null && localIp != null && _isSameSubnet(localIp, peer.ip)) {
if (_wsP2pService.isPlatformSupported) {
transports.add(TransportType.wsP2p);
}
transports.add(TransportType.localsendHttp);
transports.add(TransportType.tcpSocket);
}
@@ -322,6 +346,8 @@ class TransportRouter {
required String taskId,
}) async {
switch (transport) {
case TransportType.wsP2p:
return _sendViaWsP2p(peer: peer, filePath: filePath, taskId: taskId);
case TransportType.localsendHttp:
return _localSendService.sendFile(
ip: peer.ip ?? '',
@@ -396,10 +422,80 @@ class TransportRouter {
}
}
/// 通过WS P2P直连发送文件
Future<TransferTask> _sendViaWsP2p({
required TransferDevice peer,
required String filePath,
required String taskId,
}) async {
final file = File(filePath);
final fileSize = await file.length();
final fileName = filePath.split(Platform.pathSeparator).last;
try {
// 如果尚未连接,尝试连接对端
if (!_wsP2pService.isConnected) {
final peerIp = peer.ip;
if (peerIp == null || peerIp.isEmpty) {
throw UnsupportedError('P2P连接需要对端IP地址');
}
final address = 'ws://$peerIp:${_wsP2pService.port}';
final connected = await _wsP2pService.connect(address);
if (!connected) {
throw Exception('P2P连接失败: $address');
}
}
// 分块发送文件
double lastProgress = 0;
await for (final progress in _wsP2pService.sendFile(
file,
taskId: taskId,
)) {
lastProgress = progress;
Log.d('WsP2pService: 传输进度 ${(progress * 100).toStringAsFixed(1)}%');
}
return TransferTask(
id: taskId,
sessionId: 'ws-p2p-$taskId',
peer: peer,
transport: TransportType.wsP2p,
direction: TransferDirection.send,
status: lastProgress >= 1.0
? TransferTaskStatus.completed
: TransferTaskStatus.failed,
fileName: fileName,
fileSize: fileSize,
transferredBytes: (lastProgress * fileSize).round(),
speed: 0.0,
startTime: DateTime.now(),
errorMessage: lastProgress >= 1.0 ? null : 'P2P传输未完成',
);
} catch (e) {
Log.e('TransportRouter: WS P2P send failed: $e');
return TransferTask(
id: taskId,
sessionId: 'ws-p2p-$taskId',
peer: peer,
transport: TransportType.wsP2p,
direction: TransferDirection.send,
status: TransferTaskStatus.failed,
fileName: fileName,
fileSize: fileSize,
transferredBytes: 0,
speed: 0.0,
startTime: DateTime.now(),
errorMessage: 'P2P传输失败: $e',
);
}
}
LocalSendService get localSendService => _localSendService;
TcpSocketService get tcpSocketService => _tcpSocketService;
WebRtcService get webRtcService => _webRtcService;
UsbTransportService get usbTransportService => _usbTransportService;
WsRelayService? get wsRelayService => _wsRelayService;
WsP2pService get wsP2pService => _wsP2pService;
NearbyServiceAdapter get nearbyServiceAdapter => _nearbyServiceAdapter;
}

View File

@@ -0,0 +1,673 @@
// ============================================================
// 闲言APP — WebSocket P2P 直连服务
// 创建时间: 2026-06-05
// 更新时间: 2026-06-05
// 作用: 局域网内设备间通过 WebSocket 直连传输文件,
// 无需通过服务器中继,降低延迟、提升速度
// 支持分块传输、控制消息、心跳保活、自动重连
// 上次更新: 初始实现
// ============================================================
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../../../../core/utils/logger.dart';
import '../../../../core/utils/platform/platform_helper.dart';
import '../../models/transfer_task.dart';
// ============================================================
// P2P 连接状态
// ============================================================
/// P2P连接状态枚举
enum WsP2pConnectionState {
disconnected('disconnected', '未连接'),
listening('listening', '等待连接'),
connecting('connecting', '连接中'),
connected('connected', '已连接'),
error('error', '连接错误');
const WsP2pConnectionState(this.id, this.label);
final String id;
final String label;
}
// ============================================================
// P2P 控制消息
// ============================================================
/// P2P控制消息类型
class WsP2pControlMessage {
WsP2pControlMessage._();
static const String fileStart = 'file_start';
static const String fileEnd = 'file_end';
static const String fileChunk = 'file_chunk';
static const String ping = 'ping';
static const String pong = 'pong';
static const String ready = 'ready';
static const String error = 'error';
static const String cancel = 'cancel';
}
// ============================================================
// P2P 文件元信息
// ============================================================
/// 文件传输开始时的元信息
class WsP2pFileMeta {
const WsP2pFileMeta({
required this.taskId,
required this.fileName,
required this.fileSize,
required this.totalChunks,
this.mimeType,
this.checksum,
});
final String taskId;
final String fileName;
final int fileSize;
final int totalChunks;
final String? mimeType;
final String? checksum;
Map<String, dynamic> toJson() => {
'type': WsP2pControlMessage.fileStart,
'taskId': taskId,
'fileName': fileName,
'fileSize': fileSize,
'totalChunks': totalChunks,
if (mimeType != null) 'mimeType': mimeType,
if (checksum != null) 'checksum': checksum,
};
factory WsP2pFileMeta.fromJson(Map<String, dynamic> json) =>
WsP2pFileMeta(
taskId: json['taskId'] as String? ?? '',
fileName: json['fileName'] as String? ?? 'unknown',
fileSize: json['fileSize'] as int? ?? 0,
totalChunks: json['totalChunks'] as int? ?? 0,
mimeType: json['mimeType'] as String?,
checksum: json['checksum'] as String?,
);
}
// ============================================================
// P2P 传输进度
// ============================================================
/// P2P传输进度
class WsP2pTransferProgress {
const WsP2pTransferProgress({
required this.taskId,
required this.bytesTransferred,
required this.totalBytes,
required this.isComplete,
});
final String taskId;
final int bytesTransferred;
final int totalBytes;
final bool isComplete;
double get progress =>
totalBytes > 0 ? bytesTransferred / totalBytes : 0.0;
String get displayProgress =>
'${(progress * 100).toStringAsFixed(1)}%';
}
// ============================================================
// WsP2pService 主服务
// ============================================================
/// WebSocket P2P 直连服务
/// 局域网内设备间直连传输文件,无需服务器中继
class WsP2pService {
WsP2pService._();
static WsP2pService? _instance;
static WsP2pService get instance => _instance ??= WsP2pService._();
// ----------------------------------------------------------
// 私有字段
// ----------------------------------------------------------
HttpServer? _server;
WebSocketChannel? _client;
bool _isServer = false;
String? _localIp;
int _port = 9876;
WsP2pConnectionState _state = WsP2pConnectionState.disconnected;
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
String? _lastConnectAddress;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 3;
/// 等待对端 ready 信号的 Completer按 taskId 隔离)
final Map<String, Completer<void>> _readyCompleters = {};
/// ready 信号超时时间
static const Duration _readyTimeout = Duration(seconds: 10);
/// 分块大小 64KB
static const int defaultChunkSize = 65536;
/// 心跳间隔 30秒
static const Duration _heartbeatInterval = Duration(seconds: 30);
// ----------------------------------------------------------
// 流控制器
// ----------------------------------------------------------
final StreamController<Uint8List> _dataController =
StreamController<Uint8List>.broadcast();
final StreamController<WsP2pConnectionState> _stateController =
StreamController<WsP2pConnectionState>.broadcast();
final StreamController<WsP2pFileMeta> _fileStartController =
StreamController<WsP2pFileMeta>.broadcast();
final StreamController<String> _fileEndController =
StreamController<String>.broadcast();
final StreamController<WsP2pTransferProgress> _progressController =
StreamController<WsP2pTransferProgress>.broadcast();
// ----------------------------------------------------------
// 公开属性
// ----------------------------------------------------------
/// 当前连接状态
WsP2pConnectionState get state => _state;
/// 是否已连接
bool get isConnected => _state == WsP2pConnectionState.connected;
/// 是否为服务端
bool get isServer => _isServer;
/// 获取本机P2P地址
String? get p2pAddress =>
_localIp != null ? 'ws://$_localIp:$_port' : null;
/// 本机局域网IP
String? get localIp => _localIp;
/// 服务端口
int get port => _port;
/// 是否支持P2PWeb端不支持
bool get isPlatformSupported => !kIsWeb && PlatformHelper.supportsLocalNetwork;
// ----------------------------------------------------------
// 事件流
// ----------------------------------------------------------
/// 接收原始数据流
Stream<Uint8List> get onData => _dataController.stream;
/// 连接状态变更流
Stream<WsP2pConnectionState> get onStateChanged => _stateController.stream;
/// 文件传输开始事件
Stream<WsP2pFileMeta> get onFileStart => _fileStartController.stream;
/// 文件传输结束事件
Stream<String> get onFileEnd => _fileEndController.stream;
/// 传输进度事件
Stream<WsP2pTransferProgress> get onProgress => _progressController.stream;
// ----------------------------------------------------------
// 状态管理
// ----------------------------------------------------------
void _setState(WsP2pConnectionState newState) {
if (_state == newState) return;
_state = newState;
if (!_stateController.isClosed) {
_stateController.add(newState);
}
Log.i('WsP2pService: 状态变更 → ${newState.label}');
}
// ----------------------------------------------------------
// 启动服务端
// ----------------------------------------------------------
/// 启动P2P服务端等待对端连接
Future<bool> startServer({int port = 9876}) async {
// Web端不支持
if (kIsWeb) {
Log.w('WsP2pService: Web端不支持P2P服务');
return false;
}
// 已在监听中
if (_state == WsP2pConnectionState.listening && _server != null) {
Log.i('WsP2pService: 服务端已在运行');
return true;
}
_port = port;
try {
_server = await HttpServer.bind(InternetAddress.anyIPv4, _port);
_isServer = true;
_localIp = await _getLocalIp();
_setState(WsP2pConnectionState.listening);
Log.i(
'WsP2pService: 服务端启动 ws://$_localIp:$_port',
);
// 监听WebSocket连接请求
_server!.listen(
(HttpRequest request) async {
if (WebSocketTransformer.isUpgradeRequest(request)) {
final ws = await WebSocketTransformer.upgrade(request);
_client = IOWebSocketChannel(ws);
_setState(WsP2pConnectionState.connected);
_startListening();
_startHeartbeat();
Log.i('WsP2pService: 客户端已连接');
}
},
onError: (Object e) {
Log.e('WsP2pService: 服务端错误: $e');
_setState(WsP2pConnectionState.error);
},
);
return true;
} catch (e) {
Log.e('WsP2pService: 启动服务端失败: $e');
_setState(WsP2pConnectionState.error);
return false;
}
}
// ----------------------------------------------------------
// 客户端连接
// ----------------------------------------------------------
/// 作为客户端连接到对端
Future<bool> connect(String address) async {
// Web端不支持
if (kIsWeb) {
Log.w('WsP2pService: Web端不支持P2P连接');
return false;
}
_lastConnectAddress = address;
_reconnectAttempts = 0;
return _doConnect(address);
}
/// 执行连接
Future<bool> _doConnect(String address) async {
try {
_setState(WsP2pConnectionState.connecting);
final uri = Uri.parse(address);
_client = WebSocketChannel.connect(uri);
_isServer = false;
// 等待连接就绪
await _client!.ready;
_localIp = await _getLocalIp();
_setState(WsP2pConnectionState.connected);
_startListening();
_startHeartbeat();
Log.i('WsP2pService: 已连接到 $address');
return true;
} catch (e) {
Log.e('WsP2pService: 连接失败: $e');
_setState(WsP2pConnectionState.error);
_tryReconnect();
return false;
}
}
// ----------------------------------------------------------
// 自动重连
// ----------------------------------------------------------
/// 尝试自动重连
void _tryReconnect() {
if (_reconnectAttempts >= _maxReconnectAttempts) {
Log.w('WsP2pService: 已达最大重连次数($_maxReconnectAttempts),停止重连');
_setState(WsP2pConnectionState.error);
return;
}
if (_lastConnectAddress == null || _isServer) return;
_reconnectAttempts++;
final delay = Duration(seconds: _reconnectAttempts * 2);
Log.i(
'WsP2pService: ${delay.inSeconds}秒后尝试第$_reconnectAttempts次重连...',
);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () async {
if (_state == WsP2pConnectionState.connected) return;
await _doConnect(_lastConnectAddress!);
});
}
// ----------------------------------------------------------
// 心跳保活
// ----------------------------------------------------------
/// 启动心跳
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {
if (_state == WsP2pConnectionState.connected) {
_sendControlMessage({WsP2pControlMessage.ping: DateTime.now().millisecondsSinceEpoch});
}
});
}
/// 停止心跳
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
// ----------------------------------------------------------
// 数据发送
// ----------------------------------------------------------
/// 发送原始数据
void send(Uint8List data) {
if (_state != WsP2pConnectionState.connected || _client == null) return;
_client!.sink.add(data);
}
/// 发送控制消息JSON字符串
void _sendControlMessage(Map<String, dynamic> message) {
if (_state != WsP2pConnectionState.connected || _client == null) return;
_client!.sink.add(jsonEncode(message));
}
/// 发送文件(分块传输,带进度回调)
Stream<double> sendFile(
File file, {
String? taskId,
int chunkSize = defaultChunkSize,
}) async* {
if (_state != WsP2pConnectionState.connected) {
Log.w('WsP2pService: 未连接,无法发送文件');
return;
}
final total = await file.length();
final actualTaskId = taskId ?? 'p2p-${DateTime.now().millisecondsSinceEpoch}';
final fileName = file.path.split(Platform.pathSeparator).last;
final totalChunks = (total / chunkSize).ceil();
// 发送文件元信息
final meta = WsP2pFileMeta(
taskId: actualTaskId,
fileName: fileName,
fileSize: total,
totalChunks: totalChunks,
);
_sendControlMessage(meta.toJson());
// 等待对端ready信号异步等待带超时保护
final readyCompleter = Completer<void>();
_readyCompleters[actualTaskId] = readyCompleter;
try {
await readyCompleter.future.timeout(
_readyTimeout,
onTimeout: () {
Log.w('WsP2pService: 等待对端ready超时(${_readyTimeout.inSeconds}s),开始发送');
},
);
} finally {
_readyCompleters.remove(actualTaskId);
}
// 分块发送
int sent = 0;
int chunkIndex = 0;
final stream = file.openRead();
await for (final chunk in stream) {
if (_state != WsP2pConnectionState.connected) break;
final bytes = chunk is Uint8List ? chunk : Uint8List.fromList(chunk);
send(bytes);
sent += bytes.length;
chunkIndex++;
// 通知进度
final progress = WsP2pTransferProgress(
taskId: actualTaskId,
bytesTransferred: sent,
totalBytes: total,
isComplete: false,
);
if (!_progressController.isClosed) {
_progressController.add(progress);
}
yield sent / total;
// 每10个块让出一次事件循环避免阻塞
if (chunkIndex % 10 == 0) {
await Future<void>.delayed(Duration.zero);
}
}
// 发送文件结束消息
_sendControlMessage({
'type': WsP2pControlMessage.fileEnd,
'taskId': actualTaskId,
'totalChunks': chunkIndex,
});
// 通知完成
final finalProgress = WsP2pTransferProgress(
taskId: actualTaskId,
bytesTransferred: sent,
totalBytes: total,
isComplete: true,
);
if (!_progressController.isClosed) {
_progressController.add(finalProgress);
}
Log.i(
'WsP2pService: 文件发送完成 $fileName '
'(${TransferTask.formatFileSize(total)}, $chunkIndex chunks)',
);
}
// ----------------------------------------------------------
// 数据接收
// ----------------------------------------------------------
/// 开始监听WebSocket消息
void _startListening() {
_client?.stream.listen(
(data) {
if (data is Uint8List) {
// 二进制数据 → 原始数据流
if (!_dataController.isClosed) {
_dataController.add(data);
}
} else if (data is String) {
// 控制消息JSON字符串
_handleControlMessage(data);
}
},
onError: (Object e) {
Log.e('WsP2pService: 连接错误: $e');
_setState(WsP2pConnectionState.error);
_tryReconnect();
},
onDone: () {
Log.i('WsP2pService: 连接关闭');
_stopHeartbeat();
_setState(WsP2pConnectionState.disconnected);
_tryReconnect();
},
);
}
// ----------------------------------------------------------
// 控制消息处理
// ----------------------------------------------------------
/// 处理控制消息
void _handleControlMessage(String json) {
try {
final msg = jsonDecode(json) as Map<String, dynamic>;
final type = msg['type'] as String?;
switch (type) {
case WsP2pControlMessage.fileStart:
// 文件传输开始
final meta = WsP2pFileMeta.fromJson(msg);
Log.d(
'WsP2pService: 开始接收文件 ${meta.fileName} '
'(${TransferTask.formatFileSize(meta.fileSize)})',
);
if (!_fileStartController.isClosed) {
_fileStartController.add(meta);
}
// 回复ready
_sendControlMessage({
'type': WsP2pControlMessage.ready,
'taskId': meta.taskId,
});
case WsP2pControlMessage.fileEnd:
// 文件传输结束
final taskId = msg['taskId'] as String? ?? '';
Log.d('WsP2pService: 文件接收完成 taskId=$taskId');
if (!_fileEndController.isClosed) {
_fileEndController.add(taskId);
}
case WsP2pControlMessage.ping:
// 心跳请求 → 回复pong
final timestamp = msg[WsP2pControlMessage.ping];
_sendControlMessage({
'type': WsP2pControlMessage.pong,
'timestamp': timestamp,
});
case WsP2pControlMessage.pong:
// 心跳响应,连接正常
Log.d('WsP2pService: 收到心跳响应');
case WsP2pControlMessage.ready:
// 对端就绪,完成等待中的 Completer
final taskId = msg['taskId'] as String? ?? '';
Log.d('WsP2pService: 对端就绪 taskId=$taskId');
final completer = _readyCompleters.remove(taskId);
if (completer != null && !completer.isCompleted) {
completer.complete();
}
case WsP2pControlMessage.cancel:
// 传输取消
final taskId = msg['taskId'] as String? ?? '';
Log.i('WsP2pService: 对端取消传输 taskId=$taskId');
case WsP2pControlMessage.error:
// 对端报错
final errorMsg = msg['message'] as String? ?? '未知错误';
Log.e('WsP2pService: 对端报错: $errorMsg');
default:
Log.w('WsP2pService: 未知控制消息类型: $type');
}
} catch (e) {
Log.w('WsP2pService: 解析控制消息失败: $e');
}
}
// ----------------------------------------------------------
// 网络工具
// ----------------------------------------------------------
/// 获取本机局域网IP
Future<String?> _getLocalIp() async {
try {
final interfaces = await NetworkInterface.list();
for (final iface in interfaces) {
for (final addr in iface.addresses) {
// 选取IPv4非回环地址
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
return addr.address;
}
}
}
} catch (e) {
Log.w('WsP2pService: 获取本机IP失败: $e');
}
return null;
}
/// 检查两个IP是否在同一子网
static bool isSameSubnet(String? ip1, String? ip2) {
if (ip1 == null || ip2 == null) return false;
final parts1 = ip1.split('.');
final parts2 = ip2.split('.');
if (parts1.length < 3 || parts2.length < 3) return false;
return parts1[0] == parts2[0] &&
parts1[1] == parts2[1] &&
parts1[2] == parts2[2];
}
// ----------------------------------------------------------
// 断开与销毁
// ----------------------------------------------------------
/// 断开连接
Future<void> disconnect() async {
_stopHeartbeat();
_reconnectTimer?.cancel();
_reconnectTimer = null;
_lastConnectAddress = null;
_reconnectAttempts = 0;
// 清理所有等待中的 ready Completer
for (final completer in _readyCompleters.values) {
if (!completer.isCompleted) completer.completeError('连接断开');
}
_readyCompleters.clear();
await _client?.sink.close();
_client = null;
await _server?.close();
_server = null;
_isServer = false;
_setState(WsP2pConnectionState.disconnected);
Log.i('WsP2pService: 已断开');
}
/// 销毁服务
Future<void> dispose() async {
await disconnect();
await _dataController.close();
await _stateController.close();
await _fileStartController.close();
await _fileEndController.close();
await _progressController.close();
_instance = null;
Log.i('WsP2pService: 已销毁');
}
}