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>