import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; import 'dart:io'; import 'package:shared_preferences/shared_preferences.dart'; class LogEntry { final String level; // DEBUG, INFO, WARNING, ERROR final String message; final String? stackTrace; final DateTime timestamp; final String? userAgent; final String? deviceModel; LogEntry({ required this.level, required this.message, this.stackTrace, DateTime? timestamp, this.userAgent, this.deviceModel, }) : timestamp = timestamp ?? DateTime.now(); Map toJson() => { 'level': level, 'message': message, 'stackTrace': stackTrace, 'timestamp': timestamp.toIso8601String(), 'userAgent': userAgent, 'deviceModel': deviceModel, }; } /// Centralized Logging Service mit Auto-Upload zu Server class LoggerService extends ChangeNotifier { /// [client] nur für Tests: erlaubt zu prüfen, dass bei Widerspruch des /// Nutzers wirklich KEIN Request das Gerät verlässt. LoggerService({http.Client? client}) : _client = client ?? http.Client(); final http.Client _client; static const String _logServerUrl = 'https://logs.baka-net.de/api/logs'; static const int _batchSize = 5; /// Schlüssel der Diagnose-Entscheidung in den SharedPreferences. /// Liegt hier und nicht in AppSettings, damit der Logger ihn ohne /// Rückwärts-Abhängigkeit selbst lesen kann. static const String diagnoseKey = 'diagnose_daten'; final List _logs = []; late final String _deviceModel; bool _initialized = false; // Opt-in: standardmäßig AUS. Ohne ausdrückliche Zustimmung des Nutzers // verlässt kein Log das Gerät. bool _sendeDiagnosedaten = false; /// Steuert, ob Logs den Server erreichen. Opt-in: ohne ausdrückliche /// Zustimmung bleibt der Versand aus. Lokales `debugPrint` läuft /// unabhängig davon weiter — nur der Versand wird unterbunden. /// [AppSettings] hält den Wert persistent und schiebt Änderungen hierher. bool get sendeDiagnosedaten => _sendeDiagnosedaten; set sendeDiagnosedaten(bool wert) { if (_sendeDiagnosedaten == wert) return; _sendeDiagnosedaten = wert; // Bei Widerspruch nichts Aufgestautes zurückbehalten. if (!wert) _logs.clear(); } Future init() async { if (_initialized) return; // Datenschutz: nur grobe Plattform senden statt Hersteller+Modell // (das war ein Geräte-Fingerprint). Reicht für Bug-Triage. _deviceModel = Platform.isAndroid ? 'android' : (Platform.isIOS ? 'ios' : Platform.operatingSystem); // Die Einstellung wird hier direkt gelesen und nicht von AppSettings // abgewartet: main() initialisiert den Logger VOR den Einstellungen, und // schon dieses Zeitfenster darf nichts senden, wenn der Nutzer // widersprochen hat. try { final prefs = await SharedPreferences.getInstance(); _sendeDiagnosedaten = prefs.getBool(diagnoseKey) ?? false; } catch (_) { // Nicht lesbar: beim bisherigen Verhalten bleiben. } _initialized = true; debugPrint('✅ LoggerService initialized for $_deviceModel'); } // ─── Datenschutz: Bereinigung vor dem Server-Upload ─── static const int _maxFrames = 8; /// Maskiert sensible Fragmente in Log-Freitext (message/stackTrace), bevor /// er an den Server geht: sensible `key=value`-Fragmente, Datei-Pfade, /// nackte URLs, Bearer-Token, JWTs und lange Hex-IDs. @visibleForTesting static String scrubText(String? roh) { if (roh == null || roh.isEmpty) return roh ?? ''; var s = roh.replaceAllMapped( RegExp( r'\b(user|username|benutzer|pw|pass|passwort|password|token|secret|url|serverurl)=([^\s&]+)', caseSensitive: false, ), (m) => '${m.group(1)}=', ); s = s .replaceAll(RegExp(r'/(storage|data|home|Users|var)/\S+'), '') .replaceAll(RegExp(r'https?://\S+'), '') .replaceAll(RegExp(r'Bearer\s+[A-Za-z0-9._-]+'), 'Bearer ') .replaceAll(RegExp(r'eyJ[A-Za-z0-9._-]{10,}'), '') .replaceAll(RegExp(r'\b[A-Fa-f0-9]{16,}\b'), ''); return s; } /// Kürzt einen (bereits gescrubbten) Stack-Trace auf wenige Zeilen. @visibleForTesting static String capFrames(String s) { final zeilen = s.split('\n'); if (zeilen.length <= _maxFrames) return s; final rest = zeilen.length - _maxFrames; return '${zeilen.take(_maxFrames).join('\n')}\n… ($rest weitere Zeilen entfernt)'; } /// Bereinigter Upload-Payload eines Eintrags. Der lokale Puffer/debugPrint /// bleibt unberührt — die Maskierung greift nur auf dem Versand-Pfad. @visibleForTesting static Map sanitizedJson(LogEntry l) => { 'level': l.level, 'message': scrubText(l.message), 'stackTrace': l.stackTrace == null ? null : capFrames(scrubText(l.stackTrace)), 'timestamp': l.timestamp.toIso8601String(), 'userAgent': l.userAgent, 'deviceModel': l.deviceModel, }; /// Logge einen Fehler (wird sofort zum Server geschickt) Future error( String message, [ Object? error, StackTrace? stackTrace, ]) async { final entry = LogEntry( level: 'ERROR', message: message, stackTrace: '$error\n$stackTrace', deviceModel: _deviceModel, ); _logs.add(entry); debugPrint('❌ ERROR: $message'); await _uploadIfError(entry); } /// Logge eine Warnung void warning(String message) { final entry = LogEntry( level: 'WARNING', message: message, deviceModel: _deviceModel, ); _logs.add(entry); debugPrint('⚠️ WARNING: $message'); _checkAndFlush(); } /// Logge Info-Event void info(String message) { final entry = LogEntry( level: 'INFO', message: message, deviceModel: _deviceModel, ); _logs.add(entry); debugPrint('ℹ️ INFO: $message'); _checkAndFlush(); } void _checkAndFlush() { if (_logs.length >= _batchSize) { _uploadLogs(); } } Future _uploadIfError(LogEntry entry) async { debugPrint('📤 Uploading ERROR immediately: ${entry.message}'); await _uploadLogs(); } /// Hochladen der Logs zum Server Future _uploadLogs() async { // Opt-out des Nutzers: nichts verlässt das Gerät. Der Puffer wird // trotzdem geleert, sonst wüchse er im Dauerbetrieb unbegrenzt. if (!_sendeDiagnosedaten) { _logs.clear(); return; } if (_logs.isEmpty) return; try { final logsToUpload = List.from(_logs); await _client .post( Uri.parse(_logServerUrl), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'logs': logsToUpload.map(sanitizedJson).toList(), 'timestamp': DateTime.now().toIso8601String(), }), ) .timeout(const Duration(seconds: 10)); _logs.clear(); debugPrint( '✅ Logs uploaded successfully (${logsToUpload.length} entries)', ); } catch (e) { debugPrint('⚠️ Failed to upload logs: $e'); } } /// Manuelles Flushen (z.B. vor App-Exit) Future flush() async { await _uploadLogs(); } int get logCount => _logs.length; } /// Global Logger Instance final logger = LoggerService();