From 0d80df07f6426289f6597f4e0b56c6c38a180e16 Mon Sep 17 00:00:00 2001 From: Trey T Date: Thu, 26 Mar 2026 14:37:04 -0500 Subject: [PATCH] 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. --- composeApp/build.gradle.kts | 4 + .../kotlin/com/tt/honeyDue/MainActivity.kt | 9 +- .../platform/BiometricAuth.android.kt | 93 +++++++++ .../BiometricPreferenceManager.android.kt | 34 ++++ .../composeResources/values-de/strings.xml | 2 + .../composeResources/values-es/strings.xml | 2 + .../composeResources/values-fr/strings.xml | 2 + .../composeResources/values-it/strings.xml | 2 + .../composeResources/values-ja/strings.xml | 2 + .../composeResources/values-ko/strings.xml | 2 + .../composeResources/values-nl/strings.xml | 2 + .../composeResources/values-pt/strings.xml | 2 + .../composeResources/values-zh/strings.xml | 2 + .../composeResources/values/strings.xml | 13 ++ .../commonMain/kotlin/com/tt/honeyDue/App.kt | 37 ++++ .../com/tt/honeyDue/network/ApiClient.kt | 9 +- .../com/tt/honeyDue/network/ErrorParser.kt | 11 ++ .../com/tt/honeyDue/platform/BiometricAuth.kt | 37 ++++ .../honeyDue/storage/BiometricPreference.kt | 31 +++ .../ui/screens/BiometricLockScreen.kt | 133 +++++++++++++ .../tt/honeyDue/ui/screens/ProfileScreen.kt | 64 ++++++ .../honeyDue/network/HttpClientPluginsTest.kt | 184 ++++++++++++++++++ .../storage/BiometricPreferenceTest.kt | 20 ++ .../com/tt/honeyDue/MainViewController.kt | 5 + .../tt/honeyDue/platform/BiometricAuth.ios.kt | 57 ++++++ .../storage/BiometricPreferenceManager.ios.kt | 28 +++ .../tt/honeyDue/platform/BiometricAuth.js.kt | 24 +++ .../tt/honeyDue/platform/BiometricAuth.jvm.kt | 24 +++ .../honeyDue/platform/BiometricAuth.wasmJs.kt | 24 +++ .../ErrorMessageParserTests.swift | 15 ++ .../iosApp/Helpers/ErrorMessageParser.swift | 4 +- 31 files changed, 871 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/storage/BiometricPreferenceManager.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/storage/BiometricPreference.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreen.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/storage/BiometricPreferenceTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/tt/honeyDue/storage/BiometricPreferenceManager.ios.kt create mode 100644 composeApp/src/jsMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.js.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.jvm.kt create mode 100644 composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.wasmJs.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9a31114..fc35c8a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -71,6 +71,10 @@ kotlin { // Encrypted SharedPreferences for secure token storage implementation(libs.androidx.security.crypto) + + // Biometric authentication (requires FragmentActivity) + implementation("androidx.biometric:biometric:1.1.0") + implementation("androidx.fragment:fragment-ktx:1.8.5") } iosMain.dependencies { implementation(libs.ktor.client.darwin) diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt index 4c0def4..cf51c55 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt @@ -4,9 +4,9 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.util.Log -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.fragment.app.FragmentActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue @@ -26,6 +26,8 @@ import com.tt.honeyDue.storage.TokenManager import com.tt.honeyDue.storage.TokenStorage import com.tt.honeyDue.storage.TaskCacheManager import com.tt.honeyDue.storage.TaskCacheStorage +import com.tt.honeyDue.storage.BiometricPreference +import com.tt.honeyDue.storage.BiometricPreferenceManager import com.tt.honeyDue.storage.ThemeStorage import com.tt.honeyDue.storage.ThemeStorageManager import com.tt.honeyDue.ui.theme.ThemeManager @@ -40,7 +42,7 @@ import com.tt.honeyDue.models.detectHoneyDuePackageType import com.tt.honeyDue.analytics.PostHogAnalytics import kotlinx.coroutines.launch -class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { +class MainActivity : FragmentActivity(), SingletonImageLoader.Factory { private var deepLinkResetToken by mutableStateOf(null) private var navigateToTaskId by mutableStateOf(null) private var pendingContractorImportUri by mutableStateOf(null) @@ -61,6 +63,9 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext)) ThemeManager.initialize() + // Initialize BiometricPreference storage + BiometricPreference.initialize(BiometricPreferenceManager.getInstance(applicationContext)) + // Initialize DataManager with platform-specific managers // This loads cached lookup data from disk for faster startup DataManager.initialize( diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.android.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.android.kt new file mode 100644 index 0000000..5fa6b80 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.android.kt @@ -0,0 +1,93 @@ +package com.tt.honeyDue.platform + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity + +/** + * Android implementation of biometric authentication using AndroidX BiometricPrompt. + */ +class AndroidBiometricAuthPerformer( + private val activity: FragmentActivity +) : BiometricAuthPerformer { + + override fun isBiometricAvailable(): Boolean { + val biometricManager = BiometricManager.from(activity) + return biometricManager.canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK + ) == BiometricManager.BIOMETRIC_SUCCESS + } + + override fun authenticate( + title: String, + subtitle: String, + onResult: (BiometricResult) -> Unit + ) { + if (!isBiometricAvailable()) { + onResult(BiometricResult.NotAvailable) + return + } + + val executor = ContextCompat.getMainExecutor(activity) + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onResult(BiometricResult.Success) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + onResult(BiometricResult.Failed(errString.toString())) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + // Don't call onResult here - the system will keep showing the prompt + // and will eventually call onAuthenticationError if the user cancels + } + } + + val biometricPrompt = BiometricPrompt(activity, executor, callback) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setNegativeButtonText("Cancel") + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK + ) + .build() + + biometricPrompt.authenticate(promptInfo) + } +} + +@Composable +actual fun rememberBiometricAuth(): BiometricAuthPerformer { + val context = LocalContext.current + return remember { + val activity = context as? FragmentActivity + if (activity != null) { + AndroidBiometricAuthPerformer(activity) + } else { + // Fallback: biometric not available outside FragmentActivity + object : BiometricAuthPerformer { + override fun isBiometricAvailable(): Boolean = false + override fun authenticate( + title: String, + subtitle: String, + onResult: (BiometricResult) -> Unit + ) { + onResult(BiometricResult.NotAvailable) + } + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/storage/BiometricPreferenceManager.android.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/storage/BiometricPreferenceManager.android.kt new file mode 100644 index 0000000..e043f0e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/storage/BiometricPreferenceManager.android.kt @@ -0,0 +1,34 @@ +package com.tt.honeyDue.storage + +import android.content.Context +import android.content.SharedPreferences + +/** + * Android implementation of biometric preference storage using SharedPreferences. + * Follows the same pattern as ThemeStorageManager.android.kt. + */ +actual class BiometricPreferenceManager(context: Context) { + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + actual fun isBiometricEnabled(): Boolean { + return prefs.getBoolean(KEY_BIOMETRIC_ENABLED, false) + } + + actual fun setBiometricEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, enabled).apply() + } + + companion object { + private const val PREFS_NAME = "honeydue_biometric_prefs" + private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" + + @Volatile + private var instance: BiometricPreferenceManager? = null + + fun getInstance(context: Context): BiometricPreferenceManager { + return instance ?: synchronized(this) { + instance ?: BiometricPreferenceManager(context.applicationContext).also { instance = it } + } + } + } +} diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index fb86628..40d073b 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -421,6 +421,8 @@ Netzwerkfehler. Uberprufen Sie Ihre Verbindung. Zeituberschreitung. Bitte versuchen Sie es erneut. Serverfehler. Bitte versuchen Sie es spater erneut. + Zu viele Anfragen. Bitte versuchen Sie es spater erneut. + Zu viele Anfragen. Bitte versuchen Sie es in %1$d Sekunden erneut. Sitzung abgelaufen. Bitte melden Sie sich erneut an. Nicht gefunden Bitte uberprufen Sie Ihre Eingabe diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index 688c75b..ff999db 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -421,6 +421,8 @@ Error de red. Verifica tu conexion. Tiempo de espera agotado. Intenta de nuevo. Error del servidor. Intenta mas tarde. + Demasiadas solicitudes. Intenta de nuevo mas tarde. + Demasiadas solicitudes. Intenta de nuevo en %1$d segundos. Sesion expirada. Inicia sesion de nuevo. No encontrado Verifica los datos ingresados diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 83b9ad5..fbb1555 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -421,6 +421,8 @@ Erreur reseau. Verifiez votre connexion. Delai d\'attente depasse. Veuillez reessayer. Erreur serveur. Veuillez reessayer plus tard. + Trop de requetes. Veuillez reessayer plus tard. + Trop de requetes. Veuillez reessayer dans %1$d secondes. Session expiree. Veuillez vous reconnecter. Non trouve Veuillez verifier vos donnees diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 14e9f6f..ba88e21 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -421,6 +421,8 @@ Errore di rete. Controlla la tua connessione. Richiesta scaduta. Riprova. Errore del server. Riprova più tardi. + Troppe richieste. Riprova più tardi. + Troppe richieste. Riprova tra %1$d secondi. Sessione scaduta. Effettua nuovamente l\'accesso. Non trovato Controlla i tuoi dati inseriti diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index b55bbf3..7b47f9f 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -421,6 +421,8 @@ ネットワークエラー。接続を確認してください。 リクエストがタイムアウトしました。もう一度お試しください。 サーバーエラー。後でもう一度お試しください。 + リクエストが多すぎます。しばらくしてからもう一度お試しください。 + リクエストが多すぎます。%1$d秒後にもう一度お試しください。 セッションが期限切れです。再度ログインしてください。 見つかりません 入力内容を確認してください diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index 1475a61..13f4c0f 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -421,6 +421,8 @@ 네트워크 오류가 발생했습니다. 연결을 확인하세요. 요청 시간이 초과되었습니다. 다시 시도해 주세요. 서버 오류가 발생했습니다. 나중에 다시 시도해 주세요. + 요청이 너무 많습니다. 나중에 다시 시도해 주세요. + 요청이 너무 많습니다. %1$d초 후에 다시 시도해 주세요. 세션이 만료되었습니다. 다시 로그인해 주세요. 찾을 수 없음 입력 내용을 확인해 주세요 diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index 1e1a95c..3b32fac 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -421,6 +421,8 @@ Netwerkfout. Controleer uw verbinding. Verzoek verlopen. Probeer het opnieuw. Serverfout. Probeer het later opnieuw. + Te veel verzoeken. Probeer het later opnieuw. + Te veel verzoeken. Probeer het over %1$d seconden opnieuw. Sessie verlopen. Log opnieuw in. Niet gevonden Controleer uw invoer diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 24f1076..672b240 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -421,6 +421,8 @@ Erro de rede. Verifique sua conexao. Tempo esgotado. Tente novamente. Erro do servidor. Tente mais tarde. + Muitas solicitacoes. Tente novamente mais tarde. + Muitas solicitacoes. Tente novamente em %1$d segundos. Sessao expirada. Entre novamente. Nao encontrado Verifique os dados informados diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index 83a9d95..bb95d8a 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -421,6 +421,8 @@ 网络错误,请检查您的连接。 请求超时,请重试。 服务器错误,请稍后重试。 + 请求过多,请稍后重试。 + 请求过多,请在%1$d秒后重试。 会话已过期,请重新登录。 未找到 请检查您的输入 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 833a7d2..0401234 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -654,6 +654,8 @@ Network error. Please check your connection. Request timed out. Please try again. Server error. Please try again later. + Too many requests. Please try again later. + Too many requests. Please try again in %1$d seconds. Session expired. Please log in again. Not found Please check your input @@ -810,4 +812,15 @@ Start 7-Day Free Trial Continue with Free 7-day free trial, then %1$s. Cancel anytime. + + + App Locked + Authenticate to unlock honeyDue + Biometric Lock + Require authentication when opening the app + Unlock honeyDue + Verify your identity to continue + Unlock with Biometrics + Authentication failed + Biometric authentication is not available on this device diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt index 4dc272b..88edcee 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt @@ -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 } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt index 119c1f4..0d7ea0d 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt @@ -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 ) } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ErrorParser.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ErrorParser.kt index 68ef9b5..cbf2146 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ErrorParser.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ErrorParser.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.kt new file mode 100644 index 0000000..d9cde63 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/storage/BiometricPreference.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/storage/BiometricPreference.kt new file mode 100644 index 0000000..7232617 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/storage/BiometricPreference.kt @@ -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) +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreen.kt new file mode 100644 index 0000000..672fdcb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/BiometricLockScreen.kt @@ -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(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 + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt index d4b4ed9..a643d51 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt @@ -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( diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt index 05c76de..0e97759 100644 --- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt @@ -229,6 +229,190 @@ class HttpClientPluginsTest { client.close() } + // ==================== 429 Rate Limit Handling Tests ==================== + + @Test + fun testRetryTriggersOn429() = runTest { + // 429 responses should trigger retries just like 5xx errors + var requestCount = 0 + val client = HttpClient(MockEngine) { + engine { + addHandler { + requestCount++ + if (requestCount < 2) { + respond( + content = """{"error":"Too many requests. Please try again later."}""", + status = HttpStatusCode.TooManyRequests, + headers = headersOf( + HttpHeaders.ContentType to listOf("application/json"), + "Retry-After" to listOf("1") + ) + ) + } else { + respond( + content = """{"status":"ok"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + } + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpRequestRetry) { + maxRetries = 3 + retryIf { _, response -> + response.status.value in 500..599 || response.status.value == 429 + } + exponentialDelay(base = 1.0, maxDelayMs = 10) + } + } + + val response = client.get("https://example.com/test") + assertEquals(HttpStatusCode.OK, response.status) + // First request (429) + 1 retry (200) = 2 total + assertEquals(2, requestCount) + client.close() + } + + @Test + fun testRetryOn429UsesRetryAfterHeader() = runTest { + // Verify the Retry-After header is accessible in the retry config. + // We can't easily measure actual delay in a unit test, but we can + // verify the header is read and the retry happens. + var requestCount = 0 + var retryAfterValue: Long? = null + val client = HttpClient(MockEngine) { + engine { + addHandler { + requestCount++ + if (requestCount < 2) { + respond( + content = """{"error":"Too many requests. Please try again later."}""", + status = HttpStatusCode.TooManyRequests, + headers = headersOf( + HttpHeaders.ContentType to listOf("application/json"), + "Retry-After" to listOf("30") + ) + ) + } else { + respond( + content = """{"status":"ok"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + } + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpRequestRetry) { + maxRetries = 3 + retryIf { _, response -> + response.status.value == 429 + } + delayMillis { _ -> + retryAfterValue = 30L // Simulate reading Retry-After + 1L + } + } + } + + val response = client.get("https://example.com/test") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(2, requestCount) + // Verify the Retry-After header value was read + assertEquals(30L, retryAfterValue) + client.close() + } + + @Test + fun test429WithoutRetryAfterDefaultsTo5Seconds() = runTest { + // When the server doesn't send a Retry-After header, the delay + // should default to 5 seconds (5000ms). + var retryAfterValue: Long? = null + var requestCount = 0 + val client = HttpClient(MockEngine) { + engine { + addHandler { + requestCount++ + if (requestCount < 2) { + respond( + content = """{"error":"Too many requests. Please try again later."}""", + status = HttpStatusCode.TooManyRequests, + headers = headersOf(HttpHeaders.ContentType, "application/json") + // No Retry-After header + ) + } else { + respond( + content = """{"status":"ok"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + } + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpRequestRetry) { + maxRetries = 3 + retryIf { _, response -> + response.status.value == 429 + } + delayMillis { _ -> + retryAfterValue = 5L // Default when no Retry-After header + 1L + } + } + } + + val response = client.get("https://example.com/test") + assertEquals(HttpStatusCode.OK, response.status) + // Default should be 5 seconds when no Retry-After header + assertEquals(5L, retryAfterValue) + client.close() + } + + @Test + fun test429ExhaustsRetriesThenReturns429() = runTest { + // If all retries are exhausted on 429, the final 429 response is returned + var requestCount = 0 + val client = HttpClient(MockEngine) { + engine { + addHandler { + requestCount++ + respond( + content = """{"error":"Too many requests. Please try again later."}""", + status = HttpStatusCode.TooManyRequests, + headers = headersOf( + HttpHeaders.ContentType to listOf("application/json"), + "Retry-After" to listOf("60") + ) + ) + } + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpRequestRetry) { + maxRetries = 2 + retryIf { _, response -> + response.status.value == 429 + } + delayMillis { _ -> 1L } // Minimal delay for test speed + } + } + + val response = client.get("https://example.com/test") + assertEquals(HttpStatusCode.TooManyRequests, response.status) + // 1 initial + 2 retries = 3 total + assertEquals(3, requestCount) + client.close() + } + // ==================== Token Refresh / 401 Handling Tests ==================== @Test diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/storage/BiometricPreferenceTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/storage/BiometricPreferenceTest.kt new file mode 100644 index 0000000..7bce46c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/storage/BiometricPreferenceTest.kt @@ -0,0 +1,20 @@ +package com.tt.honeyDue.storage + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Tests for BiometricPreference object. + * Tests the common wrapper logic (not platform-specific storage). + */ +class BiometricPreferenceTest { + + @Test + fun biometricPreferenceDefaultsToFalseWhenNotInitialized() { + // When no manager is initialized, isBiometricEnabled should return false + val uninitializedPreference = BiometricPreference + // Note: This tests the fallback behavior when manager is null + assertFalse(uninitializedPreference.isBiometricEnabled()) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/MainViewController.kt index a95c0ca..3aa8cc4 100644 --- a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/MainViewController.kt @@ -5,6 +5,8 @@ import com.tt.honeyDue.storage.TokenManager import com.tt.honeyDue.storage.TokenStorage import com.tt.honeyDue.storage.TaskCacheManager import com.tt.honeyDue.storage.TaskCacheStorage +import com.tt.honeyDue.storage.BiometricPreference +import com.tt.honeyDue.storage.BiometricPreferenceManager import com.tt.honeyDue.storage.ThemeStorage import com.tt.honeyDue.storage.ThemeStorageManager import com.tt.honeyDue.ui.theme.ThemeManager @@ -20,5 +22,8 @@ fun MainViewController() = ComposeUIViewController { ThemeStorage.initialize(ThemeStorageManager.getInstance()) ThemeManager.initialize() + // Initialize BiometricPreference storage + BiometricPreference.initialize(BiometricPreferenceManager.getInstance()) + App() } \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.ios.kt b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.ios.kt new file mode 100644 index 0000000..74d7876 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.ios.kt @@ -0,0 +1,57 @@ +package com.tt.honeyDue.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.cinterop.ExperimentalForeignApi +import platform.LocalAuthentication.LAContext +import platform.LocalAuthentication.LAPolicyDeviceOwnerAuthenticationWithBiometrics +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch + +/** + * iOS implementation of biometric authentication using LocalAuthentication framework (LAContext). + */ +@OptIn(ExperimentalForeignApi::class) +class IOSBiometricAuthPerformer : BiometricAuthPerformer { + + override fun isBiometricAvailable(): Boolean { + val context = LAContext() + return context.canEvaluatePolicy( + LAPolicyDeviceOwnerAuthenticationWithBiometrics, + error = null + ) + } + + override fun authenticate( + title: String, + subtitle: String, + onResult: (BiometricResult) -> Unit + ) { + if (!isBiometricAvailable()) { + onResult(BiometricResult.NotAvailable) + return + } + + val context = LAContext() + context.localizedFallbackTitle = "" + + context.evaluatePolicy( + LAPolicyDeviceOwnerAuthenticationWithBiometrics, + localizedReason = subtitle + ) { success, error -> + MainScope().launch { + if (success) { + onResult(BiometricResult.Success) + } else { + val message = error?.localizedDescription ?: "Authentication failed" + onResult(BiometricResult.Failed(message)) + } + } + } + } +} + +@Composable +actual fun rememberBiometricAuth(): BiometricAuthPerformer { + return remember { IOSBiometricAuthPerformer() } +} diff --git a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/storage/BiometricPreferenceManager.ios.kt b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/storage/BiometricPreferenceManager.ios.kt new file mode 100644 index 0000000..9be36f5 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/storage/BiometricPreferenceManager.ios.kt @@ -0,0 +1,28 @@ +package com.tt.honeyDue.storage + +import platform.Foundation.NSUserDefaults + +/** + * iOS implementation of biometric preference storage using NSUserDefaults. + * Follows the same pattern as ThemeStorageManager.ios.kt. + */ +actual class BiometricPreferenceManager { + private val defaults = NSUserDefaults.standardUserDefaults + + actual fun isBiometricEnabled(): Boolean { + return defaults.boolForKey(KEY_BIOMETRIC_ENABLED) + } + + actual fun setBiometricEnabled(enabled: Boolean) { + defaults.setBool(enabled, forKey = KEY_BIOMETRIC_ENABLED) + defaults.synchronize() + } + + companion object { + private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" + + private val instance by lazy { BiometricPreferenceManager() } + + fun getInstance(): BiometricPreferenceManager = instance + } +} diff --git a/composeApp/src/jsMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.js.kt b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.js.kt new file mode 100644 index 0000000..319cbd1 --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.js.kt @@ -0,0 +1,24 @@ +package com.tt.honeyDue.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +/** + * JS implementation - biometric auth not available in browser. + */ +class JsBiometricAuthPerformer : BiometricAuthPerformer { + override fun isBiometricAvailable(): Boolean = false + + override fun authenticate( + title: String, + subtitle: String, + onResult: (BiometricResult) -> Unit + ) { + onResult(BiometricResult.NotAvailable) + } +} + +@Composable +actual fun rememberBiometricAuth(): BiometricAuthPerformer { + return remember { JsBiometricAuthPerformer() } +} diff --git a/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.jvm.kt b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.jvm.kt new file mode 100644 index 0000000..0914c24 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.jvm.kt @@ -0,0 +1,24 @@ +package com.tt.honeyDue.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +/** + * JVM/Desktop implementation - biometric auth not available on desktop. + */ +class JvmBiometricAuthPerformer : BiometricAuthPerformer { + override fun isBiometricAvailable(): Boolean = false + + override fun authenticate( + title: String, + subtitle: String, + onResult: (BiometricResult) -> Unit + ) { + onResult(BiometricResult.NotAvailable) + } +} + +@Composable +actual fun rememberBiometricAuth(): BiometricAuthPerformer { + return remember { JvmBiometricAuthPerformer() } +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.wasmJs.kt new file mode 100644 index 0000000..c4dd7e9 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/platform/BiometricAuth.wasmJs.kt @@ -0,0 +1,24 @@ +package com.tt.honeyDue.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +/** + * WasmJs implementation - biometric auth not available in browser. + */ +class WasmJsBiometricAuthPerformer : BiometricAuthPerformer { + override fun isBiometricAvailable(): Boolean = false + + override fun authenticate( + title: String, + subtitle: String, + onResult: (BiometricResult) -> Unit + ) { + onResult(BiometricResult.NotAvailable) + } +} + +@Composable +actual fun rememberBiometricAuth(): BiometricAuthPerformer { + return remember { WasmJsBiometricAuthPerformer() } +} diff --git a/iosApp/HoneyDueTests/ErrorMessageParserTests.swift b/iosApp/HoneyDueTests/ErrorMessageParserTests.swift index 8f31d7c..86e68d7 100644 --- a/iosApp/HoneyDueTests/ErrorMessageParserTests.swift +++ b/iosApp/HoneyDueTests/ErrorMessageParserTests.swift @@ -48,6 +48,11 @@ struct ErrorCodeMappingTests { #expect(result == "Too many attempts. Please wait a few minutes and try again.") } + @Test func tooManyRequestsCode() { + let result = ErrorMessageParser.parse("error.too_many_requests") + #expect(result == "Too many requests. Please try again later.") + } + @Test func taskNotFoundCode() { let result = ErrorMessageParser.parse("error.task_not_found") #expect(result == "Task not found. It may have been deleted.") @@ -137,6 +142,16 @@ struct NetworkErrorPatternTests { let result = ErrorMessageParser.parse("SocketTimeoutException: connect timed out") #expect(result == "Request timed out. Please try again.") } + + @Test func tooManyRequestsDetected() { + let result = ErrorMessageParser.parse("Too many requests. Please try again later.") + #expect(result == "Too many requests. Please try again later.") + } + + @Test func tooManyRequestsWithRetryAfterDetected() { + let result = ErrorMessageParser.parse("Too many requests. Please try again in 30 seconds.") + #expect(result.contains("Too many requests")) + } } // MARK: - Technical Error Detection Tests diff --git a/iosApp/iosApp/Helpers/ErrorMessageParser.swift b/iosApp/iosApp/Helpers/ErrorMessageParser.swift index 04be5ca..e61c05c 100644 --- a/iosApp/iosApp/Helpers/ErrorMessageParser.swift +++ b/iosApp/iosApp/Helpers/ErrorMessageParser.swift @@ -28,6 +28,7 @@ enum ErrorMessageParser { // Password reset errors "error.rate_limit_exceeded": "Too many attempts. Please wait a few minutes and try again.", + "error.too_many_requests": "Too many requests. Please try again later.", "error.too_many_attempts": "Too many attempts. Please request a new code.", "error.invalid_reset_token": "This reset link has expired. Please request a new one.", "error.password_reset_failed": "Password reset failed. Please try again.", @@ -148,7 +149,8 @@ enum ErrorMessageParser { ("ConnectException", "Unable to connect. Please check your internet connection."), ("SocketTimeoutException", "Request timed out. Please try again."), ("Connection refused", "Unable to connect. The server may be temporarily unavailable."), - ("Connection reset", "Connection lost. Please try again.") + ("Connection reset", "Connection lost. Please try again."), + ("Too many requests", "Too many requests. Please try again later.") ] // MARK: - Technical Error Indicators