fix: migrate ClickableText to LinkAnnotation, add server version check

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.
This commit is contained in:
Bruno Deanoz 2026-06-23 14:51:20 +02:00
parent e6fa8ef8a6
commit 7aefd7ba70
8 changed files with 86 additions and 50 deletions

View file

@ -376,7 +376,7 @@ All done:
- [x] **Code block copy button** — tap-to-copy on fenced code blocks (clipboard + haptic feedback) - [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] **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] **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] **Strikethrough**`~~text~~` rendered with line-through + dimmed alpha
- [x] **Task lists**`- [ ]` / `- [x]` with Check/Square icons in accent color - [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 - [x] **Text selection**`SelectionContainer` on user + assistant messages, long-press to select/copy/share
@ -395,7 +395,7 @@ All done:
### 3. Security hardening ### 3. Security hardening
- [x] **Auto-logout on 401** — if the token is rejected, route to login screen instead of showing error banner - [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] **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] **Security settings** — biometric toggle, signed-in devices with remote revoke, password change
- [x] **Password change**`POST /api/v1/auth/change-password` (Argon2id, rate-limited) - [x] **Password change**`POST /api/v1/auth/change-password` (Argon2id, rate-limited)

View file

@ -409,6 +409,7 @@ fun ChatScreen(
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
loadError = errors.joinToString(" · ") loadError = errors.joinToString(" · ")
} }
scope.launch { session.checkServerVersion(cfg.baseUrl) }
scope.launch { scope.launch {
val ids = chat.conversationRepo.allUnlockedIds() val ids = chat.conversationRepo.allUnlockedIds()
chat.messageRepo.prefetchMissing(cfg.baseUrl, cfg.token, ids) 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 // 3. Floating Bottom Control Dock
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
val imeVisible = WindowInsets.isImeVisible val imeVisible = WindowInsets.isImeVisible

View file

@ -1,7 +1,5 @@
package dev.kaizen.app.chat package dev.kaizen.app.chat
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import dev.kaizen.app.ui.shape.KaizenShapes import dev.kaizen.app.ui.shape.KaizenShapes
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -38,8 +35,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import dev.kaizen.app.ui.theme.KaizenSemanticColors import dev.kaizen.app.ui.theme.KaizenSemanticColors
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString 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.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@ -204,8 +202,6 @@ private fun parseBlocks(src: String): List<MdBlock> {
// ── Inline markdown ──────────────────────────────────────────────────────────── // ── Inline markdown ────────────────────────────────────────────────────────────
private const val LINK_TAG = "URL"
private fun inlineMarkdown( private fun inlineMarkdown(
text: String, text: String,
baseColor: Color, baseColor: Color,
@ -225,10 +221,8 @@ private fun inlineMarkdown(
if (closeParen > closeBracket) { if (closeParen > closeBracket) {
val linkText = src.substring(pos + 1, closeBracket) val linkText = src.substring(pos + 1, closeBracket)
val url = src.substring(closeBracket + 2, closeParen) val url = src.substring(closeBracket + 2, closeParen)
pushStringAnnotation(tag = LINK_TAG, annotation = url) pushLink(LinkAnnotation.Url(url, TextLinkStyles(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline))))
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) {
append(linkText) append(linkText)
}
pop() pop()
pos = closeParen + 1 pos = closeParen + 1
continue 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) { fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val context = LocalContext.current
val blocks = remember(text) { parseBlocks(text) } val blocks = remember(text) { parseBlocks(text) }
val codeBg = if (isDark) Color(0xFF1A1E2A) else Color(0xFFF3F4F6) 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 -> { is MdBlock.Heading -> {
if (idx > 0) Spacer(Modifier.height(if (block.level == 1) 24.dp else 20.dp)) 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) 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 }, 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 }, 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 }, 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 appendCursor = isLast && itemIdx == block.items.lastIndex
val rendered = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor) 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, fontSize = 16.sp, lineHeight = 26.sp, fontWeight = FontWeight.W300,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@ -630,7 +623,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg, linkColor) 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, fontSize = 15.sp, lineHeight = 24.sp,
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@ -642,7 +635,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
is MdBlock.Paragraph -> { is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(12.dp)) if (idx > 0) Spacer(Modifier.height(12.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor) 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, 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 @Composable
private fun ClickableMarkdownText( private fun ClickableMarkdownText(
text: AnnotatedString, text: AnnotatedString,
context: android.content.Context,
color: Color, color: Color,
fontSize: androidx.compose.ui.unit.TextUnit = 16.sp, fontSize: androidx.compose.ui.unit.TextUnit = 16.sp,
lineHeight: androidx.compose.ui.unit.TextUnit = 26.sp, lineHeight: androidx.compose.ui.unit.TextUnit = 26.sp,
@ -666,27 +658,6 @@ private fun ClickableMarkdownText(
fontStyle: FontStyle? = null, fontStyle: FontStyle? = null,
modifier: Modifier = Modifier, 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 = text, text = text,
color = color, color = color,
@ -697,7 +668,6 @@ private fun ClickableMarkdownText(
modifier = modifier, modifier = modifier,
) )
} }
}
// ── Copy button ──────────────────────────────────────────────────────────────── // ── Copy button ────────────────────────────────────────────────────────────────

View file

@ -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<MetaResponse>(resp.body!!.string())
}
} catch (e: Exception) {
Log.w(TAG, "fetchMeta: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) { suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) {
try { try {
val req = Request.Builder().url("$baseUrl/api/v1/meta").build() 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<String> = emptyList(),
)
@Serializable private data class ErrorBody(val error: String = "unknown") @Serializable private data class ErrorBody(val error: String = "unknown")

View file

@ -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). */ /** Fallback model when the server reports no user default (GET /api/v1/me → defaultModel null). */
const val DEFAULT_MODEL = "vertex:gemini-2.5-flash" 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. */ /** Strips a trailing slash so "$baseUrl/api/v1/..." never double-slashes. */
fun normalizeBaseUrl(input: String): String = input.trim().trimEnd('/') fun normalizeBaseUrl(input: String): String = input.trim().trimEnd('/')

View file

@ -12,6 +12,8 @@ import androidx.compose.runtime.setValue
* *
* Held at Activity scope (instantiated directly like SettingsViewModel) no DI yet. * 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) { class SessionViewModel(val store: SecureStore) {
var config by mutableStateOf(store.load()) var config by mutableStateOf(store.load())
@ -21,8 +23,13 @@ class SessionViewModel(val store: SecureStore) {
var favorites by mutableStateOf(store.loadFavorites()) var favorites by mutableStateOf(store.loadFavorites())
private set private set
var versionSkew by mutableStateOf<VersionSkew?>(null)
private set
val isLoggedIn: Boolean get() = config != null 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 * 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 * 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()) val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
store.save(cfg) store.save(cfg)
config = cfg config = cfg
checkServerVersion(baseUrl)
result result
} }
else -> 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. */ /** Switch the chat model (persisted). No-op if not logged in. */
fun setModel(modelId: String) { fun setModel(modelId: String) {
val cfg = config ?: return val cfg = config ?: return

View file

@ -41,6 +41,7 @@
<string name="error_rate_limited">Too many requests. Please wait.</string> <string name="error_rate_limited">Too many requests. Please wait.</string>
<string name="error_server">Server error (%1$d).</string> <string name="error_server">Server error (%1$d).</string>
<string name="error_no_connection">No connection to server.</string> <string name="error_no_connection">No connection to server.</string>
<string name="error_version_skew">Server API v%1$d — app expects v%2$d. Please update server or app.</string>
<!-- Mode pills --> <!-- Mode pills -->
<string name="mode_standard">Standard</string> <string name="mode_standard">Standard</string>

View file

@ -43,6 +43,7 @@
<string name="error_rate_limited">Zu viele Anfragen. Kurz warten.</string> <string name="error_rate_limited">Zu viele Anfragen. Kurz warten.</string>
<string name="error_server">Fehler vom Server (%1$d).</string> <string name="error_server">Fehler vom Server (%1$d).</string>
<string name="error_no_connection">Keine Verbindung zum Server.</string> <string name="error_no_connection">Keine Verbindung zum Server.</string>
<string name="error_version_skew">Server API v%1$d — App erwartet v%2$d. Bitte Server oder App aktualisieren.</string>
<!-- Mode pills --> <!-- Mode pills -->
<string name="mode_standard">Standard</string> <string name="mode_standard">Standard</string>