mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	Require authentication-confirmation to change biometric lock settings (#5695)
* Requires authentication-confirmation to change biometric lock settings * Prevent double authentications on older APIs when confirming settings changes * Use new AuthPrompt API for app lock With this commit, the app lock will only explicitly require Class 2 biometrics or screen lock credential. Class 3 biometrics are guaranteed to meet Class 2 requirements thus will also be used when available. * Use extension toast
This commit is contained in:
		| @@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.asImmediateFlow | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate | ||||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil | ||||
| import eu.kanade.tachiyomi.util.system.notification | ||||
| import kotlinx.coroutines.flow.launchIn | ||||
| import kotlinx.coroutines.flow.onEach | ||||
| @@ -132,7 +133,7 @@ open class App : Application(), LifecycleObserver, ImageLoaderFactory { | ||||
|     @OnLifecycleEvent(Lifecycle.Event.ON_STOP) | ||||
|     @Suppress("unused") | ||||
|     fun onAppBackgrounded() { | ||||
|         if (preferences.lockAppAfter().get() >= 0) { | ||||
|         if (!AuthenticatorUtil.isAuthenticating && preferences.lockAppAfter().get() >= 0) { | ||||
|             SecureActivityDelegate.locked = true | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import android.content.Intent | ||||
| import androidx.fragment.app.FragmentActivity | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil | ||||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported | ||||
| import eu.kanade.tachiyomi.util.view.setSecureScreen | ||||
| import kotlinx.coroutines.flow.combine | ||||
| import kotlinx.coroutines.flow.launchIn | ||||
| @@ -28,7 +28,7 @@ class SecureActivityDelegate(private val activity: FragmentActivity) { | ||||
|  | ||||
|     fun onResume() { | ||||
|         if (preferences.useAuthenticator().get()) { | ||||
|             if (AuthenticatorUtil.isSupported(activity)) { | ||||
|             if (activity.isAuthenticationSupported()) { | ||||
|                 if (isAppLocked()) { | ||||
|                     activity.startActivity(Intent(activity, UnlockActivity::class.java)) | ||||
|                     activity.overridePendingTransition(0, 0) | ||||
|   | ||||
| @@ -2,51 +2,45 @@ package eu.kanade.tachiyomi.ui.security | ||||
|  | ||||
| import android.os.Bundle | ||||
| import androidx.biometric.BiometricPrompt | ||||
| import androidx.fragment.app.FragmentActivity | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.activity.BaseThemedActivity | ||||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil | ||||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.startAuthentication | ||||
| import timber.log.Timber | ||||
| import java.util.Date | ||||
| import java.util.concurrent.Executors | ||||
|  | ||||
| /** | ||||
|  * Blank activity with a BiometricPrompt. | ||||
|  */ | ||||
| class UnlockActivity : BaseThemedActivity() { | ||||
|  | ||||
|     private val executor = Executors.newSingleThreadExecutor() | ||||
|  | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|  | ||||
|         val biometricPrompt = BiometricPrompt( | ||||
|             this, | ||||
|             executor, | ||||
|             object : BiometricPrompt.AuthenticationCallback() { | ||||
|                 override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { | ||||
|                     super.onAuthenticationError(errorCode, errString) | ||||
|         startAuthentication( | ||||
|             getString(R.string.unlock_app), | ||||
|             confirmationRequired = false, | ||||
|             callback = object : AuthenticatorUtil.AuthenticationCallback() { | ||||
|                 override fun onAuthenticationError( | ||||
|                     activity: FragmentActivity?, | ||||
|                     errorCode: Int, | ||||
|                     errString: CharSequence | ||||
|                 ) { | ||||
|                     super.onAuthenticationError(activity, errorCode, errString) | ||||
|                     Timber.e(errString.toString()) | ||||
|                     finishAffinity() | ||||
|                 } | ||||
|  | ||||
|                 override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { | ||||
|                     super.onAuthenticationSucceeded(result) | ||||
|                 override fun onAuthenticationSucceeded( | ||||
|                     activity: FragmentActivity?, | ||||
|                     result: BiometricPrompt.AuthenticationResult | ||||
|                 ) { | ||||
|                     super.onAuthenticationSucceeded(activity, result) | ||||
|                     SecureActivityDelegate.locked = false | ||||
|                     preferences.lastAppUnlock().set(Date().time) | ||||
|                     finish() | ||||
|                 } | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         var promptInfo = BiometricPrompt.PromptInfo.Builder() | ||||
|             .setTitle(getString(R.string.unlock_app)) | ||||
|             .setAllowedAuthenticators(AuthenticatorUtil.getSupportedAuthenticators(this)) | ||||
|             .setConfirmationRequired(false) | ||||
|  | ||||
|         if (!AuthenticatorUtil.isDeviceCredentialAllowed(this)) { | ||||
|             promptInfo = promptInfo.setNegativeButtonText(getString(R.string.action_cancel)) | ||||
|         } | ||||
|  | ||||
|         biometricPrompt.authenticate(promptInfo.build()) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,8 @@ | ||||
| package eu.kanade.tachiyomi.ui.setting | ||||
|  | ||||
| import androidx.biometric.BiometricPrompt | ||||
| import androidx.fragment.app.FragmentActivity | ||||
| import androidx.preference.Preference | ||||
| import androidx.preference.PreferenceScreen | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.preference.asImmediateFlow | ||||
| @@ -9,6 +12,9 @@ import eu.kanade.tachiyomi.util.preference.summaryRes | ||||
| import eu.kanade.tachiyomi.util.preference.switchPreference | ||||
| import eu.kanade.tachiyomi.util.preference.titleRes | ||||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil | ||||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported | ||||
| import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.startAuthentication | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import kotlinx.coroutines.flow.launchIn | ||||
| import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys | ||||
|  | ||||
| @@ -17,11 +23,36 @@ class SettingsSecurityController : SettingsController() { | ||||
|     override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { | ||||
|         titleRes = R.string.pref_category_security | ||||
|  | ||||
|         if (AuthenticatorUtil.isSupported(context)) { | ||||
|         if (context.isAuthenticationSupported()) { | ||||
|             switchPreference { | ||||
|                 key = Keys.useAuthenticator | ||||
|                 titleRes = R.string.lock_with_biometrics | ||||
|                 defaultValue = false | ||||
|                 onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> | ||||
|                     (activity as? FragmentActivity)?.startAuthentication( | ||||
|                         activity!!.getString(R.string.lock_with_biometrics), | ||||
|                         activity!!.getString(R.string.confirm_lock_change), | ||||
|                         callback = object : AuthenticatorUtil.AuthenticationCallback() { | ||||
|                             override fun onAuthenticationSucceeded( | ||||
|                                 activity: FragmentActivity?, | ||||
|                                 result: BiometricPrompt.AuthenticationResult | ||||
|                             ) { | ||||
|                                 super.onAuthenticationSucceeded(activity, result) | ||||
|                                 isChecked = newValue as Boolean | ||||
|                             } | ||||
|  | ||||
|                             override fun onAuthenticationError( | ||||
|                                 activity: FragmentActivity?, | ||||
|                                 errorCode: Int, | ||||
|                                 errString: CharSequence | ||||
|                             ) { | ||||
|                                 super.onAuthenticationError(activity, errorCode, errString) | ||||
|                                 activity?.toast(errString.toString()) | ||||
|                             } | ||||
|                         } | ||||
|                     ) | ||||
|                     false | ||||
|                 } | ||||
|             } | ||||
|             intListPreference { | ||||
|                 key = Keys.lockAppAfter | ||||
| @@ -37,6 +68,33 @@ class SettingsSecurityController : SettingsController() { | ||||
|                 entryValues = values | ||||
|                 defaultValue = "0" | ||||
|                 summary = "%s" | ||||
|                 onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> | ||||
|                     if (value == newValue) return@OnPreferenceChangeListener false | ||||
|  | ||||
|                     (activity as? FragmentActivity)?.startAuthentication( | ||||
|                         activity!!.getString(R.string.lock_when_idle), | ||||
|                         activity!!.getString(R.string.confirm_lock_change), | ||||
|                         callback = object : AuthenticatorUtil.AuthenticationCallback() { | ||||
|                             override fun onAuthenticationSucceeded( | ||||
|                                 activity: FragmentActivity?, | ||||
|                                 result: BiometricPrompt.AuthenticationResult | ||||
|                             ) { | ||||
|                                 super.onAuthenticationSucceeded(activity, result) | ||||
|                                 value = newValue as String | ||||
|                             } | ||||
|  | ||||
|                             override fun onAuthenticationError( | ||||
|                                 activity: FragmentActivity?, | ||||
|                                 errorCode: Int, | ||||
|                                 errString: CharSequence | ||||
|                             ) { | ||||
|                                 super.onAuthenticationError(activity, errorCode, errString) | ||||
|                                 activity?.toast(errString.toString()) | ||||
|                             } | ||||
|                         } | ||||
|                     ) | ||||
|                     false | ||||
|                 } | ||||
|  | ||||
|                 preferences.useAuthenticator().asImmediateFlow { isVisible = it } | ||||
|                     .launchIn(viewScope) | ||||
|   | ||||
| @@ -1,43 +1,108 @@ | ||||
| package eu.kanade.tachiyomi.util.system | ||||
|  | ||||
| import android.content.Context | ||||
| import android.os.Build | ||||
| import androidx.annotation.CallSuper | ||||
| import androidx.biometric.BiometricManager | ||||
| import androidx.biometric.BiometricManager.Authenticators | ||||
| import androidx.biometric.BiometricPrompt | ||||
| import androidx.biometric.BiometricPrompt.AuthenticationError | ||||
| import androidx.biometric.auth.AuthPromptCallback | ||||
| import androidx.biometric.auth.startClass2BiometricOrCredentialAuthentication | ||||
| import androidx.core.content.ContextCompat | ||||
| import androidx.fragment.app.FragmentActivity | ||||
|  | ||||
| object AuthenticatorUtil { | ||||
|  | ||||
|     fun getSupportedAuthenticators(context: Context): Int { | ||||
|         if (isLegacySecured(context)) { | ||||
|             return Authenticators.BIOMETRIC_WEAK or Authenticators.DEVICE_CREDENTIAL | ||||
|         } | ||||
|     /** | ||||
|      * A check to avoid double authentication on older APIs when confirming settings changes since | ||||
|      * the biometric prompt is launched in a separate activity outside of the app. | ||||
|      */ | ||||
|     var isAuthenticating = false | ||||
|  | ||||
|         return listOf( | ||||
|             Authenticators.BIOMETRIC_STRONG, | ||||
|             Authenticators.BIOMETRIC_WEAK, | ||||
|             Authenticators.DEVICE_CREDENTIAL, | ||||
|     /** | ||||
|      * Launches biometric prompt. | ||||
|      * | ||||
|      * @param title String title that will be shown on the prompt | ||||
|      * @param subtitle Optional string subtitle that will be shown on the prompt | ||||
|      * @param confirmationRequired Whether require explicit user confirmation after passive biometric is recognized | ||||
|      * @param callback Callback object to handle the authentication events | ||||
|      */ | ||||
|     fun FragmentActivity.startAuthentication( | ||||
|         title: String, | ||||
|         subtitle: String? = null, | ||||
|         confirmationRequired: Boolean = true, | ||||
|         callback: AuthenticationCallback | ||||
|     ) { | ||||
|         isAuthenticating = true | ||||
|         startClass2BiometricOrCredentialAuthentication( | ||||
|             title = title, | ||||
|             subtitle = subtitle, | ||||
|             confirmationRequired = confirmationRequired, | ||||
|             executor = ContextCompat.getMainExecutor(this), | ||||
|             callback = callback | ||||
|         ) | ||||
|             .filter { BiometricManager.from(context).canAuthenticate(it) == BiometricManager.BIOMETRIC_SUCCESS } | ||||
|             .fold(0) { acc, auth -> acc or auth } | ||||
|     } | ||||
|  | ||||
|     fun isSupported(context: Context): Boolean { | ||||
|         return isLegacySecured(context) || getSupportedAuthenticators(context) != 0 | ||||
|     } | ||||
|  | ||||
|     fun isDeviceCredentialAllowed(context: Context): Boolean { | ||||
|         return isLegacySecured(context) || (getSupportedAuthenticators(context) and Authenticators.DEVICE_CREDENTIAL != 0) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns whether the device is secured with a PIN, pattern or password. | ||||
|      * Returns true if Class 2 biometric or credential lock is set and available to use | ||||
|      */ | ||||
|     private fun isLegacySecured(context: Context): Boolean { | ||||
|         if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { | ||||
|             if (context.keyguardManager.isDeviceSecure) { | ||||
|                 return true | ||||
|             } | ||||
|     fun Context.isAuthenticationSupported(): Boolean { | ||||
|         val authenticators = Authenticators.BIOMETRIC_WEAK or Authenticators.DEVICE_CREDENTIAL | ||||
|         return BiometricManager.from(this).canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * [AuthPromptCallback] with extra check | ||||
|      * | ||||
|      * @see isAuthenticating | ||||
|      */ | ||||
|     abstract class AuthenticationCallback : AuthPromptCallback() { | ||||
|         /** | ||||
|          * Called when an unrecoverable error has been encountered and authentication has stopped. | ||||
|          * | ||||
|          * | ||||
|          * After this method is called, no further events will be sent for the current | ||||
|          * authentication session. | ||||
|          * | ||||
|          * @param activity  The activity that is currently hosting the prompt. | ||||
|          * @param errorCode An integer ID associated with the error. | ||||
|          * @param errString A human-readable string that describes the error. | ||||
|          */ | ||||
|         @CallSuper | ||||
|         override fun onAuthenticationError( | ||||
|             activity: FragmentActivity?, | ||||
|             @AuthenticationError errorCode: Int, | ||||
|             errString: CharSequence | ||||
|         ) { | ||||
|             isAuthenticating = false | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Called when the user has successfully authenticated. | ||||
|          * | ||||
|          * | ||||
|          * After this method is called, no further events will be sent for the current | ||||
|          * authentication session. | ||||
|          * | ||||
|          * @param activity The activity that is currently hosting the prompt. | ||||
|          * @param result   An object containing authentication-related data. | ||||
|          */ | ||||
|         @CallSuper | ||||
|         override fun onAuthenticationSucceeded( | ||||
|             activity: FragmentActivity?, | ||||
|             result: BiometricPrompt.AuthenticationResult | ||||
|         ) { | ||||
|             isAuthenticating = false | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Called when an authentication attempt by the user has been rejected. | ||||
|          * | ||||
|          * @param activity The activity that is currently hosting the prompt. | ||||
|          */ | ||||
|         @CallSuper | ||||
|         override fun onAuthenticationFailed(activity: FragmentActivity?) { | ||||
|             isAuthenticating = false | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user