Ver código fonte

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
Ivan Iskandar 3 anos atrás
pai
commit
90ab04e81d

+ 2 - 1
app/src/main/java/eu/kanade/tachiyomi/App.kt

@@ -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
         }
     }

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/security/SecureActivityDelegate.kt

@@ -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)

+ 17 - 23
app/src/main/java/eu/kanade/tachiyomi/ui/security/UnlockActivity.kt

@@ -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())
     }
 }

+ 59 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSecurityController.kt

@@ -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)

+ 89 - 24
app/src/main/java/eu/kanade/tachiyomi/util/system/AuthenticatorUtil.kt

@@ -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 true if Class 2 biometric or credential lock is set and available to use
+     */
+    fun Context.isAuthenticationSupported(): Boolean {
+        val authenticators = Authenticators.BIOMETRIC_WEAK or Authenticators.DEVICE_CREDENTIAL
+        return BiometricManager.from(this).canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS
     }
 
     /**
-     * Returns whether the device is secured with a PIN, pattern or password.
+     * [AuthPromptCallback] with extra check
+     *
+     * @see isAuthenticating
      */
-    private fun isLegacySecured(context: Context): Boolean {
-        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
-            if (context.keyguardManager.isDeviceSecure) {
-                return true
-            }
+    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
     }
 }

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -25,6 +25,7 @@
     <string name="label_help">Help</string>
 
     <string name="unlock_app">Unlock Tachiyomi</string>
+    <string name="confirm_lock_change">Authenticate to confirm change</string>
     <string name="confirm_exit">Press back again to exit</string>
 
     <!-- Actions -->