v2.55.1 — Code-Review-Fixes: Singleton, Race-Conditions, Fallback-Timer, Log-Security

CRITICAL (C1): RealtimeSyncService jetzt Singleton (Factory-Pattern)
  - Verhindert doppelte SSE-Verbindungen durch home_screen + settings
  - UI-Notifier (istVerbundenNotifier) app-weit konsistent

HIGH (H1): _reconnecteOderFallback + starteWennAktiviert setzen
  cloud_interval vor starteAutoSyncTimer() — sonst bleibt Timer
  bei Fallback auf 0 (kein Sync)

HIGH (H2): Race-Condition in _verbinde() nach await request.close()
  — _pausiert-Flag re-check; bei true → clean exit mit _laeuft=false

HIGH (H3): fortsetzen() re-checkt _pausiert nach starteWennAktiviert()
  — verhindert Leak bei schnellem App-Umschalten

MEDIUM (M1): _syncModusSetzen stoppt SSE bei Wechsel auf manual/interval
  (vorher lief die Verbindung weiter)

MEDIUM (M2): Event-Logging loggt nur noch Event-Typ, nicht rohe Daten
  (Privacy: Song-IDs/Titel/Künstler nicht im Log)

LOW (L1): _verbinde() setzt _laeuft=false bei Token-Fehler (kein Deadlock)
This commit is contained in:
Dustin
2026-08-06 18:04:52 +02:00
parent 3f169fa1d9
commit b4d63509d1
2 changed files with 35 additions and 5 deletions
+5 -3
View File
@@ -109,19 +109,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
await p.setString('sync_modus', modus); await p.setString('sync_modus', modus);
// Realtime-Service entsprechend starten/stoppen // Realtime-Service entsprechend starten/stoppen
final rs = RealtimeSyncService();
switch (modus) { switch (modus) {
case 'realtime': case 'realtime':
// SSE sofort starten // SSE sofort starten
final rs = RealtimeSyncService();
await rs.starteWennAktiviert(); await rs.starteWennAktiviert();
break; break;
case 'manual': case 'manual':
// Kein Auto-Sync — Timer stoppen // Kein Auto-Sync — SSE stoppen, Timer deaktivieren
rs.stoppe();
await p.setInt('cloud_interval', 0); // alten Timer deaktivieren await p.setInt('cloud_interval', 0); // alten Timer deaktivieren
await SyncService.starteAutoSyncTimer(); await SyncService.starteAutoSyncTimer();
break; break;
case 'interval': case 'interval':
// Intervall-Timer mit cloud_interval_stunden starten // Intervall-Timer — SSE stoppen, Timer mit cloud_interval_stunden starten
rs.stoppe();
await p.setInt('cloud_interval', _cloudIntervallStunden); await p.setInt('cloud_interval', _cloudIntervallStunden);
await SyncService.starteAutoSyncTimer(); await SyncService.starteAutoSyncTimer();
break; break;
+30 -2
View File
@@ -33,8 +33,13 @@ enum SyncModus {
/// **Lifecycle** (via WidgetsBindingObserver in main.dart): /// **Lifecycle** (via WidgetsBindingObserver in main.dart):
/// - resumed → fortsetzen() (SSE neu verbinden) /// - resumed → fortsetzen() (SSE neu verbinden)
/// - paused/hidden → pausiere() (Batterie sparen) /// - paused/hidden → pausiere() (Batterie sparen)
///
/// **Singleton**: Nur EINE Instanz pro App-Lebenszyklus, damit
/// home_screen.dart und settings_screen.dart dieselbe Verbindung teilen.
class RealtimeSyncService { class RealtimeSyncService {
RealtimeSyncService() static final RealtimeSyncService _instanz = RealtimeSyncService._();
factory RealtimeSyncService() => _instanz;
RealtimeSyncService._()
: _db = DbHelper(), : _db = DbHelper(),
_auth = AuthService(), _auth = AuthService(),
_favoriten = FavoritenService(); _favoriten = FavoritenService();
@@ -123,6 +128,9 @@ class RealtimeSyncService {
// Fallback: sync_modus auf interval setzen + Auto-Sync-Timer starten // Fallback: sync_modus auf interval setzen + Auto-Sync-Timer starten
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
await p.setString('sync_modus', 'interval'); await p.setString('sync_modus', 'interval');
// cloud_interval setzen, damit starteAutoSyncTimer() den Timer startet
final intervallStunden = await cloudIntervallStunden();
await p.setInt('cloud_interval', intervallStunden);
await SyncService.starteAutoSyncTimer(); await SyncService.starteAutoSyncTimer();
stoppe(); stoppe();
return; return;
@@ -164,6 +172,7 @@ class RealtimeSyncService {
final token = _auth.token; final token = _auth.token;
if (token == null || token.isEmpty) { if (token == null || token.isEmpty) {
_fehlerBericht('Kein Auth-Token'); _fehlerBericht('Kein Auth-Token');
_laeuft = false;
return; return;
} }
@@ -179,6 +188,14 @@ class RealtimeSyncService {
_sseResponse = await request.close(); _sseResponse = await request.close();
// Nach await prüfen: pausiere() könnte während des Connects
// aufgerufen worden sein (WidgetsBindingObserver-Race).
if (_pausiert) {
_sseResponse = null;
_laeuft = false;
return;
}
if (_sseResponse!.statusCode != 200) { if (_sseResponse!.statusCode != 200) {
_fehlerBericht('HTTP ${_sseResponse!.statusCode}'); _fehlerBericht('HTTP ${_sseResponse!.statusCode}');
await _reconnecteOderFallback(); await _reconnecteOderFallback();
@@ -249,7 +266,8 @@ class RealtimeSyncService {
MeloLogger().aktion('realtime_event', { MeloLogger().aktion('realtime_event', {
'type': eventTyp, 'type': eventTyp,
'data': daten, // Daten nicht loggen — könnten sensitive User-Informationen enthalten
// (Song-IDs, Titel, Künstler). Nur Event-Typ wird geloggt.
}); });
switch (eventTyp) { switch (eventTyp) {
@@ -391,6 +409,10 @@ class RealtimeSyncService {
MeloLogger().aktion('realtime_fallback_to_interval', {}); MeloLogger().aktion('realtime_fallback_to_interval', {});
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
await p.setString('sync_modus', 'interval'); await p.setString('sync_modus', 'interval');
// cloud_interval (alter Key) auf Default 48h setzen, damit
// SyncService.starteAutoSyncTimer() den Timer auch wirklich startet.
final intervallStunden = await cloudIntervallStunden();
await p.setInt('cloud_interval', intervallStunden);
await SyncService.starteAutoSyncTimer(); await SyncService.starteAutoSyncTimer();
} }
} }
@@ -435,6 +457,12 @@ class RealtimeSyncService {
if (!_pausiert) return; if (!_pausiert) return;
_pausiert = false; _pausiert = false;
await starteWennAktiviert(); await starteWennAktiviert();
// Nach await prüfen: pausiere() könnte während starteWennAktiviert()
// aufgerufen worden sein (z.B. schnelles App-Umschalten).
// Falls ja, Verbindung sofort wieder trennen.
if (_pausiert) {
stoppe();
}
} }
/// Stoppt die SSE-Verbindung endgültig (z. B. Logout, Modus-Wechsel). /// Stoppt die SSE-Verbindung endgültig (z. B. Logout, Modus-Wechsel).