From f91ba6ebe6a55566e8d62980fa2616c7cfe71b6a Mon Sep 17 00:00:00 2001 From: null Date: Fri, 21 Aug 2026 01:53:09 -0500 Subject: [PATCH] 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) --- .../period/feature/lock/LockSettingsScreen.kt | 375 ++++++++++++------ .../feature/lock/LockSettingsBackTest.kt | 63 +-- docs/security/SECURITY.md | 13 + docs/security/SECURITY_CHECKLIST.md | 4 + 4 files changed, 294 insertions(+), 161 deletions(-) diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockSettingsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockSettingsScreen.kt index 530fce7..b2ef8ff 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockSettingsScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/lock/LockSettingsScreen.kt @@ -14,6 +14,12 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll 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.OutlinedTextField import androidx.compose.material3.Surface @@ -39,6 +45,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.feature.common.SettingsSubpage @@ -59,138 +69,182 @@ fun LockSettingsScreen( viewModel: LockSettingsViewModel = hiltViewModel(), ) { 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( state = state, onNavigateBack = onNavigateBack, - onSetPin = viewModel::setPin, - onAuthoriseChange = viewModel::authoriseChange, + onChooseMethod = viewModel::request, + onConfirmWithPin = viewModel::confirmWithPin, + onSubmitNewPin = viewModel::submitNewPin, + onAcceptBiometricConsent = viewModel::acceptBiometricConsent, + onProveBiometric = biometric.prompt, onCancelChange = viewModel::cancelChange, - onRemovePin = viewModel::removePin, - onBiometric = viewModel::setBiometricEnabled, 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 - * exactly why they need a test: nothing in the navigation graph knows they - * exist, so nothing but this file makes the arrow and the system gesture agree - * about them. + * It renders `state.pending` and cannot advance it. That is the point: the + * previous version kept its own step in remembered state and moved it in the + * same breath as asking, so a wrong PIN still reached "choose a PIN". */ @Composable internal fun LockSettingsContent( state: LockSettings, onNavigateBack: () -> Unit, - onSetPin: (CharArray) -> Unit = {}, - onAuthoriseChange: (CharArray, () -> Unit) -> Unit = { _, _ -> }, + onChooseMethod: (LockMethod) -> Unit = {}, + onConfirmWithPin: (CharArray) -> Unit = {}, + onSubmitNewPin: (CharArray) -> Unit = {}, + onAcceptBiometricConsent: () -> Unit = {}, + onProveBiometric: () -> Unit = {}, onCancelChange: () -> Unit = {}, - onRemovePin: (CharArray, () -> Unit) -> Unit = { _, _ -> }, - onBiometric: (Boolean) -> 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. - // - // They have to agree. This screen has steps the navigation graph knows - // nothing about — `mode` is remembered state, not a destination — so system - // back from "choose a PIN" would otherwise leave App lock entirely while - // the arrow beside it stepped back one. Two controls a hand's width apart, - // doing different things. - val toOverview: () -> Unit = { - // Abandoning a step abandons the permission it was granted, too. - onCancelChange() - onDismissMessage() - mode = Mode.OVERVIEW + // 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. + val stepBack: () -> Unit = { + if (pending != null) { + onCancelChange() + onDismissMessage() + } else { + onNavigateBack() + } } - 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 - // the NavHost, which pops the destination — exactly what the arrow does - // there. Nothing is swallowed. - BackHandler(enabled = mode != Mode.OVERVIEW, onBack = toOverview) + SettingsSubpage(title = LockSettingsCopy.TITLE, onBack = stepBack) { + when (pending?.stage) { + null -> Overview( + 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) { - LockSettingsSteps( - mode = mode, - state = state, - onSetPin = onSetPin, - onAuthoriseChange = onAuthoriseChange, - onCancelChange = onCancelChange, - onRemovePin = onRemovePin, - onBiometric = onBiometric, - onDismissMessage = onDismissMessage, - setMode = { mode = it }, - ) + PendingChange.Stage.CONFIRM_CURRENT -> ConfirmPin( + title = LockSettingsCopy.CONFIRM_TO_CHANGE_METHOD, + busy = state.busy, + wrong = state.message == LockSettings.Message.WRONG_PIN, + onCancel = stepBack, + // Advances only if the ViewModel says the PIN was right. + onSubmit = onConfirmWithPin, + ) + + PendingChange.Stage.BIOMETRIC_CONSENT -> BiometricConsent( + 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 -private fun LockSettingsSteps( - mode: Mode, - state: LockSettings, - onSetPin: (CharArray) -> Unit, - onAuthoriseChange: (CharArray, () -> Unit) -> Unit, - onCancelChange: () -> Unit, - onRemovePin: (CharArray, () -> Unit) -> Unit, - onBiometric: (Boolean) -> Unit, - onDismissMessage: () -> Unit, - setMode: (Mode) -> Unit, -) { - when (mode) { - Mode.OVERVIEW -> Overview( - state = state, - onSetPin = { setMode(Mode.SET_FIRST) }, - onChangePin = { setMode(Mode.CONFIRM_TO_CHANGE) }, - onRemovePin = { setMode(Mode.CONFIRM_TO_REMOVE) }, - onBiometric = onBiometric, - onDismissMessage = onDismissMessage, +private fun BiometricConsent(onAccept: () -> Unit, onCancel: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + ) { + Text(LockSettingsCopy.BIOMETRIC_CONSENT_TITLE, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(20.dp)) + + Text(LockSettingsCopy.BIOMETRIC_WHO_HEADING, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(6.dp)) + Text( + LockSettingsCopy.BIOMETRIC_WHO_BODY, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Mode.SET_FIRST, Mode.SET_REPLACEMENT -> PinSetupScreen( - busy = state.busy, - failed = state.message == LockSettings.Message.COULD_NOT_SET, - onCancel = { onCancelChange(); onDismissMessage(); setMode(Mode.OVERVIEW) }, - onConfirmed = { pin -> onSetPin(pin); setMode(Mode.OVERVIEW) }, + Spacer(Modifier.height(20.dp)) + Text(LockSettingsCopy.BIOMETRIC_NO_PIN_HEADING, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(6.dp)) + Text( + LockSettingsCopy.BIOMETRIC_NO_PIN_BODY, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - // Both confirmations stay on screen until the PIN is actually right. - // - // They used to move on in the same breath as asking — the check is - // asynchronous and the mode was reassigned outside its result — so the - // wrong-PIN message arrived after the screen that would have shown it - // had gone, and in the change case any four digits reached "choose a - // 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) } }, - ) + Spacer(Modifier.height(28.dp)) + Button(onClick = onAccept, modifier = Modifier.fillMaxWidth()) { + Text(LockSettingsCopy.BIOMETRIC_CONSENT_ACCEPT) + } + TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(LockSettingsCopy.NOT_NOW) + } + } +} - Mode.CONFIRM_TO_CHANGE -> ConfirmPin( - title = "Enter your current PIN", - busy = state.busy, - wrong = state.message == LockSettings.Message.WRONG_PIN, - onCancel = { onCancelChange(); onDismissMessage(); setMode(Mode.OVERVIEW) }, - onSubmit = { pin -> onAuthoriseChange(pin) { setMode(Mode.SET_REPLACEMENT) } }, +/** One successful scan on this phone, before anything is written. */ +@Composable +private fun ProveBiometric(onProve: () -> Unit, onCancel: () -> Unit) { + Column( + modifier = Modifier + .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 private fun Overview( state: LockSettings, - onSetPin: () -> Unit, + onChooseMethod: (LockMethod) -> Unit, onChangePin: () -> Unit, - onRemovePin: () -> Unit, - onBiometric: (Boolean) -> Unit, onDismissMessage: () -> Unit, + biometricAvailable: Boolean = true, + biometricUnavailableReason: String? = null, ) { Surface(modifier = Modifier.fillMaxSize()) { Column( @@ -204,47 +258,64 @@ private fun Overview( // wade through and one more thing to keep in step. Text( - if (state.hasPin) { - "The app asks for your PIN before it opens. While the lock is on, it is " + - "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, + LockSettingsCopy.HOW, + style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 24.dp), ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(8.dp)) - if (!state.hasPin) { - Row(modifier = Modifier.padding(horizontal = 24.dp)) { - Button(onClick = onSetPin, enabled = !state.busy) { Text("Set a PIN") } - } - } else { - LockRow("Change PIN", "You will be asked for the current one first", onChangePin) - LockRow("Turn off app lock", "The app will open without a PIN", onRemovePin) + // Four choices, each with the one sentence a person cannot work out + // for herself: that the PIN is this app's and not the phone's, and + // that a fingerprint lets in anybody enrolled on this phone. + Column(Modifier.selectableGroup()) { + MethodRow( + label = LockSettingsCopy.OPTION_OFF, + detail = LockSettingsCopy.OPTION_OFF_DETAIL, + selected = state.method == LockMethod.NONE, + enabled = !state.busy, + ) { onChooseMethod(LockMethod.NONE) } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.fillMaxWidth(0.75f)) { - Text("Unlock with fingerprint", style = MaterialTheme.typography.bodyLarge) - Text( - // The thing a person cannot otherwise know, said plainly. - "Anyone whose fingerprint or face is set up on this phone will be " + - "able to open the app.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch(checked = state.biometricEnabled, onCheckedChange = onBiometric) - } + MethodRow( + label = LockSettingsCopy.OPTION_PIN, + detail = LockSettingsCopy.OPTION_PIN_DETAIL, + selected = state.method == LockMethod.PIN, + enabled = !state.busy, + ) { onChooseMethod(LockMethod.PIN) } + + MethodRow( + label = LockSettingsCopy.OPTION_BIOMETRIC, + detail = LockSettingsCopy.OPTION_BIOMETRIC_DETAIL, + selected = state.method == LockMethod.BIOMETRIC, + // A method with no fallback is offered only where it + // demonstrably works; "probably" is not good enough when + // being wrong means she cannot open the app at all. + enabled = !state.busy && biometricAvailable, + unavailableReason = biometricUnavailableReason, + ) { onChooseMethod(LockMethod.BIOMETRIC) } + + 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 -> @@ -365,7 +436,7 @@ private fun ConfirmPin( @Composable private fun LockSettingsOffPreview() { PeriodTheme { - Overview(LockSettings(hasPin = false), {}, {}, {}, {}, {}) + Overview(LockSettings(method = LockMethod.NONE), {}, {}, {}) } } @@ -374,6 +445,50 @@ private fun LockSettingsOffPreview() { @Composable private fun LockSettingsOnPreview() { 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, + ) + } } } diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsBackTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsBackTest.kt index af227d1..50d2cc0 100644 --- a/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsBackTest.kt +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsBackTest.kt @@ -1,12 +1,11 @@ package dev.privacyllc.period.feature.lock import androidx.activity.ComponentActivity -import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import dev.privacyllc.period.R +import dev.privacyllc.period.core.security.LockMethod import dev.privacyllc.period.designsystem.PeriodTheme import org.junit.Assert.assertEquals 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. * - * App lock keeps its step in remembered state rather than in a destination, so - * the system gesture would leave the screen entirely from "choose a PIN" while - * the arrow beside it stepped back one — two controls a hand's width apart doing - * different things. Both are asserted here, at both depths. - * - * The last assertion is the subtle one: at the Overview the handler must be - * *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. + * App lock's steps live in the ViewModel, not in destinations, so nothing but + * this file makes the arrow and the system gesture agree about them. The last + * assertion is the subtle one: at the overview the handler must be *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) @Config(sdk = [34]) @@ -37,12 +33,18 @@ class LockSettingsBackTest { @get:Rule val rule = createAndroidComposeRule() 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 { 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() } - @Test fun `the gesture is left alone at the first step, so it can leave the screen`() { - setUpAtOverview() + @Test fun `at the overview the gesture is left alone, so it can leave the screen`() { + show(pending = null) - // Nothing of ours is registered, so back means what it means everywhere - // else: pop the destination. assertFalse(rule.activity.onBackPressedDispatcher.hasEnabledCallbacks()) } - @Test fun `the arrow leaves the screen from the first step`() { - setUpAtOverview() + @Test fun `at the overview the arrow leaves the screen`() { + show(pending = null) rule.onNodeWithContentDescription(back).performClick() assertEquals(1, backs) + assertEquals(0, cancels) } - @Test fun `inside a step, both the gesture and the arrow step back rather than leaving`() { - setUpAtOverview() + @Test fun `inside a step the gesture abandons the change rather than the screen`() { + 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()) - systemBack() - rule.onNodeWithText("Set a PIN").assertIsDisplayed() - assertEquals("the gesture left App lock instead of stepping back", 0, backs) - assertFalse(rule.activity.onBackPressedDispatcher.hasEnabledCallbacks()) + assertEquals("the gesture left App lock instead of abandoning the change", 0, backs) + assertEquals(1, cancels) + } + + @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.onNodeWithText("Set a PIN").assertIsDisplayed() + + // Two controls a hand's width apart must not mean different things. assertEquals(0, backs) + assertEquals(1, cancels) } } diff --git a/docs/security/SECURITY.md b/docs/security/SECURITY.md index 08e5180..f7e631d 100644 --- a/docs/security/SECURITY.md +++ b/docs/security/SECURITY.md @@ -54,6 +54,19 @@ Data Safety section, and never lets health data reach any of them. breached from a server we do not run. - **No account is required** for core tracking, so there is no identity to 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 set, nothing composes before it is entered — the gate wraps the whole composition rather than being a screen inside it, because every tab starts diff --git a/docs/security/SECURITY_CHECKLIST.md b/docs/security/SECURITY_CHECKLIST.md index dce2b89..22fba7f 100644 --- a/docs/security/SECURITY_CHECKLIST.md +++ b/docs/security/SECURITY_CHECKLIST.md @@ -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 - [ ] 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 +- [ ] 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 - [ ] 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