diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt
index fdc67bbd07..0cd7734aa2 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 3c2595e805..69f8d50046 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 d7e07fef34..0808b89d6e 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 23979ecdd4..a99931de8a 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 72dd5830b0..c0e8a971da 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 9adf4c6f92..4f44316951 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