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

@@ -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(