diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index fdc67bbd0..0cd7734aa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/security/SecureActivityDelegate.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/security/SecureActivityDelegate.kt index 3c2595e80..69f8d5004 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/security/SecureActivityDelegate.kt +++ b/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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/security/UnlockActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/security/UnlockActivity.kt index d7e07fef3..0808b89d6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/security/UnlockActivity.kt +++ b/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()) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSecurityController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSecurityController.kt index 23979ecdd..a99931de8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSecurityController.kt +++ b/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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/AuthenticatorUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/AuthenticatorUtil.kt index 72dd5830b..c0e8a971d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/AuthenticatorUtil.kt +++ b/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 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 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9adf4c6f9..4f4431695 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ Help Unlock Tachiyomi + Authenticate to confirm change Press back again to exit