diff --git a/lib/connector/caplog_reassembler.dart b/lib/connector/caplog_reassembler.dart index 1bcb447..e412677 100644 --- a/lib/connector/caplog_reassembler.dart +++ b/lib/connector/caplog_reassembler.dart @@ -71,23 +71,26 @@ class CaplogBusyException implements Exception { 'CaplogBusyException: device busy, another stream is in flight'; } -/// Thrown when the reassembled caplog byte count doesn't match the length the -/// device announced in its START frame. (#430) -class CaplogTruncatedException implements Exception { - const CaplogTruncatedException({ +/// Result of a caplog download. On a clean transfer [received] == [expected] +/// and [truncated] is false; on a short transfer [bytes] still carry everything +/// that was reassembled and [truncated] is true. A partial capture is kept and +/// handed back rather than discarded, so the user can still save/share it. (#580) +class CaplogDownload { + const CaplogDownload({ + required this.bytes, required this.received, required this.expected, - this.chunks, + required this.chunks, }); + + /// The reassembled bytes (complete, or partial when [truncated]). + final Uint8List bytes; final int received; final int expected; - /// Number of CHUNK frames the client accumulated before END, a diagnostic to - /// tell client/transport frame loss apart from the firmware streaming short. - final int? chunks; + /// Number of CHUNK frames accumulated before END, a diagnostic to tell + /// client/transport frame loss apart from the firmware streaming short. + final int chunks; - @override - String toString() => - 'CaplogTruncatedException: received $received of $expected bytes' - '${chunks != null ? ' in $chunks chunks' : ''}'; + bool get truncated => received != expected; } diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 12b0371..8d70b2a 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -292,7 +292,7 @@ class MeshCoreConnector extends ChangeNotifier { bool _blockDumpInFlight = false; final Set _blockKeysTouchedDuringDump = {}; // Caplog serial-capture download (0xC4) streamed reassembly (#430). - Completer? _caplogCompleter; + Completer? _caplogCompleter; CaplogReassembler? _caplogReassembler; bool _caplogAwaitingStart = false; Completer? _caplogAckCompleter; @@ -4638,18 +4638,19 @@ class MeshCoreConnector extends ChangeNotifier { /// (0xC4). Sends the request and reassembles the START/CHUNK*/END stream into /// the raw captured bytes. /// - /// Throws [StateError] if a download is already in flight, + /// Returns a [CaplogDownload] carrying the reassembled bytes. A short transfer + /// is NOT an error: the result's [CaplogDownload.truncated] is set and the + /// partial [CaplogDownload.bytes] are kept so the caller can still save/share + /// them (#580). Throws [StateError] if a download is already in flight, /// [CaplogBusyException] if the device rejects because another stream is - /// already in flight, [CaplogTruncatedException] if the byte count doesn't - /// match the announced length, or [TimeoutException] if it never finishes. - /// (#430) - Future downloadCaplog({ + /// already in flight, or [TimeoutException] if it never finishes. (#430) + Future downloadCaplog({ Duration timeout = const Duration(seconds: 30), }) async { if (_caplogCompleter != null) { throw StateError('A caplog download is already in progress'); } - final completer = Completer(); + final completer = Completer(); _caplogCompleter = completer; _caplogReassembler = CaplogReassembler(); _caplogAwaitingStart = true; @@ -4712,7 +4713,14 @@ class MeshCoreConnector extends ChangeNotifier { '$_caplogChunks chunks', tag: 'Caplog', ); - completer.complete(event.bytes); + completer.complete( + CaplogDownload( + bytes: event.bytes!, + received: event.bytes!.length, + expected: event.bytes!.length, + chunks: _caplogChunks, + ), + ); break; case CaplogStatus.truncated: _appDebugLogService?.warn( @@ -4720,8 +4728,11 @@ class MeshCoreConnector extends ChangeNotifier { '${event.expected} bytes in $_caplogChunks chunks', tag: 'Caplog', ); - completer.completeError( - CaplogTruncatedException( + // Not an error: hand back the partial bytes so the caller can still + // save/share them (#580). + completer.complete( + CaplogDownload( + bytes: event.bytes!, received: event.bytes!.length, expected: event.expected!, chunks: _caplogChunks, diff --git a/lib/screens/serial_capture_screen.dart b/lib/screens/serial_capture_screen.dart index f5a749d..02564fe 100644 --- a/lib/screens/serial_capture_screen.dart +++ b/lib/screens/serial_capture_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -37,6 +38,7 @@ class _SerialCaptureScreenState extends State { int _durationMinutes = 5; bool _busy = false; String? _error; + String? _warning; bool _wasConnected = false; DateTime? _startedAt; @@ -247,11 +249,12 @@ class _SerialCaptureScreenState extends State { setState(() { _busy = true; _error = null; + _warning = null; }); try { - final bytes = await c.downloadCaplog(); + final result = await c.downloadCaplog(); if (!mounted) return; - if (bytes.isEmpty) { + if (result.bytes.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Buffer is empty, nothing to download.'), @@ -259,15 +262,36 @@ class _SerialCaptureScreenState extends State { ); return; } + // A truncated capture is kept, not discarded: mark the file partial (so a + // partial log is never mistaken for a complete one) and warn the user, but + // still write and offer to save/share it (#580). final dir = await getTemporaryDirectory(); final ts = DateTime.now(); final name = 'serial-capture-' '${ts.year}${_pad2(ts.month)}${_pad2(ts.day)}-' - '${_pad2(ts.hour)}${_pad2(ts.minute)}${_pad2(ts.second)}.txt'; + '${_pad2(ts.hour)}${_pad2(ts.minute)}${_pad2(ts.second)}' + '${result.truncated ? '-partial' : ''}.txt'; final file = File('${dir.path}${Platform.pathSeparator}$name'); - await file.writeAsBytes(bytes); + if (result.truncated) { + final header = utf8.encode( + '# PARTIAL Offband serial capture: ' + '${result.received} of ${result.expected} bytes in ' + '${result.chunks} chunks. Incomplete ' + '(see OffbandMesh/meshcore-firmware#711).\n\n', + ); + await file.writeAsBytes([...header, ...result.bytes]); + } else { + await file.writeAsBytes(result.bytes); + } if (!mounted) return; + if (result.truncated) { + setState( + () => _warning = + 'Partial capture: ${result.received} of ${result.expected} ' + 'bytes. Saving what was captured.', + ); + } await LogExport.shareFile( context, file, @@ -279,8 +303,6 @@ class _SerialCaptureScreenState extends State { () => _error = 'Device busy (another transfer in progress). Retry.', ); } - } on CaplogTruncatedException catch (e) { - if (mounted) setState(() => _error = 'Capture truncated: $e'); } on TimeoutException { if (mounted) setState(() => _error = 'No response from device.'); } catch (e) { @@ -381,6 +403,24 @@ class _SerialCaptureScreenState extends State { ), ), ), + if (_warning != null) + Card( + color: Theme.of(context).colorScheme.tertiaryContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + const Icon(Icons.warning_amber_outlined), + const SizedBox(width: 8), + Expanded(child: Text(_warning!)), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => setState(() => _warning = null), + ), + ], + ), + ), + ), Card( child: Padding( padding: const EdgeInsets.all(16), @@ -451,7 +491,7 @@ class _SerialCaptureScreenState extends State { OutlinedButton.icon( onPressed: _busy || capturing ? null : _download, icon: Icon(LogExport.icon), - label: const Text('Download & share'), + label: Text('Download & ${LogExport.actionVerb}'), ), const SizedBox(height: 8), TextButton.icon( diff --git a/lib/utils/log_export.dart b/lib/utils/log_export.dart index 271da1f..2532cf4 100644 --- a/lib/utils/log_export.dart +++ b/lib/utils/log_export.dart @@ -43,6 +43,11 @@ class LogExport { : context.l10n.debugLog_saveLog; } + /// Short verb for a compound button label like "Download & save". Mobile + /// shares via the OS sheet; desktop and web save a file (web via a browser + /// download), so "save" fits both. (#580) + static String get actionVerb => (!kIsWeb && _isMobile) ? 'share' : 'save'; + /// Export the combined on-disk app+BLE log (the App/BLE log screens). On web /// there is no on-disk file, so [webContent] supplies the text to download. static Future shareLogs( diff --git a/test/connector/caplog_reassembler_test.dart b/test/connector/caplog_reassembler_test.dart index 2835202..c4730c7 100644 --- a/test/connector/caplog_reassembler_test.dart +++ b/test/connector/caplog_reassembler_test.dart @@ -113,4 +113,27 @@ void main() { expect(end.bytes, Uint8List.fromList(expected)); }); }); + + group('CaplogDownload', () { + test('truncated when received < expected, and keeps the partial bytes', () { + final d = CaplogDownload( + bytes: Uint8List.fromList([1, 2, 3]), + received: 3, + expected: 10, + chunks: 2, + ); + expect(d.truncated, isTrue); + expect(d.bytes, Uint8List.fromList([1, 2, 3])); + }); + + test('not truncated when received == expected', () { + final d = CaplogDownload( + bytes: Uint8List.fromList([1, 2, 3]), + received: 3, + expected: 3, + chunks: 1, + ); + expect(d.truncated, isFalse); + }); + }); }