Add optional app lock behind biometric or device credential

Implements a full app lock (issue #1313) gating Aurora Store behind the
device biometric or, where unavailable, the screen-lock credential
(PIN/pattern/password) so it also works on TVs and older phones.

- AppLockManager: process-scoped, in-memory lock state so a cold start is
  always locked, with a short background grace timeout to avoid
  re-prompting during the install dialog or the biometric sheet itself.
- AppLockAuthenticator: BiometricPrompt wrapper using
  BIOMETRIC_STRONG | DEVICE_CREDENTIAL on API 30+ and
  setDeviceCredentialAllowed on older releases, plus an enrollment check.
- ComposeActivity (now a FragmentActivity) gates content via a three-state
  machine (AUTHENTICATING/LOCKED/UNLOCKED); the authenticating state shows
  a blank surface behind the prompt so dismissing it never flashes the
  lock card. Re-locks on return past the timeout and sets FLAG_SECURE
  while locked.
- Toggle lives on a dedicated Security preference screen, reached from
  Settings like the other preference entries.
This commit is contained in:
Rahul Patel
2026-05-30 15:25:27 +05:30
parent db9a7723a8
commit a68d1b276d
14 changed files with 469 additions and 8 deletions

View File

@@ -196,6 +196,7 @@ dependencies {
// AndroidX
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.browser)
implementation(libs.androidx.biometric)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.navigation3)
implementation(libs.androidx.preference.ktx)

View File

@@ -8,33 +8,51 @@ package com.aurora.store
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.content.IntentCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.extensions.getPackageName
import com.aurora.store.R
import com.aurora.store.compose.composition.LocalNetworkStatus
import com.aurora.store.compose.composition.LocalUI
import com.aurora.store.compose.composition.UI
import com.aurora.store.compose.navigation.NavDisplay
import com.aurora.store.compose.navigation.Screen
import com.aurora.store.compose.theme.AuroraTheme
import com.aurora.store.compose.ui.lock.AppLockScreen
import com.aurora.store.data.AppLockManager
import com.aurora.store.data.model.NetworkStatus
import com.aurora.store.data.providers.NetworkProvider
import com.aurora.store.data.receiver.MigrationReceiver
import com.aurora.store.util.AppLockAuthenticator
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class ComposeActivity : ComponentActivity() {
class ComposeActivity : FragmentActivity() {
@Inject lateinit var networkProvider: NetworkProvider
@Inject lateinit var appLockManager: AppLockManager
override fun onCreate(savedInstanceState: Bundle?) {
MigrationReceiver.runMigrationsIfRequired(this)
enableEdgeToEdge()
@@ -53,17 +71,86 @@ class ComposeActivity : ComponentActivity() {
val networkStatus by networkProvider.status.collectAsStateWithLifecycle(
initialValue = NetworkStatus.AVAILABLE
)
CompositionLocalProvider(
LocalUI provides localUI,
LocalNetworkStatus provides networkStatus
) {
AuroraTheme {
NavDisplay(startDestination = startDestination)
AuroraTheme {
var lockState by remember {
mutableStateOf(
if (appLockManager.shouldLock(this@ComposeActivity)) {
LockState.AUTHENTICATING
} else {
LockState.UNLOCKED
}
)
}
// Keep FLAG_SECURE on until unlocked; auto-prompt while authenticating
LaunchedEffect(lockState) {
when (lockState) {
LockState.AUTHENTICATING -> {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
promptUnlock(
onSuccess = { lockState = LockState.UNLOCKED },
onError = { lockState = LockState.LOCKED }
)
}
LockState.LOCKED -> window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
LockState.UNLOCKED ->
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
// Re-lock when returning from the background past the grace timeout
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> appLockManager.onBackgrounded()
Lifecycle.Event.ON_START ->
if (lockState == LockState.UNLOCKED &&
appLockManager.shouldLock(this@ComposeActivity)
) {
lockState = LockState.AUTHENTICATING
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
when (lockState) {
LockState.UNLOCKED -> CompositionLocalProvider(
LocalUI provides localUI,
LocalNetworkStatus provides networkStatus
) {
NavDisplay(startDestination = startDestination)
}
// Plain surface behind the prompt so dismissing it doesn't flash the lock card
LockState.AUTHENTICATING -> Surface(modifier = Modifier.fillMaxSize()) {}
LockState.LOCKED -> AppLockScreen(
onUnlock = { lockState = LockState.AUTHENTICATING }
)
}
}
}
}
private fun promptUnlock(onSuccess: () -> Unit, onError: () -> Unit) {
AppLockAuthenticator.authenticate(
activity = this,
title = getString(R.string.app_lock_prompt_title),
subtitle = getString(R.string.app_lock_prompt_subtitle),
onSuccess = {
appLockManager.markUnlocked()
onSuccess()
},
onError = { onError() }
)
}
private fun resolveStartDestination(): Screen {
// Parcel-based navigation (e.g. from NotificationUtil)
IntentCompat.getParcelableExtra(intent, Screen.PARCEL_KEY, Screen::class.java)
@@ -91,4 +178,6 @@ class ComposeActivity : ComponentActivity() {
!Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding
else -> Screen.Splash()
}
private enum class LockState { AUTHENTICATING, LOCKED, UNLOCKED }
}

View File

@@ -46,4 +46,5 @@ sealed class Destination {
data object UIPreference : Destination()
data object UpdatesPreference : Destination()
data object SourceFilters : Destination()
data object SecurityPreference : Destination()
}

View File

@@ -53,6 +53,7 @@ import com.aurora.store.compose.ui.preferences.UIPreferenceScreen
import com.aurora.store.compose.ui.preferences.installation.InstallationPreferenceScreen
import com.aurora.store.compose.ui.preferences.installation.InstallerScreen
import com.aurora.store.compose.ui.preferences.network.NetworkPreferenceScreen
import com.aurora.store.compose.ui.preferences.security.SecurityPreferenceScreen
import com.aurora.store.compose.ui.preferences.updates.SourceFiltersScreen
import com.aurora.store.compose.ui.preferences.updates.UpdatesPreferenceScreen
import com.aurora.store.compose.ui.search.SearchScreen
@@ -162,6 +163,7 @@ fun NavDisplay(startDestination: NavKey) {
Destination.UIPreference -> backstack.add(Screen.UIPreference)
Destination.UpdatesPreference -> backstack.add(Screen.UpdatesPreference)
Destination.SourceFilters -> backstack.add(Screen.SourceFilters)
Destination.SecurityPreference -> backstack.add(Screen.SecurityPreference)
}
}
@@ -284,6 +286,7 @@ fun NavDisplay(startDestination: NavKey) {
entry<Screen.UIPreference> { UIPreferenceScreen() }
entry<Screen.UpdatesPreference> { UpdatesPreferenceScreen(onNavigateTo = ::navigate) }
entry<Screen.SourceFilters> { SourceFiltersScreen() }
entry<Screen.SecurityPreference> { SecurityPreferenceScreen() }
}
)
}

View File

@@ -95,6 +95,9 @@ sealed class Screen : NavKey, Parcelable {
@Serializable
data object SourceFilters : Screen()
@Serializable
data object SecurityPreference : Screen()
@Serializable
data class Splash(val packageName: String? = null) : Screen()

View File

@@ -0,0 +1,69 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.lock
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.aurora.store.R
import com.aurora.store.compose.preview.ThemePreviewProvider
/**
* Full-screen lock placeholder shown in place of the app content while the app is locked.
* The actual authentication prompt is driven by the hosting activity; [onUnlock] re-triggers
* it, e.g. after the user dismissed the prompt.
*/
@Composable
fun AppLockScreen(onUnlock: () -> Unit) {
Scaffold { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(R.drawable.ic_lock),
contentDescription = null,
modifier = Modifier
.size(64.dp)
.padding(bottom = 16.dp)
)
Text(
text = stringResource(R.string.app_lock_locked_message),
modifier = Modifier.padding(bottom = 24.dp),
textAlign = TextAlign.Center
)
Button(onClick = onUnlock) {
Text(stringResource(R.string.app_lock_unlock))
}
}
}
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview
@Composable
private fun AppLockScreenPreview() {
AppLockScreen(onUnlock = {})
}

View File

@@ -112,6 +112,18 @@ private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) {
headlineContent = { Text(stringResource(R.string.title_updates)) }
)
}
item {
ListItem(
modifier = Modifier.clickable { onNavigateTo(Destination.SecurityPreference) },
leadingContent = {
Icon(
painter = painterResource(R.drawable.ic_lock),
contentDescription = null
)
},
headlineContent = { Text(stringResource(R.string.title_security)) }
)
}
}
}
}

View File

@@ -0,0 +1,87 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.preferences.security
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import com.aurora.extensions.toast
import com.aurora.store.R
import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.util.AppLockAuthenticator
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED
import com.aurora.store.util.save
@Composable
fun SecurityPreferenceScreen() {
ScreenContent()
}
@Composable
private fun ScreenContent() {
val context = LocalContext.current
var appLockEnabled by remember {
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false))
}
fun setAppLock(enabled: Boolean) {
// Refuse to enable the lock when the device has no biometric or screen-lock to fall back on
if (enabled && !AppLockAuthenticator.canAuthenticate(context)) {
context.toast(R.string.app_lock_no_credential)
return
}
appLockEnabled = enabled
context.save(PREFERENCE_APP_LOCK_ENABLED, enabled)
}
Scaffold(
topBar = { TopAppBar(title = stringResource(R.string.title_security)) }
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
item {
ListItem(
modifier = Modifier.clickable { setAppLock(!appLockEnabled) },
headlineContent = { Text(stringResource(R.string.app_lock_title)) },
supportingContent = { Text(stringResource(R.string.app_lock_summary)) },
trailingContent = {
Switch(
checked = appLockEnabled,
onCheckedChange = { setAppLock(it) }
)
}
)
}
}
}
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview
@Composable
private fun SecurityPreferenceScreenPreview() {
ScreenContent()
}

View File

@@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data
import android.content.Context
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED
import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_TIMEOUT
import javax.inject.Inject
import javax.inject.Singleton
/**
* Tracks the app-lock state at process scope. The unlocked flag lives only in memory, so a
* fresh process (cold start or process death) always starts locked. Re-locking after the
* app is backgrounded is governed by a short grace [timeout] to avoid re-prompting during
* transient stops such as the system install dialog or the biometric sheet itself.
*/
@Singleton
class AppLockManager @Inject constructor() {
companion object {
// Grace period before a backgrounded app re-locks, in seconds
private const val DEFAULT_TIMEOUT_SECONDS = 30
}
private var unlocked = false
private var backgroundedAt = 0L
/**
* Whether the app should currently present the lock screen, i.e. the feature is enabled
* and the session is not (still) unlocked within the grace [timeout].
*/
fun shouldLock(context: Context): Boolean {
if (!Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false)) return false
if (!unlocked) return true
if (backgroundedAt == 0L) return false
val elapsed = System.currentTimeMillis() - backgroundedAt
return elapsed >= timeoutMillis(context)
}
/** Marks the current session as authenticated. */
fun markUnlocked() {
unlocked = true
backgroundedAt = 0L
}
/** Records when the app went to the background so the grace timeout can be measured. */
fun onBackgrounded() {
if (unlocked) backgroundedAt = System.currentTimeMillis()
}
private fun timeoutMillis(context: Context): Long {
val seconds = Preferences.getInteger(
context,
PREFERENCE_APP_LOCK_TIMEOUT,
DEFAULT_TIMEOUT_SECONDS
)
return seconds * 1000L
}
}

View File

@@ -0,0 +1,93 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.util
import android.app.KeyguardManager
import android.content.Context
import android.os.Build
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.FragmentActivity
/**
* Thin wrapper over [BiometricPrompt] that authenticates with the device biometric *or* the
* device credential (PIN / pattern / password). Devices without a fingerprint sensor — TVs,
* older phones — therefore fall back to the lockscreen credential automatically.
*
* Combining a biometric with [DEVICE_CREDENTIAL] is only supported by the prompt on API 30+;
* on older releases the deprecated `setDeviceCredentialAllowed` provides the same behaviour.
*/
object AppLockAuthenticator {
private val supportsCombinedAuthenticators = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
/**
* Whether the device can satisfy an app-lock challenge. True when a biometric or device
* credential is enrolled, or — for the pre-API-30 fallback path — the device simply has a
* secure lockscreen set.
*/
fun canAuthenticate(context: Context): Boolean {
val authenticators = if (supportsCombinedAuthenticators) {
BIOMETRIC_STRONG or DEVICE_CREDENTIAL
} else {
BIOMETRIC_STRONG
}
if (BiometricManager.from(context).canAuthenticate(authenticators) ==
BiometricManager.BIOMETRIC_SUCCESS
) {
return true
}
// Pre-API-30 path: no enrolled biometric, but a PIN/pattern/password works as fallback
return context.getSystemService<KeyguardManager>()?.isDeviceSecure == true
}
/**
* Shows the authentication prompt. [onSuccess] fires on a successful unlock; [onError]
* fires on a non-recoverable error or user cancellation (the prompt itself handles
* transient failures like a wrong fingerprint).
*/
fun authenticate(
activity: FragmentActivity,
title: String,
subtitle: String,
onSuccess: () -> Unit,
onError: (CharSequence) -> Unit
) {
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) =
onSuccess()
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) =
onError(errString)
}
val prompt = BiometricPrompt(
activity,
ContextCompat.getMainExecutor(activity),
callback
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.apply {
if (supportsCombinedAuthenticators) {
setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
} else {
@Suppress("DEPRECATION")
setDeviceCredentialAllowed(true)
}
}
.build()
prompt.authenticate(promptInfo)
}
}

View File

@@ -76,6 +76,9 @@ object Preferences {
const val PREFERENCE_SELF_UPDATE_ENABLED = "PREFERENCE_SELF_UPDATE_ENABLED"
const val PREFERENCE_APP_LOCK_ENABLED = "PREFERENCE_APP_LOCK_ENABLED"
const val PREFERENCE_APP_LOCK_TIMEOUT = "PREFERENCE_APP_LOCK_TIMEOUT"
private var prefs: SharedPreferences? = null
fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) {

View File

@@ -0,0 +1,24 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M240,880q-33,0 -56.5,-23.5T160,800v-400q0,-33 23.5,-56.5T240,320h40v-80q0,-83 58.5,-141.5T480,40q83,0 141.5,58.5T680,240v80h40q33,0 56.5,23.5T800,400v400q0,33 -23.5,56.5T720,880L240,880ZM240,800h480v-400L240,400v400ZM536.5,656.5Q560,633 560,600t-23.5,-56.5Q513,520 480,520t-56.5,23.5Q400,567 400,600t23.5,56.5Q447,680 480,680t56.5,-23.5ZM360,320h240v-80q0,-50 -35,-85t-85,-35q-50,0 -85,35t-35,85v80ZM240,800v-400,400Z"
android:fillColor="#e3e3e3"/>
</vector>

View File

@@ -179,6 +179,16 @@
<string name="installer_root_unavailable">No root access. Grant it or change the installer.</string>
<string name="installer_shizuku_unavailable">Shizuku is not installed or set up properly.</string>
<string name="installer_fallback_session">Chosen installer is unavailable, using the default installer instead.</string>
<!-- Security -->
<string name="title_security">Security</string>
<string name="app_lock_title">App lock</string>
<string name="app_lock_summary">Require biometric or screen-lock authentication to open Aurora Store.</string>
<string name="app_lock_no_credential">Set up a screen lock (PIN, pattern, password or biometric) on your device first.</string>
<string name="app_lock_locked_message">Authentication is required to access Aurora Store</string>
<string name="app_lock_unlock">Unlock</string>
<string name="app_lock_prompt_title">Unlock Aurora Store</string>
<string name="app_lock_prompt_subtitle">Enter phone screen lock pattern, PIN, password or fingerprint</string>
<string name="installer_service_available">"Aurora Services is available and ready to install."</string>
<string name="installer_service_unavailable">Install Aurora Services 1.0.9 or above, or change the installer.</string>
<string name="installer_service_misconfigured">Set up Aurora Services and grant all permissions first.</string>

View File

@@ -11,6 +11,7 @@ agCoreservice = "13.3.1.300"
androidGradlePlugin = "9.2.1"
androidx-hilt = "1.3.0"
androidx-junit = "1.3.0"
biometric = "1.1.0"
browser = "1.10.0"
coil = "3.4.0"
composeBom = "2026.05.01"
@@ -48,6 +49,7 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp
androidx-adaptive-core = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
androidx-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "adaptive" }
androidx-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "adaptive" }
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }