feat(app): User-Konzept App-Phase - JWT-Auth, Login, WebSocket-Client (#57)

- 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)
This commit is contained in:
Jens Reinemann 2026-05-16 19:45:11 +02:00
parent 14631c7327
commit 4c2f5f08a4
14 changed files with 498 additions and 66 deletions

View file

@ -84,6 +84,7 @@ dependencies {
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.websockets)
// Shared module // Shared module
implementation(project(":shared")) implementation(project(":shared"))

View file

@ -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)

View file

@ -1,5 +1,6 @@
package de.krisenvorrat.app.data.sync 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.model.SyncError
import de.krisenvorrat.app.domain.repository.SettingsRepository import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService 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.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.put import io.ktor.client.request.put
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType import io.ktor.http.contentType
@ -25,38 +28,47 @@ internal class SyncServiceImpl @Inject constructor(
) : SyncService { ) : SyncService {
override suspend fun downloadInventory(): Result<InventoryDto> = override suspend fun downloadInventory(): Result<InventoryDto> =
executeRequest { serverUrl, apiKey -> executeRequest { serverUrl, token ->
val response = httpClient.get("$serverUrl/api/inventory") { val response = httpClient.get("$serverUrl/api/inventory") {
header("X-API-Key", apiKey) header("Authorization", "Bearer $token")
} }
handleResponse(response) handleResponse(response)
} }
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> = override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> =
executeRequest { serverUrl, apiKey -> executeRequest { serverUrl, token ->
val response = httpClient.put("$serverUrl/api/inventory") { val response = httpClient.put("$serverUrl/api/inventory") {
header("X-API-Key", apiKey) header("Authorization", "Bearer $token")
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(inventory) setBody(inventory)
} }
handleResponse(response) handleResponse(response)
} }
private suspend fun executeRequest( override suspend fun login(
block: suspend (serverUrl: String, apiKey: String) -> Result<InventoryDto> serverUrl: String,
): Result<InventoryDto> = withContext(Dispatchers.IO) { username: String,
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) password: String
if (serverUrl.isNullOrBlank()) { ): Result<Unit> = withContext(Dispatchers.IO) {
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"))
}
try { 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) { } catch (e: SocketTimeoutException) {
Result.failure(SyncError.Timeout(e)) Result.failure(SyncError.Timeout(e))
} catch (e: ConnectException) { } catch (e: ConnectException) {
@ -66,21 +78,84 @@ internal class SyncServiceImpl @Inject constructor(
} }
} }
private suspend fun handleResponse( override suspend fun logout() {
response: io.ktor.client.statement.HttpResponse settingsRepository.setValue(KEY_ACCESS_TOKEN, "")
): Result<InventoryDto> = when (response.status) { settingsRepository.setValue(KEY_REFRESH_TOKEN, "")
HttpStatusCode.OK -> Result.success(response.body()) settingsRepository.setValue(KEY_AUTH_USERNAME, "")
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
else -> Result.failure(
SyncError.ServerError(
statusCode = response.status.value,
message = response.status.description
)
)
} }
private suspend fun executeRequest(
block: suspend (serverUrl: String, token: String) -> Result<InventoryDto>
): Result<InventoryDto> = 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<InventoryDto> =
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 { private companion object {
const val KEY_SERVER_URL = "server_url" val KEY_SERVER_URL = SettingsKeys.SERVER_URL
const val KEY_API_KEY = "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
} }
} }

View file

@ -0,0 +1,16 @@
package de.krisenvorrat.app.data.sync
import kotlinx.coroutines.flow.SharedFlow
internal interface WebSocketClient {
val events: SharedFlow<WebSocketEvent>
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
}

View file

@ -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<WebSocketEvent>(extraBufferCapacity = 16)
override val events: SharedFlow<WebSocketEvent> = _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<WsServerEvent>(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
)

View file

@ -8,6 +8,8 @@ import dagger.hilt.components.SingletonComponent
import de.krisenvorrat.app.data.remote.OpenAiVisionService import de.krisenvorrat.app.data.remote.OpenAiVisionService
import de.krisenvorrat.app.data.remote.OpenAiVisionServiceImpl import de.krisenvorrat.app.data.remote.OpenAiVisionServiceImpl
import de.krisenvorrat.app.data.sync.SyncServiceImpl 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 de.krisenvorrat.app.domain.repository.SyncService
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
@ -27,6 +29,10 @@ internal abstract class NetworkModule {
@Singleton @Singleton
abstract fun bindSyncService(impl: SyncServiceImpl): SyncService abstract fun bindSyncService(impl: SyncServiceImpl): SyncService
@Binds
@Singleton
abstract fun bindWebSocketClient(impl: WebSocketClientImpl): WebSocketClient
@Binds @Binds
abstract fun bindOpenAiVisionService(impl: OpenAiVisionServiceImpl): OpenAiVisionService abstract fun bindOpenAiVisionService(impl: OpenAiVisionServiceImpl): OpenAiVisionService

View file

@ -5,7 +5,9 @@ internal object SettingsKeys {
const val DAILY_KCAL_PER_PERSON = "daily_kcal_per_person" const val DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
const val AGE_GROUPS = "age_groups" const val AGE_GROUPS = "age_groups"
const val SERVER_URL = "server_url" 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 SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
const val OPENAI_API_KEY = "openai_api_key" const val OPENAI_API_KEY = "openai_api_key"
} }

View file

@ -8,7 +8,7 @@ internal sealed class SyncError(message: String, cause: Throwable? = null) : Exc
SyncError("Zeitüberschreitung bei der Verbindung zum Server", cause) SyncError("Zeitüberschreitung bei der Verbindung zum Server", cause)
class AuthError : class AuthError :
SyncError("Authentifizierung fehlgeschlagen API-Key ungültig") SyncError("Authentifizierung fehlgeschlagen Sitzung abgelaufen oder nicht angemeldet")
class ServerError(val statusCode: Int, message: String) : class ServerError(val statusCode: Int, message: String) :
SyncError("Serverfehler ($statusCode): $message") SyncError("Serverfehler ($statusCode): $message")

View file

@ -5,4 +5,6 @@ import de.krisenvorrat.shared.model.InventoryDto
internal interface SyncService { internal interface SyncService {
suspend fun downloadInventory(): Result<InventoryDto> suspend fun downloadInventory(): Result<InventoryDto>
suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto>
suspend fun login(serverUrl: String, username: String, password: String): Result<Unit>
suspend fun logout()
} }

View file

@ -238,19 +238,74 @@ internal fun SettingsScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( if (uiState.isLoggedIn) {
value = uiState.apiKey, Row(
onValueChange = viewModel::onApiKeyChanged, modifier = Modifier.fillMaxWidth(),
label = { Text("API-Key") }, verticalAlignment = Alignment.CenterVertically,
visualTransformation = PasswordVisualTransformation(), horizontalArrangement = Arrangement.SpaceBetween
singleLine = true, ) {
modifier = Modifier.fillMaxWidth() 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)) Spacer(modifier = Modifier.height(16.dp))
val isSyncEnabled = uiState.serverUrl.isNotBlank() && val isSyncEnabled = uiState.isLoggedIn &&
uiState.apiKey.isNotBlank() && uiState.serverUrl.isNotBlank() &&
uiState.syncStatus !is SyncStatus.Running uiState.syncStatus !is SyncStatus.Running
Row( Row(

View file

@ -15,7 +15,12 @@ internal data class SettingsUiState(
val importResult: ImportResult? = null, val importResult: ImportResult? = null,
val pendingImportUri: Uri? = null, val pendingImportUri: Uri? = null,
val serverUrl: String = "", 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 syncStatus: SyncStatus = SyncStatus.Idle,
val lastSyncTime: String? = null, val lastSyncTime: String? = null,
val openAiApiKey: String = "" val openAiApiKey: String = ""

View file

@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext 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.AgeGroup
import de.krisenvorrat.app.domain.model.AgeGroupEntry import de.krisenvorrat.app.domain.model.AgeGroupEntry
import de.krisenvorrat.app.domain.model.SettingsKeys import de.krisenvorrat.app.domain.model.SettingsKeys
@ -33,6 +35,7 @@ internal class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val importExportRepository: ImportExportRepository, private val importExportRepository: ImportExportRepository,
private val syncService: SyncService, private val syncService: SyncService,
private val webSocketClient: WebSocketClient,
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) : ViewModel() { ) : ViewModel() {
@ -41,6 +44,19 @@ internal class SettingsViewModel @Inject constructor(
init { init {
loadSettings() 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() { private fun loadSettings() {
@ -48,7 +64,9 @@ internal class SettingsViewModel @Inject constructor(
try { try {
val ageGroups = loadAgeGroupsWithMigration() val ageGroups = loadAgeGroupsWithMigration()
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: "" 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 openAiApiKey = settingsRepository.getValue(KEY_OPENAI_API_KEY) ?: ""
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP) val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
@ -56,12 +74,17 @@ internal class SettingsViewModel @Inject constructor(
it.copy( it.copy(
ageGroups = ageGroups, ageGroups = ageGroups,
serverUrl = serverUrl, serverUrl = serverUrl,
apiKey = apiKey, isLoggedIn = isLoggedIn,
loggedInUsername = authUsername,
openAiApiKey = openAiApiKey, openAiApiKey = openAiApiKey,
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) }, lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
isLoading = false isLoading = false
) )
} }
if (isLoggedIn) {
webSocketClient.connect(serverUrl, accessToken)
}
} catch (e: Exception) { } catch (e: Exception) {
_uiState.update { _uiState.update {
it.copy( it.copy(
@ -121,8 +144,62 @@ internal class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(serverUrl = value, isSaved = false) } _uiState.update { it.copy(serverUrl = value, isSaved = false) }
} }
fun onApiKeyChanged(value: String) { fun onLoginUsernameChanged(value: String) {
_uiState.update { it.copy(apiKey = value, isSaved = false) } _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) { fun onOpenAiApiKeyChanged(value: String) {
@ -134,7 +211,6 @@ internal class SettingsViewModel @Inject constructor(
try { try {
settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson()) settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson())
settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl) 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) settingsRepository.setValue(KEY_OPENAI_API_KEY, _uiState.value.openAiApiKey)
_uiState.update { it.copy(isSaved = true) } _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_DAILY_KCAL_PER_PERSON = SettingsKeys.DAILY_KCAL_PER_PERSON
val KEY_AGE_GROUPS = SettingsKeys.AGE_GROUPS val KEY_AGE_GROUPS = SettingsKeys.AGE_GROUPS
val KEY_SERVER_URL = SettingsKeys.SERVER_URL 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_SYNC_LAST_TIMESTAMP = SettingsKeys.SYNC_LAST_TIMESTAMP
val KEY_OPENAI_API_KEY = SettingsKeys.OPENAI_API_KEY val KEY_OPENAI_API_KEY = SettingsKeys.OPENAI_API_KEY
} }

View file

@ -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("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 --- // --- downloadInventory ---
@ -87,7 +88,7 @@ class SyncServiceImplTest {
val engine = MockEngine { request -> val engine = MockEngine { request ->
assertEquals("/api/inventory", request.url.encodedPath) assertEquals("/api/inventory", request.url.encodedPath)
assertEquals(HttpMethod.Get, request.method) assertEquals(HttpMethod.Get, request.method)
assertEquals("test-key", request.headers["X-API-Key"]) assertEquals("Bearer test-token", request.headers["Authorization"])
respond( respond(
content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory), content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory),
status = HttpStatusCode.OK, status = HttpStatusCode.OK,
@ -175,9 +176,9 @@ class SyncServiceImplTest {
} }
@Test @Test
fun test_downloadInventory_noApiKey_returnsNotConfigured() = runTest { fun test_downloadInventory_noAccessToken_returnsNotConfigured() = runTest {
// Given // Given
setupSettings(apiKey = null) setupSettings(accessToken = null)
val engine = MockEngine { respondError(HttpStatusCode.OK) } val engine = MockEngine { respondError(HttpStatusCode.OK) }
httpClient = createClient(engine) httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository) syncService = SyncServiceImpl(httpClient, settingsRepository)
@ -199,7 +200,7 @@ class SyncServiceImplTest {
val engine = MockEngine { request -> val engine = MockEngine { request ->
assertEquals("/api/inventory", request.url.encodedPath) assertEquals("/api/inventory", request.url.encodedPath)
assertEquals(HttpMethod.Put, request.method) assertEquals(HttpMethod.Put, request.method)
assertEquals("test-key", request.headers["X-API-Key"]) assertEquals("Bearer test-token", request.headers["Authorization"])
assertEquals( assertEquals(
ContentType.Application.Json.toString(), ContentType.Application.Json.toString(),
request.body.contentType.toString() request.body.contentType.toString()

View file

@ -4,6 +4,8 @@ import android.content.Context
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import android.content.ContentResolver import android.content.ContentResolver
import android.net.Uri 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.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.model.SyncError import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.ImportExportRepository import de.krisenvorrat.app.domain.repository.ImportExportRepository
@ -45,6 +47,7 @@ class SettingsViewModelTest {
private lateinit var fakeSettingsRepository: FakeSettingsRepository private lateinit var fakeSettingsRepository: FakeSettingsRepository
private lateinit var fakeImportExportRepository: FakeImportExportRepository private lateinit var fakeImportExportRepository: FakeImportExportRepository
private lateinit var fakeSyncService: FakeSyncService private lateinit var fakeSyncService: FakeSyncService
private lateinit var fakeWebSocketClient: FakeWebSocketClient
private lateinit var mockContext: Context private lateinit var mockContext: Context
private lateinit var tempDir: File private lateinit var tempDir: File
private lateinit var viewModel: SettingsViewModel private lateinit var viewModel: SettingsViewModel
@ -55,6 +58,7 @@ class SettingsViewModelTest {
fakeSettingsRepository = FakeSettingsRepository() fakeSettingsRepository = FakeSettingsRepository()
fakeImportExportRepository = FakeImportExportRepository() fakeImportExportRepository = FakeImportExportRepository()
fakeSyncService = FakeSyncService() fakeSyncService = FakeSyncService()
fakeWebSocketClient = FakeWebSocketClient()
tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports") tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports")
tempDir.mkdirs() tempDir.mkdirs()
mockContext = mockk(relaxed = true) { mockContext = mockk(relaxed = true) {
@ -73,6 +77,7 @@ class SettingsViewModelTest {
settingsRepository = fakeSettingsRepository, settingsRepository = fakeSettingsRepository,
importExportRepository = fakeImportExportRepository, importExportRepository = fakeImportExportRepository,
syncService = fakeSyncService, syncService = fakeSyncService,
webSocketClient = fakeWebSocketClient,
context = mockContext context = mockContext
) )
@ -483,7 +488,8 @@ class SettingsViewModelTest {
fun test_init_withStoredSyncSettings_loadsSyncFields() = runTest(testDispatcher) { fun test_init_withStoredSyncSettings_loadsSyncFields() = runTest(testDispatcher) {
// Given // Given
fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL] = "https://example.com" 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" fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP] = "1715700000000"
viewModel = createViewModel() viewModel = createViewModel()
@ -494,7 +500,8 @@ class SettingsViewModelTest {
// Then // Then
val state = viewModel.uiState.value val state = viewModel.uiState.value
assertEquals("https://example.com", state.serverUrl) assertEquals("https://example.com", state.serverUrl)
assertEquals("my-secret-key", state.apiKey) assertTrue(state.isLoggedIn)
assertEquals("testuser", state.loggedInUsername)
assertNotNull(state.lastSyncTime) assertNotNull(state.lastSyncTime)
} }
@ -513,26 +520,84 @@ class SettingsViewModelTest {
} }
@Test @Test
fun test_onApiKeyChanged_updatesState() = runTest(testDispatcher) { fun test_onLoginUsernameChanged_updatesState() = runTest(testDispatcher) {
// Given // Given
viewModel = createViewModel() viewModel = createViewModel()
advanceUntilIdle() advanceUntilIdle()
// When // When
viewModel.onApiKeyChanged("new-api-key") viewModel.onLoginUsernameChanged("newuser")
// Then // Then
assertEquals("new-api-key", viewModel.uiState.value.apiKey) assertEquals("newuser", viewModel.uiState.value.loginUsername)
assertFalse(viewModel.uiState.value.isSaved)
} }
@Test @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 // Given
viewModel = createViewModel() viewModel = createViewModel()
advanceUntilIdle() advanceUntilIdle()
viewModel.onServerUrlChanged("https://myserver.com") viewModel.onServerUrlChanged("https://myserver.com")
viewModel.onApiKeyChanged("secret-key-123")
// When // When
viewModel.saveSettings() viewModel.saveSettings()
@ -541,7 +606,6 @@ class SettingsViewModelTest {
// Then // Then
assertTrue(viewModel.uiState.value.isSaved) assertTrue(viewModel.uiState.value.isSaved)
assertEquals("https://myserver.com", fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL]) assertEquals("https://myserver.com", fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL])
assertEquals("secret-key-123", fakeSettingsRepository.store[SettingsViewModel.KEY_API_KEY])
} }
@Test @Test
@ -600,7 +664,7 @@ class SettingsViewModelTest {
fun test_pullSync_authError_setsSyncStatusError() = runTest(testDispatcher) { fun test_pullSync_authError_setsSyncStatusError() = runTest(testDispatcher) {
// Given // Given
fakeSyncService.downloadShouldFail = true fakeSyncService.downloadShouldFail = true
fakeSyncService.downloadError = SyncError.AuthError() fakeSyncService.downloadError = de.krisenvorrat.app.domain.model.SyncError.AuthError()
viewModel = createViewModel() viewModel = createViewModel()
advanceUntilIdle() advanceUntilIdle()
@ -690,6 +754,7 @@ private class FakeImportExportRepository : ImportExportRepository {
private class FakeSyncService : SyncService { private class FakeSyncService : SyncService {
var uploadShouldFail = false var uploadShouldFail = false
var downloadShouldFail = false var downloadShouldFail = false
var loginShouldFail = false
var uploadError: Exception = RuntimeException("Upload failed") var uploadError: Exception = RuntimeException("Upload failed")
var downloadError: Exception = RuntimeException("Download failed") var downloadError: Exception = RuntimeException("Download failed")
var downloadResult = InventoryDto( var downloadResult = InventoryDto(
@ -714,6 +779,21 @@ private class FakeSyncService : SyncService {
if (uploadShouldFail) return Result.failure(uploadError) if (uploadShouldFail) return Result.failure(uploadError)
return Result.success(uploadResult) return Result.success(uploadResult)
} }
override suspend fun login(serverUrl: String, username: String, password: String): Result<Unit> {
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<WebSocketEvent>(extraBufferCapacity = 16)
override val events: kotlinx.coroutines.flow.SharedFlow<WebSocketEvent> = _events
var connectedUrl: String? = null
override fun connect(serverUrl: String, accessToken: String) { connectedUrl = serverUrl }
override fun disconnect() { connectedUrl = null }
} }
// endregion // endregion