fix: close biometric bypass on locked conversations
Three paths in openConversation() called unlockAndOpen() without any
authentication when biometrics were unavailable. Sidebar ToggleLock
also sent PATCH { locked: false } without auth.
- Unified requestBiometricOrPassword() gates all unlock paths
- PasswordUnlockDialog as fallback when biometrics unavailable
- KaizenApi.requestUnlock() sends password to server for verification
- All unlock flows (open chat + sidebar toggle) now require auth
This commit is contained in:
parent
f8e1747df4
commit
15f5781824
6 changed files with 327 additions and 37 deletions
|
|
@ -35,7 +35,7 @@ Make the **native Android app** (`kaizen-app`) a valid daily-driver that talks t
|
|||
### Package structure (`app/src/main/java/dev/kaizen/app/`)
|
||||
|
||||
```
|
||||
auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject)
|
||||
auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject), PasswordUnlockDialog
|
||||
chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels,
|
||||
ModelSheet, Markdown, Background
|
||||
db/ Room offline cache: KaizenDatabase, Entities, DAOs,
|
||||
|
|
@ -227,7 +227,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
|
|||
| `f3d8154` | **Sidebar redesign** — GlassSurface opacity 0.92/0.88, flat conversation rows, 3-dot context menu (rename/delete/pin/lock via `PATCH`/`DELETE /api/v1/conversations`), smaller avatar (30dp), logout integrated into user card |
|
||||
| `f3d8154` | **Markdown typography overhaul** — line-height 23→26sp (×1.625), paragraph spacing 6→12dp, heading margins 18dp/8dp, list spacing doubled. Matches Gemini readability |
|
||||
| `532cb8c` | **Reasoning + sources persistence** — saved to server (`SaveMessage.reasoning/sources`), loaded back (`StoredMessage`), cached in Room (`MessageEntity` + `SearchSource` TypeConverter). Reasoning rendered above content, sources below |
|
||||
| `88559bf` | **Biometric unlock for locked conversations** — Android KeyStore AES key with `setUserAuthenticationRequired(true)` + CryptoObject, hardware-enforced. Server unlock token via `POST /api/v1/auth/unlock` + `X-Unlock-Token` header. Auto-lock on app background |
|
||||
| `88559bf` | **Biometric unlock for locked conversations** — Android KeyStore AES key with `setUserAuthenticationRequired(true)` + CryptoObject, hardware-enforced. Server unlock token via `POST /api/v1/auth/unlock` + `X-Unlock-Token` header. Auto-lock on app background. Password fallback dialog (`PasswordUnlockDialog`) when biometrics unavailable — server verifies password via Argon2id. All unlock paths (open chat, sidebar toggle) gated behind `requestBiometricOrPassword()` |
|
||||
| `30d3f28` | **Search mode functional** — `webSearch: true` in ChatRequest triggers agentive web search. Sources/query parsed by StreamConsumer, rendered in collapsible SourcesBlock with clickable links (ACTION_VIEW) |
|
||||
| `f6dc5db` | **Reasoning presets + sampling controls** — 8-preset dropdown (Sofort→Maximal) sends `reasoningEffort`/`preferThroughput`. Sampling popover with per-parameter toggle + slider for Temperature/Top-P/Top-K |
|
||||
| `3ca1d2c` | **Token counter** — parses usage sentinel from stream (`inputTokens`/`outputTokens`), shows ⚡ badge in top bar. Sums tokens from server messages for loaded chats. Context length from model's `context_length` field |
|
||||
|
|
@ -259,7 +259,8 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
|
|||
|
||||
- **Room crash on launch** — schema hash mismatch after adding `reasoning`/`sources` fields to `MessageEntity` with version still at 1. Fix: bump to version 2, `fallbackToDestructiveMigration(true)`.
|
||||
- **Keyboard handling** — `imePadding()` on Compose Box/Column caused input field to float to top of screen OR be hidden behind keyboard. Fix: read `WindowInsets.ime.getBottom()` directly, apply as negative Y `offset()` on the bottom dock. `windowSoftInputMode=adjustNothing` in manifest.
|
||||
- **Locked chats not opening** — `context as? FragmentActivity` silently returned null (ContextWrapper). Fix: walk ContextWrapper chain. Added fallback when biometrics unavailable.
|
||||
- **Locked chats not opening** — `context as? FragmentActivity` silently returned null (ContextWrapper). Fix: walk ContextWrapper chain.
|
||||
- **Locked chats bypassed biometric auth** (06-22) — three paths in `openConversation()` called `unlockAndOpen()` without any authentication when biometrics were unavailable (FragmentActivity null, `isAvailable()` false, `Result.NotAvailable`). Sidebar `ToggleLock` also sent `PATCH { locked: false }` without auth. Fix: unified `requestBiometricOrPassword()` gates all unlock paths; `PasswordUnlockDialog` as fallback when biometrics unavailable; server `POST /api/v1/auth/unlock` now optionally verifies password (Argon2id) when `{ password }` provided.
|
||||
- **Error banner stuck** — `loadError` never cleared after unlock failure. Fix: auto-clear after 4s, reset on navigation.
|
||||
- **Reasoning/sources lost** — not persisted to server or Room cache. Fix: added fields to `SaveMessage`, `StoredMessage`, `MessageEntity`, `Converters`, `Mappers`.
|
||||
- **Reasoning at bottom** — rendered below content like sources. Fix: moved above content in `ChatComponents`.
|
||||
|
|
|
|||
207
app/src/main/java/dev/kaizen/app/auth/PasswordUnlockDialog.kt
Normal file
207
app/src/main/java/dev/kaizen/app/auth/PasswordUnlockDialog.kt
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package dev.kaizen.app.auth
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import dev.kaizen.app.R
|
||||
import dev.kaizen.app.ui.effect.GlassSurface
|
||||
import dev.kaizen.app.ui.effect.GlassTiers
|
||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
||||
|
||||
@Composable
|
||||
fun PasswordUnlockDialog(
|
||||
onSubmit: (password: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
error: String? = null,
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
val isDark = isSystemInDarkTheme()
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
GlassSurface(
|
||||
shape = KaizenShapes.xl,
|
||||
tintAlpha = GlassTiers.card(isDark),
|
||||
) {
|
||||
Column(Modifier.padding(24.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_password_title),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.unlock_password_subtitle),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
Spacer(Modifier.size(20.dp))
|
||||
|
||||
PasswordField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
onImeDone = { if (password.isNotBlank()) onSubmit(password) },
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(error, color = Color(0xFFEF4444), fontSize = 12.sp)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(20.dp))
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
DialogButton(
|
||||
label = stringResource(R.string.unlock_password_cancel),
|
||||
enabled = true,
|
||||
primary = false,
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
DialogButton(
|
||||
label = stringResource(R.string.unlock_password_submit),
|
||||
enabled = password.isNotBlank(),
|
||||
primary = true,
|
||||
onClick = { onSubmit(password) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onImeDone: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val accent = LocalKaizenAccent.current
|
||||
|
||||
val glassBg = if (isDark) {
|
||||
Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.72f), Color(0xFF0F172A).copy(alpha = 0.85f)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.90f), Color(0xFFEEF2F6).copy(alpha = 0.80f)))
|
||||
}
|
||||
val glassBorder = if (isDark) {
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.White.copy(alpha = 0.04f)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.60f), Color.Black.copy(alpha = 0.06f)))
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.shadow(elevation = 4.dp, shape = KaizenShapes.md, clip = false)
|
||||
.clip(KaizenShapes.md)
|
||||
.background(glassBg)
|
||||
.border(1.dp, glassBorder, KaizenShapes.md)
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_password_placeholder),
|
||||
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = TextStyle(color = cs.onBackground, fontSize = 15.sp),
|
||||
cursorBrush = SolidColor(accent.primary),
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { onImeDone() }),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialogButton(
|
||||
label: String,
|
||||
enabled: Boolean,
|
||||
primary: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
val accent = LocalKaizenAccent.current
|
||||
val fill = if (primary && enabled) {
|
||||
Modifier.background(Brush.linearGradient(listOf(accent.primary, accent.primaryDeep)))
|
||||
} else {
|
||||
Modifier.background(cs.surface.copy(alpha = 0.6f))
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.heightIn(min = 44.dp)
|
||||
.shadow(elevation = if (primary) 4.dp else 2.dp, shape = KaizenShapes.md, clip = false)
|
||||
.clip(KaizenShapes.md)
|
||||
.then(fill)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (primary && enabled) accent.onPrimary else cs.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (primary) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +134,11 @@ fun ChatScreen(
|
|||
|
||||
var loadError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Password unlock dialog state — shown when biometrics unavailable for locked chats
|
||||
var passwordUnlockTarget by remember { mutableStateOf<ConversationSummary?>(null) }
|
||||
var passwordUnlockError by remember { mutableStateOf<String?>(null) }
|
||||
var passwordUnlockForToggle by remember { mutableStateOf(false) }
|
||||
|
||||
// Pending file attachments — uploaded immediately on pick, cleared on send.
|
||||
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
|
||||
val context = LocalContext.current
|
||||
|
|
@ -229,16 +234,23 @@ fun ChatScreen(
|
|||
chatModel = null
|
||||
}
|
||||
|
||||
fun unlockAndOpen(summary: ConversationSummary) {
|
||||
fun unlockAndOpen(summary: ConversationSummary, password: String? = null) {
|
||||
val cfg = session.config ?: return
|
||||
scope.launch {
|
||||
loadError = null
|
||||
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token)
|
||||
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password)
|
||||
if (unlockToken == null) {
|
||||
loadError = context.getString(R.string.biometric_failed)
|
||||
scope.launch { kotlinx.coroutines.delay(4000); if (loadError == context.getString(R.string.biometric_failed)) loadError = null }
|
||||
if (password != null) {
|
||||
passwordUnlockError = context.getString(R.string.unlock_password_wrong)
|
||||
} else {
|
||||
loadError = context.getString(R.string.biometric_failed)
|
||||
scope.launch { kotlinx.coroutines.delay(4000); if (loadError == context.getString(R.string.biometric_failed)) loadError = null }
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
passwordUnlockTarget = null
|
||||
passwordUnlockError = null
|
||||
passwordUnlockForToggle = false
|
||||
val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken)
|
||||
if (stored.isNotEmpty()) {
|
||||
messages.clear()
|
||||
|
|
@ -261,35 +273,45 @@ fun ChatScreen(
|
|||
}
|
||||
}
|
||||
|
||||
fun showPasswordFallback(summary: ConversationSummary, forToggle: Boolean = false) {
|
||||
passwordUnlockTarget = summary
|
||||
passwordUnlockError = null
|
||||
passwordUnlockForToggle = forToggle
|
||||
}
|
||||
|
||||
fun requestBiometricOrPassword(
|
||||
summary: ConversationSummary,
|
||||
forToggle: Boolean = false,
|
||||
onBiometricSuccess: () -> Unit,
|
||||
) {
|
||||
var activity: androidx.fragment.app.FragmentActivity? = null
|
||||
var ctx = context
|
||||
while (ctx is android.content.ContextWrapper) {
|
||||
if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break }
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
if (activity == null || !dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) {
|
||||
showPasswordFallback(summary, forToggle)
|
||||
return
|
||||
}
|
||||
dev.kaizen.app.auth.BiometricUnlock.authenticate(
|
||||
activity = activity,
|
||||
title = context.getString(R.string.biometric_title),
|
||||
subtitle = context.getString(R.string.biometric_subtitle),
|
||||
negativeButtonText = context.getString(R.string.biometric_cancel),
|
||||
) { result ->
|
||||
when (result) {
|
||||
is dev.kaizen.app.auth.BiometricUnlock.Result.Success -> onBiometricSuccess()
|
||||
is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> showPasswordFallback(summary, forToggle)
|
||||
is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openConversation(summary: ConversationSummary) {
|
||||
val cfg = session.config ?: return
|
||||
if (summary.locked) {
|
||||
var activity: androidx.fragment.app.FragmentActivity? = null
|
||||
var ctx = context
|
||||
while (ctx is android.content.ContextWrapper) {
|
||||
if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break }
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
if (activity == null) {
|
||||
unlockAndOpen(summary)
|
||||
return
|
||||
}
|
||||
if (!dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) {
|
||||
unlockAndOpen(summary)
|
||||
return
|
||||
}
|
||||
dev.kaizen.app.auth.BiometricUnlock.authenticate(
|
||||
activity = activity,
|
||||
title = context.getString(R.string.biometric_title),
|
||||
subtitle = context.getString(R.string.biometric_subtitle),
|
||||
negativeButtonText = context.getString(R.string.biometric_cancel),
|
||||
) { result ->
|
||||
when (result) {
|
||||
is dev.kaizen.app.auth.BiometricUnlock.Result.Success -> unlockAndOpen(summary)
|
||||
is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> unlockAndOpen(summary)
|
||||
is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled */ }
|
||||
}
|
||||
}
|
||||
requestBiometricOrPassword(summary) { unlockAndOpen(summary) }
|
||||
return
|
||||
}
|
||||
viewingLockedChat = false
|
||||
|
|
@ -534,8 +556,22 @@ fun ChatScreen(
|
|||
refreshConversations()
|
||||
}
|
||||
is SidebarAction.ToggleLock -> {
|
||||
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to action.locked))
|
||||
refreshConversations()
|
||||
if (action.locked) {
|
||||
// Locking — no auth needed
|
||||
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to true))
|
||||
refreshConversations()
|
||||
} else {
|
||||
// Unlocking — require biometric or password
|
||||
val summary = conversations.find { it.id == action.id }
|
||||
if (summary != null) {
|
||||
requestBiometricOrPassword(summary, forToggle = true) {
|
||||
scope.launch {
|
||||
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to false))
|
||||
refreshConversations()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -800,6 +836,38 @@ fun ChatScreen(
|
|||
onDismiss = { showModelSheet = false },
|
||||
)
|
||||
}
|
||||
|
||||
// Password fallback dialog for locked conversations
|
||||
val pwTarget = passwordUnlockTarget
|
||||
if (pwTarget != null) {
|
||||
dev.kaizen.app.auth.PasswordUnlockDialog(
|
||||
onSubmit = { password ->
|
||||
val cfg = session.config ?: return@PasswordUnlockDialog
|
||||
if (passwordUnlockForToggle) {
|
||||
scope.launch {
|
||||
val token = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password)
|
||||
if (token == null) {
|
||||
passwordUnlockError = context.getString(R.string.unlock_password_wrong)
|
||||
} else {
|
||||
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, pwTarget.id, mapOf("locked" to false))
|
||||
refreshConversations()
|
||||
passwordUnlockTarget = null
|
||||
passwordUnlockError = null
|
||||
passwordUnlockForToggle = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
unlockAndOpen(pwTarget, password)
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
passwordUnlockTarget = null
|
||||
passwordUnlockError = null
|
||||
passwordUnlockForToggle = false
|
||||
},
|
||||
error = passwordUnlockError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ data class ConversationSummary(
|
|||
)
|
||||
|
||||
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
|
||||
@Serializable private data class UnlockRequest(val password: String? = null)
|
||||
@Serializable private data class UnlockResponse(val unlockToken: String = "")
|
||||
@Serializable private data class CreateConvoRequest(val title: String? = null)
|
||||
@Serializable private data class CreatedConversation(val id: String)
|
||||
|
|
@ -371,11 +372,12 @@ object KaizenApi {
|
|||
}
|
||||
|
||||
/** POST /api/v1/auth/unlock — get a short-lived unlock token for locked conversations. */
|
||||
suspend fun requestUnlock(baseUrl: String, token: String): String? =
|
||||
suspend fun requestUnlock(baseUrl: String, token: String, password: String? = null): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val body = json.encodeToString(UnlockRequest(password))
|
||||
val req = Request.Builder()
|
||||
.url("$baseUrl/api/v1/auth/unlock")
|
||||
.post("{}".toRequestBody(jsonMedia))
|
||||
.post(body.toRequestBody(jsonMedia))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,12 @@
|
|||
<string name="biometric_not_available">Biometrics not available</string>
|
||||
<string name="biometric_failed">Authentication failed</string>
|
||||
<string name="unlock_tap_to_unlock">Tap to unlock</string>
|
||||
<string name="unlock_password_title">Unlock Chat</string>
|
||||
<string name="unlock_password_subtitle">Enter your password to unlock this chat</string>
|
||||
<string name="unlock_password_placeholder">Password</string>
|
||||
<string name="unlock_password_submit">Unlock</string>
|
||||
<string name="unlock_password_cancel">Cancel</string>
|
||||
<string name="unlock_password_wrong">Wrong password</string>
|
||||
|
||||
<!-- Model sheet -->
|
||||
<string name="model_sheet_title">Select Model</string>
|
||||
|
|
|
|||
|
|
@ -98,6 +98,12 @@
|
|||
<string name="biometric_not_available">Biometrie nicht verfügbar</string>
|
||||
<string name="biometric_failed">Authentifizierung fehlgeschlagen</string>
|
||||
<string name="unlock_tap_to_unlock">Tippen zum Entsperren</string>
|
||||
<string name="unlock_password_title">Chat entsperren</string>
|
||||
<string name="unlock_password_subtitle">Gib dein Passwort ein, um den Chat zu entsperren</string>
|
||||
<string name="unlock_password_placeholder">Passwort</string>
|
||||
<string name="unlock_password_submit">Entsperren</string>
|
||||
<string name="unlock_password_cancel">Abbrechen</string>
|
||||
<string name="unlock_password_wrong">Falsches Passwort</string>
|
||||
|
||||
<!-- Model sheet -->
|
||||
<string name="model_sheet_title">Modell auswählen</string>
|
||||
|
|
|
|||
Loading…
Reference in a new issue