From 8d7a7252cbc335cbf7ff6590ebd19fbc3b1db6fc Mon Sep 17 00:00:00 2001 From: null Date: Tue, 18 Aug 2026 02:34:47 -0500 Subject: [PATCH] feat: DataStore-backed UserPreferences, separate from the cycle database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/datastore holds the settings from PRODUCT_PLAN.md §10 — notification privacy, reminder time, the three reminder toggles, biometric lock, theme, the ads entitlement and whether onboarding finished. Two defaults are decisions, and each has a test whose job is to stop it being changed by accident: - notification privacy defaults to DISCREET (§28). A default of DIRECT would put menstrual detail on a lock screen before the user has been asked a single question, and a notification read over a shoulder is the likeliest real privacy breach in this product. - fertility reminders default to off. Most users are not tracking fertility and an unrequested ovulation notification is an unpleasant surprise. An unrecognised stored value falls back to the SAFE option rather than to whatever enum entry happens to be first — a rollback or a hand-edited file must not be able to turn DISCREET into DIRECT. Tested for privacy mode, theme and an out-of-range reminder time. This is a separate store rather than two more Room tables, and the reason is a deletion semantic: Delete My Data removes the health history and must leave the settings alone. Handing a user back a weaker privacy setting at the exact moment they are exercising a privacy control is the worst possible time to do it, and separate stores make the correct behaviour the easy one. The repository takes a DataStore rather than a Context, so its 10 tests run on the JVM against a temporary file — no emulator, no Robolectric. The Android instance is supplied by DI at the app layer, the only place that should know where a file lives. 41 tests across the project, all passing. closes #4 --- core/datastore/build.gradle.kts | 25 +++ .../period/core/datastore/UserPreferences.kt | 66 +++++++ .../datastore/UserPreferencesRepository.kt | 111 +++++++++++ .../UserPreferencesRepositoryTest.kt | 183 ++++++++++++++++++ docs/architecture/README.md | 25 ++- gradle/libs.versions.toml | 2 + settings.gradle.kts | 6 +- 7 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 core/datastore/build.gradle.kts create mode 100644 core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferences.kt create mode 100644 core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepository.kt create mode 100644 core/datastore/src/test/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepositoryTest.kt diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts new file mode 100644 index 0000000..d6090af --- /dev/null +++ b/core/datastore/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "dev.privacyllc.period.core.datastore" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + api(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferences.kt b/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferences.kt new file mode 100644 index 0000000..e1f776a --- /dev/null +++ b/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferences.kt @@ -0,0 +1,66 @@ +package dev.privacyllc.period.core.datastore + +import java.time.LocalTime + +/** + * How a reminder appears on a lock screen. + * + * This is the most consequential setting in the app. The likeliest real breach + * of a user's privacy here is not a database compromise — it is a notification + * read over a shoulder, or by whoever else picks the phone up. + * + * PRODUCT_PLAN.md §28. The user must choose [DIRECT] explicitly; it is never a + * default and never a fallback. + */ +enum class NotificationPrivacy { + /** "Quick check-in — something may be coming up." The default. */ + DISCREET, + + /** "Reminder", and nothing else. No health information of any kind. */ + MAXIMUM_PRIVACY, + + /** "Your period may start in 2 days." Only when explicitly chosen. */ + DIRECT, +} + +enum class AppTheme { LIGHT, DARK, SYSTEM } + +/** + * Everything that is a setting rather than health history. + * + * Deliberately **not** in the cycle database. Different lifetime, different + * sensitivity, and different deletion semantics: Delete My Data removes the + * health history and must not reset the user's notification privacy choice to + * a default they did not pick. + */ +data class UserPreferences( + val notificationPrivacy: NotificationPrivacy = NotificationPrivacy.DISCREET, + val reminderTime: LocalTime = DEFAULT_REMINDER_TIME, + val periodReminderEnabled: Boolean = true, + val fertileWindowReminderEnabled: Boolean = false, + val ovulationReminderEnabled: Boolean = false, + val biometricLockEnabled: Boolean = false, + val theme: AppTheme = AppTheme.SYSTEM, + val adsRemoved: Boolean = false, + val onboardingCompleted: Boolean = false, +) { + companion object { + /** + * Late morning, not 8am. A reminder that fires while somebody is on a + * commuter train is a reminder read by strangers, and the whole point + * of the privacy modes is that this app is careful about being seen. + */ + val DEFAULT_REMINDER_TIME: LocalTime = LocalTime.of(10, 0) + + /** + * The defaults a fresh install gets, before anything has been written. + * + * Two of these are decisions rather than conveniences: + * [NotificationPrivacy.DISCREET] because a default of DIRECT would leak + * on a lock screen before the user has been asked anything, and the + * fertility reminders off because most users are not tracking fertility + * and an unrequested ovulation notification is an unpleasant surprise. + */ + val Defaults = UserPreferences() + } +} diff --git a/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepository.kt b/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepository.kt new file mode 100644 index 0000000..99176c6 --- /dev/null +++ b/core/datastore/src/main/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepository.kt @@ -0,0 +1,111 @@ +package dev.privacyllc.period.core.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException +import java.time.LocalTime + +/** + * Typed access to [UserPreferences]. + * + * Takes the [DataStore] rather than a `Context` so it can be exercised on the + * JVM against a temporary file — no emulator, no Robolectric. The Android + * instance is supplied by DI at the app layer, which is the only place that + * should know where a file lives. + */ +class UserPreferencesRepository( + private val dataStore: DataStore, +) { + + /** + * Never throws at the collector. + * + * A corrupt or unreadable preferences file must not take the app down: the + * cycle history is elsewhere and is what the user actually came for, so the + * honest failure mode is to fall back to defaults and keep working. Note + * that only [IOException] is swallowed — a programming error still surfaces. + */ + val preferences: Flow = dataStore.data + .catch { cause -> if (cause is IOException) emit(EMPTY) else throw cause } + .map(::decode) + + suspend fun setNotificationPrivacy(value: NotificationPrivacy) = edit { + it[Keys.NotificationPrivacy] = value.name + } + + suspend fun setReminderTime(value: LocalTime) = edit { + it[Keys.ReminderMinuteOfDay] = value.hour * 60 + value.minute + } + + suspend fun setPeriodReminderEnabled(value: Boolean) = edit { it[Keys.PeriodReminder] = value } + suspend fun setFertileWindowReminderEnabled(value: Boolean) = edit { it[Keys.FertileReminder] = value } + suspend fun setOvulationReminderEnabled(value: Boolean) = edit { it[Keys.OvulationReminder] = value } + suspend fun setBiometricLockEnabled(value: Boolean) = edit { it[Keys.BiometricLock] = value } + suspend fun setTheme(value: AppTheme) = edit { it[Keys.Theme] = value.name } + suspend fun setOnboardingCompleted(value: Boolean) = edit { it[Keys.OnboardingCompleted] = value } + + /** + * Entitlement, mirrored from Google Play. + * + * Play is the source of truth; this is a local cache so the UI does not + * have to wait on the network to know whether to reserve banner space. + */ + suspend fun setAdsRemoved(value: Boolean) = edit { it[Keys.AdsRemoved] = value } + + /** + * Reset the settings, deliberately **not** part of Delete My Data. + * + * Wiping the health history must not silently return notification privacy + * to a default the user did not choose — that would hand back a weaker + * setting than the one they were relying on, at the exact moment they were + * exercising a privacy control. + */ + suspend fun resetToDefaults() = edit { it.clear() } + + private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) { + dataStore.edit(block) + } + + private fun decode(p: Preferences) = UserPreferences( + notificationPrivacy = p[Keys.NotificationPrivacy] + ?.let { name -> NotificationPrivacy.entries.firstOrNull { it.name == name } } + ?: UserPreferences.Defaults.notificationPrivacy, + reminderTime = p[Keys.ReminderMinuteOfDay] + ?.takeIf { it in 0 until MINUTES_PER_DAY } + ?.let { LocalTime.of(it / 60, it % 60) } + ?: UserPreferences.Defaults.reminderTime, + periodReminderEnabled = p[Keys.PeriodReminder] ?: UserPreferences.Defaults.periodReminderEnabled, + fertileWindowReminderEnabled = p[Keys.FertileReminder] ?: UserPreferences.Defaults.fertileWindowReminderEnabled, + ovulationReminderEnabled = p[Keys.OvulationReminder] ?: UserPreferences.Defaults.ovulationReminderEnabled, + biometricLockEnabled = p[Keys.BiometricLock] ?: UserPreferences.Defaults.biometricLockEnabled, + theme = p[Keys.Theme] + ?.let { name -> AppTheme.entries.firstOrNull { it.name == name } } + ?: UserPreferences.Defaults.theme, + adsRemoved = p[Keys.AdsRemoved] ?: UserPreferences.Defaults.adsRemoved, + onboardingCompleted = p[Keys.OnboardingCompleted] ?: UserPreferences.Defaults.onboardingCompleted, + ) + + private object Keys { + val NotificationPrivacy = stringPreferencesKey("notification_privacy") + val ReminderMinuteOfDay = intPreferencesKey("reminder_minute_of_day") + val PeriodReminder = booleanPreferencesKey("period_reminder_enabled") + val FertileReminder = booleanPreferencesKey("fertile_window_reminder_enabled") + val OvulationReminder = booleanPreferencesKey("ovulation_reminder_enabled") + val BiometricLock = booleanPreferencesKey("biometric_lock_enabled") + val Theme = stringPreferencesKey("theme") + val AdsRemoved = booleanPreferencesKey("ads_removed") + val OnboardingCompleted = booleanPreferencesKey("onboarding_completed") + } + + private companion object { + const val MINUTES_PER_DAY = 24 * 60 + val EMPTY: Preferences = androidx.datastore.preferences.core.emptyPreferences() + } +} diff --git a/core/datastore/src/test/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepositoryTest.kt b/core/datastore/src/test/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepositoryTest.kt new file mode 100644 index 0000000..53b34f8 --- /dev/null +++ b/core/datastore/src/test/kotlin/dev/privacyllc/period/core/datastore/UserPreferencesRepositoryTest.kt @@ -0,0 +1,183 @@ +package dev.privacyllc.period.core.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.time.LocalTime + +/** + * Runs on the JVM against a real DataStore backed by a temporary file — no + * emulator, no Robolectric. That is possible because the repository takes a + * `DataStore` rather than a `Context`, which is the reason it is shaped that + * way. + */ +class UserPreferencesRepositoryTest { + + @get:Rule val tmp = TemporaryFolder() + + private lateinit var scope: TestScope + private lateinit var store: DataStore + private lateinit var repo: UserPreferencesRepository + + @Before fun setUp() { + scope = TestScope(StandardTestDispatcher()) + store = PreferenceDataStoreFactory.create( + scope = CoroutineScope(scope.coroutineContext), + produceFile = { tmp.newFile("prefs.preferences_pb") }, + ) + repo = UserPreferencesRepository(store) + } + + @After fun tearDown() = scope.cancel() + + // ----------------------------------------------------------------------- + // Defaults. These are decisions, and each of these tests is what stops one + // being changed by accident. + // ----------------------------------------------------------------------- + + @Test + fun `a fresh install defaults to discreet notifications`() = scope.runTest { + // PRODUCT_PLAN.md §28. A default of DIRECT would put menstrual detail on + // a lock screen before the user has been asked a single question, and it + // is the most likely real privacy breach in this product. + assertEquals(NotificationPrivacy.DISCREET, repo.preferences.first().notificationPrivacy) + } + + @Test + fun `a fresh install has fertility reminders off`() = scope.runTest { + val p = repo.preferences.first() + assertFalse("an unrequested fertile-window reminder is an unpleasant surprise", p.fertileWindowReminderEnabled) + assertFalse("likewise ovulation", p.ovulationReminderEnabled) + assertTrue("the period reminder is the one the user came for", p.periodReminderEnabled) + } + + @Test + fun `a fresh install is not entitled and has not onboarded`() = scope.runTest { + val p = repo.preferences.first() + assertFalse(p.adsRemoved) + assertFalse(p.onboardingCompleted) + assertFalse(p.biometricLockEnabled) + assertEquals(AppTheme.SYSTEM, p.theme) + assertEquals(LocalTime.of(10, 0), p.reminderTime) + } + + // ----------------------------------------------------------------------- + // Round trips + // ----------------------------------------------------------------------- + + @Test + fun `every setting survives being written and read back`() = scope.runTest { + repo.setNotificationPrivacy(NotificationPrivacy.MAXIMUM_PRIVACY) + repo.setReminderTime(LocalTime.of(21, 45)) + repo.setPeriodReminderEnabled(false) + repo.setFertileWindowReminderEnabled(true) + repo.setOvulationReminderEnabled(true) + repo.setBiometricLockEnabled(true) + repo.setTheme(AppTheme.DARK) + repo.setAdsRemoved(true) + repo.setOnboardingCompleted(true) + + assertEquals( + UserPreferences( + notificationPrivacy = NotificationPrivacy.MAXIMUM_PRIVACY, + reminderTime = LocalTime.of(21, 45), + periodReminderEnabled = false, + fertileWindowReminderEnabled = true, + ovulationReminderEnabled = true, + biometricLockEnabled = true, + theme = AppTheme.DARK, + adsRemoved = true, + onboardingCompleted = true, + ), + repo.preferences.first(), + ) + } + + @Test + fun `the reminder time keeps its minutes rather than rounding to the hour`() = scope.runTest { + repo.setReminderTime(LocalTime.of(7, 5)) + assertEquals(LocalTime.of(7, 5), repo.preferences.first().reminderTime) + repo.setReminderTime(LocalTime.of(0, 0)) + assertEquals(LocalTime.of(0, 0), repo.preferences.first().reminderTime) + repo.setReminderTime(LocalTime.of(23, 59)) + assertEquals(LocalTime.of(23, 59), repo.preferences.first().reminderTime) + } + + // ----------------------------------------------------------------------- + // What happens when the stored value is nonsense + // ----------------------------------------------------------------------- + + @Test + fun `an unrecognised privacy mode falls back to discreet rather than to direct`() = scope.runTest { + // A downgrade, a rollback, or a hand-edited file can leave a value this + // build does not know. The fallback must be the SAFE mode — falling + // back to DIRECT would leak, and falling back to whatever enum entry + // happens to be first would be luck rather than a decision. + store.edit { it[stringPreferencesKey("notification_privacy")] = "TELEPATHY" } + assertEquals(NotificationPrivacy.DISCREET, repo.preferences.first().notificationPrivacy) + } + + @Test + fun `an unrecognised theme falls back to system`() = scope.runTest { + store.edit { it[stringPreferencesKey("theme")] = "SEPIA" } + assertEquals(AppTheme.SYSTEM, repo.preferences.first().theme) + } + + @Test + fun `an out-of-range reminder time falls back to the default`() = scope.runTest { + store.edit { it[androidx.datastore.preferences.core.intPreferencesKey("reminder_minute_of_day")] = 99_999 } + assertEquals(UserPreferences.DEFAULT_REMINDER_TIME, repo.preferences.first().reminderTime) + + store.edit { it[androidx.datastore.preferences.core.intPreferencesKey("reminder_minute_of_day")] = -1 } + assertEquals(UserPreferences.DEFAULT_REMINDER_TIME, repo.preferences.first().reminderTime) + } + + // ----------------------------------------------------------------------- + // Reset + // ----------------------------------------------------------------------- + + @Test + fun `resetting returns every setting to its default`() = scope.runTest { + repo.setNotificationPrivacy(NotificationPrivacy.DIRECT) + repo.setBiometricLockEnabled(true) + + repo.resetToDefaults() + + assertEquals(UserPreferences.Defaults, repo.preferences.first()) + } + + @Test + fun `settings are independent of each other`() = scope.runTest { + // Writing one must not disturb another. Obvious, and the kind of thing + // that stops being true the day somebody writes a whole-object setter. + repo.setNotificationPrivacy(NotificationPrivacy.DIRECT) + repo.setTheme(AppTheme.LIGHT) + repo.setBiometricLockEnabled(true) + + assertEquals(NotificationPrivacy.DIRECT, repo.preferences.first().notificationPrivacy) + + repo.setAdsRemoved(true) + + val p = repo.preferences.first() + assertEquals(NotificationPrivacy.DIRECT, p.notificationPrivacy) + assertEquals(AppTheme.LIGHT, p.theme) + assertTrue(p.biometricLockEnabled) + assertTrue(p.adsRemoved) + } +} diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 41b8446..acef762 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -30,7 +30,7 @@ function calls. Nothing below the ViewModel knows Compose exists. ## Modules -Five today. core/datastore is Batch 01 issue #4 and **does not exist yet** — a module created before it has contents is a place +Six today. core/data is Batch 01 issue #5 and **does not exist yet** — a module created before it has contents is a place for things to be put by accident. The wider layout sketched in [`../planning/PRODUCT_PLAN.md` §9](../planning/PRODUCT_PLAN.md) arrives the same way, with the batch that needs it. @@ -40,6 +40,7 @@ way, with the batch that needs it. | `app` | Android application | `MainActivity`, the four-tab navigation shell, DI wiring | everything below | | `core/designsystem` | Android library | Material 3 theme, colour and type tokens | nothing in this project | | `core/database` | Android library | Room entities, DAOs, converters, the schema export | `domain/cycle`, `domain/prediction` | +| `core/datastore` | Android library | `UserPreferences` and the settings that are not health history | nothing in this project | | `domain/cycle` | **Kotlin JVM** | `PeriodRecord`, `SpottingRecord`, `CycleRecord` and the rules over them | nothing | | `domain/prediction` | **Kotlin JVM** | the forecast, the window, confidence, `NotYetObservation` | `domain/cycle` | @@ -49,8 +50,7 @@ there, and none of these are: | Module | Plugin | Owns | May depend on | Issue | | --- | --- | --- | --- | --- | -| core/datastore | Android library | `UserPreferences` | `domain/cycle` | #4 | -| core/data | Android library | the repositories — the only things that touch a DAO | core/database, core/datastore, `domain/cycle` | #5 | +| core/data | Android library | the repositories — the only things that touch a DAO | `core/database`, `core/datastore`, `domain/cycle` | #5 | | core/ads | Android library | the `AdProvider` implementation | **neither core/database nor `domain/*`** | Batch 07 | ### Why `domain/*` is `kotlin("jvm")` and not an Android library @@ -95,7 +95,24 @@ each exists and what must not happen to it. | `CycleRecord` | derived interval between two confirmed starts | derived, never stored as truth — recomputed from period records | | `PredictionRecord` | a snapshot taken *before* the outcome is known | this is what makes accuracy measurable at all; never overwritten in place | | `NotYetObservation` | the user said the period had not started by a date | a censoring observation — the forecast is re-conditioned on it, not shifted by +1 day | -| `UserPreferences` | notification privacy, reminder time, lock, theme, ads entitlement | lives in DataStore, never in the cycle database | +| `UserPreferences` | notification privacy, reminder time, lock, theme, ads entitlement | lives in DataStore, **never** in the cycle database — see below | + +### Why settings are not in the database + +`core/datastore` could have been two more Room tables. It is not, and the reason +is a deletion semantic rather than a taste in storage. + +**Delete My Data removes the health history and must leave the settings alone.** +A user exercising that control has not asked to have notification privacy +returned to a default they did not choose — handing back a weaker setting at the +exact moment somebody is reaching for a privacy control is the worst possible +time to do it. Separate stores make that the easy implementation rather than the +one you have to remember. + +`UserPreferencesRepository` takes a `DataStore` rather than a `Context`, which +is what lets its tests run on the JVM against a temporary file. The Android +instance is supplied by DI at the app layer — the only place that should know +where a file lives. **Never secretly modify health history.** A gap that looks like a missing entry ([§14](../planning/PRODUCT_PLAN.md)) produces a question, not a correction. That diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9eb7776..d480d2e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ room = "2.8.4" sqlite = "2.7.0" robolectric = "4.16.1" androidxTestCore = "1.7.0" +datastore = "1.2.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -47,6 +48,7 @@ androidx-room-runtime = { group = "androidx.room", name = "room-runtime", versio androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled", version.ref = "sqlite" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 728b698..65018b5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,11 +22,11 @@ dependencyResolutionManagement { rootProject.name = "Period" -// core/datastore arrives with Batch 01 issue #4, and core/data with #5 — see -// docs/architecture/README.md for why a module is not created before it has -// contents. +// core/data arrives with Batch 01 issue #5 — see docs/architecture/README.md +// for why a module is not created before it has contents. include(":app") include(":core:designsystem") include(":core:database") +include(":core:datastore") include(":domain:cycle") include(":domain:prediction")