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")