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