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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user