Files
SpotiFLAC-Mobile/lib/utils/logger.dart

503 lines
14 KiB
Dart

import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
const int _maxLogMessageLength = 500;
const String _redactedValue = '[REDACTED]';
final RegExp _authorizationBearerPattern = RegExp(
r'\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*',
caseSensitive: false,
);
final RegExp _genericSensitiveKeyValuePattern = RegExp(
r'\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)',
caseSensitive: false,
);
final RegExp _sensitiveQueryPattern = RegExp(
r'([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+',
caseSensitive: false,
);
final RegExp _bearerTokenPattern = RegExp(
r'\bBearer\s+[A-Za-z0-9._~+/\-]+=*',
caseSensitive: false,
);
String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
if (value.length <= maxLength) {
return value;
}
return '${value.substring(0, maxLength)}...[truncated]';
}
String _redactSensitiveText(String value) {
var redacted = value;
redacted = redacted.replaceAllMapped(_authorizationBearerPattern, (_) {
return 'Authorization: Bearer $_redactedValue';
});
redacted = redacted.replaceAllMapped(_genericSensitiveKeyValuePattern, (
match,
) {
final key = match.group(1) ?? '';
final delimiter = match.group(2) ?? '=';
return '$key$delimiter$_redactedValue';
});
redacted = redacted.replaceAllMapped(_sensitiveQueryPattern, (match) {
final prefix = match.group(1) ?? '';
return '$prefix$_redactedValue';
});
redacted = redacted.replaceAllMapped(_bearerTokenPattern, (_) {
return 'Bearer $_redactedValue';
});
return redacted;
}
class LogEntry {
final DateTime timestamp;
final String level;
final String tag;
final String message;
final String? error;
final bool isFromGo;
LogEntry({
required this.timestamp,
required this.level,
required this.tag,
required this.message,
this.error,
this.isFromGo = false,
});
String get formattedTime {
final h = timestamp.hour.toString().padLeft(2, '0');
final m = timestamp.minute.toString().padLeft(2, '0');
final s = timestamp.second.toString().padLeft(2, '0');
final ms = timestamp.millisecond.toString().padLeft(3, '0');
return '$h:$m:$s.$ms';
}
@override
String toString() {
final errorPart = error != null ? ' | $error' : '';
final goPart = isFromGo ? ' [Go]' : '';
return '[$formattedTime] [$level]$goPart [$tag] $message$errorPart';
}
}
class LogBuffer extends ChangeNotifier {
static final LogBuffer _instance = LogBuffer._internal();
factory LogBuffer() => _instance;
LogBuffer._internal();
static const int maxEntries = 500;
static const Duration _goLogPollingInterval = Duration(milliseconds: 800);
final Queue<LogEntry> _entries = Queue<LogEntry>();
Timer? _goLogTimer;
int _lastGoLogIndex = 0;
bool _isFetchingGoLogs = false;
static bool _loggingEnabled = false;
static bool get loggingEnabled => _loggingEnabled;
static set loggingEnabled(bool value) {
_loggingEnabled = value;
if (value) {
PlatformBridge.setGoLoggingEnabled(true).catchError((_) {});
} else {
PlatformBridge.setGoLoggingEnabled(false).catchError((_) {});
}
}
List<LogEntry> get entries => _entries.toList();
int get length => _entries.length;
void add(LogEntry entry) {
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
return;
}
final sanitizedMessage = _truncateLogText(
_redactSensitiveText(entry.message),
);
final sanitizedError = entry.error != null
? _truncateLogText(_redactSensitiveText(entry.error!))
: null;
final sanitizedEntry =
(sanitizedMessage == entry.message && sanitizedError == entry.error)
? entry
: LogEntry(
timestamp: entry.timestamp,
level: entry.level,
tag: entry.tag,
message: sanitizedMessage,
error: sanitizedError,
isFromGo: entry.isFromGo,
);
if (_entries.length >= maxEntries) {
_entries.removeFirst();
}
_entries.add(sanitizedEntry);
notifyListeners();
}
void startGoLogPolling() {
_goLogTimer?.cancel();
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
if (_isFetchingGoLogs) return;
_isFetchingGoLogs = true;
try {
await _fetchGoLogs();
} finally {
_isFetchingGoLogs = false;
}
});
}
void stopGoLogPolling() {
_goLogTimer?.cancel();
_goLogTimer = null;
_isFetchingGoLogs = false;
}
Future<void> _fetchGoLogs() async {
try {
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
final logs = result['logs'] as List<dynamic>? ?? [];
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
final keepNonErrorLogs = _loggingEnabled;
for (final log in logs) {
final level = log['level'] as String? ?? 'INFO';
if (!keepNonErrorLogs && level != 'ERROR' && level != 'FATAL') {
continue;
}
final timestamp = log['timestamp'] as String? ?? '';
final tag = log['tag'] as String? ?? 'Go';
final message = log['message'] as String? ?? '';
DateTime parsedTime = DateTime.now();
if (timestamp.isNotEmpty) {
try {
final parts = timestamp.split(':');
if (parts.length >= 3) {
final secParts = parts[2].split('.');
parsedTime = DateTime(
parsedTime.year,
parsedTime.month,
parsedTime.day,
int.parse(parts[0]),
int.parse(parts[1]),
int.parse(secParts[0]),
secParts.length > 1 ? int.parse(secParts[1]) : 0,
);
}
} catch (_) {}
}
add(
LogEntry(
timestamp: parsedTime,
level: level,
tag: tag,
message: message,
isFromGo: true,
),
);
}
_lastGoLogIndex = nextIndex;
} catch (e) {
if (kDebugMode) {
debugPrint('Failed to fetch Go logs: $e');
}
}
}
void clear() {
_entries.clear();
_lastGoLogIndex = 0;
PlatformBridge.clearGoLogs().catchError((_) {});
notifyListeners();
}
String export() {
final buffer = StringBuffer();
buffer.writeln('SpotiFLAC Log Export');
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
buffer.writeln('Entries: ${_entries.length}');
buffer.writeln('=' * 60);
buffer.writeln();
for (final entry in _entries) {
buffer.writeln(entry.toString());
}
return buffer.toString();
}
Future<String> exportWithDeviceInfo() async {
final buffer = StringBuffer();
buffer.writeln('=' * 60);
buffer.writeln('SPOTIFLAC LOG EXPORT');
buffer.writeln('=' * 60);
buffer.writeln();
buffer.writeln('--- App Information ---');
buffer.writeln(
'App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})',
);
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
buffer.writeln();
buffer.writeln('--- Device Information ---');
try {
final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
buffer.writeln('Platform: Android');
buffer.writeln('Device: ${android.manufacturer} ${android.model}');
buffer.writeln('Brand: ${android.brand}');
buffer.writeln(
'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})',
);
buffer.writeln('Build ID: ${android.id}');
if (android.version.securityPatch != null &&
android.version.securityPatch!.isNotEmpty) {
buffer.writeln('Security Patch: ${android.version.securityPatch}');
}
buffer.writeln('Hardware: ${android.hardware}');
buffer.writeln('Product: ${android.product}');
buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}');
buffer.writeln('Is Physical Device: ${android.isPhysicalDevice}');
} else if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo;
buffer.writeln('Platform: iOS');
buffer.writeln('Device: ${ios.utsname.machine}');
buffer.writeln('Model: ${ios.model}');
buffer.writeln('System Name: ${ios.systemName}');
buffer.writeln('System Version: ${ios.systemVersion}');
buffer.writeln('Device Name: ${ios.name}');
buffer.writeln('Is Physical Device: ${ios.isPhysicalDevice}');
}
} catch (e) {
buffer.writeln('Failed to get device info: $e');
}
buffer.writeln();
buffer.writeln('--- Log Summary ---');
buffer.writeln('Total Entries: ${_entries.length}');
int errorCount = 0;
int warnCount = 0;
int infoCount = 0;
int debugCount = 0;
int goCount = 0;
for (final entry in _entries) {
switch (entry.level) {
case 'ERROR':
case 'FATAL':
errorCount++;
break;
case 'WARN':
warnCount++;
break;
case 'INFO':
infoCount++;
break;
case 'DEBUG':
debugCount++;
break;
}
if (entry.isFromGo) goCount++;
}
buffer.writeln('Errors: $errorCount');
buffer.writeln('Warnings: $warnCount');
buffer.writeln('Info: $infoCount');
buffer.writeln('Debug: $debugCount');
buffer.writeln('From Go Backend: $goCount');
buffer.writeln();
buffer.writeln('=' * 60);
buffer.writeln('LOG ENTRIES');
buffer.writeln('=' * 60);
buffer.writeln();
for (final entry in _entries) {
buffer.writeln(entry.toString());
}
return buffer.toString();
}
List<LogEntry> filter({String? level, String? tag, String? search}) {
final tagLower = tag?.toLowerCase();
final searchLower = search?.toLowerCase();
return _entries.where((entry) {
if (level != null && level != 'ALL' && entry.level != level) {
return false;
}
if (tagLower != null && !entry.tag.toLowerCase().contains(tagLower)) {
return false;
}
if (searchLower != null && searchLower.isNotEmpty) {
return entry.message.toLowerCase().contains(searchLower) ||
entry.tag.toLowerCase().contains(searchLower) ||
(entry.error?.toLowerCase().contains(searchLower) ?? false);
}
return true;
}).toList();
}
}
class BufferedOutput extends LogOutput {
final String tag;
BufferedOutput(this.tag);
@override
void output(OutputEvent event) {
if (kDebugMode) {
for (final line in event.lines) {
debugPrint(_truncateLogText(_redactSensitiveText(line)));
}
}
final level = _levelToString(event.level);
final message = _truncateLogText(
_redactSensitiveText(event.lines.join('\n')),
);
LogBuffer().add(
LogEntry(
timestamp: DateTime.now(),
level: level,
tag: tag,
message: message,
),
);
}
String _levelToString(Level level) {
switch (level) {
case Level.debug:
return 'DEBUG';
case Level.info:
return 'INFO';
case Level.warning:
return 'WARN';
case Level.error:
return 'ERROR';
case Level.fatal:
return 'FATAL';
default:
return 'LOG';
}
}
}
final log = Logger(
printer: PrettyPrinter(
methodCount: 0,
errorMethodCount: 5,
lineLength: 80,
colors: true,
printEmojis: false,
dateTimeFormat: DateTimeFormat.none,
),
level: Level.debug,
);
class AppLogger {
final String _tag;
late final Logger? _logger;
AppLogger(this._tag) {
if (kDebugMode) {
_logger = Logger(
printer: SimplePrinter(printTime: false, colors: false),
output: BufferedOutput(_tag),
level: Level.debug,
);
} else {
_logger = null;
}
}
void _addToBuffer(String level, String message, {String? error}) {
if (!LogBuffer.loggingEnabled && level != 'ERROR' && level != 'FATAL') {
return;
}
LogBuffer().add(
LogEntry(
timestamp: DateTime.now(),
level: level,
tag: _tag,
message: message,
error: error,
),
);
}
void d(String message) {
if (kDebugMode) {
_logger?.d(message);
} else {
_addToBuffer('DEBUG', message);
}
}
void i(String message) {
if (kDebugMode) {
_logger?.i(message);
} else {
_addToBuffer('INFO', message);
}
}
void w(String message) {
if (kDebugMode) {
_logger?.w(message);
} else {
_addToBuffer('WARN', message);
}
}
void e(String message, [Object? error, StackTrace? stackTrace]) {
if (error != null) {
_addToBuffer('ERROR', message, error: error.toString());
if (kDebugMode) {
debugPrint(
'[$_tag] ERROR: ${_truncateLogText(_redactSensitiveText(message))} | ${_truncateLogText(_redactSensitiveText(error.toString()))}',
);
if (stackTrace != null) {
debugPrint(stackTrace.toString());
}
}
} else {
if (kDebugMode) {
_logger?.e(message);
} else {
_addToBuffer('ERROR', message);
}
}
}
}