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