Skip to content

Commit

Permalink
Make biometric authentication more robust
Browse files Browse the repository at this point in the history
* Hide the entire window contents instead of just the fragment contents
  when unauthenticated.
* Make invisible views unfocusable and drop all input events when
  unauthenticated.
* Set FLAG_SECURE when the window loses focus to avoid having the
  screenshot show in the recent apps switcher.
* Move the authentication enforcement to the base activity instead of
  the base fragment since the activity is what's directly attached to
  the window.
* Use the top-level activity in each task to enforce authentication
  restrictions to allow predictive back gestures to continue to work.

Signed-off-by: Andrew Gunnerson <accounts+github@chiller3.com>
  • Loading branch information
chenxiaolong committed Oct 22, 2024
1 parent 4d45b97 commit dbde9e7
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 95 deletions.
178 changes: 178 additions & 0 deletions app/src/main/java/com/chiller3/rsaf/PreferenceBaseActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,42 @@

package com.chiller3.rsaf

import android.app.ActivityManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager.Authenticators
import androidx.biometric.BiometricPrompt
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import com.chiller3.rsaf.databinding.SettingsActivityBinding

abstract class PreferenceBaseActivity : AppCompatActivity() {
companion object {
private const val INACTIVE_TIMEOUT_NS = 60_000_000_000L

// These are intentionally global to ensure that the prompt does not appear when navigating
// within the app.
private var bioAuthenticated = false
private var lastPause = 0L
}

private val tag = javaClass.simpleName

private lateinit var prefs: Preferences
private lateinit var bioPrompt: BiometricPrompt
private lateinit var activityManager: ActivityManager
private var isCoveredBySafeActivity = false

protected abstract val actionBarTitle: CharSequence?

protected abstract val showUpButton: Boolean
Expand Down Expand Up @@ -76,6 +100,39 @@ abstract class PreferenceBaseActivity : AppCompatActivity() {
actionBarTitle?.let {
setTitle(it)
}

prefs = Preferences(this)

bioPrompt = BiometricPrompt(
this,
mainExecutor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
Toast.makeText(
this@PreferenceBaseActivity,
getString(R.string.biometric_error, errString),
Toast.LENGTH_LONG,
).show()
finish()
}

override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
bioAuthenticated = true
refreshGlobalVisibility()
}

override fun onAuthenticationFailed() {
Toast.makeText(
this@PreferenceBaseActivity,
R.string.biometric_failure,
Toast.LENGTH_LONG,
).show()
finish()
}
},
)

activityManager = getSystemService(ActivityManager::class.java)
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
Expand All @@ -87,4 +144,125 @@ abstract class PreferenceBaseActivity : AppCompatActivity() {
else -> super.onOptionsItemSelected(item)
}
}

override fun onResume() {
super.onResume()
Log.d(tag, "onResume()")

if (bioAuthenticated && (System.nanoTime() - lastPause) >= INACTIVE_TIMEOUT_NS) {
Log.d(tag, "Biometric authentication timed out due to inactivity")
bioAuthenticated = false
}

if (!bioAuthenticated) {
if (!prefs.requireAuth) {
bioAuthenticated = true
} else {
startBiometricAuth()
}
}

refreshTaskState()
refreshGlobalVisibility()
}

override fun onPause() {
super.onPause()
Log.d(tag, "onPause()")

lastPause = System.nanoTime()
}

override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
Log.d(tag, "onWindowFocusChanged($hasFocus)")

refreshTaskState()

val secure = prefs.requireAuth && !hasFocus && !isCoveredBySafeActivity
Log.d(tag, "Updating window secure flag: $secure")

// We only want the top-level activity to handle FLAG_SECURE to avoid flicker in screen
// recordings and scrcpy.
if (secure) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}

// We want the activity to be visible for predictive back gestures as long as the top-level
// activity in the task is our own.
private fun canViewAndInteract() = bioAuthenticated || isCoveredBySafeActivity

override fun onWindowAttributesChanged(params: WindowManager.LayoutParams?) {
val canInteract = canViewAndInteract()
Log.d(tag, "Updating focusable/touchable state: $canInteract")

// This trick is from Signal to drop all input events going to the window.
val ignoreInput = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE

params?.let {
it.flags = if (canInteract) {
it.flags and ignoreInput.inv()
} else {
it.flags or ignoreInput
}
}

super.onWindowAttributesChanged(params)
}

private fun startBiometricAuth() {
Log.d(tag, "Starting biometric authentication")

val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL)
.setTitle(getString(R.string.biometric_title))
.build()

bioPrompt.authenticate(promptInfo)
}

private fun refreshTaskState() {
// This is an awful hack, but we need it to be able to only apply the view hiding in the
// topmost activity to ensure that predictive back gestures still work.
val taskId = taskId

val task = activityManager.appTasks.find {
taskId == if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
it.taskInfo.taskId
} else {
@Suppress("DEPRECATION")
it.taskInfo.id
}
}

val topActivity = task?.taskInfo?.topActivity

isCoveredBySafeActivity = topActivity != null
&& topActivity != componentName
&& topActivity.packageName == packageName

Log.d(tag, "Top-level activity in stack is: $topActivity")
Log.d(tag, "Covered by safe activity: $isCoveredBySafeActivity")
}

private fun refreshGlobalVisibility() {
window?.let { window ->
val visible = canViewAndInteract()
Log.d(tag, "Updating view state: $visible")

val contentView = window.decorView.findViewById<View>(android.R.id.content)
contentView.visibility = if (visible) {
View.VISIBLE
} else {
// Using View.GONE causes noticeable scrolling jank due to relayout.
View.INVISIBLE
}

onWindowAttributesChanged(window.attributes)
}
}
}
95 changes: 0 additions & 95 deletions app/src/main/java/com/chiller3/rsaf/PreferenceBaseFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,69 +7,16 @@ package com.chiller3.rsaf

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.biometric.BiometricManager.Authenticators
import androidx.biometric.BiometricPrompt
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView

abstract class PreferenceBaseFragment : PreferenceFragmentCompat() {
companion object {
private const val INACTIVE_TIMEOUT_NS = 60_000_000_000L

// These are intentionally global to ensure that the prompt does not appear when navigating
// within the app.
private var bioAuthenticated = false
private var lastPause = 0L
}

abstract val requestTag: String

protected lateinit var prefs: Preferences
private lateinit var bioPrompt: BiometricPrompt

override fun onCreate(savedInstanceState: Bundle?) {
val activity = requireActivity()

prefs = Preferences(activity)

bioPrompt = BiometricPrompt(
this,
activity.mainExecutor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
Toast.makeText(
activity,
getString(R.string.biometric_error, errString),
Toast.LENGTH_LONG,
).show()
activity.finish()
}

override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
bioAuthenticated = true
refreshGlobalVisibility()
}

override fun onAuthenticationFailed() {
Toast.makeText(
activity,
R.string.biometric_failure,
Toast.LENGTH_LONG,
).show()
activity.finish()
}
},
)

super.onCreate(savedInstanceState)
}

override fun onCreateRecyclerView(
inflater: LayoutInflater,
parent: ViewGroup,
Expand Down Expand Up @@ -100,46 +47,4 @@ abstract class PreferenceBaseFragment : PreferenceFragmentCompat() {

return view
}

override fun onResume() {
super.onResume()

if (bioAuthenticated && (System.nanoTime() - lastPause) >= INACTIVE_TIMEOUT_NS) {
bioAuthenticated = false
}

if (!bioAuthenticated) {
if (!prefs.requireAuth) {
bioAuthenticated = true
} else {
startBiometricAuth()
}
}

refreshGlobalVisibility()
}

override fun onPause() {
super.onPause()

lastPause = System.nanoTime()
}

private fun startBiometricAuth() {
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL)
.setTitle(getString(R.string.biometric_title))
.build()

bioPrompt.authenticate(promptInfo)
}

private fun refreshGlobalVisibility() {
view?.visibility = if (bioAuthenticated) {
View.VISIBLE
} else {
// Using View.GONE causes noticeable scrolling jank due to relayout.
View.INVISIBLE
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class EditRemoteFragment : PreferenceBaseFragment(), FragmentResultListener,

private val viewModel: EditRemoteViewModel by viewModels()

private lateinit var prefs: Preferences
private lateinit var prefOpenRemote: Preference
private lateinit var prefConfigureRemote: Preference
private lateinit var prefRenameRemote: Preference
Expand All @@ -61,6 +62,8 @@ class EditRemoteFragment : PreferenceBaseFragment(), FragmentResultListener,
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences_edit_remote, rootKey)

prefs = Preferences(requireContext())

prefOpenRemote = findPreference(Preferences.PREF_OPEN_REMOTE)!!
prefOpenRemote.onPreferenceClickListener = this

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener,

private val viewModel: SettingsViewModel by viewModels()

private lateinit var prefs: Preferences
private lateinit var categoryPermissions: PreferenceCategory
private lateinit var categoryRemotes: PreferenceCategory
private lateinit var categoryConfiguration: PreferenceCategory
Expand Down Expand Up @@ -120,6 +121,8 @@ class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener,

val context = requireContext()

prefs = Preferences(context)

categoryPermissions = findPreference(Preferences.CATEGORY_PERMISSIONS)!!
categoryRemotes = findPreference(Preferences.CATEGORY_REMOTES)!!
categoryConfiguration = findPreference(Preferences.CATEGORY_CONFIGURATION)!!
Expand Down

0 comments on commit dbde9e7

Please sign in to comment.