fix locked chat not opening — context cast + biometric fallback

Two issues prevented locked chats from opening:

1. context as? FragmentActivity silently returned null on some
   devices where LocalContext.current is a ContextWrapper. Fix:
   walk the ContextWrapper chain to find the FragmentActivity.

2. No fallback when biometrics unavailable — the chat just stayed
   locked with no feedback. Fix: if biometrics not available (no
   fingerprint enrolled, no hardware), skip biometric prompt and
   unlock directly via server token. Biometric is still enforced
   when available.

Extracted unlockAndOpen() to avoid duplicating the server unlock
+ message loading logic across biometric/fallback paths.
This commit is contained in:
Bruno Deanoz 2026-06-22 01:10:57 +02:00
parent ba01902b08
commit 27deaca2e4

View file

@ -225,10 +225,53 @@ fun ChatScreen(
usedTokens = 0
}
fun unlockAndOpen(summary: ConversationSummary) {
val cfg = session.config ?: return
scope.launch {
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token)
if (unlockToken == null) {
loadError = context.getString(R.string.biometric_failed)
return@launch
}
val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken)
if (stored.isNotEmpty()) {
messages.clear()
stored.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
),
)
}
conversationId = summary.id
viewingLockedChat = true
}
}
}
fun openConversation(summary: ConversationSummary) {
val cfg = session.config ?: return
if (summary.locked) {
val activity = context as? androidx.fragment.app.FragmentActivity ?: return
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),
@ -236,38 +279,9 @@ fun ChatScreen(
negativeButtonText = context.getString(R.string.biometric_cancel),
) { result ->
when (result) {
is dev.kaizen.app.auth.BiometricUnlock.Result.Success -> {
scope.launch {
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token)
if (unlockToken == null) {
loadError = context.getString(R.string.biometric_failed)
return@launch
}
val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken)
if (stored.isNotEmpty()) {
messages.clear()
stored.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
),
)
}
conversationId = summary.id
viewingLockedChat = true
}
}
}
is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> {
loadError = context.getString(R.string.biometric_not_available)
}
is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled or failed, do nothing */ }
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