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:
@@ -71,6 +71,10 @@ kotlin {
|
|||||||
|
|
||||||
// Encrypted SharedPreferences for secure token storage
|
// Encrypted SharedPreferences for secure token storage
|
||||||
implementation(libs.androidx.security.crypto)
|
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 {
|
iosMain.dependencies {
|
||||||
implementation(libs.ktor.client.darwin)
|
implementation(libs.ktor.client.darwin)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.getValue
|
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.TokenStorage
|
||||||
import com.tt.honeyDue.storage.TaskCacheManager
|
import com.tt.honeyDue.storage.TaskCacheManager
|
||||||
import com.tt.honeyDue.storage.TaskCacheStorage
|
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.ThemeStorage
|
||||||
import com.tt.honeyDue.storage.ThemeStorageManager
|
import com.tt.honeyDue.storage.ThemeStorageManager
|
||||||
import com.tt.honeyDue.ui.theme.ThemeManager
|
import com.tt.honeyDue.ui.theme.ThemeManager
|
||||||
@@ -40,7 +42,7 @@ import com.tt.honeyDue.models.detectHoneyDuePackageType
|
|||||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
|
||||||
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
||||||
private var navigateToTaskId by mutableStateOf<Int?>(null)
|
private var navigateToTaskId by mutableStateOf<Int?>(null)
|
||||||
private var pendingContractorImportUri by mutableStateOf<Uri?>(null)
|
private var pendingContractorImportUri by mutableStateOf<Uri?>(null)
|
||||||
@@ -61,6 +63,9 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext))
|
ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext))
|
||||||
ThemeManager.initialize()
|
ThemeManager.initialize()
|
||||||
|
|
||||||
|
// Initialize BiometricPreference storage
|
||||||
|
BiometricPreference.initialize(BiometricPreferenceManager.getInstance(applicationContext))
|
||||||
|
|
||||||
// Initialize DataManager with platform-specific managers
|
// Initialize DataManager with platform-specific managers
|
||||||
// This loads cached lookup data from disk for faster startup
|
// This loads cached lookup data from disk for faster startup
|
||||||
DataManager.initialize(
|
DataManager.initialize(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -421,6 +421,8 @@
|
|||||||
<string name="error_network">Netzwerkfehler. Uberprufen Sie Ihre Verbindung.</string>
|
<string name="error_network">Netzwerkfehler. Uberprufen Sie Ihre Verbindung.</string>
|
||||||
<string name="error_timeout">Zeituberschreitung. Bitte versuchen Sie es erneut.</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_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_unauthorized">Sitzung abgelaufen. Bitte melden Sie sich erneut an.</string>
|
||||||
<string name="error_not_found">Nicht gefunden</string>
|
<string name="error_not_found">Nicht gefunden</string>
|
||||||
<string name="error_invalid_input">Bitte uberprufen Sie Ihre Eingabe</string>
|
<string name="error_invalid_input">Bitte uberprufen Sie Ihre Eingabe</string>
|
||||||
|
|||||||
@@ -421,6 +421,8 @@
|
|||||||
<string name="error_network">Error de red. Verifica tu conexion.</string>
|
<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_timeout">Tiempo de espera agotado. Intenta de nuevo.</string>
|
||||||
<string name="error_server">Error del servidor. Intenta mas tarde.</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_unauthorized">Sesion expirada. Inicia sesion de nuevo.</string>
|
||||||
<string name="error_not_found">No encontrado</string>
|
<string name="error_not_found">No encontrado</string>
|
||||||
<string name="error_invalid_input">Verifica los datos ingresados</string>
|
<string name="error_invalid_input">Verifica los datos ingresados</string>
|
||||||
|
|||||||
@@ -421,6 +421,8 @@
|
|||||||
<string name="error_network">Erreur reseau. Verifiez votre connexion.</string>
|
<string name="error_network">Erreur reseau. Verifiez votre connexion.</string>
|
||||||
<string name="error_timeout">Delai d\'attente depasse. Veuillez reessayer.</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_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_unauthorized">Session expiree. Veuillez vous reconnecter.</string>
|
||||||
<string name="error_not_found">Non trouve</string>
|
<string name="error_not_found">Non trouve</string>
|
||||||
<string name="error_invalid_input">Veuillez verifier vos donnees</string>
|
<string name="error_invalid_input">Veuillez verifier vos donnees</string>
|
||||||
|
|||||||
@@ -421,6 +421,8 @@
|
|||||||
<string name="error_network">Errore di rete. Controlla la tua connessione.</string>
|
<string name="error_network">Errore di rete. Controlla la tua connessione.</string>
|
||||||
<string name="error_timeout">Richiesta scaduta. Riprova.</string>
|
<string name="error_timeout">Richiesta scaduta. Riprova.</string>
|
||||||
<string name="error_server">Errore del server. Riprova più tardi.</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_unauthorized">Sessione scaduta. Effettua nuovamente l\'accesso.</string>
|
||||||
<string name="error_not_found">Non trovato</string>
|
<string name="error_not_found">Non trovato</string>
|
||||||
<string name="error_invalid_input">Controlla i tuoi dati inseriti</string>
|
<string name="error_invalid_input">Controlla i tuoi dati inseriti</string>
|
||||||
|
|||||||
@@ -421,6 +421,8 @@
|
|||||||
<string name="error_network">ネットワークエラー。接続を確認してください。</string>
|
<string name="error_network">ネットワークエラー。接続を確認してください。</string>
|
||||||
<string name="error_timeout">リクエストがタイムアウトしました。もう一度お試しください。</string>
|
<string name="error_timeout">リクエストがタイムアウトしました。もう一度お試しください。</string>
|
||||||
<string name="error_server">サーバーエラー。後でもう一度お試しください。</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_unauthorized">セッションが期限切れです。再度ログインしてください。</string>
|
||||||
<string name="error_not_found">見つかりません</string>
|
<string name="error_not_found">見つかりません</string>
|
||||||
<string name="error_invalid_input">入力内容を確認してください</string>
|
<string name="error_invalid_input">入力内容を確認してください</string>
|
||||||
|
|||||||
@@ -421,6 +421,8 @@
|
|||||||
<string name="error_network">네트워크 오류가 발생했습니다. 연결을 확인하세요.</string>
|
<string name="error_network">네트워크 오류가 발생했습니다. 연결을 확인하세요.</string>
|
||||||
<string name="error_timeout">요청 시간이 초과되었습니다. 다시 시도해 주세요.</string>
|
<string name="error_timeout">요청 시간이 초과되었습니다. 다시 시도해 주세요.</string>
|
||||||
<string name="error_server">서버 오류가 발생했습니다. 나중에 다시 시도해 주세요.</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_unauthorized">세션이 만료되었습니다. 다시 로그인해 주세요.</string>
|
||||||
<string name="error_not_found">찾을 수 없음</string>
|
<string name="error_not_found">찾을 수 없음</string>
|
||||||
<string name="error_invalid_input">입력 내용을 확인해 주세요</string>
|
<string name="error_invalid_input">입력 내용을 확인해 주세요</string>
|
||||||
|
|||||||
@@ -421,6 +421,8 @@
|
|||||||
<string name="error_network">Netwerkfout. Controleer uw verbinding.</string>
|
<string name="error_network">Netwerkfout. Controleer uw verbinding.</string>
|
||||||
<string name="error_timeout">Verzoek verlopen. Probeer het opnieuw.</string>
|
<string name="error_timeout">Verzoek verlopen. Probeer het opnieuw.</string>
|
||||||
<string name="error_server">Serverfout. Probeer het later 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_unauthorized">Sessie verlopen. Log opnieuw in.</string>
|
||||||
<string name="error_not_found">Niet gevonden</string>
|
<string name="error_not_found">Niet gevonden</string>
|
||||||
<string name="error_invalid_input">Controleer uw invoer</string>
|
<string name="error_invalid_input">Controleer uw invoer</string>
|
||||||
|
|||||||
@@ -421,6 +421,8 @@
|
|||||||
<string name="error_network">Erro de rede. Verifique sua conexao.</string>
|
<string name="error_network">Erro de rede. Verifique sua conexao.</string>
|
||||||
<string name="error_timeout">Tempo esgotado. Tente novamente.</string>
|
<string name="error_timeout">Tempo esgotado. Tente novamente.</string>
|
||||||
<string name="error_server">Erro do servidor. Tente mais tarde.</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_unauthorized">Sessao expirada. Entre novamente.</string>
|
||||||
<string name="error_not_found">Nao encontrado</string>
|
<string name="error_not_found">Nao encontrado</string>
|
||||||
<string name="error_invalid_input">Verifique os dados informados</string>
|
<string name="error_invalid_input">Verifique os dados informados</string>
|
||||||
|
|||||||
@@ -421,6 +421,8 @@
|
|||||||
<string name="error_network">网络错误,请检查您的连接。</string>
|
<string name="error_network">网络错误,请检查您的连接。</string>
|
||||||
<string name="error_timeout">请求超时,请重试。</string>
|
<string name="error_timeout">请求超时,请重试。</string>
|
||||||
<string name="error_server">服务器错误,请稍后重试。</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_unauthorized">会话已过期,请重新登录。</string>
|
||||||
<string name="error_not_found">未找到</string>
|
<string name="error_not_found">未找到</string>
|
||||||
<string name="error_invalid_input">请检查您的输入</string>
|
<string name="error_invalid_input">请检查您的输入</string>
|
||||||
|
|||||||
@@ -654,6 +654,8 @@
|
|||||||
<string name="error_network">Network error. Please check your connection.</string>
|
<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_timeout">Request timed out. Please try again.</string>
|
||||||
<string name="error_server">Server error. Please try again later.</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_unauthorized">Session expired. Please log in again.</string>
|
||||||
<string name="error_not_found">Not found</string>
|
<string name="error_not_found">Not found</string>
|
||||||
<string name="error_invalid_input">Please check your input</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_start_trial">Start 7-Day Free Trial</string>
|
||||||
<string name="onboarding_subscription_continue_free">Continue with Free</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>
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ import com.tt.honeyDue.network.APILayer
|
|||||||
import com.tt.honeyDue.platform.ContractorImportHandler
|
import com.tt.honeyDue.platform.ContractorImportHandler
|
||||||
import com.tt.honeyDue.platform.PlatformUpgradeScreen
|
import com.tt.honeyDue.platform.PlatformUpgradeScreen
|
||||||
import com.tt.honeyDue.platform.ResidenceImportHandler
|
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.Res
|
||||||
import honeydue.composeapp.generated.resources.compose_multiplatform
|
import honeydue.composeapp.generated.resources.compose_multiplatform
|
||||||
@@ -82,6 +88,28 @@ fun App(
|
|||||||
var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) }
|
var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) }
|
||||||
val navController = rememberNavController()
|
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
|
// Handle navigation from notification tap
|
||||||
// Note: The actual navigation to the task column happens in MainScreen -> AllTasksScreen
|
// 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
|
// We just need to ensure the user is on MainRoute when a task navigation is requested
|
||||||
@@ -156,6 +184,7 @@ fun App(
|
|||||||
else -> MainRoute
|
else -> MainRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
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()
|
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) {
|
install(HttpRequestRetry) {
|
||||||
maxRetries = 3
|
maxRetries = 3
|
||||||
retryIf { _, response ->
|
retryIf { _, response ->
|
||||||
response.status.value in 500..599
|
response.status.value in 500..599 || response.status.value == 429
|
||||||
}
|
}
|
||||||
retryOnExceptionIf { _, cause ->
|
retryOnExceptionIf { _, cause ->
|
||||||
// Retry on network-level IO errors (connection resets, timeouts, etc.)
|
// Retry on network-level IO errors (connection resets, timeouts, etc.)
|
||||||
@@ -67,8 +68,8 @@ fun HttpClientConfig<*>.installCommonPlugins() {
|
|||||||
cause is kotlinx.io.IOException
|
cause is kotlinx.io.IOException
|
||||||
}
|
}
|
||||||
exponentialDelay(
|
exponentialDelay(
|
||||||
base = 1000.0, // 1 second base
|
base = 1000.0,
|
||||||
maxDelayMs = 10000 // 10 second max
|
maxDelayMs = 10_000
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,19 @@ object ErrorParser {
|
|||||||
* Parses error response from the backend.
|
* Parses error response from the backend.
|
||||||
* The backend returns: {"error": "message"}
|
* The backend returns: {"error": "message"}
|
||||||
* Falls back to detail field or field-specific errors if present.
|
* 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 {
|
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 {
|
return try {
|
||||||
val errorResponse = response.body<ErrorResponse>()
|
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 androidx.compose.runtime.getValue
|
||||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
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 honeydue.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@@ -57,6 +60,10 @@ fun ProfileScreen(
|
|||||||
var showUpgradePrompt by remember { mutableStateOf(false) }
|
var showUpgradePrompt by remember { mutableStateOf(false) }
|
||||||
var showDeleteAccountDialog 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 updateState by viewModel.updateProfileState.collectAsState()
|
||||||
val deleteAccountState by viewModel.deleteAccountState.collectAsState()
|
val deleteAccountState by viewModel.deleteAccountState.collectAsState()
|
||||||
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
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
|
// Contact Support Section
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
OrganicCard(
|
OrganicCard(
|
||||||
|
|||||||
@@ -229,6 +229,190 @@ class HttpClientPluginsTest {
|
|||||||
client.close()
|
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 ====================
|
// ==================== Token Refresh / 401 Handling Tests ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import com.tt.honeyDue.storage.TokenManager
|
|||||||
import com.tt.honeyDue.storage.TokenStorage
|
import com.tt.honeyDue.storage.TokenStorage
|
||||||
import com.tt.honeyDue.storage.TaskCacheManager
|
import com.tt.honeyDue.storage.TaskCacheManager
|
||||||
import com.tt.honeyDue.storage.TaskCacheStorage
|
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.ThemeStorage
|
||||||
import com.tt.honeyDue.storage.ThemeStorageManager
|
import com.tt.honeyDue.storage.ThemeStorageManager
|
||||||
import com.tt.honeyDue.ui.theme.ThemeManager
|
import com.tt.honeyDue.ui.theme.ThemeManager
|
||||||
@@ -20,5 +22,8 @@ fun MainViewController() = ComposeUIViewController {
|
|||||||
ThemeStorage.initialize(ThemeStorageManager.getInstance())
|
ThemeStorage.initialize(ThemeStorageManager.getInstance())
|
||||||
ThemeManager.initialize()
|
ThemeManager.initialize()
|
||||||
|
|
||||||
|
// Initialize BiometricPreference storage
|
||||||
|
BiometricPreference.initialize(BiometricPreferenceManager.getInstance())
|
||||||
|
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -48,6 +48,11 @@ struct ErrorCodeMappingTests {
|
|||||||
#expect(result == "Too many attempts. Please wait a few minutes and try again.")
|
#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() {
|
@Test func taskNotFoundCode() {
|
||||||
let result = ErrorMessageParser.parse("error.task_not_found")
|
let result = ErrorMessageParser.parse("error.task_not_found")
|
||||||
#expect(result == "Task not found. It may have been deleted.")
|
#expect(result == "Task not found. It may have been deleted.")
|
||||||
@@ -137,6 +142,16 @@ struct NetworkErrorPatternTests {
|
|||||||
let result = ErrorMessageParser.parse("SocketTimeoutException: connect timed out")
|
let result = ErrorMessageParser.parse("SocketTimeoutException: connect timed out")
|
||||||
#expect(result == "Request timed out. Please try again.")
|
#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
|
// MARK: - Technical Error Detection Tests
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ enum ErrorMessageParser {
|
|||||||
|
|
||||||
// Password reset errors
|
// Password reset errors
|
||||||
"error.rate_limit_exceeded": "Too many attempts. Please wait a few minutes and try again.",
|
"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.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.invalid_reset_token": "This reset link has expired. Please request a new one.",
|
||||||
"error.password_reset_failed": "Password reset failed. Please try again.",
|
"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."),
|
("ConnectException", "Unable to connect. Please check your internet connection."),
|
||||||
("SocketTimeoutException", "Request timed out. Please try again."),
|
("SocketTimeoutException", "Request timed out. Please try again."),
|
||||||
("Connection refused", "Unable to connect. The server may be temporarily unavailable."),
|
("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
|
// MARK: - Technical Error Indicators
|
||||||
|
|||||||
Reference in New Issue
Block a user