From 4c2f5f08a448da81d1641fd6a7015e439fe7373f Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sat, 16 May 2026 19:45:11 +0200 Subject: [PATCH] feat(app): User-Konzept App-Phase - JWT-Auth, Login, WebSocket-Client (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsKeys: API_KEY entfernt, AUTH_ACCESS_TOKEN/REFRESH_TOKEN/USERNAME hinzugefügt - SyncService: login() und logout() Interface-Methoden - SyncServiceImpl: Bearer-Token statt X-API-Key, Auto-Refresh bei 401 - AuthModels: LoginRequest, LoginResponse, RefreshRequest - WebSocketClient: Interface + Impl mit exponentiellem Backoff - SettingsViewModel: Login/Logout, WebSocket-Connect, FullSyncRequired auto-pullSync - SettingsScreen: Login-Formular (Username + Passwort) statt API-Key-Feld - NetworkModule: WebSocketClient als Singleton gebunden - Alle Tests gruen (70 Tasks up-to-date) --- app/build.gradle.kts | 1 + .../krisenvorrat/app/data/sync/AuthModels.kt | 12 ++ .../app/data/sync/SyncServiceImpl.kt | 137 ++++++++++++++---- .../app/data/sync/WebSocketClient.kt | 16 ++ .../app/data/sync/WebSocketClientImpl.kt | 99 +++++++++++++ .../de/krisenvorrat/app/di/NetworkModule.kt | 6 + .../app/domain/model/SettingsKeys.kt | 4 +- .../app/domain/model/SyncError.kt | 2 +- .../app/domain/repository/SyncService.kt | 2 + .../app/ui/settings/SettingsScreen.kt | 75 ++++++++-- .../app/ui/settings/SettingsUiState.kt | 7 +- .../app/ui/settings/SettingsViewModel.kt | 90 +++++++++++- .../app/data/sync/SyncServiceImplTest.kt | 13 +- .../app/ui/settings/SettingsViewModelTest.kt | 100 +++++++++++-- 14 files changed, 498 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a38ea04..13e8ce6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.websockets) // Shared module implementation(project(":shared")) diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt new file mode 100644 index 0000000..44527fc --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt @@ -0,0 +1,12 @@ +package de.krisenvorrat.app.data.sync + +import kotlinx.serialization.Serializable + +@Serializable +internal data class LoginRequest(val username: String, val password: String) + +@Serializable +internal data class LoginResponse(val accessToken: String, val refreshToken: String) + +@Serializable +internal data class RefreshRequest(val refreshToken: String) diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt index 6b566e9..98903df 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt @@ -1,5 +1,6 @@ package de.krisenvorrat.app.data.sync +import de.krisenvorrat.app.domain.model.SettingsKeys import de.krisenvorrat.app.domain.model.SyncError import de.krisenvorrat.app.domain.repository.SettingsRepository import de.krisenvorrat.app.domain.repository.SyncService @@ -8,8 +9,10 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.header +import io.ktor.client.request.post import io.ktor.client.request.put import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType @@ -25,38 +28,47 @@ internal class SyncServiceImpl @Inject constructor( ) : SyncService { override suspend fun downloadInventory(): Result = - executeRequest { serverUrl, apiKey -> + executeRequest { serverUrl, token -> val response = httpClient.get("$serverUrl/api/inventory") { - header("X-API-Key", apiKey) + header("Authorization", "Bearer $token") } handleResponse(response) } override suspend fun uploadInventory(inventory: InventoryDto): Result = - executeRequest { serverUrl, apiKey -> + executeRequest { serverUrl, token -> val response = httpClient.put("$serverUrl/api/inventory") { - header("X-API-Key", apiKey) + header("Authorization", "Bearer $token") contentType(ContentType.Application.Json) setBody(inventory) } handleResponse(response) } - private suspend fun executeRequest( - block: suspend (serverUrl: String, apiKey: String) -> Result - ): Result = withContext(Dispatchers.IO) { - val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) - if (serverUrl.isNullOrBlank()) { - return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt")) - } - - val apiKey = settingsRepository.getValue(KEY_API_KEY) - if (apiKey.isNullOrBlank()) { - return@withContext Result.failure(SyncError.NotConfigured("API-Key nicht gesetzt")) - } - + override suspend fun login( + serverUrl: String, + username: String, + password: String + ): Result = withContext(Dispatchers.IO) { try { - block(serverUrl.trimEnd('/'), apiKey) + val response = httpClient.post("${serverUrl.trimEnd('/')}/api/auth/login") { + contentType(ContentType.Application.Json) + setBody(LoginRequest(username, password)) + } + when (response.status) { + HttpStatusCode.OK -> { + val loginResponse: LoginResponse = response.body() + settingsRepository.setValue(KEY_SERVER_URL, serverUrl) + settingsRepository.setValue(KEY_ACCESS_TOKEN, loginResponse.accessToken) + settingsRepository.setValue(KEY_REFRESH_TOKEN, loginResponse.refreshToken) + settingsRepository.setValue(KEY_AUTH_USERNAME, username) + Result.success(Unit) + } + HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError()) + else -> Result.failure( + SyncError.ServerError(response.status.value, response.status.description) + ) + } } catch (e: SocketTimeoutException) { Result.failure(SyncError.Timeout(e)) } catch (e: ConnectException) { @@ -66,21 +78,84 @@ internal class SyncServiceImpl @Inject constructor( } } - private suspend fun handleResponse( - response: io.ktor.client.statement.HttpResponse - ): Result = when (response.status) { - HttpStatusCode.OK -> Result.success(response.body()) - HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError()) - else -> Result.failure( - SyncError.ServerError( - statusCode = response.status.value, - message = response.status.description - ) - ) + override suspend fun logout() { + settingsRepository.setValue(KEY_ACCESS_TOKEN, "") + settingsRepository.setValue(KEY_REFRESH_TOKEN, "") + settingsRepository.setValue(KEY_AUTH_USERNAME, "") } + private suspend fun executeRequest( + block: suspend (serverUrl: String, token: String) -> Result + ): Result = withContext(Dispatchers.IO) { + val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) + if (serverUrl.isNullOrBlank()) { + return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt")) + } + val token = settingsRepository.getValue(KEY_ACCESS_TOKEN) + if (token.isNullOrBlank()) { + return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet")) + } + try { + val result = block(serverUrl.trimEnd('/'), token) + // Auto-refresh on 401 + if (result.exceptionOrNull() is SyncError.AuthError) { + val refreshed = refreshToken(serverUrl.trimEnd('/')) + if (refreshed) { + val newToken = settingsRepository.getValue(KEY_ACCESS_TOKEN) + ?: return@withContext result + block(serverUrl.trimEnd('/'), newToken) + } else { + result + } + } else { + result + } + } catch (e: SocketTimeoutException) { + Result.failure(SyncError.Timeout(e)) + } catch (e: ConnectException) { + Result.failure(SyncError.ConnectionError(e)) + } catch (e: Exception) { + Result.failure(SyncError.Unknown(e)) + } + } + + private suspend fun refreshToken(serverUrl: String): Boolean { + val refreshToken = settingsRepository.getValue(KEY_REFRESH_TOKEN) + if (refreshToken.isNullOrBlank()) return false + return try { + val response = httpClient.post("$serverUrl/api/auth/refresh") { + contentType(ContentType.Application.Json) + setBody(RefreshRequest(refreshToken)) + } + if (response.status == HttpStatusCode.OK) { + val loginResponse: LoginResponse = response.body() + settingsRepository.setValue(KEY_ACCESS_TOKEN, loginResponse.accessToken) + settingsRepository.setValue(KEY_REFRESH_TOKEN, loginResponse.refreshToken) + true + } else { + false + } + } catch (_: Exception) { + false + } + } + + private suspend fun handleResponse(response: HttpResponse): Result = + when (response.status) { + HttpStatusCode.OK -> Result.success(response.body()) + HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError()) + else -> Result.failure( + SyncError.ServerError( + statusCode = response.status.value, + message = response.status.description + ) + ) + } + private companion object { - const val KEY_SERVER_URL = "server_url" - const val KEY_API_KEY = "api_key" + val KEY_SERVER_URL = SettingsKeys.SERVER_URL + val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN + val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN + val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME } } diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt new file mode 100644 index 0000000..77a97b9 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt @@ -0,0 +1,16 @@ +package de.krisenvorrat.app.data.sync + +import kotlinx.coroutines.flow.SharedFlow + +internal interface WebSocketClient { + val events: SharedFlow + fun connect(serverUrl: String, accessToken: String) + fun disconnect() +} + +internal sealed interface WebSocketEvent { + data class InventoryUpdated(val itemId: String) : WebSocketEvent + data object FullSyncRequired : WebSocketEvent + data object Connected : WebSocketEvent + data object Disconnected : WebSocketEvent +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt new file mode 100644 index 0000000..82613ee --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt @@ -0,0 +1,99 @@ +package de.krisenvorrat.app.data.sync + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { + + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + override val events: SharedFlow = _events.asSharedFlow() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var connectionJob: Job? = null + + private val wsHttpClient = HttpClient(OkHttp) { + install(WebSockets) + } + + override fun connect(serverUrl: String, accessToken: String) { + connectionJob?.cancel() + connectionJob = scope.launch { + var backoffMs = INITIAL_BACKOFF_MS + while (isActive) { + try { + val wsUrl = serverUrl.trimEnd('/') + .replace("https://", "wss://") + .replace("http://", "ws://") + wsHttpClient.webSocket("$wsUrl/ws/sync?token=$accessToken") { + backoffMs = INITIAL_BACKOFF_MS + _events.emit(WebSocketEvent.Connected) + for (frame in incoming) { + if (frame is Frame.Text) { + handleFrame(frame.readText()) + } + } + } + } catch (e: CancellationException) { + break + } catch (_: Exception) { + // connection failed – retry after backoff + } + if (!isActive) break + _events.emit(WebSocketEvent.Disconnected) + delay(backoffMs) + backoffMs = minOf(backoffMs * 2, MAX_BACKOFF_MS) + } + } + } + + override fun disconnect() { + connectionJob?.cancel() + connectionJob = null + scope.launch { _events.emit(WebSocketEvent.Disconnected) } + } + + private suspend fun handleFrame(text: String) { + try { + val event = json.decodeFromString(text) + when (event.type) { + "inventoryUpdated" -> _events.emit(WebSocketEvent.InventoryUpdated(event.itemId ?: "")) + "fullSyncRequired" -> _events.emit(WebSocketEvent.FullSyncRequired) + } + } catch (_: Exception) { + // ignore malformed events + } + } + + private companion object { + const val INITIAL_BACKOFF_MS = 2_000L + const val MAX_BACKOFF_MS = 60_000L + val json = Json { ignoreUnknownKeys = true } + } +} + +@Serializable +private data class WsServerEvent( + val type: String, + val itemId: String? = null +) diff --git a/app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt b/app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt index be398f5..989345c 100644 --- a/app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt +++ b/app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt @@ -8,6 +8,8 @@ import dagger.hilt.components.SingletonComponent import de.krisenvorrat.app.data.remote.OpenAiVisionService import de.krisenvorrat.app.data.remote.OpenAiVisionServiceImpl import de.krisenvorrat.app.data.sync.SyncServiceImpl +import de.krisenvorrat.app.data.sync.WebSocketClient +import de.krisenvorrat.app.data.sync.WebSocketClientImpl import de.krisenvorrat.app.domain.repository.SyncService import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp @@ -27,6 +29,10 @@ internal abstract class NetworkModule { @Singleton abstract fun bindSyncService(impl: SyncServiceImpl): SyncService + @Binds + @Singleton + abstract fun bindWebSocketClient(impl: WebSocketClientImpl): WebSocketClient + @Binds abstract fun bindOpenAiVisionService(impl: OpenAiVisionServiceImpl): OpenAiVisionService diff --git a/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt b/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt index 8a965ed..116ec10 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt @@ -5,7 +5,9 @@ internal object SettingsKeys { const val DAILY_KCAL_PER_PERSON = "daily_kcal_per_person" const val AGE_GROUPS = "age_groups" const val SERVER_URL = "server_url" - const val API_KEY = "api_key" + const val AUTH_ACCESS_TOKEN = "auth_access_token" + const val AUTH_REFRESH_TOKEN = "auth_refresh_token" + const val AUTH_USERNAME = "auth_username" const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp" const val OPENAI_API_KEY = "openai_api_key" } diff --git a/app/src/main/java/de/krisenvorrat/app/domain/model/SyncError.kt b/app/src/main/java/de/krisenvorrat/app/domain/model/SyncError.kt index 4502b9a..da07007 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/model/SyncError.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/model/SyncError.kt @@ -8,7 +8,7 @@ internal sealed class SyncError(message: String, cause: Throwable? = null) : Exc SyncError("Zeitüberschreitung bei der Verbindung zum Server", cause) class AuthError : - SyncError("Authentifizierung fehlgeschlagen – API-Key ungültig") + SyncError("Authentifizierung fehlgeschlagen – Sitzung abgelaufen oder nicht angemeldet") class ServerError(val statusCode: Int, message: String) : SyncError("Serverfehler ($statusCode): $message") diff --git a/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt b/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt index b406046..869a840 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt @@ -5,4 +5,6 @@ import de.krisenvorrat.shared.model.InventoryDto internal interface SyncService { suspend fun downloadInventory(): Result suspend fun uploadInventory(inventory: InventoryDto): Result + suspend fun login(serverUrl: String, username: String, password: String): Result + suspend fun logout() } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt index 5f9c157..11372b3 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt @@ -238,19 +238,74 @@ internal fun SettingsScreen( Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = uiState.apiKey, - onValueChange = viewModel::onApiKeyChanged, - label = { Text("API-Key") }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) + if (uiState.isLoggedIn) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Angemeldet als: ${uiState.loggedInUsername}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = viewModel::logout) { + Text("Abmelden") + } + } + } else { + OutlinedTextField( + value = uiState.loginUsername, + onValueChange = viewModel::onLoginUsernameChanged, + label = { Text("Benutzername") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.loginPassword, + onValueChange = viewModel::onLoginPasswordChanged, + label = { Text("Passwort") }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = viewModel::login, + enabled = !uiState.isLoggingIn, + modifier = Modifier.fillMaxWidth() + ) { + if (uiState.isLoggingIn) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("Anmelden") + } + + if (uiState.loginError != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = uiState.loginError ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } Spacer(modifier = Modifier.height(16.dp)) - val isSyncEnabled = uiState.serverUrl.isNotBlank() && - uiState.apiKey.isNotBlank() && + val isSyncEnabled = uiState.isLoggedIn && + uiState.serverUrl.isNotBlank() && uiState.syncStatus !is SyncStatus.Running Row( diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt index 36569e4..d1a6ad2 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt @@ -15,7 +15,12 @@ internal data class SettingsUiState( val importResult: ImportResult? = null, val pendingImportUri: Uri? = null, val serverUrl: String = "", - val apiKey: String = "", + val isLoggedIn: Boolean = false, + val loggedInUsername: String = "", + val loginUsername: String = "", + val loginPassword: String = "", + val isLoggingIn: Boolean = false, + val loginError: String? = null, val syncStatus: SyncStatus = SyncStatus.Idle, val lastSyncTime: String? = null, val openAiApiKey: String = "" diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt index 8ab4b11..ab0017f 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import de.krisenvorrat.app.data.sync.WebSocketClient +import de.krisenvorrat.app.data.sync.WebSocketEvent import de.krisenvorrat.app.domain.model.AgeGroup import de.krisenvorrat.app.domain.model.AgeGroupEntry import de.krisenvorrat.app.domain.model.SettingsKeys @@ -33,6 +35,7 @@ internal class SettingsViewModel @Inject constructor( private val settingsRepository: SettingsRepository, private val importExportRepository: ImportExportRepository, private val syncService: SyncService, + private val webSocketClient: WebSocketClient, @ApplicationContext private val context: Context ) : ViewModel() { @@ -41,6 +44,19 @@ internal class SettingsViewModel @Inject constructor( init { loadSettings() + observeWebSocketEvents() + } + + private fun observeWebSocketEvents() { + viewModelScope.launch { + webSocketClient.events.collect { event -> + when (event) { + is WebSocketEvent.FullSyncRequired -> pullSync() + is WebSocketEvent.InventoryUpdated -> pullSync() + else -> {} + } + } + } } private fun loadSettings() { @@ -48,7 +64,9 @@ internal class SettingsViewModel @Inject constructor( try { val ageGroups = loadAgeGroupsWithMigration() val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: "" - val apiKey = settingsRepository.getValue(KEY_API_KEY) ?: "" + val authUsername = settingsRepository.getValue(KEY_AUTH_USERNAME) ?: "" + val accessToken = settingsRepository.getValue(KEY_ACCESS_TOKEN) ?: "" + val isLoggedIn = authUsername.isNotBlank() && accessToken.isNotBlank() val openAiApiKey = settingsRepository.getValue(KEY_OPENAI_API_KEY) ?: "" val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP) @@ -56,12 +74,17 @@ internal class SettingsViewModel @Inject constructor( it.copy( ageGroups = ageGroups, serverUrl = serverUrl, - apiKey = apiKey, + isLoggedIn = isLoggedIn, + loggedInUsername = authUsername, openAiApiKey = openAiApiKey, lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) }, isLoading = false ) } + + if (isLoggedIn) { + webSocketClient.connect(serverUrl, accessToken) + } } catch (e: Exception) { _uiState.update { it.copy( @@ -121,8 +144,62 @@ internal class SettingsViewModel @Inject constructor( _uiState.update { it.copy(serverUrl = value, isSaved = false) } } - fun onApiKeyChanged(value: String) { - _uiState.update { it.copy(apiKey = value, isSaved = false) } + fun onLoginUsernameChanged(value: String) { + _uiState.update { it.copy(loginUsername = value, loginError = null) } + } + + fun onLoginPasswordChanged(value: String) { + _uiState.update { it.copy(loginPassword = value, loginError = null) } + } + + fun login() { + val serverUrl = _uiState.value.serverUrl.trim() + val username = _uiState.value.loginUsername.trim() + val password = _uiState.value.loginPassword + if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) { + _uiState.update { it.copy(loginError = "Bitte Server-URL, Benutzername und Passwort eingeben") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoggingIn = true, loginError = null) } + syncService.login(serverUrl, username, password).fold( + onSuccess = { + val accessToken = settingsRepository.getValue(KEY_ACCESS_TOKEN) ?: "" + webSocketClient.connect(serverUrl, accessToken) + _uiState.update { + it.copy( + isLoggingIn = false, + isLoggedIn = true, + loggedInUsername = username, + loginUsername = "", + loginPassword = "" + ) + } + }, + onFailure = { e -> + _uiState.update { + it.copy( + isLoggingIn = false, + loginError = e.message ?: "Anmeldung fehlgeschlagen" + ) + } + } + ) + } + } + + fun logout() { + viewModelScope.launch { + syncService.logout() + webSocketClient.disconnect() + _uiState.update { + it.copy( + isLoggedIn = false, + loggedInUsername = "", + syncStatus = SyncStatus.Idle + ) + } + } } fun onOpenAiApiKeyChanged(value: String) { @@ -134,7 +211,6 @@ internal class SettingsViewModel @Inject constructor( try { settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson()) settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl) - settingsRepository.setValue(KEY_API_KEY, _uiState.value.apiKey) settingsRepository.setValue(KEY_OPENAI_API_KEY, _uiState.value.openAiApiKey) _uiState.update { it.copy(isSaved = true) } @@ -310,7 +386,9 @@ internal class SettingsViewModel @Inject constructor( val KEY_DAILY_KCAL_PER_PERSON = SettingsKeys.DAILY_KCAL_PER_PERSON val KEY_AGE_GROUPS = SettingsKeys.AGE_GROUPS val KEY_SERVER_URL = SettingsKeys.SERVER_URL - val KEY_API_KEY = SettingsKeys.API_KEY + val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN + val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN + val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME val KEY_SYNC_LAST_TIMESTAMP = SettingsKeys.SYNC_LAST_TIMESTAMP val KEY_OPENAI_API_KEY = SettingsKeys.OPENAI_API_KEY } diff --git a/app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt index 4bba1cd..21347e3 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt @@ -73,9 +73,10 @@ class SyncServiceImplTest { } } - private fun setupSettings(serverUrl: String? = "http://localhost:8080", apiKey: String? = "test-key") { + private fun setupSettings(serverUrl: String? = "http://localhost:8080", accessToken: String? = "test-token") { coEvery { settingsRepository.getValue("server_url") } returns serverUrl - coEvery { settingsRepository.getValue("api_key") } returns apiKey + coEvery { settingsRepository.getValue("auth_access_token") } returns accessToken + coEvery { settingsRepository.getValue("auth_refresh_token") } returns null } // --- downloadInventory --- @@ -87,7 +88,7 @@ class SyncServiceImplTest { val engine = MockEngine { request -> assertEquals("/api/inventory", request.url.encodedPath) assertEquals(HttpMethod.Get, request.method) - assertEquals("test-key", request.headers["X-API-Key"]) + assertEquals("Bearer test-token", request.headers["Authorization"]) respond( content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory), status = HttpStatusCode.OK, @@ -175,9 +176,9 @@ class SyncServiceImplTest { } @Test - fun test_downloadInventory_noApiKey_returnsNotConfigured() = runTest { + fun test_downloadInventory_noAccessToken_returnsNotConfigured() = runTest { // Given - setupSettings(apiKey = null) + setupSettings(accessToken = null) val engine = MockEngine { respondError(HttpStatusCode.OK) } httpClient = createClient(engine) syncService = SyncServiceImpl(httpClient, settingsRepository) @@ -199,7 +200,7 @@ class SyncServiceImplTest { val engine = MockEngine { request -> assertEquals("/api/inventory", request.url.encodedPath) assertEquals(HttpMethod.Put, request.method) - assertEquals("test-key", request.headers["X-API-Key"]) + assertEquals("Bearer test-token", request.headers["Authorization"]) assertEquals( ContentType.Application.Json.toString(), request.body.contentType.toString() diff --git a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt index 0d6f8cf..d3833cb 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.core.content.FileProvider import android.content.ContentResolver import android.net.Uri +import de.krisenvorrat.app.data.sync.WebSocketClient +import de.krisenvorrat.app.data.sync.WebSocketEvent import de.krisenvorrat.app.data.db.entity.SettingsEntity import de.krisenvorrat.app.domain.model.SyncError import de.krisenvorrat.app.domain.repository.ImportExportRepository @@ -45,6 +47,7 @@ class SettingsViewModelTest { private lateinit var fakeSettingsRepository: FakeSettingsRepository private lateinit var fakeImportExportRepository: FakeImportExportRepository private lateinit var fakeSyncService: FakeSyncService + private lateinit var fakeWebSocketClient: FakeWebSocketClient private lateinit var mockContext: Context private lateinit var tempDir: File private lateinit var viewModel: SettingsViewModel @@ -55,6 +58,7 @@ class SettingsViewModelTest { fakeSettingsRepository = FakeSettingsRepository() fakeImportExportRepository = FakeImportExportRepository() fakeSyncService = FakeSyncService() + fakeWebSocketClient = FakeWebSocketClient() tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports") tempDir.mkdirs() mockContext = mockk(relaxed = true) { @@ -73,6 +77,7 @@ class SettingsViewModelTest { settingsRepository = fakeSettingsRepository, importExportRepository = fakeImportExportRepository, syncService = fakeSyncService, + webSocketClient = fakeWebSocketClient, context = mockContext ) @@ -483,7 +488,8 @@ class SettingsViewModelTest { fun test_init_withStoredSyncSettings_loadsSyncFields() = runTest(testDispatcher) { // Given fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL] = "https://example.com" - fakeSettingsRepository.store[SettingsViewModel.KEY_API_KEY] = "my-secret-key" + fakeSettingsRepository.store[SettingsViewModel.KEY_AUTH_USERNAME] = "testuser" + fakeSettingsRepository.store[SettingsViewModel.KEY_ACCESS_TOKEN] = "access-token-123" fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP] = "1715700000000" viewModel = createViewModel() @@ -494,7 +500,8 @@ class SettingsViewModelTest { // Then val state = viewModel.uiState.value assertEquals("https://example.com", state.serverUrl) - assertEquals("my-secret-key", state.apiKey) + assertTrue(state.isLoggedIn) + assertEquals("testuser", state.loggedInUsername) assertNotNull(state.lastSyncTime) } @@ -513,26 +520,84 @@ class SettingsViewModelTest { } @Test - fun test_onApiKeyChanged_updatesState() = runTest(testDispatcher) { + fun test_onLoginUsernameChanged_updatesState() = runTest(testDispatcher) { // Given viewModel = createViewModel() advanceUntilIdle() // When - viewModel.onApiKeyChanged("new-api-key") + viewModel.onLoginUsernameChanged("newuser") // Then - assertEquals("new-api-key", viewModel.uiState.value.apiKey) - assertFalse(viewModel.uiState.value.isSaved) + assertEquals("newuser", viewModel.uiState.value.loginUsername) } @Test - fun test_saveSettings_persistsServerUrlAndApiKey() = runTest(testDispatcher) { + fun test_login_success_setsLoggedIn() = runTest(testDispatcher) { + // Given + viewModel = createViewModel() + advanceUntilIdle() + viewModel.onServerUrlChanged("https://server.com") + viewModel.onLoginUsernameChanged("admin") + viewModel.onLoginPasswordChanged("secret") + + // When + viewModel.login() + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertTrue(state.isLoggedIn) + assertEquals("admin", state.loggedInUsername) + assertFalse(state.isLoggingIn) + assertNull(state.loginError) + } + + @Test + fun test_login_failure_setsLoginError() = runTest(testDispatcher) { + // Given + fakeSyncService.loginShouldFail = true + viewModel = createViewModel() + advanceUntilIdle() + viewModel.onServerUrlChanged("https://server.com") + viewModel.onLoginUsernameChanged("admin") + viewModel.onLoginPasswordChanged("wrong") + + // When + viewModel.login() + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isLoggedIn) + assertNotNull(state.loginError) + } + + @Test + fun test_logout_clearsLoginState() = runTest(testDispatcher) { + // Given + fakeSettingsRepository.store[SettingsViewModel.KEY_AUTH_USERNAME] = "testuser" + fakeSettingsRepository.store[SettingsViewModel.KEY_ACCESS_TOKEN] = "token-123" + fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL] = "https://server.com" + viewModel = createViewModel() + advanceUntilIdle() + + // When + viewModel.logout() + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isLoggedIn) + assertEquals("", state.loggedInUsername) + } + + @Test + fun test_saveSettings_persistsServerUrl() = runTest(testDispatcher) { // Given viewModel = createViewModel() advanceUntilIdle() viewModel.onServerUrlChanged("https://myserver.com") - viewModel.onApiKeyChanged("secret-key-123") // When viewModel.saveSettings() @@ -541,7 +606,6 @@ class SettingsViewModelTest { // Then assertTrue(viewModel.uiState.value.isSaved) assertEquals("https://myserver.com", fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL]) - assertEquals("secret-key-123", fakeSettingsRepository.store[SettingsViewModel.KEY_API_KEY]) } @Test @@ -600,7 +664,7 @@ class SettingsViewModelTest { fun test_pullSync_authError_setsSyncStatusError() = runTest(testDispatcher) { // Given fakeSyncService.downloadShouldFail = true - fakeSyncService.downloadError = SyncError.AuthError() + fakeSyncService.downloadError = de.krisenvorrat.app.domain.model.SyncError.AuthError() viewModel = createViewModel() advanceUntilIdle() @@ -690,6 +754,7 @@ private class FakeImportExportRepository : ImportExportRepository { private class FakeSyncService : SyncService { var uploadShouldFail = false var downloadShouldFail = false + var loginShouldFail = false var uploadError: Exception = RuntimeException("Upload failed") var downloadError: Exception = RuntimeException("Download failed") var downloadResult = InventoryDto( @@ -714,6 +779,21 @@ private class FakeSyncService : SyncService { if (uploadShouldFail) return Result.failure(uploadError) return Result.success(uploadResult) } + + override suspend fun login(serverUrl: String, username: String, password: String): Result { + if (loginShouldFail) return Result.failure(de.krisenvorrat.app.domain.model.SyncError.AuthError()) + return Result.success(Unit) + } + + override suspend fun logout() {} +} + +private class FakeWebSocketClient : WebSocketClient { + private val _events = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 16) + override val events: kotlinx.coroutines.flow.SharedFlow = _events + var connectedUrl: String? = null + override fun connect(serverUrl: String, accessToken: String) { connectedUrl = serverUrl } + override fun disconnect() { connectedUrl = null } } // endregion