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] **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)

View file

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

View file

@ -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<MdBlock> {
// ── 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)) {
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,27 +658,6 @@ 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,
@ -697,7 +668,6 @@ private fun ClickableMarkdownText(
modifier = modifier,
)
}
}
// ── 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) {
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<String> = emptyList(),
)
@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). */
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('/')

View file

@ -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<VersionSkew?>(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

View file

@ -41,6 +41,7 @@
<string name="error_rate_limited">Too many requests. Please wait.</string>
<string name="error_server">Server error (%1$d).</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 -->
<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_server">Fehler vom Server (%1$d).</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 -->
<string name="mode_standard">Standard</string>