From 423abc03af4986edc3d9e9aab7e94651dd55a079 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Fri, 19 Jun 2026 17:10:05 +0200 Subject: [PATCH] fix: show specific error diagnostics instead of generic connection failure Replace null-returning fetchModels/fetchConversations with FetchResult sealed interface. Error banner now shows the exact cause (HTTP status, DNS, SSL, timeout, JSON parsing) instead of "Verbindung zum Server fehlgeschlagen". --- .../java/dev/kaizen/app/chat/ChatScreen.kt | 26 ++++++---- .../main/java/dev/kaizen/app/net/KaizenApi.kt | 50 +++++++++++++++---- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 3f1c6f4..ae17f03 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -57,6 +57,7 @@ import dev.kaizen.app.haptics.rememberHaptics import dev.kaizen.app.net.ChatHttpException import dev.kaizen.app.net.ChatSpeed import dev.kaizen.app.net.ConversationSummary +import dev.kaizen.app.net.FetchResult import dev.kaizen.app.net.KaizenApi import dev.kaizen.app.net.KaizenModel import dev.kaizen.app.net.SaveMessage @@ -112,17 +113,21 @@ fun ChatScreen( LaunchedEffect(session.config?.baseUrl, session.config?.token) { val cfg = session.config ?: return@LaunchedEffect loadError = null - val fetchedModels = KaizenApi.fetchModels(cfg.baseUrl, cfg.token) - if (fetchedModels != null) { - models = fetchedModels - if (fetchedModels.isNotEmpty()) session.store.saveModelsCache(fetchedModels) + val errors = mutableListOf() + + when (val r = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)) { + is FetchResult.Ok -> { + models = r.data + if (r.data.isNotEmpty()) session.store.saveModelsCache(r.data) + } + is FetchResult.Fail -> errors.add(r.reason) } - val fetchedConvos = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) - if (fetchedConvos != null) { - conversations = fetchedConvos + when (val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)) { + is FetchResult.Ok -> conversations = r.data + is FetchResult.Fail -> errors.add(r.reason) } - if (fetchedModels == null || fetchedConvos == null) { - loadError = "Verbindung zum Server fehlgeschlagen" + if (errors.isNotEmpty()) { + loadError = errors.joinToString(" · ") } } @@ -139,7 +144,8 @@ fun ChatScreen( fun refreshConversations() { val cfg = session.config ?: return scope.launch { - KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)?.let { conversations = it } + val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) + if (r is FetchResult.Ok) conversations = r.data } } diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index e6ae8e9..89cec84 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -89,6 +89,26 @@ sealed interface LoginResult { /** Thrown by [KaizenApi.chat] when the backend rejects the request before streaming. */ class ChatHttpException(val code: Int) : IOException("chat failed: HTTP $code") +/** Result of a fetch that can either succeed or fail with a diagnostic message. */ +sealed interface FetchResult { + data class Ok(val data: T) : FetchResult + data class Fail(val reason: String) : FetchResult +} + +private fun diagnoseFetchError(endpoint: String, e: Exception): String { + val name = e.javaClass.simpleName + return when { + e is java.net.UnknownHostException -> "DNS: Host nicht gefunden" + e is java.net.ConnectException -> "Verbindung abgelehnt (${e.message})" + e is java.net.SocketTimeoutException -> "Timeout ($endpoint)" + e is javax.net.ssl.SSLException -> "SSL/TLS: ${e.message}" + e is javax.net.ssl.SSLHandshakeException -> "SSL-Zertifikat ungültig" + e is java.io.EOFException -> "Verbindung unerwartet geschlossen" + e is kotlinx.serialization.SerializationException -> "JSON-Parsing: ${e.message?.take(80)}" + else -> "$name: ${e.message?.take(100)}" + } +} + /** * Thin client over the Kaizen v1 API. One uniform `Authorization: Bearer` contract, * same against the official server or any self-host. The chat response is a @@ -145,8 +165,8 @@ object KaizenApi { } } - /** GET /api/v1/models — the full model catalog for the picker. Returns null on failure (with logged reason). */ - suspend fun fetchModels(baseUrl: String, token: String): List? = + /** GET /api/v1/models — the full model catalog for the picker. */ + suspend fun fetchModels(baseUrl: String, token: String): FetchResult> = withContext(Dispatchers.IO) { val req = Request.Builder() .url("$baseUrl/api/v1/models") @@ -155,19 +175,24 @@ object KaizenApi { try { client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) { + val reason = "models: HTTP ${resp.code}" + when (resp.code) { + 401 -> " (Token ungültig)" + 403 -> " (Zugriff verweigert)" + else -> "" + } Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl") - return@use null + return@use FetchResult.Fail(reason) } - json.decodeFromString(resp.body!!.string()).models + FetchResult.Ok(json.decodeFromString(resp.body!!.string()).models) } } catch (e: Exception) { Log.w(TAG, "fetchModels: ${e.javaClass.simpleName}: ${e.message}") - null + FetchResult.Fail(diagnoseFetchError("models", e)) } } - /** GET /api/v1/conversations — the sidebar list. Returns null on failure (with logged reason). */ - suspend fun fetchConversations(baseUrl: String, token: String): List? = + /** GET /api/v1/conversations — the sidebar list. */ + suspend fun fetchConversations(baseUrl: String, token: String): FetchResult> = withContext(Dispatchers.IO) { val req = Request.Builder() .url("$baseUrl/api/v1/conversations") @@ -176,14 +201,19 @@ object KaizenApi { try { client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) { + val reason = "conversations: HTTP ${resp.code}" + when (resp.code) { + 401 -> " (Token ungültig)" + 403 -> " (Zugriff verweigert)" + else -> "" + } Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl") - return@use null + return@use FetchResult.Fail(reason) } - json.decodeFromString(resp.body!!.string()).conversations + FetchResult.Ok(json.decodeFromString(resp.body!!.string()).conversations) } } catch (e: Exception) { Log.w(TAG, "fetchConversations: ${e.javaClass.simpleName}: ${e.message}") - null + FetchResult.Fail(diagnoseFetchError("conversations", e)) } }