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:
Bruno Deanoz 2026-06-22 13:27:55 +02:00
parent f8e1747df4
commit 15f5781824
6 changed files with 327 additions and 37 deletions

View file

@ -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/`) ### 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, chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels,
ModelSheet, Markdown, Background ModelSheet, Markdown, Background
db/ Room offline cache: KaizenDatabase, Entities, DAOs, 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` | **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 | | `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 | | `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) | | `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 | | `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 | | `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)`. - **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. - **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. - **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/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`. - **Reasoning at bottom** — rendered below content like sources. Fix: moved above content in `ChatComponents`.

View 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,
)
}
}

View file

@ -134,6 +134,11 @@ fun ChatScreen(
var loadError by remember { mutableStateOf<String?>(null) } 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. // Pending file attachments — uploaded immediately on pick, cleared on send.
val pendingFiles = remember { mutableStateListOf<PendingFile>() } val pendingFiles = remember { mutableStateListOf<PendingFile>() }
val context = LocalContext.current val context = LocalContext.current
@ -229,16 +234,23 @@ fun ChatScreen(
chatModel = null chatModel = null
} }
fun unlockAndOpen(summary: ConversationSummary) { fun unlockAndOpen(summary: ConversationSummary, password: String? = null) {
val cfg = session.config ?: return val cfg = session.config ?: return
scope.launch { scope.launch {
loadError = null loadError = null
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token) val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password)
if (unlockToken == null) { if (unlockToken == null) {
loadError = context.getString(R.string.biometric_failed) if (password != null) {
scope.launch { kotlinx.coroutines.delay(4000); if (loadError == context.getString(R.string.biometric_failed)) loadError = 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 return@launch
} }
passwordUnlockTarget = null
passwordUnlockError = null
passwordUnlockForToggle = false
val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken) val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken)
if (stored.isNotEmpty()) { if (stored.isNotEmpty()) {
messages.clear() 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) { fun openConversation(summary: ConversationSummary) {
val cfg = session.config ?: return val cfg = session.config ?: return
if (summary.locked) { if (summary.locked) {
var activity: androidx.fragment.app.FragmentActivity? = null requestBiometricOrPassword(summary) { unlockAndOpen(summary) }
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 */ }
}
}
return return
} }
viewingLockedChat = false viewingLockedChat = false
@ -534,8 +556,22 @@ fun ChatScreen(
refreshConversations() refreshConversations()
} }
is SidebarAction.ToggleLock -> { is SidebarAction.ToggleLock -> {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to action.locked)) if (action.locked) {
refreshConversations() // 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 }, 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,
)
}
} }
} }
} }

View file

@ -63,6 +63,7 @@ data class ConversationSummary(
) )
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList()) @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 UnlockResponse(val unlockToken: String = "")
@Serializable private data class CreateConvoRequest(val title: String? = null) @Serializable private data class CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String) @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. */ /** 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) { withContext(Dispatchers.IO) {
val body = json.encodeToString(UnlockRequest(password))
val req = Request.Builder() val req = Request.Builder()
.url("$baseUrl/api/v1/auth/unlock") .url("$baseUrl/api/v1/auth/unlock")
.post("{}".toRequestBody(jsonMedia)) .post(body.toRequestBody(jsonMedia))
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.build() .build()
try { try {

View file

@ -96,6 +96,12 @@
<string name="biometric_not_available">Biometrics not available</string> <string name="biometric_not_available">Biometrics not available</string>
<string name="biometric_failed">Authentication failed</string> <string name="biometric_failed">Authentication failed</string>
<string name="unlock_tap_to_unlock">Tap to unlock</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 --> <!-- Model sheet -->
<string name="model_sheet_title">Select Model</string> <string name="model_sheet_title">Select Model</string>

View file

@ -98,6 +98,12 @@
<string name="biometric_not_available">Biometrie nicht verfügbar</string> <string name="biometric_not_available">Biometrie nicht verfügbar</string>
<string name="biometric_failed">Authentifizierung fehlgeschlagen</string> <string name="biometric_failed">Authentifizierung fehlgeschlagen</string>
<string name="unlock_tap_to_unlock">Tippen zum Entsperren</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 --> <!-- Model sheet -->
<string name="model_sheet_title">Modell auswählen</string> <string name="model_sheet_title">Modell auswählen</string>