Add biometric lock and rate limit handling

Biometric lock: opt-in Face ID/Touch ID/fingerprint app lock with toggle
in ProfileScreen. Locks on background, requires auth on foreground return.
Platform implementations: BiometricPrompt (Android), LAContext (iOS).

Rate limit: 429 responses parsed with Retry-After header, user-friendly
error messages in all 10 locales, retry plugin respects 429.
ErrorMessageParser updated for both iOS Swift and KMM.
This commit is contained in:
Trey T
2026-03-26 14:37:04 -05:00
parent 334767cee7
commit 0d80df07f6
31 changed files with 871 additions and 7 deletions

View File

@@ -421,6 +421,8 @@
<string name="error_network">Netzwerkfehler. Uberprufen Sie Ihre Verbindung.</string>
<string name="error_timeout">Zeituberschreitung. Bitte versuchen Sie es erneut.</string>
<string name="error_server">Serverfehler. Bitte versuchen Sie es spater erneut.</string>
<string name="error_rate_limit">Zu viele Anfragen. Bitte versuchen Sie es spater erneut.</string>
<string name="error_rate_limit_with_delay">Zu viele Anfragen. Bitte versuchen Sie es in %1$d Sekunden erneut.</string>
<string name="error_unauthorized">Sitzung abgelaufen. Bitte melden Sie sich erneut an.</string>
<string name="error_not_found">Nicht gefunden</string>
<string name="error_invalid_input">Bitte uberprufen Sie Ihre Eingabe</string>

View File

@@ -421,6 +421,8 @@
<string name="error_network">Error de red. Verifica tu conexion.</string>
<string name="error_timeout">Tiempo de espera agotado. Intenta de nuevo.</string>
<string name="error_server">Error del servidor. Intenta mas tarde.</string>
<string name="error_rate_limit">Demasiadas solicitudes. Intenta de nuevo mas tarde.</string>
<string name="error_rate_limit_with_delay">Demasiadas solicitudes. Intenta de nuevo en %1$d segundos.</string>
<string name="error_unauthorized">Sesion expirada. Inicia sesion de nuevo.</string>
<string name="error_not_found">No encontrado</string>
<string name="error_invalid_input">Verifica los datos ingresados</string>

View File

@@ -421,6 +421,8 @@
<string name="error_network">Erreur reseau. Verifiez votre connexion.</string>
<string name="error_timeout">Delai d\'attente depasse. Veuillez reessayer.</string>
<string name="error_server">Erreur serveur. Veuillez reessayer plus tard.</string>
<string name="error_rate_limit">Trop de requetes. Veuillez reessayer plus tard.</string>
<string name="error_rate_limit_with_delay">Trop de requetes. Veuillez reessayer dans %1$d secondes.</string>
<string name="error_unauthorized">Session expiree. Veuillez vous reconnecter.</string>
<string name="error_not_found">Non trouve</string>
<string name="error_invalid_input">Veuillez verifier vos donnees</string>

View File

@@ -421,6 +421,8 @@
<string name="error_network">Errore di rete. Controlla la tua connessione.</string>
<string name="error_timeout">Richiesta scaduta. Riprova.</string>
<string name="error_server">Errore del server. Riprova più tardi.</string>
<string name="error_rate_limit">Troppe richieste. Riprova più tardi.</string>
<string name="error_rate_limit_with_delay">Troppe richieste. Riprova tra %1$d secondi.</string>
<string name="error_unauthorized">Sessione scaduta. Effettua nuovamente l\'accesso.</string>
<string name="error_not_found">Non trovato</string>
<string name="error_invalid_input">Controlla i tuoi dati inseriti</string>

View File

@@ -421,6 +421,8 @@
<string name="error_network">ネットワークエラー。接続を確認してください。</string>
<string name="error_timeout">リクエストがタイムアウトしました。もう一度お試しください。</string>
<string name="error_server">サーバーエラー。後でもう一度お試しください。</string>
<string name="error_rate_limit">リクエストが多すぎます。しばらくしてからもう一度お試しください。</string>
<string name="error_rate_limit_with_delay">リクエストが多すぎます。%1$d秒後にもう一度お試しください。</string>
<string name="error_unauthorized">セッションが期限切れです。再度ログインしてください。</string>
<string name="error_not_found">見つかりません</string>
<string name="error_invalid_input">入力内容を確認してください</string>

View File

@@ -421,6 +421,8 @@
<string name="error_network">네트워크 오류가 발생했습니다. 연결을 확인하세요.</string>
<string name="error_timeout">요청 시간이 초과되었습니다. 다시 시도해 주세요.</string>
<string name="error_server">서버 오류가 발생했습니다. 나중에 다시 시도해 주세요.</string>
<string name="error_rate_limit">요청이 너무 많습니다. 나중에 다시 시도해 주세요.</string>
<string name="error_rate_limit_with_delay">요청이 너무 많습니다. %1$d초 후에 다시 시도해 주세요.</string>
<string name="error_unauthorized">세션이 만료되었습니다. 다시 로그인해 주세요.</string>
<string name="error_not_found">찾을 수 없음</string>
<string name="error_invalid_input">입력 내용을 확인해 주세요</string>

View File

@@ -421,6 +421,8 @@
<string name="error_network">Netwerkfout. Controleer uw verbinding.</string>
<string name="error_timeout">Verzoek verlopen. Probeer het opnieuw.</string>
<string name="error_server">Serverfout. Probeer het later opnieuw.</string>
<string name="error_rate_limit">Te veel verzoeken. Probeer het later opnieuw.</string>
<string name="error_rate_limit_with_delay">Te veel verzoeken. Probeer het over %1$d seconden opnieuw.</string>
<string name="error_unauthorized">Sessie verlopen. Log opnieuw in.</string>
<string name="error_not_found">Niet gevonden</string>
<string name="error_invalid_input">Controleer uw invoer</string>

View File

@@ -421,6 +421,8 @@
<string name="error_network">Erro de rede. Verifique sua conexao.</string>
<string name="error_timeout">Tempo esgotado. Tente novamente.</string>
<string name="error_server">Erro do servidor. Tente mais tarde.</string>
<string name="error_rate_limit">Muitas solicitacoes. Tente novamente mais tarde.</string>
<string name="error_rate_limit_with_delay">Muitas solicitacoes. Tente novamente em %1$d segundos.</string>
<string name="error_unauthorized">Sessao expirada. Entre novamente.</string>
<string name="error_not_found">Nao encontrado</string>
<string name="error_invalid_input">Verifique os dados informados</string>

View File

@@ -421,6 +421,8 @@
<string name="error_network">网络错误,请检查您的连接。</string>
<string name="error_timeout">请求超时,请重试。</string>
<string name="error_server">服务器错误,请稍后重试。</string>
<string name="error_rate_limit">请求过多,请稍后重试。</string>
<string name="error_rate_limit_with_delay">请求过多,请在%1$d秒后重试。</string>
<string name="error_unauthorized">会话已过期,请重新登录。</string>
<string name="error_not_found">未找到</string>
<string name="error_invalid_input">请检查您的输入</string>

View File

@@ -654,6 +654,8 @@
<string name="error_network">Network error. Please check your connection.</string>
<string name="error_timeout">Request timed out. Please try again.</string>
<string name="error_server">Server error. Please try again later.</string>
<string name="error_rate_limit">Too many requests. Please try again later.</string>
<string name="error_rate_limit_with_delay">Too many requests. Please try again in %1$d seconds.</string>
<string name="error_unauthorized">Session expired. Please log in again.</string>
<string name="error_not_found">Not found</string>
<string name="error_invalid_input">Please check your input</string>
@@ -810,4 +812,15 @@
<string name="onboarding_subscription_start_trial">Start 7-Day Free Trial</string>
<string name="onboarding_subscription_continue_free">Continue with Free</string>
<string name="onboarding_subscription_trial_terms">7-day free trial, then %1$s. Cancel anytime.</string>
<!-- Biometric Lock -->
<string name="biometric_lock_title">App Locked</string>
<string name="biometric_lock_description">Authenticate to unlock honeyDue</string>
<string name="biometric_lock_setting_title">Biometric Lock</string>
<string name="biometric_lock_setting_subtitle">Require authentication when opening the app</string>
<string name="biometric_prompt_title">Unlock honeyDue</string>
<string name="biometric_prompt_subtitle">Verify your identity to continue</string>
<string name="biometric_unlock_button">Unlock with Biometrics</string>
<string name="biometric_auth_failed">Authentication failed</string>
<string name="biometric_not_available">Biometric authentication is not available on this device</string>
</resources>

View File

@@ -60,6 +60,12 @@ import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.platform.ContractorImportHandler
import com.tt.honeyDue.platform.PlatformUpgradeScreen
import com.tt.honeyDue.platform.ResidenceImportHandler
import com.tt.honeyDue.storage.BiometricPreference
import com.tt.honeyDue.ui.screens.BiometricLockScreen
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import honeydue.composeapp.generated.resources.Res
import honeydue.composeapp.generated.resources.compose_multiplatform
@@ -82,6 +88,28 @@ fun App(
var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) }
val navController = rememberNavController()
// Biometric lock state - starts locked if biometric is enabled and user is logged in
var isLocked by rememberSaveable {
mutableStateOf(BiometricPreference.isBiometricEnabled() && DataManager.authToken.value != null)
}
// Lock the app when returning from background
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
// App going to background - lock if biometric is enabled
if (BiometricPreference.isBiometricEnabled() && DataManager.authToken.value != null) {
isLocked = true
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// Handle navigation from notification tap
// Note: The actual navigation to the task column happens in MainScreen -> AllTasksScreen
// We just need to ensure the user is on MainRoute when a task navigation is requested
@@ -156,6 +184,7 @@ fun App(
else -> MainRoute
}
Box(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
@@ -692,6 +721,14 @@ fun App(
}
}
}
// Biometric lock overlay - shown when app is locked
if (isLocked && isLoggedIn && isVerified) {
BiometricLockScreen(
onUnlocked = { isLocked = false }
)
}
} // close Box
}

View File

@@ -52,11 +52,12 @@ fun HttpClientConfig<*>.installCommonPlugins() {
gzip()
}
// Task 2: Retry with exponential backoff for server errors and IO exceptions
// Task 2: Retry with exponential backoff for server errors, IO exceptions,
// and 429 rate-limit responses (using the Retry-After header for delay).
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
response.status.value in 500..599
response.status.value in 500..599 || response.status.value == 429
}
retryOnExceptionIf { _, cause ->
// Retry on network-level IO errors (connection resets, timeouts, etc.)
@@ -67,8 +68,8 @@ fun HttpClientConfig<*>.installCommonPlugins() {
cause is kotlinx.io.IOException
}
exponentialDelay(
base = 1000.0, // 1 second base
maxDelayMs = 10000 // 10 second max
base = 1000.0,
maxDelayMs = 10_000
)
}

View File

@@ -15,8 +15,19 @@ object ErrorParser {
* Parses error response from the backend.
* The backend returns: {"error": "message"}
* Falls back to detail field or field-specific errors if present.
* For 429 responses, includes the Retry-After value in the message.
*/
suspend fun parseError(response: HttpResponse): String {
// Handle 429 rate-limit responses with Retry-After header
if (response.status.value == 429) {
val retryAfter = response.headers["Retry-After"]?.toLongOrNull()
return if (retryAfter != null) {
"Too many requests. Please try again in $retryAfter seconds."
} else {
"Too many requests. Please try again later."
}
}
return try {
val errorResponse = response.body<ErrorResponse>()

View File

@@ -0,0 +1,37 @@
package com.tt.honeyDue.platform
import androidx.compose.runtime.Composable
/**
* Result of a biometric authentication attempt.
*/
sealed class BiometricResult {
/** Authentication succeeded */
data object Success : BiometricResult()
/** Authentication failed (wrong biometric, cancelled, etc.) */
data class Failed(val message: String) : BiometricResult()
/** Biometric hardware not available on this device */
data object NotAvailable : BiometricResult()
}
/**
* Interface for biometric authentication operations.
*/
interface BiometricAuthPerformer {
/** Check if biometric authentication is available on this device */
fun isBiometricAvailable(): Boolean
/** Trigger biometric authentication with the given reason string */
fun authenticate(
title: String,
subtitle: String,
onResult: (BiometricResult) -> Unit
)
}
/**
* Remember a biometric auth performer for the current platform.
* Follows the same expect/actual composable pattern as rememberHapticFeedback.
*/
@Composable
expect fun rememberBiometricAuth(): BiometricAuthPerformer

View File

@@ -0,0 +1,31 @@
package com.tt.honeyDue.storage
/**
* Cross-platform biometric preference storage for persisting biometric lock setting.
* Uses platform-specific implementations (SharedPreferences on Android, UserDefaults on iOS).
* Follows the same pattern as ThemeStorage / ThemeStorageManager.
*/
object BiometricPreference {
private var manager: BiometricPreferenceManager? = null
fun initialize(biometricManager: BiometricPreferenceManager) {
manager = biometricManager
}
fun isBiometricEnabled(): Boolean {
return manager?.isBiometricEnabled() ?: false
}
fun setBiometricEnabled(enabled: Boolean) {
manager?.setBiometricEnabled(enabled)
}
}
/**
* Platform-specific biometric preference storage interface.
* Each platform implements this using their native storage mechanisms.
*/
expect class BiometricPreferenceManager {
fun isBiometricEnabled(): Boolean
fun setBiometricEnabled(enabled: Boolean)
}

View File

@@ -0,0 +1,133 @@
package com.tt.honeyDue.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tt.honeyDue.platform.BiometricResult
import com.tt.honeyDue.platform.rememberBiometricAuth
import com.tt.honeyDue.ui.theme.*
import honeydue.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
/**
* Lock screen shown when the app returns to foreground with biometric lock enabled.
* Displays app logo and triggers biometric authentication.
* Follows existing design system (OrganicDesign, theme colors).
*/
@Composable
fun BiometricLockScreen(
onUnlocked: () -> Unit
) {
val biometricAuth = rememberBiometricAuth()
var authError by remember { mutableStateOf<String?>(null) }
val promptTitle = stringResource(Res.string.biometric_prompt_title)
val promptSubtitle = stringResource(Res.string.biometric_prompt_subtitle)
// Auto-trigger biometric prompt on appear
LaunchedEffect(Unit) {
biometricAuth.authenticate(
title = promptTitle,
subtitle = promptSubtitle
) { result ->
when (result) {
is BiometricResult.Success -> onUnlocked()
is BiometricResult.Failed -> authError = result.message
is BiometricResult.NotAvailable -> onUnlocked() // Fallback: unlock if biometric unavailable
}
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
WarmGradientBackground {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Lock icon
OrganicIconContainer(
icon = Icons.Default.Lock,
size = 96.dp,
iconSize = 56.dp
)
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
Text(
text = stringResource(Res.string.biometric_lock_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.biometric_lock_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
if (authError != null) {
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Text(
text = authError ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
// Retry button
Button(
onClick = {
authError = null
biometricAuth.authenticate(
title = promptTitle,
subtitle = promptSubtitle
) { result ->
when (result) {
is BiometricResult.Success -> onUnlocked()
is BiometricResult.Failed -> authError = result.message
is BiometricResult.NotAvailable -> onUnlocked()
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = MaterialTheme.shapes.medium
) {
Icon(
imageVector = Icons.Default.Fingerprint,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.biometric_unlock_button),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}

View File

@@ -33,6 +33,9 @@ import com.tt.honeyDue.ui.subscription.UpgradePromptDialog
import androidx.compose.runtime.getValue
import com.tt.honeyDue.analytics.PostHogAnalytics
import com.tt.honeyDue.analytics.AnalyticsEvents
import com.tt.honeyDue.platform.BiometricResult
import com.tt.honeyDue.platform.rememberBiometricAuth
import com.tt.honeyDue.storage.BiometricPreference
import honeydue.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -57,6 +60,10 @@ fun ProfileScreen(
var showUpgradePrompt by remember { mutableStateOf(false) }
var showDeleteAccountDialog by remember { mutableStateOf(false) }
val biometricAuth = rememberBiometricAuth()
val isBiometricAvailable = remember { biometricAuth.isBiometricAvailable() }
var isBiometricEnabled by remember { mutableStateOf(BiometricPreference.isBiometricEnabled()) }
val updateState by viewModel.updateProfileState.collectAsState()
val deleteAccountState by viewModel.deleteAccountState.collectAsState()
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
@@ -306,6 +313,63 @@ fun ProfileScreen(
}
}
// Biometric Lock Section - only show if device supports biometrics
if (isBiometricAvailable) {
val biometricPromptTitle = stringResource(Res.string.biometric_prompt_title)
val biometricPromptSubtitle = stringResource(Res.string.biometric_prompt_subtitle)
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.naturalShadow()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
) {
Text(
text = stringResource(Res.string.biometric_lock_setting_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(Res.string.biometric_lock_setting_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = isBiometricEnabled,
onCheckedChange = { newValue ->
// Authenticate before toggling
biometricAuth.authenticate(
title = biometricPromptTitle,
subtitle = biometricPromptSubtitle
) { result ->
when (result) {
is BiometricResult.Success -> {
BiometricPreference.setBiometricEnabled(newValue)
isBiometricEnabled = newValue
}
is BiometricResult.Failed,
is BiometricResult.NotAvailable -> {
// Auth failed, don't change the toggle
}
}
}
}
)
}
}
}
// Contact Support Section
val uriHandler = LocalUriHandler.current
OrganicCard(