本次更新涵盖多个功能模块的优化与新增: 1. 新增Wi-Fi直连配对方式与协作画布模块 2. 完成设备管理重命名API与前端适配 3. 优化日签卡片空数据保护与UI细节 4. 新增剪贴板工具与每日运势会话 5. 修复应用锁恢复、语音消息录制等已知问题 6. 完善文件传输统计与配对逻辑 7. 更新安卓权限配置与iOS隐私描述 8. 新增自动化测试脚本与文档 9. 清理旧版审计报告与测试文件
901 lines
27 KiB
Dart
901 lines
27 KiB
Dart
// ============================================================
|
||
// 闲言APP — 文件传输全流程验证脚本 v3
|
||
// 创建时间: 2026-05-12
|
||
// 更新时间: 2026-05-13
|
||
// 作用: 模拟两个设备通过信令服务器互发消息/文件/数据
|
||
// 验证配对、消息发送、文件传输、送达回执、在线状态等全流程
|
||
// 上次更新: v3 新增deviceOnline/deviceOffline测试+text-message vs wsRelay对比
|
||
// 运行: dart run Scripts/file_transfer_full_test.dart
|
||
// ============================================================
|
||
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:math';
|
||
|
||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||
|
||
const String kSignalingUrl = 'wss://tools.wktyl.com:9443';
|
||
const String kApiBase = 'https://tools.wktyl.com/api/file_transfer';
|
||
const Duration kTimeout = Duration(seconds: 10);
|
||
const Duration kHeartbeatInterval = Duration(seconds: 30);
|
||
|
||
class TestDevice {
|
||
TestDevice({required this.localId, required this.alias, this.userId});
|
||
|
||
final String localId;
|
||
String? serverId;
|
||
final String alias;
|
||
final String? userId;
|
||
WebSocketChannel? _channel;
|
||
bool _isConnected = false;
|
||
bool get isConnected => _isConnected;
|
||
|
||
String get effectiveId => serverId ?? localId;
|
||
|
||
final StreamController<Map<String, dynamic>> _messageController =
|
||
StreamController<Map<String, dynamic>>.broadcast();
|
||
Stream<Map<String, dynamic>> get onMessage => _messageController.stream;
|
||
|
||
final List<Map<String, dynamic>> allMessages = [];
|
||
Timer? _heartbeatTimer;
|
||
|
||
Future<bool> connect() async {
|
||
try {
|
||
print('[$alias] Connecting to $kSignalingUrl...');
|
||
_channel = WebSocketChannel.connect(Uri.parse(kSignalingUrl));
|
||
await _channel!.ready.timeout(kTimeout);
|
||
|
||
_channel!.stream.listen(
|
||
(data) {
|
||
_handleMessage(data as String);
|
||
},
|
||
onDone: () {
|
||
_isConnected = false;
|
||
print('[$alias] Connection closed');
|
||
},
|
||
onError: (error) {
|
||
_isConnected = false;
|
||
print('[$alias] Connection error: $error');
|
||
},
|
||
);
|
||
|
||
_isConnected = true;
|
||
|
||
_sendRegister();
|
||
_startHeartbeat();
|
||
|
||
print('[$alias] Connected, waiting for server ID...');
|
||
return true;
|
||
} catch (e) {
|
||
print('[$alias] Connection failed: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
void _sendRegister() {
|
||
_send({
|
||
'type': 'register',
|
||
'from': localId,
|
||
'payload': {
|
||
'alias': alias,
|
||
'fingerprint': 'test-fp-${localId.substring(0, 8)}',
|
||
'deviceType': 'headless',
|
||
'deviceModel': 'TestDevice',
|
||
if (userId != null) 'userId': userId,
|
||
},
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
}
|
||
|
||
void _startHeartbeat() {
|
||
_heartbeatTimer?.cancel();
|
||
_heartbeatTimer = Timer.periodic(kHeartbeatInterval, (_) {
|
||
if (_isConnected) {
|
||
_send({
|
||
'type': 'heartbeat',
|
||
'from': effectiveId,
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
void _handleMessage(String data) {
|
||
try {
|
||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||
allMessages.add(json);
|
||
_messageController.add(json);
|
||
|
||
final type = json['type'] as String? ?? '';
|
||
final from = json['from'] as String? ?? json['sender'] as String? ?? '';
|
||
|
||
if (type == 'registered') {
|
||
final assignedId =
|
||
json['id'] as String? ??
|
||
json['payload']?['deviceId'] as String? ??
|
||
json['payload']?['id'] as String? ??
|
||
json['deviceId'] as String?;
|
||
if (assignedId != null && serverId == null) {
|
||
serverId = assignedId;
|
||
print('[$alias] Server assigned ID: $serverId');
|
||
}
|
||
}
|
||
|
||
if (type == 'display-name') {
|
||
final sid =
|
||
json['payload']?['id'] as String? ??
|
||
json['id'] as String? ??
|
||
json['payload']?['deviceId'] as String? ??
|
||
json['sender'] as String?;
|
||
if (sid != null && serverId == null) {
|
||
serverId = sid;
|
||
print('[$alias] Got ID from display-name: $serverId');
|
||
}
|
||
}
|
||
|
||
print('[$alias] Received: type=$type from=$from');
|
||
} catch (e) {
|
||
print('[$alias] Parse error: $e');
|
||
}
|
||
}
|
||
|
||
void _send(Map<String, dynamic> msg) {
|
||
if (!_isConnected || _channel == null) return;
|
||
_channel!.sink.add(jsonEncode(msg));
|
||
}
|
||
|
||
void sendTextMessage(String targetId, String text) {
|
||
_send({
|
||
'type': 'text-message',
|
||
'from': effectiveId,
|
||
'to': targetId,
|
||
'payload': {'text': text},
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
print('[$alias] Sent text-message to $targetId: "$text"');
|
||
}
|
||
|
||
void sendWsRelayText(String targetId, String text) {
|
||
_send({
|
||
'type': 'wsRelay',
|
||
'from': effectiveId,
|
||
'to': targetId,
|
||
'relayType': 'text',
|
||
'payload': {'text': text},
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
print('[$alias] Sent wsRelay text to $targetId: "$text"');
|
||
}
|
||
|
||
void sendFileMeta(
|
||
String targetId,
|
||
String fileName,
|
||
int fileSize,
|
||
String fileId,
|
||
) {
|
||
_send({
|
||
'type': 'wsRelay',
|
||
'from': effectiveId,
|
||
'to': targetId,
|
||
'relayType': 'file-meta',
|
||
'payload': {
|
||
'fileId': fileId,
|
||
'fileName': fileName,
|
||
'fileSize': fileSize,
|
||
'chunkSize': 65536,
|
||
'checksum': 'test-checksum',
|
||
},
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
print('[$alias] Sent file meta to $targetId: $fileName ($fileSize bytes)');
|
||
}
|
||
|
||
void sendFileChunk(
|
||
String targetId,
|
||
String fileId,
|
||
int chunkIndex,
|
||
String base64Data,
|
||
) {
|
||
_send({
|
||
'type': 'wsRelay',
|
||
'from': effectiveId,
|
||
'to': targetId,
|
||
'relayType': 'file-chunk',
|
||
'payload': {
|
||
'fileId': fileId,
|
||
'chunkIndex': chunkIndex,
|
||
'data': base64Data,
|
||
},
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
}
|
||
|
||
void sendFileComplete(String targetId, String fileId) {
|
||
_send({
|
||
'type': 'wsRelay',
|
||
'from': effectiveId,
|
||
'to': targetId,
|
||
'relayType': 'file-complete',
|
||
'payload': {'fileId': fileId},
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
print('[$alias] Sent file complete to $targetId: $fileId');
|
||
}
|
||
|
||
void sendDeliveryAck(String targetId, String messageId) {
|
||
_send({
|
||
'type': 'delivery-ack',
|
||
'from': effectiveId,
|
||
'to': targetId,
|
||
'payload': {'messageId': messageId},
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
print('[$alias] Sent delivery ack to $targetId for $messageId');
|
||
}
|
||
|
||
void sendPairRequest(String targetId) {
|
||
_send({
|
||
'type': 'pair-request',
|
||
'from': effectiveId,
|
||
'to': targetId,
|
||
'payload': {
|
||
'alias': alias,
|
||
'fingerprint': 'test-fp-${localId.substring(0, 8)}',
|
||
'deviceType': 'headless',
|
||
},
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
print('[$alias] Sent pair request to $targetId');
|
||
}
|
||
|
||
void sendPairAccept(String targetId) {
|
||
_send({
|
||
'type': 'pair-accept',
|
||
'from': effectiveId,
|
||
'to': targetId,
|
||
'payload': {
|
||
'alias': alias,
|
||
'fingerprint': 'test-fp-${localId.substring(0, 8)}',
|
||
},
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
print('[$alias] Sent pair accept to $targetId');
|
||
}
|
||
|
||
void sendPairReject(String targetId) {
|
||
_send({
|
||
'type': 'pair-reject',
|
||
'from': effectiveId,
|
||
'to': targetId,
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
print('[$alias] Sent pair reject to $targetId');
|
||
}
|
||
|
||
void discover() {
|
||
_send({
|
||
'type': 'discover',
|
||
'from': effectiveId,
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
print('[$alias] Sent discover request');
|
||
}
|
||
|
||
void ping(String targetId) {
|
||
_send({
|
||
'type': 'ping',
|
||
'from': effectiveId,
|
||
'to': targetId,
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
print('[$alias] Sent ping to $targetId');
|
||
}
|
||
|
||
Future<void> disconnect() async {
|
||
_heartbeatTimer?.cancel();
|
||
if (_isConnected && _channel != null) {
|
||
_send({
|
||
'type': 'leave',
|
||
'from': effectiveId,
|
||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||
});
|
||
await _channel?.sink.close();
|
||
}
|
||
_isConnected = false;
|
||
print('[$alias] Disconnected');
|
||
}
|
||
|
||
Future<Map<String, dynamic>?> waitForMessage(
|
||
String type, {
|
||
Duration timeout = kTimeout,
|
||
bool Function(Map<String, dynamic>)? filter,
|
||
}) async {
|
||
try {
|
||
return await onMessage
|
||
.where((msg) {
|
||
final msgType = msg['type'] as String? ?? '';
|
||
if (msgType != type) return false;
|
||
if (filter != null) return filter(msg);
|
||
return true;
|
||
})
|
||
.first
|
||
.timeout(timeout);
|
||
} on TimeoutException {
|
||
print('[$alias] Timeout waiting for message type: $type');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<bool> waitForRegistration({Duration timeout = kTimeout}) async {
|
||
if (serverId != null) return true;
|
||
|
||
for (final msg in allMessages) {
|
||
final type = msg['type'] as String? ?? '';
|
||
if (type == 'registered') {
|
||
final id =
|
||
msg['id'] as String? ??
|
||
msg['payload']?['deviceId'] as String? ??
|
||
msg['payload']?['id'] as String?;
|
||
if (id != null) {
|
||
serverId = id;
|
||
print('[$alias] Server assigned ID (from cache): $serverId');
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
await onMessage
|
||
.where((msg) {
|
||
final type = msg['type'] as String? ?? '';
|
||
return type == 'registered';
|
||
})
|
||
.first
|
||
.timeout(timeout);
|
||
return serverId != null;
|
||
} on TimeoutException {
|
||
print('[$alias] Timeout waiting for registration');
|
||
return serverId != null;
|
||
}
|
||
}
|
||
}
|
||
|
||
class TestResult {
|
||
TestResult(this.name, this.passed, [this.detail]);
|
||
final String name;
|
||
final bool passed;
|
||
final String? detail;
|
||
|
||
@override
|
||
String toString() {
|
||
final icon = passed ? '✅' : '❌';
|
||
return '$icon $name${detail != null ? ': $detail' : ''}';
|
||
}
|
||
}
|
||
|
||
Future<void> main() async {
|
||
print('============================================================');
|
||
print(' 闲言APP 文件传输全流程验证脚本 v3');
|
||
print(' 时间: ${DateTime.now()}');
|
||
print(' 新增: deviceOnline/deviceOffline + text-message vs wsRelay对比');
|
||
print('============================================================\n');
|
||
|
||
final results = <TestResult>[];
|
||
|
||
final deviceA = TestDevice(
|
||
localId: 'test-a-${Random().nextInt(9999)}',
|
||
alias: 'TestDeviceA',
|
||
userId: 'test-user-a',
|
||
);
|
||
|
||
final deviceB = TestDevice(
|
||
localId: 'test-b-${Random().nextInt(9999)}',
|
||
alias: 'TestDeviceB',
|
||
userId: 'test-user-b',
|
||
);
|
||
|
||
// ---- Test 1: REST API Health Check ----
|
||
print('\n--- Test 1: REST API Health Check ---');
|
||
try {
|
||
final client = HttpClient();
|
||
final request = await client.getUrl(Uri.parse('$kApiBase/health'));
|
||
final response = await request.close().timeout(kTimeout);
|
||
final body = await response.transform(utf8.decoder).join();
|
||
client.close();
|
||
final healthy = response.statusCode == 200;
|
||
results.add(
|
||
TestResult('REST API Health', healthy, 'status=${response.statusCode} body=$body'),
|
||
);
|
||
print(' Health check: ${healthy ? "OK" : "FAIL"}');
|
||
} catch (e) {
|
||
results.add(TestResult('REST API Health', false, e.toString()));
|
||
}
|
||
|
||
// ---- Test 2: Signaling Info ----
|
||
print('\n--- Test 2: Signaling Info ---');
|
||
try {
|
||
final client = HttpClient();
|
||
final request = await client.getUrl(Uri.parse('$kApiBase/signaling_info'));
|
||
final response = await request.close().timeout(kTimeout);
|
||
final body = await response.transform(utf8.decoder).join();
|
||
client.close();
|
||
final json = jsonDecode(body) as Map<String, dynamic>;
|
||
results.add(
|
||
TestResult(
|
||
'Signaling Info',
|
||
json['code'] == 1,
|
||
'url=${json['data']?['signalingUrl']}',
|
||
),
|
||
);
|
||
} catch (e) {
|
||
results.add(TestResult('Signaling Info', false, e.toString()));
|
||
}
|
||
|
||
// ---- Test 3: Device A Connect & Register ----
|
||
print('\n--- Test 3: Device A Connect & Register ---');
|
||
final connectedA = await deviceA.connect();
|
||
results.add(TestResult('Device A Connect', connectedA));
|
||
if (connectedA) {
|
||
await Future.delayed(const Duration(seconds: 3));
|
||
final regA = await deviceA.waitForRegistration();
|
||
results.add(
|
||
TestResult(
|
||
'Device A Registered',
|
||
regA && deviceA.serverId != null,
|
||
'serverId=${deviceA.serverId}',
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Test 4: Device B Connect & Register ----
|
||
print('\n--- Test 4: Device B Connect & Register ---');
|
||
final connectedB = await deviceB.connect();
|
||
results.add(TestResult('Device B Connect', connectedB));
|
||
if (connectedB) {
|
||
await Future.delayed(const Duration(seconds: 1));
|
||
final regB = await deviceB.waitForRegistration();
|
||
results.add(
|
||
TestResult(
|
||
'Device B Registered',
|
||
regB && deviceB.serverId != null,
|
||
'serverId=${deviceB.serverId}',
|
||
),
|
||
);
|
||
}
|
||
|
||
if (!connectedA ||
|
||
!connectedB ||
|
||
deviceA.serverId == null ||
|
||
deviceB.serverId == null) {
|
||
print('\n FATAL: Cannot proceed without both devices registered.');
|
||
await deviceA.disconnect();
|
||
await deviceB.disconnect();
|
||
_printResults(results);
|
||
return;
|
||
}
|
||
|
||
print('\n Device A server ID: ${deviceA.serverId}');
|
||
print(' Device B server ID: ${deviceB.serverId}');
|
||
|
||
// ---- Test 5: Discover Devices ----
|
||
print('\n--- Test 5: Discover Devices ---');
|
||
deviceA.discover();
|
||
final discoverResponse = await deviceA.waitForMessage(
|
||
'peers',
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
results.add(
|
||
TestResult(
|
||
'Discover Response (peers)',
|
||
discoverResponse != null,
|
||
discoverResponse != null
|
||
? 'peers count=${(discoverResponse['payload']?['peers'] as List?)?.length ?? 0}'
|
||
: null,
|
||
),
|
||
);
|
||
|
||
// ---- Test 6: deviceOnline Event ----
|
||
print('\n--- Test 6: deviceOnline Event ---');
|
||
final onlineEvents = deviceA.allMessages.where(
|
||
(m) => m['type'] == 'deviceOnline',
|
||
).toList();
|
||
results.add(
|
||
TestResult(
|
||
'deviceOnline Event Received by A',
|
||
onlineEvents.isNotEmpty,
|
||
'count=${onlineEvents.length}',
|
||
),
|
||
);
|
||
if (onlineEvents.isNotEmpty) {
|
||
final event = onlineEvents.first;
|
||
results.add(
|
||
TestResult(
|
||
'deviceOnline Contains Device Info',
|
||
event['from'] != null || event['payload'] != null,
|
||
'from=${event['from']} payload=${event['payload']?.toString().substring(0, 50)}',
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Test 7: Ping/Pong ----
|
||
print('\n--- Test 7: Ping/Pong ---');
|
||
deviceA.ping(deviceB.serverId!);
|
||
final pongReceived = await deviceA.waitForMessage(
|
||
'pong',
|
||
filter: (msg) => msg['from'] == deviceB.serverId,
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
results.add(TestResult('Ping/Pong', pongReceived != null));
|
||
|
||
// ---- Test 8: Pairing Flow ----
|
||
print('\n--- Test 8: Pairing Flow ---');
|
||
deviceA.sendPairRequest(deviceB.serverId!);
|
||
|
||
final pairReqAtB = await deviceB.waitForMessage(
|
||
'pair-request',
|
||
filter: (msg) => msg['from'] == deviceA.serverId,
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
results.add(
|
||
TestResult(
|
||
'Pair Request Received by B',
|
||
pairReqAtB != null,
|
||
pairReqAtB != null ? 'from=${pairReqAtB['from']}' : null,
|
||
),
|
||
);
|
||
|
||
if (pairReqAtB != null) {
|
||
deviceB.sendPairAccept(deviceA.serverId!);
|
||
|
||
final pairResponse = await deviceA.waitForMessage(
|
||
'pair-response',
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
results.add(
|
||
TestResult(
|
||
'Pair Accept/Response Received by A',
|
||
pairResponse != null,
|
||
pairResponse != null
|
||
? 'from=${pairResponse['from']} status=${pairResponse['payload']?['status']}'
|
||
: null,
|
||
),
|
||
);
|
||
} else {
|
||
final pairResponseDirect = await deviceA.waitForMessage(
|
||
'pair-response',
|
||
timeout: const Duration(seconds: 3),
|
||
);
|
||
results.add(
|
||
TestResult(
|
||
'Pair Response (server-mediated)',
|
||
pairResponseDirect != null,
|
||
pairResponseDirect != null
|
||
? 'payload=${pairResponseDirect['payload']}'
|
||
: null,
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Test 9: text-message A → B (旧协议) ----
|
||
print('\n--- Test 9: text-message A → B (旧协议) ---');
|
||
final testText1 = 'OLD-PROTOCOL from A! ${DateTime.now().millisecondsSinceEpoch}';
|
||
deviceA.sendTextMessage(deviceB.serverId!, testText1);
|
||
|
||
final textReceived1 = await deviceB.waitForMessage(
|
||
'text-message',
|
||
filter: (msg) => msg['from'] == deviceA.serverId,
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
results.add(
|
||
TestResult(
|
||
'text-message A→B (旧协议)',
|
||
textReceived1 != null,
|
||
textReceived1 != null
|
||
? 'text="${(textReceived1['payload']?['text'] as String?)?.substring(0, 30)}..."'
|
||
: '❌ 旧协议text-message未送达! 这就是bug根因',
|
||
),
|
||
);
|
||
|
||
// ---- Test 10: wsRelay Text A → B (新协议) ----
|
||
print('\n--- Test 10: wsRelay Text A → B (新协议) ---');
|
||
final relayText1 = 'NEW-PROTOCOL from A! ${DateTime.now().millisecondsSinceEpoch}';
|
||
deviceA.sendWsRelayText(deviceB.serverId!, relayText1);
|
||
|
||
final relayReceived1 = await deviceB.waitForMessage(
|
||
'wsRelay',
|
||
filter: (msg) =>
|
||
msg['from'] == deviceA.serverId &&
|
||
(msg['relayType'] == 'text' || msg['payload']?['relayType'] == 'text'),
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
results.add(
|
||
TestResult(
|
||
'wsRelay Text A→B (新协议)',
|
||
relayReceived1 != null,
|
||
relayReceived1 != null
|
||
? 'text="${(relayReceived1['payload']?['text'] as String?)?.substring(0, 30)}..."'
|
||
: null,
|
||
),
|
||
);
|
||
|
||
// ---- Test 11: wsRelay Text B → A ----
|
||
print('\n--- Test 11: wsRelay Text B → A ---');
|
||
final relayText2 = 'NEW-PROTOCOL from B! ${DateTime.now().millisecondsSinceEpoch}';
|
||
deviceB.sendWsRelayText(deviceA.serverId!, relayText2);
|
||
|
||
final relayReceived2 = await deviceA.waitForMessage(
|
||
'wsRelay',
|
||
filter: (msg) =>
|
||
msg['from'] == deviceB.serverId &&
|
||
(msg['relayType'] == 'text' || msg['payload']?['relayType'] == 'text'),
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
results.add(
|
||
TestResult(
|
||
'wsRelay Text B→A',
|
||
relayReceived2 != null,
|
||
relayReceived2 != null
|
||
? 'text="${(relayReceived2['payload']?['text'] as String?)?.substring(0, 30)}..."'
|
||
: null,
|
||
),
|
||
);
|
||
|
||
// ---- Test 12: Protocol Comparison ----
|
||
print('\n--- Test 12: text-message vs wsRelay 协议对比 ---');
|
||
final oldProtocolWorks = textReceived1 != null;
|
||
final newProtocolWorks = relayReceived1 != null;
|
||
results.add(
|
||
TestResult(
|
||
'协议对比: wsRelay优于text-message',
|
||
newProtocolWorks,
|
||
'text-message=${oldProtocolWorks ? "✅" : "❌"} wsRelay=${newProtocolWorks ? "✅" : "❌"}',
|
||
),
|
||
);
|
||
if (!oldProtocolWorks && newProtocolWorks) {
|
||
print(' 💡 确认: text-message协议不可靠,wsRelay协议可靠,修复正确!');
|
||
}
|
||
|
||
// ---- Test 13: File Transfer via WsRelay A → B ----
|
||
print('\n--- Test 13: File Transfer via WsRelay A → B ---');
|
||
final fileId = 'test-file-${Random().nextInt(9999)}';
|
||
final fileName = 'test_file.txt';
|
||
final fileContent = 'This is a test file from device A. ' * 10;
|
||
final fileBytes = utf8.encode(fileContent);
|
||
final fileSize = fileBytes.length;
|
||
final chunkSize = 65536;
|
||
|
||
deviceA.sendFileMeta(deviceB.serverId!, fileName, fileSize, fileId);
|
||
|
||
final fileMetaReceived = await deviceB.waitForMessage(
|
||
'wsRelay',
|
||
filter: (msg) =>
|
||
msg['from'] == deviceA.serverId &&
|
||
(msg['relayType'] == 'file-meta' ||
|
||
msg['payload']?['relayType'] == 'file-meta'),
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
results.add(
|
||
TestResult(
|
||
'File Meta Received by B',
|
||
fileMetaReceived != null,
|
||
fileMetaReceived != null
|
||
? 'fileName=${fileMetaReceived['payload']?['fileName']} fileSize=${fileMetaReceived['payload']?['fileSize']}'
|
||
: null,
|
||
),
|
||
);
|
||
|
||
if (fileMetaReceived != null) {
|
||
int offset = 0;
|
||
int chunkIndex = 0;
|
||
while (offset < fileBytes.length) {
|
||
final end = (offset + chunkSize < fileBytes.length)
|
||
? offset + chunkSize
|
||
: fileBytes.length;
|
||
final chunk = fileBytes.sublist(offset, end);
|
||
final base64Chunk = base64Encode(chunk);
|
||
deviceA.sendFileChunk(deviceB.serverId!, fileId, chunkIndex, base64Chunk);
|
||
offset = end;
|
||
chunkIndex++;
|
||
}
|
||
|
||
deviceA.sendFileComplete(deviceB.serverId!, fileId);
|
||
|
||
final fileCompleteReceived = await deviceB.waitForMessage(
|
||
'wsRelay',
|
||
filter: (msg) =>
|
||
msg['from'] == deviceA.serverId &&
|
||
(msg['relayType'] == 'file-complete' ||
|
||
msg['payload']?['relayType'] == 'file-complete'),
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
results.add(
|
||
TestResult(
|
||
'File Complete Received by B',
|
||
fileCompleteReceived != null,
|
||
'chunks=$chunkIndex totalSize=$fileSize',
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Test 14: Delivery Ack ----
|
||
print('\n--- Test 14: Delivery Ack ---');
|
||
if (relayReceived1 != null) {
|
||
deviceB.sendDeliveryAck(
|
||
deviceA.serverId!,
|
||
relayReceived1['ts']?.toString() ?? '',
|
||
);
|
||
final ackReceived = await deviceA.waitForMessage(
|
||
'delivery-ack',
|
||
filter: (msg) => msg['from'] == deviceB.serverId,
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
results.add(TestResult('Delivery Ack Received by A', ackReceived != null));
|
||
} else {
|
||
results.add(TestResult('Delivery Ack', false, 'Skipped: no wsRelay message'));
|
||
}
|
||
|
||
// ---- Test 15: deviceOffline Event ----
|
||
print('\n--- Test 15: deviceOffline Event ---');
|
||
final deviceC = TestDevice(
|
||
localId: 'test-c-${Random().nextInt(9999)}',
|
||
alias: 'TestDeviceC',
|
||
);
|
||
final connectedC = await deviceC.connect();
|
||
if (connectedC) {
|
||
final regC = await deviceC.waitForRegistration();
|
||
await Future.delayed(const Duration(seconds: 1));
|
||
if (regC && deviceC.serverId != null) {
|
||
print(' Device C registered: ${deviceC.serverId}');
|
||
await deviceC.disconnect();
|
||
print(' Device C disconnected, waiting for deviceOffline...');
|
||
final offlineEvent = await deviceA.waitForMessage(
|
||
'deviceOffline',
|
||
filter: (msg) => msg['from'] == deviceC.serverId,
|
||
timeout: const Duration(seconds: 8),
|
||
);
|
||
results.add(
|
||
TestResult(
|
||
'deviceOffline Event',
|
||
offlineEvent != null,
|
||
offlineEvent != null
|
||
? 'from=${offlineEvent['from']}'
|
||
: '未收到deviceOffline广播',
|
||
),
|
||
);
|
||
} else {
|
||
results.add(TestResult('deviceOffline Event', false, 'Device C registration failed'));
|
||
}
|
||
} else {
|
||
results.add(TestResult('deviceOffline Event', false, 'Device C connect failed'));
|
||
}
|
||
|
||
// ---- Test 16: Pair Reject Flow ----
|
||
print('\n--- Test 16: Pair Reject Flow ---');
|
||
final deviceD = TestDevice(
|
||
localId: 'test-d-${Random().nextInt(9999)}',
|
||
alias: 'TestDeviceD',
|
||
);
|
||
final connectedD = await deviceD.connect();
|
||
if (connectedD) {
|
||
final regD = await deviceD.waitForRegistration();
|
||
if (regD && deviceD.serverId != null) {
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
deviceD.sendPairRequest(deviceB.serverId!);
|
||
final pairReqD = await deviceB.waitForMessage(
|
||
'pair-request',
|
||
filter: (msg) => msg['from'] == deviceD.serverId,
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
if (pairReqD != null) {
|
||
deviceB.sendPairReject(deviceD.serverId!);
|
||
final pairRejected = await deviceD.waitForMessage(
|
||
'pair-reject',
|
||
filter: (msg) => msg['from'] == deviceB.serverId,
|
||
timeout: const Duration(seconds: 5),
|
||
);
|
||
results.add(TestResult('Pair Reject Flow', pairRejected != null));
|
||
} else {
|
||
results.add(TestResult('Pair Reject Flow', false, 'Pair request not received'));
|
||
}
|
||
} else {
|
||
results.add(TestResult('Pair Reject Flow', false, 'Device D registration failed'));
|
||
}
|
||
await deviceD.disconnect();
|
||
} else {
|
||
results.add(TestResult('Pair Reject Flow', false, 'Device D connect failed'));
|
||
}
|
||
|
||
// ---- Test 17: REST API Pair Request ----
|
||
print('\n--- Test 17: REST API Pair Request ---');
|
||
try {
|
||
final client = HttpClient();
|
||
final request = await client.postUrl(Uri.parse('$kApiBase/pair_request'));
|
||
request.headers.set('Content-Type', 'application/json; charset=utf-8');
|
||
final body = jsonEncode({
|
||
'fromId': deviceA.serverId,
|
||
'toId': deviceB.serverId,
|
||
'alias': deviceA.alias,
|
||
'fingerprint': 'test-fp-a',
|
||
});
|
||
request.write(utf8.encode(body));
|
||
final response = await request.close().timeout(kTimeout);
|
||
final respBody = await response.transform(utf8.decoder).join();
|
||
client.close();
|
||
final json = jsonDecode(respBody) as Map<String, dynamic>;
|
||
results.add(
|
||
TestResult(
|
||
'REST Pair Request',
|
||
json['code'] == 1,
|
||
'code=${json['code']} msg=${json['msg']}',
|
||
),
|
||
);
|
||
} catch (e) {
|
||
results.add(TestResult('REST Pair Request', false, e.toString()));
|
||
}
|
||
|
||
// ---- Test 18: REST Paired Devices ----
|
||
print('\n--- Test 18: REST Paired Devices ---');
|
||
try {
|
||
final client = HttpClient();
|
||
final request = await client.getUrl(
|
||
Uri.parse('$kApiBase/paired_devices?deviceId=${deviceA.serverId}'),
|
||
);
|
||
final response = await request.close().timeout(kTimeout);
|
||
final respBody = await response.transform(utf8.decoder).join();
|
||
client.close();
|
||
final json = jsonDecode(respBody) as Map<String, dynamic>;
|
||
results.add(
|
||
TestResult(
|
||
'REST Paired Devices',
|
||
json['code'] == 1,
|
||
'code=${json['code']} devices=${json['data']?['devices']?.length ?? 0}',
|
||
),
|
||
);
|
||
} catch (e) {
|
||
results.add(TestResult('REST Paired Devices', false, e.toString()));
|
||
}
|
||
|
||
// ---- Cleanup ----
|
||
print('\n--- Cleanup ---');
|
||
await deviceA.disconnect();
|
||
await deviceB.disconnect();
|
||
|
||
// ---- Summary ----
|
||
_printResults(results);
|
||
}
|
||
|
||
void _printResults(List<TestResult> results) {
|
||
print('\n============================================================');
|
||
print(' 验证结果汇总');
|
||
print('============================================================');
|
||
int passed = 0;
|
||
int failed = 0;
|
||
for (final r in results) {
|
||
print(' $r');
|
||
if (r.passed) {
|
||
passed++;
|
||
} else {
|
||
failed++;
|
||
}
|
||
}
|
||
print('------------------------------------------------------------');
|
||
print(' 总计: ${results.length} 通过: $passed 失败: $failed');
|
||
if (results.isNotEmpty) {
|
||
print(' 通过率: ${(passed / results.length * 100).toStringAsFixed(1)}%');
|
||
}
|
||
print('============================================================');
|
||
|
||
if (failed > 0) {
|
||
print('\n❌ 失败项:');
|
||
for (final r in results.where((r) => !r.passed)) {
|
||
print(' - ${r.name}: ${r.detail ?? "无详情"}');
|
||
}
|
||
print('\n💡 建议检查:');
|
||
print(' 1. 信令服务器是否正确转发消息');
|
||
print(' 2. 设备ID是否使用服务器分配的ID');
|
||
print(' 3. wsRelay协议是否被服务器正确处理');
|
||
print(' 4. deviceOnline/deviceOffline广播是否正常');
|
||
}
|
||
}
|