Files
PinePods-nix/PinePods-0.8.2/mobile/lib/services/logging/app_logger.dart
2026-03-03 10:57:43 -05:00

488 lines
15 KiB
Dart

// lib/services/logging/app_logger.dart
import 'dart:io';
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path_helper;
import 'package:intl/intl.dart';
enum LogLevel {
debug,
info,
warning,
error,
critical,
}
class LogEntry {
final DateTime timestamp;
final LogLevel level;
final String tag;
final String message;
final String? stackTrace;
LogEntry({
required this.timestamp,
required this.level,
required this.tag,
required this.message,
this.stackTrace,
});
String get levelString {
switch (level) {
case LogLevel.debug:
return 'DEBUG';
case LogLevel.info:
return 'INFO';
case LogLevel.warning:
return 'WARN';
case LogLevel.error:
return 'ERROR';
case LogLevel.critical:
return 'CRITICAL';
}
}
String get formattedMessage {
final timeStr = timestamp.toString().substring(0, 19); // Remove milliseconds for readability
var result = '[$timeStr] [$levelString] [$tag] $message';
if (stackTrace != null && stackTrace!.isNotEmpty) {
result += '\nStackTrace: $stackTrace';
}
return result;
}
}
class DeviceInfo {
final String platform;
final String osVersion;
final String model;
final String manufacturer;
final String appVersion;
final String buildNumber;
DeviceInfo({
required this.platform,
required this.osVersion,
required this.model,
required this.manufacturer,
required this.appVersion,
required this.buildNumber,
});
String get formattedInfo {
return '''
Device Information:
- Platform: $platform
- OS Version: $osVersion
- Model: $model
- Manufacturer: $manufacturer
- App Version: $appVersion
- Build Number: $buildNumber
''';
}
}
class AppLogger {
static final AppLogger _instance = AppLogger._internal();
factory AppLogger() => _instance;
AppLogger._internal();
static const int maxLogEntries = 1000; // Keep last 1000 log entries in memory
static const int maxSessionFiles = 5; // Keep last 5 session log files
static const String crashLogFileName = 'pinepods_last_crash.txt';
final Queue<LogEntry> _logs = Queue<LogEntry>();
DeviceInfo? _deviceInfo;
File? _currentSessionFile;
File? _crashLogFile;
Directory? _logsDirectory;
String? _sessionId;
bool _isInitialized = false;
// Initialize the logger and collect device info
Future<void> initialize() async {
if (_isInitialized) return;
await _collectDeviceInfo();
await _initializeLogFiles();
await _setupCrashHandler();
await _loadPreviousCrash();
_isInitialized = true;
// Log initialization
log(LogLevel.info, 'AppLogger', 'Logger initialized successfully');
}
Future<void> _collectDeviceInfo() async {
try {
final deviceInfoPlugin = DeviceInfoPlugin();
final packageInfo = await PackageInfo.fromPlatform();
if (Platform.isAndroid) {
final androidInfo = await deviceInfoPlugin.androidInfo;
_deviceInfo = DeviceInfo(
platform: 'Android',
osVersion: 'Android ${androidInfo.version.release} (API ${androidInfo.version.sdkInt})',
model: '${androidInfo.manufacturer} ${androidInfo.model}',
manufacturer: androidInfo.manufacturer,
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
} else if (Platform.isIOS) {
final iosInfo = await deviceInfoPlugin.iosInfo;
_deviceInfo = DeviceInfo(
platform: 'iOS',
osVersion: '${iosInfo.systemName} ${iosInfo.systemVersion}',
model: iosInfo.model,
manufacturer: 'Apple',
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
} else {
_deviceInfo = DeviceInfo(
platform: Platform.operatingSystem,
osVersion: Platform.operatingSystemVersion,
model: 'Unknown',
manufacturer: 'Unknown',
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
}
} catch (e) {
// If device info collection fails, create a basic info object
try {
final packageInfo = await PackageInfo.fromPlatform();
_deviceInfo = DeviceInfo(
platform: Platform.operatingSystem,
osVersion: Platform.operatingSystemVersion,
model: 'Unknown',
manufacturer: 'Unknown',
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
);
} catch (e2) {
_deviceInfo = DeviceInfo(
platform: 'Unknown',
osVersion: 'Unknown',
model: 'Unknown',
manufacturer: 'Unknown',
appVersion: 'Unknown',
buildNumber: 'Unknown',
);
}
}
}
void log(LogLevel level, String tag, String message, [String? stackTrace]) {
final entry = LogEntry(
timestamp: DateTime.now(),
level: level,
tag: tag,
message: message,
stackTrace: stackTrace,
);
_logs.add(entry);
// Keep only the last maxLogEntries in memory
while (_logs.length > maxLogEntries) {
_logs.removeFirst();
}
// Write to current session file asynchronously (don't await to avoid blocking)
_writeToSessionFile(entry);
// Also print to console in debug mode
if (kDebugMode) {
print(entry.formattedMessage);
}
}
// Convenience methods for different log levels
void debug(String tag, String message) => log(LogLevel.debug, tag, message);
void info(String tag, String message) => log(LogLevel.info, tag, message);
void warning(String tag, String message) => log(LogLevel.warning, tag, message);
void error(String tag, String message, [String? stackTrace]) => log(LogLevel.error, tag, message, stackTrace);
void critical(String tag, String message, [String? stackTrace]) => log(LogLevel.critical, tag, message, stackTrace);
// Log an exception with automatic stack trace
void logException(String tag, String message, dynamic exception, [StackTrace? stackTrace]) {
final stackTraceStr = stackTrace?.toString() ?? exception.toString();
error(tag, '$message: $exception', stackTraceStr);
}
// Get all logs
List<LogEntry> get logs => _logs.toList();
// Get logs filtered by level
List<LogEntry> getLogsByLevel(LogLevel level) {
return _logs.where((log) => log.level == level).toList();
}
// Get logs from a specific time period
List<LogEntry> getLogsInTimeRange(DateTime start, DateTime end) {
return _logs.where((log) =>
log.timestamp.isAfter(start) && log.timestamp.isBefore(end)
).toList();
}
// Get formatted log string for copying
String getFormattedLogs() {
final buffer = StringBuffer();
// Add device info
if (_deviceInfo != null) {
buffer.writeln(_deviceInfo!.formattedInfo);
}
// Add separator
buffer.writeln('=' * 50);
buffer.writeln('Application Logs:');
buffer.writeln('=' * 50);
// Add all logs
for (final log in _logs) {
buffer.writeln(log.formattedMessage);
}
// Add footer
buffer.writeln();
buffer.writeln('=' * 50);
buffer.writeln('End of logs - Total entries: ${_logs.length}');
buffer.writeln('Bug reports: https://github.com/madeofpendletonwool/pinepods/issues');
return buffer.toString();
}
// Clear all logs
void clearLogs() {
_logs.clear();
log(LogLevel.info, 'AppLogger', 'Logs cleared by user');
}
// Initialize log files and directory structure
Future<void> _initializeLogFiles() async {
try {
final appDocDir = await getApplicationDocumentsDirectory();
_logsDirectory = Directory(path_helper.join(appDocDir.path, 'logs'));
// Create logs directory if it doesn't exist
if (!await _logsDirectory!.exists()) {
await _logsDirectory!.create(recursive: true);
}
// Clean up old session files (keep only last 5)
await _cleanupOldSessionFiles();
// Create new session file
_sessionId = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
_currentSessionFile = File(path_helper.join(_logsDirectory!.path, 'session_$_sessionId.log'));
await _currentSessionFile!.create();
// Initialize crash log file
_crashLogFile = File(path_helper.join(_logsDirectory!.path, crashLogFileName));
if (!await _crashLogFile!.exists()) {
await _crashLogFile!.create();
}
log(LogLevel.info, 'AppLogger', 'Session log files initialized at ${_logsDirectory!.path}');
} catch (e) {
if (kDebugMode) {
print('Failed to initialize log files: $e');
}
}
}
// Clean up old session files, keeping only the most recent ones
Future<void> _cleanupOldSessionFiles() async {
try {
final files = await _logsDirectory!.list().toList();
final sessionFiles = files
.whereType<File>()
.where((f) => path_helper.basename(f.path).startsWith('session_'))
.toList();
// Sort by last modified date (newest first)
sessionFiles.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
// Delete files beyond the limit
if (sessionFiles.length > maxSessionFiles) {
for (int i = maxSessionFiles; i < sessionFiles.length; i++) {
await sessionFiles[i].delete();
}
}
} catch (e) {
if (kDebugMode) {
print('Failed to cleanup old session files: $e');
}
}
}
// Write log entry to current session file
Future<void> _writeToSessionFile(LogEntry entry) async {
if (_currentSessionFile == null) return;
try {
await _currentSessionFile!.writeAsString(
'${entry.formattedMessage}\n',
mode: FileMode.append,
);
} catch (e) {
// Silently fail to avoid logging loops
if (kDebugMode) {
print('Failed to write log to session file: $e');
}
}
}
// Setup crash handler
Future<void> _setupCrashHandler() async {
FlutterError.onError = (FlutterErrorDetails details) {
_logCrash('Flutter Error', details.exception.toString(), details.stack);
// Still call the default error handler
FlutterError.presentError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
_logCrash('Platform Error', error.toString(), stack);
return true; // Mark as handled
};
}
// Log crash to persistent storage
Future<void> _logCrash(String type, String error, StackTrace? stackTrace) async {
try {
final crashInfo = {
'timestamp': DateTime.now().toIso8601String(),
'sessionId': _sessionId,
'type': type,
'error': error,
'stackTrace': stackTrace?.toString(),
'deviceInfo': _deviceInfo?.formattedInfo,
'recentLogs': _logs.length > 20 ? _logs.skip(_logs.length - 20).map((e) => e.formattedMessage).toList() : _logs.map((e) => e.formattedMessage).toList(), // Only last 20 entries
};
if (_crashLogFile != null) {
await _crashLogFile!.writeAsString(jsonEncode(crashInfo));
}
// Also log through normal logging
critical('CrashHandler', '$type: $error', stackTrace?.toString());
} catch (e) {
if (kDebugMode) {
print('Failed to log crash: $e');
}
}
}
// Load and log previous crash if exists
Future<void> _loadPreviousCrash() async {
if (_crashLogFile == null || !await _crashLogFile!.exists()) return;
try {
final crashData = await _crashLogFile!.readAsString();
if (crashData.isNotEmpty) {
final crash = jsonDecode(crashData);
warning('PreviousCrash', 'Previous crash detected: ${crash['type']} at ${crash['timestamp']}');
warning('PreviousCrash', 'Session: ${crash['sessionId'] ?? 'unknown'}');
warning('PreviousCrash', 'Error: ${crash['error']}');
if (crash['stackTrace'] != null) {
warning('PreviousCrash', 'Stack trace available in crash log file');
}
}
} catch (e) {
warning('AppLogger', 'Failed to load previous crash info: $e');
}
}
// Get list of available session files
Future<List<File>> getSessionFiles() async {
if (_logsDirectory == null) return [];
try {
final files = await _logsDirectory!.list().toList();
final sessionFiles = files
.whereType<File>()
.where((f) => path_helper.basename(f.path).startsWith('session_'))
.toList();
// Sort by last modified date (newest first)
sessionFiles.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
return sessionFiles;
} catch (e) {
return [];
}
}
// Get current session file path
String? get currentSessionPath => _currentSessionFile?.path;
// Get crash log file path
String? get crashLogPath => _crashLogFile?.path;
// Get logs directory path
String? get logsDirectoryPath => _logsDirectory?.path;
// Check if previous crash exists
Future<bool> hasPreviousCrash() async {
if (_crashLogFile == null) return false;
try {
final exists = await _crashLogFile!.exists();
if (!exists) return false;
final content = await _crashLogFile!.readAsString();
return content.isNotEmpty;
} catch (e) {
return false;
}
}
// Clear crash log
Future<void> clearCrashLog() async {
if (_crashLogFile != null && await _crashLogFile!.exists()) {
await _crashLogFile!.writeAsString('');
}
}
// Get formatted logs with session info
String getFormattedLogsWithSessionInfo() {
final buffer = StringBuffer();
// Add session info
buffer.writeln('Session ID: $_sessionId');
buffer.writeln('Session started: ${DateTime.now().toString()}');
// Add device info
if (_deviceInfo != null) {
buffer.writeln(_deviceInfo!.formattedInfo);
}
// Add separator
buffer.writeln('=' * 50);
buffer.writeln('Application Logs (Current Session):');
buffer.writeln('=' * 50);
// Add all logs
for (final log in _logs) {
buffer.writeln(log.formattedMessage);
}
// Add footer
buffer.writeln();
buffer.writeln('=' * 50);
buffer.writeln('End of logs - Total entries: ${_logs.length}');
buffer.writeln('Session file: ${_currentSessionFile?.path}');
buffer.writeln('Bug reports: https://github.com/madeofpendletonwool/pinepods/issues');
return buffer.toString();
}
// Get device info
DeviceInfo? get deviceInfo => _deviceInfo;
}