mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-21 02:19:30 +00:00
feat: error reporting with logs (#45)
* feat: add ability to get logs file from ui * test: add unit test for log line parsing in logs_provider * refactor: update all logs to obfuscate sensitive information * feat: generate dynamic zip file name for logs export * feat: enhance logging in audiobook player and provider for better debugging * refactor: extract user display logic into UserBar widget for offline access of settings and logs * feat: add About section with app metadata and source code link in YouPage
This commit is contained in:
parent
7b0c2c4b88
commit
35a2d7cfce
44 changed files with 861 additions and 176 deletions
36
lib/features/logging/core/logger.dart
Normal file
36
lib/features/logging/core/logger.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:logging_appenders/logging_appenders.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:vaani/shared/extensions/duration_format.dart';
|
||||
|
||||
Future<String> getLoggingFilePath() async {
|
||||
final Directory directory = await getApplicationDocumentsDirectory();
|
||||
return '${directory.path}/vaani.log';
|
||||
}
|
||||
|
||||
Future<void> initLogging() async {
|
||||
final formatter = const DefaultLogRecordFormatter();
|
||||
if (kReleaseMode) {
|
||||
Logger.root.level = Level.INFO; // is also the default
|
||||
// Write to a file
|
||||
RotatingFileAppender(
|
||||
baseFilePath: await getLoggingFilePath(),
|
||||
formatter: formatter,
|
||||
).attachToLogger(Logger.root);
|
||||
} else {
|
||||
Logger.root.level = Level.FINE; // Capture all logs
|
||||
RotatingFileAppender(
|
||||
baseFilePath: await getLoggingFilePath(),
|
||||
formatter: formatter,
|
||||
).attachToLogger(Logger.root);
|
||||
Logger.root.onRecord.listen((record) {
|
||||
// Print log records to the console
|
||||
debugPrint(
|
||||
'${record.loggerName}: ${record.level.name}: ${record.time.time}: ${record.message}',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
76
lib/features/logging/providers/logs_provider.dart
Normal file
76
lib/features/logging/providers/logs_provider.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:vaani/features/logging/core/logger.dart';
|
||||
|
||||
part 'logs_provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class Logs extends _$Logs {
|
||||
@override
|
||||
Future<List<LogRecord>> build() async {
|
||||
final path = await getLoggingFilePath();
|
||||
final file = File(path);
|
||||
if (!file.existsSync()) {
|
||||
return [];
|
||||
}
|
||||
final lines = await file.readAsLines();
|
||||
return lines.map(parseLogLine).toList();
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final path = await getLoggingFilePath();
|
||||
final file = File(path);
|
||||
await file.writeAsString('');
|
||||
state = AsyncData([]);
|
||||
}
|
||||
|
||||
Future<String> getZipFilePath() async {
|
||||
var encoder = ZipFileEncoder();
|
||||
encoder.create(await generateZipFilePath());
|
||||
encoder.addFile(File(await getLoggingFilePath()));
|
||||
encoder.close();
|
||||
return encoder.zipPath;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> generateZipFilePath() async {
|
||||
Directory appDocDirectory = await getTemporaryDirectory();
|
||||
return '${appDocDirectory.path}/${generateZipFileName()}';
|
||||
}
|
||||
|
||||
String generateZipFileName() {
|
||||
return 'vaani-${DateTime.now().toIso8601String()}.zip';
|
||||
}
|
||||
|
||||
Level parseLevel(String level) {
|
||||
return Level.LEVELS
|
||||
.firstWhere((l) => l.name == level, orElse: () => Level.ALL);
|
||||
}
|
||||
|
||||
LogRecord parseLogLine(String line) {
|
||||
// 2024-10-03 00:48:58.012400 INFO GoRouter - getting location for name: "logs"
|
||||
|
||||
final RegExp logLineRegExp = RegExp(
|
||||
r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) (\w+) (\w+) - (.+)',
|
||||
);
|
||||
|
||||
final match = logLineRegExp.firstMatch(line);
|
||||
if (match == null) {
|
||||
// return as is
|
||||
return LogRecord(Level.ALL, line, 'Unknown');
|
||||
}
|
||||
|
||||
final timeString = match.group(1)!;
|
||||
final levelString = match.group(2)!;
|
||||
final loggerName = match.group(3)!;
|
||||
final message = match.group(4)!;
|
||||
|
||||
final time = DateTime.parse(timeString);
|
||||
final level = parseLevel(levelString);
|
||||
|
||||
return LogRecord(level, message, loggerName, time);
|
||||
}
|
||||
25
lib/features/logging/providers/logs_provider.g.dart
Normal file
25
lib/features/logging/providers/logs_provider.g.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'logs_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$logsHash() => r'901376741d17ddbb889d1b7b96bc2882289720a0';
|
||||
|
||||
/// See also [Logs].
|
||||
@ProviderFor(Logs)
|
||||
final logsProvider =
|
||||
AutoDisposeAsyncNotifierProvider<Logs, List<LogRecord>>.internal(
|
||||
Logs.new,
|
||||
name: r'logsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$logsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$Logs = AutoDisposeAsyncNotifier<List<LogRecord>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
266
lib/features/logging/view/logs_page.dart
Normal file
266
lib/features/logging/view/logs_page.dart
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:vaani/features/logging/providers/logs_provider.dart';
|
||||
import 'package:vaani/main.dart';
|
||||
|
||||
class LogsPage extends HookConsumerWidget {
|
||||
const LogsPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final logs = ref.watch(logsProvider);
|
||||
final theme = Theme.of(context);
|
||||
final scrollController = useScrollController();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Logs'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Clear logs',
|
||||
icon: const Icon(Icons.delete_forever),
|
||||
onPressed: () async {
|
||||
// ask for confirmation
|
||||
final shouldClear = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Clear logs?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (shouldClear == true) {
|
||||
ref.read(logsProvider.notifier).clear();
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Share logs',
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: () async {
|
||||
appLogger.info('Preparing logs for sharing');
|
||||
final zipLogFilePath =
|
||||
await ref.read(logsProvider.notifier).getZipFilePath();
|
||||
|
||||
// submit logs
|
||||
final result = await Share.shareXFiles([XFile(zipLogFilePath)]);
|
||||
|
||||
switch (result.status) {
|
||||
case ShareResultStatus.success:
|
||||
appLogger.info('Share success');
|
||||
break;
|
||||
case ShareResultStatus.dismissed:
|
||||
appLogger.info('Share dismissed');
|
||||
break;
|
||||
case ShareResultStatus.unavailable:
|
||||
appLogger.severe('Share unavailable');
|
||||
break;
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Download logs',
|
||||
icon: const Icon(Icons.download),
|
||||
onPressed: () async {
|
||||
appLogger.info('Preparing logs for download');
|
||||
final zipLogFilePath =
|
||||
await ref.read(logsProvider.notifier).getZipFilePath();
|
||||
|
||||
// save to folder
|
||||
String? outputFile = await FilePicker.platform.saveFile(
|
||||
dialogTitle: 'Please select an output file:',
|
||||
fileName: zipLogFilePath.split('/').last,
|
||||
);
|
||||
if (outputFile != null) {
|
||||
try {
|
||||
final file = File(outputFile);
|
||||
final zipFile = File(zipLogFilePath);
|
||||
await zipFile.copy(file.path);
|
||||
} catch (e) {
|
||||
appLogger.severe('Error saving file: $e');
|
||||
}
|
||||
} else {
|
||||
appLogger.info('Download cancelled');
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh logs',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
ref.invalidate(logsProvider);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Scroll to top',
|
||||
icon: const Icon(Icons.arrow_upward),
|
||||
onPressed: () {
|
||||
scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// a column with listview.builder and a scrollable list of logs
|
||||
body: Column(
|
||||
children: [
|
||||
// a filter for log levels, loggers, and search
|
||||
// TODO: implement filters and search
|
||||
|
||||
Expanded(
|
||||
child: logs.when(
|
||||
data: (logRecords) {
|
||||
return Scrollbar(
|
||||
controller: scrollController,
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: logRecords.length,
|
||||
itemBuilder: (context, index) {
|
||||
final logRecord = logRecords[index];
|
||||
return LogRecordTile(logRecord: logRecord, theme: theme);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('Error loading logs'),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.invalidate(logsProvider);
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LogRecordTile extends StatelessWidget {
|
||||
final LogRecord logRecord;
|
||||
final ThemeData theme;
|
||||
const LogRecordTile({
|
||||
required this.logRecord,
|
||||
required this.theme,
|
||||
super.key,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: getLogLevelColor(logRecord.level, theme),
|
||||
child: Icon(
|
||||
getLogLevelIcon(logRecord.level),
|
||||
color: getLogLevelTextColor(logRecord.level, theme),
|
||||
),
|
||||
),
|
||||
title: Text(logRecord.loggerName),
|
||||
subtitle: Text(logRecord.message),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
icon: Icon(getLogLevelIcon(logRecord.level)),
|
||||
title: Text(logRecord.loggerName),
|
||||
content: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: logRecord.time.toIso8601String(),
|
||||
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
const TextSpan(text: '\n\n'),
|
||||
TextSpan(
|
||||
text: logRecord.message,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IconData getLogLevelIcon(Level level) {
|
||||
switch (level) {
|
||||
case Level.INFO:
|
||||
return (Icons.info);
|
||||
case Level.WARNING:
|
||||
return (Icons.warning);
|
||||
case Level.SEVERE:
|
||||
case Level.SHOUT:
|
||||
return (Icons.error);
|
||||
default:
|
||||
return (Icons.bug_report);
|
||||
}
|
||||
}
|
||||
|
||||
Color? getLogLevelColor(Level level, ThemeData theme) {
|
||||
switch (level) {
|
||||
case Level.INFO:
|
||||
return theme.colorScheme.surfaceContainerLow;
|
||||
case Level.WARNING:
|
||||
return theme.colorScheme.surfaceBright;
|
||||
case Level.SEVERE:
|
||||
case Level.SHOUT:
|
||||
return theme.colorScheme.errorContainer;
|
||||
default:
|
||||
return theme.colorScheme.primaryContainer;
|
||||
}
|
||||
}
|
||||
|
||||
Color? getLogLevelTextColor(Level level, ThemeData theme) {
|
||||
switch (level) {
|
||||
case Level.INFO:
|
||||
return theme.colorScheme.onSurface;
|
||||
case Level.WARNING:
|
||||
return theme.colorScheme.onSurface;
|
||||
case Level.SEVERE:
|
||||
case Level.SHOUT:
|
||||
return theme.colorScheme.onErrorContainer;
|
||||
default:
|
||||
return theme.colorScheme.onPrimaryContainer;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue