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)
This commit is contained in:
parent
575c0ad709
commit
dad2907481
5 changed files with 139 additions and 47 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -15,8 +15,11 @@ import javax.inject.Singleton
|
|||
internal class AuthEventBus @Inject constructor() {
|
||||
private val _sessionExpired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val sessionExpired: SharedFlow<Unit> = _sessionExpired.asSharedFlow()
|
||||
fun notifySessionExpired() { _sessionExpired.tryEmit(Unit) }
|
||||
|
||||
fun notifySessionExpired() {
|
||||
_sessionExpired.tryEmit(Unit)
|
||||
private val _loginSuccess = MutableSharedFlow<Pair<String, String>>(extraBufferCapacity = 1)
|
||||
val loginSuccess: SharedFlow<Pair<String, String>> = _loginSuccess.asSharedFlow()
|
||||
fun notifyLoginSuccess(serverUrl: String, accessToken: String) {
|
||||
_loginSuccess.tryEmit(serverUrl to accessToken)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Unit>(extraBufferCapacity = 1)
|
||||
val navigateToSettings: SharedFlow<Unit> = _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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue