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:
@@ -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<String?>(null)
|
||||
private var navigateToTaskId by mutableStateOf<Int?>(null)
|
||||
private var pendingContractorImportUri by mutableStateOf<Uri?>(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(
|
||||
|
||||
@@ -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_timeout">Zeituberschreitung. Bitte versuchen Sie es erneut.</string>
|
||||
<string name="error_server">Serverfehler. Bitte versuchen Sie es spater erneut.</string>
|
||||
<string name="error_rate_limit">Zu viele Anfragen. Bitte versuchen Sie es spater erneut.</string>
|
||||
<string name="error_rate_limit_with_delay">Zu viele Anfragen. Bitte versuchen Sie es in %1$d Sekunden erneut.</string>
|
||||
<string name="error_unauthorized">Sitzung abgelaufen. Bitte melden Sie sich erneut an.</string>
|
||||
<string name="error_not_found">Nicht gefunden</string>
|
||||
<string name="error_invalid_input">Bitte uberprufen Sie Ihre Eingabe</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">Error de red. Verifica tu conexion.</string>
|
||||
<string name="error_timeout">Tiempo de espera agotado. Intenta de nuevo.</string>
|
||||
<string name="error_server">Error del servidor. Intenta mas tarde.</string>
|
||||
<string name="error_rate_limit">Demasiadas solicitudes. Intenta de nuevo mas tarde.</string>
|
||||
<string name="error_rate_limit_with_delay">Demasiadas solicitudes. Intenta de nuevo en %1$d segundos.</string>
|
||||
<string name="error_unauthorized">Sesion expirada. Inicia sesion de nuevo.</string>
|
||||
<string name="error_not_found">No encontrado</string>
|
||||
<string name="error_invalid_input">Verifica los datos ingresados</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">Erreur reseau. Verifiez votre connexion.</string>
|
||||
<string name="error_timeout">Delai d\'attente depasse. Veuillez reessayer.</string>
|
||||
<string name="error_server">Erreur serveur. Veuillez reessayer plus tard.</string>
|
||||
<string name="error_rate_limit">Trop de requetes. Veuillez reessayer plus tard.</string>
|
||||
<string name="error_rate_limit_with_delay">Trop de requetes. Veuillez reessayer dans %1$d secondes.</string>
|
||||
<string name="error_unauthorized">Session expiree. Veuillez vous reconnecter.</string>
|
||||
<string name="error_not_found">Non trouve</string>
|
||||
<string name="error_invalid_input">Veuillez verifier vos donnees</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">Errore di rete. Controlla la tua connessione.</string>
|
||||
<string name="error_timeout">Richiesta scaduta. Riprova.</string>
|
||||
<string name="error_server">Errore del server. Riprova più tardi.</string>
|
||||
<string name="error_rate_limit">Troppe richieste. Riprova più tardi.</string>
|
||||
<string name="error_rate_limit_with_delay">Troppe richieste. Riprova tra %1$d secondi.</string>
|
||||
<string name="error_unauthorized">Sessione scaduta. Effettua nuovamente l\'accesso.</string>
|
||||
<string name="error_not_found">Non trovato</string>
|
||||
<string name="error_invalid_input">Controlla i tuoi dati inseriti</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">ネットワークエラー。接続を確認してください。</string>
|
||||
<string name="error_timeout">リクエストがタイムアウトしました。もう一度お試しください。</string>
|
||||
<string name="error_server">サーバーエラー。後でもう一度お試しください。</string>
|
||||
<string name="error_rate_limit">リクエストが多すぎます。しばらくしてからもう一度お試しください。</string>
|
||||
<string name="error_rate_limit_with_delay">リクエストが多すぎます。%1$d秒後にもう一度お試しください。</string>
|
||||
<string name="error_unauthorized">セッションが期限切れです。再度ログインしてください。</string>
|
||||
<string name="error_not_found">見つかりません</string>
|
||||
<string name="error_invalid_input">入力内容を確認してください</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">네트워크 오류가 발생했습니다. 연결을 확인하세요.</string>
|
||||
<string name="error_timeout">요청 시간이 초과되었습니다. 다시 시도해 주세요.</string>
|
||||
<string name="error_server">서버 오류가 발생했습니다. 나중에 다시 시도해 주세요.</string>
|
||||
<string name="error_rate_limit">요청이 너무 많습니다. 나중에 다시 시도해 주세요.</string>
|
||||
<string name="error_rate_limit_with_delay">요청이 너무 많습니다. %1$d초 후에 다시 시도해 주세요.</string>
|
||||
<string name="error_unauthorized">세션이 만료되었습니다. 다시 로그인해 주세요.</string>
|
||||
<string name="error_not_found">찾을 수 없음</string>
|
||||
<string name="error_invalid_input">입력 내용을 확인해 주세요</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">Netwerkfout. Controleer uw verbinding.</string>
|
||||
<string name="error_timeout">Verzoek verlopen. Probeer het opnieuw.</string>
|
||||
<string name="error_server">Serverfout. Probeer het later opnieuw.</string>
|
||||
<string name="error_rate_limit">Te veel verzoeken. Probeer het later opnieuw.</string>
|
||||
<string name="error_rate_limit_with_delay">Te veel verzoeken. Probeer het over %1$d seconden opnieuw.</string>
|
||||
<string name="error_unauthorized">Sessie verlopen. Log opnieuw in.</string>
|
||||
<string name="error_not_found">Niet gevonden</string>
|
||||
<string name="error_invalid_input">Controleer uw invoer</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">Erro de rede. Verifique sua conexao.</string>
|
||||
<string name="error_timeout">Tempo esgotado. Tente novamente.</string>
|
||||
<string name="error_server">Erro do servidor. Tente mais tarde.</string>
|
||||
<string name="error_rate_limit">Muitas solicitacoes. Tente novamente mais tarde.</string>
|
||||
<string name="error_rate_limit_with_delay">Muitas solicitacoes. Tente novamente em %1$d segundos.</string>
|
||||
<string name="error_unauthorized">Sessao expirada. Entre novamente.</string>
|
||||
<string name="error_not_found">Nao encontrado</string>
|
||||
<string name="error_invalid_input">Verifique os dados informados</string>
|
||||
|
||||
@@ -421,6 +421,8 @@
|
||||
<string name="error_network">网络错误,请检查您的连接。</string>
|
||||
<string name="error_timeout">请求超时,请重试。</string>
|
||||
<string name="error_server">服务器错误,请稍后重试。</string>
|
||||
<string name="error_rate_limit">请求过多,请稍后重试。</string>
|
||||
<string name="error_rate_limit_with_delay">请求过多,请在%1$d秒后重试。</string>
|
||||
<string name="error_unauthorized">会话已过期,请重新登录。</string>
|
||||
<string name="error_not_found">未找到</string>
|
||||
<string name="error_invalid_input">请检查您的输入</string>
|
||||
|
||||
@@ -654,6 +654,8 @@
|
||||
<string name="error_network">Network error. Please check your connection.</string>
|
||||
<string name="error_timeout">Request timed out. Please try again.</string>
|
||||
<string name="error_server">Server error. Please try again later.</string>
|
||||
<string name="error_rate_limit">Too many requests. Please try again later.</string>
|
||||
<string name="error_rate_limit_with_delay">Too many requests. Please try again in %1$d seconds.</string>
|
||||
<string name="error_unauthorized">Session expired. Please log in again.</string>
|
||||
<string name="error_not_found">Not found</string>
|
||||
<string name="error_invalid_input">Please check your input</string>
|
||||
@@ -810,4 +812,15 @@
|
||||
<string name="onboarding_subscription_start_trial">Start 7-Day Free Trial</string>
|
||||
<string name="onboarding_subscription_continue_free">Continue with Free</string>
|
||||
<string name="onboarding_subscription_trial_terms">7-day free trial, then %1$s. Cancel anytime.</string>
|
||||
|
||||
<!-- Biometric Lock -->
|
||||
<string name="biometric_lock_title">App Locked</string>
|
||||
<string name="biometric_lock_description">Authenticate to unlock honeyDue</string>
|
||||
<string name="biometric_lock_setting_title">Biometric Lock</string>
|
||||
<string name="biometric_lock_setting_subtitle">Require authentication when opening the app</string>
|
||||
<string name="biometric_prompt_title">Unlock honeyDue</string>
|
||||
<string name="biometric_prompt_subtitle">Verify your identity to continue</string>
|
||||
<string name="biometric_unlock_button">Unlock with Biometrics</string>
|
||||
<string name="biometric_auth_failed">Authentication failed</string>
|
||||
<string name="biometric_not_available">Biometric authentication is not available on this device</string>
|
||||
</resources>
|
||||
|
||||
@@ -60,6 +60,12 @@ import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.platform.ContractorImportHandler
|
||||
import com.tt.honeyDue.platform.PlatformUpgradeScreen
|
||||
import com.tt.honeyDue.platform.ResidenceImportHandler
|
||||
import com.tt.honeyDue.storage.BiometricPreference
|
||||
import com.tt.honeyDue.ui.screens.BiometricLockScreen
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
|
||||
import honeydue.composeapp.generated.resources.Res
|
||||
import honeydue.composeapp.generated.resources.compose_multiplatform
|
||||
@@ -82,6 +88,28 @@ fun App(
|
||||
var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Biometric lock state - starts locked if biometric is enabled and user is logged in
|
||||
var isLocked by rememberSaveable {
|
||||
mutableStateOf(BiometricPreference.isBiometricEnabled() && DataManager.authToken.value != null)
|
||||
}
|
||||
|
||||
// Lock the app when returning from background
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
// App going to background - lock if biometric is enabled
|
||||
if (BiometricPreference.isBiometricEnabled() && DataManager.authToken.value != null) {
|
||||
isLocked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation from notification tap
|
||||
// Note: The actual navigation to the task column happens in MainScreen -> AllTasksScreen
|
||||
// We just need to ensure the user is on MainRoute when a task navigation is requested
|
||||
@@ -156,6 +184,7 @@ fun App(
|
||||
else -> MainRoute
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
@@ -692,6 +721,14 @@ fun App(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Biometric lock overlay - shown when app is locked
|
||||
if (isLocked && isLoggedIn && isVerified) {
|
||||
BiometricLockScreen(
|
||||
onUnlocked = { isLocked = false }
|
||||
)
|
||||
}
|
||||
} // close Box
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -52,11 +52,12 @@ fun HttpClientConfig<*>.installCommonPlugins() {
|
||||
gzip()
|
||||
}
|
||||
|
||||
// Task 2: Retry with exponential backoff for server errors and IO exceptions
|
||||
// Task 2: Retry with exponential backoff for server errors, IO exceptions,
|
||||
// and 429 rate-limit responses (using the Retry-After header for delay).
|
||||
install(HttpRequestRetry) {
|
||||
maxRetries = 3
|
||||
retryIf { _, response ->
|
||||
response.status.value in 500..599
|
||||
response.status.value in 500..599 || response.status.value == 429
|
||||
}
|
||||
retryOnExceptionIf { _, cause ->
|
||||
// Retry on network-level IO errors (connection resets, timeouts, etc.)
|
||||
@@ -67,8 +68,8 @@ fun HttpClientConfig<*>.installCommonPlugins() {
|
||||
cause is kotlinx.io.IOException
|
||||
}
|
||||
exponentialDelay(
|
||||
base = 1000.0, // 1 second base
|
||||
maxDelayMs = 10000 // 10 second max
|
||||
base = 1000.0,
|
||||
maxDelayMs = 10_000
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,19 @@ object ErrorParser {
|
||||
* Parses error response from the backend.
|
||||
* The backend returns: {"error": "message"}
|
||||
* Falls back to detail field or field-specific errors if present.
|
||||
* For 429 responses, includes the Retry-After value in the message.
|
||||
*/
|
||||
suspend fun parseError(response: HttpResponse): String {
|
||||
// Handle 429 rate-limit responses with Retry-After header
|
||||
if (response.status.value == 429) {
|
||||
val retryAfter = response.headers["Retry-After"]?.toLongOrNull()
|
||||
return if (retryAfter != null) {
|
||||
"Too many requests. Please try again in $retryAfter seconds."
|
||||
} else {
|
||||
"Too many requests. Please try again later."
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val errorResponse = response.body<ErrorResponse>()
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Result of a biometric authentication attempt.
|
||||
*/
|
||||
sealed class BiometricResult {
|
||||
/** Authentication succeeded */
|
||||
data object Success : BiometricResult()
|
||||
/** Authentication failed (wrong biometric, cancelled, etc.) */
|
||||
data class Failed(val message: String) : BiometricResult()
|
||||
/** Biometric hardware not available on this device */
|
||||
data object NotAvailable : BiometricResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for biometric authentication operations.
|
||||
*/
|
||||
interface BiometricAuthPerformer {
|
||||
/** Check if biometric authentication is available on this device */
|
||||
fun isBiometricAvailable(): Boolean
|
||||
|
||||
/** Trigger biometric authentication with the given reason string */
|
||||
fun authenticate(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onResult: (BiometricResult) -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a biometric auth performer for the current platform.
|
||||
* Follows the same expect/actual composable pattern as rememberHapticFeedback.
|
||||
*/
|
||||
@Composable
|
||||
expect fun rememberBiometricAuth(): BiometricAuthPerformer
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
/**
|
||||
* Cross-platform biometric preference storage for persisting biometric lock setting.
|
||||
* Uses platform-specific implementations (SharedPreferences on Android, UserDefaults on iOS).
|
||||
* Follows the same pattern as ThemeStorage / ThemeStorageManager.
|
||||
*/
|
||||
object BiometricPreference {
|
||||
private var manager: BiometricPreferenceManager? = null
|
||||
|
||||
fun initialize(biometricManager: BiometricPreferenceManager) {
|
||||
manager = biometricManager
|
||||
}
|
||||
|
||||
fun isBiometricEnabled(): Boolean {
|
||||
return manager?.isBiometricEnabled() ?: false
|
||||
}
|
||||
|
||||
fun setBiometricEnabled(enabled: Boolean) {
|
||||
manager?.setBiometricEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-specific biometric preference storage interface.
|
||||
* Each platform implements this using their native storage mechanisms.
|
||||
*/
|
||||
expect class BiometricPreferenceManager {
|
||||
fun isBiometricEnabled(): Boolean
|
||||
fun setBiometricEnabled(enabled: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.tt.honeyDue.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tt.honeyDue.platform.BiometricResult
|
||||
import com.tt.honeyDue.platform.rememberBiometricAuth
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Lock screen shown when the app returns to foreground with biometric lock enabled.
|
||||
* Displays app logo and triggers biometric authentication.
|
||||
* Follows existing design system (OrganicDesign, theme colors).
|
||||
*/
|
||||
@Composable
|
||||
fun BiometricLockScreen(
|
||||
onUnlocked: () -> Unit
|
||||
) {
|
||||
val biometricAuth = rememberBiometricAuth()
|
||||
var authError by remember { mutableStateOf<String?>(null) }
|
||||
val promptTitle = stringResource(Res.string.biometric_prompt_title)
|
||||
val promptSubtitle = stringResource(Res.string.biometric_prompt_subtitle)
|
||||
|
||||
// Auto-trigger biometric prompt on appear
|
||||
LaunchedEffect(Unit) {
|
||||
biometricAuth.authenticate(
|
||||
title = promptTitle,
|
||||
subtitle = promptSubtitle
|
||||
) { result ->
|
||||
when (result) {
|
||||
is BiometricResult.Success -> onUnlocked()
|
||||
is BiometricResult.Failed -> authError = result.message
|
||||
is BiometricResult.NotAvailable -> onUnlocked() // Fallback: unlock if biometric unavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
WarmGradientBackground {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Lock icon
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.Lock,
|
||||
size = 96.dp,
|
||||
iconSize = 56.dp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_lock_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_lock_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (authError != null) {
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
Text(
|
||||
text = authError ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
|
||||
// Retry button
|
||||
Button(
|
||||
onClick = {
|
||||
authError = null
|
||||
biometricAuth.authenticate(
|
||||
title = promptTitle,
|
||||
subtitle = promptSubtitle
|
||||
) { result ->
|
||||
when (result) {
|
||||
is BiometricResult.Success -> onUnlocked()
|
||||
is BiometricResult.Failed -> authError = result.message
|
||||
is BiometricResult.NotAvailable -> onUnlocked()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Fingerprint,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_unlock_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ import com.tt.honeyDue.ui.subscription.UpgradePromptDialog
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import com.tt.honeyDue.platform.BiometricResult
|
||||
import com.tt.honeyDue.platform.rememberBiometricAuth
|
||||
import com.tt.honeyDue.storage.BiometricPreference
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@@ -57,6 +60,10 @@ fun ProfileScreen(
|
||||
var showUpgradePrompt by remember { mutableStateOf(false) }
|
||||
var showDeleteAccountDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val biometricAuth = rememberBiometricAuth()
|
||||
val isBiometricAvailable = remember { biometricAuth.isBiometricAvailable() }
|
||||
var isBiometricEnabled by remember { mutableStateOf(BiometricPreference.isBiometricEnabled()) }
|
||||
|
||||
val updateState by viewModel.updateProfileState.collectAsState()
|
||||
val deleteAccountState by viewModel.deleteAccountState.collectAsState()
|
||||
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
||||
@@ -306,6 +313,63 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Biometric Lock Section - only show if device supports biometrics
|
||||
if (isBiometricAvailable) {
|
||||
val biometricPromptTitle = stringResource(Res.string.biometric_prompt_title)
|
||||
val biometricPromptSubtitle = stringResource(Res.string.biometric_prompt_subtitle)
|
||||
|
||||
OrganicCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.naturalShadow()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.lg),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_lock_setting_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.biometric_lock_setting_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isBiometricEnabled,
|
||||
onCheckedChange = { newValue ->
|
||||
// Authenticate before toggling
|
||||
biometricAuth.authenticate(
|
||||
title = biometricPromptTitle,
|
||||
subtitle = biometricPromptSubtitle
|
||||
) { result ->
|
||||
when (result) {
|
||||
is BiometricResult.Success -> {
|
||||
BiometricPreference.setBiometricEnabled(newValue)
|
||||
isBiometricEnabled = newValue
|
||||
}
|
||||
is BiometricResult.Failed,
|
||||
is BiometricResult.NotAvailable -> {
|
||||
// Auth failed, don't change the toggle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contact Support Section
|
||||
val uriHandler = LocalUriHandler.current
|
||||
OrganicCard(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.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()
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
Reference in New Issue
Block a user