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] **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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,37 +658,15 @@ 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()
|
Text(
|
||||||
if (hasLinks) {
|
text = text,
|
||||||
ClickableText(
|
color = color,
|
||||||
text = text,
|
fontSize = fontSize,
|
||||||
style = androidx.compose.ui.text.TextStyle(
|
lineHeight = lineHeight,
|
||||||
color = color,
|
fontWeight = fontWeight,
|
||||||
fontSize = fontSize,
|
fontStyle = fontStyle,
|
||||||
lineHeight = lineHeight,
|
modifier = modifier,
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Copy button ────────────────────────────────────────────────────────────────
|
// ── 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) {
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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('/')
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue