diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 258903a..368050e 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -148,6 +148,22 @@ class UploadErgebnis { } } +/// Was seit dem letzten erfolgreichen Abgleich passiert ist — der Inhalt des +/// „Willkommen zurück"-Dialogs. +class SyncBericht { + const SyncBericht({ + required this.neueSongs, + required this.geloeschte, + required this.favoriten, + }); + + final int neueSongs; + final int geloeschte; + final int favoriten; + + bool get istLeer => neueSongs == 0 && geloeschte == 0 && favoriten == 0; +} + /// Hält Gerät und Server auf demselben Stand: lädt neue Server-Titel herunter, /// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und /// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe. @@ -160,6 +176,7 @@ class SyncService extends ChangeNotifier { }) : _musikOrdner = musikOrdner ?? _standardMusikOrdner; static const _letzterLaufKey = 'cloud_sync_letzter_lauf'; + static const _letzterErfolgKey = 'cloud_sync_letzter_erfolg'; static const _verlaufStandKey = 'cloud_sync_verlauf_stand'; static const _uuid = Uuid(); @@ -174,6 +191,8 @@ class SyncService extends ChangeNotifier { String? _fehler; String? _status; DateTime? _letzterLauf; + DateTime? _letzterErfolg; + SyncBericht? _bericht; bool _abbruchGewuenscht = false; bool get laeuft => _laeuft; @@ -183,10 +202,23 @@ class SyncService extends ChangeNotifier { String? get status => _status; DateTime? get letzterLauf => _letzterLauf; + /// Der fällige Bericht, oder `null`. Wird von der Oberfläche genau einmal + /// abgeholt und dann mit [berichtGesehen] quittiert. + SyncBericht? get bericht => _bericht; + + void berichtGesehen() { + _bericht = null; + notifyListeners(); + } + Future laden() async { final prefs = await SharedPreferences.getInstance(); final ms = prefs.getInt(_letzterLaufKey); if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms); + final erfolg = prefs.getInt(_letzterErfolgKey); + if (erfolg != null) { + _letzterErfolg = DateTime.fromMillisecondsSinceEpoch(erfolg); + } notifyListeners(); } @@ -214,6 +246,14 @@ class SyncService extends ChangeNotifier { _gesamt = 0; _melde('Vergleiche mit dem Server …'); + // Der Zeitstempel ist der Stand VOR dem Listen: was während des Laufs am + // Server passiert, muss beim nächsten Mal noch drankommen (v2-Lektion, + // Tombstone-Race). + final laufBeginn = DateTime.now(); + var vollstaendig = true; + var neueSongs = 0; + var geloeschte = 0; + try { final amServer = await cloud.liste(); final plan = planeSync( @@ -233,18 +273,38 @@ class SyncService extends ChangeNotifier { '$bestand Titeln würden am Server gelöscht. Das sieht nach einem ' 'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde ' 'nichts gelöscht.'; + vollstaendig = false; } + // Jede Phase für sich: fällt eine aus, laufen die übrigen weiter, und + // der Erfolgs-Zeitstempel bleibt stehen. + geloeschte = plan.lokalLoeschen.length; await _ziehLoeschungenNach(plan.lokalLoeschen); - if (!bremse) await _meldeLoeschungen(plan.serverLoeschen); - await _ladeHerunter(plan.herunterladen); - await _ladeHoch(plan.hochladen); - await _gleicheFavoritenAb(); + if (!bremse && !await _meldeLoeschungen(plan.serverLoeschen)) { + vollstaendig = false; + } + neueSongs = await _ladeHerunter(plan.herunterladen); + if (!await _ladeHoch(plan.hochladen)) vollstaendig = false; + if (!await _gleicheFavoritenAb()) vollstaendig = false; await _meldeVerlauf(); - _letzterLauf = DateTime.now(); + _letzterLauf = laufBeginn; final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_letzterLaufKey, _letzterLauf!.millisecondsSinceEpoch); + await prefs.setInt(_letzterLaufKey, laufBeginn.millisecondsSinceEpoch); + + if (vollstaendig) { + // „Erfolgreich" heißt: keine Phase hat einen Fehler geschluckt. + if (berichtFaellig(_letzterErfolg, laufBeginn)) { + _bericht = SyncBericht( + neueSongs: neueSongs, + geloeschte: geloeschte, + favoriten: 0, + ); + } + _letzterErfolg = laufBeginn; + await prefs.setInt( + _letzterErfolgKey, laufBeginn.millisecondsSinceEpoch); + } } on CloudException catch (e) { _fehler = e.message; } catch (e) { @@ -363,7 +423,13 @@ class SyncService extends ChangeNotifier { notifyListeners(); } - Future _meldeLoeschungen(List songs) async { + /// Meldet die hier getombsteten Titel am Server. + /// + /// Gibt `false` zurück, sobald eine Meldung geschluckt wurde: der Lauf geht + /// weiter, gilt aber nicht mehr als erfolgreich — sonst rückte der + /// 24-h-Zeitstempel des Berichts vor, obwohl eine Phase ausgefallen ist. + Future _meldeLoeschungen(List songs) async { + var vollstaendig = true; for (final song in songs) { _melde('Melde Löschung von „${song.title}“ …'); try { @@ -371,14 +437,19 @@ class SyncService extends ChangeNotifier { } on CloudException catch (e) { // Eine abgelehnte Löschung darf den Lauf nicht beenden. debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}'); + vollstaendig = false; } _erledigt++; notifyListeners(); } + return vollstaendig; } - Future _ladeHerunter(List songs) async { - if (songs.isEmpty) return; + /// Gibt zurück, wie viele Titel wirklich neu dazugekommen sind — die Zahl + /// im „Was ist neu"-Bericht. + Future _ladeHerunter(List songs) async { + if (songs.isEmpty) return 0; + var neu = 0; final ordner = await _musikOrdner(); for (final cloudSong in songs) { _melde('Lade „${cloudSong.titel}“ …'); @@ -418,12 +489,21 @@ class SyncService extends ChangeNotifier { cloudId: Value(cloudSong.id), ), ]); + neu++; _erledigt++; notifyListeners(); } + return neu; } - Future _ladeHoch(List songs) async { + /// Lädt alle Titel ohne cloudId hoch. + /// + /// Gibt `false` zurück, sobald ein Upload an einem Fehler oder einer + /// Zeitüberschreitung hängenblieb. Eine **fehlende Datei** zählt bewusst + /// nicht dazu: dort ist nichts schiefgegangen, der Titel ist weg und der + /// nächste Scan tombstoned ihn. + Future _ladeHoch(List songs) async { + var vollstaendig = true; for (final song in songs) { final datei = File(song.path); if (!await datei.exists()) { @@ -436,19 +516,27 @@ class SyncService extends ChangeNotifier { datei, dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}', ); - if (cloudId != null) await db.setCloudId(song.id, cloudId); + if (cloudId != null) { + await db.setCloudId(song.id, cloudId); + } else { + // 200 ohne Server-ID: der Titel ist oben nicht angekommen. + vollstaendig = false; + } } on CloudException catch (e) { // Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden. debugPrint('Upload „${song.title}“ übersprungen: ${e.message}'); + vollstaendig = false; } on TimeoutException { // Der 120-s-Timeout (melo_cloud_service.dart:180) wirft // TimeoutException, nicht CloudException — ohne diesen Zweig riss ein // einziger hängender Upload den ganzen Lauf ab. debugPrint('Upload „${song.title}“: Zeitüberschreitung'); + vollstaendig = false; } _erledigt++; notifyListeners(); } + return vollstaendig; } /// Additiver Favoriten-Abgleich: gleicht in **beide** Richtungen an, diff --git a/test/services/sync_service_test.dart b/test/services/sync_service_test.dart index 85d6838..728c8b6 100644 --- a/test/services/sync_service_test.dart +++ b/test/services/sync_service_test.dart @@ -634,4 +634,193 @@ void main() { expect(sync.fehler, contains('Abgleich')); }); }); + + group('Sync-Bericht', () { + test('beim allerersten Lauf gibt es keinen Bericht', () async { + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/list')) { + return http.Response(jsonEncode({'songs': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + // letzterErfolg war null: „Willkommen zurück!" auf einem frisch + // eingerichteten Gerät wäre Unsinn. + expect(sync.bericht, isNull); + }); + + test('nach mehr als 24 Stunden kommt der Bericht mit Zählern', () async { + final vorgestern = DateTime.now().subtract(const Duration(days: 2)); + SharedPreferences.setMockInitialValues({ + 'cloud_sync_letzter_erfolg': vorgestern.millisecondsSinceEpoch, + }); + + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c1', 'title': 'Neu', 'artist': 'X', 'duration': 100} + ] + }), + 200, + ); + } + if (pfad.contains('/download/')) { + return http.Response.bytes([1], 200, + headers: {'content-type': 'audio/mpeg'}); + } + if (pfad.endsWith('/favorites')) { + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + await sync.laden(); + + await sync.synchronisiere(); + + expect(sync.bericht, isNotNull); + expect(sync.bericht!.neueSongs, 1); + sync.berichtGesehen(); + expect(sync.bericht, isNull); + }); + + test('eine ausgefallene Phase verschiebt den Erfolgs-Zeitstempel nicht', + () async { + SharedPreferences.setMockInitialValues({}); + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response(jsonEncode({'songs': []}), 200); + } + if (pfad.endsWith('/favorites')) { + // Fehler im 200er-Körper: die Favoriten-Phase fällt aus. + return http.Response( + jsonEncode({'status': 'error', 'error': 'kaputt'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull); + // Die Drossel läuft trotzdem weiter — sonst rennt der Sync bei jedem + // Tab-Wechsel neu los. + expect(sync.letzterLauf, isNotNull); + }); + + test('eine gescheiterte Löschmeldung verschiebt den Erfolgs-Zeitstempel ' + 'nicht', () async { + SharedPreferences.setMockInitialValues({}); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: '${tempDir.path}/weg.mp3', + title: 'Weg', + dateAddedMs: 0, + updatedAtMs: 0, + deleted: const Value(true), + ), + ]); + await db.setCloudId('lokal-1', 'c5'); + + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c5', 'title': 'Weg'} + ] + }), + 200, + ); + } + if (pfad.endsWith('/delete')) { + // _meldeLoeschungen schluckt die CloudException — ohne Rückgabe + // bis zum Flag hätte der Lauf trotzdem als erfolgreich gegolten. + return http.Response(jsonEncode({'error': 'kaputt'}), 500); + } + if (pfad.endsWith('/favorites')) { + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull); + expect(sync.letzterLauf, isNotNull); + }); + + test('ein gescheiterter Upload verschiebt den Erfolgs-Zeitstempel nicht', + () async { + SharedPreferences.setMockInitialValues({}); + final datei = File('${tempDir.path}/zu-gross.mp3'); + await datei.writeAsBytes([1]); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: datei.path, + title: 'Zu groß', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response(jsonEncode({'songs': []}), 200); + } + if (pfad.endsWith('/upload')) { + return http.Response( + jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200); + } + if (pfad.endsWith('/favorites')) { + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull); + }); + + test('der Zeitstempel ist der Stand VOR dem Listen', () async { + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/list')) { + // Während des Laufs vergeht Zeit — der Zeitstempel darf nicht + // danach genommen werden, sonst fallen zwischenzeitliche + // Änderungen durchs Raster (Tombstone-Race, v2-Lektion). + await Future.delayed(const Duration(milliseconds: 50)); + return http.Response(jsonEncode({'songs': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + final vorher = DateTime.now(); + await sync.synchronisiere(); + final nachher = DateTime.now(); + + expect(sync.letzterLauf!.isBefore(nachher), isTrue); + expect( + sync.letzterLauf! + .isAfter(vorher.subtract(const Duration(milliseconds: 1))), + isTrue, + ); + expect( + nachher.difference(sync.letzterLauf!) >= + const Duration(milliseconds: 50), + isTrue, + ); + }); + }); }