mirror of
https://github.com/mihonapp/mihon.git
synced 2024-11-07 20:31:02 -05: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:
parent
26b8df5354
commit
90ab04e81d
6 changed files with 172 additions and 53 deletions
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
Loading…
Reference in a new issue