feat: Delete My Data, and fix a privacy option nobody could tap
closes #36 The deletion has existed since Batch 01 with an instrumented test and no way to reach it. This adds the Privacy & Security section, a confirmation that says what goes and what stays, and PrivacyViewModelTest. A PRIVACY CONTROL WHOSE LABEL DID NOTHING The confirmation promises "your reminder settings are unchanged". Proving that meant changing a setting first, so I tapped "Maximum privacy" on a device and nothing happened: PrivacyRow and onboarding's PrivacyOption both put onClick on the RadioButton and left the row inert. The option that decides what a lock screen shows could only be changed by hitting a 20dp circle — in both places a user ever chooses it. Modifier.selectable on the row, onClick = null on the radio. That is Material's documented pattern and it also merges the semantics, so TalkBack announces one selectable option instead of a radio button and two loose strings. Found by trying to verify a different claim, which is the argument for verifying claims rather than asserting them. The setting does survive deletion — set to Maximum privacy, deleted everything, still Maximum privacy. A DESIGN THAT WAS WRONG BEFORE IT WAS WRITTEN The first draft cancelled the reminder schedule on delete. Seems obviously right; is not. ReminderWorker reads the forecast each run and NoData maps to no decision, so scheduled work already does nothing while there is nothing to say — and scheduling only happens from ReminderCoordinator and the settings screen, so cancelling would have left reminders silently off until the user next toggled something, long after logging a new period. Checked the call sites instead of reasoning from the name. Delete touches health data only. UserPreferences is a separate store precisely so a privacy action cannot reset a choice somebody made, and there is no undo — §45 says irreversible, and an undo snackbar keeps the data alive for its timeout. Round 4 recorded in docs/qa/. 194 tests pass; ./gradlew check, schema-guard and doc-claims all pass. Driven on PeriodMinSdk26. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f43e1c2824
commit
8807951553
|
|
@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
|
@ -30,6 +31,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -278,13 +280,29 @@ private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: Onboard
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
// The whole row is the target, not the radio.
|
||||||
|
//
|
||||||
|
// Found by tapping the label "Maximum privacy" on a device and watching
|
||||||
|
// nothing happen: only the RadioButton was clickable, so the option that
|
||||||
|
// decides what a lock screen shows could only be changed by hitting a
|
||||||
|
// ~20dp circle. selectable() on the Row with onClick = null on the radio is
|
||||||
|
// Material's documented pattern, and it also merges the semantics — TalkBack
|
||||||
|
// now announces one selectable option per row instead of a radio button
|
||||||
|
// followed by two loose pieces of text.
|
||||||
private fun PrivacyOption(title: String, example: String, selected: Boolean, onSelect: () -> Unit) {
|
private fun PrivacyOption(title: String, example: String, selected: Boolean, onSelect: () -> Unit) {
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(12.dp),
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = selected,
|
||||||
|
role = Role.RadioButton,
|
||||||
|
onClick = onSelect,
|
||||||
|
)
|
||||||
|
.padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
RadioButton(selected = selected, onClick = onSelect)
|
RadioButton(selected = selected, onClick = null)
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Column {
|
Column {
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
|
@ -27,6 +28,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -149,13 +151,29 @@ private fun NotificationSettingsContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
// The whole row is the target, not the radio.
|
||||||
|
//
|
||||||
|
// Found by tapping the label "Maximum privacy" on a device and watching
|
||||||
|
// nothing happen: only the RadioButton was clickable, so the option that
|
||||||
|
// decides what a lock screen shows could only be changed by hitting a
|
||||||
|
// ~20dp circle. selectable() on the Row with onClick = null on the radio is
|
||||||
|
// Material's documented pattern, and it also merges the semantics — TalkBack
|
||||||
|
// now announces one selectable option per row instead of a radio button
|
||||||
|
// followed by two loose pieces of text.
|
||||||
private fun PrivacyRow(title: String, example: String, selected: Boolean, onSelect: () -> Unit) {
|
private fun PrivacyRow(title: String, example: String, selected: Boolean, onSelect: () -> Unit) {
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
|
Card(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(12.dp),
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = selected,
|
||||||
|
role = Role.RadioButton,
|
||||||
|
onClick = onSelect,
|
||||||
|
)
|
||||||
|
.padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
RadioButton(selected = selected, onClick = onSelect)
|
RadioButton(selected = selected, onClick = null)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Column(Modifier.padding(start = 8.dp)) {
|
Column(Modifier.padding(start = 8.dp)) {
|
||||||
Text(title, style = MaterialTheme.typography.titleSmall)
|
Text(title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package dev.privacyllc.period.feature.settings
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.privacyllc.period.core.data.CycleRepository
|
||||||
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** What the Privacy & Security section is doing right now. */
|
||||||
|
enum class DeletionState { IDLE, DELETING, DONE, FAILED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §45's Delete My Data.
|
||||||
|
*
|
||||||
|
* The deletion itself has existed since Batch 01 and had an instrumented test
|
||||||
|
* before anything could call it. This adds the part a person touches, and the
|
||||||
|
* three rules that make it a privacy control rather than a destructive button.
|
||||||
|
*
|
||||||
|
* ## It deletes health data and nothing else
|
||||||
|
*
|
||||||
|
* `UserPreferences` lives in `core/datastore`, a separate store from the cycle
|
||||||
|
* database, and that separation exists for this moment: somebody exercising a
|
||||||
|
* privacy control has not asked to have their notification privacy reset to a
|
||||||
|
* default they did not choose. A user who wipes their history and is then sent
|
||||||
|
* a Direct-mode reminder has been failed twice.
|
||||||
|
*
|
||||||
|
* ## It does not touch the reminder schedule
|
||||||
|
*
|
||||||
|
* Tempting, and wrong. `ReminderWorker` reads the forecast on each run, and
|
||||||
|
* with no history `CycleStatusRules` returns `NoData`, which `ReminderRules`
|
||||||
|
* maps to no decision — so the scheduled work already does nothing while there
|
||||||
|
* is nothing to say. Cancelling it would be worse than pointless: scheduling
|
||||||
|
* only happens from `ReminderCoordinator` and the notification settings screen,
|
||||||
|
* so a cancel here would leave reminders silently off until the user next
|
||||||
|
* toggled a setting, long after they had logged a new period.
|
||||||
|
*
|
||||||
|
* ## There is no undo
|
||||||
|
*
|
||||||
|
* §45 says irreversible after confirmation, and an undo snackbar keeps the data
|
||||||
|
* alive for the length of its timeout — the opposite of what was just asked
|
||||||
|
* for. The confirmation carries the whole weight, so it says plainly what goes
|
||||||
|
* and what stays.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class PrivacyViewModel @Inject constructor(
|
||||||
|
private val repository: CycleRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _deletion = MutableStateFlow(DeletionState.IDLE)
|
||||||
|
val deletion: StateFlow<DeletionState> = _deletion.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failure surfaces instead of taking the process down.
|
||||||
|
*
|
||||||
|
* Batch 01's lesson, learned by tapping a button twice: a repository call
|
||||||
|
* that throws inside `viewModelScope.launch` kills the app. Here it would
|
||||||
|
* also leave the user unable to tell whether their data is gone, which is
|
||||||
|
* the worst possible moment for an ambiguous outcome.
|
||||||
|
*/
|
||||||
|
private val handler = CoroutineExceptionHandler { _, _ ->
|
||||||
|
_deletion.value = DeletionState.FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteEverything() {
|
||||||
|
if (_deletion.value == DeletionState.DELETING) return
|
||||||
|
_deletion.value = DeletionState.DELETING
|
||||||
|
viewModelScope.launch(handler) {
|
||||||
|
repository.deleteAllHealthData()
|
||||||
|
_deletion.value = DeletionState.DONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acknowledge() {
|
||||||
|
_deletion.value = DeletionState.IDLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,11 +12,15 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -49,7 +53,13 @@ import dev.privacyllc.period.designsystem.PeriodTheme
|
||||||
* theme control, and Premium with Batch 07.
|
* theme control, and Premium with Batch 07.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onOpenNotifications: () -> Unit) {
|
fun SettingsScreen(
|
||||||
|
onOpenNotifications: () -> Unit,
|
||||||
|
viewModel: PrivacyViewModel? = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val deletion = viewModel?.deletion?.collectAsStateWithLifecycle()?.value ?: DeletionState.IDLE
|
||||||
|
var confirming by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -63,6 +73,14 @@ fun SettingsScreen(onOpenNotifications: () -> Unit) {
|
||||||
onClick = onOpenNotifications,
|
onClick = onOpenNotifications,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SectionHeader("Privacy & Security")
|
||||||
|
SettingsRow(
|
||||||
|
title = "Delete my data",
|
||||||
|
subtitle = "Erase every period, spotting and prediction record",
|
||||||
|
onClick = { confirming = true },
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
SectionHeader("About")
|
SectionHeader("About")
|
||||||
ExpandableRow(
|
ExpandableRow(
|
||||||
|
|
@ -82,6 +100,73 @@ fun SettingsScreen(onOpenNotifications: () -> Unit) {
|
||||||
)
|
)
|
||||||
StaticRow(title = "Version", value = versionName())
|
StaticRow(title = "Version", value = versionName())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (confirming) {
|
||||||
|
DeleteConfirmation(
|
||||||
|
onConfirm = {
|
||||||
|
confirming = false
|
||||||
|
viewModel?.deleteEverything()
|
||||||
|
},
|
||||||
|
onDismiss = { confirming = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (deletion) {
|
||||||
|
DeletionState.DONE -> ResultDialog(
|
||||||
|
title = "Your data is deleted",
|
||||||
|
body = "Every period, spotting and prediction record has been erased " +
|
||||||
|
"from this device. Your reminder settings are unchanged.",
|
||||||
|
onDismiss = { viewModel?.acknowledge() },
|
||||||
|
)
|
||||||
|
DeletionState.FAILED -> ResultDialog(
|
||||||
|
title = "Nothing was deleted",
|
||||||
|
body = "Something went wrong and your data is still here. Try again, " +
|
||||||
|
"and if it keeps failing your records are intact.",
|
||||||
|
onDismiss = { viewModel?.acknowledge() },
|
||||||
|
)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §45 wants this "clear and irreversible after confirmation", so the dialog
|
||||||
|
* does three things a default one would not.
|
||||||
|
*
|
||||||
|
* It says what is destroyed **and what is kept** — a user deleting their history
|
||||||
|
* should not have to wonder whether their notification privacy went with it. It
|
||||||
|
* labels the button with the action rather than "OK", because "OK" to a question
|
||||||
|
* nobody read is how accidental deletions happen. And it does not offer an undo:
|
||||||
|
* that is the point of the control, not an omission.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DeleteConfirmation(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Delete your data?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"This erases every period, spotting and prediction record on this " +
|
||||||
|
"device. Your reminder and privacy settings are kept.\n\n" +
|
||||||
|
"This cannot be undone.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text("Delete my data", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Keep my data") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResultDialog(title: String, body: String, onDismiss: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = { Text(body) },
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -195,5 +280,5 @@ private fun StaticRow(title: String, value: String) {
|
||||||
)
|
)
|
||||||
@Composable
|
@Composable
|
||||||
private fun PreviewSettingsRoot() = PeriodTheme {
|
private fun PreviewSettingsRoot() = PeriodTheme {
|
||||||
SettingsScreen(onOpenNotifications = {})
|
SettingsScreen(onOpenNotifications = {}, viewModel = null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
package dev.privacyllc.period.feature.settings
|
||||||
|
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import dev.privacyllc.period.core.data.CycleData
|
||||||
|
import dev.privacyllc.period.core.data.CycleRepository
|
||||||
|
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §45's Delete My Data, tested for what it destroys **and what it leaves**.
|
||||||
|
*
|
||||||
|
* The second half is the one worth having. "It deleted everything" is easy to
|
||||||
|
* assert and is not the requirement: the requirement is that health history goes
|
||||||
|
* and the user's own settings do not, because `core/datastore` was made a
|
||||||
|
* separate store precisely so a privacy action could not quietly reset a choice
|
||||||
|
* somebody made.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34])
|
||||||
|
class PrivacyViewModelTest {
|
||||||
|
|
||||||
|
private val dispatcher = UnconfinedTestDispatcher()
|
||||||
|
private val today = LocalDate.of(2026, 8, 18)
|
||||||
|
private val clock = Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC)
|
||||||
|
|
||||||
|
private lateinit var repo: CycleRepository
|
||||||
|
private lateinit var vm: PrivacyViewModel
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
repo = CycleData.repository(
|
||||||
|
ApplicationProvider.getApplicationContext(),
|
||||||
|
PersonalPredictionEngine(),
|
||||||
|
clock,
|
||||||
|
)
|
||||||
|
runBlocking { repo.deleteAllHealthData() }
|
||||||
|
vm = PrivacyViewModel(repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() = Dispatchers.resetMain()
|
||||||
|
|
||||||
|
private fun await(predicate: suspend () -> Boolean) = runBlocking {
|
||||||
|
withTimeout(5_000) { while (!predicate()) delay(10) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seedThreeCycles() = runBlocking {
|
||||||
|
repo.confirmPeriodStart(today.minusDays(84))
|
||||||
|
repo.confirmPeriodStart(today.minusDays(56))
|
||||||
|
repo.confirmPeriodStart(today.minusDays(28))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `it starts idle`() {
|
||||||
|
assertEquals(DeletionState.IDLE, vm.deletion.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `it erases every period record`() {
|
||||||
|
seedThreeCycles()
|
||||||
|
await { repo.confirmedPeriods.first().size == 3 }
|
||||||
|
|
||||||
|
vm.deleteEverything()
|
||||||
|
|
||||||
|
await { vm.deletion.value == DeletionState.DONE }
|
||||||
|
assertTrue(runBlocking { repo.confirmedPeriods.first().isEmpty() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `the forecast goes with the history that produced it`() {
|
||||||
|
seedThreeCycles()
|
||||||
|
await { repo.forecast.first() != null }
|
||||||
|
|
||||||
|
vm.deleteEverything()
|
||||||
|
await { vm.deletion.value == DeletionState.DONE }
|
||||||
|
|
||||||
|
assertEquals(null, runBlocking { repo.forecast.first() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app must survive its own empty state afterwards.
|
||||||
|
*
|
||||||
|
* Deleting everything puts the user back on a screen that has never been
|
||||||
|
* reached from this direction before — the first-run empty state on a
|
||||||
|
* process that has already seen data. Reading the flows is what Today does
|
||||||
|
* on recomposition, so this is the crash that would greet them.
|
||||||
|
*/
|
||||||
|
@Test fun `reading the flows after deletion does not throw`() {
|
||||||
|
seedThreeCycles()
|
||||||
|
await { repo.confirmedPeriods.first().size == 3 }
|
||||||
|
|
||||||
|
vm.deleteEverything()
|
||||||
|
await { vm.deletion.value == DeletionState.DONE }
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
assertTrue(repo.confirmedPeriods.first().isEmpty())
|
||||||
|
assertEquals(null, repo.forecast.first())
|
||||||
|
assertTrue(repo.notYetObservations.first().isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `acknowledging returns it to idle so the dialog does not reappear`() {
|
||||||
|
seedThreeCycles()
|
||||||
|
vm.deleteEverything()
|
||||||
|
await { vm.deletion.value == DeletionState.DONE }
|
||||||
|
|
||||||
|
vm.acknowledge()
|
||||||
|
|
||||||
|
assertEquals(DeletionState.IDLE, vm.deletion.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tapping twice was a real crash in Batch 01, on a different screen.
|
||||||
|
* Here the second tap must be ignored rather than starting a second
|
||||||
|
* transaction against a database the first one is emptying.
|
||||||
|
*/
|
||||||
|
@Test fun `a second delete while one is running is ignored`() {
|
||||||
|
seedThreeCycles()
|
||||||
|
|
||||||
|
vm.deleteEverything()
|
||||||
|
vm.deleteEverything()
|
||||||
|
|
||||||
|
await { vm.deletion.value == DeletionState.DONE }
|
||||||
|
assertTrue(runBlocking { repo.confirmedPeriods.first().isEmpty() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,66 @@ written and stay true. It is exempt from review for the same reason a receipt is
|
||||||
|
|
||||||
## Entries
|
## Entries
|
||||||
|
|
||||||
|
### 2026-08-18 — Batch 06 starts, and two defects that only a device could show
|
||||||
|
|
||||||
|
The designed Settings screen and Delete My Data both landed. Neither is the
|
||||||
|
interesting part of the day.
|
||||||
|
|
||||||
|
**A privacy control whose label did nothing.** The delete confirmation promises
|
||||||
|
"your reminder settings are unchanged", and proving that meant changing a
|
||||||
|
setting first — so I tapped "Maximum privacy" on a device and watched nothing
|
||||||
|
happen. `PrivacyRow` and onboarding's `PrivacyOption` both put `onClick` on the
|
||||||
|
`RadioButton` and left the row inert. The option deciding what a lock screen
|
||||||
|
shows could only be changed by hitting a 20dp circle, in the two places a user
|
||||||
|
ever chooses it. Fixed with `Modifier.selectable` on the row, which also merges
|
||||||
|
the semantics so TalkBack announces one option instead of a radio and two loose
|
||||||
|
strings.
|
||||||
|
|
||||||
|
It was found by trying to verify a different claim. That is worth noting on its
|
||||||
|
own: the check that found it was not looking for it.
|
||||||
|
|
||||||
|
**And the setting does survive deletion** — set to Maximum privacy, deleted
|
||||||
|
everything, still Maximum privacy. Now demonstrated rather than argued from the
|
||||||
|
fact that `core/datastore` is a separate store.
|
||||||
|
|
||||||
|
**A design I had wrong before writing it.** My first draft of `PrivacyViewModel`
|
||||||
|
cancelled the reminder schedule on delete, which seems obviously right and is
|
||||||
|
not. `ReminderWorker` reads the forecast each run and `NoData` maps to no
|
||||||
|
decision, so the scheduled work already does nothing while there is nothing to
|
||||||
|
say — and scheduling only happens from `ReminderCoordinator` and the settings
|
||||||
|
screen, so cancelling would have left reminders silently off until the user next
|
||||||
|
toggled something, long after they had logged a new period. Checked the call
|
||||||
|
sites instead of reasoning from the name.
|
||||||
|
|
||||||
|
**Also fixed, from the font-scale pass:** the bottom navigation wrapped its
|
||||||
|
labels mid-word at scale 2.0 — *"Calenda / r"*. `maxLines = 1` and an ellipsis.
|
||||||
|
That defect predates all of today's work; the font-scale gap is simply the first
|
||||||
|
thing that looked.
|
||||||
|
|
||||||
|
**Settings shows only the sections that have something behind them.** A
|
||||||
|
greyed-out "Delete My Data" tells a user that control over their own health data
|
||||||
|
exists and that they may not have it, which is a bad first thing for this
|
||||||
|
product to say. Recorded in `docs/design/README.md` so the next person adding a
|
||||||
|
row knows it was a decision.
|
||||||
|
|
||||||
|
Round 4 recorded in `docs/qa/`. Four rounds in, the pattern has not moved: every
|
||||||
|
defect of consequence has been found by a person using the app, and none by a
|
||||||
|
test.
|
||||||
|
|
||||||
|
- **Closed:** #29, #33, #36.
|
||||||
|
- **Next action:** Continue Batch 06. #38, the release-build logging guard, is
|
||||||
|
independent of the rest and can go next — write the guard first and prove it
|
||||||
|
fails, per `docs/architecture/GUARDS.md`, because three guards in this project
|
||||||
|
have been green over the exact failure they claimed to catch. #35 export and
|
||||||
|
#37 the privacy promise both need only the Settings screen, which now exists.
|
||||||
|
#34, app lock, still carries an open product question: what happens when
|
||||||
|
somebody forgets their PIN, given an irreversible delete sits on the same
|
||||||
|
screen.
|
||||||
|
- **Blockers:** #9, the Command Center webhook, still needs a person. TalkBack
|
||||||
|
has still never been run and no real lock screen has been looked at — and
|
||||||
|
after today's find, the TalkBack gap looks more expensive than it did: two
|
||||||
|
screens had merged-semantics problems nobody would see without it.
|
||||||
|
|
||||||
### 2026-08-18 — The onboarding artwork ships, and a plan I had argued for turned out to be wrong
|
### 2026-08-18 — The onboarding artwork ships, and a plan I had argued for turned out to be wrong
|
||||||
|
|
||||||
All eight illustrations are in the app in both themes, every onboarding step has
|
All eight illustrations are in the app in both themes, every onboarding step has
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,42 @@ Invisible to the unit tests and invisible to an API 36 emulator. Fixed, and
|
||||||
worth recording as a standing gap below: **this project has no test on a device
|
worth recording as a standing gap below: **this project has no test on a device
|
||||||
at its own `minSdk`.**
|
at its own `minSdk`.**
|
||||||
|
|
||||||
|
## Round 4 — 2026-08-18 at `f43e1c2`, partial
|
||||||
|
|
||||||
|
Batch 06 began: the designed Settings screen and Delete My Data. Driven by hand
|
||||||
|
on `PeriodMinSdk26`, plus the font-scale pass listed under the gaps below.
|
||||||
|
|
||||||
|
### What driving it found that no test would have
|
||||||
|
|
||||||
|
Two defects, both in controls that already looked finished:
|
||||||
|
|
||||||
|
1. **Only the radio button in a privacy option was clickable.** Tapping the
|
||||||
|
label "Maximum privacy" did nothing — `PrivacyRow` and onboarding's
|
||||||
|
`PrivacyOption` put `onClick` on the `RadioButton` and left the row inert, so
|
||||||
|
the control that decides what a lock screen shows could only be changed by
|
||||||
|
hitting a ~20dp circle. Found while trying to *prove something else*: the
|
||||||
|
delete confirmation promises "your reminder settings are unchanged", and
|
||||||
|
testing that claim meant changing a setting first, which would not work.
|
||||||
|
Fixed with `Modifier.selectable` on the row and `onClick = null` on the
|
||||||
|
radio, which also merges the semantics for TalkBack.
|
||||||
|
2. **The navigation bar wrapped its labels mid-word at font scale 2.0** —
|
||||||
|
recorded under the font-scaling gap below.
|
||||||
|
|
||||||
|
Neither is visible in a unit test, a preview, or a code review. Both took one
|
||||||
|
person tapping the thing.
|
||||||
|
|
||||||
|
### What was verified rather than assumed
|
||||||
|
|
||||||
|
- Delete My Data erases every period, spotting and prediction record, and the
|
||||||
|
user's notification-privacy choice **survives it** — set to Maximum privacy,
|
||||||
|
deleted, still Maximum privacy afterwards. That is the claim the confirmation
|
||||||
|
dialog makes to the user, so it is checked on a device rather than argued from
|
||||||
|
the code.
|
||||||
|
- Today renders its empty state after a deletion without crashing, which is the
|
||||||
|
first-run screen reached from a direction it had never been reached from.
|
||||||
|
- `PrivacyViewModelTest` covers the same ground on the JVM, including a second
|
||||||
|
delete while one is running.
|
||||||
|
|
||||||
## Standing gaps
|
## Standing gaps
|
||||||
|
|
||||||
Things no round has ever covered, carried forward until they are. This list
|
Things no round has ever covered, carried forward until they are. This list
|
||||||
|
|
|
||||||
|
|
@ -19,21 +19,22 @@ Review trigger: Any QA round run
|
||||||
|
|
||||||
## Current run-state
|
## Current run-state
|
||||||
|
|
||||||
- **Last QA round:** Round 3 — 2026-08-18, partial (A and B pass; C, D, E, F, G and H partial)
|
- **Last QA round:** Round 4 — 2026-08-18, partial (A and B pass; C, D, E, F, G and H partial)
|
||||||
- **Last verified build SHA:** `0451fbe`
|
- **Last verified build SHA:** `f43e1c2`
|
||||||
- **Last tested device / environment:** emulator `PeriodQA`, API 36, Pixel 6 profile, debug build
|
- **Last tested device / environment:** emulator `PeriodMinSdk26`, API 26, Pixel 6 profile, debug build, at font scales 1.0, 1.3 and 2.0
|
||||||
- **Overall status:** Five of eight batches are done and the app is a coherent
|
- **Overall status:** Five of eight batches are done and Batch 06 has begun:
|
||||||
product: onboarding to a forecast, a Today screen with six honest states,
|
Settings is now the screen §36 describes, and Delete My Data is reachable,
|
||||||
two-tap logging, a calendar readable in greyscale, insights that decline to
|
confirmed and irreversible. The app has run at its minimum Android version and
|
||||||
overstate, fertility estimates that refuse to appear when the forecast is too
|
at the largest accessibility font scale, and both found defects that had been
|
||||||
vague to support them, and discreet reminders. It now wears its real brand,
|
shipping unnoticed — a navigation bar that wrapped its labels mid-word, and a
|
||||||
supplied by the owner. Four instrumented tests assert that nothing about a
|
privacy option whose label was not clickable at all, so the control deciding
|
||||||
period reaches a lock screen in either private mode — but **nobody has yet
|
what a lock screen shows could only be changed by hitting the radio button
|
||||||
looked at an actual locked screen**, and that last mile is the single most
|
exactly. Both are fixed. The pattern is now four rounds old and unchanged:
|
||||||
valuable thing left to check, because a notification read over a shoulder is
|
**every defect of consequence in this project has been found by a person using
|
||||||
the likeliest real privacy failure in this product. The other standing gaps
|
the app, and none by a test.** The tests are what stop them coming back. The
|
||||||
are of the same kind: TalkBack has never been run, text has never been scaled,
|
standing gaps are the same shape — nobody has yet looked at an actual locked
|
||||||
and nothing has run at the minimum Android version this app claims to support.
|
screen, and TalkBack has still never been run, which is now the single most
|
||||||
|
valuable unspent hour.
|
||||||
|
|
||||||
## Open defects
|
## Open defects
|
||||||
|
|
||||||
|
|
@ -61,6 +62,12 @@ One entry per round, the verdict only. What each pass reached, and what it could
|
||||||
not, lives in [`ClaudeQACoverage.md`](ClaudeQACoverage.md) and is deliberately
|
not, lives in [`ClaudeQACoverage.md`](ClaudeQACoverage.md) and is deliberately
|
||||||
not repeated here.
|
not repeated here.
|
||||||
|
|
||||||
|
- **Round 4 — 2026-08-18 at `f43e1c2`, partial.** Batch 06 began, and driving
|
||||||
|
it found two defects in controls that already looked finished: navigation
|
||||||
|
labels wrapping mid-word at font scale 2.0, and a privacy option whose row was
|
||||||
|
inert because only its radio button was clickable. Verdict: the privacy
|
||||||
|
controls now behave as their own dialog promises — checked by setting a
|
||||||
|
preference, deleting all data, and confirming the preference survived.
|
||||||
- **Round 3 — 2026-08-18 at `0451fbe`, partial.** Fertility and reminders landed
|
- **Round 3 — 2026-08-18 at `0451fbe`, partial.** Fertility and reminders landed
|
||||||
and pass F became runnable for the first time. Verdict: fit to keep building,
|
and pass F became runnable for the first time. Verdict: fit to keep building,
|
||||||
not fit to ship — the privacy promise is proved against the notification
|
not fit to ship — the privacy promise is proved against the notification
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue