From 1df18a2a763c4f00ecbabfbcdfa3fe71db72c18c Mon Sep 17 00:00:00 2001 From: ycram Date: Fri, 12 Feb 2021 18:32:54 +0100 Subject: [PATCH] Add Biometric Authentication (#16) This allows using the biometric authentication to authenticate as parent user. Co-authored-by: Marcel Voigt Reviewed-on: https://codeberg.org/timelimit/opentimelimit-android/pulls/16 Co-Authored-By: ycram Co-Committed-By: ycram --- CONTRIBUTORS.md | 9 ++ app/build.gradle | 3 + .../io/timelimit/android/data/model/User.kt | 7 +- .../ui/login/LoginDialogFragmentModel.kt | 79 ++++++++++--- .../android/ui/login/NewLoginFragment.kt | 53 +++++++++ .../android/ui/main/ActivityViewModel.kt | 12 +- .../ui/manage/parent/ManageParentFragment.kt | 10 ++ ...ometricAuthDeniedPasswordRequiredDialog.kt | 35 ++++++ .../EnableBiometricAuthConfirmDialog.kt | 103 +++++++++++++++++ ...eBiometricAuthDeniedNoCredentialsDialog.kt | 51 +++++++++ ...ManageBiometricAuthDeniedNotOwnerDialog.kt | 44 ++++++++ .../biometric/ManageBiometricAuthDialog.kt | 73 ++++++++++++ .../biometric/ManageUserBiometricAuthView.kt | 104 ++++++++++++++++++ .../main/res/drawable/ic_fingerprint_24dp.xml | 10 ++ .../res/layout/fragment_manage_parent.xml | 4 + .../manage_user_biometric_auth_dialog.xml | 93 ++++++++++++++++ .../manage_user_biometric_auth_view.xml | 85 ++++++++++++++ .../layout/new_login_fragment_password.xml | 14 +++ app/src/main/res/values-de/strings.xml | 23 ++++ app/src/main/res/values-v28/strings.xml | 20 ++++ app/src/main/res/values/strings.xml | 34 +++++- 21 files changed, 843 insertions(+), 23 deletions(-) create mode 100644 CONTRIBUTORS.md create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/DisableBiometricAuthDeniedPasswordRequiredDialog.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/EnableBiometricAuthConfirmDialog.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/EnableBiometricAuthDeniedNoCredentialsDialog.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageBiometricAuthDeniedNotOwnerDialog.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageBiometricAuthDialog.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageUserBiometricAuthView.kt create mode 100644 app/src/main/res/drawable/ic_fingerprint_24dp.xml create mode 100644 app/src/main/res/layout/manage_user_biometric_auth_dialog.xml create mode 100644 app/src/main/res/layout/manage_user_biometric_auth_view.xml create mode 100644 app/src/main/res/values-v28/strings.xml diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..64bb9ca --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,9 @@ +# TimeLimit Contributors + +- [Jonas Lochmann](https://codeberg.org/jonas-l) + + - Author and maintainer + +- [Marcel Voigt](https://codeberg.org/ycram) + + - Biometric Authentication diff --git a/app/build.gradle b/app/build.gradle index 4e1290a..75c4002 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,6 @@ /* * Open TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * Copyright 2020 Marcel Voigt * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -103,6 +104,8 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0' + implementation 'androidx.biometric:biometric:1.1.0' + testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' diff --git a/app/src/main/java/io/timelimit/android/data/model/User.kt b/app/src/main/java/io/timelimit/android/data/model/User.kt index 98f6c49..c5c1667 100644 --- a/app/src/main/java/io/timelimit/android/data/model/User.kt +++ b/app/src/main/java/io/timelimit/android/data/model/User.kt @@ -1,5 +1,6 @@ /* * Open TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * Copyright 2020 Marcel Voigt * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -127,6 +128,9 @@ data class User( val allowSelfLimitAdding: Boolean get() = flags and UserFlags.ALLOW_SELF_LIMIT_ADD == UserFlags.ALLOW_SELF_LIMIT_ADD + val biometricAuthEnabled + get() = flags and UserFlags.BIOMETRIC_AUTH_ENABLED == UserFlags.BIOMETRIC_AUTH_ENABLED + override fun serialize(writer: JsonWriter) { writer.beginObject() @@ -175,5 +179,6 @@ class UserTypeConverter { object UserFlags { const val RESTRICT_VIEWING_TO_PARENTS = 1L const val ALLOW_SELF_LIMIT_ADD = 2L - const val ALL_FLAGS = RESTRICT_VIEWING_TO_PARENTS or ALLOW_SELF_LIMIT_ADD + const val BIOMETRIC_AUTH_ENABLED = 4L + const val ALL_FLAGS = RESTRICT_VIEWING_TO_PARENTS or ALLOW_SELF_LIMIT_ADD or BIOMETRIC_AUTH_ENABLED } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt b/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt index 30e8fd1..4e41c03 100644 --- a/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt @@ -1,5 +1,6 @@ /* * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * Copyright 2020 Marcel Voigt * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -36,6 +37,7 @@ import io.timelimit.android.sync.actions.ChildSignInAction import io.timelimit.android.sync.actions.apply.ApplyActionUtil import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.AuthenticatedUser +import io.timelimit.android.ui.main.AuthenticationMethod import io.timelimit.android.ui.manage.parent.key.ScannedKey import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -79,6 +81,7 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli } private val isCheckingPassword = MutableLiveData().apply { value = false } private val wasPasswordWrong = MutableLiveData().apply { value = false } + var biometricPromptDismissed = false private val isLoginDone = MutableLiveData().apply { value = false } private val loginLock = Mutex() @@ -94,21 +97,21 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli val loginScreen = isCheckingPassword.switchMap { isCheckingPassword -> wasPasswordWrong.map { wasPasswordWrong -> ParentUserLogin( - isCheckingPassword = isCheckingPassword, - wasPasswordWrong = wasPasswordWrong + isCheckingPassword = isCheckingPassword, + wasPasswordWrong = wasPasswordWrong, + biometricAuthEnabled = selectedUser.biometricAuthEnabled, + userName = selectedUser.name ) as LoginDialogStatus } } AllowUserLoginStatusUtil.calculateLive(logic, selectedUser.id).switchMap { status -> - if (status is AllowUserLoginStatus.Allow) { - loginScreen - } else if (status is AllowUserLoginStatus.ForbidByCategory) { + if (status is AllowUserLoginStatus.ForbidByCategory) { liveDataFromValue( - ParentUserLoginBlockedByCategory( - categoryTitle = status.categoryTitle, - reason = status.blockingReason - ) as LoginDialogStatus + ParentUserLoginBlockedByCategory( + categoryTitle = status.categoryTitle, + reason = status.blockingReason + ) as LoginDialogStatus ) } else { loginScreen @@ -146,6 +149,7 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli } fun startSignIn(user: User) { + biometricPromptDismissed = false selectedUserId.value = user.id } @@ -170,8 +174,10 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli if (shouldSignIn) { model.setAuthenticatedUser(AuthenticatedUser( - userId = user.id, - passwordHash = user.password + userId = user.id, + passwordHash = user.password, + isPasswordDisabled = emptyPasswordValid, + authenticatedBy = AuthenticationMethod.Password )) isLoginDone.value = true @@ -228,8 +234,10 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli if (shouldSignIn) { model.setAuthenticatedUser(AuthenticatedUser( - userId = user.id, - passwordHash = user.password + userId = user.id, + passwordHash = user.password, + isPasswordDisabled = Threads.crypto.executeAndWait { PasswordHashing.validateSync("", user.password) }, + authenticatedBy = AuthenticationMethod.KeyCode )) isLoginDone.value = true @@ -268,8 +276,10 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli } val authenticatedUser = AuthenticatedUser( - userId = userEntry.id, - passwordHash = userEntry.password + userId = userEntry.id, + passwordHash = userEntry.password, + isPasswordDisabled = Threads.crypto.executeAndWait { PasswordHashing.validateSync("", userEntry.password) }, + authenticatedBy = AuthenticationMethod.Password ) val allowLoginStatus = Threads.database.executeAndWait { @@ -337,6 +347,37 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli } } + fun performBiometricLogin(activityViewModel: ActivityViewModel) { + runAsync { + loginLock.withLock { + selectedUser.waitForNullableValue()?.loginRelatedData?.user?.let { user: User -> + if (!user.biometricAuthEnabled) { + return@runAsync + } + + val allowLoginStatus = Threads.database.executeAndWait { + AllowUserLoginStatusUtil.calculateSync( + logic = logic, + userId = user.id + ) + } + if (allowLoginStatus !is AllowUserLoginStatus.Allow) { + Toast.makeText(getApplication(), formatAllowLoginStatusError(allowLoginStatus, getApplication()), Toast.LENGTH_SHORT).show() + return@runAsync + } + + activityViewModel.setAuthenticatedUser(AuthenticatedUser( + userId = user.id, + passwordHash = user.password, + isPasswordDisabled = Threads.crypto.executeAndWait { PasswordHashing.validateSync("", user.password) }, + authenticatedBy = AuthenticationMethod.Biometric + )) + isLoginDone.value = true + } + } + } + } + fun resetPasswordWrong() { if (wasPasswordWrong.value == true) { wasPasswordWrong.value = false @@ -358,8 +399,10 @@ sealed class LoginDialogStatus data class UserListLoginDialogStatus(val usersToShow: List): LoginDialogStatus() data class ParentUserLoginBlockedByCategory(val categoryTitle: String, val reason: BlockingReason): LoginDialogStatus() data class ParentUserLogin( - val isCheckingPassword: Boolean, - val wasPasswordWrong: Boolean + val isCheckingPassword: Boolean, + val wasPasswordWrong: Boolean, + val biometricAuthEnabled: Boolean, + val userName: String ): LoginDialogStatus() object LoginDialogDone: LoginDialogStatus() data class CanNotSignInChildHasNoPassword(val childName: String): LoginDialogStatus() @@ -367,4 +410,4 @@ object ChildAlreadyDeviceUser: LoginDialogStatus() data class ChildUserLogin( val isCheckingPassword: Boolean, val wasPasswordWrong: Boolean -): LoginDialogStatus() \ No newline at end of file +): LoginDialogStatus() diff --git a/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt b/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt index d571a8a..0e859e0 100644 --- a/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt @@ -1,5 +1,6 @@ /* * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * Copyright 2020 Marcel Voigt * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,6 +25,8 @@ import android.view.ViewGroup import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.Toast +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat import androidx.fragment.app.DialogFragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders @@ -159,6 +162,10 @@ class NewLoginFragment: DialogFragment() { } password.setOnEnterListenr { go() } + + biometricAuthButton.setOnClickListener { + tryBiometricLogin() + } } binding.childPassword.apply { @@ -192,9 +199,13 @@ class NewLoginFragment: DialogFragment() { binding.switcher.setInAnimation(context!!, R.anim.wizard_open_step_in) binding.switcher.setOutAnimation(context!!, R.anim.wizard_open_step_out) binding.switcher.displayedChild = PARENT_AUTH + if (status.biometricAuthEnabled && !model.biometricPromptDismissed) { + tryBiometricLogin() + } } binding.enterPassword.password.isEnabled = !status.isCheckingPassword + binding.enterPassword.biometricAuthEnabled = status.biometricAuthEnabled if (!binding.enterPassword.showCustomKeyboard) { binding.enterPassword.password.requestFocus() @@ -272,4 +283,46 @@ class NewLoginFragment: DialogFragment() { fun tryCodeLogin(code: ScannedKey) { model.tryCodeLogin(code, getActivityViewModel(activity!!)) } + + private fun tryBiometricLogin() { + model.biometricPromptDismissed = false + model.status.value?.let { status -> + if (status is ParentUserLogin) { + BiometricPrompt(this, object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + model.biometricPromptDismissed = true + Toast.makeText( + context, + getString(R.string.biometric_auth_failed, status.userName) + "\n" + + getString(R.string.biometric_auth_failed_reason, errString), + Toast.LENGTH_LONG + ).show() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + model.biometricPromptDismissed = true + model.performBiometricLogin(getActivityViewModel(requireActivity())) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + model.biometricPromptDismissed = true + Toast.makeText(context, getString(R.string.biometric_auth_failed, status.userName), Toast.LENGTH_LONG) + .show() + } + }).authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.biometric_login_prompt_title)) + .setSubtitle(status.userName) + .setDescription(getString(R.string.biometric_login_prompt_description, status.userName)) + .setNegativeButtonText(getString(R.string.generic_cancel)) + .setConfirmationRequired(false) + .build() + ) + } + } + } + } diff --git a/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModel.kt b/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModel.kt index 7565340..003ae53 100644 --- a/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModel.kt @@ -1,5 +1,6 @@ /* * Open TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * Copyright 2020 Marcel Voigt * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -150,7 +151,12 @@ class ActivityViewModel(application: Application): AndroidViewModel(application) } } -data class AuthenticatedUser ( - val userId: String, - val passwordHash: String +data class AuthenticatedUser( + val userId: String, + val passwordHash: String, + val isPasswordDisabled: Boolean, + val authenticatedBy: AuthenticationMethod ) +enum class AuthenticationMethod { + Password, KeyCode, Biometric +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt index 340e9dc..bafba83 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt @@ -1,5 +1,6 @@ /* * Open TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * Copyright 2020 Marcel Voigt * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -40,6 +41,7 @@ import io.timelimit.android.ui.manage.child.advanced.timezone.UserTimezoneView import io.timelimit.android.ui.manage.parent.delete.DeleteParentView import io.timelimit.android.ui.manage.parent.key.ManageUserKeyView import io.timelimit.android.ui.manage.parent.limitlogin.ParentLimitLoginView +import io.timelimit.android.ui.manage.parent.password.biometric.ManageUserBiometricAuthView class ManageParentFragment : Fragment(), FragmentWithCustomTitle { private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } @@ -119,6 +121,14 @@ class ManageParentFragment : Fragment(), FragmentWithCustomTitle { fragmentManager = parentFragmentManager ) + ManageUserBiometricAuthView.bind( + view = binding.biometricAuth, + user = parentUser, + auth = activity.getActivityViewModel(), + fragmentManager = parentFragmentManager, + fragment = this + ) + binding.handlers = object: ManageParentFragmentHandlers { override fun onChangePasswordClicked() { navigation.safeNavigate( diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/DisableBiometricAuthDeniedPasswordRequiredDialog.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/DisableBiometricAuthDeniedPasswordRequiredDialog.kt new file mode 100644 index 0000000..69029fd --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/DisableBiometricAuthDeniedPasswordRequiredDialog.kt @@ -0,0 +1,35 @@ +/* + * TimeLimit Copyright 2020 Marcel Voigt + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.password.biometric + +import io.timelimit.android.R + +class DisableBiometricAuthDeniedPasswordRequiredDialog : + ManageBiometricAuthDialog(DisableBiometricAuthDeniedPasswordRequiredDialog::class.java.simpleName) { + override val titleText by lazy { getString(R.string.biometric_manage_disable_password_required_dialog_title) } + override val messageText by lazy { getString(R.string.biometric_manage_disable_password_required_dialog_text) } + override val positiveButtonText by lazy { getString(R.string.generic_logout) } + override val negativeButtonText by lazy { getString(R.string.generic_cancel) } + + override fun onPositiveButtonClicked() { + auth.logOut() + super.onPositiveButtonClicked() + } + + companion object { + fun newInstance() = DisableBiometricAuthDeniedPasswordRequiredDialog() + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/EnableBiometricAuthConfirmDialog.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/EnableBiometricAuthConfirmDialog.kt new file mode 100644 index 0000000..7c1989d --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/EnableBiometricAuthConfirmDialog.kt @@ -0,0 +1,103 @@ +/* + * TimeLimit Copyright 2020 Marcel Voigt + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.password.biometric + +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.biometric.BiometricPrompt +import io.timelimit.android.R +import io.timelimit.android.data.model.UserFlags +import io.timelimit.android.sync.actions.UpdateUserFlagsAction + +class EnableBiometricAuthConfirmDialog : + ManageBiometricAuthDialog(EnableBiometricAuthConfirmDialog::class.java.simpleName) { + override val titleText by lazy { getString(R.string.biometric_manage_enable_dialog_title) } + override val messageText by lazy { getString(R.string.biometric_manage_enable_dialog_text, userName) } + override val positiveButtonText by lazy { getString(R.string.generic_enable) } + override val negativeButtonText by lazy { getString(R.string.generic_cancel) } + + private lateinit var biometricPrompt: BiometricPrompt + private val userName by lazy { requireArguments().getString(ARG_USER_NAME) } + + override fun onPositiveButtonClicked() { + showBiometricPrompt() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + biometricPrompt = BiometricPrompt(requireActivity(), object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + Toast.makeText( + context, + getString(R.string.biometric_auth_failed, userName) + "\n" + + getString(R.string.biometric_auth_failed_reason, errString), + Toast.LENGTH_LONG + ).show() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + if (setUserFlag()) dismiss() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + Toast.makeText( + context, + getString(R.string.biometric_auth_failed, userName), + Toast.LENGTH_LONG + ).show() + } + }) + } + + private fun showBiometricPrompt() { + biometricPrompt.authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.biometric_enable_prompt_title)) + .setSubtitle(userName) + .setDescription(getString(R.string.biometric_enable_prompt_description, userName)) + .setNegativeButtonText(getString(R.string.generic_cancel)) + .setConfirmationRequired(false) + .build() + ) + } + + private fun setUserFlag(): Boolean { + val userId = requireArguments().getString(ARG_USER_ID) ?: return false + return auth.tryDispatchParentAction( + UpdateUserFlagsAction( + userId = userId, + modifiedBits = UserFlags.BIOMETRIC_AUTH_ENABLED, + newValues = UserFlags.BIOMETRIC_AUTH_ENABLED + ) + ) + } + + companion object { + private const val ARG_USER_ID = "userId" + private const val ARG_USER_NAME = "userName" + fun newInstance(userId: String, userName: String) = EnableBiometricAuthConfirmDialog().apply { + arguments = Bundle().apply { + putString(ARG_USER_ID, userId) + putString(ARG_USER_NAME, userName) + } + } + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/EnableBiometricAuthDeniedNoCredentialsDialog.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/EnableBiometricAuthDeniedNoCredentialsDialog.kt new file mode 100644 index 0000000..12cfedd --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/EnableBiometricAuthDeniedNoCredentialsDialog.kt @@ -0,0 +1,51 @@ +/* + * TimeLimit Copyright 2020 Marcel Voigt + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.password.biometric + +import android.content.Intent +import android.os.Build +import android.provider.Settings +import io.timelimit.android.R + +class EnableBiometricAuthDeniedNoCredentialsDialog : + ManageBiometricAuthDialog(EnableBiometricAuthDeniedNoCredentialsDialog::class.java.simpleName) { + override val titleText by lazy { getString(R.string.biometric_manage_no_credentials_dialog_title) } + override val messageText by lazy { getString(R.string.biometric_manage_no_credentials_dialog_text) } + override val positiveButtonText by lazy { getString(R.string.biometric_manage_no_credentials_dialog_action) } + override val negativeButtonText by lazy { getString(R.string.generic_cancel) } + + override fun onPositiveButtonClicked() { + super.onPositiveButtonClicked() + + @Suppress("DEPRECATION") + startActivity( + Intent( + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> + Settings.ACTION_BIOMETRIC_ENROLL + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> + Settings.ACTION_FINGERPRINT_ENROLL + else -> + Settings.ACTION_SECURITY_SETTINGS + } + ) + ) + } + + companion object { + fun newInstance() = EnableBiometricAuthDeniedNoCredentialsDialog() + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageBiometricAuthDeniedNotOwnerDialog.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageBiometricAuthDeniedNotOwnerDialog.kt new file mode 100644 index 0000000..85fc816 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageBiometricAuthDeniedNotOwnerDialog.kt @@ -0,0 +1,44 @@ +/* + * TimeLimit Copyright 2020 Marcel Voigt + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.password.biometric + +import android.os.Bundle +import io.timelimit.android.R + +class ManageBiometricAuthDeniedNotOwnerDialog : + ManageBiometricAuthDialog(ManageBiometricAuthDeniedNotOwnerDialog::class.java.simpleName) { + override val titleText by lazy { getString(R.string.biometric_manage_not_owner_dialog_title) } + override val messageText by lazy { + getString(R.string.biometric_manage_not_owner_dialog_text, requireArguments().getString(ARG_USER_NAME)) + } + override val positiveButtonText by lazy { getString(R.string.generic_logout) } + override val negativeButtonText by lazy { getString(R.string.generic_cancel) } + + override fun onPositiveButtonClicked() { + auth.logOut() + super.onPositiveButtonClicked() + } + + companion object { + private const val ARG_USER_NAME = "userName" + + fun newInstance(userName: String) = ManageBiometricAuthDeniedNotOwnerDialog().apply { + arguments = Bundle().apply { + putString(ARG_USER_NAME, userName) + } + } + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageBiometricAuthDialog.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageBiometricAuthDialog.kt new file mode 100644 index 0000000..4df222f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageBiometricAuthDialog.kt @@ -0,0 +1,73 @@ +/* + * TimeLimit Copyright 2020 Marcel Voigt + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.password.biometric + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import io.timelimit.android.databinding.ManageUserBiometricAuthDialogBinding +import io.timelimit.android.extensions.showSafe +import io.timelimit.android.ui.main.getActivityViewModel + +interface ManageBiometricAuthDialogHandler { + fun onPositiveButtonClicked() + fun onNegativeButtonClicked() +} + +abstract class ManageBiometricAuthDialog(private val dialogTag: String) : + BottomSheetDialogFragment(), ManageBiometricAuthDialogHandler { + protected abstract val titleText: String + protected abstract val messageText: String + protected abstract val positiveButtonText: String + protected abstract val negativeButtonText: String + + protected val auth by lazy { getActivityViewModel(requireActivity()) } + protected lateinit var binding: ManageUserBiometricAuthDialogBinding + + var handleCancelAsNegativeButton = true + + override fun onPositiveButtonClicked() = dismiss() + override fun onNegativeButtonClicked() = dismiss() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = ManageUserBiometricAuthDialogBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.title = titleText + binding.text = messageText + binding.positiveButtonText = positiveButtonText + binding.negativeButtonText = negativeButtonText + binding.handler = this + } + + override fun onCancel(dialog: DialogInterface) { + if (handleCancelAsNegativeButton) onNegativeButtonClicked() + super.onCancel(dialog) + } + + fun show(fragmentManager: FragmentManager) { + showSafe(fragmentManager, dialogTag) + } + +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageUserBiometricAuthView.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageUserBiometricAuthView.kt new file mode 100644 index 0000000..6686338 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/password/biometric/ManageUserBiometricAuthView.kt @@ -0,0 +1,104 @@ +/* + * TimeLimit Copyright 2020 Marcel Voigt + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.password.biometric + +import androidx.biometric.BiometricManager +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LiveData +import io.timelimit.android.R +import io.timelimit.android.data.model.User +import io.timelimit.android.data.model.UserFlags +import io.timelimit.android.databinding.ManageUserBiometricAuthViewBinding +import io.timelimit.android.sync.actions.UpdateUserFlagsAction +import io.timelimit.android.ui.extension.bindHelpDialog +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.ui.main.AuthenticationMethod + +object ManageUserBiometricAuthView { + fun bind( + view: ManageUserBiometricAuthViewBinding, + user: LiveData, + auth: ActivityViewModel, + fragmentManager: FragmentManager, + fragment: Fragment + ) { + user.observe(view.lifecycleOwner ?: fragment.viewLifecycleOwner) { + if (it != null) { + view.userName = it.name + view.biometricAuthEnabled = it.biometricAuthEnabled + view.errorText = when (BiometricManager.from(fragment.requireContext()).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)) { + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> fragment.getString(R.string.biometric_manage_error_no_hw) + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE, BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> + fragment.getString(R.string.biometric_manage_error_hw_not_available) + //BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> "" //Handled later by a dialog if necessary + //BiometricManager.BIOMETRIC_SUCCESS -> "" + else -> "" + } + } + } + + fun toggleUserFlag() { + user.value?.let { user -> + auth.tryDispatchParentAction( + UpdateUserFlagsAction( + userId = user.id, + modifiedBits = UserFlags.BIOMETRIC_AUTH_ENABLED, + newValues = if (!view.biometricAuthEnabled) UserFlags.BIOMETRIC_AUTH_ENABLED else 0 + ) + ) + } + } + + view.toggleBiometricAuthSwitch.setOnCheckedChangeListener { v, isChecked -> + // Checked state of the switch view shall always reflect the currently active setting: + // when it's the same there's nothing to do (just updating the UI from user data); + // when it differs (changed via UI) just reset the UI state, which is updated whenever the user's flag actually has changed. + if (isChecked == view.biometricAuthEnabled) + return@setOnCheckedChangeListener + else + v.isChecked = view.biometricAuthEnabled + + if (auth.requestAuthenticationOrReturnTrue()) { + @Suppress("NAME_SHADOWING") val user = user.value ?: return@setOnCheckedChangeListener + val authenticatedUser = auth.authenticatedUser.value ?: return@setOnCheckedChangeListener + + if (authenticatedUser.id != user.id) { + ManageBiometricAuthDeniedNotOwnerDialog.newInstance(userName = user.name).show(fragmentManager) + } else if (!view.biometricAuthEnabled) { + when (BiometricManager.from(fragment.requireContext()).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)) { + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> + EnableBiometricAuthDeniedNoCredentialsDialog.newInstance().show(fragmentManager) + else -> + EnableBiometricAuthConfirmDialog.newInstance(userId = user.id, userName = user.name).show(fragmentManager) + } + } else { + if (auth.getAuthenticatedUser()?.authenticatedBy == AuthenticationMethod.Password || auth.getAuthenticatedUser()?.isPasswordDisabled == true) { + toggleUserFlag() + } else { + DisableBiometricAuthDeniedPasswordRequiredDialog.newInstance().show(fragmentManager) + } + } + } + } + + view.titleView.bindHelpDialog( + titleRes = R.string.biometric_manage_title, + textRes = R.string.biometric_manage_info, + fragmentManager = fragmentManager + ) + } +} diff --git a/app/src/main/res/drawable/ic_fingerprint_24dp.xml b/app/src/main/res/drawable/ic_fingerprint_24dp.xml new file mode 100644 index 0000000..4ad0310 --- /dev/null +++ b/app/src/main/res/drawable/ic_fingerprint_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_manage_parent.xml b/app/src/main/res/layout/fragment_manage_parent.xml index bcb20d4..bdd883b 100644 --- a/app/src/main/res/layout/fragment_manage_parent.xml +++ b/app/src/main/res/layout/fragment_manage_parent.xml @@ -1,5 +1,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +