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:
parent
e6fa8ef8a6
commit
7aefd7ba70
8 changed files with 86 additions and 50 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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('/')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue