feat(#97): mirror app/BLE logs to rotating files on disk (cross-platform)

Always-on file logging so users/support can retrieve a log without enabling
anything in the UI. No-op on web (no filesystem).

- FileLogWriter: rotating file writer (size cap + max files), single + batch
  writes, unit-tested against a real temp dir.
- FileLogService: singleton initialised in main(); resolves
  getApplicationSupportDirectory()/logs, queues lines, periodic 2s batched
  flush, bounded queue, never crashes the app on an IO error.
- AppDebugLogService.log + BleDebugLogService.logFrame write every entry to the
  file (app logs always, independent of the in-app viewer's enable gate).

Location per the #97 decision: standard support dir on all platforms (Windows
%APPDATA%\app.offband.meshcore\logs). Still TODO before recompile: access UI
("Open logs folder" on desktop / "Share logs" on mobile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/99/head
Strycher 4 weeks ago
parent d73a04ec33
commit 1fa0b53d57

@ -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();

@ -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.

@ -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(),

@ -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<String> _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<void> 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<void> _flush() async {
final w = _writer;
if (w == null || _queue.isEmpty) return;
final batch = List<String>.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<File?> 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<void> dispose() async {
_flushTimer?.cancel();
_flushTimer = null;
await _flush();
}
}

@ -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 (`<baseName>.log`) past
/// [maxBytes], it rotates: `<baseName>.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<void> 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<void> writeLines(List<String> 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);
}
/// `<base>.(N-1).log` -> `<base>.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<void> _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);
}
}
}

@ -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);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.