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:
Ivan Iskandar 2021-08-20 05:10:07 +07:00 committed by GitHub
parent 26b8df5354
commit 90ab04e81d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 172 additions and 53 deletions

View file

@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.asImmediateFlow import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil
import eu.kanade.tachiyomi.util.system.notification import eu.kanade.tachiyomi.util.system.notification
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -132,7 +133,7 @@ open class App : Application(), LifecycleObserver, ImageLoaderFactory {
@OnLifecycleEvent(Lifecycle.Event.ON_STOP) @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
@Suppress("unused") @Suppress("unused")
fun onAppBackgrounded() { fun onAppBackgrounded() {
if (preferences.lockAppAfter().get() >= 0) { if (!AuthenticatorUtil.isAuthenticating && preferences.lockAppAfter().get() >= 0) {
SecureActivityDelegate.locked = true SecureActivityDelegate.locked = true
} }
} }

View file

@ -4,7 +4,7 @@ import android.content.Intent
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.data.preference.PreferencesHelper 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 eu.kanade.tachiyomi.util.view.setSecureScreen
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -28,7 +28,7 @@ class SecureActivityDelegate(private val activity: FragmentActivity) {
fun onResume() { fun onResume() {
if (preferences.useAuthenticator().get()) { if (preferences.useAuthenticator().get()) {
if (AuthenticatorUtil.isSupported(activity)) { if (activity.isAuthenticationSupported()) {
if (isAppLocked()) { if (isAppLocked()) {
activity.startActivity(Intent(activity, UnlockActivity::class.java)) activity.startActivity(Intent(activity, UnlockActivity::class.java))
activity.overridePendingTransition(0, 0) activity.overridePendingTransition(0, 0)

View file

@ -2,51 +2,45 @@ package eu.kanade.tachiyomi.ui.security
import android.os.Bundle import android.os.Bundle
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.activity.BaseThemedActivity import eu.kanade.tachiyomi.ui.base.activity.BaseThemedActivity
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil import eu.kanade.tachiyomi.util.system.AuthenticatorUtil
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.startAuthentication
import timber.log.Timber import timber.log.Timber
import java.util.Date import java.util.Date
import java.util.concurrent.Executors
/** /**
* Blank activity with a BiometricPrompt. * Blank activity with a BiometricPrompt.
*/ */
class UnlockActivity : BaseThemedActivity() { class UnlockActivity : BaseThemedActivity() {
private val executor = Executors.newSingleThreadExecutor()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
startAuthentication(
val biometricPrompt = BiometricPrompt( getString(R.string.unlock_app),
this, confirmationRequired = false,
executor, callback = object : AuthenticatorUtil.AuthenticationCallback() {
object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { activity: FragmentActivity?,
super.onAuthenticationError(errorCode, errString) errorCode: Int,
errString: CharSequence
) {
super.onAuthenticationError(activity, errorCode, errString)
Timber.e(errString.toString()) Timber.e(errString.toString())
finishAffinity() finishAffinity()
} }
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(
super.onAuthenticationSucceeded(result) activity: FragmentActivity?,
result: BiometricPrompt.AuthenticationResult
) {
super.onAuthenticationSucceeded(activity, result)
SecureActivityDelegate.locked = false SecureActivityDelegate.locked = false
preferences.lastAppUnlock().set(Date().time) preferences.lastAppUnlock().set(Date().time)
finish() 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())
} }
} }

View file

@ -1,5 +1,8 @@
package eu.kanade.tachiyomi.ui.setting package eu.kanade.tachiyomi.ui.setting
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import androidx.preference.Preference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.asImmediateFlow 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.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes import eu.kanade.tachiyomi.util.preference.titleRes
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil 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 kotlinx.coroutines.flow.launchIn
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
@ -17,11 +23,36 @@ class SettingsSecurityController : SettingsController() {
override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply {
titleRes = R.string.pref_category_security titleRes = R.string.pref_category_security
if (AuthenticatorUtil.isSupported(context)) { if (context.isAuthenticationSupported()) {
switchPreference { switchPreference {
key = Keys.useAuthenticator key = Keys.useAuthenticator
titleRes = R.string.lock_with_biometrics titleRes = R.string.lock_with_biometrics
defaultValue = false 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 { intListPreference {
key = Keys.lockAppAfter key = Keys.lockAppAfter
@ -37,6 +68,33 @@ class SettingsSecurityController : SettingsController() {
entryValues = values entryValues = values
defaultValue = "0" defaultValue = "0"
summary = "%s" 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 } preferences.useAuthenticator().asImmediateFlow { isVisible = it }
.launchIn(viewScope) .launchIn(viewScope)

View file

@ -1,43 +1,108 @@
package eu.kanade.tachiyomi.util.system package eu.kanade.tachiyomi.util.system
import android.content.Context import android.content.Context
import android.os.Build import androidx.annotation.CallSuper
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators 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 { object AuthenticatorUtil {
fun getSupportedAuthenticators(context: Context): Int { /**
if (isLegacySecured(context)) { * A check to avoid double authentication on older APIs when confirming settings changes since
return Authenticators.BIOMETRIC_WEAK or Authenticators.DEVICE_CREDENTIAL * the biometric prompt is launched in a separate activity outside of the app.
} */
var isAuthenticating = false
return listOf( /**
Authenticators.BIOMETRIC_STRONG, * Launches biometric prompt.
Authenticators.BIOMETRIC_WEAK, *
Authenticators.DEVICE_CREDENTIAL, * @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 { fun Context.isAuthenticationSupported(): Boolean {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { val authenticators = Authenticators.BIOMETRIC_WEAK or Authenticators.DEVICE_CREDENTIAL
if (context.keyguardManager.isDeviceSecure) { return BiometricManager.from(this).canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS
return true }
/**
* [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
}
} }

View file

@ -25,6 +25,7 @@
<string name="label_help">Help</string> <string name="label_help">Help</string>
<string name="unlock_app">Unlock Tachiyomi</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> <string name="confirm_exit">Press back again to exit</string>
<!-- Actions --> <!-- Actions -->