This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/services/sync_service.dart
T
Dustin 85615d253c v2.55.3 — Code-Review-Fixes: foregroundServiceType (Android 14+), POST_NOTIFICATIONS-Check, FG-Service-Notification-Channel, try/catch _zeigeSyncNotification
CRITICAL:
- AndroidManifest: foregroundServiceType="dataSync" für BackgroundService (Android 14+ MissingForegroundServiceTypeException)
- main.dart: notificationChannelId + initialNotificationTitle + foregroundServiceTypes für FlutterBackgroundService (fehlende FG-Notification → ANR/crash nach 5s)

MEDIUM:
- sync_service.dart: POST_NOTIFICATIONS-Permission-Check vor _starteForegroundService (Android 13+)
- sync_service.dart: try/catch um _zeigeSyncNotification (plugin-dispose-Sicherheit)

flutter analyze: clean | flutter test: 194/194 passed
2026-08-06 18:17:25 +02:00

681 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import '../database/db_helper.dart';
import '../models/song.dart';
import '../utils/sanitize.dart';
import 'cloud_service.dart';
import 'favoriten_service.dart';
import 'favoriten_sync.dart';
import 'melo_logger.dart';
import '../main.dart'; // notificationsPlugin
/// Ergebnis des Konflikt-Dialogs (UI-Callback aus dem Cloud-Tab):
/// [wahl] = 'lokal' | 'server' | 'beide' | 'ueberspringen' | null (Abbruch).
/// [fuerAlle] = Checkbox „Für alle weiteren Konflikte übernehmen“.
class KonfliktErgebnis {
final String? wahl;
final bool fuerAlle;
const KonfliktErgebnis(this.wahl, {this.fuerAlle = false});
}
/// Zusammenfassung der Änderungen eines Sync-Laufs (Konflikt-Report).
///
/// Der SyncService sammelt die Zähler + Detail-Zeilen; der CloudScreen zeigt
/// sie nach dem Sync als Dialog („Willkommen zurück! …“ mit [Übernehmen] /
/// [Details]) an — v.a. wenn der letzte Sync länger als 24h zurückliegt.
class SyncBericht {
final int neueSongs; // neu heruntergeladene Server-Songs
final int geloeschteSongs; // lokal nachgezogene Server-Löschungen (Tombstones)
final int favoritenGeaendert; // Merge-Differenz lokal ↔ Server
/// Menschlich lesbare Änderungszeilen („+ Neuer Song: …“, „− Gelöscht: …“).
final List<String> details;
/// Letzter Sync-ZEITPUNKT VOR diesem Lauf (Snapshot vor listSongs,
/// MED-1) — Basis für den 24h-Check des „Willkommen zurück!“-Dialogs
/// (LOW-1). null = noch nie gesynct (Erst-Sync) → Dialog zeigen.
final DateTime? letzterSyncVorher;
const SyncBericht({
required this.neueSongs,
required this.geloeschteSongs,
required this.favoritenGeaendert,
this.details = const [],
this.letzterSyncVorher,
});
bool get hatAenderungen =>
neueSongs > 0 || geloeschteSongs > 0 || favoritenGeaendert > 0;
/// Kurzfassung: „+8 neue Songs · 3 gelöscht · ⭐5 Favoriten geändert“.
String get zusammenfassung {
final teile = <String>[
if (neueSongs > 0) '+$neueSongs neue Songs',
if (geloeschteSongs > 0) '$geloeschteSongs gelöscht',
if (favoritenGeaendert > 0) '⭐$favoritenGeaendert Favoriten geändert',
];
return teile.join(' · ');
}
/// LOW-1: „Willkommen zurück!“-Dialog nur nach längerer Pause zeigen —
/// letzter Sync mehr als 24h zurück (oder noch nie gesynct). [jetzt]
/// injizierbar für Tests.
bool nachLangerPause({DateTime? jetzt}) {
final vorher = letzterSyncVorher;
if (vorher == null) return true;
final j = jetzt ?? DateTime.now();
return j.difference(vorher) > const Duration(hours: 24);
}
}
/// Zentraler Cloud-Sync (Sprint D, v2.52.2).
///
/// Der komplette Sync-Loop (Songs → Favoriten-Merge → Playlisten → Metadaten)
/// ist hier herausgelöst aus dem CloudScreen, damit er auch OHNE geöffneten
/// Cloud-Tab laufen kann (Auto-Sync-Timer, app-weit) und nach
/// „Im Hintergrund fortsetzen“ sichtbar bleibt:
///
/// - **Persistente Sync-Notification** („Synchronisiere… 12/25“) mit Fortschritt
/// - **Abschluss-Notification** („Sync fertig: X Songs, Y Favoriten“, tippbar)
/// - **☁️-Musikserver-Chip pulsiert** während des Syncs ([laeuftNotifier])
/// - **Echter Doppel-Sync-Guard** ([laeuftGlobal]) über alle Instanzen hinweg
/// - **Auto-Sync-Intervall** Aus/1h/3h/6h/12h ([starteAutoSyncTimer]) —
/// KEIN Sync beim App-Start, nur wenn der letzte Sync älter als das
/// Intervall ist ([istSyncFaellig]).
///
/// UI-Feedback läuft über Callbacks ([onFortschritt], [onStatus], …) — ohne
/// UI (Hintergrund/Auto-Sync) sind sie No-ops, die Notifications übernehmen.
class SyncService {
SyncService(this.cloud)
: _db = DbHelper(),
_favoriten = FavoritenService();
final CloudService cloud;
final DbHelper _db;
final FavoritenService _favoriten;
// ─── Globale Laufzeit-Signale ───
/// Echter Doppel-Sync-Guard: bleibt true, solange IRGENDEIN Sync-Loop läuft
/// (auch im Hintergrund nach „Im Hintergrund fortsetzen“ oder via
/// Auto-Sync-Timer). Statisch → alle SyncService-Instanzen teilen ihn.
static bool _laeuftGlobal = false;
static bool get laeuftGlobal => _laeuftGlobal;
/// UI-Signal für den ☁️-Musikserver-Chip (pulsiert während des Syncs).
/// ValueNotifier, damit der Home-Screen (ListenableBuilder) mitrebuildet.
static final ValueNotifier<bool> laeuftNotifier = ValueNotifier<bool>(false);
/// Wird inkrementiert, wenn die Sync-Abschluss-/Fehler-Notification getippt
/// wird — MeloHome hört darauf und öffnet den Cloud-Tab (main.dart
/// verdrahtet den Tipp via `onDidReceiveNotificationResponse`).
static final ValueNotifier<int> syncBenachrichtigungGetippt =
ValueNotifier<int>(0);
// ─── Auto-Sync-Timer (app-weit, F3) ───
static Timer? _autoSyncTimer;
/// Startet den Auto-Sync-Timer neu anhand der gespeicherten Einstellung
/// `cloud_interval` (0 = Aus). Erster Tick erst NACH dem Intervall —
/// kein Sync beim App-Start. Wird beim App-Start und bei jeder
/// Intervall-Änderung aufgerufen.
static Future<void> starteAutoSyncTimer() async {
_autoSyncTimer?.cancel();
_autoSyncTimer = null;
final p = await SharedPreferences.getInstance();
final intervall = p.getInt('cloud_interval') ?? 0;
if (intervall <= 0) return;
_autoSyncTimer = Timer.periodic(Duration(hours: intervall), (_) {
final service = SyncService(CloudService());
service._autoSyncTick(intervall);
});
}
/// F3c: Auto-Sync nur wenn der letzte Sync älter als das Intervall ist.
/// [letzterSync] = null (nie gesynct) → fällig.
static bool istSyncFaellig(DateTime? letzterSync, int intervallStunden,
{DateTime? jetzt}) {
if (intervallStunden <= 0) return false;
final j = jetzt ?? DateTime.now();
final l = letzterSync;
if (l == null) return true;
return j.difference(l).inHours >= intervallStunden;
}
Future<void> _autoSyncTick(int intervallStunden) async {
if (_laeuftGlobal) return;
final p = await SharedPreferences.getInstance();
final lastTs = p.getString('cloud_last_sync_ts');
final letzter = lastTs != null ? DateTime.tryParse(lastTs) : null;
if (!istSyncFaellig(letzter, intervallStunden)) return;
MeloLogger().aktion('auto_sync_timer', {'intervall': intervallStunden});
await syncAlles(automatisch: true);
}
/// Tombstone-Entscheidung (Sprint E): Soll eine serverseitige Löschung
/// ([deletedAt] = ISO-Zeitstempel) lokal angewendet werden?
///
/// Ja, wenn … kein letzter Sync bekannt ist (Erst-Sync), die Server-
/// Löschung keinen Zeitstempel hat (Alt-Tombstone) oder die Löschung
/// NEUER als der letzte Sync ist. Reine, statische Funktion → testbar.
@visibleForTesting
static bool tombstoneAnwenden({DateTime? letzterSync, String? deletedAt}) {
final d = deletedAt != null ? DateTime.tryParse(deletedAt) : null;
return letzterSync == null || d == null || d.isAfter(letzterSync);
}
/// Baut den lokalen [Song]-DB-Eintrag aus einem Server-Listeneintrag
/// (handle_list-Felder: id/title/artist/duration/size/istKorrupt).
///
/// Grundlage des MED-2-Fixes: Neue Server-Songs werden nach dem Download
/// MIT cloud_id + Titeldaten in die lokale DB eingefügt — vorher fehlten
/// sie in der Bibliothek und wurden bei jedem Sync erneut heruntergeladen.
/// `ytUrl` wird bewusst NICHT übernommen (v2.52-F2: Der Server ist die
/// Quelle für YT-Links, lokal wird yt_url nicht mehr persistiert).
/// Reine, statische Funktion → testbar.
@visibleForTesting
static Song songAusServerMap(Map song, String destPath) {
final dauerRaw = song['duration'];
final dauer = dauerRaw is num
? dauerRaw.toInt()
: int.tryParse(dauerRaw?.toString() ?? '') ?? 0;
final groesseRaw = song['size'];
final groesse = groesseRaw is num
? groesseRaw.toInt()
: int.tryParse(groesseRaw?.toString() ?? '') ?? 0;
return Song(
titel: (song['title'] ?? 'unknown').toString(),
kuenstler: (song['artist']?.toString() ?? '').trim(),
dauerSekunden: dauer,
groesseBytes: groesse,
dateiPfad: destPath,
downloadQuelle: 'cloud',
istHeruntergeladen: true,
istKorrupt: song['istKorrupt'] == true,
cloudId: song['id']?.toString(),
);
}
/// Lokale Löschung eines Cloud-Songs an den Server melden (Tombstone).
/// Der Server speichert `deleted=1` + `deleted_at` → andere Geräte löschen
/// den Song nach ihrem nächsten Sync ebenfalls lokal (keine Zombie-Songs).
/// Aufrufen, sobald ein Song mit cloud_id lokal gelöscht wird.
Future<bool> meldeLoeschung(String cloudId) async {
return cloud.delete(cloudId, deletedAt: DateTime.now().toIso8601String());
}
// ─── UI-Callbacks (CloudScreen verdrahtet sie; ohne UI = No-op) ───
/// Phase + Fortschritt (0.01.0) für die Fortschritts-Ansicht im Cloud-Tab.
void Function(String phase, double progress)? onFortschritt;
/// „12/25“-Zähler (aktuelle Position / Gesamtanzahl).
void Function(int aktuell, int gesamt)? onFortschrittZaehler;
/// Transiente Status-Meldung (grün/rot).
void Function(String msg, {bool ok})? onStatus;
/// Sync-Historie-Eintrag (Dateien, Favoriten, Playlisten).
void Function(int dateien, int favoriten, int playlists)? onHistorie;
/// Konflikt-Dialog (nur bei manuellem Sync — Auto-Sync gewinnt still).
Future<KonfliktErgebnis?> Function(Song lokal, Map serverSong)? onKonflikt;
/// Server-Favoriten-Anzahl nach dem Merge.
void Function(int anzahl)? onFavoritenAnzahl;
/// Playlisten vom Server neu laden (für Sync-Phase 3 + Historie).
/// Rückgabe: Anzahl der geladenen Playlisten.
Future<int> Function()? onPlaylistenLaden;
/// Nach erfolgreichem Sync: Status/Playlisten/Favoriten/letzten Sync neu laden.
Future<void> Function()? onNachSync;
/// Konflikt-Report: wird nach dem Sync aufgerufen, wenn es Änderungen gab
/// (neue/gelöschte Songs, Favoriten-Merge). UI zeigt den Zusammenfassungs-
/// Dialog mit [Übernehmen]/[Details].
void Function(SyncBericht bericht)? onBericht;
/// Immer am Ende (auch bei Fehler): Animation stoppen, Sync-Ansicht schließen.
void Function()? onSyncEnde;
// ─── Notifications ───
static const int _syncNotifyId = 300;
static const String _syncChannelId = 'de.baka.melo.sync';
/// Komplett-Sync. [automatisch]=true (Auto-Sync-Timer): Konflikte werden
/// NICHT interaktiv gelöst — die Server-Metadaten gewinnen still.
/// Rückgabe: true bei Erfolg, false bei Fehler oder wenn bereits ein
/// Sync läuft (globaler Guard).
///
/// v2.55.2: Startet den Foreground-Service (FlutterBackgroundService),
/// damit der Sync auch bei Display-aus und minimierter App weiterläuft.
Future<bool> syncAlles({bool automatisch = false}) async {
if (_laeuftGlobal) return false;
_laeuftGlobal = true;
laeuftNotifier.value = true;
String? konfliktBatch;
// v2.55.2: Foreground-Service starten — hält den Prozess im Hintergrund
// aktiv, auch wenn das Display aus ist oder die App minimiert wird.
await _starteForegroundService();
try {
_zeigeSyncNotification('Verbinde…', 0, 0);
onFortschritt?.call('Verbinde...', 0);
onFortschrittZaehler?.call(0, 0);
// ── Phase 1: Songs (inkl. Tombstone-Sync, Sprint E) ──
onFortschritt?.call('Lade Songs…', 0.1);
// MED-1 (Tombstone-Race): letzterSync-Snapshot VOR listSongs lesen.
// Der persistierte „letzter Sync“ darf nie später liegen als die
// letzte Beobachtung der Server-Liste — sonst gilt eine Löschung, die
// zwischen listSongs und Sync-Ende auf dem Server ankommt, im nächsten
// Sync als „bereits gesehen“ (deletedAt < letzterSync) und wird
// dauerhaft übersprungen (Zombie-Song). Der Snapshot (Sync-BEGINN,
// nicht now-am-Ende) wird am Ende persistiert.
final prefs = await SharedPreferences.getInstance();
final letzterSyncRaw = prefs.getString('cloud_last_sync_ts');
final letzterSync =
letzterSyncRaw != null ? DateTime.tryParse(letzterSyncRaw) : null;
final syncSnapshot = DateTime.now();
final serverSongs = await cloud.listSongs();
final dir =
Directory('${(await getApplicationDocumentsDirectory()).path}/music');
if (!await dir.exists()) await dir.create(recursive: true);
// ── Tombstones zuerst: serverseitig gelöschte Songs lokal nachziehen ──
// (keine Zombie-Songs: Offline-Geräte löschen nach dem Sync ebenfalls)
final tombstones =
serverSongs.where((s) => s['deleted'] == true).toList();
final aktiveSongs =
serverSongs.where((s) => s['deleted'] != true).toList();
var geloeschteSongs = 0;
final loeschDetails = <String>[];
for (final ts in tombstones) {
final sid = ts['id']?.toString() ?? '';
if (sid.isEmpty) continue;
if (!tombstoneAnwenden(
letzterSync: letzterSync,
deletedAt: ts['deletedAt']?.toString())) {
continue;
}
final lokal = await _db.songNachCloudId(sid);
if (lokal == null || lokal.id == null) continue;
// Datei entfernen (falls vorhanden), dann DB-Eintrag löschen
try {
final f = File(lokal.dateiPfad);
if (lokal.dateiPfad.isNotEmpty && await f.exists()) await f.delete();
} catch (_) {
// Datei-Fehler dürfen den Sync nicht abbrechen
}
await _db.loeschSong(lokal.id!);
geloeschteSongs++;
loeschDetails.add(' Gelöscht: ${lokal.titel}');
}
int downloaded = 0;
int neueSongs = 0;
final neueDetails = <String>[];
int totalNew = 0;
// Zähle neue Songs (nur aktive — Tombstones sind oben erledigt)
for (final song in aktiveSongs) {
final sid = song['id']?.toString() ?? '';
if (sid.isEmpty) continue;
final existing = await _db.songNachCloudId(sid);
if (existing == null) totalNew++;
}
// Downloade neue Songs + löse Konflikte (Titel ODER Künstler ≠ Server)
int processed = 0;
for (final song in aktiveSongs) {
final sid = song['id']?.toString() ?? '';
if (sid.isEmpty) continue;
processed++;
// Fortschritt je Song (auch für bereits vorhandene) — „12/25“
_zeigeSyncNotification('Synchronisiere…', processed, aktiveSongs.length);
onFortschrittZaehler?.call(processed, aktiveSongs.length);
final title = (song['title'] ?? 'unknown').toString();
final existing = await _db.songNachCloudId(sid);
if (existing != null) {
final serverTitel = title.trim();
final lokalerTitel = existing.titel.trim();
final serverKuenstler = (song['artist']?.toString() ?? '').trim();
final lokalerKuenstler = existing.kuenstler.trim();
final titelWeichtAb = serverTitel.isNotEmpty &&
lokalerTitel.toLowerCase() != serverTitel.toLowerCase();
final kuenstlerWeichtAb = serverKuenstler.isNotEmpty &&
lokalerKuenstler.toLowerCase() != serverKuenstler.toLowerCase();
if (titelWeichtAb || kuenstlerWeichtAb) {
// Batch: einmal gewählt → für alle weiteren Konflikte anwenden
String? wahl;
final batch = konfliktBatch;
if (batch != null) {
wahl = batch == 'ueberspringen' ? 'lokal' : batch;
} else if (automatisch) {
// Auto-Sync: keine Dialoge — Server-Metadaten gewinnen still
wahl = 'server';
} else {
final ergebnis =
onKonflikt != null ? await onKonflikt!(existing, song) : null;
if (ergebnis != null) {
wahl = ergebnis.wahl;
if (ergebnis.fuerAlle &&
konfliktBatch == null &&
wahl != null) {
konfliktBatch = wahl;
}
}
}
if (wahl == 'server') {
await _db.cloudMetadatenAktualisieren(existing.id!,
title: serverTitel, artist: serverKuenstler);
downloaded++;
} else if (wahl == 'beide') {
// Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen
final safeTitle = sanitizeDateiname(serverTitel);
var dest = '${dir.path}/$safeTitle';
if (await File(dest).exists()) {
dest = '${dir.path}/$safeTitle (Server)';
}
if (await cloud.download(sid, dest)) {
await _db.songEinfuegen(Song(
titel: serverTitel,
kuenstler: serverKuenstler,
dauerSekunden: 0,
dateiPfad: dest,
downloadQuelle: 'cloud',
istHeruntergeladen: true,
));
downloaded++;
}
}
// 'lokal' → nichts tun (lokale Version behalten)
}
continue;
}
onFortschritt?.call('Download: $title…',
0.1 + (0.4 * processed / (totalNew > 0 ? totalNew : 1)));
final safeTitle = sanitizeDateiname(title);
// Eindeutiger Zielpfad: Kollisionen (gleichnamiger Titel oder
// verwaiste Datei aus einem früheren Sync ohne DB-Eintrag) würden
// die Datei überschreiben bzw. am UNIQUE-Constraint der
// datei_pfad-Spalte scheitern (Insert würde still ignoriert).
var dest = '${dir.path}/$safeTitle';
var kollision = 2;
while (await File(dest).exists()) {
dest = '${dir.path}/$safeTitle ($kollision)';
kollision++;
}
if (await cloud.download(sid, dest)) {
downloaded++;
neueSongs++;
neueDetails.add('+ Neuer Song: $title');
// MED-2: Song mit cloud_id + Titeldaten aus der Server-Liste in
// die lokale DB einfügen — sonst fehlt der Song in der
// Bibliothek, songNachCloudId bleibt null und der Song wird bei
// JEDEM Sync erneut heruntergeladen (GB/Sync) und fälschlich als
// „+neu“ gemeldet.
await _db.songEinfuegen(songAusServerMap(song, dest));
}
await Future.delayed(
const Duration(milliseconds: 50)); // UI-Update erlauben
}
// ── Phase 2: Favoriten bidirektional (Merge lokal Server) ──
onFortschritt?.call('Sync Favoriten…', 0.55);
final serverFavs = await cloud.getFavorites();
final serverIds = serverFavs
.map((f) => f['id']?.toString() ?? '')
.where((id) => id.isNotEmpty)
.toSet();
final lokalIds = await _favoriten.favoritenCloudIds();
final merged = favoritenMerge(lokal: lokalIds, server: serverIds);
final mergedSet = merged.toSet();
// Merge-Differenz = Anzahl der Favoriten-Änderungen (Konflikt-Report):
// lokal neu (wurden zum Server gepusht) + vom Server neu (lokal markiert)
final favoritenGeaendert = mergedSet.difference(serverIds).length +
serverIds.difference(lokalIds).length;
final favoritenDetails = <String>[
for (final cid in mergedSet.difference(serverIds))
'⭐ Favorit lokal → Server: $cid',
for (final cid in serverIds.difference(lokalIds))
'⭐ Favorit Server → lokal: $cid',
];
// Lokal → Server: lokale Toggles erreichen den Server, Server-Favoriten
// bleiben erhalten (kein Datenverlust in beide Richtungen)
await cloud.syncFavorites(merged);
// Server → Lokal: Server-Favoriten lokal als ⭐ markieren (wenn Song
// lokal existiert); `server_favorites` spiegelt den Merge-Zustand
for (final cid in serverIds) {
await _favoriten.merkeCloudFavorit(cid);
}
await _db.serverFavoritesSet(merged);
onFavoritenAnzahl?.call(merged.length);
onFortschrittZaehler?.call(downloaded + merged.length, aktiveSongs.length);
// ── Phase 3: Playlisten ──
onFortschritt?.call('Sync Playlisten…', 0.7);
int playlistCount = 0;
if (onPlaylistenLaden != null) {
try {
playlistCount = await onPlaylistenLaden!();
} catch (e) {
MeloLogger().fehler('cloud_playlisten_sync', e);
}
}
// ── Phase 4: Sync-Metadaten + letzter Sync ──
onFortschritt?.call('Speichere Sync-Zeitpunkt...', 0.9);
await cloud.syncAll();
// Zeitstempel = Sync-BEGINN (Snapshot VOR listSongs), NICHT das
// Sync-Ende: Der Tombstone-Vergleich anderer Geräte hängt am
// „letzten Beobachtungszeitpunkt der Server-Liste“ (MED-1).
final now = syncSnapshot.toIso8601String();
await _db.syncMetaSet('last_full_sync', now);
// Nur bei Erfolg persistieren (Auto-Sync-Fälligkeit hängt daran)
final p = await SharedPreferences.getInstance();
final zeit = _formatZeit(syncSnapshot);
await p.setString('cloud_last_sync', zeit);
await p.setString('cloud_last_sync_ts', now);
onFortschritt?.call('Fertig!', 1.0);
onStatus?.call(
'$downloaded Songs + ${merged.length} Favoriten synchronisiert',
ok: true);
// UI-Nachladen (Status, Playlisten, Favoriten, letzter Sync)
await onNachSync?.call();
onHistorie?.call(downloaded, merged.length, playlistCount);
_zeigeSyncFertigNotification(downloaded, merged.length);
MeloLogger().aktion('cloud_sync_all', {
'downloaded': downloaded,
'neue': neueSongs,
'geloescht': geloeschteSongs,
'favorites': merged.length,
'playlists': playlistCount,
});
// Konflikt-Report (Sprint E): Zusammenfassung nur bei echten Änderungen.
// Der CloudScreen zeigt den Dialog („Willkommen zurück! …“).
final bericht = SyncBericht(
neueSongs: neueSongs,
geloeschteSongs: geloeschteSongs,
favoritenGeaendert: favoritenGeaendert,
details: [...neueDetails, ...loeschDetails, ...favoritenDetails],
// LOW-1: 24h-Check des „Willkommen zurück!“-Dialogs gegen den
// letzterSync-Stand VOR diesem Sync (MED-1-Snapshot) — nicht gegen
// den frisch persistierten, sonst wäre die Pause immer 0.
letzterSyncVorher: letzterSync,
);
if (bericht.hatAenderungen) {
onBericht?.call(bericht);
}
return true;
} catch (e) {
MeloLogger().fehler('cloud_sync_all', e);
onStatus?.call('Sync-Fehler: $e', ok: false);
_zeigeSyncFehlerNotification();
return false;
} finally {
// Guard IMMER freigeben — auch bei Fehler oder wenn der Screen während
// des Syncs verlassen wurde („Im Hintergrund fortsetzen“).
_laeuftGlobal = false;
laeuftNotifier.value = false;
try {
notificationsPlugin.cancel(id: _syncNotifyId);
} catch (_) {
// Plugin kann beim App-Exit bereits disposed sein
}
// v2.55.2: Foreground-Service stoppen, sobald der Sync beendet ist.
await _stoppeForegroundService();
onSyncEnde?.call();
}
}
// ─── Notifications (v2.55.2: HIGH + public für Sperrbildschirm) ───
/// Erzeugt die [AndroidNotificationDetails] für Sync-Notifications.
/// Statisch → testbar ohne Platform-/Plugin-Zugriff.
/// [ongoing]=true für Fortschritts-Notification (nicht wegwischbar),
/// [ongoing]=false für Abschluss-/Fehler-Notification (automatisch gelöscht).
@visibleForTesting
static AndroidNotificationDetails syncNotificationDetails({
required bool ongoing,
int progress = 0,
int maxProgress = 0,
}) {
return AndroidNotificationDetails(
_syncChannelId,
'Melo Sync',
channelDescription: ongoing
? 'Cloud-Sync-Fortschritt'
: 'Cloud-Sync-Abschluss',
importance: Importance.high,
priority: Priority.high,
visibility: NotificationVisibility.public,
onlyAlertOnce: true,
showProgress: ongoing,
maxProgress: ongoing ? (maxProgress > 0 ? maxProgress : 1) : 0,
progress: ongoing ? progress : 0,
ongoing: ongoing,
autoCancel: !ongoing,
);
}
/// Persistente Fortschritts-Notification (nicht wegwischbar, ongoing).
void _zeigeSyncNotification(String body, int aktuell, int gesamt) {
if (!Platform.isAndroid) return;
final maxP = gesamt > 0 ? gesamt : 1;
final p = aktuell > maxP ? maxP : aktuell;
try {
notificationsPlugin.show(
id: _syncNotifyId,
title: 'Synchronisiere…',
body: gesamt > 0 ? '$aktuell / $gesamt Songs' : body,
notificationDetails: NotificationDetails(
android: syncNotificationDetails(
ongoing: true,
progress: p,
maxProgress: maxP,
),
),
);
} catch (_) {
// Plugin kann beim App-Exit bereits disposed sein
}
}
/// Abschluss-Notification — tippbar (öffnet den Cloud-Tab, payload
/// 'sync_fertig' wird in main.dart behandelt).
void _zeigeSyncFertigNotification(int songs, int favoriten) {
if (!Platform.isAndroid) return;
try {
notificationsPlugin.cancel(id: _syncNotifyId);
} catch (_) {}
notificationsPlugin.show(
id: _syncNotifyId + 1,
title: 'Sync fertig',
body: '$songs Songs, $favoriten Favoriten synchronisiert ✅',
payload: 'sync_fertig',
notificationDetails: NotificationDetails(
android: syncNotificationDetails(ongoing: false),
),
);
}
/// Fehler-Notification (tippbar, öffnet den Cloud-Tab).
void _zeigeSyncFehlerNotification() {
if (!Platform.isAndroid) return;
try {
notificationsPlugin.cancel(id: _syncNotifyId);
} catch (_) {}
notificationsPlugin.show(
id: _syncNotifyId + 1,
title: 'Sync fehlgeschlagen',
body: 'Bitte erneut versuchen ❌',
payload: 'sync_fehler',
notificationDetails: NotificationDetails(
android: syncNotificationDetails(ongoing: false),
),
);
}
// ─── Foreground-Service (v2.55.2) ───
/// Startet den Foreground-Service, damit der Sync auch bei Display-aus
/// und minimierter App weiterläuft (Android-Prozess-Garantie).
/// v2.55.3: Prüft POST_NOTIFICATIONS-Permission (Android 13+).
/// Ohne diese Permission crasht der Foreground-Service innerhalb von
/// 5 Sekunden (Android killt Services ohne sichtbare Notification).
Future<void> _starteForegroundService() async {
if (!Platform.isAndroid) return;
try {
// Android 13+: Benachrichtigungs-Berechtigung erforderlich
final status = await Permission.notification.status;
if (status.isDenied || status.isPermanentlyDenied) {
MeloLogger().aktion('foreground_sync_skip', {'grund': 'notification_denied'});
return;
}
final svc = FlutterBackgroundService();
final laeuft = await svc.isRunning();
if (!laeuft) {
await svc.startService();
MeloLogger().aktion('foreground_sync_start', {});
}
} catch (e) {
MeloLogger().fehler('foreground_sync_start', e);
}
}
/// Stoppt den Foreground-Service nach Sync-Ende.
Future<void> _stoppeForegroundService() async {
if (!Platform.isAndroid) return;
try {
final svc = FlutterBackgroundService();
final laeuft = await svc.isRunning();
if (laeuft) {
svc.invoke('stopService'); // Feuer-und-vergessen (Isolate-Nachricht)
MeloLogger().aktion('foreground_sync_stop', {});
}
} catch (e) {
MeloLogger().fehler('foreground_sync_stop', e);
}
}
String _formatZeit(DateTime? dt) {
if (dt == null) return 'Nie';
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
}