diff --git a/lib/main.dart b/lib/main.dart index 13c6522..5affdd6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'services/app_settings_service.dart'; import 'services/notification_service.dart'; import 'services/ble_debug_log_service.dart'; import 'services/app_debug_log_service.dart'; +import 'services/file_log_service.dart'; import 'services/background_service.dart'; import 'services/map_tile_cache_service.dart'; import 'services/chat_text_scale_service.dart'; @@ -33,6 +34,9 @@ void main() async { // Initialize SharedPreferences cache await PrefsManager.initialize(); + // Start always-on file logging (#97); no-op on web. + await FileLogService.instance.init(); + // Initialize services final storage = StorageService(); final connector = MeshCoreConnector(); diff --git a/lib/services/app_debug_log_service.dart b/lib/services/app_debug_log_service.dart index d31c3e5..49b7089 100644 --- a/lib/services/app_debug_log_service.dart +++ b/lib/services/app_debug_log_service.dart @@ -1,5 +1,7 @@ import 'package:flutter/foundation.dart'; +import 'file_log_service.dart'; + enum AppDebugLogLevel { info, warning, error } class AppDebugLogEntry { @@ -53,6 +55,14 @@ class AppDebugLogService extends ChangeNotifier { AppDebugLogLevel level = AppDebugLogLevel.info, bool noNotify = false, }) { + final lvl = level == AppDebugLogLevel.error + ? 'ERROR' + : level == AppDebugLogLevel.warning + ? 'WARN' + : 'INFO'; + FileLogService.instance.write( + '${DateTime.now().toIso8601String()} [$lvl/$tag] $message', + ); if (!_enabled && !kDebugMode) return; if (!_enabled) { // In debug mode, still print to console but don't store entries. diff --git a/lib/services/ble_debug_log_service.dart b/lib/services/ble_debug_log_service.dart index df2822b..b4685f2 100644 --- a/lib/services/ble_debug_log_service.dart +++ b/lib/services/ble_debug_log_service.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import '../connector/meshcore_protocol.dart'; +import 'file_log_service.dart'; class BleDebugLogEntry { final DateTime timestamp; @@ -55,6 +56,10 @@ class BleDebugLogService extends ChangeNotifier { if (frame.isEmpty) return; final code = frame[0]; final description = _describeFrame(code, frame, outgoing, note); + FileLogService.instance.write( + '${DateTime.now().toIso8601String()} $description ' + '${frame.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}', + ); _entries.add( BleDebugLogEntry( timestamp: DateTime.now(), diff --git a/lib/services/file_log_service.dart b/lib/services/file_log_service.dart new file mode 100644 index 0000000..703333e --- /dev/null +++ b/lib/services/file_log_service.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../utils/file_log_writer.dart'; + +/// Always-on, app-wide file logging (#97). Mirrors the in-memory app + BLE logs +/// to a rotating file under `getApplicationSupportDirectory()/logs` so users +/// (and support) can retrieve a log without enabling anything. No-op on web +/// (no filesystem). A singleton initialised in `main()`; the two log services +/// call [write] on every entry, and a periodic flush batches them to disk so +/// the hot logging path never touches the disk directly. +class FileLogService { + FileLogService._(); + static final FileLogService instance = FileLogService._(); + + FileLogWriter? _writer; + Directory? _dir; + final List _queue = []; + Timer? _flushTimer; + bool _initStarted = false; + + /// The logs directory once resolved (null on web / before init / on failure). + Directory? get logDir => _dir; + + /// Resolve the platform logs dir and start the periodic flush. Safe to call + /// once; a no-op on web. Lines written before this completes are queued. + Future init() async { + if (_initStarted || kIsWeb) return; + _initStarted = true; + try { + final support = await getApplicationSupportDirectory(); + final dir = Directory('${support.path}${Platform.pathSeparator}logs'); + await dir.create(recursive: true); + _dir = dir; + _writer = FileLogWriter(dir: dir); + _flushTimer = Timer.periodic( + const Duration(seconds: 2), + (_) => unawaited(_flush()), + ); + } catch (_) { + // Logging must never crash the app. If the dir can't be resolved we stay + // a no-op; the in-memory logs still work. + } + } + + /// Queue one already-formatted line for the next flush. No-op on web. Bounded + /// so a stalled flush can't grow memory without limit. + void write(String line) { + if (kIsWeb) return; + _queue.add(line); + if (_queue.length > 5000) _queue.removeRange(0, _queue.length - 5000); + } + + Future _flush() async { + final w = _writer; + if (w == null || _queue.isEmpty) return; + final batch = List.of(_queue); + _queue.clear(); + try { + await w.writeLines(batch); + } catch (_) { + // Drop on IO error; never crash the app over a log write. + } + } + + /// Flush pending lines and return the active log file (for share/export). + /// Null when file logging is unavailable (web / not initialised). + Future flushAndGetActiveFile() async { + await _flush(); + return _writer?.activeFile; + } + + /// Cancel the periodic flush and flush once more. The singleton normally lives + /// for the app's lifetime; this is for a clean shutdown / tests. + Future dispose() async { + _flushTimer?.cancel(); + _flushTimer = null; + await _flush(); + } +} diff --git a/lib/utils/file_log_writer.dart b/lib/utils/file_log_writer.dart new file mode 100644 index 0000000..63a7209 --- /dev/null +++ b/lib/utils/file_log_writer.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +/// Appends log lines to a rotating file under [dir] (#97). +/// +/// When writing a line would push the active file (`.log`) past +/// [maxBytes], it rotates: `.log` -> `.1.log` -> `.2.log` …, keeping +/// at most [maxFiles] files total (oldest dropped). Platform-agnostic: the +/// caller resolves [dir] (e.g. `getApplicationSupportDirectory()`) and only +/// constructs this where a filesystem exists — never on web. Writes are appended +/// and flushed so logs survive a crash. +class FileLogWriter { + FileLogWriter({ + required this.dir, + this.baseName = 'offband', + this.maxBytes = 2 * 1024 * 1024, + this.maxFiles = 3, + }) : assert(maxFiles >= 1); + + final Directory dir; + final String baseName; + final int maxBytes; + final int maxFiles; + + File get activeFile => File('${dir.path}/$baseName.log'); + File _rotated(int n) => File('${dir.path}/$baseName.$n.log'); + + /// Append [line] (a trailing newline is added), rotating first if it would + /// push the active file past [maxBytes]. + Future writeLine(String line) async { + if (!await dir.exists()) await dir.create(recursive: true); + final active = activeFile; + final size = await active.exists() ? await active.length() : 0; + if (size > 0 && size + line.length + 1 > maxBytes) { + await _rotate(); + } + await activeFile.writeAsString( + '$line\n', + mode: FileMode.append, + flush: true, + ); + } + + /// Append a batch of [lines] in one write (single open + flush), rotating + /// once first if the batch would push the active file past [maxBytes]. Used by + /// the service's periodic flush so frequent log calls don't each hit the disk. + Future writeLines(List lines) async { + if (lines.isEmpty) return; + if (!await dir.exists()) await dir.create(recursive: true); + final data = '${lines.join('\n')}\n'; + final active = activeFile; + final size = await active.exists() ? await active.length() : 0; + if (size > 0 && size + data.length > maxBytes) { + await _rotate(); + } + await activeFile.writeAsString(data, mode: FileMode.append, flush: true); + } + + /// `.(N-1).log` -> `.N.log` … and active -> `.1.log`; anything + /// beyond [maxFiles] total is dropped. With [maxFiles] == 1 the active file is + /// simply restarted (no rotated copies). + Future _rotate() async { + if (maxFiles <= 1) { + if (await activeFile.exists()) await activeFile.delete(); + return; + } + final oldest = _rotated(maxFiles - 1); + if (await oldest.exists()) await oldest.delete(); + for (var n = maxFiles - 2; n >= 1; n--) { + final f = _rotated(n); + if (await f.exists()) await f.rename(_rotated(n + 1).path); + } + if (await activeFile.exists()) { + await activeFile.rename(_rotated(1).path); + } + } +} diff --git a/test/utils/file_log_writer_test.dart b/test/utils/file_log_writer_test.dart new file mode 100644 index 0000000..22a0a23 --- /dev/null +++ b/test/utils/file_log_writer_test.dart @@ -0,0 +1,79 @@ +// Rotating file-log writer (#97). Exercised against a real temp directory — +// dart:io File works in flutter_test on desktop, so the rotation behaviour is +// integration-tested, not mocked. + +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/utils/file_log_writer.dart'; + +void main() { + group('FileLogWriter', () { + late Directory dir; + setUp(() => dir = Directory.systemTemp.createTempSync('flog')); + tearDown(() { + if (dir.existsSync()) dir.deleteSync(recursive: true); + }); + + test('appends lines to the active file', () async { + final w = FileLogWriter(dir: dir, baseName: 't', maxBytes: 1 << 20); + await w.writeLine('hello'); + await w.writeLine('world'); + expect(await w.activeFile.readAsString(), 'hello\nworld\n'); + }); + + test('creates the directory if missing', () async { + final sub = Directory('${dir.path}/nested/logs'); + final w = FileLogWriter(dir: sub, baseName: 't', maxBytes: 1 << 20); + await w.writeLine('x'); + expect(sub.existsSync(), isTrue); + expect(w.activeFile.existsSync(), isTrue); + }); + + test('rotates past maxBytes and caps at maxFiles total', () async { + final w = FileLogWriter( + dir: dir, + baseName: 't', + maxBytes: 100, + maxFiles: 3, + ); + for (var i = 0; i < 60; i++) { + await w.writeLine('line $i ${'x' * 20}'); + } + expect(File('${dir.path}/t.log').existsSync(), isTrue); + expect(File('${dir.path}/t.1.log').existsSync(), isTrue); + expect(File('${dir.path}/t.2.log').existsSync(), isTrue); + // capped: active + .1 + .2 = 3 files, no .3 + expect(File('${dir.path}/t.3.log').existsSync(), isFalse); + }); + + test('maxFiles=1 keeps only the active file (restart on rotate)', () async { + final w = FileLogWriter( + dir: dir, + baseName: 't', + maxBytes: 50, + maxFiles: 1, + ); + for (var i = 0; i < 20; i++) { + await w.writeLine('line $i padding padding'); + } + expect(File('${dir.path}/t.log').existsSync(), isTrue); + expect(File('${dir.path}/t.1.log').existsSync(), isFalse); + }); + + test('writeLines batches and still rotates', () async { + final w = FileLogWriter( + dir: dir, + baseName: 't', + maxBytes: 200, + maxFiles: 3, + ); + for (var b = 0; b < 10; b++) { + await w.writeLines( + List.generate(5, (i) => 'batch $b line $i ${'y' * 10}'), + ); + } + expect(File('${dir.path}/t.log').existsSync(), isTrue); + expect(File('${dir.path}/t.1.log').existsSync(), isTrue); + }); + }); +}