diff --git a/lib/core/logging/cubit/app_logs_cubit.dart b/lib/core/logging/cubit/app_logs_cubit.dart new file mode 100644 index 0000000..7ff127a --- /dev/null +++ b/lib/core/logging/cubit/app_logs_cubit.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:paperless_mobile/core/logging/models/parsed_log_message.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:path/path.dart' as p; +import 'package:rxdart/rxdart.dart'; +part 'app_logs_state.dart'; + +final _fileNameFormat = DateFormat("yyyy-MM-dd"); + +class AppLogsCubit extends Cubit { + StreamSubscription? _fileChangesSubscription; + AppLogsCubit(DateTime date) : super(AppLogsStateInitial(date: date)); + + Future loadLogs(DateTime date) async { + if (date == state.date) { + return; + } + _fileChangesSubscription?.cancel(); + emit(AppLogsStateLoading(date: date)); + final logDir = FileService.instance.logDirectory; + final availableLogs = (await logDir + .list() + .whereType() + .where((event) => event.path.endsWith('.log')) + .map((e) => + _fileNameFormat.parse(p.basenameWithoutExtension(e.path))) + .toList()) + .sorted(); + final logFile = _getLogfile(date); + if (!await logFile.exists()) { + emit(AppLogsStateLoaded( + date: date, + logs: [], + availableLogs: availableLogs, + )); + } + try { + final logs = await logFile.readAsLines(); + final parsedLogs = + ParsedLogMessage.parse(logs.skip(2000).toList()).reversed.toList(); + _fileChangesSubscription = logFile.watch().listen((event) async { + if (!isClosed) { + final logs = await logFile.readAsLines(); + emit(AppLogsStateLoaded( + date: date, + logs: parsedLogs, + availableLogs: availableLogs, + )); + } + }); + emit(AppLogsStateLoaded( + date: date, + logs: parsedLogs, + availableLogs: availableLogs, + )); + } catch (e) { + emit(AppLogsStateError( + error: e, + date: date, + )); + } + } + + Future clearLogs(DateTime date) async { + final logFile = _getLogfile(date); + await logFile.writeAsString(''); + await loadLogs(date); + } + + Future copyToClipboard(DateTime date) async { + final file = _getLogfile(date); + if (!await file.exists()) { + return; + } + final content = await file.readAsString(); + Clipboard.setData(ClipboardData(text: content)); + } + + Future saveLogs(DateTime date, String locale) async { + var formattedDate = _fileNameFormat.format(date); + final filename = 'paperless_mobile_logs_$formattedDate.log'; + final parentDir = await FilePicker.platform.getDirectoryPath( + dialogTitle: "Save log from ${DateFormat.yMd(locale).format(date)}", + initialDirectory: Platform.isAndroid + ? FileService.instance.downloadsDirectory.path + : null, + ); + final logFile = _getLogfile(date); + if (parentDir != null) { + await logFile.copy(p.join(parentDir, filename)); + } + } + + File _getLogfile(DateTime date) { + return File(p.join(FileService.instance.logDirectory.path, + '${_fileNameFormat.format(date)}.log')); + } + + @override + Future close() { + _fileChangesSubscription?.cancel(); + return super.close(); + } +} diff --git a/lib/core/logging/cubit/app_logs_state.dart b/lib/core/logging/cubit/app_logs_state.dart new file mode 100644 index 0000000..f8b1966 --- /dev/null +++ b/lib/core/logging/cubit/app_logs_state.dart @@ -0,0 +1,33 @@ +part of 'app_logs_cubit.dart'; + +sealed class AppLogsState { + final DateTime date; + const AppLogsState({required this.date}); +} + +class AppLogsStateInitial extends AppLogsState { + const AppLogsStateInitial({required super.date}); +} + +class AppLogsStateLoading extends AppLogsState { + const AppLogsStateLoading({required super.date}); +} + +class AppLogsStateLoaded extends AppLogsState { + const AppLogsStateLoaded({ + required super.date, + required this.logs, + required this.availableLogs, + }); + final List availableLogs; + final List logs; +} + +class AppLogsStateError extends AppLogsState { + const AppLogsStateError({ + required this.error, + required super.date, + }); + + final Object error; +} diff --git a/lib/core/logging/data/formatted_printer.dart b/lib/core/logging/data/formatted_printer.dart new file mode 100644 index 0000000..63d88c2 --- /dev/null +++ b/lib/core/logging/data/formatted_printer.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:intl/intl.dart'; +import 'package:logger/logger.dart'; +import 'package:paperless_mobile/core/logging/models/formatted_log_message.dart'; + +class FormattedPrinter extends LogPrinter { + static final _timestampFormat = DateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + static const _mulitlineObjectEncoder = JsonEncoder.withIndent(null); + + @override + List log(LogEvent event) { + final unformattedMessage = event.message; + final formattedMessage = switch (unformattedMessage) { + FormattedLogMessage m => m.format(), + Iterable i => _mulitlineObjectEncoder + .convert(i) + .padLeft(FormattedLogMessage.maxLength), + Map m => _mulitlineObjectEncoder + .convert(m) + .padLeft(FormattedLogMessage.maxLength), + _ => unformattedMessage.toString().padLeft(FormattedLogMessage.maxLength), + }; + final formattedLevel = event.level.name + .toUpperCase() + .padRight(Level.values.map((e) => e.name.length).max); + final formattedTimestamp = _timestampFormat.format(event.time); + + return [ + '$formattedTimestamp\t$formattedLevel --- $formattedMessage', + if (event.error != null) ...[ + "---BEGIN ERROR---", + event.error.toString(), + "---END ERROR---", + ], + if (event.stackTrace != null) ...[ + "---BEGIN STACKTRACE---", + event.stackTrace.toString(), + "---END STACKTRACE---" + ], + ]; + } +} diff --git a/lib/core/logging/data/logger.dart b/lib/core/logging/data/logger.dart new file mode 100644 index 0000000..e7016be --- /dev/null +++ b/lib/core/logging/data/logger.dart @@ -0,0 +1,116 @@ +import 'package:logger/logger.dart'; +import 'package:paperless_mobile/core/logging/models/formatted_log_message.dart'; + +late Logger logger; + +extension FormattedLoggerExtension on Logger { + void ft( + dynamic message, { + String className = '', + String methodName = '', + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + final formattedMessage = FormattedLogMessage( + message, + className: className, + methodName: methodName, + ); + log( + Level.trace, + formattedMessage, + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void fw( + dynamic message, { + String className = '', + String methodName = '', + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + final formattedMessage = FormattedLogMessage( + message, + className: className, + methodName: methodName, + ); + log( + Level.warning, + formattedMessage, + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void fd( + dynamic message, { + String className = '', + String methodName = '', + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + final formattedMessage = FormattedLogMessage( + message, + className: className, + methodName: methodName, + ); + log( + Level.debug, + formattedMessage, + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void fi( + dynamic message, { + String className = '', + String methodName = '', + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + final formattedMessage = FormattedLogMessage( + message, + className: className, + methodName: methodName, + ); + log( + Level.info, + formattedMessage, + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void fe( + dynamic message, { + String className = '', + String methodName = '', + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + final formattedMessage = FormattedLogMessage( + message, + className: className, + methodName: methodName, + ); + log( + Level.error, + formattedMessage, + time: time, + error: error, + stackTrace: stackTrace, + ); + } +} diff --git a/lib/core/logging/data/mirrored_file_output.dart b/lib/core/logging/data/mirrored_file_output.dart new file mode 100644 index 0000000..8feff46 --- /dev/null +++ b/lib/core/logging/data/mirrored_file_output.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:logger/logger.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:path/path.dart' as p; +import 'package:synchronized/synchronized.dart'; + +class MirroredFileOutput extends LogOutput { + final Completer _initCompleter = Completer(); + var lock = Lock(); + MirroredFileOutput(); + + late final File file; + + @override + Future init() async { + final today = DateFormat("yyyy-MM-dd").format(DateTime.now()); + final logDir = FileService.instance.logDirectory; + file = File(p.join(logDir.path, '$today.log')); + debugPrint("Logging files to ${file.path}."); + _initCompleter.complete(); + try { + final oldLogs = await FileService.instance.getAllFiles(logDir); + if (oldLogs.length > 10) { + oldLogs + .sortedBy((file) => file.lastModifiedSync()) + .reversed + .skip(10) + .forEach((log) => log.delete()); + } + } catch (e) { + debugPrint("Failed to delete old logs..."); + } + } + + @override + void output(OutputEvent event) async { + await lock.synchronized(() async { + for (var line in event.lines) { + debugPrint(line); + if (_initCompleter.isCompleted) { + await file.writeAsString( + "$line${Platform.lineTerminator}", + mode: FileMode.append, + ); + } + } + }); + } +} diff --git a/lib/core/logging/logger.dart b/lib/core/logging/logger.dart deleted file mode 100644 index 1a5fbe4..0000000 --- a/lib/core/logging/logger.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:flutter/widgets.dart'; -import 'package:intl/intl.dart'; -import 'package:logger/logger.dart'; -import 'package:paperless_mobile/core/service/file_service.dart'; -import 'package:path/path.dart' as p; -import 'package:rxdart/rxdart.dart'; - -late Logger logger; - -class MirroredFileOutput extends LogOutput { - late final File file; - final Completer _initCompleter = Completer(); - MirroredFileOutput(); - - @override - Future init() async { - final today = DateFormat("yyyy-MM-dd").format(DateTime.now()); - final logDir = await FileService.logDirectory; - file = File(p.join(logDir.path, '$today.log')); - debugPrint("Logging files to ${file.path}."); - _initCompleter.complete(); - try { - final oldLogs = await logDir.list().whereType().toList(); - if (oldLogs.length > 10) { - oldLogs - .sortedBy((file) => file.lastModifiedSync()) - .reversed - .skip(10) - .forEach((log) => log.delete()); - } - } catch (e) { - debugPrint("Failed to delete old logs..."); - } - } - - @override - void output(OutputEvent event) async { - for (var line in event.lines) { - debugPrint(line); - if (_initCompleter.isCompleted) { - await file.writeAsString( - "$line\n", - mode: FileMode.append, - ); - } - } - } -} - -class SpringBootLikePrinter extends LogPrinter { - SpringBootLikePrinter(); - static final _timestampFormat = DateFormat("yyyy-MM-dd HH:mm:ss.SSS"); - - @override - List log(LogEvent event) { - final level = _buildLeftAligned(event.level.name.toUpperCase(), - Level.values.map((e) => e.name.length).max); - String message = _stringifyMessage(event.message); - final timestamp = - _buildLeftAligned(_timestampFormat.format(event.time), 23); - final traceRegex = RegExp(r"(.*)#(.*)\(\): (.*)"); - final match = traceRegex.firstMatch(message); - if (match != null) { - final className = match.group(1)!; - final methodName = match.group(2)!; - final remainingMessage = match.group(3)!; - final formattedClassName = _buildRightAligned(className, 25); - final formattedMethodName = _buildLeftAligned(methodName, 25); - message = message.replaceFirst(traceRegex, - "[$formattedClassName] - $formattedMethodName: $remainingMessage"); - } else { - message = List.filled(55, " ").join("") + ": " + message; - } - return [ - '$timestamp\t$level --- $message', - if (event.error != null) '\t\t${event.error}', - if (event.stackTrace != null) '\t\t${event.stackTrace.toString()}', - ]; - } - - String _buildLeftAligned(String message, int maxLength) { - return message.padRight(maxLength, ' '); - } - - String _buildRightAligned(String message, int maxLength) { - return message.padLeft(maxLength, ' '); - } - - String _stringifyMessage(dynamic message) { - final finalMessage = message is Function ? message() : message; - if (finalMessage is Map || finalMessage is Iterable) { - var encoder = const JsonEncoder.withIndent(null); - return encoder.convert(finalMessage); - } else { - return finalMessage.toString(); - } - } -} diff --git a/lib/core/logging/models/formatted_log_message.dart b/lib/core/logging/models/formatted_log_message.dart new file mode 100644 index 0000000..94b13ce --- /dev/null +++ b/lib/core/logging/models/formatted_log_message.dart @@ -0,0 +1,19 @@ +/// Class passed to the printer to be formatted and printed. +class FormattedLogMessage { + static const maxLength = 55; + final String message; + final String methodName; + final String className; + + FormattedLogMessage( + this.message, { + required this.methodName, + required this.className, + }); + + String format() { + final formattedClassName = className.padLeft(25); + final formattedMethodName = methodName.padRight(25); + return '[$formattedClassName] - $formattedMethodName: $message'; + } +} diff --git a/lib/core/logging/models/parsed_log_message.dart b/lib/core/logging/models/parsed_log_message.dart new file mode 100644 index 0000000..3c121cc --- /dev/null +++ b/lib/core/logging/models/parsed_log_message.dart @@ -0,0 +1,148 @@ +import 'dart:io'; + +import 'package:logger/logger.dart'; + +final _newLine = Platform.lineTerminator; + +sealed class ParsedLogMessage { + static List parse(List logs) { + List messages = []; + int offset = 0; + while (offset < logs.length) { + final currentLine = logs[offset]; + if (ParsedFormattedLogMessage.canConsumeFirstLine(currentLine)) { + final (consumedLines, result) = + ParsedFormattedLogMessage.consume(logs.sublist(offset)); + messages.add(result); + offset += consumedLines; + } else { + messages.add(UnformattedLogMessage(currentLine)); + offset++; + } + } + return messages; + } +} + +class ParsedErrorLogMessage { + static final RegExp _errorBeginPattern = RegExp(r"---BEGIN ERROR---\s*"); + static final RegExp _errorEndPattern = RegExp(r"---END ERROR---\s*"); + static final RegExp _stackTraceBeginPattern = + RegExp(r"---BEGIN STACKTRACE---\s*"); + static final RegExp _stackTraceEndPattern = + RegExp(r"---END STACKTRACE---\s*"); + final String error; + final String? stackTrace; + ParsedErrorLogMessage({ + required this.error, + this.stackTrace, + }); + static bool canConsumeFirstLine(String line) => + _errorBeginPattern.hasMatch(line); + + static (int consumedLines, ParsedErrorLogMessage result) consume( + List log) { + assert(log.isNotEmpty && canConsumeFirstLine(log.first)); + String errorText = ""; + int currentLine = + 1; // Skip first because we know that the first line is ---BEGIN ERROR--- + while (!_errorEndPattern.hasMatch(log[currentLine])) { + errorText += log[currentLine] + _newLine; + currentLine++; + } + currentLine++; + final hasStackTrace = _stackTraceBeginPattern.hasMatch(log[currentLine]); + String? stackTrace; + if (hasStackTrace) { + currentLine++; + String stackTraceText = ''; + + while (!_stackTraceEndPattern.hasMatch(log[currentLine])) { + stackTraceText += log[currentLine] + _newLine; + currentLine++; + } + stackTrace = stackTraceText; + } + return ( + currentLine + 1, + ParsedErrorLogMessage(error: errorText, stackTrace: stackTrace) + ); + } +} + +class UnformattedLogMessage extends ParsedLogMessage { + final String message; + + UnformattedLogMessage(this.message); +} + +class ParsedFormattedLogMessage extends ParsedLogMessage { + static final RegExp pattern = RegExp( + r'(?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s*(?[A-Z]*)' + r'\s*---\s*(?:\[\s*(?.*)\]\s*-\s*(?.*)\s*)?:\s*(?.+)', + ); + + final Level level; + final String message; + final String? className; + final String? methodName; + final DateTime timestamp; + + final ParsedErrorLogMessage? error; + + ParsedFormattedLogMessage({ + required this.level, + required this.message, + this.className, + this.methodName, + required this.timestamp, + this.error, + }); + + static bool canConsumeFirstLine(String line) => pattern.hasMatch(line); + + static (int consumedLines, ParsedFormattedLogMessage result) consume( + List log) { + assert(log.isNotEmpty && canConsumeFirstLine(log.first)); + + final match = pattern.firstMatch(log.first)!; + final result = ParsedFormattedLogMessage( + level: Level.values.byName(match.namedGroup('level')!.toLowerCase()), + message: match.namedGroup('message')!, + className: match.namedGroup('className'), + methodName: match.namedGroup('methodName'), + timestamp: DateTime.parse(match.namedGroup('timestamp')!), + ); + final updatedLog = log.sublist(1); + if (updatedLog.isEmpty) { + return (1, result); + } + if (ParsedErrorLogMessage.canConsumeFirstLine(updatedLog.first)) { + final (consumedLines, parsedError) = + ParsedErrorLogMessage.consume(updatedLog); + return ( + consumedLines + 1, + result.copyWith(error: parsedError), + ); + } + return (1, result); + } + + ParsedFormattedLogMessage copyWith({ + Level? level, + String? message, + String? className, + String? methodName, + DateTime? timestamp, + ParsedErrorLogMessage? error, + }) { + return ParsedFormattedLogMessage( + level: level ?? this.level, + message: message ?? this.message, + className: className ?? this.className, + methodName: methodName ?? this.methodName, + timestamp: timestamp ?? this.timestamp, + error: error ?? this.error, + ); + } +} diff --git a/lib/core/logging/utils/redaction_utils.dart b/lib/core/logging/utils/redaction_utils.dart new file mode 100644 index 0000000..724e6fa --- /dev/null +++ b/lib/core/logging/utils/redaction_utils.dart @@ -0,0 +1,22 @@ +(String username, String obscuredUrl) splitRedactUserId(String userId) { + final parts = userId.split('@'); + if (parts.length != 2) { + return ('unknown', 'unknown'); + } + + final username = parts.first; + final serverUrl = parts.last; + final uri = Uri.parse(serverUrl); + final hostLen = uri.host.length; + final obscuredUrl = uri.scheme + + "://" + + uri.host.substring(0, 2) + + List.filled(hostLen - 4, '*').join() + + uri.host.substring(uri.host.length - 2, uri.host.length); + return (username, obscuredUrl); +} + +String redactUserId(String userId) { + final (username, obscuredUrl) = splitRedactUserId(userId); + return '$username@$obscuredUrl'; +} diff --git a/lib/core/logging/view/app_logs_page.dart b/lib/core/logging/view/app_logs_page.dart index 3f127b9..0ba82ce 100644 --- a/lib/core/logging/view/app_logs_page.dart +++ b/lib/core/logging/view/app_logs_page.dart @@ -1,19 +1,13 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:logger/logger.dart'; -import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:paperless_mobile/core/logging/cubit/app_logs_cubit.dart'; +import 'package:paperless_mobile/core/logging/models/parsed_log_message.dart'; +import 'package:paperless_mobile/extensions/dart_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:path/path.dart' as p; -import 'package:rxdart/subjects.dart'; - -final _fileNameFormat = DateFormat("yyyy-MM-dd"); +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class AppLogsPage extends StatefulWidget { const AppLogsPage({super.key}); @@ -23,304 +17,181 @@ class AppLogsPage extends StatefulWidget { } class _AppLogsPageState extends State { - final _fileContentStream = BehaviorSubject(); final ScrollController _scrollController = ScrollController(); - StreamSubscription? _fileChangesSubscription; - - late DateTime _date; - File? file; bool autoScroll = true; - List? _availableLogs; - - Future _initFile() async { - final logDir = await FileService.logDirectory; - // logDir.listSync().whereType().forEach((element) { - // element.deleteSync(); - // }); - if (logDir.listSync().isEmpty) { - return; - } - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - final filename = _fileNameFormat.format(_date); - setState(() { - file = File(p.join(logDir.path, '$filename.log')); - }); - _scrollController.addListener(_initialScrollListener); - _updateFileContent(); - _fileChangesSubscription?.cancel(); - _fileChangesSubscription = file!.watch().listen((event) async { - await _updateFileContent(); - }); - }); - } - - void _initialScrollListener() { - if (_scrollController.positions.isNotEmpty) { - _scrollController.animateTo( - 0, - duration: 500.milliseconds, - curve: Curves.easeIn, - ); - _scrollController.removeListener(_initialScrollListener); - } - } - - @override - void initState() { - super.initState(); - _date = DateTime.now().copyWith( - minute: 0, - hour: 0, - second: 0, - millisecond: 0, - microsecond: 0, - ); - _initFile(); - () async { - final logDir = await FileService.logDirectory; - final files = logDir.listSync(followLinks: false).whereType(); - final fileNames = files.map((e) => p.basenameWithoutExtension(e.path)); - final dates = - fileNames.map((filename) => _fileNameFormat.parseStrict(filename)); - _availableLogs = dates.toList(); - }(); - } - - @override - void dispose() { - _fileChangesSubscription?.cancel(); - super.dispose(); - } @override Widget build(BuildContext context) { final locale = Localizations.localeOf(context).toString(); final theme = Theme.of(context); - return Scaffold( - appBar: AppBar( - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text("Logs"), - SizedBox(width: 16), - DropdownButton( - - value: _date, - items: [ - for (var date in _availableLogs ?? []) - DropdownMenuItem( - child: Text(DateFormat.yMMMd(locale).format(date)), - value: date, - ), - ], - onChanged: (value) { - if (value != null) { - setState(() { - _date = value; - }); - _initFile(); - } + return BlocBuilder( + builder: (context, state) { + final formattedDate = DateFormat.yMMMd(locale).format(state.date); + return Scaffold( + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: switch (state) { + AppLogsStateInitial() => [], + AppLogsStateLoading() => [], + AppLogsStateLoaded() => [ + IconButton( + tooltip: S.of(context)!.copyToClipboard, + onPressed: () { + context + .read() + .copyToClipboard(state.date); + }, + icon: const Icon(Icons.copy), + ).padded(), + IconButton( + tooltip: S.of(context)!.saveLogsToFile, + onPressed: () { + context + .read() + .saveLogs(state.date, locale); + }, + icon: const Icon(Icons.download), + ).padded(), + IconButton( + tooltip: S.of(context)!.clearLogs(formattedDate), + onPressed: () { + context.read().clearLogs(state.date); + }, + icon: Icon( + Icons.delete_sweep, + color: Theme.of(context).colorScheme.error, + ), + ).padded(), + ], + _ => [], }, ), - ], - ), - actions: file != null - ? [ + ), + appBar: AppBar( + title: Text(S.of(context)!.appLogs(formattedDate)), + actions: [ + if (state is AppLogsStateLoaded) IconButton( - tooltip: "Save log file to selected directory", - onPressed: () => _saveFile(locale), - icon: const Icon(Icons.download), - ), - IconButton( - tooltip: "Copy logs to clipboard", - onPressed: _copyToClipboard, - icon: const Icon(Icons.copy), + tooltip: MaterialLocalizations.of(context).datePickerHelpText, + onPressed: () async { + final selectedDate = await showDatePicker( + context: context, + initialDate: state.date, + firstDate: state.availableLogs.first, + lastDate: state.availableLogs.last, + selectableDayPredicate: (day) => state.availableLogs + .any((date) => day.isOnSameDayAs(date)), + initialEntryMode: DatePickerEntryMode.calendarOnly, + ); + if (selectedDate != null) { + context.read().loadLogs(selectedDate); + } + }, + icon: const Icon(Icons.calendar_today), ).padded(), - ] - : null, - ), - body: Builder( - builder: (context) { - if (_availableLogs == null) { - return Center( - child: Text("No logs available."), - ); - } - return StreamBuilder( - stream: _fileContentStream, - builder: (context, snapshot) { - if (!snapshot.hasData || file == null) { - return const Center( - child: Text( - "Initializing logs...", - ), - ); - } - final messages = _transformLog(snapshot.data!).reversed.toList(); - return ColoredBox( - color: theme.colorScheme.background, - child: Column( - children: [ - Expanded( - child: ListView.builder( - reverse: true, - controller: _scrollController, - itemBuilder: (context, index) { - if (index == 0) { - return Center( - child: Text( - "End of logs.", - style: theme.textTheme.labelLarge?.copyWith( - fontStyle: FontStyle.italic, - ), - ), - ).padded(24); - } - final logMessage = messages[index - 1]; - final altColor = CupertinoDynamicColor.withBrightness( - color: Colors.grey.shade200, - darkColor: Colors.grey.shade800, - ).resolveFrom(context); - return _LogMessageWidget( - message: logMessage, - backgroundColor: (index % 2 == 0) - ? theme.colorScheme.background - : altColor, - ); - }, - itemCount: messages.length + 1, - ), - ), - ], + ], + ), + body: switch (state) { + AppLogsStateLoaded( + logs: var logs, + ) => + Builder( + builder: (context) { + if (state.logs.isEmpty) { + return Center( + child: Text(S.of(context)!.noLogsFoundOn(formattedDate)), + ); + } + return ListView.builder( + reverse: true, + controller: _scrollController, + itemBuilder: (context, index) { + if (index == 0) { + return Center( + child: Text(S.of(context)!.logfileBottomReached, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.disabledColor, + )), + ).padded(24); + } + final messages = state.logs; + final logMessage = messages[index - 1]; + final altColor = CupertinoDynamicColor.withBrightness( + color: Colors.grey.shade200, + darkColor: Colors.grey.shade800, + ).resolveFrom(context); + return ParsedLogMessageTile( + message: logMessage, + backgroundColor: (index % 2 == 0) + ? theme.colorScheme.background + : altColor, + ); + }, + itemCount: logs.length + 1, + ); + }, + ), + AppLogsStateError() => Center( + child: Text( + S.of(context)!.couldNotLoadLogfileFrom(formattedDate), ), - ); - }, - ); - }, + ), + _ => _buildLoadingLogs(state.date) + }, + ); + }, + ); + } + + Widget _buildLoadingLogs(DateTime date) { + final formattedDate = + DateFormat.yMd(Localizations.localeOf(context).toString()).format(date); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + Text(S.of(context)!.loadingLogsFrom(formattedDate)), + ], ), ); } - - Future _saveFile(String locale) async { - assert(file != null); - var formattedDate = _fileNameFormat.format(_date); - final filename = 'paperless_mobile_logs_$formattedDate.log'; - final parentDir = await FilePicker.platform.getDirectoryPath( - dialogTitle: "Save log from ${DateFormat.yMd(locale).format(_date)}", - initialDirectory: - Platform.isAndroid ? "/storage/emulated/0/Download/" : null, - ); - if (parentDir != null) { - await file!.copy(p.join(parentDir, filename)); - } - } - - Future _copyToClipboard() async { - assert(file != null); - final content = await file!.readAsString(); - await Clipboard.setData(ClipboardData(text: content)); - } - - List<_LogMessage> _transformLog(String log) { - List<_LogMessage> messages = []; - List currentCoherentLines = []; - final lines = log.split("\n"); - for (var line in lines) { - final isMatch = _LogMessage.hasMatch(line); - if (currentCoherentLines.isNotEmpty && isMatch) { - messages.add(_LogMessage(message: currentCoherentLines.join("\n"))); - currentCoherentLines.clear(); - messages.add(_LogMessage.fromMessage(line)); - } - if (_LogMessage.hasMatch(line)) { - messages.add(_LogMessage.fromMessage(line)); - } else { - currentCoherentLines.add(line); - } - } - - return messages; - } - - Future _updateFileContent() async { - final content = await file!.readAsString(); - _fileContentStream.add(content); - Future.delayed(400.milliseconds, () { - _scrollController.animateTo( - 0, - duration: 500.milliseconds, - curve: Curves.easeIn, - ); - }); - } } -class _LogMessage { - static final RegExp pattern = RegExp( - r'(?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s*(?[A-Z]*)' - r'\s+---\s*(?:\[\s*(?.*)\]\s*-\s*(?.*)\s*)?:\s*(?.+)', - ); - final Level? level; - final String message; - final String? className; - final String? methodName; - final DateTime? timestamp; - - bool get isFormatted => level != null; - const _LogMessage({ - this.level, - required this.message, - this.className, - this.methodName, - this.timestamp, - }); - - static bool hasMatch(String message) => pattern.hasMatch(message); - - factory _LogMessage.fromMessage(String message) { - final match = pattern.firstMatch(message); - if (match == null) { - return _LogMessage(message: message); - } - return _LogMessage( - level: Level.values.byName(match.namedGroup('level')!.toLowerCase()), - message: match.namedGroup('message')!, - className: match.namedGroup('className'), - methodName: match.namedGroup('methodName'), - timestamp: DateTime.tryParse(match.namedGroup('timestamp') ?? ''), - ); - } -} - -class _LogMessageWidget extends StatelessWidget { - final _LogMessage message; +class ParsedLogMessageTile extends StatelessWidget { + final ParsedLogMessage message; final Color backgroundColor; - const _LogMessageWidget({ + + const ParsedLogMessageTile({ + super.key, required this.message, required this.backgroundColor, }); @override Widget build(BuildContext context) { - final c = Theme.of(context).colorScheme; - if (!message.isFormatted) { - return Text( - message.message, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 5, - color: c.onBackground.withOpacity(0.7), - ), - ); - } - final color = switch (message.level) { - Level.trace => c.onBackground.withOpacity(0.75), - Level.warning => Colors.yellow.shade600, - Level.error => Colors.red, - Level.fatal => Colors.red.shade900, - _ => c.onBackground, + return switch (message) { + ParsedFormattedLogMessage m => FormattedLogMessageWidget( + message: m, + backgroundColor: backgroundColor, + ), + UnformattedLogMessage(message: var m) => Text(m), }; + } +} + +class FormattedLogMessageWidget extends StatelessWidget { + final ParsedFormattedLogMessage message; + final Color backgroundColor; + const FormattedLogMessageWidget( + {super.key, required this.message, required this.backgroundColor}); + static final _timeFormat = DateFormat("HH:mm:ss.SSS"); + @override + Widget build(BuildContext context) { + final c = Theme.of(context).colorScheme; + final icon = switch (message.level) { Level.trace => Icons.troubleshoot, Level.debug => Icons.bug_report, @@ -330,31 +201,83 @@ class _LogMessageWidget extends StatelessWidget { Level.fatal => Icons.error_outline, _ => null, }; + final color = switch (message.level) { + Level.trace => c.onBackground.withOpacity(0.75), + Level.warning => Colors.yellow.shade600, + Level.error => Colors.red, + Level.fatal => Colors.red.shade900, + Level.info => Colors.blue, + _ => c.onBackground, + }; + + final logStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + fontSize: 12, + ); + final formattedMethodName = + message.methodName != null ? '${message.methodName!.trim()}()' : ''; + final source = switch (message.className) { + '' || null => formattedMethodName, + String className => '$className.$formattedMethodName', + }; return Material( - child: ListTile( + color: backgroundColor, + child: ExpansionTile( + leading: Text( + _timeFormat.format(message.timestamp), + style: logStyle?.copyWith(color: color), + ), + title: Text( + message.message, + style: logStyle?.copyWith(color: color), + ), trailing: Icon( icon, color: color, ), - tileColor: backgroundColor, - title: Text( - message.message, - style: TextStyle(color: color), - ), - subtitle: message.className != null - ? Text( - "${message.className ?? ''} ${message.methodName ?? ''}", - style: TextStyle( - color: color.withOpacity(0.75), - fontSize: 10, - fontFamily: "monospace", + expandedCrossAxisAlignment: CrossAxisAlignment.start, + childrenPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + expandedAlignment: Alignment.topLeft, + children: source.isNotEmpty + ? [ + Row( + children: [ + const Icon(Icons.arrow_right), + Flexible( + child: Text( + 'In $source', + style: logStyle?.copyWith(fontSize: 14), + ), + ), + ], ), - ) - : null, - leading: message.timestamp != null - ? Text(DateFormat("HH:mm:ss.SSS").format(message.timestamp!)) - : null, + ..._buildErrorWidgets(context), + ] + : _buildErrorWidgets(context), ), ); } + + List _buildErrorWidgets(BuildContext context) { + if (message.error != null) { + return [ + Divider(), + Text( + message.error!.error, + style: TextStyle(color: Colors.red), + ).padded(), + if (message.error?.stackTrace != null) ...[ + Text( + message.error!.stackTrace!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 10, + ), + ).paddedOnly(left: 8), + ], + ]; + } else { + return []; + } + } } diff --git a/lib/core/service/file_service.dart b/lib/core/service/file_service.dart index 81c53b7..83d0ebe 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -1,166 +1,260 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; +import 'package:paperless_mobile/core/logging/utils/redaction_utils.dart'; import 'package:paperless_mobile/helpers/format_helpers.dart'; +import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; class FileService { - const FileService._(); + FileService._(); - static Future saveToFile( + static FileService? _singleton; + + late final Directory _logDirectory; + late final Directory _temporaryDirectory; + late final Directory _documentsDirectory; + late final Directory _downloadsDirectory; + late final Directory _uploadDirectory; + late final Directory _temporaryScansDirectory; + + Directory get logDirectory => _logDirectory; + Directory get temporaryDirectory => _temporaryDirectory; + Directory get documentsDirectory => _documentsDirectory; + Directory get downloadsDirectory => _downloadsDirectory; + Directory get uploadDirectory => _uploadDirectory; + Directory get temporaryScansDirectory => _temporaryScansDirectory; + + Future initialize() async { + try { + await _initTemporaryDirectory(); + await _initTemporaryScansDirectory(); + await _initUploadDirectory(); + await _initLogDirectory(); + await _initDownloadsDirectory(); + await _initializeDocumentsDirectory(); + } catch (error, stackTrace) { + debugPrint("Could not initialize directories."); + debugPrint(error.toString()); + debugPrintStack(stackTrace: stackTrace); + } + } + + /// Make sure to call and await initialize before accessing any of the instance members. + static FileService get instance { + _singleton ??= FileService._(); + return _singleton!; + } + + Future saveToFile( Uint8List bytes, String filename, ) async { - final dir = await documentsDirectory; - File file = File("${dir.path}/$filename"); + File file = File(p.join(_logDirectory.path, filename)); + logger.fd( + "Writing bytes to file $filename", + methodName: 'saveToFile', + className: runtimeType.toString(), + ); return file..writeAsBytes(bytes); } - static Future getDirectory(PaperlessDirectoryType type) { + Directory getDirectory(PaperlessDirectoryType type) { return switch (type) { - PaperlessDirectoryType.documents => documentsDirectory, - PaperlessDirectoryType.temporary => temporaryDirectory, - PaperlessDirectoryType.scans => temporaryScansDirectory, - PaperlessDirectoryType.download => downloadsDirectory, - PaperlessDirectoryType.upload => uploadDirectory, + PaperlessDirectoryType.documents => _documentsDirectory, + PaperlessDirectoryType.temporary => _temporaryDirectory, + PaperlessDirectoryType.scans => _temporaryScansDirectory, + PaperlessDirectoryType.download => _downloadsDirectory, + PaperlessDirectoryType.upload => _uploadDirectory, + PaperlessDirectoryType.logs => _logDirectory, }; } - static Future allocateTemporaryFile( + /// + /// Returns a [File] pointing to a temporary file in the directory specified by [type]. + /// If [create] is true, the file will be created. + /// If [fileName] is left blank, a random UUID will be generated. + /// + Future allocateTemporaryFile( PaperlessDirectoryType type, { required String extension, String? fileName, + bool create = false, }) async { - final dir = await getDirectory(type); - final _fileName = (fileName ?? const Uuid().v1()) + '.$extension'; - return File('${dir?.path}/$_fileName'); - } - - static Future get temporaryDirectory => getTemporaryDirectory(); - - static Future get documentsDirectory async { - if (Platform.isAndroid) { - return (await getExternalStorageDirectories( - type: StorageDirectory.documents, - ))! - .first; - } else if (Platform.isIOS) { - final dir = await getApplicationDocumentsDirectory() - .then((dir) => Directory('${dir.path}/documents')); - return dir.create(recursive: true); - } else { - throw UnsupportedError("Platform not supported."); + final dir = getDirectory(type); + final filename = (fileName ?? const Uuid().v1()) + '.$extension'; + final file = File(p.join(dir.path, filename)); + if (create) { + await file.create(recursive: true); } + return file; } - static Future get logDirectory async { - if (Platform.isAndroid) { - return getExternalStorageDirectories(type: StorageDirectory.documents) - .then((directory) async => - directory?.firstOrNull ?? - await getApplicationDocumentsDirectory()) - .then((directory) => - Directory('${directory.path}/logs').create(recursive: true)); - } else if (Platform.isIOS) { - return getApplicationDocumentsDirectory().then( - (value) => Directory('${value.path}/logs').create(recursive: true)); - } - throw UnsupportedError("Platform not supported."); + Future getConsumptionDirectory({required String userId}) async { + return Directory(p.join(_uploadDirectory.path, userId)) + .create(recursive: true); } - static Future get downloadsDirectory async { - if (Platform.isAndroid) { - var directory = Directory('/storage/emulated/0/Download'); - if (!directory.existsSync()) { - final downloadsDir = await getExternalStorageDirectories( - type: StorageDirectory.downloads, - ); - directory = downloadsDir!.first; - } - return directory; - } else if (Platform.isIOS) { - final appDir = await getApplicationDocumentsDirectory(); - final dir = Directory('${appDir.path}/downloads'); - return dir.create(recursive: true); - } else { - throw UnsupportedError("Platform not supported."); - } - } + Future clearUserData({required String userId}) async { + final redactedId = redactUserId(userId); + logger.fd( + "Clearing data for user $redactedId...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); - static Future get uploadDirectory async { - final dir = await getApplicationDocumentsDirectory() - .then((dir) => Directory('${dir.path}/upload')); - return dir.create(recursive: true); - } - - static Future getConsumptionDirectory( - {required String userId}) async { - final uploadDir = - await uploadDirectory.then((dir) => Directory('${dir.path}/$userId')); - return uploadDir.create(recursive: true); - } - - static Future get temporaryScansDirectory async { - final tempDir = await temporaryDirectory; - final scansDir = Directory('${tempDir.path}/scans'); - return scansDir.create(recursive: true); - } - - static Future clearUserData({required String userId}) async { - logger.t("FileService#clearUserData(): Clearing data for user $userId..."); - - final scanDir = await temporaryScansDirectory; - final scanDirSize = formatBytes(await getDirSizeInBytes(scanDir)); - final tempDir = await temporaryDirectory; - final tempDirSize = formatBytes(await getDirSizeInBytes(tempDir)); + final scanDirSize = + formatBytes(await getDirSizeInBytes(_temporaryScansDirectory)); + final tempDirSize = + formatBytes(await getDirSizeInBytes(_temporaryDirectory)); final consumptionDir = await getConsumptionDirectory(userId: userId); final consumptionDirSize = formatBytes(await getDirSizeInBytes(consumptionDir)); - logger.t("FileService#clearUserData(): Removing scans..."); - await scanDir.delete(recursive: true); - logger.t("FileService#clearUserData(): Removed $scanDirSize..."); + logger.ft( + "Removing scans...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); + await _temporaryScansDirectory.delete(recursive: true); + logger.ft( + "Removed $scanDirSize...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); + logger.ft( + "Removing temporary files and cache content...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); - logger.t( - "FileService#clearUserData(): Removing temporary files and cache content..."); + await _temporaryDirectory.delete(recursive: true); + logger.ft( + "Removed $tempDirSize...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); - await tempDir.delete(recursive: true); - logger.t("FileService#clearUserData(): Removed $tempDirSize..."); - - logger.t( - "FileService#clearUserData(): Removing files waiting for consumption..."); + logger.ft( + "Removing files waiting for consumption...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); await consumptionDir.delete(recursive: true); - logger.t("FileService#clearUserData(): Removed $consumptionDirSize..."); - } - - static Future clearDirectoryContent(PaperlessDirectoryType type) async { - final dir = await getDirectory(type); - - if (dir == null || !(await dir.exists())) { - return; - } - - await Future.wait( - dir.listSync().map((item) => item.delete(recursive: true)), + logger.ft( + "Removed $consumptionDirSize...", + className: runtimeType.toString(), + methodName: "clearUserData", ); } - static Future> getAllFiles(Directory directory) { + Future clearDirectoryContent( + PaperlessDirectoryType type, { + bool filesOnly = false, + }) async { + final dir = getDirectory(type); + final dirSize = await getDirSizeInBytes(dir); + if (!await dir.exists()) { + return 0; + } + final streamedEntities = filesOnly + ? dir.list().whereType().cast() + : dir.list(); + + final entities = await streamedEntities.toList(); + await Future.wait([ + for (var entity in entities) entity.delete(recursive: !filesOnly), + ]); + return dirSize; + } + + Future> getAllFiles(Directory directory) { return directory.list().whereType().toList(); } - static Future> getAllSubdirectories(Directory directory) { + Future> getAllSubdirectories(Directory directory) { return directory.list().whereType().toList(); } - static Future getDirSizeInBytes(Directory dir) async { + Future getDirSizeInBytes(Directory dir) async { return dir .list(recursive: true) .fold(0, (previous, element) => previous + element.statSync().size); } + + Future _initTemporaryDirectory() async { + _temporaryDirectory = await getTemporaryDirectory(); + } + + Future _initializeDocumentsDirectory() async { + if (Platform.isAndroid) { + final dirs = + await getExternalStorageDirectories(type: StorageDirectory.documents); + _documentsDirectory = dirs!.first; + return; + } else if (Platform.isIOS) { + final dir = await getApplicationDocumentsDirectory(); + _documentsDirectory = await Directory(p.join(dir.path, 'documents')) + .create(recursive: true); + return; + } else { + throw UnsupportedError("Platform not supported."); + } + } + + Future _initLogDirectory() async { + if (Platform.isAndroid) { + _logDirectory = + await getExternalStorageDirectories(type: StorageDirectory.documents) + .then((directory) async => + directory?.firstOrNull ?? + await getApplicationDocumentsDirectory()) + .then((directory) => + Directory('${directory.path}/logs').create(recursive: true)); + return; + } else if (Platform.isIOS) { + _logDirectory = await getApplicationDocumentsDirectory().then( + (value) => Directory('${value.path}/logs').create(recursive: true)); + return; + } + throw UnsupportedError("Platform not supported."); + } + + Future _initDownloadsDirectory() async { + if (Platform.isAndroid) { + var directory = Directory('/storage/emulated/0/Download'); + if (!await directory.exists()) { + final downloadsDir = await getExternalStorageDirectories( + type: StorageDirectory.downloads, + ); + directory = await downloadsDir!.first.create(recursive: true); + return; + } + _downloadsDirectory = directory; + } else if (Platform.isIOS) { + final appDir = await getApplicationDocumentsDirectory(); + final dir = Directory('${appDir.path}/downloads'); + _downloadsDirectory = await dir.create(recursive: true); + return; + } else { + throw UnsupportedError("Platform not supported."); + } + } + + Future _initUploadDirectory() async { + final dir = await getApplicationDocumentsDirectory() + .then((dir) => Directory('${dir.path}/upload')); + _uploadDirectory = await dir.create(recursive: true); + } + + Future _initTemporaryScansDirectory() async { + _temporaryScansDirectory = + await Directory(p.join(_temporaryDirectory.path, 'scans')) + .create(recursive: true); + } } enum PaperlessDirectoryType { @@ -168,5 +262,6 @@ enum PaperlessDirectoryType { temporary, scans, download, - upload; + upload, + logs; } diff --git a/lib/extensions/dart_extensions.dart b/lib/extensions/dart_extensions.dart index 9b23841..9bca5ad 100644 --- a/lib/extensions/dart_extensions.dart +++ b/lib/extensions/dart_extensions.dart @@ -35,6 +35,10 @@ extension DateHelpers on DateTime { yesterday.month == month && yesterday.year == year; } + + bool isOnSameDayAs(DateTime other) { + return other.day == day && other.month == month && other.year == year; + } } extension StringNormalizer on String { diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index 9144dec..f569109 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -2,12 +2,9 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/global/asset_images.dart'; import 'package:paperless_mobile/core/logging/view/app_logs_page.dart'; -import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/core/widgets/paperless_logo.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; @@ -19,6 +16,7 @@ import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart'; import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/app_logs_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/changelog_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart'; import 'package:provider/provider.dart'; @@ -185,12 +183,9 @@ class AppDrawer extends StatelessWidget { ListTile( dense: true, leading: const Icon(Icons.subject), - title: const Text('Logs'), //TODO: INTL + title: Text(S.of(context)!.appLogs('')), onTap: () { - Navigator.of(context) - .push(MaterialPageRoute(builder: (context) { - return const AppLogsPage(); - })); + AppLogsRoute().push(context); }, ), ListTile( diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index e5c10cb..f28ff2b 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; @@ -85,7 +85,7 @@ class DocumentDetailsCubit extends Cubit { } Future openDocumentInSystemViewer() async { - final cacheDir = await FileService.temporaryDirectory; + final cacheDir = FileService.instance.temporaryDirectory; if (state.metaData == null) { await loadMetaData(); } @@ -121,7 +121,7 @@ class DocumentDetailsCubit extends Cubit { } String targetPath = _buildDownloadFilePath( downloadOriginal, - await FileService.downloadsDirectory, + FileService.instance.downloadsDirectory, ); if (!await File(targetPath).exists()) { @@ -170,7 +170,7 @@ class DocumentDetailsCubit extends Cubit { locale: locale, userId: userId, ); - logger.i("Document '${state.document.title}' saved to $targetPath."); + logger.fi("Document '${state.document.title}' saved to $targetPath."); } Future shareDocument({bool shareOriginal = false}) async { @@ -179,7 +179,7 @@ class DocumentDetailsCubit extends Cubit { } String filePath = _buildDownloadFilePath( shareOriginal, - await FileService.temporaryDirectory, + FileService.instance.temporaryDirectory, ); await _api.downloadToFile( state.document, @@ -204,7 +204,7 @@ class DocumentDetailsCubit extends Cubit { await loadMetaData(); } final filePath = - _buildDownloadFilePath(false, await FileService.temporaryDirectory); + _buildDownloadFilePath(false, FileService.instance.temporaryDirectory); await _api.downloadToFile( state.document, filePath, diff --git a/lib/features/document_scan/cubit/document_scanner_cubit.dart b/lib/features/document_scan/cubit/document_scanner_cubit.dart index 9471dc0..364ce7f 100644 --- a/lib/features/document_scan/cubit/document_scanner_cubit.dart +++ b/lib/features/document_scan/cubit/document_scanner_cubit.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; @@ -19,13 +19,21 @@ class DocumentScannerCubit extends Cubit { : super(const InitialDocumentScannerState()); Future initialize() async { - logger.t("Restoring scans..."); + logger.fd( + "Restoring scans...", + className: runtimeType.toString(), + methodName: "initialize", + ); emit(const RestoringDocumentScannerState()); - final tempDir = await FileService.temporaryScansDirectory; + final tempDir = FileService.instance.temporaryScansDirectory; final allFiles = tempDir.list().whereType(); final scans = await allFiles.where((event) => event.path.endsWith(".jpeg")).toList(); - logger.t("Restored ${scans.length} scans."); + logger.fd( + "Restored ${scans.length} scans.", + className: runtimeType.toString(), + methodName: "initialize", + ); emit( scans.isEmpty ? const InitialDocumentScannerState() @@ -75,7 +83,7 @@ class DocumentScannerCubit extends Cubit { String fileName, String locale, ) async { - var file = await FileService.saveToFile(bytes, fileName); + var file = await FileService.instance.saveToFile(bytes, fileName); _notificationService.notifyFileSaved( filename: fileName, filePath: file.path, diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index d44f623..316c3a2 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -227,9 +227,10 @@ class _ScannerPageState extends State if (!isGranted) { return; } - final file = await FileService.allocateTemporaryFile( + final file = await FileService.instance.allocateTemporaryFile( PaperlessDirectoryType.scans, extension: 'jpeg', + create: true, ); if (kDebugMode) { dev.log('[ScannerPage] Created temporary file: ${file.path}'); diff --git a/lib/features/document_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index b120fd9..bd0d8d5 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -12,7 +12,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/widgets/future_or_builder.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; @@ -378,8 +378,10 @@ class _DocumentUploadPreparationPageState } on PaperlessFormValidationException catch (exception) { setState(() => _errors = exception.validationMessages); } catch (error, stackTrace) { - logger.e( + logger.fe( "An unknown error occurred during document upload.", + className: runtimeType.toString(), + methodName: "_onSubmit", error: error, stackTrace: stackTrace, ); diff --git a/lib/features/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index c4c3430..b67f012 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -1,16 +1,15 @@ import 'dart:async'; -import 'package:flutter/widgets.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository_state.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; -import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; +import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; part 'inbox_cubit.g.dart'; part 'inbox_state.dart'; @@ -50,18 +49,12 @@ class InboxCubit extends HydratedCubit final wasInInboxBeforeUpdate = state.documents.map((e) => e.id).contains(document.id); if (!hasInboxTag && wasInInboxBeforeUpdate) { - print( - "INBOX: Removing document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); remove(document); emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1)); } else if (hasInboxTag) { if (wasInInboxBeforeUpdate) { - print( - "INBOX: Replacing document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); replace(document); } else { - print( - "INBOX: Adding document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); _addDocument(document); emit( state.copyWith(itemsInInboxCount: state.itemsInInboxCount + 1)); @@ -84,17 +77,26 @@ class InboxCubit extends HydratedCubit } Future refreshItemsInInboxCount([bool shouldLoadInbox = true]) async { - logger.t( - "InboxCubit#refreshItemsInInboxCount(): Checking for new documents in inbox..."); + logger.fi( + "Checking for new documents in inbox...", + className: runtimeType.toString(), + methodName: "refreshItemsInInboxCount", + ); final stats = await _statsApi.getServerStatistics(); if (stats.documentsInInbox != state.itemsInInboxCount && shouldLoadInbox) { - logger.t( - "InboxCubit#refreshItemsInInboxCount(): New documents found in inbox, reloading inbox."); + logger.fi( + "New documents found in inbox, reloading.", + className: runtimeType.toString(), + methodName: "refreshItemsInInboxCount", + ); await loadInbox(); } else { - logger.t( - "InboxCubit#refreshItemsInInboxCount(): No new documents found in inbox."); + logger.fi( + "No new documents found in inbox.", + className: runtimeType.toString(), + methodName: "refreshItemsInInboxCount", + ); } emit(state.copyWith(itemsInInboxCount: stats.documentsInInbox)); } diff --git a/lib/features/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart index 12368d2..7f0b71b 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -7,7 +7,7 @@ import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart'; -import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; import 'package:paperless_mobile/core/widgets/material/colored_tab_bar.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; @@ -213,16 +213,18 @@ class _LabelsPageState extends State ][_currentIndex] .call(); } catch (error, stackTrace) { - logger.e( - "An error ocurred while reloading " - "${[ - "correspondents", - "document types", - "tags", - "storage paths" - ][_currentIndex]}: ${error.toString()}", - stackTrace: stackTrace, - ); + logger.fe( + "An error ocurred while reloading " + "${[ + "correspondents", + "document types", + "tags", + "storage paths" + ][_currentIndex]}.", + error: error, + stackTrace: stackTrace, + className: runtimeType.toString(), + methodName: 'onRefresh'); } }, child: TabBarView( diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index 2631069..f59223c 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; @@ -13,7 +14,8 @@ import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; import 'package:paperless_mobile/core/database/tables/user_credentials.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; -import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; +import 'package:paperless_mobile/core/logging/utils/redaction_utils.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; @@ -56,7 +58,13 @@ class AuthenticationCubit extends Cubit { } emit(const AuthenticatingState(AuthenticatingStage.authenticating)); final localUserId = "${credentials.username}@$serverUrl"; - logger.t("AuthenticationCubit#login(): Trying to log in $localUserId..."); + final redactedId = redactUserId(localUserId); + + logger.fd( + "Trying to log in $redactedId...", + className: runtimeType.toString(), + methodName: 'login', + ); try { await _addUser( localUserId, @@ -95,15 +103,22 @@ class AuthenticationCubit extends Cubit { await globalSettings.save(); emit(AuthenticatedState(localUserId: localUserId)); - logger.t( - 'AuthenticationCubit#login(): User $localUserId successfully logged in.'); + logger.fd( + 'User $redactedId successfully logged in.', + className: runtimeType.toString(), + methodName: 'login', + ); } /// Switches to another account if it exists. Future switchAccount(String localUserId) async { emit(const SwitchingAccountsState()); - logger.t( - 'AuthenticationCubit#switchAccount(): Trying to switch to user $localUserId...'); + final redactedId = redactUserId(localUserId); + logger.fd( + 'Trying to switch to user $redactedId...', + className: runtimeType.toString(), + methodName: 'switchAccount', + ); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; @@ -111,9 +126,11 @@ class AuthenticationCubit extends Cubit { final userAccountBox = Hive.localUserAccountBox; if (!userAccountBox.containsKey(localUserId)) { - logger.w( - 'AuthenticationCubit#switchAccount(): User $localUserId not yet registered. ' + logger.fw( + 'User $redactedId not yet registered. ' 'This should never be the case!', + className: runtimeType.toString(), + methodName: 'switchAccount', ); return; } @@ -124,8 +141,11 @@ class AuthenticationCubit extends Cubit { final authenticated = await _localAuthService .authenticateLocalUser("Authenticate to switch your account."); if (!authenticated) { - logger.w( - "AuthenticationCubit#switchAccount(): User could not be authenticated."); + logger.fw( + "User could not be authenticated.", + className: runtimeType.toString(), + methodName: 'switchAccount', + ); emit(VerifyIdentityState(userId: localUserId)); return; } @@ -138,8 +158,11 @@ class AuthenticationCubit extends Cubit { HiveBoxes.localUserCredentials, (credentialsBox) async { if (!credentialsBox.containsKey(localUserId)) { await credentialsBox.close(); - logger.w( - "AuthenticationCubit#switchAccount(): Invalid authentication for $localUserId."); + logger.fw( + "Invalid authentication for $redactedId.", + className: runtimeType.toString(), + methodName: 'switchAccount', + ); return; } final credentials = credentialsBox.get(localUserId); @@ -176,8 +199,12 @@ class AuthenticationCubit extends Cubit { }) async { assert(credentials.password != null && credentials.username != null); final localUserId = "${credentials.username}@$serverUrl"; - logger - .d("AuthenticationCubit#addAccount(): Adding account $localUserId..."); + final redactedId = redactUserId(localUserId); + logger.fd( + "Adding account $redactedId...", + className: runtimeType.toString(), + methodName: 'switchAccount', + ); final sessionManager = SessionManager([ LanguageHeaderInterceptor(locale), @@ -194,12 +221,16 @@ class AuthenticationCubit extends Cubit { } Future removeAccount(String userId) async { - logger - .t("AuthenticationCubit#removeAccount(): Removing account $userId..."); + final redactedId = redactUserId(userId); + logger.fd( + "Trying to remove account $redactedId...", + className: runtimeType.toString(), + methodName: 'removeAccount', + ); final userAccountBox = Hive.localUserAccountBox; final userAppStateBox = Hive.localUserAppStateBox; - await FileService.clearUserData(userId: userId); + await FileService.instance.clearUserData(userId: userId); await userAccountBox.delete(userId); await userAppStateBox.delete(userId); await withEncryptedBox( @@ -213,15 +244,21 @@ class AuthenticationCubit extends Cubit { /// Future restoreSession([String? userId]) async { emit(const RestoringSessionState()); - logger.t( - "AuthenticationCubit#restoreSessionState(): Trying to restore previous session..."); + logger.fd( + "Trying to restore previous session...", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; final restoreSessionForUser = userId ?? globalSettings.loggedInUserId; // final localUserId = globalSettings.loggedInUserId; if (restoreSessionForUser == null) { - logger.t( - "AuthenticationCubit#restoreSessionState(): There is nothing to restore."); + logger.fd( + "There is nothing to restore.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); final otherAccountsExist = Hive.localUserAccountBox.isNotEmpty; // If there is nothing to restore, we can quit here. emit( @@ -233,24 +270,36 @@ class AuthenticationCubit extends Cubit { Hive.box(HiveBoxes.localUserAccount); final localUserAccount = localUserAccountBox.get(restoreSessionForUser)!; if (localUserAccount.settings.isBiometricAuthenticationEnabled) { - logger.t( - "AuthenticationCubit#restoreSessionState(): Verifying user identity..."); + logger.fd( + "Verifying user identity...", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); final authenticationMesage = (await S.delegate.load(Locale(globalSettings.preferredLocaleSubtag))) .verifyYourIdentity; final localAuthSuccess = await _localAuthService.authenticateLocalUser(authenticationMesage); if (!localAuthSuccess) { - logger.w( - "AuthenticationCubit#restoreSessionState(): Identity could not be verified."); + logger.fw( + "Identity could not be verified.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); emit(VerifyIdentityState(userId: restoreSessionForUser)); return; } - logger.t( - "AuthenticationCubit#restoreSessionState(): Identity successfully verified."); + logger.fd( + "Identity successfully verified.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); } - logger.t( - "AuthenticationCubit#restoreSessionState(): Reading encrypted credentials..."); + logger.fd( + "Reading encrypted credentials...", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); final authentication = await withEncryptedBox( HiveBoxes.localUserCredentials, (box) { @@ -258,33 +307,48 @@ class AuthenticationCubit extends Cubit { }); if (authentication == null) { - logger.e( - "AuthenticationCubit#restoreSessionState(): Credentials could not be read!"); + logger.fe( + "Credentials could not be read!", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); throw Exception( "User should be authenticated but no authentication information was found.", ); } - logger.t( - "AuthenticationCubit#restoreSessionState(): Credentials successfully retrieved."); + logger.fd( + "Credentials successfully retrieved.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); - logger.t( - "AuthenticationCubit#restoreSessionState(): Updating security context..."); + logger.fd( + "Updating security context...", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); _sessionManager.updateSettings( clientCertificate: authentication.clientCertificate, authToken: authentication.token, baseUrl: localUserAccount.serverUrl, ); - logger.t( - "AuthenticationCubit#restoreSessionState(): Security context successfully updated."); + logger.fd( + "Security context successfully updated.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); final isPaperlessServerReachable = await _connectivityService.isPaperlessServerReachable( localUserAccount.serverUrl, authentication.clientCertificate, ) == ReachabilityStatus.reachable; - logger.t( - "AuthenticationCubit#restoreSessionState(): Trying to update remote paperless user..."); + logger.fd( + "Trying to update remote paperless user...", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); if (isPaperlessServerReachable) { final apiVersion = await _getApiVersion(_sessionManager.client); await _updateRemoteUser( @@ -292,51 +356,83 @@ class AuthenticationCubit extends Cubit { localUserAccount, apiVersion, ); - logger.t( - "AuthenticationCubit#restoreSessionState(): Successfully updated remote paperless user."); + logger.fd( + "Successfully updated remote paperless user.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); } else { - logger.w( - "AuthenticationCubit#restoreSessionState(): Could not update remote paperless user. Server could not be reached. The app might behave unexpected!"); + logger.fw( + "Could not update remote paperless user - " + "Server could not be reached. The app might behave unexpected!", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); } globalSettings.loggedInUserId = restoreSessionForUser; await globalSettings.save(); emit(AuthenticatedState(localUserId: restoreSessionForUser)); - logger.t( - "AuthenticationCubit#restoreSessionState(): Previous session successfully restored."); + logger.fd( + "Previous session successfully restored.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); } - Future logout([bool removeAccount = false]) async { + Future logout([bool shouldRemoveAccount = false]) async { emit(const LoggingOutState()); final globalSettings = Hive.globalSettingsBox.getValue()!; final userId = globalSettings.loggedInUserId!; - logger.t( - "AuthenticationCubit#logout(): Logging out current user ($userId)..."); + final redactedId = redactUserId(userId); + + logger.fd( + "Logging out $redactedId...", + className: runtimeType.toString(), + methodName: 'logout', + ); await _resetExternalState(); await _notificationService.cancelUserNotifications(userId); final otherAccountsExist = Hive.localUserAccountBox.length > 1; emit(UnauthenticatedState(redirectToAccountSelection: otherAccountsExist)); - if (removeAccount) { - await this.removeAccount(userId); + if (shouldRemoveAccount) { + await removeAccount(userId); } globalSettings.loggedInUserId = null; await globalSettings.save(); - logger.t("AuthenticationCubit#logout(): User successfully logged out."); + logger.fd( + "User successfully logged out.", + className: runtimeType.toString(), + methodName: 'logout', + ); } Future _resetExternalState() async { - logger.t( - "AuthenticationCubit#_resetExternalState(): Resetting security context..."); + logger.fd( + "Resetting security context...", + className: runtimeType.toString(), + methodName: '_resetExternalState', + ); _sessionManager.resetSettings(); - logger.t( - "AuthenticationCubit#_resetExternalState(): Security context reset."); - logger.t( - "AuthenticationCubit#_resetExternalState(): Clearing local state..."); + logger.fd( + "Security context reset.", + className: runtimeType.toString(), + methodName: '_resetExternalState', + ); + logger.fd( + "Clearing local state...", + className: runtimeType.toString(), + methodName: '_resetExternalState', + ); await HydratedBloc.storage.clear(); - logger.t("AuthenticationCubit#_resetExternalState(): Local state cleard."); + logger.fd( + "Local state cleard.", + className: runtimeType.toString(), + methodName: '_resetExternalState', + ); } Future _addUser( @@ -350,8 +446,13 @@ class AuthenticationCubit extends Cubit { _FutureVoidCallback? onFetchUserInformation, }) async { assert(credentials.username != null && credentials.password != null); - logger - .t("AuthenticationCubit#_addUser(): Adding new user $localUserId...."); + final redactedId = redactUserId(localUserId); + + logger.fd( + "Adding new user $redactedId..", + className: runtimeType.toString(), + methodName: '_addUser', + ); sessionManager.updateSettings( baseUrl: serverUrl, @@ -360,8 +461,11 @@ class AuthenticationCubit extends Cubit { final authApi = _apiFactory.createAuthenticationApi(sessionManager.client); - logger.t( - "AuthenticationCubit#_addUser(): Fetching bearer token from the server..."); + logger.fd( + "Fetching bearer token from the server...", + className: runtimeType.toString(), + methodName: '_addUser', + ); await onPerformLogin?.call(); @@ -370,8 +474,11 @@ class AuthenticationCubit extends Cubit { password: credentials.password!, ); - logger.t( - "AuthenticationCubit#_addUser(): Bearer token successfully retrieved."); + logger.fd( + "Bearer token successfully retrieved.", + className: runtimeType.toString(), + methodName: '_addUser', + ); sessionManager.updateSettings( baseUrl: serverUrl, @@ -385,14 +492,20 @@ class AuthenticationCubit extends Cubit { Hive.box(HiveBoxes.localUserAppState); if (userAccountBox.containsKey(localUserId)) { - logger.w( - "AuthenticationCubit#_addUser(): The user $localUserId already exists."); + logger.fw( + "The user $redactedId already exists.", + className: runtimeType.toString(), + methodName: '_addUser', + ); throw InfoMessageException(code: ErrorCode.userAlreadyExists); } await onFetchUserInformation?.call(); final apiVersion = await _getApiVersion(sessionManager.client); - logger.t( - "AuthenticationCubit#_addUser(): Trying to fetch remote paperless user for $localUserId."); + logger.fd( + "Trying to fetch remote paperless user for $redactedId.", + className: runtimeType.toString(), + methodName: '_addUser', + ); late UserModel serverUser; try { @@ -403,19 +516,27 @@ class AuthenticationCubit extends Cubit { ) .findCurrentUser(); } on DioException catch (error, stackTrace) { - logger.e( - "AuthenticationCubit#_addUser(): An error occurred while fetching the remote paperless user.", + logger.fe( + "An error occurred while fetching the remote paperless user.", + className: runtimeType.toString(), + methodName: '_addUser', error: error, stackTrace: stackTrace, ); rethrow; } - logger.t( - "AuthenticationCubit#_addUser(): Remote paperless user successfully fetched."); + logger.fd( + "Remote paperless user successfully fetched.", + className: runtimeType.toString(), + methodName: '_addUser', + ); - logger.t( - "AuthenticationCubit#_addUser(): Persisting user account information..."); + logger.fd( + "Persisting user account information...", + className: runtimeType.toString(), + methodName: '_addUser', + ); await onPersistLocalUserData?.call(); // Create user account @@ -429,20 +550,33 @@ class AuthenticationCubit extends Cubit { apiVersion: apiVersion, ), ); - logger.t( - "AuthenticationCubit#_addUser(): User account information successfully persisted."); - logger.t("AuthenticationCubit#_addUser(): Persisting user app state..."); + logger.fd( + "User account information successfully persisted.", + className: runtimeType.toString(), + methodName: '_addUser', + ); + logger.fd( + "Persisting user app state...", + className: runtimeType.toString(), + methodName: '_addUser', + ); // Create user state await userStateBox.put( localUserId, LocalUserAppState(userId: localUserId), ); - logger.t( - "AuthenticationCubit#_addUser(): User state successfully persisted."); + logger.fd( + "User state successfully persisted.", + className: runtimeType.toString(), + methodName: '_addUser', + ); // Save credentials in encrypted box await withEncryptedBox(HiveBoxes.localUserCredentials, (box) async { - logger.t( - "AuthenticationCubit#_addUser(): Saving user credentials inside encrypted storage..."); + logger.fd( + "Saving user credentials inside encrypted storage...", + className: runtimeType.toString(), + methodName: '_addUser', + ); await box.put( localUserId, @@ -451,12 +585,20 @@ class AuthenticationCubit extends Cubit { clientCertificate: clientCert, ), ); - logger.t( - "AuthenticationCubit#_addUser(): User credentials successfully saved."); + logger.fd( + "User credentials successfully saved.", + className: runtimeType.toString(), + methodName: '_addUser', + ); }); final hostsBox = Hive.box(HiveBoxes.hosts); if (!hostsBox.values.contains(serverUrl)) { await hostsBox.add(serverUrl); + logger.fd( + "Added new url to list of hosts.", + className: runtimeType.toString(), + methodName: '_addUser', + ); } return serverUser.id; @@ -467,8 +609,11 @@ class AuthenticationCubit extends Cubit { Duration? timeout, int defaultValue = 2, }) async { - logger.t( - "AuthenticationCubit#_getApiVersion(): Trying to fetch API version..."); + logger.fd( + "Trying to fetch API version...", + className: runtimeType.toString(), + methodName: '_getApiVersion', + ); try { final response = await dio.get( "/api/", @@ -478,13 +623,19 @@ class AuthenticationCubit extends Cubit { ); final apiVersion = int.parse(response.headers.value('x-api-version') ?? "3"); - logger.t( - "AuthenticationCubit#_getApiVersion(): Successfully retrieved API version ($apiVersion)."); + logger.fd( + "Successfully retrieved API version ($apiVersion).", + className: runtimeType.toString(), + methodName: '_getApiVersion', + ); return apiVersion; } on DioException catch (_) { - logger.w( - "AuthenticationCubit#_getApiVersion(): Could not retrieve API version."); + logger.fw( + "Could not retrieve API version, using default ($defaultValue).", + className: runtimeType.toString(), + methodName: '_getApiVersion', + ); return defaultValue; } } @@ -495,18 +646,21 @@ class AuthenticationCubit extends Cubit { LocalUserAccount localUserAccount, int apiVersion, ) async { - logger.t( - "AuthenticationCubit#_updateRemoteUser(): Trying to update remote user object..."); + logger.fd( + "Trying to update remote user object...", + className: runtimeType.toString(), + methodName: '_updateRemoteUser', + ); final updatedPaperlessUser = await _apiFactory - .createUserApi( - sessionManager.client, - apiVersion: apiVersion, - ) + .createUserApi(sessionManager.client, apiVersion: apiVersion) .findCurrentUser(); localUserAccount.paperlessUser = updatedPaperlessUser; await localUserAccount.save(); - logger.t( - "AuthenticationCubit#_updateRemoteUser(): Successfully updated remote user object."); + logger.fd( + "Successfully updated remote user object.", + className: runtimeType.toString(), + methodName: '_updateRemoteUser', + ); } } diff --git a/lib/features/settings/view/widgets/clear_storage_settings.dart b/lib/features/settings/view/widgets/clear_storage_settings.dart index b5aa166..9d3df1c 100644 --- a/lib/features/settings/view/widgets/clear_storage_settings.dart +++ b/lib/features/settings/view/widgets/clear_storage_settings.dart @@ -18,43 +18,25 @@ class _ClearCacheSettingState extends State { Widget build(BuildContext context) { return ListTile( title: Text(S.of(context)!.clearCache), - subtitle: FutureBuilder( - future: FileService.temporaryDirectory.then(_dirSize), + subtitle: FutureBuilder( + future: FileService.instance + .getDirSizeInBytes(FileService.instance.temporaryDirectory), builder: (context, snapshot) { if (!snapshot.hasData) { return Text(S.of(context)!.calculatingDots); } - return Text(S.of(context)!.freeBytes(snapshot.data!)); + final dirSize = formatBytes(snapshot.data!); + return Text(S.of(context)!.freeBytes(dirSize)); }, ), onTap: () async { - final dir = await FileService.temporaryDirectory; - final deletedSize = await _dirSize(dir); - await dir.delete(recursive: true); + final freedBytes = await FileService.instance + .clearDirectoryContent(PaperlessDirectoryType.temporary); showSnackBar( context, - S.of(context)!.freedDiskSpace(deletedSize), + S.of(context)!.freedDiskSpace(formatBytes(freedBytes)), ); }, ); } } - -Future _dirSize(Directory dir) async { - int totalSize = 0; - try { - if (await dir.exists()) { - dir - .listSync(recursive: true, followLinks: false) - .forEach((FileSystemEntity entity) async { - if (entity is File) { - totalSize += (await entity.length()); - } - }); - } - } catch (error) { - debugPrint(error.toString()); - } - - return formatBytes(totalSize, 0); -} diff --git a/lib/features/sharing/cubit/receive_share_cubit.dart b/lib/features/sharing/cubit/receive_share_cubit.dart index facaada..7979dde 100644 --- a/lib/features/sharing/cubit/receive_share_cubit.dart +++ b/lib/features/sharing/cubit/receive_share_cubit.dart @@ -32,7 +32,7 @@ class ConsumptionChangeNotifier extends ChangeNotifier { return []; } final consumptionDirectory = - await FileService.getConsumptionDirectory(userId: userId); + await FileService.instance.getConsumptionDirectory(userId: userId); final List localFiles = []; for (final file in files) { if (!file.path.startsWith(consumptionDirectory.path)) { @@ -53,7 +53,7 @@ class ConsumptionChangeNotifier extends ChangeNotifier { required String userId, }) async { final consumptionDirectory = - await FileService.getConsumptionDirectory(userId: userId); + await FileService.instance.getConsumptionDirectory(userId: userId); if (file.path.startsWith(consumptionDirectory.path)) { await file.delete(); } @@ -70,8 +70,8 @@ class ConsumptionChangeNotifier extends ChangeNotifier { } Future> _getCurrentFiles(String userId) async { - final directory = await FileService.getConsumptionDirectory(userId: userId); - final files = await FileService.getAllFiles(directory); - return files; + final directory = + await FileService.instance.getConsumptionDirectory(userId: userId); + return await FileService.instance.getAllFiles(directory); } } diff --git a/lib/features/sharing/view/widgets/event_listener_shell.dart b/lib/features/sharing/view/widgets/event_listener_shell.dart index ff637e2..4dff64e 100644 --- a/lib/features/sharing/view/widgets/event_listener_shell.dart +++ b/lib/features/sharing/view/widgets/event_listener_shell.dart @@ -85,7 +85,6 @@ class _EventListenerShellState extends State if (!currentUser.paperlessUser.canViewInbox || _inboxTimer != null) { return; } - cubit.refreshItemsInInboxCount(false); _inboxTimer = Timer.periodic(30.seconds, (_) { cubit.refreshItemsInInboxCount(false); }); diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index 2a2c013..7428f7b 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -1001,5 +1001,13 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}" } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 3267615..e151c4d 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -1001,5 +1001,13 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}" } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 6438a81..d902965 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1001,5 +1001,13 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "Keine Logs am {date} gefunden.", + "logfileBottomReached": "Du hast das Ende dieser Logdatei erreicht.", + "appLogs": "App Logs {date}", + "saveLogsToFile": "Logs in Datei speichern", + "copyToClipboard": "In Zwischenablage kopieren", + "couldNotLoadLogfileFrom": "Logs vom {date} konnten nicht geladen werden.", + "loadingLogsFrom": "Lade Logs vom {date}...", + "clearLogs": "Logs vom {date} leeren" } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 37a00a8..2bfc980 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1001,5 +1001,13 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}" } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index daa10e5..61e2c55 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1001,5 +1001,13 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}" } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 8d81cb7..c605519 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1001,5 +1001,13 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}" } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 4a6fa14..b9589fa 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1001,5 +1001,13 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}" } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 42535d2..b6db6c0 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1001,5 +1001,13 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}" } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index a1c0e30..26935f8 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -1001,5 +1001,13 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 80c2948..16746cf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,10 +29,13 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; -import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/formatted_printer.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; +import 'package:paperless_mobile/core/logging/data/mirrored_file_output.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; @@ -42,6 +45,7 @@ import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:paperless_mobile/routes/typed/branches/landing_route.dart'; import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/add_account_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/app_logs_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/changelog_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/logging_out_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; @@ -85,7 +89,11 @@ Future performMigrations() async { final requiresMigrationForCurrentVersion = !performedMigrations.contains(currentVersion); if (requiresMigrationForCurrentVersion) { - logger.t("Applying migration scripts for version $currentVersion"); + logger.fd( + "Applying migration scripts for version $currentVersion", + className: "", + methodName: "performMigrations", + ); await migrationProcedure(); await sp.setStringList( 'performed_migrations', @@ -115,7 +123,15 @@ Future _initHive() async { void main() async { runZonedGuarded(() async { WidgetsFlutterBinding.ensureInitialized(); + await FileService.instance.initialize(); + + logger = l.Logger( + output: MirroredFileOutput(), + printer: FormattedPrinter(), + level: l.Level.trace, + ); Paint.enableDithering = true; + // if (kDebugMode) { // // URL: http://localhost:3131 // // Login: admin:test @@ -128,12 +144,6 @@ void main() async { // .start(); // } - logger = l.Logger( - output: MirroredFileOutput(), - printer: SpringBootLikePrinter(), - level: l.Level.trace, - ); - packageInfo = await PackageInfo.fromPlatform(); if (Platform.isAndroid) { @@ -168,7 +178,6 @@ void main() async { ); // Manages security context, required for self signed client certificates final sessionManager = SessionManager([ - languageHeaderInterceptor, PrettyDioLogger( compact: true, responseBody: false, @@ -178,6 +187,7 @@ void main() async { requestHeader: false, logPrint: (object) => logger.t, ), + languageHeaderInterceptor, ]); // Initialize Blocs/Cubits @@ -225,14 +235,19 @@ void main() async { ), ), ); - }, (error, stack) { + }, (error, stackTrace) { // Catches all unexpected/uncaught errors and prints them to the console. - String message = switch (error) { + final message = switch (error) { PaperlessApiException e => e.details ?? error.toString(), ServerMessageException e => e.message, - _ => error.toString() + _ => null }; - logger.e(message, stackTrace: stack); + logger.fe( + "An unexpected error occurred${message != null ? "- $message" : ""}", + error: message == null ? error : null, + methodName: "main", + stackTrace: stackTrace, + ); }); } @@ -270,7 +285,7 @@ class _GoRouterShellState extends State { final DisplayMode mostOptimalMode = sameResolution.isNotEmpty ? sameResolution.first : active; - logger.d('Setting refresh rate to ${mostOptimalMode.refreshRate}'); + logger.fi('Setting refresh rate to ${mostOptimalMode.refreshRate}'); await FlutterDisplayMode.setPreferredMode(mostOptimalMode); } @@ -336,6 +351,7 @@ class _GoRouterShellState extends State { $loggingOutRoute, $addAccountRoute, $changelogRoute, + $appLogsRoute, $authenticatedRoute, ], ), diff --git a/lib/routes/typed/top_level/app_logs_route.dart b/lib/routes/typed/top_level/app_logs_route.dart new file mode 100644 index 0000000..dd7c2ae --- /dev/null +++ b/lib/routes/typed/top_level/app_logs_route.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/core/logging/cubit/app_logs_cubit.dart'; +import 'package:paperless_mobile/core/logging/view/app_logs_page.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/theme.dart'; + +part 'app_logs_route.g.dart'; + +@TypedGoRoute(path: '/app-logs') +class AppLogsRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + @override + Widget build(BuildContext context, GoRouterState state) { + return AnnotatedRegion( + value: buildOverlayStyle(Theme.of(context)), + child: BlocProvider( + create: (context) => + AppLogsCubit(DateTime.now())..loadLogs(DateTime.now()), + child: AppLogsPage(key: state.pageKey), + ), + ); + } +} diff --git a/lib/routes/typed/top_level/changelog_route.dart b/lib/routes/typed/top_level/changelog_route.dart index a2acbba..ad56cf5 100644 --- a/lib/routes/typed/top_level/changelog_route.dart +++ b/lib/routes/typed/top_level/changelog_route.dart @@ -6,7 +6,7 @@ import 'package:paperless_mobile/routes/utils/dialog_page.dart'; part 'changelog_route.g.dart'; -@TypedGoRoute(path: '/changelogs)') +@TypedGoRoute(path: '/changelogs') class ChangelogRoute extends GoRouteData { static final $parentNavigatorKey = rootNavigatorKey; @override diff --git a/pubspec.lock b/pubspec.lock index a13759c..a4f0034 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1657,7 +1657,7 @@ packages: source: hosted version: "0.3.1" synchronized: - dependency: transitive + dependency: "direct main" description: name: synchronized sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" diff --git a/pubspec.yaml b/pubspec.yaml index 20a9a79..b8363f6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -99,6 +99,7 @@ dependencies: shared_preferences: ^2.2.1 flutter_markdown: ^0.6.18 logger: ^2.0.2+1 + synchronized: ^3.1.0 # camerawesome: ^2.0.0-dev.1 dependency_overrides: