diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 08d19cb5..59a85c0a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,9 @@ dependencies { // Phone number formatting implementation("com.googlecode.libphonenumber:libphonenumber:8.2.0") + // Biometrics + implementation("androidx.biometric:biometric-ktx:1.2.0-alpha05") + // Markdown support for notes implementation("com.halilibo.compose-richtext:richtext-ui-material3:0.17.0") implementation("com.halilibo.compose-richtext:richtext-commonmark:0.17.0") diff --git a/app/src/main/java/com/bnyro/contacts/ui/activities/BaseActivity.kt b/app/src/main/java/com/bnyro/contacts/ui/activities/BaseActivity.kt index ed20c1ff..824d543e 100644 --- a/app/src/main/java/com/bnyro/contacts/ui/activities/BaseActivity.kt +++ b/app/src/main/java/com/bnyro/contacts/ui/activities/BaseActivity.kt @@ -7,8 +7,8 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.provider.Telephony -import androidx.activity.ComponentActivity import androidx.activity.viewModels +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.get import com.bnyro.contacts.ui.models.ContactsModel @@ -17,7 +17,7 @@ import com.bnyro.contacts.ui.models.ThemeModel import com.bnyro.contacts.util.NotificationHelper import com.bnyro.contacts.util.PermissionHelper -abstract class BaseActivity : ComponentActivity() { +abstract class BaseActivity : FragmentActivity() { lateinit var themeModel: ThemeModel val contactsModel by viewModels { ContactsModel.Factory diff --git a/app/src/main/java/com/bnyro/contacts/ui/activities/MainActivity.kt b/app/src/main/java/com/bnyro/contacts/ui/activities/MainActivity.kt index 0c0cb578..cb2b62d0 100644 --- a/app/src/main/java/com/bnyro/contacts/ui/activities/MainActivity.kt +++ b/app/src/main/java/com/bnyro/contacts/ui/activities/MainActivity.kt @@ -2,18 +2,25 @@ package com.bnyro.contacts.ui.activities import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle import android.provider.ContactsContract.Intents import android.provider.ContactsContract.QuickContact import androidx.activity.compose.setContent +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import com.bnyro.contacts.ext.parcelable import com.bnyro.contacts.nav.NavContainer import com.bnyro.contacts.obj.ContactData -import com.bnyro.contacts.obj.ValueWithType import com.bnyro.contacts.ui.components.ConfirmImportContactsDialog import com.bnyro.contacts.ui.components.dialogs.AddToContactDialog import com.bnyro.contacts.ui.theme.ConnectYouTheme import com.bnyro.contacts.util.BackupHelper +import com.bnyro.contacts.util.BiometricAuthUtil import com.bnyro.contacts.util.ContactsHelper import com.bnyro.contacts.util.Preferences import com.bnyro.contacts.util.IntentHelper @@ -38,12 +45,31 @@ class MainActivity : BaseActivity() { ?: Preferences.getInt(Preferences.homeTabKey, 0) setContent { ConnectYouTheme(themeModel.themeMode) { - NavContainer(initialTabIndex) - getInsertOrEditNumber()?.let { - AddToContactDialog(it) + val context = LocalContext.current + + var authSuccess by rememberSaveable { + mutableStateOf(!Preferences.getBoolean(Preferences.biometricAuthKey, false)) + } + + if (authSuccess) { + NavContainer(initialTabIndex) + getInsertOrEditNumber()?.let { + AddToContactDialog(it) + } + getSharedVcfUri()?.let { + ConfirmImportContactsDialog(contactsModel, it) + } } - getSharedVcfUri()?.let { - ConfirmImportContactsDialog(contactsModel, it) + + LaunchedEffect(Unit) { + if (authSuccess) return@LaunchedEffect + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + BiometricAuthUtil.requestAuth(context) { success -> + if (success) authSuccess = true + else finish() + } + } } } } diff --git a/app/src/main/java/com/bnyro/contacts/ui/components/prefs/SwitchPref.kt b/app/src/main/java/com/bnyro/contacts/ui/components/prefs/SwitchPref.kt index 825b3261..c18f097f 100644 --- a/app/src/main/java/com/bnyro/contacts/ui/components/prefs/SwitchPref.kt +++ b/app/src/main/java/com/bnyro/contacts/ui/components/prefs/SwitchPref.kt @@ -22,14 +22,12 @@ import androidx.compose.ui.unit.sp import com.bnyro.contacts.util.rememberPreference @Composable -fun SwitchPref( - prefKey: String, +fun SwitchPrefBase( title: String, summary: String? = null, - defaultValue: Boolean = false, - onCheckedChange: (Boolean) -> Unit = {} + checked: Boolean, + onCheckedChange: (Boolean) -> Unit ) { - var checked by rememberPreference(key = prefKey, defaultValue = defaultValue) val interactionSource = remember { MutableInteractionSource() } Row( @@ -39,7 +37,7 @@ fun SwitchPref( interactionSource = interactionSource, indication = null ) { - checked = !checked + onCheckedChange(!checked) }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically @@ -62,9 +60,24 @@ fun SwitchPref( Switch( checked = checked, onCheckedChange = { - checked = it onCheckedChange.invoke(it) } ) } } + +@Composable +fun SwitchPref( + prefKey: String, + title: String, + summary: String? = null, + defaultValue: Boolean = false, + onCheckedChange: (Boolean) -> Unit = {} +) { + var checked by rememberPreference(key = prefKey, defaultValue = defaultValue) + + SwitchPrefBase(title = title, summary = summary, checked = checked) { + checked = it + onCheckedChange.invoke(it) + } +} diff --git a/app/src/main/java/com/bnyro/contacts/ui/screens/SettingsScreen.kt b/app/src/main/java/com/bnyro/contacts/ui/screens/SettingsScreen.kt index 7f3d605d..6801c854 100644 --- a/app/src/main/java/com/bnyro/contacts/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/bnyro/contacts/ui/screens/SettingsScreen.kt @@ -1,5 +1,6 @@ package com.bnyro.contacts.ui.screens +import android.os.Build import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -15,8 +16,13 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bnyro.contacts.R @@ -27,13 +33,16 @@ import com.bnyro.contacts.ui.components.prefs.BlockPreference import com.bnyro.contacts.ui.components.prefs.EncryptBackupsPref import com.bnyro.contacts.ui.components.prefs.SettingsCategory import com.bnyro.contacts.ui.components.prefs.SwitchPref +import com.bnyro.contacts.ui.components.prefs.SwitchPrefBase import com.bnyro.contacts.ui.models.SmsModel import com.bnyro.contacts.ui.models.ThemeModel +import com.bnyro.contacts.util.BiometricAuthUtil import com.bnyro.contacts.util.Preferences @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(themeModel: ThemeModel, smsModel: SmsModel, onBackPress: () -> Unit) { + val context = LocalContext.current val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState() @@ -121,6 +130,26 @@ fun SettingsScreen(themeModel: ThemeModel, smsModel: SmsModel, onBackPress: () - modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), color = MaterialTheme.colorScheme.surfaceVariant ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + SettingsCategory(title = stringResource(R.string.security)) + + var biometricAuthEnabled by remember { + mutableStateOf(Preferences.getBoolean(Preferences.biometricAuthKey, false)) + } + + SwitchPrefBase( + title = stringResource(R.string.biometric_authentication), + summary = stringResource(R.string.biometric_authentication_summary), + checked = biometricAuthEnabled + ) { newValue -> + BiometricAuthUtil.requestAuth(context) { authSuccess -> + if (authSuccess) { + Preferences.edit { putBoolean(Preferences.biometricAuthKey, newValue) } + biometricAuthEnabled = newValue + } + } + } + } AutoBackupPref() EncryptBackupsPref() } diff --git a/app/src/main/java/com/bnyro/contacts/util/BiometricAuthUtil.kt b/app/src/main/java/com/bnyro/contacts/util/BiometricAuthUtil.kt new file mode 100644 index 00000000..4018161c --- /dev/null +++ b/app/src/main/java/com/bnyro/contacts/util/BiometricAuthUtil.kt @@ -0,0 +1,48 @@ +package com.bnyro.contacts.util + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import com.bnyro.contacts.R +import com.bnyro.contacts.ui.activities.BaseActivity + +object BiometricAuthUtil { + private const val ALLOWED_AUTHENTICATORS = + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + + @RequiresApi(Build.VERSION_CODES.P) + fun requestAuth(context: Context, onResult: (Boolean) -> Unit) { + val executor = ContextCompat.getMainExecutor(context) + + val biometricPrompt = BiometricPrompt(context as BaseActivity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + onResult(false) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onResult(true) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + onResult(false) + } + } + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(context.getString(R.string.biometric_authentication)) + .setAllowedAuthenticators(ALLOWED_AUTHENTICATORS) + .build() + + biometricPrompt.authenticate(promptInfo) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/contacts/util/Preferences.kt b/app/src/main/java/com/bnyro/contacts/util/Preferences.kt index 7464feea..fd5b99a9 100644 --- a/app/src/main/java/com/bnyro/contacts/util/Preferences.kt +++ b/app/src/main/java/com/bnyro/contacts/util/Preferences.kt @@ -29,6 +29,7 @@ object Preferences { const val encryptBackupPasswordKey = "encryptBackupsPassword" const val storeSmsLocallyKey = "storeSmsLocally" const val lastChosenAccount = "lastChosenAccount" + const val biometricAuthKey = "biometricAuth" fun init(context: Context) { preferences = context.getSharedPreferences(prefFile, Context.MODE_PRIVATE) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2db7a5f6..a6907f5e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -115,6 +115,9 @@ Backup Encrypt backups as zip Password + Security + Biometric Authentication + Require biometric authentication (e.g. fingerprint) before the app can be accessed. About License