Skip to content

Commit aaa2d24

Browse files
committed
新增文件传输进度通知功能,优化发送和接收过程中的用户体验
1 parent faa83db commit aaa2d24

1 file changed

Lines changed: 248 additions & 8 deletions

File tree

Core/Services/DeviceCommunication/DeviceCommunicationService.cs

Lines changed: 248 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using System.Text.Json;
1414
using System.Threading;
1515
using System.Threading.Tasks;
16+
using Avalonia.Controls.Notifications;
1617
using Core.Services.Config;
1718
using PluginCore;
1819

@@ -47,6 +48,9 @@ public class DeviceCommunicationService : IDeviceCommunication, IDisposable
4748
private readonly ConcurrentDictionary<Guid, UdpReassemblySession> _udpSessions = new();
4849
private readonly ConcurrentDictionary<string, string> _pendingDownloads = new(); // RequestId -> SavePath
4950
private readonly ConcurrentDictionary<string, bool> _discoveredDeviceQuicCapabilities = new(); // DeviceId -> SupportsQuic
51+
private readonly ConcurrentDictionary<string, IToastProgressHandle> _sendingTransferToasts = new();
52+
private readonly ConcurrentDictionary<string, IToastProgressHandle> _receivingTransferToasts = new();
53+
private static readonly TimeSpan TransferToastUpdateInterval = TimeSpan.FromMilliseconds(200);
5054

5155
public ObservableCollection<DeviceModel> DiscoveredDevices { get; } = new();
5256

@@ -106,6 +110,7 @@ private static string GetLocalDisplayName()
106110

107111
private void NotifyTransferInterrupted(string requestId, string reason, bool isSending)
108112
{
113+
FailTransferToast(requestId, reason, isSending);
109114
if (!string.IsNullOrEmpty(requestId))
110115
{
111116
_ = Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
@@ -216,14 +221,55 @@ public async Task RequestFileTransferAsync(DeviceModel target, string filePath)
216221
SenderId = _myId.ToString(),
217222
SenderName = GetLocalDisplayName()
218223
};
219-
await SendStreamAsync(target, fs, JsonSerializer.Serialize(fileMeta));
224+
var targetName = GetDeviceDisplayName(target);
225+
StartTransferToast(requestId, true, fileInfo.Name, fileInfo.Length, targetName);
226+
227+
long transferredBytes = 0;
228+
int lastPercent = -1;
229+
var lastUpdate = DateTime.MinValue;
230+
var progressLock = new object();
231+
Action<long>? onProgress = fileInfo.Length > 0
232+
? bytes =>
233+
{
234+
var copied = Interlocked.Add(ref transferredBytes, bytes);
235+
var percent = (int)Math.Min(100, copied * 100d / fileInfo.Length);
236+
var now = DateTime.UtcNow;
237+
238+
lock (progressLock)
239+
{
240+
if (percent == lastPercent && now - lastUpdate < TransferToastUpdateInterval)
241+
{
242+
return;
243+
}
244+
245+
lastPercent = percent;
246+
lastUpdate = now;
247+
}
248+
249+
UpdateTransferToastProgress(
250+
requestId,
251+
true,
252+
fileInfo.Name,
253+
copied,
254+
fileInfo.Length,
255+
targetName);
256+
}
257+
: null;
258+
259+
await SendStreamInternalAsync(target, fs, JsonSerializer.Serialize(fileMeta), onProgress);
260+
CompleteTransferToast(requestId, true, fileInfo.Name, fileInfo.Length, targetName);
220261
}
221262
else
222263
{
223264
_pendingFileRequests.TryRemove(requestId, out _);
224265
if (completedTask != tcs.Task) throw new TimeoutException("User did not respond in time.");
225266
}
226267
}
268+
catch (Exception ex)
269+
{
270+
FailTransferToast(requestId, ex.Message, true);
271+
throw;
272+
}
227273
finally
228274
{
229275
_pendingFileRequests.TryRemove(requestId, out _);
@@ -251,9 +297,15 @@ public async Task RespondToFileRequestAsync(DeviceModel target, string requestId
251297
}
252298

253299
public async Task SendStreamAsync(DeviceModel target, Stream stream, string? metaData = null)
300+
{
301+
await SendStreamInternalAsync(target, stream, metaData);
302+
}
303+
304+
private async Task SendStreamInternalAsync(DeviceModel target, Stream stream, string? metaData = null,
305+
Action<long>? onProgress = null)
254306
{
255307
// Prioritize QUIC
256-
if (ShouldTryQuic(target) && await TrySendQuicAsync(target, stream, metaData))
308+
if (ShouldTryQuic(target) && await TrySendQuicAsync(target, stream, metaData, onProgress))
257309
return;
258310

259311
// Check if we should fallback to UDP
@@ -266,7 +318,7 @@ public async Task SendStreamAsync(DeviceModel target, Stream stream, string? met
266318
// Fallback to UDP
267319
try
268320
{
269-
await SendUdpAsync(target, stream, metaData);
321+
await SendUdpAsync(target, stream, metaData, onProgress);
270322
}
271323
catch (Exception ex)
272324
{
@@ -301,7 +353,8 @@ private bool ShouldTryQuic(DeviceModel target)
301353
return true;
302354
}
303355

304-
private async Task<bool> TrySendQuicAsync(DeviceModel target, Stream stream, string? metaData)
356+
private async Task<bool> TrySendQuicAsync(DeviceModel target, Stream stream, string? metaData,
357+
Action<long>? onProgress = null)
305358
{
306359
try
307360
{
@@ -332,7 +385,7 @@ private async Task<bool> TrySendQuicAsync(DeviceModel target, Stream stream, str
332385
await WriteMetaDataAsync(quicStream, metaData, TimeSpan.FromSeconds(2));
333386

334387
// Send Data with timeout
335-
await CopyStreamWithTimeoutAsync(stream, quicStream, TimeSpan.FromSeconds(10));
388+
await CopyStreamWithTimeoutAsync(stream, quicStream, TimeSpan.FromSeconds(10), onProgress: onProgress);
336389

337390
return true;
338391
}
@@ -342,7 +395,8 @@ private async Task<bool> TrySendQuicAsync(DeviceModel target, Stream stream, str
342395
}
343396
}
344397

345-
private async Task CopyStreamWithTimeoutAsync(Stream source, Stream destination, TimeSpan timeout, int bufferSize = 8192)
398+
private async Task CopyStreamWithTimeoutAsync(Stream source, Stream destination, TimeSpan timeout, int bufferSize = 8192,
399+
Action<long>? onProgress = null)
346400
{
347401
var buffer = new byte[bufferSize];
348402
int bytesRead;
@@ -358,6 +412,7 @@ private async Task CopyStreamWithTimeoutAsync(Stream source, Stream destination,
358412

359413
cts.CancelAfter(timeout);
360414
await destination.WriteAsync(buffer, 0, bytesRead, cts.Token);
415+
onProgress?.Invoke(bytesRead);
361416
}
362417
catch (OperationCanceledException)
363418
{
@@ -366,7 +421,7 @@ private async Task CopyStreamWithTimeoutAsync(Stream source, Stream destination,
366421
}
367422
}
368423

369-
private async Task SendUdpAsync(DeviceModel target, Stream stream, string? metaData)
424+
private async Task SendUdpAsync(DeviceModel target, Stream stream, string? metaData, Action<long>? onProgress = null)
370425
{
371426
// Simple UDP impl with chunking and reassembly support
372427
var targetEndPoint = CreateTargetEndPoint(target.Address, target.Port + 1);
@@ -393,6 +448,7 @@ private async Task SendUdpAsync(DeviceModel target, Stream stream, string? metaD
393448
{
394449
await SendUdpPacket(tempClient, targetEndPoint, sessionId, offset, 1, buffer.AsSpan(0, read).ToArray(), false);
395450
offset += read;
451+
onProgress?.Invoke(read);
396452

397453
// Throttle: Reduce to 1ms delay every 10 packets to improve speed
398454
packetCount++;
@@ -522,17 +578,53 @@ private async Task DispatchPacketAsync(PacketMetadata packet, Stream dataStream,
522578
if (_pendingDownloads.TryRemove(packet.RequestId, out var savePath))
523579
{
524580
bool success = false;
581+
var senderName = GetDeviceDisplayName(sender);
582+
StartTransferToast(packet.RequestId, false, packet.FileName, packet.Size, senderName);
583+
584+
long transferredBytes = 0;
585+
int lastPercent = -1;
586+
var lastUpdate = DateTime.MinValue;
587+
var progressLock = new object();
588+
Action<long>? onProgress = packet.Size > 0
589+
? bytes =>
590+
{
591+
var copied = Interlocked.Add(ref transferredBytes, bytes);
592+
var percent = (int)Math.Min(100, copied * 100d / packet.Size);
593+
var now = DateTime.UtcNow;
594+
595+
lock (progressLock)
596+
{
597+
if (percent == lastPercent && now - lastUpdate < TransferToastUpdateInterval)
598+
{
599+
return;
600+
}
601+
602+
lastPercent = percent;
603+
lastUpdate = now;
604+
}
605+
606+
UpdateTransferToastProgress(
607+
packet.RequestId,
608+
false,
609+
packet.FileName,
610+
copied,
611+
packet.Size,
612+
senderName);
613+
}
614+
: null;
615+
525616
try
526617
{
527618
// Stream directly to file
528619
await using var fs = new FileStream(savePath, FileMode.Create, FileAccess.Write);
529-
await CopyStreamWithTimeoutAsync(dataStream, fs, TimeSpan.FromSeconds(10));
620+
await CopyStreamWithTimeoutAsync(dataStream, fs, TimeSpan.FromSeconds(10), onProgress: onProgress);
530621

531622
if (packet.Size > 0 && fs.Length != packet.Size)
532623
{
533624
throw new IOException($"File size mismatch. Expected {packet.Size}, got {fs.Length}");
534625
}
535626
success = true;
627+
CompleteTransferToast(packet.RequestId, false, packet.FileName, packet.Size, senderName);
536628
}
537629
catch (Exception ex)
538630
{
@@ -1192,6 +1284,153 @@ private X509Certificate2 GenerateCertificate()
11921284
// Export/Import as PFX to ensure the private key is properly associated and accessible for SChannel/MsQuic on Windows
11931285
return new X509Certificate2(cert.Export(X509ContentType.Pfx));
11941286
}
1287+
1288+
private static string GetDeviceDisplayName(DeviceModel? device)
1289+
{
1290+
if (device is null)
1291+
{
1292+
return "未知设备";
1293+
}
1294+
1295+
if (!string.IsNullOrWhiteSpace(device.CustomName))
1296+
{
1297+
return device.CustomName.Trim();
1298+
}
1299+
1300+
if (!string.IsNullOrWhiteSpace(device.Name))
1301+
{
1302+
return device.Name.Trim();
1303+
}
1304+
1305+
return device.Address.ToString();
1306+
}
1307+
1308+
private static string FormatFileSize(long bytes)
1309+
{
1310+
if (bytes <= 0)
1311+
{
1312+
return "0 B";
1313+
}
1314+
1315+
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
1316+
var unitIndex = 0;
1317+
double value = bytes;
1318+
while (value >= 1024 && unitIndex < units.Length - 1)
1319+
{
1320+
value /= 1024;
1321+
unitIndex++;
1322+
}
1323+
1324+
if (unitIndex == 0)
1325+
{
1326+
return $"{bytes:N0} B";
1327+
}
1328+
1329+
return $"{value:0.##} {units[unitIndex]}";
1330+
}
1331+
1332+
private static IToastService? GetToastService()
1333+
{
1334+
try
1335+
{
1336+
return ServiceManager.Services?.GetService(typeof(IToastService)) as IToastService;
1337+
}
1338+
catch
1339+
{
1340+
return null;
1341+
}
1342+
}
1343+
1344+
private void StartTransferToast(string requestId, bool isSending, string fileName, long totalBytes, string remoteName)
1345+
{
1346+
if (string.IsNullOrWhiteSpace(requestId))
1347+
{
1348+
return;
1349+
}
1350+
1351+
var toastService = GetToastService();
1352+
if (toastService is null)
1353+
{
1354+
return;
1355+
}
1356+
1357+
var action = isSending ? "发送" : "接收";
1358+
var direction = isSending ? "到" : "从";
1359+
var detail = totalBytes > 0
1360+
? $"{FormatFileSize(0)} / {FormatFileSize(totalBytes)}"
1361+
: "准备中...";
1362+
var handle = toastService.ShowProgress(
1363+
isSending ? "文件发送" : "文件接收",
1364+
$"{action} {fileName} {direction} {remoteName} ({detail})",
1365+
NotificationType.Information,
1366+
initialProgress: 0,
1367+
isIndeterminate: totalBytes <= 0);
1368+
1369+
var map = isSending ? _sendingTransferToasts : _receivingTransferToasts;
1370+
map[requestId] = handle;
1371+
}
1372+
1373+
private void UpdateTransferToastProgress(string requestId, bool isSending, string fileName, long transferredBytes,
1374+
long totalBytes, string remoteName)
1375+
{
1376+
if (string.IsNullOrWhiteSpace(requestId))
1377+
{
1378+
return;
1379+
}
1380+
1381+
var map = isSending ? _sendingTransferToasts : _receivingTransferToasts;
1382+
if (!map.TryGetValue(requestId, out var handle))
1383+
{
1384+
return;
1385+
}
1386+
1387+
var action = isSending ? "发送" : "接收";
1388+
var direction = isSending ? "到" : "从";
1389+
if (totalBytes > 0)
1390+
{
1391+
var progress = Math.Min(100, transferredBytes * 100d / totalBytes);
1392+
handle.Update(
1393+
progress: progress,
1394+
text:
1395+
$"{action} {fileName} {direction} {remoteName} ({FormatFileSize(transferredBytes)} / {FormatFileSize(totalBytes)})");
1396+
return;
1397+
}
1398+
1399+
handle.Update(
1400+
text: $"{action} {fileName} {direction} {remoteName} ({FormatFileSize(transferredBytes)})",
1401+
isIndeterminate: true);
1402+
}
1403+
1404+
private void CompleteTransferToast(string requestId, bool isSending, string fileName, long totalBytes,
1405+
string remoteName)
1406+
{
1407+
var map = isSending ? _sendingTransferToasts : _receivingTransferToasts;
1408+
if (!map.TryRemove(requestId, out var handle))
1409+
{
1410+
return;
1411+
}
1412+
1413+
var action = isSending ? "已发送" : "已接收";
1414+
var direction = isSending ? "到" : "从";
1415+
var sizeText = totalBytes > 0 ? $" ({FormatFileSize(totalBytes)})" : string.Empty;
1416+
handle.Complete($"{action} {fileName}{sizeText} {direction} {remoteName}");
1417+
}
1418+
1419+
private void FailTransferToast(string requestId, string reason, bool isSending)
1420+
{
1421+
if (string.IsNullOrWhiteSpace(requestId))
1422+
{
1423+
return;
1424+
}
1425+
1426+
var map = isSending ? _sendingTransferToasts : _receivingTransferToasts;
1427+
if (!map.TryRemove(requestId, out var handle))
1428+
{
1429+
return;
1430+
}
1431+
1432+
handle.Fail($"传输中断:{reason}");
1433+
}
11951434

11961435
public void Dispose()
11971436
{
@@ -1229,3 +1468,4 @@ private class PacketMetadata
12291468
public string SenderName { get; set; } = "";
12301469
}
12311470
}
1471+

0 commit comments

Comments
 (0)