Lock your app with a biometric lock after X minutes of inactivity

If your app contains privacy sensitive data, such as a banking app, a password manager, an app for ‘buying surprise gifts for your loved one’ or an app for looking up the spelling of words like ‘colleague’ - then you might want to add extra protection.

The snippets below can lock your app using a biometric lock or a passcode, provided that either of those are set up in the user’s system settings.

It tracks when the user exits the app, and allows access without a prompt if the user last exited within a short amount of time.

Callbacks

First, define the following callback interfaces:

interface LockEventListener {
    fun onAppUnlocked()
    fun onAppUnlockFailed()
}
interface LockProvider {
    fun shouldShowScreenUnlock() : Boolean
    fun maybeShowScreenUnlock(activity:Activity, listener: LockEventListener, requestCode: Int)
    fun onAuthorizationEvent(resultCode: Int): Boolean
}

Observe the lifecycle

To track when the user leaves the app, include our LockUtil (code below) in your Application class, like so:

class MyApp : Application(), LockProvider {

	val prefs = context.getSharedPreferences(pref_name, Context.MODE_PRIVATE)

	private val userVerificationListener: LockUtil by lazy {
        LockUtilFactory.create(prefs)
    }

    override fun onCreate() {
        super.onCreate()
        
        // Set up lifecycle listener
        ProcessLifecycleOwner.get().lifecycle.addObserver(userVerificationListener)

        ...
    }

    // Call this when you 
	override fun shouldShowScreenUnlock() = userVerificationListener.shouldShowLock()

    override fun maybeShowScreenUnlock(activity: Activity, listener: LockEventListener, requestCode: Int) =
        userVerificationListener.authorize(activity, listener, requestCode, R.string.lock_message)

    override fun onAuthorizationEvent(resultCode: Int): Boolean =
        userVerificationListener.onAuthorizationEvent(resultCode)
}

Call the LockUtil

In you MainActivity, or whichever screen you’de like to protect, call the LockProvider from the onResume:

override fun onResume() {
    super.onResume()
    // Don't show the lock screen when the user didn't even log in
    val isLoggedIn = preferences.accessToken != null
    if ((application as LockProvider).shouldShowScreenUnlock() && isLoggedIn) {
        startActivity(LockActivity.createLaunchIntent(this))
    }
}

Create LockUtils

In the Application class we instantiated a LockUtil using a Factory. Here is the LockUtilFactory:

object LockUtilFactory {

    fun create(prefs: SharedPrefs): LockUtil =
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
            LockUtilPie(prefs)
        } else {
            LockUtilCompat(prefs)
        }
}

Depending on the version of the OS, it creates a compatible LockUtil. Both of these are child classes of this abstract parent:

/**
 * Helps with the screen unlock feature (pattern/pin/fingerprint/etc).
 *
 * Call [autorize] to initiate the unlock. Will skip
 * and return true when already authorized, or when not set up
 * or not supported.
 */
abstract class LockUtil internal constructor(protected val prefs: SharedPreferences) : LifecycleObserver {

    companion object {

        private const val TAG = "LockUtil"

        /**
         * Re-lock after 3 minutes
         * (or 10 seconds in debug builds)
         */
        private val TEMP_AUTH_TIMEOUT_MILLIS: Long
            get() = if (BuildConfig.DEBUG)
                    TimeUnit.SECONDS.toMillis(10)
                else
                    TimeUnit.MINUTES.toMillis(3)

        /**
         * The lock screen is not supported at all below Lollipop.
         */
        fun isSupported(): Boolean =
            android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP

    }

    /**
     * Keeps track of the last time the user was verified
     */
    private var lastExitMillis: Long
    	// FIXME In your own peferences implementation, don't use hardcoded keys
        get() = prefs.getLong("LAST_APP_EXIT", 0L)
        set(value) {
            prefs.edit().putLong("LAST_APP_EXIT", value).apply()
        }

    /**
     * Should be true while the user is verified and still
     * using the app. We can't rely on the lastExitMillis alone,
     * because it would expire while the app is still open.
     */
    internal var isTempAuthorized: Boolean = false

    /**
     * Resets the authorization. Call this e.g. when the main screen
     * is exited by the user.
     */
    fun unauthorize() {
        lastExitMillis = 0
        isTempAuthorized = false
    }

    /**
     * Checks if the user is still authorized, so we don't ask again too often
     */
    private fun isTempAuthorized(): Boolean {
        val now = System.currentTimeMillis()
        val hasExpired = (now - lastExitMillis) > TEMP_AUTH_TIMEOUT_MILLIS
        return isTempAuthorized
                || !prefs.isLockEnabled
                || !hasExpired
    }

    fun shouldShowLock(): Boolean {
        val isSupported = isSupported()
        val isTempAuthorized = isTempAuthorized()
        return isSupported
                && !isTempAuthorized
                && prefs.isLockEnabled
    }

    @Suppress("unused")
    @OnLifecycleEvent(Lifecycle.Event.ON_START)
    fun onAppMovedToForeground() {
        Log.v("LockUtil", "App is back to foreground, last seen $lastExitMillis")
        if (isTempAuthorized()) {
            isTempAuthorized = true
        }
    }

    @Suppress("unused")
    @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
    fun onAppMovedToBackground() {
        lastExitMillis = if (isTempAuthorized) System.currentTimeMillis() else 0
        isTempAuthorized = false
        Log.v("LockUtil", "App moved to background, timestamp $lastExitMillis")
    }

    fun onAuthorizationEvent(resultCode: Int): Boolean {
        isTempAuthorized = (resultCode == Activity.RESULT_OK)
        return isTempAuthorized
    }

    /**
     * Starts the verification flow if needed.
     */
    abstract fun authorize(
        activity: Activity,
        listener: LockEventListener,
        requestCode: Int,
        @StringRes message: Int
    )
}

Compatibility - Pie and up

Android Pie and up support the biometric locks, such as face recognition or fingerprint. So, on Pie and up, we can use the LockUtilPie:


@RequiresApi(Build.VERSION_CODES.P)
class LockUtilPie internal constructor(prefs: SharedPreferences) : LockUtil(prefs) {

    /**
     * Starts the verification flow if needed.
     */
    @Suppress("DEPRECATION")
    override fun authorize(
        activity: Activity,
        listener: LockEventListener,
        requestCode: Int,
        @StringRes message: Int
    ) {
        val km = activity.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager?
        val keyguardIntent = km?.createConfirmDeviceCredentialIntent(
            activity.getString(R.string.app_name),
            activity.getString(message)
        )

        val builder = BiometricPrompt.Builder(activity)
            .setTitle(
                activity.getString(
                    R.string.lock_title,
                    activity.getString(R.string.app_name)
                )
            )
            .setSubtitle(activity.getString(R.string.lock_message))
        	// optional: .setDescription("Touch Sensor")

        // Fallback to pin/pattern by default
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            builder.setDeviceCredentialAllowed(true)
        } else if (keyguardIntent != null) {
            builder.setNegativeButton(
                activity.getString(R.string.cancel),
                activity.mainExecutor,
                DialogInterface.OnClickListener { _, _ ->
                    keyguardIntent.let {
                        activity.startActivityForResult(it, requestCode)
                    }
                })
        } else {
            builder.setNegativeButton(
                activity.getString(R.string.cancel),
                activity.mainExecutor,
                DialogInterface.OnClickListener { _, _ ->
                    listener.onAppUnlockFailed()
                })
        }

        builder.build()
            .authenticate(
                getCancellationSignal(listener),
                activity.mainExecutor,
                getAuthenticationCallback(listener)
            )
    }

    private fun getCancellationSignal(listener: LockEventListener) = CancellationSignal().apply {
        setOnCancelListener {
            listener.onAppUnlockFailed()
        }
    }

    @RequiresApi(Build.VERSION_CODES.P)
    private fun getAuthenticationCallback(listener: LockEventListener): BiometricPrompt.AuthenticationCallback {
        return object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) {
                super.onAuthenticationError(errorCode, errString)

                // If there is no lock possible, because there is none set,
                // then remember that (turn the toggle off)
                // and exit gracefully
                if (errorCode == BiometricPrompt.BIOMETRIC_ERROR_NO_BIOMETRICS
                || errorCode == BiometricPrompt.BIOMETRIC_ERROR_NO_DEVICE_CREDENTIAL) {
                    prefs.isLockEnabled = false
                    listener.onAppUnlocked()
                } else {
                    listener.onAppUnlockFailed()
                }
            }

            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) {
                super.onAuthenticationSucceeded(result)
                isTempAuthorized = true
                listener.onAppUnlocked()
            }
        }
    }
}

Compatibility - Oreo and below

Before Pie, biometrics were not available, so the LockUtilCompat looks a lot simpler:

class LockUtilCompat internal constructor(prefs: SharedPrefs) : LockUtil(prefs) {

    /**
     * Starts the verification flow if needed.
     */
    @Suppress("DEPRECATION")
    override fun authorize(
        activity: Activity,
        listener: LockEventListener,
        requestCode: Int,
        @StringRes message: Int
    ) {
        val km = activity.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager?
        val keyguardIntent = km?.createConfirmDeviceCredentialIntent(
            activity.getString(R.string.app_name),
            activity.getString(message)
        )
        keyguardIntent?.let { activity.startActivityForResult(it, requestCode) }
    }
}

Activity

And finally, the LockActivity that pops up when needed and opens the right unlock method:

/**
 * Faceless Activity that launches the applicable screen lock
 */
class LockActivity : AppCompatActivity(),
    LockEventListener {

    private lateinit var delayedHandler: Handler

    companion object {

        const val LOCK_REQUEST_CODE: Int = 123

        fun createLaunchIntent(context: Context): Intent =
            Intent(context, LockActivity::class.java)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        delayedHandler = Handler()
        (application as LockProvider).apply {

            // Delaying the lock slightly because if might be a
            // fix for a NullPointerException reported in BiometricPrompt.
            // Workaround implemented for a bug in the androidx library,
            // that was supposedly fixed in lib update 1.0.1.
            // More info:
            // https://stackoverflow.com/questions/58089766/biometric-prompt-crashing-on-android-9-and-10-on-some-devices
            // https://issuetracker.google.com/issues/141838014
            delayedHandler.postDelayed({
                if (shouldShowScreenUnlock()) {
                    maybeShowScreenUnlock(this@LockActivity, this@LockActivity, LOCK_REQUEST_CODE)
                } else {
                    onAppUnlocked()
                }
            }, 20)
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        // This is executed when LockUtil is used on Oreo or below
        if (requestCode == LOCK_REQUEST_CODE) {
            val ok = (application as LockProvider).onAuthorizationEvent(resultCode)
            if (ok) {
                onAppUnlocked()
            } else {
                onAppUnlockFailed()
            }
        }
    }

    override fun onAppUnlocked() {
        // Finish this empty Activity and let the app continue.
        setResult(Activity.RESULT_OK)
        finish()
    }

    override fun onAppUnlockFailed() {
        // Finish this empty activity and take everything with us
        setResult(Activity.RESULT_CANCELED)
        closeApp()
    }

    private fun closeApp() {
        // Close the entire stack
        finishAffinity()
        // Show a message to the user.
        // Though a Snackbar is usually better than a Toast,
        // a Toast can linger while the activity is closed,
        // which is excellent in this case.
        Toast.makeText(this, R.string.warning_app_locked, Toast.LENGTH_LONG).show()
    }

}

Dependencies

Dependencies:

    implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
    implementation 'androidx.biometric:biometric:1.1.0-alpha02'

Some strings used in this recipe:

    <string name="lock_message">Please verify your identity</string>
    <string name="lock_title">Log in to %s</string>
    <string name="cancel">Cancel</string>
    <string name="title_identify">Unlock app</string>
    <string name="warning_app_locked">Bye</string>