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:
parent
750fdaeb0d
commit
f91ba6ebe6
|
|
@ -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
|
|
||||||
// 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()
|
onCancelChange()
|
||||||
onDismissMessage()
|
onDismissMessage()
|
||||||
mode = Mode.OVERVIEW
|
} 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
|
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)
|
|
||||||
|
|
||||||
SettingsSubpage(title = "App lock", onBack = stepBack) {
|
|
||||||
LockSettingsSteps(
|
|
||||||
mode = mode,
|
|
||||||
state = state,
|
state = state,
|
||||||
onSetPin = onSetPin,
|
onChooseMethod = onChooseMethod,
|
||||||
onAuthoriseChange = onAuthoriseChange,
|
onChangePin = { onChooseMethod(state.method) },
|
||||||
onCancelChange = onCancelChange,
|
|
||||||
onRemovePin = onRemovePin,
|
|
||||||
onBiometric = onBiometric,
|
|
||||||
onDismissMessage = onDismissMessage,
|
|
||||||
setMode = { mode = 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,
|
onDismissMessage = onDismissMessage,
|
||||||
|
biometricAvailable = biometricAvailable,
|
||||||
|
biometricUnavailableReason =
|
||||||
|
if (biometricAvailable) null else LockSettingsCopy.UNAVAILABLE_NOT_ENROLLED,
|
||||||
)
|
)
|
||||||
|
|
||||||
Mode.SET_FIRST, Mode.SET_REPLACEMENT -> PinSetupScreen(
|
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,
|
busy = state.busy,
|
||||||
failed = state.message == LockSettings.Message.COULD_NOT_SET,
|
failed = state.message == LockSettings.Message.COULD_NOT_SET,
|
||||||
onCancel = { onCancelChange(); onDismissMessage(); setMode(Mode.OVERVIEW) },
|
onCancel = stepBack,
|
||||||
onConfirmed = { pin -> onSetPin(pin); setMode(Mode.OVERVIEW) },
|
onConfirmed = onSubmitNewPin,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Both confirmations stay on screen until the PIN is actually right.
|
PendingChange.Stage.PROVE_BIOMETRIC -> ProveBiometric(
|
||||||
//
|
onProve = onProveBiometric,
|
||||||
// They used to move on in the same breath as asking — the check is
|
onCancel = stepBack,
|
||||||
// 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(
|
* What a fingerprint actually means here, before it is turned on.
|
||||||
title = "Enter your PIN to turn the lock off",
|
*
|
||||||
busy = state.busy,
|
* Two things a person cannot work out for herself, so both are said: anybody
|
||||||
wrong = state.message == LockSettings.Message.WRONG_PIN,
|
* enrolled on this phone can open the app — including somebody who enrols
|
||||||
onCancel = { onDismissMessage(); setMode(Mode.OVERVIEW) },
|
* later — and in fingerprint-only mode there is no PIN behind it.
|
||||||
onSubmit = { pin -> onRemovePin(pin) { setMode(Mode.OVERVIEW) } },
|
*/
|
||||||
|
@Composable
|
||||||
|
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.CONFIRM_TO_CHANGE -> ConfirmPin(
|
Spacer(Modifier.height(20.dp))
|
||||||
title = "Enter your current PIN",
|
Text(LockSettingsCopy.BIOMETRIC_NO_PIN_HEADING, style = MaterialTheme.typography.titleMedium)
|
||||||
busy = state.busy,
|
Spacer(Modifier.height(6.dp))
|
||||||
wrong = state.message == LockSettings.Message.WRONG_PIN,
|
Text(
|
||||||
onCancel = { onCancelChange(); onDismissMessage(); setMode(Mode.OVERVIEW) },
|
LockSettingsCopy.BIOMETRIC_NO_PIN_BODY,
|
||||||
onSubmit = { pin -> onAuthoriseChange(pin) { setMode(Mode.SET_REPLACEMENT) } },
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
@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()) {
|
||||||
|
MethodRow(
|
||||||
|
label = LockSettingsCopy.OPTION_OFF,
|
||||||
|
detail = LockSettingsCopy.OPTION_OFF_DETAIL,
|
||||||
|
selected = state.method == LockMethod.NONE,
|
||||||
|
enabled = !state.busy,
|
||||||
|
) { onChooseMethod(LockMethod.NONE) }
|
||||||
|
|
||||||
|
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) }
|
||||||
}
|
}
|
||||||
} 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)
|
|
||||||
|
|
||||||
Row(
|
if (state.method.isOn) {
|
||||||
modifier = Modifier
|
Spacer(Modifier.height(8.dp))
|
||||||
.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(
|
Text(
|
||||||
// The thing a person cannot otherwise know, said plainly.
|
LockSettingsCopy.WHILE_ON,
|
||||||
"Anyone whose fingerprint or face is set up on this phone will be " +
|
|
||||||
"able to open the app.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Switch(checked = state.biometricEnabled, onCheckedChange = onBiometric)
|
|
||||||
}
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue