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 _entries = Queue(); 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 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 _fetchGoLogs() async { try { final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex); final logs = result['logs'] as List? ?? []; 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 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 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); } } } }