feat: offer the choice on screen, and say what each one means

The App lock screen now asks how the app should unlock and offers four
answers: off, a PIN just for this app, a fingerprint or face, or either.

Each carries the sentence a person cannot work out for herself. The PIN
option says it is separate from the phone's, because somebody who assumes
otherwise will assume they can reset it the way they reset a phone PIN --
and by the time they find out, the only way back in is to erase
everything. The fingerprint option says who it lets in: anyone enrolled on
this phone, including anyone who enrols later.

An option that cannot work is disabled with its reason rather than hidden.
A choice that is simply absent reads as a feature the app does not have,
and this one is absent for something she can fix in her phone's settings.

The screen renders the ViewModel's stage and cannot advance it. It used to
keep its own step in remembered state and move it in the same breath as
asking, which is how a wrong PIN reached "choose a PIN"; two state
machines for one flow is the shape that produced that, so there is one
now.

Verified on the emulator: the four options render, the PIN detail reads as
intended, and both biometric options are correctly disabled with "this
phone has no fingerprint or face set up" -- which is true of that image.

SECURITY.md gains the three-methods bullet and the fingerprint-only
caveat. The checklist gains four device rows, including the one that
matters most: removing every fingerprint must leave the app closed, not
open it.

Part of #63

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-21 01:53:09 -05:00
parent 750fdaeb0d
commit f91ba6ebe6
4 changed files with 294 additions and 161 deletions

View File

@ -14,6 +14,12 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.RadioButton
import androidx.compose.ui.semantics.Role
import androidx.compose.foundation.layout.size
import dev.privacyllc.period.core.security.LockMethod
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -39,6 +45,10 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.activity.compose.LocalActivity
import androidx.compose.ui.text.style.TextAlign
import androidx.fragment.app.FragmentActivity
import dev.privacyllc.period.lock.rememberBiometricUnlock
import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.feature.common.SettingsSubpage import dev.privacyllc.period.feature.common.SettingsSubpage
@ -59,138 +69,182 @@ fun LockSettingsScreen(
viewModel: LockSettingsViewModel = hiltViewModel(), viewModel: LockSettingsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val biometric = rememberBiometricUnlock(
activity = LocalActivity.current as? FragmentActivity,
onBeginAuth = {},
onEndAuth = {},
onUnlocked = { viewModel.onBiometricOutcome(succeeded = true) },
onUnavailable = { viewModel.onBiometricOutcome(succeeded = false) },
)
LockSettingsContent( LockSettingsContent(
state = state, state = state,
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onSetPin = viewModel::setPin, onChooseMethod = viewModel::request,
onAuthoriseChange = viewModel::authoriseChange, onConfirmWithPin = viewModel::confirmWithPin,
onSubmitNewPin = viewModel::submitNewPin,
onAcceptBiometricConsent = viewModel::acceptBiometricConsent,
onProveBiometric = biometric.prompt,
onCancelChange = viewModel::cancelChange, onCancelChange = viewModel::cancelChange,
onRemovePin = viewModel::removePin,
onBiometric = viewModel::setBiometricEnabled,
onDismissMessage = viewModel::clearMessage, onDismissMessage = viewModel::clearMessage,
biometricAvailable = biometric.available,
) )
} }
/** /**
* The screen without Hilt, so its back behaviour can be asserted. * The screen without Hilt, so its steps and its back behaviour can be asserted.
* *
* The steps below are remembered state rather than destinations, which is * It renders `state.pending` and cannot advance it. That is the point: the
* exactly why they need a test: nothing in the navigation graph knows they * previous version kept its own step in remembered state and moved it in the
* exist, so nothing but this file makes the arrow and the system gesture agree * same breath as asking, so a wrong PIN still reached "choose a PIN".
* about them.
*/ */
@Composable @Composable
internal fun LockSettingsContent( internal fun LockSettingsContent(
state: LockSettings, state: LockSettings,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onSetPin: (CharArray) -> Unit = {}, onChooseMethod: (LockMethod) -> Unit = {},
onAuthoriseChange: (CharArray, () -> Unit) -> Unit = { _, _ -> }, onConfirmWithPin: (CharArray) -> Unit = {},
onSubmitNewPin: (CharArray) -> Unit = {},
onAcceptBiometricConsent: () -> Unit = {},
onProveBiometric: () -> Unit = {},
onCancelChange: () -> Unit = {}, onCancelChange: () -> Unit = {},
onRemovePin: (CharArray, () -> Unit) -> Unit = { _, _ -> },
onBiometric: (Boolean) -> Unit = {},
onDismissMessage: () -> Unit = {}, onDismissMessage: () -> Unit = {},
biometricAvailable: Boolean = true,
) { ) {
var mode by remember { mutableStateOf(Mode.OVERVIEW) } val pending = state.pending
// One definition of "back", used by the arrow and by the system gesture. // One definition of "back", shared by the arrow and the system gesture:
// // inside a step it abandons the change, and at the overview it leaves.
// They have to agree. This screen has steps the navigation graph knows val stepBack: () -> Unit = {
// nothing about — `mode` is remembered state, not a destination — so system if (pending != null) {
// back from "choose a PIN" would otherwise leave App lock entirely while onCancelChange()
// the arrow beside it stepped back one. Two controls a hand's width apart, onDismissMessage()
// doing different things. } else {
val toOverview: () -> Unit = { onNavigateBack()
// Abandoning a step abandons the permission it was granted, too. }
onCancelChange()
onDismissMessage()
mode = Mode.OVERVIEW
} }
val stepBack: () -> Unit = { if (mode == Mode.OVERVIEW) onNavigateBack() else toOverview() } BackHandler(enabled = pending != null) { onCancelChange(); onDismissMessage() }
// Enabled only inside a step: at the Overview the gesture falls through to SettingsSubpage(title = LockSettingsCopy.TITLE, onBack = stepBack) {
// the NavHost, which pops the destination — exactly what the arrow does when (pending?.stage) {
// there. Nothing is swallowed. null -> Overview(
BackHandler(enabled = mode != Mode.OVERVIEW, onBack = toOverview) state = state,
onChooseMethod = onChooseMethod,
onChangePin = { onChooseMethod(state.method) },
onDismissMessage = onDismissMessage,
biometricAvailable = biometricAvailable,
biometricUnavailableReason =
if (biometricAvailable) null else LockSettingsCopy.UNAVAILABLE_NOT_ENROLLED,
)
SettingsSubpage(title = "App lock", onBack = stepBack) { PendingChange.Stage.CONFIRM_CURRENT -> ConfirmPin(
LockSettingsSteps( title = LockSettingsCopy.CONFIRM_TO_CHANGE_METHOD,
mode = mode, busy = state.busy,
state = state, wrong = state.message == LockSettings.Message.WRONG_PIN,
onSetPin = onSetPin, onCancel = stepBack,
onAuthoriseChange = onAuthoriseChange, // Advances only if the ViewModel says the PIN was right.
onCancelChange = onCancelChange, onSubmit = onConfirmWithPin,
onRemovePin = onRemovePin, )
onBiometric = onBiometric,
onDismissMessage = onDismissMessage, PendingChange.Stage.BIOMETRIC_CONSENT -> BiometricConsent(
setMode = { mode = it }, onAccept = onAcceptBiometricConsent,
) onCancel = stepBack,
)
PendingChange.Stage.SET_PIN -> PinSetupScreen(
busy = state.busy,
failed = state.message == LockSettings.Message.COULD_NOT_SET,
onCancel = stepBack,
onConfirmed = onSubmitNewPin,
)
PendingChange.Stage.PROVE_BIOMETRIC -> ProveBiometric(
onProve = onProveBiometric,
onCancel = stepBack,
)
}
} }
} }
/**
* What a fingerprint actually means here, before it is turned on.
*
* Two things a person cannot work out for herself, so both are said: anybody
* enrolled on this phone can open the app including somebody who enrols
* later and in fingerprint-only mode there is no PIN behind it.
*/
@Composable @Composable
private fun LockSettingsSteps( private fun BiometricConsent(onAccept: () -> Unit, onCancel: () -> Unit) {
mode: Mode, Column(
state: LockSettings, modifier = Modifier
onSetPin: (CharArray) -> Unit, .fillMaxSize()
onAuthoriseChange: (CharArray, () -> Unit) -> Unit, .verticalScroll(rememberScrollState())
onCancelChange: () -> Unit, .padding(24.dp),
onRemovePin: (CharArray, () -> Unit) -> Unit, ) {
onBiometric: (Boolean) -> Unit, Text(LockSettingsCopy.BIOMETRIC_CONSENT_TITLE, style = MaterialTheme.typography.headlineSmall)
onDismissMessage: () -> Unit, Spacer(Modifier.height(20.dp))
setMode: (Mode) -> Unit,
) { Text(LockSettingsCopy.BIOMETRIC_WHO_HEADING, style = MaterialTheme.typography.titleMedium)
when (mode) { Spacer(Modifier.height(6.dp))
Mode.OVERVIEW -> Overview( Text(
state = state, LockSettingsCopy.BIOMETRIC_WHO_BODY,
onSetPin = { setMode(Mode.SET_FIRST) }, style = MaterialTheme.typography.bodyMedium,
onChangePin = { setMode(Mode.CONFIRM_TO_CHANGE) }, color = MaterialTheme.colorScheme.onSurfaceVariant,
onRemovePin = { setMode(Mode.CONFIRM_TO_REMOVE) },
onBiometric = onBiometric,
onDismissMessage = onDismissMessage,
) )
Mode.SET_FIRST, Mode.SET_REPLACEMENT -> PinSetupScreen( Spacer(Modifier.height(20.dp))
busy = state.busy, Text(LockSettingsCopy.BIOMETRIC_NO_PIN_HEADING, style = MaterialTheme.typography.titleMedium)
failed = state.message == LockSettings.Message.COULD_NOT_SET, Spacer(Modifier.height(6.dp))
onCancel = { onCancelChange(); onDismissMessage(); setMode(Mode.OVERVIEW) }, Text(
onConfirmed = { pin -> onSetPin(pin); setMode(Mode.OVERVIEW) }, LockSettingsCopy.BIOMETRIC_NO_PIN_BODY,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// Both confirmations stay on screen until the PIN is actually right. Spacer(Modifier.height(28.dp))
// Button(onClick = onAccept, modifier = Modifier.fillMaxWidth()) {
// They used to move on in the same breath as asking — the check is Text(LockSettingsCopy.BIOMETRIC_CONSENT_ACCEPT)
// asynchronous and the mode was reassigned outside its result — so the }
// wrong-PIN message arrived after the screen that would have shown it TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) {
// had gone, and in the change case any four digits reached "choose a Text(LockSettingsCopy.NOT_NOW)
// PIN". Advancing from inside the callback is the whole fix here; the }
// ViewModel refuses an unauthorised replacement as well, because this }
// file is exactly the kind that gets rewritten. }
Mode.CONFIRM_TO_REMOVE -> ConfirmPin(
title = "Enter your PIN to turn the lock off",
busy = state.busy,
wrong = state.message == LockSettings.Message.WRONG_PIN,
onCancel = { onDismissMessage(); setMode(Mode.OVERVIEW) },
onSubmit = { pin -> onRemovePin(pin) { setMode(Mode.OVERVIEW) } },
)
Mode.CONFIRM_TO_CHANGE -> ConfirmPin( /** One successful scan on this phone, before anything is written. */
title = "Enter your current PIN", @Composable
busy = state.busy, private fun ProveBiometric(onProve: () -> Unit, onCancel: () -> Unit) {
wrong = state.message == LockSettings.Message.WRONG_PIN, Column(
onCancel = { onCancelChange(); onDismissMessage(); setMode(Mode.OVERVIEW) }, modifier = Modifier
onSubmit = { pin -> onAuthoriseChange(pin) { setMode(Mode.SET_REPLACEMENT) } }, .fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(LockSettingsCopy.PROVE_TITLE, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(12.dp))
Text(
LockSettingsCopy.PROVE_BODY,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(28.dp))
Button(onClick = onProve, modifier = Modifier.fillMaxWidth()) {
Text(LockSettingsCopy.OPTION_BIOMETRIC)
}
TextButton(onClick = onCancel) { Text(LockSettingsCopy.NOT_NOW) }
} }
} }
@Composable @Composable
private fun Overview( private fun Overview(
state: LockSettings, state: LockSettings,
onSetPin: () -> Unit, onChooseMethod: (LockMethod) -> Unit,
onChangePin: () -> Unit, onChangePin: () -> Unit,
onRemovePin: () -> Unit,
onBiometric: (Boolean) -> Unit,
onDismissMessage: () -> Unit, onDismissMessage: () -> Unit,
biometricAvailable: Boolean = true,
biometricUnavailableReason: String? = null,
) { ) {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
Column( Column(
@ -204,47 +258,64 @@ private fun Overview(
// wade through and one more thing to keep in step. // wade through and one more thing to keep in step.
Text( Text(
if (state.hasPin) { LockSettingsCopy.HOW,
"The app asks for your PIN before it opens. While the lock is on, it is " + style = MaterialTheme.typography.titleMedium,
"hidden in the task switcher and screenshots are blocked."
} else {
"Ask for a PIN before the app opens. There is no way to reset a forgotten " +
"PIN, so you will be asked to confirm you understand that first."
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp), modifier = Modifier.padding(horizontal = 24.dp),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(8.dp))
if (!state.hasPin) { // Four choices, each with the one sentence a person cannot work out
Row(modifier = Modifier.padding(horizontal = 24.dp)) { // for herself: that the PIN is this app's and not the phone's, and
Button(onClick = onSetPin, enabled = !state.busy) { Text("Set a PIN") } // that a fingerprint lets in anybody enrolled on this phone.
} Column(Modifier.selectableGroup()) {
} else { MethodRow(
LockRow("Change PIN", "You will be asked for the current one first", onChangePin) label = LockSettingsCopy.OPTION_OFF,
LockRow("Turn off app lock", "The app will open without a PIN", onRemovePin) detail = LockSettingsCopy.OPTION_OFF_DETAIL,
selected = state.method == LockMethod.NONE,
enabled = !state.busy,
) { onChooseMethod(LockMethod.NONE) }
Row( MethodRow(
modifier = Modifier label = LockSettingsCopy.OPTION_PIN,
.fillMaxWidth() detail = LockSettingsCopy.OPTION_PIN_DETAIL,
.padding(horizontal = 24.dp, vertical = 12.dp), selected = state.method == LockMethod.PIN,
verticalAlignment = Alignment.CenterVertically, enabled = !state.busy,
horizontalArrangement = Arrangement.SpaceBetween, ) { onChooseMethod(LockMethod.PIN) }
) {
Column(modifier = Modifier.fillMaxWidth(0.75f)) { MethodRow(
Text("Unlock with fingerprint", style = MaterialTheme.typography.bodyLarge) label = LockSettingsCopy.OPTION_BIOMETRIC,
Text( detail = LockSettingsCopy.OPTION_BIOMETRIC_DETAIL,
// The thing a person cannot otherwise know, said plainly. selected = state.method == LockMethod.BIOMETRIC,
"Anyone whose fingerprint or face is set up on this phone will be " + // A method with no fallback is offered only where it
"able to open the app.", // demonstrably works; "probably" is not good enough when
style = MaterialTheme.typography.bodySmall, // being wrong means she cannot open the app at all.
color = MaterialTheme.colorScheme.onSurfaceVariant, enabled = !state.busy && biometricAvailable,
) unavailableReason = biometricUnavailableReason,
} ) { onChooseMethod(LockMethod.BIOMETRIC) }
Switch(checked = state.biometricEnabled, onCheckedChange = onBiometric)
} MethodRow(
label = LockSettingsCopy.OPTION_EITHER,
detail = LockSettingsCopy.OPTION_EITHER_DETAIL,
selected = state.method == LockMethod.PIN_AND_BIOMETRIC,
enabled = !state.busy && biometricAvailable,
unavailableReason = biometricUnavailableReason,
) { onChooseMethod(LockMethod.PIN_AND_BIOMETRIC) }
}
if (state.method.isOn) {
Spacer(Modifier.height(8.dp))
Text(
LockSettingsCopy.WHILE_ON,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp),
)
}
if (state.method.requiresPin) {
Spacer(Modifier.height(8.dp))
LockRow(LockSettingsCopy.CHANGE_PIN, LockSettingsCopy.CHANGE_PIN_DETAIL, onChangePin)
} }
state.message?.let { message -> state.message?.let { message ->
@ -365,7 +436,7 @@ private fun ConfirmPin(
@Composable @Composable
private fun LockSettingsOffPreview() { private fun LockSettingsOffPreview() {
PeriodTheme { PeriodTheme {
Overview(LockSettings(hasPin = false), {}, {}, {}, {}, {}) Overview(LockSettings(method = LockMethod.NONE), {}, {}, {})
} }
} }
@ -374,6 +445,50 @@ private fun LockSettingsOffPreview() {
@Composable @Composable
private fun LockSettingsOnPreview() { private fun LockSettingsOnPreview() {
PeriodTheme { PeriodTheme {
Overview(LockSettings(hasPin = true, biometricEnabled = true), {}, {}, {}, {}, {}) Overview(LockSettings(method = LockMethod.PIN_AND_BIOMETRIC), {}, {}, {})
}
}
/**
* One way in, and the sentence that decides whether somebody wants it.
*
* Disabled options keep their explanation rather than disappearing: a choice
* that is simply absent reads as a feature the app does not have, and this one
* is absent for a reason she can fix in her phone's settings.
*/
@Composable
private fun MethodRow(
label: String,
detail: String,
selected: Boolean,
enabled: Boolean,
unavailableReason: String? = null,
onSelect: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onSelect)
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalAlignment = Alignment.Top,
) {
RadioButton(selected = selected, onClick = null, enabled = enabled)
Spacer(Modifier.size(12.dp))
Column {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
Text(
if (enabled) detail else (unavailableReason ?: detail),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
} }

View File

@ -1,12 +1,11 @@
package dev.privacyllc.period.feature.lock package dev.privacyllc.period.feature.lock
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import dev.privacyllc.period.R import dev.privacyllc.period.R
import dev.privacyllc.period.core.security.LockMethod
import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.designsystem.PeriodTheme
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@ -20,15 +19,12 @@ import org.robolectric.annotation.Config
/** /**
* Back, on a screen whose steps the navigation graph knows nothing about. * Back, on a screen whose steps the navigation graph knows nothing about.
* *
* App lock keeps its step in remembered state rather than in a destination, so * App lock's steps live in the ViewModel, not in destinations, so nothing but
* the system gesture would leave the screen entirely from "choose a PIN" while * this file makes the arrow and the system gesture agree about them. The last
* the arrow beside it stepped back one two controls a hand's width apart doing * assertion is the subtle one: at the overview the handler must be *disabled*,
* different things. Both are asserted here, at both depths. * so the gesture falls through to the NavHost and pops the destination. A
* * handler that stayed enabled and did nothing would be a different trap in the
* The last assertion is the subtle one: at the Overview the handler must be * same place.
* *disabled*, so the gesture falls through to the NavHost and pops the
* destination. A handler that stayed enabled and did nothing would be a
* different trap in the same place.
*/ */
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34]) @Config(sdk = [34])
@ -37,12 +33,18 @@ class LockSettingsBackTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val back get() = rule.activity.getString(R.string.action_back) private val back get() = rule.activity.getString(R.string.action_back)
private var backs = 0
private fun setUpAtOverview() { private var backs = 0
private var cancels = 0
private fun show(pending: PendingChange?) {
rule.setContent { rule.setContent {
PeriodTheme { PeriodTheme {
LockSettingsContent(state = LockSettings(hasPin = false), onNavigateBack = { backs++ }) LockSettingsContent(
state = LockSettings(method = LockMethod.PIN, pending = pending),
onNavigateBack = { backs++ },
onCancelChange = { cancels++ },
)
} }
} }
} }
@ -52,39 +54,38 @@ class LockSettingsBackTest {
rule.waitForIdle() rule.waitForIdle()
} }
@Test fun `the gesture is left alone at the first step, so it can leave the screen`() { @Test fun `at the overview the gesture is left alone, so it can leave the screen`() {
setUpAtOverview() show(pending = null)
// Nothing of ours is registered, so back means what it means everywhere
// else: pop the destination.
assertFalse(rule.activity.onBackPressedDispatcher.hasEnabledCallbacks()) assertFalse(rule.activity.onBackPressedDispatcher.hasEnabledCallbacks())
} }
@Test fun `the arrow leaves the screen from the first step`() { @Test fun `at the overview the arrow leaves the screen`() {
setUpAtOverview() show(pending = null)
rule.onNodeWithContentDescription(back).performClick() rule.onNodeWithContentDescription(back).performClick()
assertEquals(1, backs) assertEquals(1, backs)
assertEquals(0, cancels)
} }
@Test fun `inside a step, both the gesture and the arrow step back rather than leaving`() { @Test fun `inside a step the gesture abandons the change rather than the screen`() {
setUpAtOverview() show(pending = PendingChange(LockMethod.NONE, PendingChange.Stage.CONFIRM_CURRENT))
rule.onNodeWithText("Set a PIN").performClick()
rule.onNodeWithText("Before you set a PIN").assertIsDisplayed()
assertTrue(rule.activity.onBackPressedDispatcher.hasEnabledCallbacks()) assertTrue(rule.activity.onBackPressedDispatcher.hasEnabledCallbacks())
systemBack() systemBack()
rule.onNodeWithText("Set a PIN").assertIsDisplayed() assertEquals("the gesture left App lock instead of abandoning the change", 0, backs)
assertEquals("the gesture left App lock instead of stepping back", 0, backs) assertEquals(1, cancels)
assertFalse(rule.activity.onBackPressedDispatcher.hasEnabledCallbacks()) }
@Test fun `inside a step the arrow does the same thing as the gesture`() {
show(pending = PendingChange(LockMethod.NONE, PendingChange.Stage.CONFIRM_CURRENT))
// And the arrow, from the same step, does the same thing.
rule.onNodeWithText("Set a PIN").performClick()
rule.onNodeWithContentDescription(back).performClick() rule.onNodeWithContentDescription(back).performClick()
rule.onNodeWithText("Set a PIN").assertIsDisplayed()
// Two controls a hand's width apart must not mean different things.
assertEquals(0, backs) assertEquals(0, backs)
assertEquals(1, cancels)
} }
} }

View File

@ -54,6 +54,19 @@ Data Safety section, and never lets health data reach any of them.
breached from a server we do not run. breached from a server we do not run.
- **No account is required** for core tracking, so there is no identity to - **No account is required** for core tracking, so there is no identity to
correlate the history with. correlate the history with.
- **The lock offers three ways in, and the user picks one.** A PIN belonging to
this app, a fingerprint or face already set up on the phone, or either. The
choice is stored in the lock's own DataStore rather than in `UserPreferences`,
because `resetToDefaults()` there clears everything — a lock recorded beside
the theme would be one "reset my settings" away from vanishing. Changing it
always authenticates with the method in force, and a fingerprint is never
turned on without one succeeding on that phone first.
- **Fingerprint-only has no PIN behind it, and the app says so before she picks
it.** Anyone enrolled on that phone can open the app, including somebody who
enrols later; if every fingerprint is removed the app stays closed until one is
added again. Critically, an unavailable sensor does **not** switch the lock
off: that would make the lock removable by anyone who knows the phone's own
PIN. The screen names the way back — enrol again, or erase.
- **The app lock is built, and it is a gate rather than encryption.** With a PIN - **The app lock is built, and it is a gate rather than encryption.** With a PIN
set, nothing composes before it is entered — the gate wraps the whole set, nothing composes before it is entered — the gate wraps the whole
composition rather than being a screen inside it, because every tab starts composition rather than being a screen inside it, because every tab starts

View File

@ -54,6 +54,10 @@ The one group that is not generic. Every item proves part of
- [ ] With the app lock on, the recents card is a solid colour and `adb exec-out screencap` returns a black frame — proves `FLAG_SECURE` is actually applied, which no source check can establish - [ ] With the app lock on, the recents card is a solid colour and `adb exec-out screencap` returns a black frame — proves `FLAG_SECURE` is actually applied, which no source check can establish
- [ ] Killed with `adb shell am kill` and reopened from recents, the app lands on the lock screen — proves the unlock flag is not in saved state, which is the one bug that would make the lock look fine and never engage - [ ] Killed with `adb shell am kill` and reopened from recents, the app lands on the lock screen — proves the unlock flag is not in saved state, which is the one bug that would make the lock look fine and never engage
- [ ] A reminder action tapped while locked writes nothing until after the unlock — proves a bystander cannot record an answer in someone's history from the phone's own lock screen - [ ] A reminder action tapped while locked writes nothing until after the unlock — proves a bystander cannot record an answer in someone's history from the phone's own lock screen
- [ ] In fingerprint-only mode, removing every fingerprint in phone settings leaves the app **closed**, showing how to get back in — proves an unavailable sensor does not switch the lock off, which would make it removable by anyone who knows the phone's own PIN
- [ ] In fingerprint-only mode, `am kill` and reopen shows one prompt, and cancelling leaves a button rather than an empty screen — proves the auto-prompt cannot loop and cannot strand
- [ ] Switching to fingerprint-only with no fingerprint enrolled is refused before anything is written — proves the method with no fallback is offered only where it demonstrably works
- [ ] An install upgraded from a PIN-only build still opens with that PIN, and one with the fingerprint shortcut on offers both — proves the migration gave every old install the method it already had
- [ ] A fingerprint enrolled on API 26 or 27 opens the app through the compat dialog — proves the pre-API-28 path `USE_FINGERPRINT` exists for is real, and is the one biometric route no current emulator exercises - [ ] A fingerprint enrolled on API 26 or 27 opens the app through the compat dialog — proves the pre-API-28 path `USE_FINGERPRINT` exists for is real, and is the one biometric route no current emulator exercises
- [ ] An OEM biometric overlay that stops the activity does not relock the app under its own prompt — proves the `authInProgress` guard holds on the devices it was written for; without it a successful scan returns to a locked screen forever - [ ] An OEM biometric overlay that stops the activity does not relock the app under its own prompt — proves the `authInProgress` guard holds on the devices it was written for; without it a successful scan returns to a locked screen forever
- [ ] Rotating the phone and setting font scale to 2.0 while unlocked does not relock — proves the gate tells a configuration change apart from a real backgrounding - [ ] Rotating the phone and setting font scale to 2.0 while unlocked does not relock — proves the gate tells a configuration change apart from a real backgrounding