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:
@@ -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 ||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// 是否支持P2P(Web端不支持)
|
||||
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: 已销毁');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user