Erfolgs-Flag, Zeitstempel-Snapshot und Zaehler fuer den Sync-Bericht
This commit is contained in:
@@ -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<void> 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<void> _meldeLoeschungen(List<Song> 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<bool> _meldeLoeschungen(List<Song> 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<void> _ladeHerunter(List<CloudSong> songs) async {
|
||||
if (songs.isEmpty) return;
|
||||
/// Gibt zurück, wie viele Titel wirklich neu dazugekommen sind — die Zahl
|
||||
/// im „Was ist neu"-Bericht.
|
||||
Future<int> _ladeHerunter(List<CloudSong> 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<void> _ladeHoch(List<Song> 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<bool> _ladeHoch(List<Song> 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,
|
||||
|
||||
@@ -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<void>.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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user