From d86f63a5426df231af045a14af052ea8b0aae7a6 Mon Sep 17 00:00:00 2001 From: Flavia Handrea Date: Tue, 12 Nov 2024 18:38:10 +0200 Subject: [PATCH] Biometrics implementation. --- .../ui/login/AuthenticationActivity.kt | 12 +- .../ui/login/AuthenticationFragment.kt | 38 ++++- .../login/compose/AuthenticationContainer.kt | 4 + .../ui/login/compose/BiometricsPage.kt | 111 ++++++++++++ .../viewmodels/AuthenticationViewModel.kt | 160 +++++++++++++++++- .../res/drawable/ic_fingerprint_primary.xml | 27 +++ .../main/res/layout/fragment_biometrics.xml | 2 +- app/src/main/res/values/strings.xml | 4 +- 8 files changed, 336 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/org/permanent/permanent/ui/login/compose/BiometricsPage.kt create mode 100644 app/src/main/res/drawable/ic_fingerprint_primary.xml diff --git a/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationActivity.kt b/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationActivity.kt index f1186a32..e4ef5d96 100644 --- a/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationActivity.kt +++ b/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationActivity.kt @@ -11,7 +11,6 @@ import org.permanent.permanent.ui.PREFS_NAME import org.permanent.permanent.ui.PreferencesHelper import org.permanent.permanent.ui.activities.PermanentBaseActivity import org.permanent.permanent.ui.computeWindowSizeClasses -import org.permanent.permanent.ui.login.compose.AuthPage class AuthenticationActivity : PermanentBaseActivity() { @@ -41,16 +40,7 @@ class AuthenticationActivity : PermanentBaseActivity() { val navHostFragment = supportFragmentManager.findFragmentById(R.id.authenticationNavHostFragment) as NavHostFragment navController = navHostFragment.navController - - val intentExtras = intent.extras - val startDestPageVal = intentExtras?.getInt(START_DESTINATION_PAGE_VALUE_KEY) - if (startDestPageVal != null && startDestPageVal != 0 && startDestPageVal == AuthPage.BIOMETRICS.value) { - val navGraph = navController.graph - navGraph.startDestination = R.id.biometricsFragment - navController.setGraph(navGraph, intent.extras) - } else { - navController.setGraph(R.navigation.authentication_navigation_graph, intent.extras) - } + navController.setGraph(R.navigation.authentication_navigation_graph, intent.extras) } override fun connectViewModelEvents() { diff --git a/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationFragment.kt b/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationFragment.kt index a7b373da..d09cba63 100644 --- a/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationFragment.kt +++ b/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationFragment.kt @@ -31,6 +31,7 @@ class AuthenticationFragment : PermanentBaseFragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { viewModel = ViewModelProvider(this)[AuthenticationViewModel::class.java] + viewModel.buildPromptParams(this) prefsHelper = PreferencesHelper( requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -53,16 +54,36 @@ class AuthenticationFragment : PermanentBaseFragment() { startDestPageValue?.let { val targetPage = when (it) { AuthPage.SIGN_UP.value -> AuthPage.SIGN_UP + AuthPage.BIOMETRICS.value -> { + if (!prefsHelper.isBiometricsLogIn() || viewModel.skipLogin()) { + navigateToMainActivity() + return + } else if (!prefsHelper.isUserLoggedIn()) { + onLoggedOut() + AuthPage.SIGN_IN + } else { + viewModel.authenticateUser() + AuthPage.BIOMETRICS + } + } else -> AuthPage.SIGN_IN } viewModel.setNavigateToPage(targetPage) } } - private val onLoggedIn = Observer { + private fun onLoggedOut() { + EventsManager(requireContext()).resetUser() + // Navigate to Sign in after this + } + + private val onSignedIn = Observer { logSignInEvents() - startActivity(Intent(context, MainActivity::class.java)) - activity?.finish() + navigateToMainActivity() + } + + private val onAuthenticated = Observer { + navigateToMainActivity() } private val onAccountCreated = Observer { @@ -91,15 +112,22 @@ class AuthenticationFragment : PermanentBaseFragment() { EventsManager(requireContext()).sendToMixpanel(EventType.SignUp) } + private fun navigateToMainActivity() { + startActivity(Intent(context, MainActivity::class.java)) + activity?.finish() + } + override fun connectViewModelEvents() { viewModel.getOnAccountCreated().observe(this, onAccountCreated) - viewModel.getOnLoggedIn().observe(this, onLoggedIn) + viewModel.getOnSignedIn().observe(this, onSignedIn) + viewModel.getOnAuthenticated().observe(this, onAuthenticated) viewModel.getOnUserMissingDefaultArchive().observe(this, userMissingDefaultArchiveObserver) } override fun disconnectViewModelEvents() { viewModel.getOnAccountCreated().removeObserver(onAccountCreated) - viewModel.getOnLoggedIn().removeObserver(onLoggedIn) + viewModel.getOnSignedIn().removeObserver(onSignedIn) + viewModel.getOnAuthenticated().removeObserver(onAuthenticated) viewModel.getOnUserMissingDefaultArchive().removeObserver(userMissingDefaultArchiveObserver) } diff --git a/app/src/main/java/org/permanent/permanent/ui/login/compose/AuthenticationContainer.kt b/app/src/main/java/org/permanent/permanent/ui/login/compose/AuthenticationContainer.kt index 5c9b60a3..b805d7a3 100644 --- a/app/src/main/java/org/permanent/permanent/ui/login/compose/AuthenticationContainer.kt +++ b/app/src/main/java/org/permanent/permanent/ui/login/compose/AuthenticationContainer.kt @@ -119,6 +119,10 @@ fun AuthenticationContainer( AuthPage.FORGOT_PASSWORD_DONE.value -> { ForgotPasswordDonePage(pagerState = pagerState) } + + AuthPage.BIOMETRICS.value -> { + BiometricsPage(viewModel = viewModel) + } } } } diff --git a/app/src/main/java/org/permanent/permanent/ui/login/compose/BiometricsPage.kt b/app/src/main/java/org/permanent/permanent/ui/login/compose/BiometricsPage.kt new file mode 100644 index 00000000..3ee964b7 --- /dev/null +++ b/app/src/main/java/org/permanent/permanent/ui/login/compose/BiometricsPage.kt @@ -0,0 +1,111 @@ +@file:OptIn(ExperimentalFoundationApi::class) + +package org.permanent.permanent.ui.login.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.permanent.permanent.R +import org.permanent.permanent.ui.composeComponents.ButtonColor +import org.permanent.permanent.ui.composeComponents.CenteredTextAndIconButton +import org.permanent.permanent.ui.composeComponents.CustomSnackbar +import org.permanent.permanent.viewmodels.AuthenticationViewModel + + +@Composable +fun BiometricsPage( + viewModel: AuthenticationViewModel +) { + val regularFont = FontFamily(Font(R.font.open_sans_regular_ttf)) + + val snackbarMessage by viewModel.snackbarMessage.collectAsState() + val snackbarType by viewModel.snackbarType.collectAsState() + + val keyboardController = LocalSoftwareKeyboardController.current + val scrollState = rememberScrollState() + + Box( + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.img_logo), + contentDescription = "Logo", + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = stringResource(id = R.string.welcome_back), + fontSize = 32.sp, + lineHeight = 48.sp, + color = Color.White, + fontFamily = regularFont + ) + + Spacer(modifier = Modifier.height(64.dp)) + + Column( + modifier = Modifier.fillMaxWidth() + ) { + CenteredTextAndIconButton( + buttonColor = ButtonColor.LIGHT, + text = stringResource(id = R.string.unlock_with_fingerprint), + icon = painterResource(id = R.drawable.ic_fingerprint_primary), + ) { + keyboardController?.hide() + viewModel.clearSnackbar() + viewModel.authenticateUser() + } + + Spacer(modifier = Modifier.height(16.dp)) + + CenteredTextAndIconButton( + buttonColor = ButtonColor.TRANSPARENT, + text = stringResource(id = R.string.unlock_with_credentials), + icon = null + ) { + viewModel.deleteDeviceToken() + } + } + } + + CustomSnackbar(modifier = Modifier.align(Alignment.BottomCenter), + isForError = snackbarType == AuthenticationViewModel.SnackbarType.ERROR, + message = snackbarMessage, + buttonText = stringResource(id = R.string.ok), + onButtonClick = { + viewModel.clearSnackbar() + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt b/app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt index 43fd2639..d1c0eba3 100644 --- a/app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt +++ b/app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt @@ -1,19 +1,31 @@ package org.permanent.permanent.viewmodels +import android.app.AlertDialog import android.app.Application import android.content.Context +import android.content.Intent +import android.provider.Settings +import android.util.Log +import androidx.fragment.app.Fragment import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import co.infinum.goldfinger.Goldfinger +import co.infinum.goldfinger.MissingHardwareException +import co.infinum.goldfinger.NoEnrolledFingerprintException +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.permanent.permanent.Constants +import org.permanent.permanent.EventsManager import org.permanent.permanent.R import org.permanent.permanent.Validator import org.permanent.permanent.models.Account import org.permanent.permanent.models.Archive import org.permanent.permanent.network.IDataListener +import org.permanent.permanent.network.IResponseListener import org.permanent.permanent.network.models.Datum import org.permanent.permanent.repositories.AccountRepositoryImpl import org.permanent.permanent.repositories.ArchiveRepositoryImpl @@ -21,17 +33,21 @@ import org.permanent.permanent.repositories.AuthenticationRepositoryImpl import org.permanent.permanent.repositories.IAccountRepository import org.permanent.permanent.repositories.IArchiveRepository import org.permanent.permanent.repositories.IAuthenticationRepository +import org.permanent.permanent.repositories.INotificationRepository +import org.permanent.permanent.repositories.NotificationRepositoryImpl import org.permanent.permanent.ui.PREFS_NAME import org.permanent.permanent.ui.PreferencesHelper import org.permanent.permanent.ui.login.compose.AuthPage class AuthenticationViewModel(application: Application) : ObservableAndroidViewModel(application) { + private val TAG = AuthenticationViewModel::class.java.simpleName private val appContext = application.applicationContext private val prefsHelper = PreferencesHelper( application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) ) private var isTablet = false - private val onLoggedIn = SingleLiveEvent() + private val onSignedIn = SingleLiveEvent() + private val onAuthenticated = SingleLiveEvent() private val onAccountCreated = SingleLiveEvent() private val onUserMissingDefaultArchive = SingleLiveEvent() private val _isBusyState = MutableStateFlow(false) @@ -45,6 +61,9 @@ class AuthenticationViewModel(application: Application) : ObservableAndroidViewM private val _navigateToPage = MutableStateFlow(null) val navigateToPage: StateFlow = _navigateToPage + private lateinit var promptParams: Goldfinger.PromptParams + private var goldFinger = Goldfinger.Builder(appContext).build() + private var savedEmail: String? = null private var savedPassword: String? = null @@ -136,7 +155,7 @@ class AuthenticationViewModel(application: Application) : ObservableAndroidViewM archive.thumbURL200, archive.accessRole ) - onLoggedIn.call() + onSignedIn.call() return } } @@ -191,7 +210,7 @@ class AuthenticationViewModel(application: Application) : ObservableAndroidViewM object : IAuthenticationRepository.IOnVerifyListener { override fun onSuccess() { _isBusyState.value = false - onLoggedIn.call() + onSignedIn.call() } override fun onFailed(error: String?) { @@ -319,6 +338,137 @@ class AuthenticationViewModel(application: Application) : ObservableAndroidViewM }) } + fun skipLogin(): Boolean { + return prefsHelper.isUserLoggedIn() + && !goldFinger.canAuthenticate() + && !goldFinger.hasFingerprintHardware() + } + + fun buildPromptParams(fragment: Fragment) { + promptParams = Goldfinger.PromptParams.Builder(fragment) + .title(R.string.login_biometric_title) + .description(R.string.login_biometric_message) + .deviceCredentialsAllowed(true) + .negativeButtonText(R.string.button_cancel) + .build() + } + + fun authenticateUser() { + goldFinger.authenticate(promptParams, object : Goldfinger.Callback { + override fun onError(exception: Exception) { + when (exception) { + is NoEnrolledFingerprintException -> handleResult(Goldfinger.Reason.NO_BIOMETRICS) + is MissingHardwareException -> handleResult(Goldfinger.Reason.HW_NOT_PRESENT) + else -> exception.message?.let { showErrorMessage(it) } + } + } + override fun onResult(result: Goldfinger.Result) { + if (result.type() == Goldfinger.Type.SUCCESS) + handleResult(Goldfinger.Reason.AUTHENTICATION_SUCCESS) + else if (result.type() != Goldfinger.Type.INFO) + handleResult(result.reason()) + } + }) + } + + fun handleResult(reason: Goldfinger.Reason) { + var messageId = 0 + when (reason) { + Goldfinger.Reason.CANCELED -> messageId = 0 + Goldfinger.Reason.USER_CANCELED -> messageId = 0 + Goldfinger.Reason.AUTHENTICATION_START -> messageId = 0 + Goldfinger.Reason.AUTHENTICATION_SUCCESS -> onAuthenticated.call() + Goldfinger.Reason.NO_BIOMETRICS -> showOpenSettingsQuestionDialog() + Goldfinger.Reason.HW_NOT_PRESENT -> + messageId = R.string.login_biometric_error_no_biometric_hardware + Goldfinger.Reason.HARDWARE_UNAVAILABLE -> + messageId = R.string.login_biometric_error_unavailable + Goldfinger.Reason.TIMEOUT -> + messageId = R.string.login_biometric_error_timeout + Goldfinger.Reason.LOCKOUT, + Goldfinger.Reason.LOCKOUT_PERMANENT -> { + messageId = R.string.login_biometric_error_too_many_failed_attempts + deleteDeviceToken() + } + Goldfinger.Reason.NO_DEVICE_CREDENTIAL, + Goldfinger.Reason.NEGATIVE_BUTTON, + Goldfinger.Reason.UNABLE_TO_PROCESS, + Goldfinger.Reason.VENDOR, + Goldfinger.Reason.NO_SPACE, + Goldfinger.Reason.AUTHENTICATION_FAIL, + Goldfinger.Reason.UNKNOWN -> messageId = R.string.login_biometric_error_failed + } + if (messageId != 0) showErrorMessage(appContext.getString(messageId)) + } + + fun deleteDeviceToken() { + if (_isBusyState.value) { + return + } + + _isBusyState.value = true + FirebaseMessaging.getInstance().token + .addOnCompleteListener(OnCompleteListener { task -> + if (!task.isSuccessful) { + _isBusyState.value = false + Log.e(TAG, "Fetching FCM token failed: ${task.exception}") + return@OnCompleteListener + } + val notificationsRepository: INotificationRepository = + NotificationRepositoryImpl(appContext) + + notificationsRepository.deleteDevice(task.result, object : IResponseListener { + + override fun onSuccess(message: String?) { + _isBusyState.value = false + logout() + } + + override fun onFailed(error: String?) { + _isBusyState.value = false + Log.e(TAG, "Deleting Device FCM token failed: $error") + logout() + } + }) + }) + } + + fun logout() { + if (_isBusyState.value) { + return + } + + _isBusyState.value = true + authRepository.logout(object : IAuthenticationRepository.IOnLogoutListener { + override fun onSuccess() { + _isBusyState.value = false + EventsManager(appContext).resetUser() + _navigateToPage.value = AuthPage.SIGN_IN + } + + override fun onFailed(error: String?) { + _isBusyState.value = false + when (error) { + Constants.ERROR_SERVER_ERROR, + Constants.ERROR_NO_API_KEY -> showErrorMessage(appContext.getString(R.string.server_error)) + else -> error?.let { showErrorMessage(it) } + } + } + }) + } + + private fun showOpenSettingsQuestionDialog() { + AlertDialog.Builder(appContext).apply { + setTitle(context.getString(R.string.login_biometric_error_no_biometrics_enrolled_title)) + setMessage(context.getString(R.string.login_biometric_error_no_biometrics_enrolled_message)) + setPositiveButton(R.string.yes_button) { _, _ -> + appContext.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) } + setNegativeButton(R.string.button_cancel) { _, _ -> } + create() + show() + } + } + fun clearSnackbar() { _snackbarMessage.value = "" } @@ -335,7 +485,9 @@ class AuthenticationViewModel(application: Application) : ObservableAndroidViewM fun getOnUserMissingDefaultArchive(): MutableLiveData = onUserMissingDefaultArchive - fun getOnLoggedIn(): MutableLiveData = onLoggedIn + fun getOnSignedIn(): MutableLiveData = onSignedIn + + fun getOnAuthenticated(): MutableLiveData = onAuthenticated fun getOnAccountCreated(): MutableLiveData = onAccountCreated } diff --git a/app/src/main/res/drawable/ic_fingerprint_primary.xml b/app/src/main/res/drawable/ic_fingerprint_primary.xml new file mode 100644 index 00000000..7f4b66f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_fingerprint_primary.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_biometrics.xml b/app/src/main/res/layout/fragment_biometrics.xml index 11bb7b65..c276c2ed 100644 --- a/app/src/main/res/layout/fragment_biometrics.xml +++ b/app/src/main/res/layout/fragment_biometrics.xml @@ -31,7 +31,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" - android:text="@string/login_biometric_welcome_message" + android:text="@string/welcome_back" android:textColor="@color/colorAccent" android:textSize="18sp" android:fontFamily="@font/open_sans_bold" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18ee7af5..24df159e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,6 +53,8 @@ I agree to receive updates via email I agree with Terms and Conditions + Unlock with fingerprint + Unlock with Sign in credentials Verification code can not be empty Invalid verification code. Please try again. @@ -85,7 +87,7 @@ Too many failed attempts. Please login with you credentials. Biometric login Log in using your biometric credential. - Welcome back + Welcome back! Unlock with Biometrics Use credentials instead View Profile