From dad2907481fdb9a4ab862a3f7061593f1d4e3a98 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Mon, 18 May 2026 01:17:47 +0200 Subject: [PATCH] feat: WebSocket-Lifecycle und Sync ab App-Start unabhaengig von Settings-Screen - MainViewModel: verbindet WebSocket beim App-Start (connectOnStartup) und nach Login (via AuthEventBus.loginSuccess). Behandelt alle WebSocket-Events (Connected/FullSyncRequired/InventoryUpdated) -> pullSync/pushSync. Auto-pushSync wenn Server leer ist und lokale Daten vorhanden (Daten-Recovery). - AuthEventBus: loginSuccess-Signal ergaenzt (serverUrl + token) - SyncServiceImpl: emittiert loginSuccess nach erfolgreichem Login - SettingsViewModel: WebSocket-Lifecycle entfernt (nur noch ConnectionFailed fuer UI-Fehlermeldung). Manueller Sync-Button bleibt erhalten. - WebSocketClientImpl: vollstaendiges Logging, wiederholende User-Benachrichtigung bei Verbindungsfehlern (alle MAX_RETRIES Versuche statt nur einmalig) --- .../bollwerk/app/data/sync/SyncServiceImpl.kt | 1 + .../app/data/sync/WebSocketClientImpl.kt | 35 +++--- .../de/bollwerk/app/domain/AuthEventBus.kt | 7 +- .../java/de/bollwerk/app/ui/MainViewModel.kt | 109 +++++++++++++++++- .../app/ui/settings/SettingsViewModel.kt | 34 +----- 5 files changed, 139 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/de/bollwerk/app/data/sync/SyncServiceImpl.kt b/app/src/main/java/de/bollwerk/app/data/sync/SyncServiceImpl.kt index a28007f..9745ec3 100644 --- a/app/src/main/java/de/bollwerk/app/data/sync/SyncServiceImpl.kt +++ b/app/src/main/java/de/bollwerk/app/data/sync/SyncServiceImpl.kt @@ -98,6 +98,7 @@ internal class SyncServiceImpl @Inject constructor( loginResponse.inventoryName?.let { settingsRepository.setString(StringKey.ActiveInventoryName, it) } + authEventBus.notifyLoginSuccess(serverUrl.trimEnd('/'), loginResponse.accessToken) Result.success(Unit) } HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError()) diff --git a/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt b/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt index de7ffdf..2dc3ca6 100644 --- a/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt +++ b/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt @@ -51,50 +51,57 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { connectionJob = scope.launch { var backoffMs = INITIAL_BACKOFF_MS var consecutiveFailures = 0 - var connectionFailedEmitted = false + val wsUrl = serverUrl.trimEnd('/') + .replace("https://", "wss://") + .replace("http://", "ws://") + Log.i(TAG, "WebSocket: Starte Verbindung zu $wsUrl/ws/sync") while (isActive) { _connectionState.value = ConnectionState.Connecting + Log.d(TAG, "WebSocket: Verbindungsversuch #${consecutiveFailures + 1} (Backoff: ${backoffMs}ms)") try { - val wsUrl = serverUrl.trimEnd('/') - .replace("https://", "wss://") - .replace("http://", "ws://") wsHttpClient.webSocket("$wsUrl/ws/sync?token=$accessToken") { backoffMs = INITIAL_BACKOFF_MS consecutiveFailures = 0 - connectionFailedEmitted = false _connectionState.value = ConnectionState.Connected + Log.i(TAG, "WebSocket: Verbunden mit $wsUrl/ws/sync") _events.emit(WebSocketEvent.Connected) for (frame in incoming) { if (frame is Frame.Text) { - handleFrame(frame.readText()) + val text = frame.readText() + Log.d(TAG, "WebSocket: Frame empfangen: ${text.take(200)}") + handleFrame(text) } } + Log.i(TAG, "WebSocket: Session normal beendet") } } catch (e: CancellationException) { + Log.d(TAG, "WebSocket: Verbindung abgebrochen (disconnect)") break - } catch (_: Exception) { + } catch (e: Exception) { consecutiveFailures++ - if (consecutiveFailures >= MAX_RETRIES && !connectionFailedEmitted) { - _events.emit( - WebSocketEvent.ConnectionFailed( - "Verbindung nach $MAX_RETRIES Versuchen fehlgeschlagen" - ) - ) - connectionFailedEmitted = true + Log.w(TAG, "WebSocket: Verbindungsfehler #$consecutiveFailures – ${e::class.simpleName}: ${e.message}", e) + // Nutzer nach jeweils MAX_RETRIES Fehlern benachrichtigen + if (consecutiveFailures % MAX_RETRIES == 0) { + val msg = "WebSocket-Verbindung fehlgeschlagen (${consecutiveFailures}x): ${e.message ?: e::class.simpleName}" + Log.e(TAG, "WebSocket: $msg") + _events.emit(WebSocketEvent.ConnectionFailed(msg)) } } if (!isActive) break _events.emit(WebSocketEvent.Disconnected) val jitter = backoffMs * JITTER_FACTOR * (Random.nextDouble() * 2 - 1) val totalDelayMs = backoffMs + jitter.toLong() + Log.d(TAG, "WebSocket: Nächster Versuch in ${totalDelayMs / 1000}s") startCountdown(totalDelayMs) delay(totalDelayMs) backoffMs = minOf(backoffMs * 2, MAX_BACKOFF_MS) } + Log.i(TAG, "WebSocket: Verbindungsschleife beendet") } } override fun disconnect() { + Log.i(TAG, "WebSocket: Verbindung wird getrennt") connectionJob?.cancel() connectionJob = null countdownJob?.cancel() diff --git a/app/src/main/java/de/bollwerk/app/domain/AuthEventBus.kt b/app/src/main/java/de/bollwerk/app/domain/AuthEventBus.kt index 5f07755..e8cad7e 100644 --- a/app/src/main/java/de/bollwerk/app/domain/AuthEventBus.kt +++ b/app/src/main/java/de/bollwerk/app/domain/AuthEventBus.kt @@ -15,8 +15,11 @@ import javax.inject.Singleton internal class AuthEventBus @Inject constructor() { private val _sessionExpired = MutableSharedFlow(extraBufferCapacity = 1) val sessionExpired: SharedFlow = _sessionExpired.asSharedFlow() + fun notifySessionExpired() { _sessionExpired.tryEmit(Unit) } - fun notifySessionExpired() { - _sessionExpired.tryEmit(Unit) + private val _loginSuccess = MutableSharedFlow>(extraBufferCapacity = 1) + val loginSuccess: SharedFlow> = _loginSuccess.asSharedFlow() + fun notifyLoginSuccess(serverUrl: String, accessToken: String) { + _loginSuccess.tryEmit(serverUrl to accessToken) } } diff --git a/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt index 1a6343e..52ac26b 100644 --- a/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt +++ b/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt @@ -1,36 +1,139 @@ package de.bollwerk.app.ui +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.bollwerk.app.data.sync.WebSocketClient +import de.bollwerk.app.data.sync.WebSocketEvent import de.bollwerk.app.domain.AuthEventBus +import de.bollwerk.app.domain.model.SettingsKey.StringKey +import de.bollwerk.app.domain.repository.ImportExportRepository +import de.bollwerk.app.domain.repository.SettingsRepository import de.bollwerk.app.domain.repository.SyncService import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import java.time.Instant import javax.inject.Inject @HiltViewModel internal class MainViewModel @Inject constructor( private val authEventBus: AuthEventBus, private val syncService: SyncService, - private val webSocketClient: WebSocketClient + private val webSocketClient: WebSocketClient, + private val settingsRepository: SettingsRepository, + private val importExportRepository: ImportExportRepository, ) : ViewModel() { private val _navigateToSettings = MutableSharedFlow(extraBufferCapacity = 1) val navigateToSettings: SharedFlow = _navigateToSettings.asSharedFlow() init { + connectOnStartup() + observeLoginSuccess() + observeSessionExpiry() + observeWebSocketEvents() + } + + /** Beim App-Start: wenn Token + Server-URL vorhanden → WebSocket verbinden */ + private fun connectOnStartup() { + viewModelScope.launch { + val token = settingsRepository.getString(StringKey.AuthAccessToken) + val serverUrl = settingsRepository.getString(StringKey.ServerUrl) + if (token.isNotBlank() && serverUrl.isNotBlank()) { + Log.i(TAG, "App-Start: Token vorhanden – verbinde WebSocket") + webSocketClient.connect(serverUrl, token) + } else { + Log.d(TAG, "App-Start: Kein Token – kein WebSocket") + } + } + } + + /** Nach erfolgreichem Login → WebSocket verbinden */ + private fun observeLoginSuccess() { + viewModelScope.launch { + authEventBus.loginSuccess.collect { (serverUrl, token) -> + Log.i(TAG, "Login erfolgreich – verbinde WebSocket") + webSocketClient.connect(serverUrl, token) + } + } + } + + /** Session abgelaufen → Token löschen, WebSocket trennen, zu Settings navigieren */ + private fun observeSessionExpiry() { viewModelScope.launch { authEventBus.sessionExpired.collect { - // Tokens löschen (kein Datenverlust – nur Auth-Daten) + Log.w(TAG, "Session abgelaufen – Forced-Logout") syncService.logout() webSocketClient.disconnect() - // Navigation zu Settings (Login-Formular) _navigateToSettings.emit(Unit) } } } + + /** WebSocket-Events → Sync auslösen */ + private fun observeWebSocketEvents() { + viewModelScope.launch { + webSocketClient.events.collect { event -> + when (event) { + is WebSocketEvent.Connected -> { + Log.i(TAG, "WebSocket verbunden – starte initialen Sync") + pullSync(isInitialConnect = true) + } + is WebSocketEvent.FullSyncRequired -> { + Log.i(TAG, "Server fordert Full-Sync") + pullSync(fullSync = true) + } + is WebSocketEvent.InventoryUpdated -> { + Log.d(TAG, "Inventar-Update empfangen (id=${event.itemId})") + pullSync() + } + else -> {} + } + } + } + } + + private suspend fun pullSync(fullSync: Boolean = false, isInitialConnect: Boolean = false) { + val since = if (fullSync) null + else settingsRepository.getStringOrNull(StringKey.SyncLastTimestamp)?.toLongOrNull() + Log.d(TAG, "pullSync: fullSync=$fullSync, isInitialConnect=$isInitialConnect, since=$since") + syncService.downloadInventory(since).fold( + onSuccess = { inventoryDto -> + importExportRepository.importFromInventoryDto(inventoryDto) + .onSuccess { + val now = Instant.now().toEpochMilli().toString() + settingsRepository.setString(StringKey.SyncLastTimestamp, now) + Log.i(TAG, "pullSync: ${inventoryDto.items.size} Items vom Server") + // Server leer aber lokal Daten vorhanden → hochladen + if (isInitialConnect && inventoryDto.items.isEmpty()) { + Log.i(TAG, "Server hat keine Daten – uploade lokales Inventar") + pushSync() + } + } + .onFailure { e -> + Log.e(TAG, "pullSync: Import fehlgeschlagen: ${e.message}", e) + } + }, + onFailure = { e -> + Log.e(TAG, "pullSync: Download fehlgeschlagen: ${e.message}", e) + } + ) + } + + private suspend fun pushSync() { + val inventoryDto = importExportRepository.exportToInventoryDto() + Log.i(TAG, "pushSync: Uploade ${inventoryDto.items.size} Items zum Server") + syncService.uploadInventory(inventoryDto).fold( + onSuccess = { Log.i(TAG, "pushSync erfolgreich") }, + onFailure = { e -> Log.e(TAG, "pushSync fehlgeschlagen: ${e.message}", e) } + ) + } + + companion object { + private const val TAG = "MainViewModel" + } } + diff --git a/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt index fc31ac9..5e52629 100644 --- a/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt @@ -52,7 +52,7 @@ internal class SettingsViewModel @Inject constructor( init { loadSettings() - observeWebSocketEvents() + observeConnectionFailedEvent() observeConnectionState() observePendingQueueCount() } @@ -76,25 +76,11 @@ internal class SettingsViewModel @Inject constructor( } } - private fun observeWebSocketEvents() { + private fun observeConnectionFailedEvent() { viewModelScope.launch { webSocketClient.events.collect { event -> - when (event) { - is WebSocketEvent.FullSyncRequired -> { - showActivity(SyncActivityMessage.ReceivingUpdate) - pullSync(fullSync = true) - } - is WebSocketEvent.InventoryUpdated -> { - showActivity(SyncActivityMessage.ReceivingUpdate) - pullSync(fullSync = false) - } - is WebSocketEvent.ConnectionFailed -> { - showActivity(SyncActivityMessage.Error(event.message)) - } - is WebSocketEvent.Connected -> { - pullSync(fullSync = false, isInitialConnect = true) - } - else -> {} + if (event is WebSocketEvent.ConnectionFailed) { + showActivity(SyncActivityMessage.Error(event.message)) } } } @@ -133,14 +119,7 @@ internal class SettingsViewModel @Inject constructor( ) } - if (isLoggedIn) { - val currentState = webSocketClient.connectionState.value - if (currentState == ConnectionState.NotConfigured - || currentState is ConnectionState.Disconnected - ) { - webSocketClient.connect(serverUrl, accessToken) - } - } + // WebSocket-Lifecycle wird von MainViewModel verwaltet } catch (e: Exception) { _uiState.update { it.copy( @@ -224,8 +203,7 @@ internal class SettingsViewModel @Inject constructor( _uiState.update { it.copy(isLoggingIn = true, loginError = null) } syncService.login(serverUrl, username, password).fold( onSuccess = { - val accessToken = settingsRepository.getString(StringKey.AuthAccessToken) - webSocketClient.connect(serverUrl, accessToken) + // WebSocket-Connect durch MainViewModel via AuthEventBus.loginSuccess _uiState.update { it.copy( isLoggingIn = false,