Add biometric lock and rate limit handling

Biometric lock: opt-in Face ID/Touch ID/fingerprint app lock with toggle
in ProfileScreen. Locks on background, requires auth on foreground return.
Platform implementations: BiometricPrompt (Android), LAContext (iOS).

Rate limit: 429 responses parsed with Retry-After header, user-friendly
error messages in all 10 locales, retry plugin respects 429.
ErrorMessageParser updated for both iOS Swift and KMM.
This commit is contained in:
Trey T
2026-03-26 14:37:04 -05:00
parent 334767cee7
commit 0d80df07f6
31 changed files with 871 additions and 7 deletions

View File

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

View File

@@ -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)
}
}
}
}
}

View File

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