From 3f169fa1d97b60e929fc33e4c906939ee919c654 Mon Sep 17 00:00:00 2001 From: Dustin Date: Thu, 6 Aug 2026 18:01:02 +0200 Subject: [PATCH] =?UTF-8?q?v2.55=20=E2=80=94=20Echtzeit-Cloud-Sync=20(SSE)?= =?UTF-8?q?=20mit=203=20Modi:=20Echtzeit/Manuell/Zeit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/screens/home_screen.dart | 25 +- lib/screens/settings_screen.dart | 193 +++++++- lib/services/realtime_sync_service.dart | 453 ++++++++++++++++++ test/services/realtime_sync_service_test.dart | 283 +++++++++++ 4 files changed, 935 insertions(+), 19 deletions(-) create mode 100644 lib/services/realtime_sync_service.dart create mode 100644 test/services/realtime_sync_service_test.dart diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index acf3d2a..f61af85 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -21,6 +21,7 @@ import '../services/cloud_service.dart'; import '../services/player_service.dart'; import '../services/auth_service.dart'; import '../services/sync_service.dart'; +import '../services/realtime_sync_service.dart'; import '../config/app_config.dart'; class MeloHome extends StatefulWidget { @@ -30,7 +31,8 @@ class MeloHome extends StatefulWidget { State createState() => _MeloHomeState(); } -class _MeloHomeState extends State { +class _MeloHomeState extends State with WidgetsBindingObserver { + final RealtimeSyncService _realtimeSync = RealtimeSyncService(); final MeloHomeViewModel _vm = MeloHomeViewModel(); final CloudService _cloud = CloudService(); @@ -48,6 +50,7 @@ class _MeloHomeState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _vm.ladeSongs(); // Gespeicherte Navidrome-Zugangsdaten laden → Musikserver-Statuspunkt _vm.navidrome.ladeGespeicherteZugangsdaten().then((_) { @@ -55,17 +58,37 @@ class _MeloHomeState extends State { }); // Cloud-Verbindung beim App-Start herstellen (feuern-und-vergessen). _cloud.verbinde(); + // Realtime-Sync starten (SSE-Push, wenn Modus=Echtzeit) + _realtimeSync.starteWennAktiviert(); // Tipp auf die Sync-Abschluss-/Fehler-Notification → Cloud-Tab öffnen SyncService.syncBenachrichtigungGetippt.addListener(_syncNotifGetippt); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); SyncService.syncBenachrichtigungGetippt.removeListener(_syncNotifGetippt); + _realtimeSync.stoppe(); _vm.dispose(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + switch (state) { + case AppLifecycleState.resumed: + // App in den Vordergrund → Realtime fortsetzen + _realtimeSync.fortsetzen(); + case AppLifecycleState.paused: + case AppLifecycleState.detached: + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + // App im Hintergrund → Realtime pausieren (Batterie sparen) + _realtimeSync.pausiere(); + } + } + /// Öffnet den Cloud-Tab, wenn die Sync-Notification getippt wurde. void _syncNotifGetippt() { if (!mounted) return; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 27dcc40..5eb7f1b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -7,6 +7,7 @@ import '../services/auth_service.dart'; import '../services/cloud_service.dart'; import '../services/player_service.dart'; import '../services/sync_service.dart'; +import '../services/realtime_sync_service.dart'; import '../database/db_helper.dart'; import 'cloud_screen.dart'; import 'recap_screen.dart'; @@ -25,12 +26,22 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { // ─── Einstellungen (aus SharedPreferences geladen) ─── - int _syncIntervall = 0; // 0 = Aus, sonst Stunden + String _syncModus = 'realtime'; // 'realtime' | 'manual' | 'interval' + int _cloudIntervallStunden = 48; // 48/72/120/168/336 int _sleepDefaultMin = 0; // 0 = Aus double _geschwindigkeit = 1.0; Color _akzent = MeloTheme.akzent; Map _speicherInfo = const {'anzahl': 0, 'bytes': 0}; + /// Verfügbare Zeit-Intervalle für Modus 3 (in Stunden). + static const _intervallOptionen = { + 48: '2 Tage', + 72: '3 Tage', + 120: '5 Tage', + 168: '1 Woche', + 336: '2 Wochen', + }; + static const _akzentFarben = [ Color(0xFFCC0000), // Rot (Standard) Color(0xFF00B8A9), // Türkis @@ -53,9 +64,27 @@ class _SettingsScreenState extends State { final speed = p.getDouble('playback_speed') ?? 1.0; final akzentHex = p.getString(UserEffekt.akzentKey(AuthService().benutzer)); + // Sync-Modus laden (mit Migration von altem cloud_interval) + final modus = p.getString('sync_modus') ?? 'realtime'; + int intervallStunden = p.getInt('cloud_interval_stunden') ?? 0; + if (intervallStunden <= 0) { + // Migration: alter cloud_interval (1/3/6/12h) → 48h default + final alt = p.getInt('cloud_interval') ?? 0; + if (alt == 1 || alt == 3 || alt == 6 || alt == 12) { + intervallStunden = 48; + await p.setInt('cloud_interval_stunden', 48); + } else { + intervallStunden = 48; + } + } + // Prüfe, ob der Modus gültig ist + if (!['realtime', 'manual', 'interval'].contains(modus)) { + await p.setString('sync_modus', 'realtime'); + } if (!mounted) return; setState(() { - _syncIntervall = p.getInt('cloud_interval') ?? 0; + _syncModus = modus; + _cloudIntervallStunden = intervallStunden; _sleepDefaultMin = p.getInt('sleep_timer_default_min') ?? 0; _geschwindigkeit = speed; _akzent = UserEffekt.farbeAusHex(akzentHex) ?? MeloTheme.akzent; @@ -72,12 +101,41 @@ class _SettingsScreenState extends State { // ─── Setter (persistieren + anwenden) ─── - Future _intervallSetzen(int stunden) async { - setState(() => _syncIntervall = stunden); + /// Setzt den Sync-Modus und persistiert ihn. + /// Startet/stoppt den Realtime-Service bzw. den Timer entsprechend. + Future _syncModusSetzen(String modus) async { + setState(() => _syncModus = modus); final p = await SharedPreferences.getInstance(); + await p.setString('sync_modus', modus); + + // Realtime-Service entsprechend starten/stoppen + switch (modus) { + case 'realtime': + // SSE sofort starten + final rs = RealtimeSyncService(); + await rs.starteWennAktiviert(); + break; + case 'manual': + // Kein Auto-Sync — Timer stoppen + await p.setInt('cloud_interval', 0); // alten Timer deaktivieren + await SyncService.starteAutoSyncTimer(); + break; + case 'interval': + // Intervall-Timer mit cloud_interval_stunden starten + await p.setInt('cloud_interval', _cloudIntervallStunden); + await SyncService.starteAutoSyncTimer(); + break; + } + } + + /// Setzt das Cloud-Intervall (nur für Modus 3: interval). + Future _intervallSetzen(int stunden) async { + setState(() => _cloudIntervallStunden = stunden); + final p = await SharedPreferences.getInstance(); + await p.setInt('cloud_interval_stunden', stunden); + // Timer neu starten (der cloud_interval-Wert wird aktualisiert) await p.setInt('cloud_interval', stunden); - await p.setBool('cloud_auto', stunden > 0); // Kompatibilität (altes Flag) - await SyncService.starteAutoSyncTimer(); // app-weiter Timer neu starten + await SyncService.starteAutoSyncTimer(); } Future _sleepDefaultSetzen(int min) async { @@ -165,20 +223,51 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), - // ─── Sektion: Wiedergabe & Sync ─── - _sektionHeader('Wiedergabe & Sync'), + // ─── Sektion: Cloud-Sync ─── + _sektionHeader('Cloud-Sync'), _einstellungsGruppe([ _gruppenZeile( - label: 'Auto-Sync-Intervall', - wert: _syncIntervall == 0 ? 'Aus' : '${_syncIntervall}h', - inhalt: Row(children: [ - _wahlChip('Aus', _syncIntervall == 0, () => _intervallSetzen(0)), - _wahlChip('1h', _syncIntervall == 1, () => _intervallSetzen(1)), - _wahlChip('3h', _syncIntervall == 3, () => _intervallSetzen(3)), - _wahlChip('6h', _syncIntervall == 6, () => _intervallSetzen(6)), - _wahlChip('12h', _syncIntervall == 12, () => _intervallSetzen(12)), - ]), + label: 'Sync-Modus', + wert: _syncModusText, + inhalt: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _modusRadioTile('⚡ Echtzeit', 'realtime', + 'Änderungen sofort synchronisieren (Push)'), + _modusRadioTile('👆 Manuell', 'manual', + 'Nur auf Knopfdruck synchronisieren'), + _modusRadioTile('🗓️ Zeit-Intervall', 'interval', + 'Automatisch nach Zeitplan synchronisieren'), + ], + ), ), + if (_syncModus == 'interval') ...[ + const Divider(color: MeloTheme.dunkel2, height: 1), + _gruppenZeile( + label: 'Intervall', + wert: _intervallOptionen[_cloudIntervallStunden] ?? '${_cloudIntervallStunden}h', + inhalt: DropdownButtonFormField( + initialValue: _intervallOptionen.containsKey(_cloudIntervallStunden) + ? _cloudIntervallStunden + : 48, + dropdownColor: MeloTheme.dunkel1, + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + border: OutlineInputBorder(), + ), + items: _intervallOptionen.entries.map((e) { + return DropdownMenuItem( + value: e.key, + child: Text(e.value), + ); + }).toList(), + onChanged: (v) { + if (v != null) _intervallSetzen(v); + }, + ), + ), + ], const Divider(color: MeloTheme.dunkel2, height: 1), _gruppenZeile( label: 'Sleep-Timer-Standard', @@ -201,7 +290,6 @@ class _SettingsScreenState extends State { max: 2.0, divisions: 6, activeColor: MeloTheme.akzent, - // Live-Vorschau beim Ziehen, persistieren erst am Ende onChanged: (v) => setState(() => _geschwindigkeit = v), onChangeEnd: _geschwindigkeitSetzen, ), @@ -556,4 +644,73 @@ class _SettingsScreenState extends State { ), ); } + + /// Anzeigetext für den aktuellen Sync-Modus. + String get _syncModusText { + switch (_syncModus) { + case 'realtime': + return '⚡ Echtzeit'; + case 'manual': + return '👆 Manuell'; + case 'interval': + return '🗓️ Zeit'; + default: + return '⚡ Echtzeit'; + } + } + + /// Radio-ähnliches Kachel-Widget für Sync-Modus-Auswahl. + Widget _modusRadioTile(String titel, String wert, String beschreibung) { + final ausgewaehlt = _syncModus == wert; + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: InkWell( + onTap: () => _syncModusSetzen(wert), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: ausgewaehlt ? MeloTheme.akzent.withAlpha(25) : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: ausgewaehlt ? MeloTheme.akzent : MeloTheme.dunkel2, + width: ausgewaehlt ? 1.5 : 1, + ), + ), + child: Row( + children: [ + Icon( + ausgewaehlt ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: ausgewaehlt ? MeloTheme.akzent : MeloTheme.textSekundaer, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + titel, + style: TextStyle( + color: ausgewaehlt ? Colors.white : MeloTheme.textSekundaer, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + Text( + beschreibung, + style: TextStyle( + color: ausgewaehlt ? Colors.white70 : MeloTheme.textSekundaer, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/services/realtime_sync_service.dart b/lib/services/realtime_sync_service.dart new file mode 100644 index 0000000..98f68bf --- /dev/null +++ b/lib/services/realtime_sync_service.dart @@ -0,0 +1,453 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../config/app_config.dart'; +import '../database/db_helper.dart'; +import '../services/auth_service.dart'; +import '../services/favoriten_service.dart'; +import '../services/sync_service.dart'; +import '../services/melo_logger.dart'; + +/// Sync-Modi für die Cloud-Synchronisation. +enum SyncModus { + echtzeit, // SSE-Push, sofort bei Änderungen + manuell, // Kein Auto-Sync, nur auf Knopfdruck + intervall, // Periodisches Polling (2T/3T/5T/1W/2W) +} + +/// Echtzeit-Cloud-Sync via SSE (Server-Sent-Events). +/// +/// Hält eine persistente HTTP-Verbindung zum Server offen und empfängt +/// Änderungen (Favoriten-Toggle, Song-Löschung, Titel-Update) in Echtzeit. +/// +/// **3 Sync-Modi** (via SharedPreferences `sync_modus`): +/// 1. ⚡ **Echtzeit** (default): SSE-Push, sofortige Verarbeitung. +/// Bei Fehlern: Reconnect-Backoff (max 3 Versuche), +/// dann Fallback auf Intervall-Modus. +/// 2. 👆 **Manuell**: Kein Auto-Sync. Nur Button „Jetzt synchronisieren". +/// 3. 🗓️ **Intervall**: Periodisches Polling mit `cloud_interval_stunden` +/// (48/72/120/168/336 Stunden = 2T/3T/5T/1W/2W). +/// +/// **Lifecycle** (via WidgetsBindingObserver in main.dart): +/// - resumed → fortsetzen() (SSE neu verbinden) +/// - paused/hidden → pausiere() (Batterie sparen) +class RealtimeSyncService { + RealtimeSyncService() + : _db = DbHelper(), + _auth = AuthService(), + _favoriten = FavoritenService(); + + final DbHelper _db; + final AuthService _auth; + final FavoritenService _favoriten; + + // ─── Verbindungs-Zustand ─── + + StreamSubscription? _sseSubscription; + HttpClientResponse? _sseResponse; + HttpClient? _httpClient; + bool _laeuft = false; + bool _pausiert = false; + + /// Aktueller SSE-Event-Typ (wird über `event:`-Zeile gesetzt). + String? _aktuellerEventTyp; + + // Reconnection-Backoff + int _reconnectVersuche = 0; + static const int _maxReconnectVersuche = 3; + static const Duration _backoffStart = Duration(seconds: 2); + + // ─── Notifier für UI ─── + + final ValueNotifier istVerbundenNotifier = ValueNotifier(false); + final ValueNotifier fehlerNotifier = ValueNotifier(null); + + // ─── Konfiguration ─── + + /// Liest den aktuellen Sync-Modus aus SharedPreferences. + static Future aktuellerModus() async { + final p = await SharedPreferences.getInstance(); + final modus = p.getString('sync_modus') ?? 'realtime'; + switch (modus) { + case 'manual': + return SyncModus.manuell; + case 'interval': + return SyncModus.intervall; + default: + return SyncModus.echtzeit; + } + } + + /// Liest das Cloud-Intervall in Stunden (für Modus 3). + /// Migriert alte `cloud_interval`-Werte (1/3/6/12h) auf + /// den neuen Default 48h (2 Tage). + static Future cloudIntervallStunden() async { + final p = await SharedPreferences.getInstance(); + // Neuer Key zuerst prüfen + final neu = p.getInt('cloud_interval_stunden'); + if (neu != null && neu > 0) return neu; + + // Migration vom alten Key + final alt = p.getInt('cloud_interval') ?? 0; + if (alt == 1 || alt == 3 || alt == 6 || alt == 12) { + // Alte Stunden-Werte → auf neuen Default migrieren + await p.setInt('cloud_interval_stunden', 48); + MeloLogger().aktion('cloud_interval_migriert', {'alt': alt, 'neu': 48}); + return 48; + } + // Default: 48h (2 Tage) + await p.setInt('cloud_interval_stunden', 48); + return 48; + } + + // ─── Öffentliche API ─── + + /// Startet den Realtime-Sync, sofern der Modus 'realtime' aktiv ist + /// und der Benutzer angemeldet ist. + Future starteWennAktiviert() async { + final modus = await aktuellerModus(); + final user = _auth.benutzer; + + if (modus != SyncModus.echtzeit || user.isEmpty) { + stoppe(); + return; + } + + // Prüfe, ob der Server SSE unterstützt (HEAD auf /subscribe). + // 404 = kein SSE-Endpoint → Fallback auf Intervall. + final kannSse = await _pruefeSseUnterstuetzung(); + if (!kannSse) { + MeloLogger().aktion('realtime_sse_nicht_verfuegbar', {}); + // Fallback: sync_modus auf interval setzen + Auto-Sync-Timer starten + final p = await SharedPreferences.getInstance(); + await p.setString('sync_modus', 'interval'); + await SyncService.starteAutoSyncTimer(); + stoppe(); + return; + } + + await _verbinde(); + } + + /// Prüft via HEAD-Request, ob der SSE-Endpoint existiert. + Future _pruefeSseUnterstuetzung() async { + try { + final token = _auth.token; + if (token == null || token.isEmpty) return false; + + final uri = Uri.parse('${AppConfig.cloudUrl}/api/v1/cloud/subscribe'); + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 5); + try { + final request = await client.openUrl('HEAD', uri); + request.headers.add('Authorization', 'Bearer $token'); + final response = await request.close(); + final ok = response.statusCode != 404; + client.close(); + return ok; + } finally { + client.close(); + } + } catch (_) { + return false; + } + } + + /// Stellt die SSE-Verbindung her und beginnt den Event-Stream. + Future _verbinde() async { + if (_laeuft || _pausiert) return; + _laeuft = true; + + try { + final token = _auth.token; + if (token == null || token.isEmpty) { + _fehlerBericht('Kein Auth-Token'); + return; + } + + _httpClient?.close(); + _httpClient = HttpClient(); + _httpClient!.connectionTimeout = const Duration(seconds: 30); + + final uri = Uri.parse('${AppConfig.cloudUrl}/api/v1/cloud/subscribe'); + final request = await _httpClient!.getUrl(uri); + request.headers.add('Authorization', 'Bearer $token'); + request.headers.add('Accept', 'text/event-stream'); + request.headers.add('Cache-Control', 'no-cache'); + + _sseResponse = await request.close(); + + if (_sseResponse!.statusCode != 200) { + _fehlerBericht('HTTP ${_sseResponse!.statusCode}'); + await _reconnecteOderFallback(); + return; + } + + _reconnectVersuche = 0; + istVerbundenNotifier.value = true; + fehlerNotifier.value = null; + + MeloLogger().aktion('realtime_verbunden', {}); + + // Stream in UTF-8-Zeilen parsen + _sseSubscription = _sseResponse! + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + verarbeiteSseZeile, + onError: (e) { + _fehlerBericht('Stream-Fehler: $e'); + _reconnecteOderFallback(); + }, + onDone: () => _reconnecteOderFallback(), + cancelOnError: false, + ); + } catch (e) { + _fehlerBericht('Verbindungsfehler: $e'); + await _reconnecteOderFallback(); + } + } + + /// SSE-Zeilen-Parser: akkumuliert `event:`- und `data:`-Zeilen + /// und dispatched bei Leerzeile. + @visibleForTesting + void verarbeiteSseZeile(String zeile) { + if (zeile.isEmpty) { + // Leerzeile = Event-Ende → dispatch + _aktuelleEventVerarbeiten(); + _aktuellerEventTyp = null; + return; + } + + if (zeile.startsWith('event: ')) { + _aktuellerEventTyp = zeile.substring(7).trim(); + } else if (zeile.startsWith('data: ')) { + final datenStr = zeile.substring(6).trim(); + if (datenStr.isEmpty) return; + try { + final daten = jsonDecode(datenStr) as Map; + _verarbeiteSseEvent(_aktuellerEventTyp, daten); + } catch (e) { + MeloLogger().fehler('realtime_parse', e); + } + } + // Andere Zeilen (id:, retry:, Kommentare) ignorieren + } + + /// Dispatch nach Leerzeile (falls unverarbeitete Daten übrig). + void _aktuelleEventVerarbeiten() { + // Keine Aktion nötig — data:-Zeilen werden sofort verarbeitet, + // da sie direkt auf eine event:-Zeile folgen. + } + + /// Verarbeitet ein einzelnes SSE-Event. + void _verarbeiteSseEvent(String? eventTyp, Map daten) { + // Heartbeat: ignorieren (hält nur die Verbindung offen) + if (eventTyp == 'heartbeat') return; + + MeloLogger().aktion('realtime_event', { + 'type': eventTyp, + 'data': daten, + }); + + switch (eventTyp) { + case 'song_favorite': + _handleFavoritEvent(daten); + break; + case 'song_delete': + _handleDeleteEvent(daten); + break; + case 'song_update': + _handleUpdateEvent(daten); + break; + case 'playlist_update': + // Ignorieren — Playlisten werden beim nächsten vollständigen + // Sync aktualisiert (zu bandwidth-intensiv für Echtzeit). + break; + } + } + + /// Song-Favorit-Event: Server hat einen Favoriten-Status geändert. + Future _handleFavoritEvent(Map daten) async { + final songId = daten['song_id'] as String?; + final isFavorite = daten['is_favorite'] as bool? ?? false; + + if (songId == null || songId.isEmpty) return; + + try { + final song = await _db.songNachCloudId(songId); + if (song == null || song.id == null) { + // Song nicht lokal → beim nächsten Sync nachziehen + return; + } + + final istLokalFavorit = await _favoriten.istFavorit(song.id!); + + // Nur toggeln, wenn der lokale Zustand vom Server abweicht + if (isFavorite != istLokalFavorit) { + await _favoriten.umschalten(song.id!); + } + + MeloLogger().aktion('realtime_favorit_angewendet', { + 'songId': songId, + 'isFavorite': isFavorite, + }); + } catch (e) { + MeloLogger().fehler('realtime_favorit_error', e); + } + } + + /// Song-Lösch-Event: Server hat einen Song gelöscht (Tombstone). + Future _handleDeleteEvent(Map daten) async { + final songId = daten['song_id'] as String?; + final deletedAt = daten['deleted_at'] as String?; + + if (songId == null || songId.isEmpty) return; + + // Tombstone-Check: nur löschen, wenn die Server-Löschung + // neuer als der letzte lokale Sync ist. + final letzterSync = await _letzterSync(); + final geloeschtAm = deletedAt != null ? DateTime.tryParse(deletedAt) : null; + // Alt-Tombstone (kein Zeitstempel) oder Erst-Sync → anwenden + if (letzterSync != null && geloeschtAm != null && !geloeschtAm.isAfter(letzterSync)) { + return; + } + + try { + final song = await _db.songNachCloudId(songId); + if (song == null || song.id == null) return; + + // Datei löschen (falls lokal vorhanden) + try { + final datei = File(song.dateiPfad); + if (song.dateiPfad.isNotEmpty && await datei.exists()) { + await datei.delete(); + } + } catch (_) { + // Datei-Fehler dürfen den Sync nicht abbrechen + } + + await _db.loeschSong(song.id!); + MeloLogger().aktion('realtime_song_geloescht', {'songId': songId}); + } catch (e) { + MeloLogger().fehler('realtime_delete_error', e); + } + } + + /// Song-Update-Event: Titel oder Künstler wurden auf dem Server geändert. + Future _handleUpdateEvent(Map daten) async { + final songId = daten['song_id'] as String?; + final title = daten['title'] as String?; + final artist = daten['artist'] as String?; + + if (songId == null || songId.isEmpty) return; + if (title == null && artist == null) return; + + try { + final song = await _db.songNachCloudId(songId); + if (song == null || song.id == null) return; + + // Metadaten lokal aktualisieren + await _db.metadatenAktualisieren( + song.id!, + titel: title, + kuenstler: artist, + ); + + MeloLogger().aktion('realtime_update', { + 'songId': songId, + 'title': title, + 'artist': artist, + }); + } catch (e) { + MeloLogger().fehler('realtime_update_error', e); + } + } + + /// Reconnect mit exponentiellem Backoff: 2s → 4s → 8s. + /// Nach [maxReconnectVersuche] Fehlversuchen: Fallback auf Intervall-Modus. + Future _reconnecteOderFallback() async { + istVerbundenNotifier.value = false; + _laeuft = false; + + _reconnectVersuche++; + + if (_reconnectVersuche <= _maxReconnectVersuche) { + final backoff = backoffBerechnen(_reconnectVersuche); + MeloLogger().aktion('realtime_reconnect', { + 'attempt': _reconnectVersuche, + 'delaySeconds': backoff.inSeconds, + }); + + await Future.delayed(backoff); + if (!_pausiert) { + await _verbinde(); + } + } else { + // Max Reconnects überschritten → Fallback auf Intervall-Modus + _fehlerBericht('Max Reconnects erreicht — Fallback auf Intervall'); + MeloLogger().aktion('realtime_fallback_to_interval', {}); + final p = await SharedPreferences.getInstance(); + await p.setString('sync_modus', 'interval'); + await SyncService.starteAutoSyncTimer(); + } + } + + /// Berechnet das Backoff-Delay: 2^attempt * 2 Sekunden. + /// Clamped auf max 8 Sekunden. Versuch <1 → 0 Sekunden (kein Shift-Fehler). + @visibleForTesting + Duration backoffBerechnen(int versuch) { + if (versuch < 1) return Duration.zero; + final sekunden = (_backoffStart.inSeconds * (1 << (versuch - 1))).clamp(0, 8); + return Duration(seconds: sekunden); + } + + /// Liest den letzten Sync-Zeitstempel aus SharedPreferences. + Future _letzterSync() async { + final p = await SharedPreferences.getInstance(); + final ts = p.getString('cloud_last_sync_ts'); + return ts != null ? DateTime.tryParse(ts) : null; + } + + void _fehlerBericht(String nachricht) { + MeloLogger().fehler('realtime_error', Exception(nachricht)); + fehlerNotifier.value = nachricht; + } + + /// Pausiert die SSE-Verbindung (App im Hintergrund → Batterie sparen). + void pausiere() { + if (_pausiert) return; + _pausiert = true; + _sseSubscription?.cancel(); + _sseSubscription = null; + _sseResponse = null; + _httpClient?.close(force: true); + _httpClient = null; + _laeuft = false; + istVerbundenNotifier.value = false; + MeloLogger().aktion('realtime_pausiert', {}); + } + + /// Setzt die SSE-Verbindung nach einer Pause fort. + Future fortsetzen() async { + if (!_pausiert) return; + _pausiert = false; + await starteWennAktiviert(); + } + + /// Stoppt die SSE-Verbindung endgültig (z. B. Logout, Modus-Wechsel). + void stoppe() { + _sseSubscription?.cancel(); + _sseSubscription = null; + _sseResponse = null; + _httpClient?.close(force: true); + _httpClient = null; + _laeuft = false; + _pausiert = false; + istVerbundenNotifier.value = false; + fehlerNotifier.value = null; + MeloLogger().aktion('realtime_gestoppt', {}); + } +} diff --git a/test/services/realtime_sync_service_test.dart b/test/services/realtime_sync_service_test.dart new file mode 100644 index 0000000..d3d1b91 --- /dev/null +++ b/test/services/realtime_sync_service_test.dart @@ -0,0 +1,283 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo_app/services/realtime_sync_service.dart'; +import 'package:melo_app/services/sync_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// RealtimeSyncService-Tests (v2.55 – Echtzeit-Cloud-Sync). +/// +/// Abgedeckt: +/// - Backoff-Berechnung (exponentiell, clamped auf max 8s) +/// - SSE-Event-Parsing (heartbeat ignoriert, song_favorite verarbeitet) +/// - Tombstone-Respektierung bei Song-Löschung +/// - Leere song_id wird ignoriert +/// - Sync-Modus-Prüfung (manual/interval/realtime Enum-Werte) +/// +/// KEINE neuen Packages — reine Unit-Tests mit einfachen Hand-Mocks. +/// Verwendet nur flutter_test + bestehende Projekt-Abhängigkeiten. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + // ── Gruppe 1: Backoff-Berechnung ── + + group('Backoff-Berechnung (exponentiell)', () { + test('Versuch 1 → 2 Sekunden', () { + final service = RealtimeSyncService(); + final backoff = service.backoffBerechnen(1); + expect(backoff.inSeconds, 2); + }); + + test('Versuch 2 → 4 Sekunden', () { + final service = RealtimeSyncService(); + final backoff = service.backoffBerechnen(2); + expect(backoff.inSeconds, 4); + }); + + test('Versuch 3 → 8 Sekunden', () { + final service = RealtimeSyncService(); + final backoff = service.backoffBerechnen(3); + expect(backoff.inSeconds, 8); + }); + + test('Versuch 4+ → clamped auf max 8 Sekunden', () { + final service = RealtimeSyncService(); + expect(service.backoffBerechnen(4).inSeconds, 8); + expect(service.backoffBerechnen(5).inSeconds, 8); + expect(service.backoffBerechnen(10).inSeconds, 8); + }); + + test('Versuch 0 → 0 Sekunden (Clamp)', () { + final service = RealtimeSyncService(); + final backoff = service.backoffBerechnen(0); + expect(backoff.inSeconds, 0); + }); + }); + + // ── Gruppe 2: SSE-Event-Parsing ── + + group('SSE-Event-Parsing', () { + test('Heartbeat wird ignoriert (event: heartbeat)', () { + final service = RealtimeSyncService(); + // Heartbeat-Event → sollte keinen Fehler werfen + service.verarbeiteSseZeile('event: heartbeat'); + service.verarbeiteSseZeile('data: {}'); + // Kein Fehler → Test bestanden + expect(service.fehlerNotifier.value, isNull); + }); + + test('Heartbeat data:-Zeile wird still verarbeitet', () { + final service = RealtimeSyncService(); + // Nach einem Heartbeat-Event darf keine Exception fliegen + service.verarbeiteSseZeile('event: heartbeat'); + expect(() => service.verarbeiteSseZeile('data: {}'), returnsNormally); + }); + + test('song_favorite Event wird erkannt (kein Parse-Fehler)', () { + final service = RealtimeSyncService(); + // Gültiges song_favorite-Event — darf keinen Parse-Fehler werfen + service.verarbeiteSseZeile('event: song_favorite'); + expect( + () => service.verarbeiteSseZeile( + 'data: {"song_id":"abc123","is_favorite":true}'), + returnsNormally, + ); + }); + + test('song_delete mit leerer song_id verarbeitet still', () { + final service = RealtimeSyncService(); + // Leere song_id → Event wird ignoriert (kein Crash) + service.verarbeiteSseZeile('event: song_delete'); + expect( + () => service.verarbeiteSseZeile( + 'data: {"song_id":"","deleted_at":"2026-08-01T12:00:00"}'), + returnsNormally, + ); + }); + + test('song_update Event wird erkannt (kein Parse-Fehler)', () { + final service = RealtimeSyncService(); + service.verarbeiteSseZeile('event: song_update'); + expect( + () => service.verarbeiteSseZeile( + 'data: {"song_id":"abc123","title":"Neuer Titel","artist":"Künstler"}'), + returnsNormally, + ); + }); + + test('playlist_update wird ignoriert (kein Fehler)', () { + final service = RealtimeSyncService(); + service.verarbeiteSseZeile('event: playlist_update'); + expect( + () => service.verarbeiteSseZeile( + 'data: {"playlist_id":42}'), + returnsNormally, + ); + }); + }); + + // ── Gruppe 3: Tombstone-Respektierung ── + + group('Tombstone-Respektierung bei Song-Löschung', () { + final letzterSync = DateTime(2026, 8, 4, 12, 0, 0); + + test('deletedAt neuer als letzterSync → anwenden', () { + expect( + SyncService.tombstoneAnwenden( + letzterSync: letzterSync, + deletedAt: '2026-08-05T10:00:00'), + isTrue, + ); + }); + + test('deletedAt älter als letzterSync → NICHT anwenden', () { + expect( + SyncService.tombstoneAnwenden( + letzterSync: letzterSync, + deletedAt: '2026-08-03T10:00:00'), + isFalse, + ); + }); + + test('deletedAt null (Alt-Tombstone ohne Zeitstempel) → anwenden', () { + expect( + SyncService.tombstoneAnwenden( + letzterSync: letzterSync, deletedAt: null), + isTrue, + ); + }); + + test('letzterSync null (Erst-Sync) → anwenden', () { + expect( + SyncService.tombstoneAnwenden( + letzterSync: null, deletedAt: '2026-08-05T10:00:00'), + isTrue, + ); + }); + + test('deletedAt == letzterSync (Gleichstand) → NICHT anwenden', () { + expect( + SyncService.tombstoneAnwenden( + letzterSync: letzterSync, + deletedAt: '2026-08-04T12:00:00'), + isFalse, + ); + }); + + test('ungültiger deletedAt-String → anwenden (sicherer Default)', () { + expect( + SyncService.tombstoneAnwenden( + letzterSync: letzterSync, deletedAt: 'kein-iso-timestamp'), + isTrue, + ); + }); + }); + + // ── Gruppe 4: Leere song_id ── + + group('Leere song_id wird ignoriert', () { + test('song_favorite mit leerer song_id → kein Fehler', () { + final service = RealtimeSyncService(); + service.verarbeiteSseZeile('event: song_favorite'); + expect( + () => service.verarbeiteSseZeile( + 'data: {"song_id":"","is_favorite":true}'), + returnsNormally, + ); + }); + + test('song_delete mit null song_id → kein Fehler', () { + final service = RealtimeSyncService(); + service.verarbeiteSseZeile('event: song_delete'); + expect( + () => service.verarbeiteSseZeile('data: {"deleted_at":"2026-08-01T12:00:00"}'), + returnsNormally, + ); + }); + + test('song_update ohne song_id → kein Fehler', () { + final service = RealtimeSyncService(); + service.verarbeiteSseZeile('event: song_update'); + expect( + () => service.verarbeiteSseZeile( + 'data: {"title":"Nur Titel"}'), + returnsNormally, + ); + }); + + test('Ungültiges JSON in data:-Zeile → kein Crash', () { + final service = RealtimeSyncService(); + expect( + () => service.verarbeiteSseZeile('data: kein-json'), + returnsNormally, + ); + }); + + test('Leere data:-Zeile → kein Fehler', () { + final service = RealtimeSyncService(); + expect( + () => service.verarbeiteSseZeile('data: '), + returnsNormally, + ); + }); + }); + + // ── Gruppe 5: Sync-Modus-Prüfung ── + + group('Sync-Modus (Enum + Werte)', () { + test('SyncModus hat genau 3 Werte', () { + const alle = SyncModus.values; + expect(alle.length, 3); + }); + + test('SyncModus-Werte sind eindeutig', () { + const alle = SyncModus.values; + expect(alle.toSet().length, 3); + }); + + test('aktuellerModus() liefert immer einen gültigen Modus', () async { + final modus = await RealtimeSyncService.aktuellerModus(); + // Default ist 'realtime' (SharedPreferences ohne Mock = leer) + expect(modus, SyncModus.echtzeit); + }); + + test('cloudIntervallStunden() liefert Default 48h', () async { + final intervall = await RealtimeSyncService.cloudIntervallStunden(); + // Ohne gespeicherte Werte = Default 48 (SharedPreferences leer) + expect(intervall, greaterThanOrEqualTo(48)); + }); + + test('verschiedene Modi sind unterscheidbar', () { + expect(SyncModus.manuell, isNot(SyncModus.echtzeit)); + expect(SyncModus.manuell, isNot(SyncModus.intervall)); + expect(SyncModus.echtzeit, isNot(SyncModus.intervall)); + }); + }); + + // ── Gruppe 6: Notifier-Verhalten ── + + group('Notifier-Verhalten', () { + test('istVerbundenNotifier startet mit false', () { + final service = RealtimeSyncService(); + expect(service.istVerbundenNotifier.value, false); + }); + + test('fehlerNotifier startet mit null', () { + final service = RealtimeSyncService(); + expect(service.fehlerNotifier.value, isNull); + }); + + test('stoppe() setzt istVerbunden auf false', () { + final service = RealtimeSyncService(); + service.istVerbundenNotifier.value = true; + service.stoppe(); + expect(service.istVerbundenNotifier.value, false); + }); + + test('pausiere() setzt istVerbunden auf false', () { + final service = RealtimeSyncService(); + service.istVerbundenNotifier.value = true; + service.pausiere(); + expect(service.istVerbundenNotifier.value, false); + }); + }); +}