From 7aefd7ba70edd023412973d890e6a37fbc36b5e0 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Tue, 23 Jun 2026 14:51:20 +0200 Subject: [PATCH] fix: migrate ClickableText to LinkAnnotation, add server version check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace deprecated ClickableText with LinkAnnotation.Url + Text (Compose native URI handling). Add GET /api/v1/meta call after login and on each app resume to detect API version skew — shows dismissible amber banner. --- CLAUDE.md | 4 +- .../java/dev/kaizen/app/chat/ChatScreen.kt | 25 +++++++ .../main/java/dev/kaizen/app/chat/Markdown.kt | 66 +++++-------------- .../main/java/dev/kaizen/app/net/KaizenApi.kt | 19 ++++++ .../java/dev/kaizen/app/net/ServerConfig.kt | 3 + .../dev/kaizen/app/net/SessionViewModel.kt | 17 +++++ app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 8 files changed, 86 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 336a398..db6284c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -376,7 +376,7 @@ All done: - [x] **Code block copy button** — tap-to-copy on fenced code blocks (clipboard + haptic feedback) - [x] **Horizontal rule / blockquote rendering** — `---` and `> quote` parsed and rendered - [x] **Table rendering** — `| header | header |` with separator detection, styled borders, header background, horizontal scroll for wide tables, inline markdown in cells -- [x] **Link rendering** — clickable via `ClickableText` + `ACTION_VIEW`, opens browser on tap +- [x] **Link rendering** — clickable via `LinkAnnotation.Url` + `LocalUriHandler`, opens browser on tap - [x] **Strikethrough** — `~~text~~` rendered with line-through + dimmed alpha - [x] **Task lists** — `- [ ]` / `- [x]` with Check/Square icons in accent color - [x] **Text selection** — `SelectionContainer` on user + assistant messages, long-press to select/copy/share @@ -395,7 +395,7 @@ All done: ### 3. Security hardening - [x] **Auto-logout on 401** — if the token is rejected, route to login screen instead of showing error banner -- [ ] **Server version check** — call `/api/v1/meta` after login to detect server skew and warn cleanly +- [x] **Server version check** — calls `GET /api/v1/meta` after login + on each foreground, compares `apiVersion` against `EXPECTED_API_VERSION`, shows dismissible amber warning banner on skew - [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — hardware-enforced, password fallback dialog when unavailable, biometric toggle in Settings, all unlock paths gated - [x] **Security settings** — biometric toggle, signed-in devices with remote revoke, password change - [x] **Password change** — `POST /api/v1/auth/change-password` (Argon2id, rate-limited) 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 bf847cf..03eedf3 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -409,6 +409,7 @@ fun ChatScreen( if (errors.isNotEmpty()) { loadError = errors.joinToString(" · ") } + scope.launch { session.checkServerVersion(cfg.baseUrl) } scope.launch { val ids = chat.conversationRepo.allUnlockedIds() chat.messageRepo.prefetchMissing(cfg.baseUrl, cfg.token, ids) @@ -1071,6 +1072,30 @@ fun ChatScreen( } } + // Version skew warning banner + val skew = session.versionSkew + if (skew != null) { + val warningColor = Color(0xFFD97706) + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = if (loadError != null) 100.dp else 62.dp, start = 24.dp, end = 24.dp) + .clip(KaizenShapes.sm) + .background(warningColor.copy(alpha = 0.15f)) + .border(1.dp, warningColor.copy(alpha = 0.3f), KaizenShapes.sm) + .clickable { session.dismissVersionWarning() } + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + stringResource(R.string.error_version_skew, skew.serverVersion, skew.expectedVersion), + color = warningColor, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + } + // 3. Floating Bottom Control Dock @OptIn(ExperimentalLayoutApi::class) val imeVisible = WindowInsets.isImeVisible diff --git a/app/src/main/java/dev/kaizen/app/chat/Markdown.kt b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt index 201fad4..d0dcfd3 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Markdown.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt @@ -1,7 +1,5 @@ package dev.kaizen.app.chat -import android.content.Intent -import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -21,7 +19,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText import dev.kaizen.app.ui.shape.KaizenShapes import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -38,8 +35,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import dev.kaizen.app.ui.theme.KaizenSemanticColors import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -204,8 +202,6 @@ private fun parseBlocks(src: String): List { // ── Inline markdown ──────────────────────────────────────────────────────────── -private const val LINK_TAG = "URL" - private fun inlineMarkdown( text: String, baseColor: Color, @@ -225,10 +221,8 @@ private fun inlineMarkdown( if (closeParen > closeBracket) { val linkText = src.substring(pos + 1, closeBracket) val url = src.substring(closeBracket + 2, closeParen) - pushStringAnnotation(tag = LINK_TAG, annotation = url) - withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { - append(linkText) - } + pushLink(LinkAnnotation.Url(url, TextLinkStyles(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)))) + append(linkText) pop() pos = closeParen + 1 continue @@ -440,7 +434,6 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Modifier) { val cs = MaterialTheme.colorScheme val isDark = isSystemInDarkTheme() - val context = LocalContext.current val blocks = remember(text) { parseBlocks(text) } val codeBg = if (isDark) Color(0xFF1A1E2A) else Color(0xFFF3F4F6) @@ -461,7 +454,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = is MdBlock.Heading -> { if (idx > 0) Spacer(Modifier.height(if (block.level == 1) 24.dp else 20.dp)) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor) - ClickableMarkdownText(rendered, context, cs.onBackground, + ClickableMarkdownText(rendered, cs.onBackground, fontSize = when (block.level) { 1 -> 26.sp; 2 -> 22.sp; else -> 18.sp }, fontWeight = when (block.level) { 1 -> FontWeight.W600; 2 -> FontWeight.W600; else -> FontWeight.W500 }, lineHeight = when (block.level) { 1 -> 34.sp; 2 -> 30.sp; else -> 26.sp }, @@ -593,7 +586,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = } val appendCursor = isLast && itemIdx == block.items.lastIndex val rendered = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor) - ClickableMarkdownText(rendered, context, cs.onBackground, + ClickableMarkdownText(rendered, cs.onBackground, fontSize = 16.sp, lineHeight = 26.sp, fontWeight = FontWeight.W300, modifier = Modifier.weight(1f), ) @@ -630,7 +623,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = ) Spacer(Modifier.width(12.dp)) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg, linkColor) - ClickableMarkdownText(rendered, context, cs.onSurfaceVariant, + ClickableMarkdownText(rendered, cs.onSurfaceVariant, fontSize = 15.sp, lineHeight = 24.sp, fontStyle = FontStyle.Italic, modifier = Modifier.weight(1f), @@ -642,7 +635,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = is MdBlock.Paragraph -> { if (idx > 0) Spacer(Modifier.height(12.dp)) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor) - ClickableMarkdownText(rendered, context, cs.onBackground, + ClickableMarkdownText(rendered, cs.onBackground, fontSize = 16.sp, lineHeight = 26.sp, fontWeight = FontWeight.W300, ) } @@ -653,12 +646,11 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = } } -// ── Clickable text (handles link taps) ───────────────────────────────────────── +// ── Clickable text (handles link taps via LinkAnnotation + LocalUriHandler) ──── @Composable private fun ClickableMarkdownText( text: AnnotatedString, - context: android.content.Context, color: Color, fontSize: androidx.compose.ui.unit.TextUnit = 16.sp, lineHeight: androidx.compose.ui.unit.TextUnit = 26.sp, @@ -666,37 +658,15 @@ private fun ClickableMarkdownText( fontStyle: FontStyle? = null, modifier: Modifier = Modifier, ) { - val hasLinks = text.getStringAnnotations(LINK_TAG, 0, text.length).isNotEmpty() - if (hasLinks) { - ClickableText( - text = text, - style = androidx.compose.ui.text.TextStyle( - color = color, - fontSize = fontSize, - lineHeight = lineHeight, - fontWeight = fontWeight, - fontStyle = fontStyle, - ), - modifier = modifier, - onClick = { offset -> - text.getStringAnnotations(LINK_TAG, offset, offset).firstOrNull()?.let { ann -> - try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ann.item))) - } catch (_: Exception) {} - } - }, - ) - } else { - Text( - text = text, - color = color, - fontSize = fontSize, - lineHeight = lineHeight, - fontWeight = fontWeight, - fontStyle = fontStyle, - modifier = modifier, - ) - } + Text( + text = text, + color = color, + fontSize = fontSize, + lineHeight = lineHeight, + fontWeight = fontWeight, + fontStyle = fontStyle, + modifier = modifier, + ) } // ── Copy button ──────────────────────────────────────────────────────────────── 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 e202d0e..8075a79 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -611,6 +611,19 @@ object KaizenApi { } } + suspend fun fetchMeta(baseUrl: String): MetaResponse? = withContext(Dispatchers.IO) { + val req = Request.Builder().url("$baseUrl/api/v1/meta").build() + try { + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@use null + json.decodeFromString(resp.body!!.string()) + } + } catch (e: Exception) { + Log.w(TAG, "fetchMeta: ${e.javaClass.simpleName}: ${e.message}") + null + } + } + suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) { try { val req = Request.Builder().url("$baseUrl/api/v1/meta").build() @@ -678,4 +691,10 @@ object KaizenApi { } } +@Serializable data class MetaResponse( + val app: String = "", + val apiVersion: Int = 0, + val features: List = emptyList(), +) + @Serializable private data class ErrorBody(val error: String = "unknown") diff --git a/app/src/main/java/dev/kaizen/app/net/ServerConfig.kt b/app/src/main/java/dev/kaizen/app/net/ServerConfig.kt index d41b26f..28e77bc 100644 --- a/app/src/main/java/dev/kaizen/app/net/ServerConfig.kt +++ b/app/src/main/java/dev/kaizen/app/net/ServerConfig.kt @@ -37,5 +37,8 @@ const val DEFAULT_BASE_URL = "https://ask.kryptomrx.de" /** Fallback model when the server reports no user default (GET /api/v1/me → defaultModel null). */ const val DEFAULT_MODEL = "vertex:gemini-2.5-flash" +/** The v1 API version this app build expects from the server. */ +const val EXPECTED_API_VERSION = 1 + /** Strips a trailing slash so "$baseUrl/api/v1/..." never double-slashes. */ fun normalizeBaseUrl(input: String): String = input.trim().trimEnd('/') diff --git a/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt index 3dda110..11a8c6f 100644 --- a/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt +++ b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt @@ -12,6 +12,8 @@ import androidx.compose.runtime.setValue * * Held at Activity scope (instantiated directly like SettingsViewModel) — no DI yet. */ +data class VersionSkew(val serverVersion: Int, val expectedVersion: Int) + class SessionViewModel(val store: SecureStore) { var config by mutableStateOf(store.load()) @@ -21,8 +23,13 @@ class SessionViewModel(val store: SecureStore) { var favorites by mutableStateOf(store.loadFavorites()) private set + var versionSkew by mutableStateOf(null) + private set + val isLoggedIn: Boolean get() = config != null + fun dismissVersionWarning() { versionSkew = null } + /** * In-app login: mint a token, then resolve the user's default model from the * server (falling back to [DEFAULT_MODEL]). On success the config is persisted @@ -36,12 +43,22 @@ class SessionViewModel(val store: SecureStore) { val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim()) store.save(cfg) config = cfg + checkServerVersion(baseUrl) result } else -> result } } + suspend fun checkServerVersion(baseUrl: String) { + val meta = KaizenApi.fetchMeta(baseUrl) ?: return + if (meta.apiVersion != EXPECTED_API_VERSION) { + versionSkew = VersionSkew(meta.apiVersion, EXPECTED_API_VERSION) + } else { + versionSkew = null + } + } + /** Switch the chat model (persisted). No-op if not logged in. */ fun setModel(modelId: String) { val cfg = config ?: return diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 205df5d..0591bc1 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -41,6 +41,7 @@ Too many requests. Please wait. Server error (%1$d). No connection to server. + Server API v%1$d — app expects v%2$d. Please update server or app. Standard diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7aa35fe..1ca1989 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,6 +43,7 @@ Zu viele Anfragen. Kurz warten. Fehler vom Server (%1$d). Keine Verbindung zum Server. + Server API v%1$d — App erwartet v%2$d. Bitte Server oder App aktualisieren. Standard