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:
Dr.Blank 2024-10-03 05:54:29 -04:00 committed by GitHub
parent 7b0c2c4b88
commit 35a2d7cfce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 861 additions and 176 deletions

View 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}',
);
});
}
}

View 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);
}

View 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

View 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;
}
}